From a1f841ac723902e165015748f972cc6ca1f4d0c1 Mon Sep 17 00:00:00 2001 From: "vamsi.balaga" Date: Wed, 28 Oct 2026 11:35:16 +0530 Subject: [PATCH] fix: pre-fill the Edit Asset form with the asset's values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit form - Look up each value under every key the details page reads: the Edit field id, the INIT field id, the view's column for the field, and a key named after its label. - Match select values by value or label in any case or spacing, again once the builder's options load; keep an unmatched saved value as an option so it isn't shown blank. - Read dates in ISO, dd-mm-yyyy and epoch form, and costs with formatting, into what the inputs hold. - Pick Asset Owner by name when the view gives no employee id. Asset values - Read the INIT activity's record view (asset table, de18263e-…) and fill in each asset's Add Asset values where the workflow views have none, matched by Asset ID, serial number or asset identifier. Edited values still win. - Keep detail view values under their field ids, and show an Edit column's INIT value until the asset is edited. Activity forms and lists - Take select options from the builder's activity fields; leave out a field whose options can't be confirmed (Recived by). - Show which asset each assignment and return row is, filled in from the other asset workflow views and the detail view. Co-Authored-By: Claude Opus 5.5 --- src/api/activityForm.js | 134 ++++++++++ src/api/assets.js | 311 +++++++++++++++++++++--- src/api/recordView.js | 8 + src/pages/assets/AssetActivityModal.jsx | 87 ++++++- src/pages/assets/AssetDetails.jsx | 6 +- 5 files changed, 501 insertions(+), 45 deletions(-) diff --git a/src/api/activityForm.js b/src/api/activityForm.js index 38f8b5f..5364f68 100644 --- a/src/api/activityForm.js +++ b/src/api/activityForm.js @@ -68,6 +68,96 @@ export function imageEntries(field, raw) { return list.filter(Boolean) } +/** + * The builder's field definitions for one activity, as + * Map: + * + * GET {APP_API_BASE_URL}/api/apps/{APP_ID}/versions/{VERSION_UID}/activities/{ACTIVITY_ID}/data-fields + * Authorization: Bearer + * + * The runtime accepts a select only with one of these option values, so forms + * take their options from here rather than from a copy that can drift. The + * response shape isn't documented: the list may come bare or wrapped, and + * options may sit on the field or in its config, so both are read loosely. + * Fetched once per activity; a failed fetch is retried next time. + */ +export function fetchActivityFieldDefs({ appId, versionUuid, activityId }) { + const key = `${appId}/${versionUuid}/${activityId}` + if (!fieldDefsCache.has(key)) { + const defs = apiFetch( + `${APP_API_BASE_URL}/api/apps/${appId}/versions/${versionUuid}/activities/${activityId}/data-fields`, + ) + .then((res) => new Map(listOf(res).map(builderFieldDef).filter(Boolean).map((d) => [d.id, d]))) + .catch((err) => { + fieldDefsCache.delete(key) + throw err + }) + fieldDefsCache.set(key, defs) + } + return fieldDefsCache.get(key) +} + +const fieldDefsCache = new Map() + +/** The first array in a response: itself, or under data / fields / data_fields / items. */ +function listOf(res) { + if (Array.isArray(res)) return res + for (const k of ['data', 'fields', 'data_fields', 'items', 'results']) { + const v = res?.[k] + if (Array.isArray(v)) return v + if (v && typeof v === 'object') { + const inner = listOf(v) + if (inner.length) return inner + } + } + return [] +} + +/** One builder field as { id, label, type, options? }, or null when it has no field id. */ +function builderFieldDef(raw) { + if (!raw || typeof raw !== 'object') return null + const id = [raw.field_id, raw.field_key, raw.key, raw.id, raw.name].find( + (v) => typeof v === 'string' && /^(field|idgen)_\d+$/.test(v), + ) + if (!id) return null + const holders = [raw, raw.config, raw.properties, raw.settings, raw.meta, raw.data_config, raw.field_config] + const rawOptions = holders.map((h) => h?.options ?? h?.choices).find(Array.isArray) + const options = rawOptions?.map(builderOption).filter(Boolean) + return { + id, + label: String(raw.label ?? raw.field_label ?? raw.display_name ?? raw.name ?? id), + type: String(raw.data_type ?? raw.type ?? ''), + ...(options && { options }), + } +} + +/** A builder option ("x", or { label, value } under various names) as { label, value }. */ +function builderOption(o) { + if (typeof o === 'string' || typeof o === 'number') return { label: String(o), value: String(o) } + if (!o || typeof o !== 'object') return null + const value = [o.value, o.option_value, o.key, o.id, o.label].find((v) => v != null && v !== '') + if (value == null) return null + const label = [o.label, o.option_label, o.name, o.text, o.display_name].find((v) => v != null && v !== '') + return { label: String(label ?? value), value: String(value) } +} + +/** + * `fields` with each select's options taken from the builder (`defs`, from + * fetchActivityFieldDefs), matched by field id; the app's own list stays where + * the builder has none. A field marked `unconfirmedOptions` has no list known + * to be right, so without the builder's options (`defs` null, or it has + * none) it is left out of the form — sending a guessed value is rejected + * ("… must be one of the listed options") and fails the whole activity. + */ +export function withBuilderOptions(fields, defs) { + return fields.flatMap((field) => { + if (field.type !== 'select') return [field] + const options = defs?.get(field.id)?.options + if (options?.length) return [{ ...field, options }] + return field.unconfirmedOptions ? [] : [field] + }) +} + /** * Turn a raw backend error into something actionable. * @@ -94,6 +184,13 @@ export function explainActivityError(err) { : `This activity is not one of the "${state}" state's activities. ` + 'Check that the activity id in the app matches the one in that state in the Zino builder.' } + // A select value that isn't one of the deployed field's option values. + const select = message.match(/([^:;.\n]+?)\s+must be one of the listed options/i)?.[1]?.trim() + if (select) { + err.message = + `"${select}" was rejected: the value picked is not one of that field's options in the deployed ` + + 'Zino app. Leave it empty and try again, or ask an admin to add that option in the builder and deploy.' + } return err } @@ -106,6 +203,43 @@ export function instanceState(record) { /** True once an instance has reached its end state — no activity can run on it. */ export const isEndState = (record) => /^end(\s|_)?state$|^end$/i.test(instanceState(record)) +/** 'In use', 'in_use' and ' IN-USE ' compare equal. */ +const optionKey = (v) => String(v ?? '').trim().toLowerCase().replace(/[\s_-]+/g, '_') + +/** + * The option value `raw` names, matched by value or label in any case or + * spacing (a view may send "In use" for the value in_use), or undefined. + * `raw` may also be an option object ({ value, label }). + */ +export function matchOption(options, raw) { + const wanted = [raw?.value, raw?.option_value, raw?.label, raw] + .filter((v) => v != null && typeof v !== 'object') + .map(optionKey) + .filter(Boolean) + const hit = (options ?? []).find((o) => { + const opt = typeof o === 'string' ? { value: o, label: o } : o + return wanted.includes(optionKey(opt.value)) || wanted.includes(optionKey(opt.label)) + }) + return hit === undefined ? undefined : typeof hit === 'string' ? hit : hit.value +} + +/** + * A stored date as a date input's value (yyyy-mm-dd), or '' when it can't be + * read: ISO dates and timestamps, dd-mm-yyyy / dd/mm/yyyy, and epoch numbers. + */ +export function toDateInput(value) { + if (value == null || value === '') return '' + const s = String(value).trim() + const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/) + if (iso) return `${iso[1]}-${iso[2].padStart(2, '0')}-${iso[3].padStart(2, '0')}` + const dmy = s.match(/^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})$/) + if (dmy) return `${dmy[3]}-${dmy[2].padStart(2, '0')}-${dmy[1].padStart(2, '0')}` + const d = new Date(/^\d+$/.test(s) ? Number(s) : s) + if (Number.isNaN(d.getTime())) return '' + const pad = (n) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + /** Blank form state for a field list. */ export function emptyValues(fields) { return Object.fromEntries(fields.filter((f) => !f.computed).map((f) => [f.id, ''])) diff --git a/src/api/assets.js b/src/api/assets.js index abdda7a..f39a966 100644 --- a/src/api/assets.js +++ b/src/api/assets.js @@ -1,5 +1,13 @@ import { APP_API_BASE_URL, apiFetch } from './client' -import { buildActivityData, explainActivityError, instanceState, uploadActivityFile } from './activityForm' +import { + buildActivityData, + explainActivityError, + fetchActivityFieldDefs, + instanceState, + matchOption, + toDateInput, + uploadActivityFile, +} from './activityForm' import { fetchEmployeeListPage } from './employees' import { RECORD_VIEWS, fetchAllRecordRows, toISODate } from './recordView' import { withRichColumns } from './richColumns' @@ -176,6 +184,10 @@ export async function uploadAssetFile(activityId, fieldId, file) { return send(ASSET_INIT_ACTIVITY_ID, INIT_IMAGE_FIELD) } +/** The builder's field definitions for one of the asset workflow's activities (see fetchActivityFieldDefs). */ +export const fetchAssetActivityFieldDefs = (activityId) => + fetchActivityFieldDefs({ appId: ASSET_APP_ID, versionUuid: ASSET_VERSION_UID, activityId }) + /** * "Asset Owner": a lookup into the employee table, like ASSIGNED's "Employee * data". The API sample shows only { id }; it is sent as { id, first_name } @@ -367,12 +379,17 @@ export const ASSET_RETURN_FIELDS = [ 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. + // The builder spells it "Recived by". Its option values aren't known here + // (the sample cURL shows only "option_value"), and guessed names were + // rejected with "Recived by must be one of the listed options". So the + // form offers the builder's own options (see withBuilderOptions) and + // leaves the field out when they can't be read. id: 'field_72', - label: 'Received by', + label: 'Recived by', + formLabel: 'Received by', type: 'select', - options: ['Praveen', 'Akshay', 'Prasad'].map((name) => ({ label: name, value: name })), + options: [], + unconfirmedOptions: true, }, ] @@ -618,26 +635,47 @@ export function completeAssetRepair(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. + * (its latest edit, else its INIT value), as the form holds it. `row` and + * `config` are fetchAssetInstance's. A value is looked for under every key + * the details page may show it by — the Edit field id (and field_60 for Hard + * Drive), the INIT field id, the view's column for the field, and a key named + * after its label — so the form opens with what the page shows. */ -export function assetEditValues(row) { - const norm = (v) => String(v).trim().toLowerCase() +export function assetEditValues(config, row) { + const columns = config?.fields ?? [] return Object.fromEntries( ASSET_EDIT_FIELDS.map((f) => { const init = ASSET_FIELDS.find((d) => d.id === f.initId) - const 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)] + const keys = [ + f.id, + ...EDITED_FIELDS.filter((e) => e.initId === init.id && e.id !== f.id).map((e) => e.id), + init.id, + ...columns.filter((c) => hasLabel(init, c.output_label)).map((c) => c.field_key), + initValueKey(init, row ?? {}), + ] + const raw = keys.map((k) => row?.[k]).find(present) + return [f.id, present(raw) ? editFormValue(f, raw) : ''] }), ) } +/** A stored value as an Edit form field holds it; '' when it can't be shown. */ +function editFormValue(field, raw) { + if (field.type === 'image') return savedPhotos(field, raw) + if (field.type === 'select') return matchOption(field.options, raw) ?? String(raw?.value ?? raw) + // A single-value field may come as an option-like object or a one-item list. + let v = Array.isArray(raw) ? raw[0] : raw + if (v && typeof v === 'object') v = v.value ?? v.label ?? v.name ?? '' + if (!present(v)) return '' + if (field.type === 'date') return toDateInput(v) + if (field.type === 'number') { + // "₹1,200.50" shows as 1200.5; the number input can't hold the formatting. + const n = Number(String(v).replace(/[^\d.-]/g, '')) + return /\d/.test(String(v)) && Number.isFinite(n) ? String(n) : String(v) + } + return String(v) +} + /** * An asset's saved photos as the image form value ({ Front, Back, Side, * Other: [...] }), in the order they were sent, so saving the form sends them @@ -684,11 +722,16 @@ export function withEditedValues(row) { return out } -/** The employee-table id of the asset's current owner (its latest edit, else INIT), or null. */ -export function assetOwnerId(row) { +/** + * The employee-table id of the asset's current owner (its latest edit, else + * INIT), or null. Read from the owner field, or any `config` column labelled + * Asset Owner. + */ +export function assetOwnerId(row, config) { const edited = withEditedValues(row) const owner = ASSET_FORM_FIELDS.find((d) => d.id === ASSET_OWNER_FIELD) - return lookupId(edited, initValueKey(owner, edited)) + const keys = [initValueKey(owner, edited), ...ownerKeys(config)] + return keys.map((k) => lookupId(edited, k)).find((id) => id != null) ?? null } /** Asset workflow states, as named in the builder diagram. */ @@ -806,7 +849,11 @@ export const assignedEmployeeName = (row) => currentAssignment(row).name function activityValues(row, people) { const current = currentAssignment(row, people) const assignee = current.id != null ? people?.get(String(current.id)) : undefined + const asset = withEditedValues(row) return { + asset_code: assetCodeOf(row), + asset_model: asset.field_42 ?? '', + asset_serial: asset.field_44 ?? '', assigned_to: current.name, assigned_date: current.date, assigned_by: current.by, @@ -835,6 +882,13 @@ const derived = (field_key, output_label, data_type = 'text', { sources = [], al const STATE_COLUMN = derived('state', 'State', 'asset_state') +/** Which asset a row is: an activity's own view may have no column for it. */ +const ASSET_IDENTITY_COLUMNS = [ + derived('asset_code', 'Asset ID', 'id_gen', { aliases: ['Asset ID', 'Asset id'] }), + derived('asset_model', 'Model', 'text', { sources: ['field_42', 'field_2'] }), + derived('asset_serial', 'Serial number', 'text', { sources: ['field_44', 'field_13'] }), +] + /** Who has the asset now: ASSIGNED's or Reassigned's employee, whichever is newer. */ const ASSIGNED_TO_COLUMN = derived('assigned_to', 'Assigned to', 'text', { sources: [ASSIGN_EMPLOYEE_FIELD, REASSIGN_EMPLOYEE_FIELD], @@ -846,6 +900,7 @@ const ASSIGNED_DATE_COLUMN = derived('assigned_date', 'Assigned date', 'date', { /** Columns the asset record view doesn't have: the ASSIGNED values and the state. */ const ASSIGNMENT_COLUMNS = [ + ...ASSET_IDENTITY_COLUMNS, ASSIGNED_TO_COLUMN, ASSIGNED_DATE_COLUMN, derived('assigned_by', 'Assigned by', 'text', { sources: ['field_5', 'field_11'] }), @@ -855,6 +910,7 @@ const ASSIGNMENT_COLUMNS = [ /** Columns for the return list: who had it, and the RETURNED values. */ const RETURN_COLUMNS = [ + ...ASSET_IDENTITY_COLUMNS, ASSIGNED_TO_COLUMN, ASSIGNED_DATE_COLUMN, derived('returned_date', 'Returned date', 'date', { sources: ['field_8'] }), @@ -893,13 +949,20 @@ export const RETURN_FILTERS = [ * `columns` the view already has (by field id or label) isn't added twice: * the view's column shows its value — the latest assignment's, so after a * reassignment "Employee data" names the new holder. + * + * A view holds only the columns it was built with (the return view has no + * ASSIGNED values and nothing naming the asset), so each row is filled in + * from the same instance in the other asset workflow views, then from its + * detail view (see withInstanceValues). */ async function fetchAssetActivityPage(templateUid, query, { columns, filters, images = [] }) { const { page = 1, limit = 10, filter = filters[0].value, ...rest } = query - const [{ config, rows }, people] = await Promise.all([ + const [{ config, rows: own }, people, others] = await Promise.all([ fetchAllRecordRows(templateUid, rest), fetchEmployeeDirectory(), + fetchInstanceValues(ASSET_INSTANCE_VIEWS.filter((uid) => uid !== templateUid)), ]) + const rows = await withDetailValues(own.map((row) => fillMissing(row, others.get(String(row.instance_id))))) const match = (filters.find((f) => f.value === filter) ?? filters[0]).match // The activity's own photo fields (`images`) show like the asset list's @@ -941,14 +1004,103 @@ async function fetchAssetActivityPage(templateUid, query, { columns, filters, im }) .filter(match) + // The asset's identity first, then the view's columns, then the activity's. + const isIdentity = (col) => ASSET_IDENTITY_COLUMNS.includes(col) const totalPages = Math.max(1, Math.ceil(data.length / limit)) return { - config: { ...config, fields: [...fields, ...added, ...imageColumns] }, + config: { + ...config, + fields: [...added.filter(isIdentity), ...fields, ...added.filter((c) => !isIdentity(c)), ...imageColumns], + }, data: data.slice((page - 1) * limit, page * limit), pagination: { page, limit, total_count: data.length, total_pages: totalPages }, } } +/** + * The asset workflow's record views. Each holds only some of an instance's + * fields, so a list fills its rows in from the others, by instance_id. + */ +const ASSET_INSTANCE_VIEWS = [ + RECORD_VIEWS.assets.templateUid, + RECORD_VIEWS.assetAssignments.templateUid, + RECORD_VIEWS.assetReturns.templateUid, +] + +/** Workflow field keys (field_22, idgen_2): unique across the workflow, so safe to merge. */ +const isFieldKey = (k) => /^(field|idgen)_\d+$/.test(k) + +/** `row` with the field values it lacks taken from `from`; its own values always win. */ +function fillMissing(row, from) { + if (!from) return row + const out = { ...row } + for (const [k, v] of Object.entries(from)) if (isFieldKey(k) && present(v) && !present(out[k])) out[k] = v + return out +} + +/** + * Every instance's field values across the views `templateUids`, as + * Map. A view that fails to load (except on + * 401) is skipped, so the list still renders from its own view. + */ +async function fetchInstanceValues(templateUids) { + const results = await Promise.all( + templateUids.map((uid) => + fetchAllRecordRows(uid).catch((err) => { + if (err?.status === 401) throw err + return { rows: [] } + }), + ), + ) + const byId = new Map() + for (const { rows } of results) { + for (const row of rows ?? []) { + if (!present(row.instance_id)) continue + const key = String(row.instance_id) + byId.set(key, fillMissing(byId.get(key) ?? {}, row)) + } + } + return byId +} + +/** The asset's generated Asset ID (e.g. ID-00002) from its id_gen field, or ''. */ +const assetCodeOf = (row) => String(Object.entries(row ?? {}).find(([k, v]) => /^idgen_\d+$/.test(k) && present(v))?.[1] ?? '') + +/** Most detail views read for one list, so a large list doesn't send a request per row. */ +const MAX_DETAIL_FILLS = 30 + +/** + * Rows that still don't say which asset they are, or who has an Assigned + * asset, filled in from the instance's detail view — which holds every field. + * A detail view that fails to load (except on 401) leaves its row as it is. + */ +async function withDetailValues(rows) { + const lacking = (row) => + present(row.instance_id) && + ((!assetCodeOf(row) && !present(withEditedValues(row).field_42)) || + (isAssetState(row, ASSET_STATE.ASSIGNED) && !currentAssignment(row).name)) + const todo = rows.filter(lacking).slice(0, MAX_DETAIL_FILLS) + if (!todo.length) return rows + + const BATCH = 6 + const filled = new Map() + for (let i = 0; i < todo.length; i += BATCH) { + await Promise.all( + todo.slice(i, i + BATCH).map(async (row) => { + try { + const res = await fetchDetailView(DETAIL_VIEWS.assets.templateUid, row.instance_id) + const view = normalizeDetailView(res, { instanceId: row.instance_id, fieldDefs: ASSET_FORM_FIELDS }) + const fields = (view.sections ?? []).flatMap((s) => s.fields).filter((f) => f.key) + filled.set(row, { ...view.record, ...Object.fromEntries(fields.map((f) => [f.key, f.value])) }) + } catch (err) { + if (err?.status === 401) throw err + } + }), + ) + } + return rows.map((row) => fillMissing(row, filled.get(row))) +} + /** One page of the asset assignment list. `query.filter` is one of ASSIGNMENT_FILTERS. */ export const fetchAssignmentListPage = (templateUid, query = {}) => fetchAssetActivityPage(templateUid, query, { columns: ASSIGNMENT_COLUMNS, filters: ASSIGNMENT_FILTERS }) @@ -1093,12 +1245,75 @@ function withInstanceIdColumn(res) { * 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 table = await fetchAssetTableIndex() + const rows = (Array.isArray(res?.data) ? res.data : []).map((row) => withTableValues(table, res?.config, row)) const named = await withOwnerNames(res?.config, rows.map(withEditedValues)) const display = withAssetFormColumns({ ...res, data: named }) return withInstanceIdColumn(withRichColumns(display, ASSET_RICH_COLUMNS)) } +/** + * The asset table view (RECORD_VIEWS.assetTable) indexed for withTableValues: + * each table row's INIT values by field id, looked up by instance id, Asset + * ID, serial number or asset identifier. Null when the view can't be read + * (except on 401), so the pages still work from the workflow views alone. + */ +async function fetchAssetTableIndex() { + let table + try { + table = await fetchAllRecordRows(RECORD_VIEWS.assetTable.templateUid) + } catch (err) { + if (err?.status === 401) throw err + return null + } + const index = { instance: new Map(), code: new Map(), serial: new Map(), ident: new Map() } + const add = (map, key, values) => key && !map.has(key) && map.set(key, values) + for (const row of table.rows ?? []) { + const values = initValuesOf(table.config, row) + add(index.instance, present(row.instance_id) ? String(row.instance_id) : '', values) + add(index.code, matchKey(assetCode(table.config, row)), values) + add(index.serial, matchKey(values.field_44), values) + add(index.ident, matchKey(values.field_49), values) + } + return index +} + +/** A view row's Add Asset values by INIT field id, read from its columns by id, label or table column name. */ +function initValuesOf(config, row) { + const values = {} + for (const c of config?.fields ?? []) { + const def = assetFieldDef(c.field_key, c.output_label) + if (def && present(row[c.field_key]) && !present(values[def.id])) values[def.id] = row[c.field_key] + } + for (const def of ASSET_FORM_FIELDS) { + const v = rowFieldValue(def, row) + if (!present(values[def.id]) && present(v)) values[def.id] = v + } + return values +} + +/** + * A workflow row with its asset's Add Asset values from the asset table + * (`table`, from fetchAssetTableIndex) wherever it has none. The workflow + * views hold the Edit activity's values, so an asset that was never edited + * shows nothing there. Its own values, edits included, always win. + */ +function withTableValues(table, config, row) { + if (!table || !row) return row + const serial = ASSET_FIELDS.find((f) => f.id === 'field_44') + const ident = ASSET_FIELDS.find((f) => f.id === 'field_49') + const edited = withEditedValues(row) + const values = + (present(row.instance_id) && table.instance.get(String(row.instance_id))) || + table.code.get(matchKey(assetCode(config, row) || assetCodeOf(row))) || + [rowFieldValue(serial, row), rowFieldValue(serial, edited)].map((v) => table.serial.get(matchKey(v))).find(Boolean) || + [rowFieldValue(ident, row), rowFieldValue(ident, edited)].map((v) => table.ident.get(matchKey(v))).find(Boolean) + if (!values) return row + const out = { ...row } + for (const [k, v] of Object.entries(values)) if (!present(out[k])) out[k] = v + return out +} + /** * The row key holding an INIT field's value. Workflow-backed views send it by * field id (field_43); table-backed views send the storage column, named after @@ -1163,8 +1378,18 @@ export function withAssetFormColumns(res) { }) const otherColumns = viewFields.filter((c) => !defOf.get(c)) const selectColumns = viewFields.filter((c) => defOf.get(c)?.type === 'select') + // An Edit column (field_2…) is empty until the asset is edited: it shows the INIT value. + const fieldColumns = viewFields.filter((c) => defOf.get(c)) + const withInit = (row) => ({ + ...row, + ...Object.fromEntries( + fieldColumns + .filter((c) => !present(row[c.field_key]) && present(rowFieldValue(defOf.get(c), row))) + .map((c) => [c.field_key, rowFieldValue(defOf.get(c), row)]), + ), + }) - const rows = Array.isArray(res?.data) ? res.data : [] + const rows = Array.isArray(res?.data) ? res.data.map(withInit) : [] return { ...res, config: { ...res?.config, fields: [...formColumns, ...otherColumns] }, @@ -1557,19 +1782,22 @@ export async function fetchHistoryListPage(templateUid, query = {}) { * 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. + * no column for — and the asset table's Add Asset values where neither has + * one (see withTableValues). `config` is the record view's column config, so + * the details page shows the same fields as the asset list. * If the detail view fails, the page still works from the record view alone. - * `ownerId` is the Asset Owner's employee id, which the row shows as a name. - * @returns {Promise<{config: object|null, row: object|null, ownerId?: number|string|null, instanceProblem?: string}>} + * `ownerId` is the Asset Owner's employee id, which the row shows as a name; + * `ownerName` is that name. + * @returns {Promise<{config: object|null, row: object|null, ownerId?: number|string|null, ownerName?: string, instanceProblem?: string}>} */ export async function fetchAssetInstance(instanceId) { - const [{ config, rows }, detail] = await Promise.all([ + const [{ config, rows }, detail, table] = await Promise.all([ fetchAllRecordRows(RECORD_VIEWS.assets.templateUid), fetchDetailView(DETAIL_VIEWS.assets.templateUid, instanceId).catch((err) => { if (err?.status === 401) throw err return null }), + fetchAssetTableIndex(), ]) const listRow = rows.find((r) => String(assetRowId(r)) === String(instanceId)) || null const view = detail ? normalizeDetailView(detail, { instanceId, fieldDefs: ASSET_FORM_FIELDS }) : null @@ -1583,6 +1811,16 @@ 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 detail view may key a field by its own name (not its field id); keep + // its value under the field id too, so the page and the Edit form find it. + // A key that is a field id is already in place (and may be another activity's field). + for (const f of detailFields) { + if (isFieldKey(f.key)) continue + const def = assetFieldDef(f.key, f.label) + if (def && present(f.value) && !present(row[def.id])) row[def.id] = f.value + } + // Every Add Asset value the workflow views lack, from the asset table. + Object.assign(row, withTableValues(table, config, row)) // The asset table's rows carry no instance_id, and the URL id is the table // id, which the runtime rejects as NOT_FOUND; look the instance up instead. const known = [view?.record?.instance_id, listRow?.instance_id].find(present) @@ -1590,7 +1828,18 @@ export async function fetchAssetInstance(instanceId) { row.instance_id = lookup.instanceId ?? null const [named] = await withOwnerNames(config, [withEditedValues(row)]) - // Read before withOwnerNames swaps the owner lookup for a name. + const owner = ASSET_FORM_FIELDS.find((d) => d.id === ASSET_OWNER_FIELD) + const ownerName = [initValueKey(owner, named), ...ownerKeys(config)] + .map((k) => named[k]) + .find((v) => typeof v === 'string' && v.trim()) + // ownerId is read before withOwnerNames swaps the owner lookup for a name; + // ownerName picks the owner when the id isn't known. // instanceProblem: why the instance couldn't be found, when it couldn't. - return { config, row: named, ownerId: assetOwnerId(row), instanceProblem: lookup.problem } + return { + config, + row: named, + ownerId: assetOwnerId(row, config), + ownerName: ownerName?.trim() ?? '', + instanceProblem: lookup.problem, + } } diff --git a/src/api/recordView.js b/src/api/recordView.js index 7fe5d2f..063e472 100644 --- a/src/api/recordView.js +++ b/src/api/recordView.js @@ -33,6 +33,14 @@ export const RECORD_VIEWS = { screen: 'ddddd', rowKey: 'instance_id', }, + assetTable: { + // The asset table: the INIT (Add Asset) activity's values, columns named + // after the builder labels (asset_brand, serial_number…). Rows are keyed + // by _pk_value and carry no instance_id, so they are matched to a + // workflow instance by Asset ID or serial number (see withTableValues). + templateUid: 'de18263e-dc82-4d4d-98a2-99607104373b', + rowKey: '_pk_value', + }, assetAssignments: { // The asset workflow view the Assignment list reads; its rows carry the // instance_id the ASSIGNED and Reassigned activities run on. diff --git a/src/pages/assets/AssetActivityModal.jsx b/src/pages/assets/AssetActivityModal.jsx index e0f9f2c..3442d54 100644 --- a/src/pages/assets/AssetActivityModal.jsx +++ b/src/pages/assets/AssetActivityModal.jsx @@ -3,10 +3,13 @@ import { useNavigate } from 'react-router-dom' import { Button, Field, Icon, Modal, SelectInput } from '../../components/ui' import ActivityFields from '../../components/activity/ActivityFields' import { + ASSET_ASSIGN_ACTIVITY_ID, ASSET_ASSIGN_FIELDS, ASSET_EDIT_ACTIVITY_ID, ASSET_EDIT_FIELDS, + ASSET_LOST_ACTIVITY_ID, ASSET_LOST_FIELDS, + ASSET_REASSIGN_ACTIVITY_ID, ASSET_REASSIGN_FIELDS, ASSET_REPAIRED_FIELDS, ASSET_REPAIR_FIELDS, @@ -15,6 +18,7 @@ import { assignAsset, completeAssetRepair, editAsset, + fetchAssetActivityFieldDefs, fetchAssignableEmployees, reassignAsset, reportAssetLost, @@ -22,7 +26,7 @@ import { sendAssetForRepair, uploadAssetFile, } from '../../api/assets' -import { emptyValues, validateActivity } from '../../api/activityForm' +import { emptyValues, matchOption, validateActivity, withBuilderOptions } from '../../api/activityForm' import { useAuth } from '../../store/AuthContext' /** @@ -31,12 +35,14 @@ import { useAuth } from '../../store/AuthContext' * other fields come from api/assets.js, so each form matches its activity. * `employeeRequired: false` lets the form run without one, and `employee: false` * leaves the picker out; `warning` is shown above the form for an activity - * that can't be undone. + * that can't be undone. `activityId` tags photo uploads, and its select + * options are read from the builder when the dialog opens. */ const ACTIVITIES = { assign: { title: 'Assign Asset', subtitle: 'Runs the ASSIGNED activity in the asset workflow.', + activityId: ASSET_ASSIGN_ACTIVITY_ID, fields: ASSET_ASSIGN_FIELDS, employeeLabel: 'Employee data', employeeError: 'Choose who gets this asset.', @@ -48,6 +54,7 @@ const ACTIVITIES = { reassign: { title: 'Reassign Asset', subtitle: 'Runs the Reassigned activity in the asset workflow. The asset stays Assigned.', + activityId: ASSET_REASSIGN_ACTIVITY_ID, fields: ASSET_REASSIGN_FIELDS, employeeLabel: 'Employee data', employeeError: 'Choose who gets this asset next.', @@ -72,6 +79,7 @@ const ACTIVITIES = { lost: { title: 'Report Asset Lost', subtitle: 'Runs the Lost activity in the asset workflow.', + activityId: ASSET_LOST_ACTIVITY_ID, fields: ASSET_LOST_FIELDS, employeeLabel: 'Last owner', employeeRequired: false, @@ -121,7 +129,8 @@ const ACTIVITIES = { /** * Dialog for one asset activity. `kind` is a key of ACTIVITIES; - * `defaultEmployeeId` preselects an employee (e.g. the assignee on return); + * `defaultEmployeeId` preselects an employee (e.g. the assignee on return), + * else `defaultEmployeeName` does, matched by name; * `initialValues` pre-fills the fields (e.g. the asset's details on edit); * `instanceProblem` says why there is no `instanceId`, when there is none. */ @@ -131,6 +140,7 @@ export default function AssetActivityModal({ instanceId, instanceProblem, defaultEmployeeId, + defaultEmployeeName, initialValues, onClose, onDone, @@ -144,6 +154,9 @@ export default function AssetActivityModal({ const [employees, setEmployees] = useState([]) const [loadingEmployees, setLoadingEmployees] = useState(false) const [employeeId, setEmployeeId] = useState('') + // The builder's field definitions (select options); null until read, or when they can't be. + const [builderDefs, setBuilderDefs] = useState(null) + const [loadingOptions, setLoadingOptions] = useState(false) const [values, setValues] = useState(() => emptyValues(activity.fields)) const [errors, setErrors] = useState({}) const [serverError, setServerError] = useState('') @@ -160,14 +173,34 @@ export default function AssetActivityModal({ setErrors({}) setServerError('') setSubmitting(false) - if (!hasEmployee) return undefined - let cancelled = false + + // Select options as the builder has them. Not being able to read them + // (the builder API may refuse an app user) isn't an error: the form keeps + // its own lists and leaves out fields whose options are unconfirmed. + setBuilderDefs(null) + if (activity.activityId && activity.fields.some((f) => f.type === 'select')) { + setLoadingOptions(true) + fetchAssetActivityFieldDefs(activity.activityId) + .then((defs) => !cancelled && setBuilderDefs(defs)) + .catch(() => {}) + .finally(() => !cancelled && setLoadingOptions(false)) + } + if (!hasEmployee) { + return () => { + cancelled = true + } + } + setLoadingEmployees(true) fetchAssignableEmployees() .then((list) => { if (cancelled) return setEmployees(list) + // No usable default id (none given, or not an active employee): match by name. + const byName = (defaultEmployeeName ?? '').trim().toLowerCase() + const named = byName && list.find((emp) => String(emp.name ?? '').trim().toLowerCase() === byName) + if (named) setEmployeeId((cur) => (list.some((emp) => String(emp.id) === cur) ? cur : String(named.id))) // A field that names an employee (Assigned by) starts as the signed-in user. const me = list.find( (emp) => (user?.email && emp.email === user.email) || (user?.name && emp.name === user.name), @@ -189,7 +222,27 @@ export default function AssetActivityModal({ return () => { cancelled = true } - }, [open, activity, hasEmployee, defaultEmployeeId, logout, navigate, user]) + }, [open, activity, hasEmployee, defaultEmployeeId, defaultEmployeeName, logout, navigate, user]) + + // The builder's options may name a pre-filled select's value differently + // ("In use" for in_use): switch it to the matching option's value. + useEffect(() => { + if (!builderDefs) return + setValues((v) => { + let changed = false + const next = { ...v } + for (const f of activity.fields) { + const options = builderDefs.get(f.id)?.options + if (f.type !== 'select' || !options?.length || !next[f.id]) continue + const hit = matchOption(options, next[f.id]) + if (hit !== undefined && hit !== next[f.id]) { + next[f.id] = hit + changed = true + } + } + return changed ? next : v + }) + }, [builderDefs, activity]) function close() { if (submitting) return @@ -213,7 +266,7 @@ export default function AssetActivityModal({ return } const employee = employees.find((emp) => String(emp.id) === employeeId) ?? null - const nextErrors = validateActivity(activity.fields, values) + const nextErrors = validateActivity(fields, values) if (!employee && employeeRequired) nextErrors.employee = activity.employeeError setErrors(nextErrors) if (Object.keys(nextErrors).length) return @@ -221,7 +274,10 @@ export default function AssetActivityModal({ setSubmitting(true) setServerError('') try { - const response = await activity.run(instanceId, values, employee) + // Only what the form shows is sent; a field left out has no options the runtime accepts. + const shown = new Set(fields.map((f) => f.id)) + const sent = Object.fromEntries(Object.entries(values).filter(([id]) => shown.has(id))) + const response = await activity.run(instanceId, sent, employee) onDone?.(employee, response) onClose?.() } catch (err) { @@ -241,9 +297,16 @@ export default function AssetActivityModal({ })) const formId = `${kind}-asset-form` // Fields with no builder options that pick an employee by name (Assigned by). - const fields = activity.fields.map((f) => - f.employeeOptions ? { ...f, options: employees.map((emp) => ({ label: emp.name, value: emp.name })) } : f, - ) + // A pre-filled select value that's in none of the options (the asset was + // saved with it) is still offered, so the form shows it instead of blank. + const fields = withBuilderOptions(activity.fields, builderDefs).map((f) => { + if (f.employeeOptions) return { ...f, options: employees.map((emp) => ({ label: emp.name, value: emp.name })) } + const current = initialValues?.[f.id] + if (f.type !== 'select' || !current || values[f.id] !== current || matchOption(f.options, current) !== undefined) { + return f + } + return { ...f, options: [...(f.options ?? []), { label: String(current), value: String(current) }] } + }) return ( {submitting ? activity.submitting : activity.submit} diff --git a/src/pages/assets/AssetDetails.jsx b/src/pages/assets/AssetDetails.jsx index dc99191..8203eb7 100644 --- a/src/pages/assets/AssetDetails.jsx +++ b/src/pages/assets/AssetDetails.jsx @@ -190,7 +190,7 @@ export default function AssetDetails() { const toFields = (defs) => defs.map((f) => ({ key: f.id, - label: f.label, + label: f.formLabel ?? f.label, type: f.type, slots: f.slots, otherSlot: f.otherSlot, @@ -412,7 +412,9 @@ export default function AssetDetails() { ? current.id : null } - initialValues={activity === 'edit' ? assetEditValues(row) : undefined} + // Picks the owner by name when the view gives no employee id. + defaultEmployeeName={activity === 'edit' ? data.ownerName : undefined} + initialValues={activity === 'edit' ? assetEditValues(config, row) : undefined} onClose={() => setActivity(null)} onDone={handleDone} />