import { APP_API_BASE_URL, apiFetch } from './client' import { buildActivityData, explainActivityError, fetchActivityFieldDefs, instanceState, matchOption, toDateInput, uploadActivityFile, } from './activityForm' import { fetchEmployeeListPage } from './employees' import { RECORD_VIEWS, fetchAllRecordRows, toISODate } from './recordView' import { withRichColumns } from './richColumns' import { DETAIL_VIEWS, fetchDetailView, normLabel, normalizeDetailView } from './detailView' import { HISTORY_ACTION } from '../constants' /** * Asset onboarding is a second Zino workflow in the same app. Creating an * asset means starting that workflow's INIT activity with the form data. * * POST {APP_API_BASE_URL}/app/{APP_ID}/start * Authorization: Bearer * { activity_id, version, workflow_uuid, * data: { field_42 … field_59, field_61, field_51: { id, first_name } } } * * Exactly the INIT activity's sample cURL in the builder. * * NOTE ON IDS * 762affb9-… in the editor URL is the workflow *version* uid (version_uuid), * which file uploads send. The workflow uuid is b5ea718c-… (from * GET /api/apps/531/workflows, "asset workflow"). */ export const ASSET_APP_ID = '531' export const ASSET_WORKFLOW_UUID = 'b5ea718c-e1a3-4d73-ac72-d0fc25f8b611' export const ASSET_WORKFLOW_VERSION = 1 export const ASSET_VERSION_UID = '762affb9-ed54-40e5-9958-b27ff6772893' export const ASSET_INIT_ACTIVITY_ID = 'a2405547-0db4-474b-aa76-af1246d01a6a' /** * INIT activity fields, exactly as configured in the Zino builder — same ids, * labels, types, option values and order. The activity has these fields and * Asset_Owner (field_51) only; anything else in `data` is rejected as * "field_NN(unknown)". Asset_Owner is a lookup the form picks separately (see * ASSET_OWNER_FIELD); in the builder it comes after Condition. * Source: the INIT activity's Fields tab and sample cURL in the builder. */ export const ASSET_FIELDS = [ { id: 'field_42', label: 'Model', type: 'text' }, { // The asset table stores it in its asset_details column. formLabel also // renames a view column of this field that is still labelled "Category". id: 'field_43', label: 'Asset Details', formLabel: 'Asset Details', type: 'select', options: [ { label: 'Desktop', value: 'desktop' }, { label: 'Monitor', value: 'monitor' }, { label: 'Laptop', value: 'laptop' }, { label: 'Mobile phone', value: 'mobile_phone' }, { label: 'Tablet', value: 'tablet' }, { label: 'Accessories', value: 'accessories' }, { label: 'Networking', value: 'networking' }, { label: 'Printer', value: 'printer' }, ], }, { id: 'field_45', label: 'Purchase date', type: 'date' }, { id: 'field_44', label: 'Serial number', type: 'text' }, { id: 'field_49', label: 'Asset identifier', type: 'text' }, { id: 'field_46', label: 'Purchase cost', type: 'number' }, // Option values must match the builder exactly. { id: 'field_56', label: 'Asset State', type: 'select', options: [ { label: 'In use', value: 'in_use' }, { label: 'Available', value: 'available' }, { label: 'Under maintenance', value: 'under_maintenance' }, { label: 'Retired', value: 'retired' }, ], }, { id: 'field_58', label: 'Asset Priority', type: 'select', options: [ { label: 'Low', value: 'low' }, { label: 'Medium', value: 'medium' }, { label: 'High', value: 'high' }, ], }, { id: 'field_47', label: 'Condition', type: 'select', options: [ { label: 'new', value: 'new' }, { label: 'damage', value: 'damage' }, ], }, { id: 'field_50', label: 'CPU', type: 'text' }, { id: 'field_54', label: 'Warranty Start date', type: 'date' }, // `label` keeps the builder's spelling (the asset table's columns are named // after it); `formLabel` is what the app shows. `column` also matches a // table column spelled "warrenty_status". { id: 'field_48', label: 'Warrrenty Status', formLabel: 'Warranty Status', column: 'warrenty_status', type: 'select', // under_warrenty is the builder's value (from the sample cURL), misspelling included. options: [ { label: 'Under warranty', value: 'under_warrenty' }, { label: 'Out of warranty', value: 'out_of_the_warrenty' }, { label: 'In AMC', value: 'in_amc' }, { label: 'Not in AMC', value: 'not_in_amc' }, ], }, { id: 'field_55', label: 'Warrenty End date', formLabel: 'Warranty End date', type: 'date' }, { id: 'field_57', label: 'Asset Classification', type: 'select', options: [ { label: 'Internal', value: 'internal' }, { label: 'External', value: 'external' }, ], }, { id: 'field_52', label: 'Hard Drive', type: 'text' }, // One photo per view, then any number of others; sent as one list in this order. // `column` is the asset table's column for it, where it isn't named after the label. { id: 'field_53', label: 'images', formLabel: 'Images', column: 'image', type: 'image', maxSizeMb: 5, slots: ['Front', 'Back', 'Side'], otherSlot: 'Other', }, { id: 'field_59', label: 'Asset Brand', type: 'text' }, { id: 'field_61', label: 'Asset Location', type: 'text' }, ] /** INIT's photo field, whose uploads the runtime always accepts. */ const INIT_IMAGE_FIELD = 'field_53' /** Activity/field pairs the runtime refused an upload for; their photos go through INIT. */ const refusedUploads = new Set() /** * Upload a photo for an image field of one of the asset workflow's activities. * Resolves with the file object the activity sends for it. * * The runtime refuses uploads tagged with some later activities (RETURNED's * field_71 answers 400 "The request could not be read") while INIT's always * work. The upload only stores the file; the activity then sends its file * object, so a refused photo is uploaded the way INIT uploads one, and that * pair goes straight to INIT from then on. */ export async function uploadAssetFile(activityId, fieldId, file) { const send = (activity, field) => uploadActivityFile({ appId: ASSET_APP_ID, workflowUuid: ASSET_WORKFLOW_UUID, versionUuid: ASSET_VERSION_UID, activityId: activity, fieldId: field, file, }) const pair = `${activityId}/${fieldId}` if (activityId === ASSET_INIT_ACTIVITY_ID || !refusedUploads.has(pair)) { try { return await send(activityId, fieldId) } catch (err) { if (err?.status !== 400 || activityId === ASSET_INIT_ACTIVITY_ID) throw err refusedUploads.add(pair) } } return send(ASSET_INIT_ACTIVITY_ID, INIT_IMAGE_FIELD) } /** The builder's field definitions for one of the asset workflow's activities (see fetchActivityFieldDefs). */ export const fetchAssetActivityFieldDefs = (activityId) => fetchActivityFieldDefs({ appId: ASSET_APP_ID, versionUuid: ASSET_VERSION_UID, activityId }) /** * "Asset Owner": a lookup into the employee table, like ASSIGNED's "Employee * data". The API sample shows only { id }; it is sent as { id, first_name } * because the runtime checks lookups against their storage columns. */ export const ASSET_OWNER_FIELD = 'field_51' /** * Start the asset INIT activity. `owner` is one entry from * fetchAssignableEmployees(), or null to leave "Asset Owner" out. * Resolves with the backend response. */ export async function createAsset(values, owner = null) { try { return await apiFetch(`${APP_API_BASE_URL}/app/${ASSET_APP_ID}/start`, { method: 'POST', body: { activity_id: ASSET_INIT_ACTIVITY_ID, data: { ...buildActivityData(ASSET_FIELDS, values), ...(owner ? { [ASSET_OWNER_FIELD]: employeeLookup(owner) } : {}), }, version: ASSET_WORKFLOW_VERSION, workflow_uuid: ASSET_WORKFLOW_UUID, }, }) } catch (err) { throw explainActivityError(err) } } /** * The "ASSIGNED" activity in the Available state of the asset workflow. It * hands an existing asset to an employee and moves it to the Assigned state. * Unlike INIT (which uses /start), it posts to /activity with the asset's * instance_id. * * POST {APP_API_BASE_URL}/app/{APP_ID}/activity * Authorization: Bearer * { activity_id, workflow_uuid, instance_id, * data: { field_1, field_5, field_6, field_21, field_22: { id, first_name } } } * * NOTE ON IDS * The activity id is the one in the builder URL (…/activities/d18815a4-…). * 119d494a-… is the Retired activity in the same state — its lookup is * "Retired by" — so don't use it here: running it closes the asset for good. * Source: the asset workflow "ASSIGNED" activity, Fields tab, in the Zino builder. */ export const ASSET_ASSIGN_ACTIVITY_ID = 'd18815a4-4e74-480d-a943-67dce894412f' /** Fields the user fills in. */ export const ASSET_ASSIGN_FIELDS = [ { id: 'field_1', label: 'Assigned date', type: 'date' }, // Assigned by (field_5) is left out of the form on purpose: it is a select in // the builder and the runtime rejects any value not in its options ("Assigned // by must be one of the listed options"), so employee names can't go there. // Add it back, with `employeeOptions: true`, once the builder field is text. { id: 'field_6', label: 'Remarks', type: 'longtext' }, { id: 'field_21', label: 'department', type: 'select', // Read from a cut-off screenshot of the builder; values are the labels in // snake_case. Check them against the builder. "Database and …" is left // out until its full label and value are known. options: [ { label: 'Implementation', value: 'implementation' }, { label: 'Software Development', value: 'software_development' }, { label: 'Quality Assurance', value: 'quality_assurance' }, { label: 'DevOps and Cloud', value: 'devops_and_cloud' }, { label: 'Human Resources', value: 'human_resources' }, ], }, ] /** * "Employee data": a lookup into the employee table. The runtime checks it * against the lookup's storage columns (a Retired attempt failed with * "missing required storage column 'first_name'"), so it carries * { id, first_name }, not the { id, name } the API sample shows. It is picked * from a list of active employees, so it is sent alongside the form data. */ export const ASSIGN_EMPLOYEE_FIELD = 'field_22' /** * Employees in `filter` (one of EMPLOYMENT_FILTERS), as * { id, first_name, name, code, email, department }. `id` is the * asset_management.employees bigint id the lookup expects. */ async function fetchEmployees(filter) { const res = await fetchEmployeeListPage(RECORD_VIEWS.employees.templateUid, { filter, limit: 1000 }) return res.data .filter((r) => r.id != null && r.id !== '') .map((r) => ({ id: Number(r.id), first_name: String(r.first_name ?? ''), name: [r.first_name, r.last_name].filter(Boolean).join(' ').trim() || String(r.employee_code || r.email || `Employee ${r.id}`), code: r.employee_code ?? '', email: r.email ?? '', department: r.department ?? '', })) } /** Active employees an asset can be assigned to (see fetchEmployees). */ export const fetchAssignableEmployees = () => fetchEmployees('active') /** * Every employee, active or not, keyed by id as a string — for showing the * names behind lookups that arrive as just { id }. Empty when the list can't * be read (except on 401), so a list still renders. */ async function fetchEmployeeDirectory() { try { return new Map((await fetchEmployees('all')).map((e) => [String(e.id), e])) } catch (err) { if (err?.status === 401) throw err return new Map() } } /** * The "Reassigned" activity in the Assigned state of the asset workflow. It * hands an assigned asset to another employee; the asset stays Assigned. * Like ASSIGNED it posts to /activity with the asset's instance_id. * * POST {APP_API_BASE_URL}/app/{APP_ID}/activity * Authorization: Bearer * { activity_id, workflow_uuid, instance_id, * data: { field_4, field_12, field_23: { id, first_name } } } * * Source: the asset workflow "Reassigned" activity, Fields tab, in the Zino * builder (…/activities/db4463c4-…). */ export const ASSET_REASSIGN_ACTIVITY_ID = 'db4463c4-033b-496d-9d21-88a6e5159028' /** Fields the user fills in. */ export const ASSET_REASSIGN_FIELDS = [ { id: 'field_4', label: 'Assigned date', type: 'date' }, // Assigned by (field_11) is left out of the form on purpose. { id: 'field_12', label: 'Remarks', type: 'longtext' }, ] /** Its own "Employee data" lookup — a different id from ASSIGNED's field_22. */ export const REASSIGN_EMPLOYEE_FIELD = 'field_23' /** * The "RETURNED" activity in the Assigned state of the asset workflow. It takes * an asset back from its employee and moves it to Available again. Like * ASSIGNED it posts to /activity with the asset's instance_id. * * POST {APP_API_BASE_URL}/app/{APP_ID}/activity * Authorization: Bearer * { activity_id, workflow_uuid, instance_id, * data: { field_8, field_9, field_10, field_28: { id, first_name }, field_29, * field_71: [{ blob_path, mime_type, original_name, size_bytes, uuid }, …], field_72 } } * * The activity has exactly these seven fields; anything else in `data` is * rejected as "field_NN(unknown)". Each photo for field_71 is uploaded first * (POST /app/{APP_ID}/upload with this activity's id), and the activity sends * the returned file objects. * * Source: the asset workflow "RETURNED" activity, Fields tab and sample cURL, * in the Zino builder (…/activities/7f7b43fb-…). */ export const ASSET_RETURN_ACTIVITY_ID = '7f7b43fb-557c-483a-97a5-09b0a8080622' /** Fields the user fills in, in the builder's order. */ export const ASSET_RETURN_FIELDS = [ { id: 'field_8', label: 'Returned date', type: 'date' }, { id: 'field_9', label: 'Damage details', type: 'longtext' }, { id: 'field_10', label: 'Remarks', type: 'longtext' }, { id: 'field_29', label: 'Condition', type: 'select', // "good" is the value in the builder's sample cURL. Add the activity's other // option values here exactly as the builder has them. options: [{ label: 'Good', value: 'good' }], }, // One photo per view, then any number of others; sent as one list in this order. { id: 'field_71', label: 'Return images', type: 'image', maxSizeMb: 5, slots: ['Front', 'Back', 'Screen', 'Keyboard'], otherSlot: 'Other', }, { // The builder spells it "Recived by". Its option values aren't known here // (the sample cURL shows only "option_value"), and guessed names were // rejected with "Recived by must be one of the listed options". So the // form offers the builder's own options (see withBuilderOptions) and // leaves the field out when they can't be read. id: 'field_72', label: 'Recived by', formLabel: 'Received by', type: 'select', options: [], unconfirmedOptions: true, }, ] /** "Returned by": a lookup into the employee table, like "Employee data". */ export const RETURN_EMPLOYEE_FIELD = 'field_28' /** * The "Lost" activity of the asset workflow. It records a lost asset and moves * it to Closed, which has no activities — the asset can't be used again. The * diagram offers it from Assigned (and lists it in Available); the runtime * rejects it in any state that doesn't have it. * * POST {APP_API_BASE_URL}/app/{APP_ID}/activity * Authorization: Bearer * { activity_id, workflow_uuid, instance_id, * data: { field_36, field_38 … field_41, field_35: { id, first_name } } } * * Source: the asset workflow "Lost" activity, Fields tab, in the Zino builder * (…/activities/5e9b6f96-…). */ export const ASSET_LOST_ACTIVITY_ID = '5e9b6f96-24d2-4412-a41c-dad21e6ae70c' /** Fields the user fills in. */ export const ASSET_LOST_FIELDS = [ { id: 'field_36', label: 'Date reported', type: 'date' }, // Type (field_37) is left out of the form on purpose. { id: 'field_39', label: 'Reference number', type: 'number' }, { id: 'field_40', label: 'Recovery cost', type: 'number' }, { id: 'field_41', label: 'Manager name', type: 'select', // The builder has no options yet; add these there too if the runtime rejects them. options: [ 'Reporting Manager', 'HR Manager', 'IT Manager', 'Admin Manager', 'Finance Manager', 'Operations Manager', 'Department Head', ].map((m) => ({ label: m, value: m })), }, { id: 'field_38', label: 'Remarks', type: 'longtext' }, ] /** "Last owner": a lookup into the employee table, like "Employee data". */ export const LOST_EMPLOYEE_FIELD = 'field_35' /** An employee lookup value, in the shape the runtime validates. */ const employeeLookup = (employee) => ({ id: employee.id, first_name: employee.first_name }) /** * Run a later activity (ASSIGNED, RETURNED, …) on an existing asset instance. * `extra` is merged into `data` as-is, for values the form doesn't hold. */ async function runAssetActivity(activityId, fields, instanceId, values, extra = {}) { try { return await apiFetch(`${APP_API_BASE_URL}/app/${ASSET_APP_ID}/activity`, { method: 'POST', body: { activity_id: activityId, data: { ...buildActivityData(fields, values), ...extra }, // The runtime decodes this as int64, so "58950" (e.g. from a URL) is rejected. instance_id: /^\d+$/.test(String(instanceId)) ? Number(instanceId) : instanceId, workflow_uuid: ASSET_WORKFLOW_UUID, }, }) } catch (err) { throw explainActivityError(err) } } /** * Run the ASSIGNED activity on an existing asset instance (the one created by * INIT). `employee` is one entry from fetchAssignableEmployees(). */ export function assignAsset(instanceId, values, employee) { return runAssetActivity(ASSET_ASSIGN_ACTIVITY_ID, ASSET_ASSIGN_FIELDS, instanceId, values, { [ASSIGN_EMPLOYEE_FIELD]: employeeLookup(employee), }) } /** * Run the Reassigned activity on an assigned asset instance. `employee` (the * new holder) is one entry from fetchAssignableEmployees(). */ export function reassignAsset(instanceId, values, employee) { return runAssetActivity(ASSET_REASSIGN_ACTIVITY_ID, ASSET_REASSIGN_FIELDS, instanceId, values, { [REASSIGN_EMPLOYEE_FIELD]: employeeLookup(employee), }) } /** * Run the RETURNED activity on an assigned asset instance. `employee` (who * returned it) is one entry from fetchAssignableEmployees(). */ export function returnAsset(instanceId, values, employee) { return runAssetActivity(ASSET_RETURN_ACTIVITY_ID, ASSET_RETURN_FIELDS, instanceId, values, { [RETURN_EMPLOYEE_FIELD]: employeeLookup(employee), }) } /** * Run the Lost activity on an asset instance. `employee` (the last owner) is * one entry from fetchAssignableEmployees(), or null when there is none — * then the lookup is left out. */ export function reportAssetLost(instanceId, values, employee) { return runAssetActivity( ASSET_LOST_ACTIVITY_ID, ASSET_LOST_FIELDS, instanceId, values, employee ? { [LOST_EMPLOYEE_FIELD]: employeeLookup(employee) } : {}, ) } /** * The "Asset details" activity of the asset workflow: it edits what INIT * filled in, without moving the asset to another state. Like ASSIGNED it * posts to /activity with the asset's instance_id. * * POST {APP_API_BASE_URL}/app/{APP_ID}/activity * Authorization: Bearer * { activity_id, workflow_uuid, instance_id, * data: { field_2, field_3, field_13 … field_20, field_60, field_62 … field_66, * field_68 … field_70, field_67: { id, first_name } } } * * Like the employee Update activity, it has its own field ids; `initId` is the * INIT field each one updates, so the asset shows the edited value in its * place (see withEditedValues). * * Source: the asset workflow "Asset details" activity, Fields tab and sample * cURL, in the Zino builder (…/activities/8b90e8c1-…). */ export const ASSET_EDIT_ACTIVITY_ID = '8b90e8c1-0497-4084-9b80-3b1995f30072' /** A copy of an INIT field for the Edit activity, under the Edit field's id. */ const editFieldOf = (initId, id) => ({ ...ASSET_FIELDS.find((f) => f.id === initId), id, initId }) /** * Fields the user fills in, pre-filled with the asset's current values. They * take the INIT field's labels and options, so the form reads like Add Asset. * The form shows them in this order: Asset Brand and Asset Location first, * then the builder's order. The order is the form's only; `data` is keyed by * field id, so the builder and what is sent don't change. The activity also * has a second "Hard Drive" (field_60); the form shows one, field_63, and * sends its value in both. */ export const ASSET_EDIT_FIELDS = [ editFieldOf('field_59', 'field_69'), // Asset Brand editFieldOf('field_61', 'field_70'), // Asset Location editFieldOf('field_43', 'field_3'), // Asset Details editFieldOf('field_42', 'field_2'), // Model editFieldOf('field_44', 'field_13'), // Serial number editFieldOf('field_45', 'field_14'), // Purchase date editFieldOf('field_46', 'field_15'), // Purchase cost editFieldOf('field_56', 'field_16'), // Asset State editFieldOf('field_58', 'field_17'), // Asset Priority editFieldOf('field_47', 'field_18'), // Condition editFieldOf('field_50', 'field_19'), // CPU editFieldOf('field_54', 'field_20'), // Warranty Start date editFieldOf('field_57', 'field_62'), // Asset Classification editFieldOf('field_52', 'field_63'), // Hard Drive editFieldOf('field_49', 'field_64'), // Asset identifier editFieldOf('field_55', 'field_65'), // Warranty End date editFieldOf('field_48', 'field_66'), // Warranty Status editFieldOf('field_53', 'field_68'), // Images ] /** "Asset Owner": the Edit activity's employee lookup, in place of INIT's Asset_Owner. */ export const EDIT_OWNER_FIELD = 'field_67' /** The Edit activity's second "Hard Drive", sent with field_63's value. */ const EDIT_HARD_DRIVE_COPY_FIELD = 'field_60' /** * Every Edit value and the INIT field it replaces, the owner lookup included. * field_60 comes first so field_63, when present, wins. */ const EDITED_FIELDS = [ { id: EDIT_HARD_DRIVE_COPY_FIELD, initId: 'field_52' }, ...ASSET_EDIT_FIELDS, { id: EDIT_OWNER_FIELD, initId: ASSET_OWNER_FIELD }, ] /** * The repair flow: "Send for Repair" moves an Available asset to Under Repair, * and "Repair Completed" moves it back to Available. Assigned assets are * returned first, so the return records the damage. Both post to /activity * with the asset's instance_id. * * POST {APP_API_BASE_URL}/app/{APP_ID}/activity * { activity_id, workflow_uuid, instance_id, data: { field_73 … field_76 } } * { activity_id, workflow_uuid, instance_id, data: { field_77 … field_79 } } * * PLACEHOLDER ids: add an "Under Repair" state and these two activities, with * these fields, in the Zino builder, deploy, and put their ids here. Until * then, running them fails. */ export const ASSET_REPAIR_ACTIVITY_ID = 'TODO-send-for-repair-activity-id' /** Send for Repair fields. */ export const ASSET_REPAIR_FIELDS = [ { id: 'field_73', label: 'Repair date', type: 'date' }, { id: 'field_74', label: 'Issue details', type: 'longtext' }, { id: 'field_75', label: 'Repair vendor', type: 'text' }, { id: 'field_76', label: 'Estimated cost', type: 'number' }, ] export const ASSET_REPAIRED_ACTIVITY_ID = 'TODO-repair-completed-activity-id' /** Repair Completed fields. */ export const ASSET_REPAIRED_FIELDS = [ { id: 'field_77', label: 'Repaired date', type: 'date' }, { id: 'field_78', label: 'Repair cost', type: 'number' }, { id: 'field_79', label: 'Repair remarks', type: 'longtext' }, ] /** * Run the Asset details (Edit) activity on an asset instance. `employee` (the * Asset Owner) is one entry from fetchAssignableEmployees(), or null to leave * the owner as it is. */ export function editAsset(instanceId, values, employee) { const data = buildActivityData(ASSET_EDIT_FIELDS, values) return runAssetActivity(ASSET_EDIT_ACTIVITY_ID, ASSET_EDIT_FIELDS, instanceId, values, { ...(data.field_63 !== undefined ? { [EDIT_HARD_DRIVE_COPY_FIELD]: data.field_63 } : {}), ...(employee ? { [EDIT_OWNER_FIELD]: employeeLookup(employee) } : {}), }) } /** Run Send for Repair on an Available asset instance. */ export function sendAssetForRepair(instanceId, values) { return runAssetActivity(ASSET_REPAIR_ACTIVITY_ID, ASSET_REPAIR_FIELDS, instanceId, values) } /** Run Repair Completed on an asset instance that is Under Repair. */ export function completeAssetRepair(instanceId, values) { return runAssetActivity(ASSET_REPAIRED_ACTIVITY_ID, ASSET_REPAIRED_FIELDS, instanceId, values) } /** * The Edit form's starting values: each field's current value on the asset * (its latest edit, else its INIT value), as the form holds it. `row` and * `config` are fetchAssetInstance's. A value is looked for under every key * the details page may show it by — the Edit field id (and field_60 for Hard * Drive), the INIT field id, the view's column for the field, and a key named * after its label — so the form opens with what the page shows. */ export function assetEditValues(config, row) { const columns = config?.fields ?? [] return Object.fromEntries( ASSET_EDIT_FIELDS.map((f) => { const init = ASSET_FIELDS.find((d) => d.id === f.initId) const keys = [ f.id, ...EDITED_FIELDS.filter((e) => e.initId === init.id && e.id !== f.id).map((e) => e.id), init.id, ...columns.filter((c) => hasLabel(init, c.output_label)).map((c) => c.field_key), initValueKey(init, row ?? {}), ] const raw = keys.map((k) => row?.[k]).find(present) return [f.id, present(raw) ? editFormValue(f, raw) : ''] }), ) } /** A stored value as an Edit form field holds it; '' when it can't be shown. */ function editFormValue(field, raw) { if (field.type === 'image') return savedPhotos(field, raw) if (field.type === 'select') return matchOption(field.options, raw) ?? String(raw?.value ?? raw) // A single-value field may come as an option-like object or a one-item list. let v = Array.isArray(raw) ? raw[0] : raw if (v && typeof v === 'object') v = v.value ?? v.label ?? v.name ?? '' if (!present(v)) return '' if (field.type === 'date') return toDateInput(v) if (field.type === 'number') { // "₹1,200.50" shows as 1200.5; the number input can't hold the formatting. const n = Number(String(v).replace(/[^\d.-]/g, '')) return /\d/.test(String(v)) && Number.isFinite(n) ? String(n) : String(v) } return String(v) } /** * An asset's saved photos as the image form value ({ Front, Back, Side, * Other: [...] }), in the order they were sent, so saving the form sends them * again. Only uploaded file objects can be sent back; a view that gives bare * URLs leaves the form empty, and the photos stay as they are unless new ones * are picked. */ function savedPhotos(field, raw) { const parse = (v) => { if (typeof v !== 'string') return v try { return JSON.parse(v) } catch { return v } } let v = parse(raw) if (v && typeof v === 'object' && !Array.isArray(v) && !v.blob_path) v = Object.values(v).flat() const photos = (Array.isArray(v) ? v : [v]) .map(parse) .filter((p) => p && typeof p === 'object' && p.blob_path) .map((p, i) => ({ ...p, key: `saved-${i}` })) if (!photos.length) return '' const out = {} field.slots.forEach((slot, i) => photos[i] && (out[slot] = photos[i])) const others = photos.slice(field.slots.length) if (others.length && field.otherSlot) out[field.otherSlot] = others return out } /** * A row with each edited value in place of its INIT value, so the asset shows * its latest details. The INIT value stays when the asset was never edited. */ export function withEditedValues(row) { if (!row) return row const out = { ...row } for (const f of EDITED_FIELDS) { if (!present(row[f.id])) continue const init = ASSET_FORM_FIELDS.find((d) => d.id === f.initId) out[initValueKey(init, row)] = row[f.id] } return out } /** * The employee-table id of the asset's current owner (its latest edit, else * INIT), or null. Read from the owner field, or any `config` column labelled * Asset Owner. */ export function assetOwnerId(row, config) { const edited = withEditedValues(row) const owner = ASSET_FORM_FIELDS.find((d) => d.id === ASSET_OWNER_FIELD) const keys = [initValueKey(owner, edited), ...ownerKeys(config)] return keys.map((k) => lookupId(edited, k)).find((id) => id != null) ?? null } /** Asset workflow states, as named in the builder diagram. */ export const ASSET_STATE = { AVAILABLE: 'Available', ASSIGNED: 'Assigned', UNDER_REPAIR: 'Under Repair', CLOSED: 'Closed', } /** True when a workflow row is in `state` (case-insensitive). */ export const isAssetState = (row, state) => instanceState(row).toLowerCase() === state.toLowerCase() /** * An employee lookup value on a row ({ id, first_name }), or null. The runtime * may send a lookup as an object or as its JSON text. */ function lookupValue(row, fieldId) { let v = row?.[fieldId] if (typeof v === 'string') { try { v = JSON.parse(v) } catch { return v.trim() ? { first_name: v.trim() } : null } } if (v && typeof v === 'object') return v return v != null && v !== '' ? { id: v } : null } /** * Display name from an employee lookup field, or ''. `people` (from * fetchEmployeeDirectory) names a lookup that holds only an id. */ export function lookupName(row, fieldId, people) { const v = lookupValue(row, fieldId) return v ? personName(v, people) : '' } function personName(v, people) { const known = v.id != null ? people?.get(String(v.id)) : undefined return String( known?.name ?? v.first_name ?? v.name ?? v.employee_code ?? (v.id != null ? `Employee ${v.id}` : ''), ) } const LOOKUP_KEYS = new Set(['id', 'first_name', 'last_name', 'name', 'employee_code']) /** * A view cell holding an employee lookup ({ id, first_name? }, or its JSON * text) as the employee's name; any other value as-is. */ function lookupCellText(value, people) { let v = value if (typeof v === 'string' && v.trim().startsWith('{')) { try { v = JSON.parse(v) } catch { return value } } const isLookup = v && typeof v === 'object' && !Array.isArray(v) && v.id != null && Object.keys(v).every((k) => LOOKUP_KEYS.has(k)) return isLookup ? personName(v, people) : value } /** The employee-table id held by an employee lookup field, or null. */ export function lookupId(row, fieldId) { const id = lookupValue(row, fieldId)?.id return id != null && id !== '' ? id : null } /** * The asset's latest hand-over: ASSIGNED or Reassigned, whichever is newer — * { source, employeeField, name, id, date, by, remarks }. * * The instance keeps both activities' values, so after assign → reassign → * return → assign the old reassignment is still on it. Compare their dates; * the reassignment wins when it is dated the same or later, or when the * assignment has no date. */ export function currentAssignment(row, people) { const assign = { source: 'assign', employeeField: ASSIGN_EMPLOYEE_FIELD, date: row?.field_1 ?? '', by: row?.field_5 ?? '', remarks: row?.field_6 ?? '', } const reassign = { source: 'reassign', employeeField: REASSIGN_EMPLOYEE_FIELD, date: row?.field_4 ?? '', by: row?.field_11 ?? '', remarks: row?.field_12 ?? '', } let pick = assign if (present(reassign.date) || lookupValue(row, REASSIGN_EMPLOYEE_FIELD)) { const a = present(assign.date) ? toISODate(assign.date) : '' const r = present(reassign.date) ? toISODate(reassign.date) : '' if (!a || (r && r >= a)) pick = reassign } return { ...pick, name: lookupName(row, pick.employeeField, people), id: lookupId(row, pick.employeeField) } } /** Who an asset is (or was last) assigned or reassigned to. */ export const assignedEmployeeName = (row) => currentAssignment(row).name /** * Activity values of one asset row, keyed by the derived columns below. * Department, when ASSIGNED left it blank, is the assignee's own department. */ function activityValues(row, people) { const current = currentAssignment(row, people) const assignee = current.id != null ? people?.get(String(current.id)) : undefined const asset = withEditedValues(row) return { asset_code: assetCodeOf(row), asset_model: asset.field_42 ?? '', asset_serial: asset.field_44 ?? '', assigned_to: current.name, assigned_date: current.date, assigned_by: current.by, assigned_department: present(row.field_21) ? row.field_21 : assignee?.department ?? '', returned_date: row.field_8 ?? '', returned_by: lookupName(row, RETURN_EMPLOYEE_FIELD, people), return_condition: row.field_29 ?? '', state: instanceState(row), } } /** * A column the view doesn't send. `sources` (field ids) and `aliases` (labels) * name view columns that show the same value: a list shows its value there * instead of adding the column twice (see fetchAssetActivityPage). */ const derived = (field_key, output_label, data_type = 'text', { sources = [], aliases = [] } = {}) => ({ type: 'derived', field_key, output_label, data_type, sortable: false, sources, aliases, }) const STATE_COLUMN = derived('state', 'State', 'asset_state') /** Which asset a row is: an activity's own view may have no column for it. */ const ASSET_IDENTITY_COLUMNS = [ derived('asset_code', 'Asset ID', 'id_gen', { aliases: ['Asset ID', 'Asset id'] }), derived('asset_model', 'Model', 'text', { sources: ['field_42', 'field_2'] }), derived('asset_serial', 'Serial number', 'text', { sources: ['field_44', 'field_13'] }), ] /** Who has the asset now: ASSIGNED's or Reassigned's employee, whichever is newer. */ const ASSIGNED_TO_COLUMN = derived('assigned_to', 'Assigned to', 'text', { sources: [ASSIGN_EMPLOYEE_FIELD, REASSIGN_EMPLOYEE_FIELD], aliases: ['Employee data', 'Employee'], }) const ASSIGNED_DATE_COLUMN = derived('assigned_date', 'Assigned date', 'date', { sources: ['field_1', 'field_4'], }) /** Columns the asset record view doesn't have: the ASSIGNED values and the state. */ const ASSIGNMENT_COLUMNS = [ ...ASSET_IDENTITY_COLUMNS, ASSIGNED_TO_COLUMN, ASSIGNED_DATE_COLUMN, derived('assigned_by', 'Assigned by', 'text', { sources: ['field_5', 'field_11'] }), derived('assigned_department', 'department', 'text', { sources: ['field_21'] }), STATE_COLUMN, ] /** Columns for the return list: who had it, and the RETURNED values. */ const RETURN_COLUMNS = [ ...ASSET_IDENTITY_COLUMNS, ASSIGNED_TO_COLUMN, ASSIGNED_DATE_COLUMN, derived('returned_date', 'Returned date', 'date', { sources: ['field_8'] }), derived('returned_by', 'Returned by', 'text', { sources: [RETURN_EMPLOYEE_FIELD] }), derived('return_condition', 'Condition', 'text', { sources: ['field_29'] }), STATE_COLUMN, ] const inState = (state) => (row) => row.state.toLowerCase() === state.toLowerCase() const always = () => true /** Assignment list filters: by workflow state. `match` picks the rows. */ export const ASSIGNMENT_FILTERS = [ { value: 'assigned', label: 'Assigned', match: inState(ASSET_STATE.ASSIGNED) }, { value: 'available', label: 'Available', match: inState(ASSET_STATE.AVAILABLE) }, { value: 'all', label: 'All assets', match: always }, ] /** Return list filters: waiting to come back, already back, or everything. */ export const RETURN_FILTERS = [ { value: 'assigned', label: 'To return (Assigned)', match: inState(ASSET_STATE.ASSIGNED) }, { value: 'returned', label: 'Returned', match: (row) => row.returned_date !== '' }, { value: 'all', label: 'All assets', match: always }, ] /** * One page of an asset list with activity columns added. * * There is no separate assignment or return view: the asset record view is * workflow-backed, so each row is an asset instance carrying its state and the * latest ASSIGNED / RETURNED values. Load every row (with the same search and * sort), add `columns`, keep the rows `filters` matches for `query.filter`, * then page here. * * Employee lookups in the view's own columns show as names. A column of * `columns` the view already has (by field id or label) isn't added twice: * the view's column shows its value — the latest assignment's, so after a * reassignment "Employee data" names the new holder. * * A view holds only the columns it was built with (the return view has no * ASSIGNED values and nothing naming the asset), so each row is filled in * from the same instance in the other asset workflow views, then from its * detail view (see withInstanceValues). */ async function fetchAssetActivityPage(templateUid, query, { columns, filters, images = [] }) { const { page = 1, limit = 10, filter = filters[0].value, ...rest } = query const [{ config, rows: own }, people, others] = await Promise.all([ fetchAllRecordRows(templateUid, rest), fetchEmployeeDirectory(), fetchInstanceValues(ASSET_INSTANCE_VIEWS.filter((uid) => uid !== templateUid)), ]) const rows = await withDetailValues(own.map((row) => fillMissing(row, others.get(String(row.instance_id))))) const match = (filters.find((f) => f.value === filter) ?? filters[0]).match // The activity's own photo fields (`images`) show like the asset list's // photos: from the view's column for them (by id or label), else from the // row's raw field id. Other images make the rows tall and say nothing about // who has the asset. const viewFields = config?.fields ?? [] const isImageOf = (def, c) => c.field_key === def.id || hasLabel(def, c.output_label) const imageColumns = images.map((def) => ({ ...(viewFields.find((c) => isImageOf(def, c)) ?? derived(def.id, def.formLabel ?? def.label)), data_type: 'image', slots: def.slots, otherSlot: def.otherSlot, })) const fields = viewFields.filter((f) => f.data_type !== 'image' && !images.some((d) => isImageOf(d, f))) const mirrorOf = (col) => fields.find( (f) => col.sources.includes(f.field_key) || [col.output_label, ...col.aliases].some((l) => normLabel(l) === normLabel(f.output_label)), ) const mirrors = columns.map((col) => [col, mirrorOf(col)]).filter(([, f]) => f) const added = columns.filter((col) => !mirrors.some(([c]) => c === col)) const data = rows .map((row) => { const values = activityValues(row, people) const next = { ...row, ...values, ...Object.fromEntries(imageColumns.map((c, i) => [c.field_key, rowFieldValue(images[i], row)])), } for (const f of fields) next[f.field_key] = lookupCellText(next[f.field_key], people) for (const [col, f] of mirrors) { if (present(values[col.field_key])) next[f.field_key] = values[col.field_key] } return next }) .filter(match) // The asset's identity first, then the view's columns, then the activity's. const isIdentity = (col) => ASSET_IDENTITY_COLUMNS.includes(col) const totalPages = Math.max(1, Math.ceil(data.length / limit)) return { config: { ...config, fields: [...added.filter(isIdentity), ...fields, ...added.filter((c) => !isIdentity(c)), ...imageColumns], }, data: data.slice((page - 1) * limit, page * limit), pagination: { page, limit, total_count: data.length, total_pages: totalPages }, } } /** * The asset workflow's record views. Each holds only some of an instance's * fields, so a list fills its rows in from the others, by instance_id. */ const ASSET_INSTANCE_VIEWS = [ RECORD_VIEWS.assets.templateUid, RECORD_VIEWS.assetAssignments.templateUid, RECORD_VIEWS.assetReturns.templateUid, ] /** Workflow field keys (field_22, idgen_2): unique across the workflow, so safe to merge. */ const isFieldKey = (k) => /^(field|idgen)_\d+$/.test(k) /** `row` with the field values it lacks taken from `from`; its own values always win. */ function fillMissing(row, from) { if (!from) return row const out = { ...row } for (const [k, v] of Object.entries(from)) if (isFieldKey(k) && present(v) && !present(out[k])) out[k] = v return out } /** * Every instance's field values across the views `templateUids`, as * Map. A view that fails to load (except on * 401) is skipped, so the list still renders from its own view. */ async function fetchInstanceValues(templateUids) { const results = await Promise.all( templateUids.map((uid) => fetchAllRecordRows(uid).catch((err) => { if (err?.status === 401) throw err return { rows: [] } }), ), ) const byId = new Map() for (const { rows } of results) { for (const row of rows ?? []) { if (!present(row.instance_id)) continue const key = String(row.instance_id) byId.set(key, fillMissing(byId.get(key) ?? {}, row)) } } return byId } /** The asset's generated Asset ID (e.g. ID-00002) from its id_gen field, or ''. */ const assetCodeOf = (row) => String(Object.entries(row ?? {}).find(([k, v]) => /^idgen_\d+$/.test(k) && present(v))?.[1] ?? '') /** Most detail views read for one list, so a large list doesn't send a request per row. */ const MAX_DETAIL_FILLS = 30 /** * Rows that still don't say which asset they are, or who has an Assigned * asset, filled in from the instance's detail view — which holds every field. * A detail view that fails to load (except on 401) leaves its row as it is. */ async function withDetailValues(rows) { const lacking = (row) => present(row.instance_id) && ((!assetCodeOf(row) && !present(withEditedValues(row).field_42)) || (isAssetState(row, ASSET_STATE.ASSIGNED) && !currentAssignment(row).name)) const todo = rows.filter(lacking).slice(0, MAX_DETAIL_FILLS) if (!todo.length) return rows const BATCH = 6 const filled = new Map() for (let i = 0; i < todo.length; i += BATCH) { await Promise.all( todo.slice(i, i + BATCH).map(async (row) => { try { const res = await fetchDetailView(DETAIL_VIEWS.assets.templateUid, row.instance_id) const view = normalizeDetailView(res, { instanceId: row.instance_id, fieldDefs: ASSET_FORM_FIELDS }) const fields = (view.sections ?? []).flatMap((s) => s.fields).filter((f) => f.key) filled.set(row, { ...view.record, ...Object.fromEntries(fields.map((f) => [f.key, f.value])) }) } catch (err) { if (err?.status === 401) throw err } }), ) } return rows.map((row) => fillMissing(row, filled.get(row))) } /** One page of the asset assignment list. `query.filter` is one of ASSIGNMENT_FILTERS. */ export const fetchAssignmentListPage = (templateUid, query = {}) => fetchAssetActivityPage(templateUid, query, { columns: ASSIGNMENT_COLUMNS, filters: ASSIGNMENT_FILTERS }) /** One page of the asset return list. `query.filter` is one of RETURN_FILTERS. */ export const fetchReturnListPage = (templateUid, query = {}) => fetchAssetActivityPage(templateUid, query, { columns: RETURN_COLUMNS, filters: RETURN_FILTERS, images: ASSET_RETURN_FIELDS.filter((f) => f.type === 'image'), }) /** * An INIT value of an asset row by builder label ('Asset Details', 'Asset ID'…): * from the asset view's column with that label, else from the raw field id. */ export function assetInitValue(config, row, label) { const col = (config?.fields ?? []).find((f) => normLabel(f.output_label) === normLabel(label)) const v = col ? row[col.field_key] : undefined if (v != null && v !== '') return v const def = ASSET_FIELDS.find((f) => normLabel(f.label) === normLabel(label)) return def ? row[initValueKey(def, row)] : undefined } /** Every INIT field the form fills, plus Asset Owner, as { id, label, type, options?, slots? }. */ const ASSET_FORM_FIELDS = [ ...ASSET_FIELDS.filter((f) => !f.computed), { id: ASSET_OWNER_FIELD, label: 'Asset Owner', type: 'lookup' }, ] /** The Add Asset field a view column shows, matched by field id or label. */ export function assetFieldDef(key, label) { return ASSET_FORM_FIELDS.find((d) => d.id === key || hasLabel(d, label)) } /** True when `label` is the field's builder label, display label or table column. */ const hasLabel = (def, label) => [def.label, def.formLabel, def.column].some((l) => l && normLabel(l) === normLabel(label)) /** A select's option label for `value`, else `value` itself. */ export const optionLabel = (def, value) => def?.type === 'select' ? def.options?.find((o) => o.value === value)?.label ?? value : value /** Keys that hold the Asset Owner lookup: its field id, and any view column labelled so. */ function ownerKeys(config) { const keys = new Set([ASSET_OWNER_FIELD]) for (const f of config?.fields ?? []) { if (normLabel(f.output_label) === normLabel('Asset Owner')) keys.add(f.field_key) } return [...keys] } /** * Rows with Asset Owner replaced by the employee's name, for display. The views * send the lookup as just { id }, so the name comes from the employee list; an * owner who isn't in it (e.g. no longer active) shows as "Employee ". */ export async function withOwnerNames(config, rows) { const keys = ownerKeys(config) if (!rows.some((row) => keys.some((k) => lookupValue(row, k)))) return rows const employees = await fetchAssignableEmployees().catch((err) => { if (err?.status === 401) throw err return [] }) const nameById = new Map(employees.map((e) => [String(e.id), e.name])) return rows.map((row) => { const next = { ...row } for (const k of keys) { const v = lookupValue(row, k) if (v) next[k] = (v.id != null && nameById.get(String(v.id))) || lookupName(row, k) } return next }) } /** * Related asset list columns shown together, one cell each (see * api/richColumns.js). Labels are the Add Asset form's, which * withAssetFormColumns() gives every column. */ export const ASSET_RICH_COLUMNS = [ { key: 'asset', label: 'Asset', title: ['Model'], subtitle: ['Asset ID', 'Asset Details', 'Asset Brand'] }, { key: 'serial', label: 'Serial', title: ['Serial number'], subtitle: ['Asset identifier'] }, { key: 'status', label: 'Status', title: ['Asset State'], subtitle: [ { label: 'Asset Priority', prefix: 'Priority' }, 'Condition', 'Asset Classification', ], }, { key: 'owner', label: 'Owner', title: ['Asset Owner'], subtitle: ['Asset Location'] }, { key: 'specs', label: 'Specs', title: ['CPU'], subtitle: ['Hard Drive'] }, { key: 'purchase', label: 'Purchase', title: ['Purchase cost'], subtitle: ['Purchase date'], }, { key: 'warranty', label: 'Warranty', title: ['Warranty Status'], subtitle: [ { label: 'Warranty Start date', prefix: 'From' }, { label: 'Warranty End date', prefix: 'to' }, ], }, ] /** * Show the asset's workflow instance_id as the first list column. It shows * only when the view sends one (as a column, or as a value on its rows); * false hides it even then. */ export const SHOW_INSTANCE_ID = false /** True for a key or label naming the instance id: instance_id, instanceId, "Instance ID"… */ const isInstanceKey = (k) => String(k ?? '').replace(/[\s_-]+/g, '').toLowerCase() === 'instanceid' /** The list with its instance id column first (SHOW_INSTANCE_ID), or without one. */ function withInstanceIdColumn(res) { const fields = res?.config?.fields ?? [] const rows = Array.isArray(res?.data) ? res.data : [] const own = fields.find((c) => isInstanceKey(c.field_key) || isInstanceKey(c.output_label)) const rest = fields.filter((c) => c !== own) if (!SHOW_INSTANCE_ID) return { ...res, config: { ...res?.config, fields: rest } } const key = own?.field_key ?? [...new Set(rows.flatMap(Object.keys))].find((k) => isInstanceKey(k) && rows.some((r) => present(r[k]))) if (!key) return res const column = { ...(own ?? derived(key, 'Instance ID', 'bigint')), output_label: 'Instance ID' } return { ...res, config: { ...res?.config, fields: [column, ...rest] } } } /** * Asset list rows for display: owner names, a column for every Add Asset * field, related columns grouped (ASSET_RICH_COLUMNS), and the instance id. */ export async function withAssetListDisplay(res) { const table = await fetchAssetTableIndex() const rows = (Array.isArray(res?.data) ? res.data : []).map((row) => withTableValues(table, res?.config, row)) const named = await withOwnerNames(res?.config, rows.map(withEditedValues)) const display = withAssetFormColumns({ ...res, data: named }) return withInstanceIdColumn(withRichColumns(display, ASSET_RICH_COLUMNS)) } /** * The asset table view (RECORD_VIEWS.assetTable) indexed for withTableValues: * each table row's INIT values by field id, looked up by instance id, Asset * ID, serial number or asset identifier. Null when the view can't be read * (except on 401), so the pages still work from the workflow views alone. */ async function fetchAssetTableIndex() { let table try { table = await fetchAllRecordRows(RECORD_VIEWS.assetTable.templateUid) } catch (err) { if (err?.status === 401) throw err return null } const index = { instance: new Map(), code: new Map(), serial: new Map(), ident: new Map() } const add = (map, key, values) => key && !map.has(key) && map.set(key, values) for (const row of table.rows ?? []) { const values = initValuesOf(table.config, row) add(index.instance, present(row.instance_id) ? String(row.instance_id) : '', values) add(index.code, matchKey(assetCode(table.config, row)), values) add(index.serial, matchKey(values.field_44), values) add(index.ident, matchKey(values.field_49), values) } return index } /** A view row's Add Asset values by INIT field id, read from its columns by id, label or table column name. */ function initValuesOf(config, row) { const values = {} for (const c of config?.fields ?? []) { const def = assetFieldDef(c.field_key, c.output_label) if (def && present(row[c.field_key]) && !present(values[def.id])) values[def.id] = row[c.field_key] } for (const def of ASSET_FORM_FIELDS) { const v = rowFieldValue(def, row) if (!present(values[def.id]) && present(v)) values[def.id] = v } return values } /** * A workflow row with its asset's Add Asset values from the asset table * (`table`, from fetchAssetTableIndex) wherever it has none. The workflow * views hold the Edit activity's values, so an asset that was never edited * shows nothing there. Its own values, edits included, always win. */ function withTableValues(table, config, row) { if (!table || !row) return row const serial = ASSET_FIELDS.find((f) => f.id === 'field_44') const ident = ASSET_FIELDS.find((f) => f.id === 'field_49') const edited = withEditedValues(row) const values = (present(row.instance_id) && table.instance.get(String(row.instance_id))) || table.code.get(matchKey(assetCode(config, row) || assetCodeOf(row))) || [rowFieldValue(serial, row), rowFieldValue(serial, edited)].map((v) => table.serial.get(matchKey(v))).find(Boolean) || [rowFieldValue(ident, row), rowFieldValue(ident, edited)].map((v) => table.ident.get(matchKey(v))).find(Boolean) if (!values) return row const out = { ...row } for (const [k, v] of Object.entries(values)) if (!present(out[k])) out[k] = v return out } /** * The row key holding an INIT field's value. Workflow-backed views send it by * field id (field_43); table-backed views send the storage column, named after * the builder label (category, warranty_status…). */ function initValueKey(def, row) { if (present(row[def.id])) return def.id const names = [def.label, def.column].filter(Boolean).map(normLabel) return Object.keys(row).find((k) => names.includes(normLabel(k)) && present(row[k])) ?? def.id } /** A field's value in a row: by its field id, else by a column named after its label. */ export const rowFieldValue = (def, row) => row[initValueKey(def, row)] /** What an INIT field shows: a select's option label, the owner's name. */ function initDisplayValue(def, row) { const key = initValueKey(def, row) if (def.type === 'lookup') return lookupName(row, key) return optionLabel(def, row[key]) ?? '' } /** * One column per field the Add Asset form fills, in the form's order, then the * view's other columns (created_at…). A view column belongs to a field when it * has the field's id, label or table column name; it shows the form's label * (the table view labels columns by storage name, e.g. asset_brand), a select * shows its option label ("Under warranty", not "under_warrenty"), and a photo * column shows photos. * * A field the view has no column for (e.g. added in the builder after the * table was set up) still gets one, filled from the row's raw field id when * the view sends it — otherwise it stays empty until the table has a column. */ export function withAssetFormColumns(res) { // An Edit field's column (field_3…) belongs to the INIT field it updates. const editsDef = (def, c) => EDITED_FIELDS.some((e) => e.id === c.field_key && e.initId === def.id) const isDef = (def, c) => c.field_key === def.id || editsDef(def, c) || hasLabel(def, c.output_label) const viewFields = res?.config?.fields ?? [] const defOf = new Map(viewFields.map((c) => [c, ASSET_FORM_FIELDS.find((d) => isDef(d, c))])) const formLabel = (def) => (def.formLabel ?? def.label).trim() const shownAs = (c, def) => ({ ...c, output_label: formLabel(def), ...(def.type === 'image' && { data_type: 'image', slots: def.slots, otherSlot: def.otherSlot }), ...(def.type === 'file' && { data_type: 'file' }), }) const key = (def) => `init_${def.id}` const added = (def) => ({ ...derived(key(def), formLabel(def), ['select', 'lookup'].includes(def.type) ? 'text' : def.type), slots: def.slots, otherSlot: def.otherSlot, }) const missing = [] const formColumns = ASSET_FORM_FIELDS.flatMap((def) => { // One column per field: the Edit activity's two "Hard Drive"s show as one. const own = viewFields.find((c) => defOf.get(c) === def) if (own) return [shownAs(own, def)] missing.push(def) return [added(def)] }) const otherColumns = viewFields.filter((c) => !defOf.get(c)) const selectColumns = viewFields.filter((c) => defOf.get(c)?.type === 'select') // An Edit column (field_2…) is empty until the asset is edited: it shows the INIT value. const fieldColumns = viewFields.filter((c) => defOf.get(c)) const withInit = (row) => ({ ...row, ...Object.fromEntries( fieldColumns .filter((c) => !present(row[c.field_key]) && present(rowFieldValue(defOf.get(c), row))) .map((c) => [c.field_key, rowFieldValue(defOf.get(c), row)]), ), }) const rows = Array.isArray(res?.data) ? res.data.map(withInit) : [] return { ...res, config: { ...res?.config, fields: [...formColumns, ...otherColumns] }, data: rows.map((row) => ({ ...row, ...Object.fromEntries(selectColumns.map((c) => [c.field_key, optionLabel(defOf.get(c), row[c.field_key])])), ...Object.fromEntries(missing.map((def) => [key(def), initDisplayValue(def, row)])), })), } } /** The asset's generated code (e.g. ID-00002), or ''. */ export function assetCode(config, row) { const idGen = (config?.fields ?? []).find((f) => f.data_type === 'id_gen') return String(assetInitValue(config, row, 'Asset ID') ?? (idGen ? row[idGen.field_key] : '') ?? '') } const present = (v) => v !== undefined && v !== null && v !== '' /** * The id an asset row is opened by (/assets/:id) and that the detail view gets * as `instance_id`. The asset record view reads the asset table, whose rows * carry `_pk_value` / `id` rather than `instance_id`. */ export function assetRowId(row) { return [row?.instance_id, row?._pk_value, row?.id].find(present) } /** * The asset table's id (/assets/43) is NOT a workflow instance_id: sent to * /activity, the runtime answers "The record you're working on could not be * found". A record view of the asset workflow itself does return instance_id, * so a table row is matched to its instance by serial number — as the * employee pages match by email (see resolveEmployeeInstanceId). * * Source: the "ddddd" record view on the asset workflow (…/views/cdba05a2-…). * It replaced "data5" (59d266e8-…). It matches on the Asset id (idgen_2) and * the Edit activity's Serial number (field_13) and Asset identifier (field_64). * An asset that was never edited has no Edit values to match on unless the * view also has INIT's Serial number (field_44). field_60 is the Edit * activity's second "Hard Drive", so it can't identify an asset. */ export const ASSET_WORKFLOW_VIEW_UID = 'cdba05a2-a993-4de0-9364-c6ea048bd775' /** * Field ids (INIT and Edit) that identify an asset, tried in order: the serial * number, then the asset identifier. */ const ASSET_MATCH_IDS = [ ['field_44', 'field_13'], ['field_49', 'field_64'], ] const matchKey = (v) => String(v ?? '').trim().toLowerCase() /** Columns of a view config that show the generated Asset ID (an id_gen, or labelled so). */ const assetIdColumns = (config) => (config?.fields ?? []) .filter((c) => c.data_type === 'id_gen' || normLabel(c.output_label) === normLabel('Asset ID')) .map((c) => c.field_key) /** Labels of the fields an asset is matched by, in a detail view. */ const MATCH_LABELS = ['Asset ID', 'Serial number', 'Asset identifier'].map(normLabel) /** instance_id -> Promise of the match values its detail view holds. */ const detailMatchValues = new Map() /** * The values an instance's detail view holds that can identify its asset: any * text value, and the Asset ID, serial number and identifier fields whatever * their type. Fetched once per instance; a failed fetch counts as none. */ function instanceMatchValues(instanceId) { if (!detailMatchValues.has(instanceId)) { const values = fetchDetailView(DETAIL_VIEWS.assets.templateUid, instanceId) .then((res) => { const view = normalizeDetailView(res, { instanceId, fieldDefs: ASSET_FORM_FIELDS }) const fields = (view.sections ?? []).flatMap((sec) => sec.fields) return [ ...Object.values(view.record ?? {}).filter((v) => typeof v === 'string'), ...fields.filter((f) => typeof f.value === 'string' || MATCH_LABELS.includes(normLabel(f.label))).map((f) => f.value), ] .filter(present) .map(matchKey) }) .catch((err) => { detailMatchValues.delete(instanceId) if (err?.status === 401) throw err return [] }) detailMatchValues.set(instanceId, values) } return detailMatchValues.get(instanceId) } /** * The first of `instanceIds` whose detail view holds one of `keys`, or null. * Newest first, a few at a time, stopping at the first match. */ async function findInstanceByDetail(instanceIds, keys) { const ids = [...instanceIds].sort((a, b) => Number(b) - Number(a)) const BATCH = 6 for (let i = 0; i < ids.length; i += BATCH) { const batch = ids.slice(i, i + BATCH) const values = await Promise.all(batch.map(instanceMatchValues)) for (const key of keys) { const at = values.findIndex((vs) => vs.includes(key)) if (at >= 0) return batch[at] } } return null } /** * The workflow instance_id of an asset row: { instanceId } or, when it can't * be found, { problem } saying why, for the activity dialog to show. A row * that already carries one (a workflow-backed view) is used as is. * `tableConfig` is the asset table view's config, for the row's Asset ID. */ async function resolveAssetInstanceId(row, tableConfig) { if (present(row?.instance_id)) return { instanceId: row.instance_id } if (!ASSET_WORKFLOW_VIEW_UID) return { problem: 'ASSET_WORKFLOW_VIEW_UID is not set.' } let config let instances try { ;({ config, rows: instances } = await fetchAllRecordRows(ASSET_WORKFLOW_VIEW_UID)) } catch (err) { if (err?.status === 401) throw err return { problem: `The asset workflow record view (ddddd) could not be loaded: ${err?.message || err}.` } } if (!instances.length) return { problem: 'The asset workflow record view (ddddd) returned no records.' } if (!instances.some((r) => present(r.instance_id))) { return { problem: 'The asset workflow record view (ddddd) returns no instance_id for its records.' } } // A view column holds a field when it has the field's id or label. const columnsOf = (def) => (config?.fields ?? []).filter((c) => c.field_key === def.id || hasLabel(def, c.output_label)).map((c) => c.field_key) const tried = [] const keys = [] // The generated Asset ID (e.g. ID-00002), when both views show it. const idColumns = assetIdColumns(config) const code = matchKey(assetIdColumns(tableConfig).map((k) => row[k]).find(present)) if (code && idColumns.length) { const hit = instances.find((r) => idColumns.some((k) => matchKey(r[k]) === code)) if (hit && present(hit.instance_id)) return { instanceId: hit.instance_id } tried.push(`Asset ID "${code}"`) } for (const ids of ASSET_MATCH_IDS) { const defs = ids.map((id) => ASSET_FIELDS.find((f) => f.id === id)).filter(Boolean) const key = matchKey(defs.map((d) => row[initValueKey(d, row)]).find(present)) if (!key) continue const cols = [...ids, ...defs.flatMap(columnsOf)] const hit = instances.find( (r) => cols.some((k) => matchKey(r[k]) === key) || defs.some((d) => matchKey(r[initValueKey(d, r)]) === key), ) if (hit && present(hit.instance_id)) return { instanceId: hit.instance_id } tried.push(`${defs[0].label} "${key}"`) keys.push(key) } // The view may send a column's values under another key than its field_key, // so look for the serial number (then the identifier) in any text value. for (const key of keys) { const hit = instances.find( (r) => present(r.instance_id) && Object.values(r).some((v) => typeof v === 'string' && matchKey(v) === key), ) if (hit) return { instanceId: hit.instance_id } } if (!tried.length) { return { problem: 'This asset has no Serial number or Asset identifier to find its workflow record by.' } } // ddddd may send only instance_id and state, with its value columns empty; // the asset detail view of each instance does hold the INIT values. const detailKeys = [code, ...keys].filter(Boolean) const instanceIds = [...new Set(instances.map((r) => r.instance_id).filter(present))] const byDetail = await findInstanceByDetail(instanceIds, detailKeys) if (byDetail != null) return { instanceId: byDetail } // What ddddd does hold, to tell a missing record from a mismatched column. const serialDef = ASSET_FIELDS.find((f) => f.id === 'field_44') const serialKeys = ['field_44', 'field_13', ...columnsOf(serialDef)] const serials = [ ...new Set(instances.map((r) => serialKeys.map((k) => r[k]).find(present)).filter(present).map(String)), ] const shown = serials.slice(0, 10).join(', ') + (serials.length > 10 ? `, … (${serials.length} in all)` : '') // With no serial numbers read, show what a record does hold. const sample = Object.entries(instances.find((r) => present(r.instance_id)) ?? {}) .filter(([, v]) => present(v)) .slice(0, 12) .map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v).slice(0, 40) : String(v).slice(0, 40)}`) .join(', ') return { problem: `No record in the asset workflow record view (ddddd) has ${tried.join(' or ')}. ` + `ddddd returned ${instances.length} record(s); their serial numbers: ${shown || 'none'}. ` + (serials.length ? '' : `A ddddd record holds: ${sample}. `) + `None of the ${instanceIds.length} instances' detail views has it either. ` + 'This asset may have been added to the asset table without running the INIT activity, ' + 'so it has no workflow record.', } } /** Non-empty parts joined with ' · '; `false` (from `cond && part`) is skipped. */ const joinParts = (...parts) => parts.filter((p) => p !== false && present(p)).join(' · ') /** * The history of one asset, oldest first: * { id, action, date, employee, meta, notes }. * * The workflow keeps each activity's latest values on the instance, so this * holds the purchase and the latest assignment, return and loss — an earlier * assignment that was later replaced is not in the record any more. */ export function assetEvents(config, row) { const val = (id) => row[id] const events = [] const add = (action, date, employee, meta, notes) => events.push({ id: `${assetRowId(row)}-${action}`, action, date: present(date) ? toISODate(date) : '', employee: employee || '', meta, notes: notes || '', }) const purchased = val('field_45') ?? row.created_at if (present(purchased)) { const cost = val('field_46') add( HISTORY_ACTION.PURCHASED, purchased, '', joinParts( assetInitValue(config, row, 'Asset Details'), assetInitValue(config, row, 'Model'), present(cost) && `Cost ${cost}`, ), ) } const assignee = lookupName(row, ASSIGN_EMPLOYEE_FIELD) if (present(val('field_1')) || assignee) { add( HISTORY_ACTION.ASSIGNED, val('field_1'), assignee, joinParts(present(val('field_5')) && `By ${val('field_5')}`, val('field_21')), val('field_6'), ) } const reassignee = lookupName(row, REASSIGN_EMPLOYEE_FIELD) if (present(val('field_4')) || reassignee) { add( HISTORY_ACTION.REASSIGNED, val('field_4'), reassignee, joinParts(present(val('field_11')) && `By ${val('field_11')}`), val('field_12'), ) } const returner = lookupName(row, RETURN_EMPLOYEE_FIELD) if (present(val('field_8')) || returner) { add( HISTORY_ACTION.RETURNED, val('field_8'), returner, joinParts(present(val('field_29')) && `Condition ${val('field_29')}`), joinParts(val('field_9'), val('field_10')), ) } if (present(val('field_73'))) { add( HISTORY_ACTION.REPAIR, val('field_73'), '', joinParts(val('field_75'), present(val('field_76')) && `Estimate ${val('field_76')}`), val('field_74'), ) } if (present(val('field_77'))) { add( HISTORY_ACTION.REPAIRED, val('field_77'), '', joinParts(present(val('field_78')) && `Cost ${val('field_78')}`), val('field_79'), ) } const lastOwner = lookupName(row, LOST_EMPLOYEE_FIELD) if (present(val('field_36')) || lastOwner) { add( HISTORY_ACTION.LOST, val('field_36'), lastOwner, joinParts( val('field_37'), present(val('field_39')) && `Ref ${val('field_39')}`, present(val('field_40')) && `Recovery cost ${val('field_40')}`, ), val('field_38'), ) } // By date; undated events keep their workflow order. A re-assignment after // a return is dated later than the return, so it sorts after it. return events .map((e, i) => ({ e, i })) .sort((a, b) => (a.e.date && b.e.date ? a.e.date.localeCompare(b.e.date) : 0) || a.i - b.i) .map(({ e }) => e) } /** History list filters: by event kind. */ export const HISTORY_FILTERS = [ { value: 'all', label: 'All events' }, { value: HISTORY_ACTION.PURCHASED, label: 'Purchased' }, { value: HISTORY_ACTION.ASSIGNED, label: 'Assigned' }, { value: HISTORY_ACTION.REASSIGNED, label: 'Reassigned' }, { value: HISTORY_ACTION.RETURNED, label: 'Returned' }, { value: HISTORY_ACTION.REPAIR, label: 'Sent for repair' }, { value: HISTORY_ACTION.REPAIRED, label: 'Repaired' }, { value: HISTORY_ACTION.LOST, label: 'Lost' }, ] /** The history list's columns — every one derived from the asset rows. */ const HISTORY_COLUMNS = [ derived('date', 'Date', 'date'), derived('asset_code', 'Asset ID', 'id_gen'), derived('category', 'Asset Details'), derived('model', 'Model'), derived('action', 'Event', 'history_action'), derived('employee', 'Employee'), derived('details', 'Details'), STATE_COLUMN, ] /** * One page of the asset history: every asset's events, newest first, one row * per event. Search goes to the server (it matches asset fields); the event * filter, order and paging happen here. `query.filter` is one of HISTORY_FILTERS. */ export async function fetchHistoryListPage(templateUid, query = {}) { const { page = 1, limit = 10, filter = 'all', sortBy, sortDir, ...rest } = query const { config, rows } = await fetchAllRecordRows(templateUid, rest) const data = rows .flatMap((row) => { const shared = { instance_id: assetRowId(row), asset_code: assetCode(config, row), category: assetInitValue(config, row, 'Asset Details') ?? '', model: assetInitValue(config, row, 'Model') ?? '', state: instanceState(row), } return assetEvents(config, row).map((e) => ({ ...shared, event_id: e.id, date: e.date, action: e.action, employee: e.employee, details: joinParts(e.meta, e.notes), })) }) .filter((e) => filter === 'all' || e.action === filter) // Newest first; undated events last. .sort((a, b) => (b.date || '').localeCompare(a.date || '')) const totalPages = Math.max(1, Math.ceil(data.length / limit)) return { config: { ...config, fields: HISTORY_COLUMNS }, data: data.slice((page - 1) * limit, page * limit), pagination: { page, limit, total_count: data.length, total_pages: totalPages }, } } /** * One asset instance, or `row: null` when no instance has this id. * * The row comes from the asset record view (it carries the state and the * activity values), with the asset detail view's values filled in where the * record view has none — the detail view also holds fields the record view has * no column for — and the asset table's Add Asset values where neither has * one (see withTableValues). `config` is the record view's column config, so * the details page shows the same fields as the asset list. * If the detail view fails, the page still works from the record view alone. * `ownerId` is the Asset Owner's employee id, which the row shows as a name; * `ownerName` is that name. * @returns {Promise<{config: object|null, row: object|null, ownerId?: number|string|null, ownerName?: string, instanceProblem?: string}>} */ export async function fetchAssetInstance(instanceId) { const [{ config, rows }, detail, table] = await Promise.all([ fetchAllRecordRows(RECORD_VIEWS.assets.templateUid), fetchDetailView(DETAIL_VIEWS.assets.templateUid, instanceId).catch((err) => { if (err?.status === 401) throw err return null }), fetchAssetTableIndex(), ]) const listRow = rows.find((r) => String(assetRowId(r)) === String(instanceId)) || null const view = detail ? normalizeDetailView(detail, { instanceId, fieldDefs: ASSET_FORM_FIELDS }) : null const detailFields = (view?.sections ?? []).flatMap((s) => s.fields).filter((f) => f.key) if (!listRow && !detailFields.length) return { config, row: null } const detailValues = { ...view?.record, ...Object.fromEntries(detailFields.map((f) => [f.key, f.value])), } const row = { ...detailValues } for (const [k, v] of Object.entries(listRow ?? {})) if (present(v) || !(k in row)) row[k] = v // The detail view may key a field by its own name (not its field id); keep // its value under the field id too, so the page and the Edit form find it. // A key that is a field id is already in place (and may be another activity's field). for (const f of detailFields) { if (isFieldKey(f.key)) continue const def = assetFieldDef(f.key, f.label) if (def && present(f.value) && !present(row[def.id])) row[def.id] = f.value } // Every Add Asset value the workflow views lack, from the asset table. Object.assign(row, withTableValues(table, config, row)) // The asset table's rows carry no instance_id, and the URL id is the table // id, which the runtime rejects as NOT_FOUND; look the instance up instead. const known = [view?.record?.instance_id, listRow?.instance_id].find(present) const lookup = known != null ? { instanceId: known } : await resolveAssetInstanceId(row, config) row.instance_id = lookup.instanceId ?? null const [named] = await withOwnerNames(config, [withEditedValues(row)]) const owner = ASSET_FORM_FIELDS.find((d) => d.id === ASSET_OWNER_FIELD) const ownerName = [initValueKey(owner, named), ...ownerKeys(config)] .map((k) => named[k]) .find((v) => typeof v === 'string' && v.trim()) // ownerId is read before withOwnerNames swaps the owner lookup for a name; // ownerName picks the owner when the id isn't known. // instanceProblem: why the instance couldn't be found, when it couldn't. return { config, row: named, ownerId: assetOwnerId(row, config), ownerName: ownerName?.trim() ?? '', instanceProblem: lookup.problem, } }