asset-management/src/api/assets.js
vamsi.balaga 1004ad4895 feat: workflow-backed asset lists with employee names and assigned-by
- Read assets, assignments and returns from their asset workflow record views
- Show employee names instead of raw lookup ids in the activity lists, and
  fall back to the assignee's department when none was picked
- Merge derived activity columns into the view's matching columns instead of
  showing them twice
- Offer active employees for "Assigned by" and default it to the signed-in user
- Edit activity uploads photos and pre-fills saved ones

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:38:02 +05:30

1596 lines
61 KiB
JavaScript

import { APP_API_BASE_URL, apiFetch } from './client'
import { buildActivityData, explainActivityError, instanceState, 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 <JWT>
* { 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)
}
/**
* "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 <JWT>
* { 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' },
// The builder has no options for Assigned by, so the form offers the active
// employees' names (`employeeOptions`, filled in by the dialog).
{ id: 'field_5', label: 'Assigned by', type: 'select', options: [], employeeOptions: true },
{ 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 <JWT>
* { 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 <JWT>
* { 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',
},
{
// UNCONFIRMED: the sample cURL shows only "option_value". Copy this field's
// label and option values from the builder exactly, or the runtime rejects it.
id: 'field_72',
label: 'Received by',
type: 'select',
options: ['Praveen', 'Akshay', 'Prasad'].map((name) => ({ label: name, value: name })),
},
]
/** "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 <JWT>
* { 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 <JWT>
* { 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.
*/
export function assetEditValues(row) {
const norm = (v) => String(v).trim().toLowerCase()
return Object.fromEntries(
ASSET_EDIT_FIELDS.map((f) => {
const init = ASSET_FIELDS.find((d) => d.id === f.initId)
const raw = present(row?.[f.id]) ? row[f.id] : row?.[initValueKey(init, row ?? {})]
if (!present(raw)) return [f.id, '']
if (f.type === 'image') return [f.id, savedPhotos(f, raw)]
if (f.type === 'date') return [f.id, toISODate(raw)]
if (f.type === 'select') {
const hit = f.options.find((o) => norm(o.value) === norm(raw) || norm(o.label) === norm(raw))
return [f.id, hit ? hit.value : String(raw)]
}
return [f.id, String(raw)]
}),
)
}
/**
* 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. */
export function assetOwnerId(row) {
const edited = withEditedValues(row)
const owner = ASSET_FORM_FIELDS.find((d) => d.id === ASSET_OWNER_FIELD)
return lookupId(edited, initValueKey(owner, edited))
}
/** 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
return {
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')
/** 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 = [
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 = [
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.
*/
async function fetchAssetActivityPage(templateUid, query, { columns, filters, images = [] }) {
const { page = 1, limit = 10, filter = filters[0].value, ...rest } = query
const [{ config, rows }, people] = await Promise.all([
fetchAllRecordRows(templateUid, rest),
fetchEmployeeDirectory(),
])
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)
const totalPages = Math.max(1, Math.ceil(data.length / limit))
return {
config: { ...config, fields: [...fields, ...added, ...imageColumns] },
data: data.slice((page - 1) * limit, page * limit),
pagination: { page, limit, total_count: data.length, total_pages: totalPages },
}
}
/** 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 <id>".
*/
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 rows = Array.isArray(res?.data) ? res.data : []
const named = await withOwnerNames(res?.config, rows.map(withEditedValues))
const display = withAssetFormColumns({ ...res, data: named })
return withInstanceIdColumn(withRichColumns(display, ASSET_RICH_COLUMNS))
}
/**
* 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')
const rows = Array.isArray(res?.data) ? res.data : []
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. `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.
* @returns {Promise<{config: object|null, row: object|null, ownerId?: number|string|null, instanceProblem?: string}>}
*/
export async function fetchAssetInstance(instanceId) {
const [{ config, rows }, detail] = await Promise.all([
fetchAllRecordRows(RECORD_VIEWS.assets.templateUid),
fetchDetailView(DETAIL_VIEWS.assets.templateUid, instanceId).catch((err) => {
if (err?.status === 401) throw err
return null
}),
])
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 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)])
// Read before withOwnerNames swaps the owner lookup for a name.
// instanceProblem: why the instance couldn't be found, when it couldn't.
return { config, row: named, ownerId: assetOwnerId(row), instanceProblem: lookup.problem }
}