fix: pre-fill the Edit Asset form with the asset's values

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 <noreply@anthropic.com>
This commit is contained in:
vamsi.balaga 2026-10-28 11:35:16 +05:30
parent 4612155f6f
commit a1f841ac72
5 changed files with 501 additions and 45 deletions

View File

@ -68,6 +68,96 @@ export function imageEntries(field, raw) {
return list.filter(Boolean) return list.filter(Boolean)
} }
/**
* The builder's field definitions for one activity, as
* Map<field id, { id, label, type, options? }>:
*
* GET {APP_API_BASE_URL}/api/apps/{APP_ID}/versions/{VERSION_UID}/activities/{ACTIVITY_ID}/data-fields
* Authorization: Bearer <JWT>
*
* 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. * 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. ` + : `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.' '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 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. */ /** 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)) 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. */ /** Blank form state for a field list. */
export function emptyValues(fields) { export function emptyValues(fields) {
return Object.fromEntries(fields.filter((f) => !f.computed).map((f) => [f.id, ''])) return Object.fromEntries(fields.filter((f) => !f.computed).map((f) => [f.id, '']))

View File

@ -1,5 +1,13 @@
import { APP_API_BASE_URL, apiFetch } from './client' 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 { fetchEmployeeListPage } from './employees'
import { RECORD_VIEWS, fetchAllRecordRows, toISODate } from './recordView' import { RECORD_VIEWS, fetchAllRecordRows, toISODate } from './recordView'
import { withRichColumns } from './richColumns' import { withRichColumns } from './richColumns'
@ -176,6 +184,10 @@ export async function uploadAssetFile(activityId, fieldId, file) {
return send(ASSET_INIT_ACTIVITY_ID, INIT_IMAGE_FIELD) 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 * "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 } * 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', otherSlot: 'Other',
}, },
{ {
// UNCONFIRMED: the sample cURL shows only "option_value". Copy this field's // The builder spells it "Recived by". Its option values aren't known here
// label and option values from the builder exactly, or the runtime rejects it. // (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', id: 'field_72',
label: 'Received by', label: 'Recived by',
formLabel: 'Received by',
type: 'select', 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 * 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) { export function assetEditValues(config, row) {
const norm = (v) => String(v).trim().toLowerCase() const columns = config?.fields ?? []
return Object.fromEntries( return Object.fromEntries(
ASSET_EDIT_FIELDS.map((f) => { ASSET_EDIT_FIELDS.map((f) => {
const init = ASSET_FIELDS.find((d) => d.id === f.initId) const init = ASSET_FIELDS.find((d) => d.id === f.initId)
const raw = present(row?.[f.id]) ? row[f.id] : row?.[initValueKey(init, row ?? {})] const keys = [
if (!present(raw)) return [f.id, ''] f.id,
if (f.type === 'image') return [f.id, savedPhotos(f, raw)] ...EDITED_FIELDS.filter((e) => e.initId === init.id && e.id !== f.id).map((e) => e.id),
if (f.type === 'date') return [f.id, toISODate(raw)] init.id,
if (f.type === 'select') { ...columns.filter((c) => hasLabel(init, c.output_label)).map((c) => c.field_key),
const hit = f.options.find((o) => norm(o.value) === norm(raw) || norm(o.label) === norm(raw)) initValueKey(init, row ?? {}),
return [f.id, hit ? hit.value : String(raw)] ]
} const raw = keys.map((k) => row?.[k]).find(present)
return [f.id, String(raw)] 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, * 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 * Other: [...] }), in the order they were sent, so saving the form sends them
@ -684,11 +722,16 @@ export function withEditedValues(row) {
return out 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 edited = withEditedValues(row)
const owner = ASSET_FORM_FIELDS.find((d) => d.id === ASSET_OWNER_FIELD) 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. */ /** Asset workflow states, as named in the builder diagram. */
@ -806,7 +849,11 @@ export const assignedEmployeeName = (row) => currentAssignment(row).name
function activityValues(row, people) { function activityValues(row, people) {
const current = currentAssignment(row, people) const current = currentAssignment(row, people)
const assignee = current.id != null ? people?.get(String(current.id)) : undefined const assignee = current.id != null ? people?.get(String(current.id)) : undefined
const asset = withEditedValues(row)
return { return {
asset_code: assetCodeOf(row),
asset_model: asset.field_42 ?? '',
asset_serial: asset.field_44 ?? '',
assigned_to: current.name, assigned_to: current.name,
assigned_date: current.date, assigned_date: current.date,
assigned_by: current.by, 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') 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. */ /** Who has the asset now: ASSIGNED's or Reassigned's employee, whichever is newer. */
const ASSIGNED_TO_COLUMN = derived('assigned_to', 'Assigned to', 'text', { const ASSIGNED_TO_COLUMN = derived('assigned_to', 'Assigned to', 'text', {
sources: [ASSIGN_EMPLOYEE_FIELD, REASSIGN_EMPLOYEE_FIELD], 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. */ /** Columns the asset record view doesn't have: the ASSIGNED values and the state. */
const ASSIGNMENT_COLUMNS = [ const ASSIGNMENT_COLUMNS = [
...ASSET_IDENTITY_COLUMNS,
ASSIGNED_TO_COLUMN, ASSIGNED_TO_COLUMN,
ASSIGNED_DATE_COLUMN, ASSIGNED_DATE_COLUMN,
derived('assigned_by', 'Assigned by', 'text', { sources: ['field_5', 'field_11'] }), 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. */ /** Columns for the return list: who had it, and the RETURNED values. */
const RETURN_COLUMNS = [ const RETURN_COLUMNS = [
...ASSET_IDENTITY_COLUMNS,
ASSIGNED_TO_COLUMN, ASSIGNED_TO_COLUMN,
ASSIGNED_DATE_COLUMN, ASSIGNED_DATE_COLUMN,
derived('returned_date', 'Returned date', 'date', { sources: ['field_8'] }), 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: * `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 * the view's column shows its value — the latest assignment's, so after a
* reassignment "Employee data" names the new holder. * 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 = [] }) { async function fetchAssetActivityPage(templateUid, query, { columns, filters, images = [] }) {
const { page = 1, limit = 10, filter = filters[0].value, ...rest } = query 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), fetchAllRecordRows(templateUid, rest),
fetchEmployeeDirectory(), 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 const match = (filters.find((f) => f.value === filter) ?? filters[0]).match
// The activity's own photo fields (`images`) show like the asset list's // 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) .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)) const totalPages = Math.max(1, Math.ceil(data.length / limit))
return { 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), data: data.slice((page - 1) * limit, page * limit),
pagination: { page, limit, total_count: data.length, total_pages: totalPages }, 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<instance_id as string, values>. 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. */ /** One page of the asset assignment list. `query.filter` is one of ASSIGNMENT_FILTERS. */
export const fetchAssignmentListPage = (templateUid, query = {}) => export const fetchAssignmentListPage = (templateUid, query = {}) =>
fetchAssetActivityPage(templateUid, query, { columns: ASSIGNMENT_COLUMNS, filters: ASSIGNMENT_FILTERS }) 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. * field, related columns grouped (ASSET_RICH_COLUMNS), and the instance id.
*/ */
export async function withAssetListDisplay(res) { 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 named = await withOwnerNames(res?.config, rows.map(withEditedValues))
const display = withAssetFormColumns({ ...res, data: named }) const display = withAssetFormColumns({ ...res, data: named })
return withInstanceIdColumn(withRichColumns(display, ASSET_RICH_COLUMNS)) 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 * 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 * 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 otherColumns = viewFields.filter((c) => !defOf.get(c))
const selectColumns = viewFields.filter((c) => defOf.get(c)?.type === 'select') 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 { return {
...res, ...res,
config: { ...res?.config, fields: [...formColumns, ...otherColumns] }, 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 * 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 * 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 * 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 * no column for — and the asset table's Add Asset values where neither has
* page shows the same fields as the asset list. * 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. * 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. * `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}>} * `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) { 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), fetchAllRecordRows(RECORD_VIEWS.assets.templateUid),
fetchDetailView(DETAIL_VIEWS.assets.templateUid, instanceId).catch((err) => { fetchDetailView(DETAIL_VIEWS.assets.templateUid, instanceId).catch((err) => {
if (err?.status === 401) throw err if (err?.status === 401) throw err
return null return null
}), }),
fetchAssetTableIndex(),
]) ])
const listRow = rows.find((r) => String(assetRowId(r)) === String(instanceId)) || null const listRow = rows.find((r) => String(assetRowId(r)) === String(instanceId)) || null
const view = detail ? normalizeDetailView(detail, { instanceId, fieldDefs: ASSET_FORM_FIELDS }) : null const view = detail ? normalizeDetailView(detail, { instanceId, fieldDefs: ASSET_FORM_FIELDS }) : null
@ -1583,6 +1811,16 @@ export async function fetchAssetInstance(instanceId) {
} }
const row = { ...detailValues } const row = { ...detailValues }
for (const [k, v] of Object.entries(listRow ?? {})) if (present(v) || !(k in row)) row[k] = v 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 // 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. // 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 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 row.instance_id = lookup.instanceId ?? null
const [named] = await withOwnerNames(config, [withEditedValues(row)]) 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. // 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,
}
} }

View File

@ -33,6 +33,14 @@ export const RECORD_VIEWS = {
screen: 'ddddd', screen: 'ddddd',
rowKey: 'instance_id', 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: { assetAssignments: {
// The asset workflow view the Assignment list reads; its rows carry the // The asset workflow view the Assignment list reads; its rows carry the
// instance_id the ASSIGNED and Reassigned activities run on. // instance_id the ASSIGNED and Reassigned activities run on.

View File

@ -3,10 +3,13 @@ import { useNavigate } from 'react-router-dom'
import { Button, Field, Icon, Modal, SelectInput } from '../../components/ui' import { Button, Field, Icon, Modal, SelectInput } from '../../components/ui'
import ActivityFields from '../../components/activity/ActivityFields' import ActivityFields from '../../components/activity/ActivityFields'
import { import {
ASSET_ASSIGN_ACTIVITY_ID,
ASSET_ASSIGN_FIELDS, ASSET_ASSIGN_FIELDS,
ASSET_EDIT_ACTIVITY_ID, ASSET_EDIT_ACTIVITY_ID,
ASSET_EDIT_FIELDS, ASSET_EDIT_FIELDS,
ASSET_LOST_ACTIVITY_ID,
ASSET_LOST_FIELDS, ASSET_LOST_FIELDS,
ASSET_REASSIGN_ACTIVITY_ID,
ASSET_REASSIGN_FIELDS, ASSET_REASSIGN_FIELDS,
ASSET_REPAIRED_FIELDS, ASSET_REPAIRED_FIELDS,
ASSET_REPAIR_FIELDS, ASSET_REPAIR_FIELDS,
@ -15,6 +18,7 @@ import {
assignAsset, assignAsset,
completeAssetRepair, completeAssetRepair,
editAsset, editAsset,
fetchAssetActivityFieldDefs,
fetchAssignableEmployees, fetchAssignableEmployees,
reassignAsset, reassignAsset,
reportAssetLost, reportAssetLost,
@ -22,7 +26,7 @@ import {
sendAssetForRepair, sendAssetForRepair,
uploadAssetFile, uploadAssetFile,
} from '../../api/assets' } from '../../api/assets'
import { emptyValues, validateActivity } from '../../api/activityForm' import { emptyValues, matchOption, validateActivity, withBuilderOptions } from '../../api/activityForm'
import { useAuth } from '../../store/AuthContext' 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. * other fields come from api/assets.js, so each form matches its activity.
* `employeeRequired: false` lets the form run without one, and `employee: false` * `employeeRequired: false` lets the form run without one, and `employee: false`
* leaves the picker out; `warning` is shown above the form for an activity * 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 = { const ACTIVITIES = {
assign: { assign: {
title: 'Assign Asset', title: 'Assign Asset',
subtitle: 'Runs the ASSIGNED activity in the asset workflow.', subtitle: 'Runs the ASSIGNED activity in the asset workflow.',
activityId: ASSET_ASSIGN_ACTIVITY_ID,
fields: ASSET_ASSIGN_FIELDS, fields: ASSET_ASSIGN_FIELDS,
employeeLabel: 'Employee data', employeeLabel: 'Employee data',
employeeError: 'Choose who gets this asset.', employeeError: 'Choose who gets this asset.',
@ -48,6 +54,7 @@ const ACTIVITIES = {
reassign: { reassign: {
title: 'Reassign Asset', title: 'Reassign Asset',
subtitle: 'Runs the Reassigned activity in the asset workflow. The asset stays Assigned.', subtitle: 'Runs the Reassigned activity in the asset workflow. The asset stays Assigned.',
activityId: ASSET_REASSIGN_ACTIVITY_ID,
fields: ASSET_REASSIGN_FIELDS, fields: ASSET_REASSIGN_FIELDS,
employeeLabel: 'Employee data', employeeLabel: 'Employee data',
employeeError: 'Choose who gets this asset next.', employeeError: 'Choose who gets this asset next.',
@ -72,6 +79,7 @@ const ACTIVITIES = {
lost: { lost: {
title: 'Report Asset Lost', title: 'Report Asset Lost',
subtitle: 'Runs the Lost activity in the asset workflow.', subtitle: 'Runs the Lost activity in the asset workflow.',
activityId: ASSET_LOST_ACTIVITY_ID,
fields: ASSET_LOST_FIELDS, fields: ASSET_LOST_FIELDS,
employeeLabel: 'Last owner', employeeLabel: 'Last owner',
employeeRequired: false, employeeRequired: false,
@ -121,7 +129,8 @@ const ACTIVITIES = {
/** /**
* Dialog for one asset activity. `kind` is a key of 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); * `initialValues` pre-fills the fields (e.g. the asset's details on edit);
* `instanceProblem` says why there is no `instanceId`, when there is none. * `instanceProblem` says why there is no `instanceId`, when there is none.
*/ */
@ -131,6 +140,7 @@ export default function AssetActivityModal({
instanceId, instanceId,
instanceProblem, instanceProblem,
defaultEmployeeId, defaultEmployeeId,
defaultEmployeeName,
initialValues, initialValues,
onClose, onClose,
onDone, onDone,
@ -144,6 +154,9 @@ export default function AssetActivityModal({
const [employees, setEmployees] = useState([]) const [employees, setEmployees] = useState([])
const [loadingEmployees, setLoadingEmployees] = useState(false) const [loadingEmployees, setLoadingEmployees] = useState(false)
const [employeeId, setEmployeeId] = useState('') 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 [values, setValues] = useState(() => emptyValues(activity.fields))
const [errors, setErrors] = useState({}) const [errors, setErrors] = useState({})
const [serverError, setServerError] = useState('') const [serverError, setServerError] = useState('')
@ -160,14 +173,34 @@ export default function AssetActivityModal({
setErrors({}) setErrors({})
setServerError('') setServerError('')
setSubmitting(false) setSubmitting(false)
if (!hasEmployee) return undefined
let cancelled = false 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) setLoadingEmployees(true)
fetchAssignableEmployees() fetchAssignableEmployees()
.then((list) => { .then((list) => {
if (cancelled) return if (cancelled) return
setEmployees(list) 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. // A field that names an employee (Assigned by) starts as the signed-in user.
const me = list.find( const me = list.find(
(emp) => (user?.email && emp.email === user.email) || (user?.name && emp.name === user.name), (emp) => (user?.email && emp.email === user.email) || (user?.name && emp.name === user.name),
@ -189,7 +222,27 @@ export default function AssetActivityModal({
return () => { return () => {
cancelled = true 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() { function close() {
if (submitting) return if (submitting) return
@ -213,7 +266,7 @@ export default function AssetActivityModal({
return return
} }
const employee = employees.find((emp) => String(emp.id) === employeeId) ?? null 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 if (!employee && employeeRequired) nextErrors.employee = activity.employeeError
setErrors(nextErrors) setErrors(nextErrors)
if (Object.keys(nextErrors).length) return if (Object.keys(nextErrors).length) return
@ -221,7 +274,10 @@ export default function AssetActivityModal({
setSubmitting(true) setSubmitting(true)
setServerError('') setServerError('')
try { 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) onDone?.(employee, response)
onClose?.() onClose?.()
} catch (err) { } catch (err) {
@ -241,9 +297,16 @@ export default function AssetActivityModal({
})) }))
const formId = `${kind}-asset-form` const formId = `${kind}-asset-form`
// Fields with no builder options that pick an employee by name (Assigned by). // Fields with no builder options that pick an employee by name (Assigned by).
const fields = activity.fields.map((f) => // A pre-filled select value that's in none of the options (the asset was
f.employeeOptions ? { ...f, options: employees.map((emp) => ({ label: emp.name, value: emp.name })) } : f, // 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 ( return (
<Modal <Modal
@ -262,7 +325,7 @@ export default function AssetActivityModal({
form={formId} form={formId}
variant={activity.variant ?? 'primary'} variant={activity.variant ?? 'primary'}
icon={submitting ? undefined : 'check'} icon={submitting ? undefined : 'check'}
disabled={submitting || loadingEmployees} disabled={submitting || loadingEmployees || loadingOptions}
> >
{submitting ? activity.submitting : activity.submit} {submitting ? activity.submitting : activity.submit}
</Button> </Button>

View File

@ -190,7 +190,7 @@ export default function AssetDetails() {
const toFields = (defs) => const toFields = (defs) =>
defs.map((f) => ({ defs.map((f) => ({
key: f.id, key: f.id,
label: f.label, label: f.formLabel ?? f.label,
type: f.type, type: f.type,
slots: f.slots, slots: f.slots,
otherSlot: f.otherSlot, otherSlot: f.otherSlot,
@ -412,7 +412,9 @@ export default function AssetDetails() {
? current.id ? current.id
: null : 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)} onClose={() => setActivity(null)}
onDone={handleDone} onDone={handleDone}
/> />