From 1004ad48956f5cda883591a3a9380d9e1746c878 Mon Sep 17 00:00:00 2001 From: "vamsi.balaga" Date: Tue, 27 Oct 2026 12:38:02 +0530 Subject: [PATCH] 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 --- src/api/assets.js | 769 ++++++++++++++++----- src/api/recordView.js | 24 +- src/components/activity/ActivityFields.jsx | 9 +- src/pages/assets/AddAssetModal.jsx | 19 +- src/pages/assets/AssetActivityList.jsx | 9 +- src/pages/assets/AssetActivityModal.jsx | 42 +- src/pages/assets/AssetDetails.jsx | 39 +- src/pages/assets/AssetList.jsx | 5 +- 8 files changed, 706 insertions(+), 210 deletions(-) diff --git a/src/api/assets.js b/src/api/assets.js index c0983df..30bd996 100644 --- a/src/api/assets.js +++ b/src/api/assets.js @@ -12,54 +12,33 @@ import { HISTORY_ACTION } from '../constants' * * POST {APP_API_BASE_URL}/app/{APP_ID}/start * Authorization: Bearer - * { activity_id, workflow_uuid, version, - * data: { field_42 … field_61, field_51: { id, first_name } } } + * { activity_id, version, workflow_uuid, + * data: { field_42 … field_59, field_61, field_51: { id, first_name } } } * - * Matches the INIT activity's sample cURL in the builder. version_uuid and - * version_id are sent too, as the Zino app itself does. + * 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); - * 150002 is that version's numeric id (version_id). The workflow uuid is - * b5ea718c-… (from GET /api/apps/531/workflows, "asset workflow"). + * 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_VERSION_ID = 150002 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. "Asset_Owner" (field_51) is a lookup - * the form picks separately; see ASSET_OWNER_FIELD. + * 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. */ -/** - * The Invoice file field. PLACEHOLDER id: add a file field labelled "Invoice" - * to the INIT activity in the Zino builder, deploy, and put its id here. Until - * then, picking an invoice fails to upload; assets without one save as before. - */ -export const ASSET_INVOICE_FIELD = 'field_62' - -/** - * The monthly allowance paid for the asset. PLACEHOLDER id: add a number field - * labelled "Monthly allowance" to the INIT activity in the Zino builder, - * deploy, and put its id here. Until then the runtime may reject it. - */ -export const ASSET_MONTHLY_ALLOWANCE_FIELD = 'field_65' - -/** - * Where the asset stands in the repair flow. PLACEHOLDER id: add a dropdown - * field labelled "Repair status" to the INIT activity in the Zino builder, - * with the option values below, deploy, and put its id here. Until then the - * runtime may reject it. - */ -export const ASSET_REPAIR_STATUS_FIELD = 'field_80' - 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". @@ -78,11 +57,10 @@ export const ASSET_FIELDS = [ { label: 'Printer', value: 'printer' }, ], }, - { id: 'field_42', label: 'Model', type: 'text' }, - { id: 'field_44', label: 'Serial number', type: 'text' }, { 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' }, - { id: ASSET_MONTHLY_ALLOWANCE_FIELD, label: 'Monthly allowance', type: 'number' }, // Option values must match the builder exactly. { id: 'field_56', @@ -105,17 +83,6 @@ export const ASSET_FIELDS = [ { label: 'High', value: 'high' }, ], }, - { - id: ASSET_REPAIR_STATUS_FIELD, - label: 'Repair status', - type: 'select', - options: [ - { label: 'Not required', value: 'not_required' }, - { label: 'Needs repair', value: 'needs_repair' }, - { label: 'Under repair', value: 'under_repair' }, - { label: 'Repaired', value: 'repaired' }, - ], - }, { id: 'field_47', label: 'Condition', @@ -127,6 +94,24 @@ export const ASSET_FIELDS = [ }, { 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', @@ -137,23 +122,6 @@ export const ASSET_FIELDS = [ ], }, { id: 'field_52', label: 'Hard Drive', type: 'text' }, - { id: 'field_49', label: 'Asset identifier', type: 'text' }, - // `label` keeps the builder's spelling (the asset table's columns are named - // after it); `formLabel` is what the app shows. - { id: 'field_55', label: 'Warrenty End date', formLabel: 'Warranty End date', type: 'date' }, - { - id: 'field_48', - label: 'Warrenty Status', - formLabel: 'Warranty 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' }, - ], - }, // 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. { @@ -167,25 +135,46 @@ export const ASSET_FIELDS = [ otherSlot: 'Other', }, { id: 'field_59', label: 'Asset Brand', type: 'text' }, - { id: 'field_60', label: 'Asset ID', type: 'text' }, { id: 'field_61', label: 'Asset Location', type: 'text' }, - // The purchase invoice, to check what was bought, when and for how much. - { id: ASSET_INVOICE_FIELD, label: 'Invoice', type: 'file', maxSizeMb: 10 }, ] +/** 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 const uploadAssetFile = (activityId, fieldId, file) => - uploadActivityFile({ - appId: ASSET_APP_ID, - workflowUuid: ASSET_WORKFLOW_UUID, - versionUuid: ASSET_VERSION_UID, - activityId, - fieldId, - file, - }) +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 @@ -211,8 +200,6 @@ export async function createAsset(values, owner = null) { }, version: ASSET_WORKFLOW_VERSION, workflow_uuid: ASSET_WORKFLOW_UUID, - version_uuid: ASSET_VERSION_UID, - version_id: ASSET_VERSION_ID, }, }) } catch (err) { @@ -242,10 +229,25 @@ 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' }, - // Options for both selects aren't known yet — copy them from the builder. - { id: 'field_5', label: 'Assigned by', type: 'select', options: [] }, + // 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', options: [] }, + { + 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' }, + ], + }, ] /** @@ -258,14 +260,12 @@ export const ASSET_ASSIGN_FIELDS = [ export const ASSIGN_EMPLOYEE_FIELD = 'field_22' /** - * Active employees an asset can be assigned to, as { id, first_name, name, code }. - * `id` is the asset_management.employees bigint id the lookup expects. + * 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. */ -export async function fetchAssignableEmployees() { - const res = await fetchEmployeeListPage(RECORD_VIEWS.employees.templateUid, { - filter: 'active', - limit: 1000, - }) +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) => ({ @@ -275,9 +275,28 @@ export async function fetchAssignableEmployees() { [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. @@ -311,54 +330,49 @@ export const REASSIGN_EMPLOYEE_FIELD = 'field_23' * 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 }, - * [ASSET_RETURN_IMAGES_FIELD]: [{ blob_path, mime_type, original_name, size_bytes, uuid }, …] } } + * 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 } } * - * Each return photo is uploaded first (POST /app/{APP_ID}/upload with this - * activity's id and the field id), and the activity sends the returned file - * objects, as the start/activity endpoints expect. + * 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, in the Zino - * builder (…/activities/7f7b43fb-…). + * 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' -/** - * Photos of the laptop as it comes back. PLACEHOLDER id: add an image field - * labelled "Return images" to the RETURNED activity in the Zino builder, - * deploy, and put its id here. Until then, picking a photo fails to upload; - * returns without photos save as before. - */ -export const ASSET_RETURN_IMAGES_FIELD = 'field_63' - -/** - * Who takes the asset back. PLACEHOLDER id: add a dropdown field labelled - * "Received by" to the RETURNED activity in the Zino builder, with the options - * below, deploy, and put its id here. Until then the runtime may reject it. - */ -export const ASSET_RETURN_RECEIVER_FIELD = 'field_64' - -/** Fields the user fills in. */ +/** Fields the user fills in, in the builder's order. */ export const ASSET_RETURN_FIELDS = [ { id: 'field_8', label: 'Returned date', type: 'date' }, - { - id: ASSET_RETURN_RECEIVER_FIELD, - label: 'Received by', - type: 'select', - options: ['Praveen', 'Akshay', 'Prasad'].map((name) => ({ label: name, value: name })), - }, - // Condition (field_29) is left out of the form on purpose. { 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: ASSET_RETURN_IMAGES_FIELD, + 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". */ @@ -477,37 +491,72 @@ export function reportAssetLost(instanceId, values, employee) { } /** - * The "Edit" activity of the asset workflow. It updates the details of an - * asset that change over time, without moving it to another state. It should - * be offered in Available, Assigned and Under Repair. Like ASSIGNED it posts - * to /activity with the asset's instance_id. + * 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 - * { activity_id, workflow_uuid, instance_id, data: { field_66 … field_72, field_81 } } + * 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). * - * PLACEHOLDER ids: add the "Edit" activity and its fields (same labels, types - * and options as the INIT fields) in the Zino builder, deploy, and put the - * activity id and field ids here. Until then, running it fails. + * Source: the asset workflow "Asset details" activity, Fields tab and sample + * cURL, in the Zino builder (…/activities/8b90e8c1-…). */ -export const ASSET_EDIT_ACTIVITY_ID = 'TODO-edit-activity-id' +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. */ +/** + * 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_61', 'field_66'), // Asset Location - editFieldOf('field_56', 'field_67'), // Asset State - editFieldOf('field_58', 'field_68'), // Asset Priority - editFieldOf('field_47', 'field_69'), // Condition - editFieldOf(ASSET_REPAIR_STATUS_FIELD, 'field_81'), - editFieldOf(ASSET_MONTHLY_ALLOWANCE_FIELD, 'field_70'), - editFieldOf('field_48', 'field_71'), // Warranty Status - editFieldOf('field_55', 'field_72'), // Warranty End date + 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 }, ] /** @@ -543,9 +592,17 @@ export const ASSET_REPAIRED_FIELDS = [ { id: 'field_79', label: 'Repair remarks', type: 'longtext' }, ] -/** Run the Edit activity on an asset instance. */ -export function editAsset(instanceId, values) { - return runAssetActivity(ASSET_EDIT_ACTIVITY_ID, ASSET_EDIT_FIELDS, instanceId, values) +/** + * 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. */ @@ -569,6 +626,7 @@ export function assetEditValues(row) { 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)) @@ -579,6 +637,37 @@ export function assetEditValues(row) { ) } +/** + * 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. @@ -586,14 +675,21 @@ export function assetEditValues(row) { export function withEditedValues(row) { if (!row) return row const out = { ...row } - for (const f of ASSET_EDIT_FIELDS) { + for (const f of EDITED_FIELDS) { if (!present(row[f.id])) continue - const init = ASSET_FIELDS.find((d) => d.id === f.initId) + 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', @@ -623,11 +719,41 @@ function lookupValue(row, fieldId) { return v != null && v !== '' ? { id: v } : null } -/** Display name from an employee lookup field, or ''. */ -export function lookupName(row, fieldId) { +/** + * 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) - if (!v) return '' - return String(v.first_name ?? v.name ?? v.employee_code ?? (v.id != null ? `Employee ${v.id}` : '')) + 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. */ @@ -645,7 +771,7 @@ export function lookupId(row, fieldId) { * the reassignment wins when it is dated the same or later, or when the * assignment has no date. */ -export function currentAssignment(row) { +export function currentAssignment(row, people) { const assign = { source: 'assign', employeeField: ASSIGN_EMPLOYEE_FIELD, @@ -666,53 +792,73 @@ export function currentAssignment(row) { const r = present(reassign.date) ? toISODate(reassign.date) : '' if (!a || (r && r >= a)) pick = reassign } - return { ...pick, name: lookupName(row, pick.employeeField), id: lookupId(row, pick.employeeField) } + 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. */ -function activityValues(row) { - const current = currentAssignment(row) +/** + * 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: row.field_21 ?? '', + assigned_department: present(row.field_21) ? row.field_21 : assignee?.department ?? '', returned_date: row.field_8 ?? '', - returned_by: lookupName(row, RETURN_EMPLOYEE_FIELD), + returned_by: lookupName(row, RETURN_EMPLOYEE_FIELD, people), return_condition: row.field_29 ?? '', state: instanceState(row), } } -const derived = (field_key, output_label, data_type = 'text') => ({ +/** + * 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 = [ - derived('assigned_to', 'Assigned to'), - derived('assigned_date', 'Assigned date', 'date'), - derived('assigned_by', 'Assigned by'), - derived('assigned_department', 'department'), + 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 = [ - derived('assigned_to', 'Assigned to'), - derived('assigned_date', 'Assigned date', 'date'), - derived('returned_date', 'Returned date', 'date'), - derived('returned_by', 'Returned by'), - derived('return_condition', 'Condition'), + 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, ] @@ -741,19 +887,62 @@ export const RETURN_FILTERS = [ * 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 }) { +async function fetchAssetActivityPage(templateUid, query, { columns, filters, images = [] }) { const { page = 1, limit = 10, filter = filters[0].value, ...rest } = query - const { config, rows } = await fetchAllRecordRows(templateUid, rest) + const [{ config, rows }, people] = await Promise.all([ + fetchAllRecordRows(templateUid, rest), + fetchEmployeeDirectory(), + ]) const match = (filters.find((f) => f.value === filter) ?? filters[0]).match - const data = rows.map((row) => ({ ...row, ...activityValues(row) })).filter(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) - // Images make the rows tall and say nothing about who has the asset. - const fields = (config?.fields ?? []).filter((f) => f.data_type !== 'image') const totalPages = Math.max(1, Math.ceil(data.length / limit)) return { - config: { ...config, fields: [...fields, ...columns] }, + config: { ...config, fields: [...fields, ...added, ...imageColumns] }, data: data.slice((page - 1) * limit, page * limit), pagination: { page, limit, total_count: data.length, total_pages: totalPages }, } @@ -765,7 +954,11 @@ export const fetchAssignmentListPage = (templateUid, query = {}) => /** 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 }) + 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'…): @@ -846,7 +1039,6 @@ export const ASSET_RICH_COLUMNS = [ subtitle: [ { label: 'Asset Priority', prefix: 'Priority' }, 'Condition', - { label: 'Repair status', prefix: 'Repair' }, 'Asset Classification', ], }, @@ -856,7 +1048,7 @@ export const ASSET_RICH_COLUMNS = [ key: 'purchase', label: 'Purchase', title: ['Purchase cost'], - subtitle: ['Purchase date', { label: 'Monthly allowance', prefix: 'Allowance' }], + subtitle: ['Purchase date'], }, { key: 'warranty', @@ -869,15 +1061,41 @@ export const ASSET_RICH_COLUMNS = [ }, ] +/** + * 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, and related columns grouped (ASSET_RICH_COLUMNS). + * 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 withRichColumns(display, ASSET_RICH_COLUMNS) + return withInstanceIdColumn(withRichColumns(display, ASSET_RICH_COLUMNS)) } /** @@ -891,6 +1109,9 @@ function initValueKey(def, row) { 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) @@ -911,7 +1132,9 @@ function initDisplayValue(def, row) { * the view sends it — otherwise it stays empty until the table has a column. */ export function withAssetFormColumns(res) { - const isDef = (def, c) => c.field_key === def.id || hasLabel(def, c.output_label) + // 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() @@ -931,8 +1154,9 @@ export function withAssetFormColumns(res) { const missing = [] const formColumns = ASSET_FORM_FIELDS.flatMap((def) => { - const own = viewFields.filter((c) => defOf.get(c) === def) - if (own.length) return own.map((c) => shownAs(c, 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)] }) @@ -967,6 +1191,187 @@ const present = (v) => v !== undefined && v !== null && v !== '' 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(' · ') @@ -1154,7 +1559,8 @@ export async function fetchHistoryListPage(templateUid, query = {}) { * 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. - * @returns {Promise<{config: object|null, row: object|null}>} + * `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([ @@ -1176,7 +1582,14 @@ export async function fetchAssetInstance(instanceId) { } 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)]) - return { config, row: named } + // 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 } } diff --git a/src/api/recordView.js b/src/api/recordView.js index 290af12..7fe5d2f 100644 --- a/src/api/recordView.js +++ b/src/api/recordView.js @@ -25,11 +25,25 @@ export const RECORD_VIEWS = { rowKey: '_pk_value', }, assets: { - // Reads the asset table (rows keyed by id / _pk_value). It has no - // instance_id or workflow state; the old workflow view c91be68b-… is gone. - templateUid: 'de18263e-dc82-4d4d-98a2-99607104373b', - screen: 'Asset_management', - rowKey: '_pk_value', + // The "ddddd" view on the asset workflow: every row is an instance and + // carries its instance_id, which Edit and the other activities need. Its + // columns are the "Asset details" (Edit) activity's fields (field_2, + // field_3, field_13…). It replaced the asset table view de18263e-…. + templateUid: 'cdba05a2-a993-4de0-9364-c6ea048bd775', + screen: 'ddddd', + rowKey: 'instance_id', + }, + assetAssignments: { + // The asset workflow view the Assignment list reads; its rows carry the + // instance_id the ASSIGNED and Reassigned activities run on. + templateUid: '08438179-a28f-4d92-b340-97c3028cece2', + rowKey: 'instance_id', + }, + assetReturns: { + // The asset workflow view the Return list reads; its rows carry the + // instance_id the RETURNED activity runs on. + templateUid: '45ea19d6-8729-438e-8c4e-488a9d1ffddb', + rowKey: 'instance_id', }, } diff --git a/src/components/activity/ActivityFields.jsx b/src/components/activity/ActivityFields.jsx index 3f888f6..d1b4841 100644 --- a/src/components/activity/ActivityFields.jsx +++ b/src/components/activity/ActivityFields.jsx @@ -1,6 +1,7 @@ import { useRef } from 'react' import { Field, SelectInput, TextArea, TextInput } from '../ui' import { displayLabel } from '../../utils/format' +import { fileUrl } from './FieldValue' /** * Renders one Zino activity field per builder definition, matching its @@ -232,11 +233,15 @@ function startUpload(file, field, upload, done) { return pending } -/** A photo's thumbnail with its remove button, dimmed while it uploads. */ +/** + * A photo's thumbnail with its remove button, dimmed while it uploads. A photo + * already saved on the record (e.g. pre-filled on edit) has no local preview, + * so it shows from the stored file. + */ function Thumb({ photo, alt, className, onRemove, disabled, removeLabel }) { return (
- {alt} + {alt} {photo.uploading && Uploading…}