Compare commits

..

13 Commits

Author SHA1 Message Date
vamsi.balaga
5208e29905 fix: drop the 'to' label before Warranty End date in asset lists
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-05 16:40:18 +05:30
vamsi.balaga
25a635cda3 fix: send employee lookups as { id, first_name } and add Monthly allowance field
The runtime validates employee lookups against the lookup's storage
columns, so { id, name } fails with "incomplete selection" — field_51
and the other lookups go back to { id, first_name }. Also adds INIT's
new Monthly allowence field (field_73) and moves the placeholder repair
fields to field_80…field_83.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-05 16:34:25 +05:30
vamsi.balaga
086c397f78 feat: replace page buttons with a Rows selector on record views
Instead of Previous/Next paging, a Rows selector (5-100) sets how many
records the server returns, and the pager shows "Showing X of Y records".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-03 11:21:07 +05:30
vamsi.balaga
bd141e3585 feat: show the search box on every record view list
The backend searches its columns even though it marks every field
is_search: false, so the box was never shown. Render it always and keep
the field-name placeholder for views that do flag searchable fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-03 11:19:07 +05:30
vamsi.balaga
b8f50efaf0 fix: tidy the Employees list columns and export
- Show "Joined <date>" as the main text in the Date column, with the
  employee type below it (was the other way around)
- Hide the raw status column from the list; the Employment badge
  already shows Active / Inactive
- Drop the duplicate status column from the employee Excel export

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-02 16:34:11 +05:30
vamsi.balaga
5dead10c22 feat: add Export button to the Assets and Employees lists
Downloads every row (all pages) through Zino's record view export
endpoint, using the same columns the Zino screens export. If the server
emails the file instead of returning it, a notice says where it will go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:25:25 +05:30
vamsi.balaga
97dc3b6286 fix: show Asset Brand and Location first on the Add Asset form
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:31:39 +05:30
vamsi.balaga
c4ccdbf7d3 fix: pre-fill the Edit Asset form with the asset's values
- Look up each Edit value under every key the details page reads, and
  fill the asset's Add Asset values from the INIT record view (asset
  table, de18263e-…), matched by Asset ID, serial number or identifier.
  Edited values still win.
- Match select values by value or label in any case or spacing, also
  against the builder's options; read dates and formatted costs into
  what the inputs hold; pick Asset Owner by name when there's no id.
- Take activity select options from the builder; show which asset each
  assignment and return row is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:44:53 +05:30
vamsi.balaga
c0749f50e4 fix: show Update values on the employee details page
The Update activity maps its fields into the employees table, but the
detail view and workflow record view return only the INIT values, so the
details page showed old data after an Update. Read the employee's table
row and use its values for the Update fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:38:01 +05:30
vamsi.balaga
5b55bd267b fix: build files into static/ so /assets reloads
The /assets route shared its URL with Vite's assets/ build folder, so
nginx found a real folder with no index.html and answered 403 on reload
or a direct link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:59:28 +05:30
vamsi.balaga
0d2e7585b8 Revert "fix: allow Edit only on Available assets"
This reverts commit 72cdae3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:57:57 +05:30
vamsi.balaga
72cdae3d1c fix: allow Edit only on Available assets
The builder only allows the Asset details activity in Available, so the
runtime rejected Edit in Assigned and Under Repair. Disable the button
outside Available with a hint to return the asset first, and keep the
closed-asset notice keyed to the open states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:53:44 +05:30
vamsi.balaga
96718d173c fix: leave Assigned by out of the Assign form
field_5 is a select in the builder and the runtime rejects any value not
in its options, so sending employee names failed with "Assigned by must be
one of the listed options". Drop it from the form, as Reassign already does
for field_11, until the builder field is changed to text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:37:07 +05:30
14 changed files with 838 additions and 116 deletions

View File

@ -68,6 +68,96 @@ export function imageEntries(field, raw) {
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.
*
@ -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, '']))

View File

@ -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'
@ -136,6 +144,8 @@ export const ASSET_FIELDS = [
},
{ id: 'field_59', label: 'Asset Brand', type: 'text' },
{ id: 'field_61', label: 'Asset Location', type: 'text' },
// `label` keeps the builder's spelling; `formLabel` is what the app shows.
{ id: 'field_73', label: 'Monthly allowence', formLabel: 'Monthly allowance', type: 'number' },
]
/** INIT's photo field, whose uploads the runtime always accepts. */
@ -176,10 +186,13 @@ 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 }
* because the runtime checks lookups against their storage columns.
* data". Sent as { id, first_name } — see employeeLookup.
*/
export const ASSET_OWNER_FIELD = 'field_51'
@ -229,9 +242,10 @@ export const ASSET_ASSIGN_ACTIVITY_ID = 'd18815a4-4e74-480d-a943-67dce894412f'
/** Fields the user fills in. */
export const ASSET_ASSIGN_FIELDS = [
{ id: 'field_1', label: 'Assigned date', type: 'date' },
// The builder has no options for Assigned by, so the form offers the active
// employees' names (`employeeOptions`, filled in by the dialog).
{ id: 'field_5', label: 'Assigned by', type: 'select', options: [], employeeOptions: true },
// Assigned by (field_5) is left out of the form on purpose: it is a select in
// the builder and the runtime rejects any value not in its options ("Assigned
// by must be one of the listed options"), so employee names can't go there.
// Add it back, with `employeeOptions: true`, once the builder field is text.
{ id: 'field_6', label: 'Remarks', type: 'longtext' },
{
id: 'field_21',
@ -251,11 +265,9 @@ export const ASSET_ASSIGN_FIELDS = [
]
/**
* "Employee data": a lookup into the employee table. The runtime checks it
* against the lookup's storage columns (a Retired attempt failed with
* "missing required storage column 'first_name'"), so it carries
* { id, first_name }, not the { id, name } the API sample shows. It is picked
* from a list of active employees, so it is sent alongside the form data.
* "Employee data": a lookup into the employee table, sent as { id, first_name }
* like every employee lookup (see employeeLookup). It is picked from a list
* of active employees, so it is sent alongside the form data.
*/
export const ASSIGN_EMPLOYEE_FIELD = 'field_22'
@ -366,12 +378,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,
},
]
@ -421,8 +438,17 @@ export const ASSET_LOST_FIELDS = [
/** "Last owner": a lookup into the employee table, like "Employee data". */
export const LOST_EMPLOYEE_FIELD = 'field_35'
/** An employee lookup value, in the shape the runtime validates. */
const employeeLookup = (employee) => ({ id: employee.id, first_name: employee.first_name })
/**
* An employee lookup value, in the shape the runtime validates: { id,
* first_name }, the lookup's storage columns. The employee table has no "name"
* column, so { id, name } fails with "… has an incomplete selection"
* (invalid_type), and omitting first_name fails with "missing required
* storage column 'first_name'".
*/
const employeeLookup = (employee) => ({
id: employee.id,
first_name: employee.first_name || employee.name,
})
/**
* Run a later activity (ASSIGNED, RETURNED, …) on an existing asset instance.
@ -566,21 +592,23 @@ const EDITED_FIELDS = [
* with the asset's instance_id.
*
* POST {APP_API_BASE_URL}/app/{APP_ID}/activity
* { activity_id, workflow_uuid, instance_id, data: { field_73 … field_76 } }
* { activity_id, workflow_uuid, instance_id, data: { field_80 … field_83 } }
* { activity_id, workflow_uuid, instance_id, data: { field_77 … field_79 } }
*
* PLACEHOLDER ids: add an "Under Repair" state and these two activities, with
* these fields, in the Zino builder, deploy, and put their ids here. Until
* then, running them fails.
* then, running them fails. (The send fields were field_73 … field_76, but the
* builder has since given field_73 to INIT's "Monthly allowence", so they
* moved to field_80 …; match whatever ids the builder assigns when adding.)
*/
export const ASSET_REPAIR_ACTIVITY_ID = 'TODO-send-for-repair-activity-id'
/** Send for Repair fields. */
export const ASSET_REPAIR_FIELDS = [
{ id: 'field_73', label: 'Repair date', type: 'date' },
{ id: 'field_74', label: 'Issue details', type: 'longtext' },
{ id: 'field_75', label: 'Repair vendor', type: 'text' },
{ id: 'field_76', label: 'Estimated cost', type: 'number' },
{ id: 'field_80', label: 'Repair date', type: 'date' },
{ id: 'field_81', label: 'Issue details', type: 'longtext' },
{ id: 'field_82', label: 'Repair vendor', type: 'text' },
{ id: 'field_83', label: 'Estimated cost', type: 'number' },
]
export const ASSET_REPAIRED_ACTIVITY_ID = 'TODO-repair-completed-activity-id'
@ -617,26 +645,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
@ -683,11 +732,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. */
@ -805,7 +859,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,
@ -834,6 +892,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],
@ -845,6 +910,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'] }),
@ -854,6 +920,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'] }),
@ -892,13 +959,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
@ -940,14 +1014,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<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. */
export const fetchAssignmentListPage = (templateUid, query = {}) =>
fetchAssetActivityPage(templateUid, query, { columns: ASSIGNMENT_COLUMNS, filters: ASSIGNMENT_FILTERS })
@ -1056,7 +1219,7 @@ export const ASSET_RICH_COLUMNS = [
title: ['Warranty Status'],
subtitle: [
{ label: 'Warranty Start date', prefix: 'From' },
{ label: 'Warranty End date', prefix: 'to' },
'Warranty End date',
],
},
]
@ -1092,12 +1255,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
@ -1162,8 +1388,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] },
@ -1444,13 +1680,13 @@ export function assetEvents(config, row) {
)
}
if (present(val('field_73'))) {
if (present(val('field_80'))) {
add(
HISTORY_ACTION.REPAIR,
val('field_73'),
val('field_80'),
'',
joinParts(val('field_75'), present(val('field_76')) && `Estimate ${val('field_76')}`),
val('field_74'),
joinParts(val('field_82'), present(val('field_83')) && `Estimate ${val('field_83')}`),
val('field_81'),
)
}
@ -1556,19 +1792,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
@ -1582,6 +1821,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)
@ -1589,7 +1838,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,
}
}

View File

@ -223,6 +223,22 @@ export async function fetchEmployeeInstance(instanceId) {
return rows.find((r) => String(r.instance_id) === String(instanceId)) || null
}
/**
* An employee-table row's values under the Update field ids. Update maps every
* field into the table, so the row holds what was last saved even though the
* workflow views return only the INIT fields.
*/
function updateValuesFromTable(row) {
if (!row) return {}
const byLabel = new Map(Object.entries(row).map(([k, v]) => [normLabel(k), v]))
const out = {}
for (const f of EMPLOYEE_UPDATE_FIELDS) {
const v = byLabel.get(normLabel(f.label))
if (v !== undefined && v !== null && v !== '') out[f.id] = v
}
return out
}
/**
* Show the latest saved values on the details page.
*
@ -242,7 +258,13 @@ export async function withLatestInstanceValues(view, instanceId) {
}
if (!instance) return view
const record = { ...view.record, ...instance }
let tableRow = null
try {
tableRow = await fetchEmployeeTableRow(instance)
} catch {
// Keep the instance values.
}
const record = { ...view.record, ...instance, ...updateValuesFromTable(tableRow) }
const latest = new Map(
[...latestByLabel(ALL_EMPLOYEE_FIELDS, record)].map(([label, v]) => [normLabel(label), v]),
)
@ -335,10 +357,10 @@ export const EMPLOYEE_RICH_COLUMNS = [
{ key: 'contact', label: 'Contact', title: ['email'], subtitle: ['phone'] },
{ key: 'role', label: 'Role', title: ['designation'], subtitle: ['department'] },
{
key: 'type',
label: 'Type',
title: ['Employee type'],
subtitle: [{ label: 'joining_date', prefix: 'Joined' }],
key: 'date',
label: 'Date',
title: [{ label: 'joining_date', prefix: 'Joined' }],
subtitle: ['Employee type'],
},
]
@ -368,7 +390,11 @@ export async function fetchEmployeeListPage(templateUid, query = {}) {
.map((row) => ({ ...row, employment: isEndState(row) ? 'Inactive' : 'Active' }))
.filter((row) => filter === 'all' || row.employment.toLowerCase() === filter)
const fields = config?.fields ?? []
// The view's own "status" column isn't wanted in the list — the derived
// Employment badge below already shows each row's Active / Inactive state.
const fields = (config?.fields ?? []).filter(
(c) => ![c.field_key, c.output_label].some((l) => normLabel(l) === 'status'),
)
const totalPages = Math.max(1, Math.ceil(data.length / limit))
return {
config: { ...config, fields: [...fields, EMPLOYMENT_COLUMN] },

View File

@ -1,4 +1,4 @@
import { APP_API_BASE_URL, apiFetch } from './client'
import { APP_API_BASE_URL, ApiError, apiFetch, getStoredToken } from './client'
/**
* Record view data source.
@ -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.
@ -96,6 +104,137 @@ export async function fetchAllRecordRows(templateUid, query = {}) {
return { config, rows }
}
/**
* Columns the Zino "Asset" screen exports from the asset table view
* (RECORD_VIEWS.assetTable). Names are the backend's own, typos included
* (warrenty_end_date, warrenty_status).
*/
export const ASSET_EXPORT_COLUMNS = [
'monthly_allowance',
'asset_classification',
'warranty_start_date',
'asset_identifier',
'created_at',
'updated_at',
'asset_details',
'model',
'serial_number',
'purchase_date',
'purchase_cost',
'asset_state',
'warrenty_end_date',
'hard_drive',
'asset_owner',
'image',
'warrenty_status',
'asset_brand',
'asset_location',
'cpu',
]
/** Columns the Zino "Employee" screen exports from RECORD_VIEWS.employees. */
export const EMPLOYEE_EXPORT_COLUMNS = [
'id',
'employee_code',
'first_name',
'last_name',
'email',
'phone',
'department',
'designation',
'joining_date',
'created_at',
'updated_at',
]
/**
* Download every row of a record view as a file, the way the Zino screen's
* export button does:
*
* POST {RUNTIME}/app/{APP_ID}/view/recordview/export
* { rv_template_uid, columns: [{ name, value_path }], preset_alias, params, search, filters }
*
* The file is saved as `fileName` unless the server names it
* (Content-Disposition). A JSON reply carrying a link is followed instead; one
* without a link means the file will be emailed.
* @returns {Promise<{emailed: boolean, data?: object}>}
*/
export async function downloadRecordView(
templateUid,
columns,
{ fileName = 'export.xlsx', search = '', filters = [], params = {}, presetAlias = '' } = {},
) {
const token = getStoredToken()
let response
try {
response = await fetch(`${APP_API_BASE_URL}/app/${RECORD_VIEW_APP_ID}/view/recordview/export`, {
method: 'POST',
headers: {
accept: '*/*',
'content-type': 'application/json',
...(token && { authorization: `Bearer ${token}` }),
},
body: JSON.stringify({
rv_template_uid: templateUid,
columns: columns.map((name) => ({ name, value_path: name })),
preset_alias: presetAlias,
params,
search,
filters,
}),
})
} catch (err) {
throw new ApiError('Unable to reach the server. Check your connection and try again.', {
status: 0,
data: err,
})
}
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new ApiError(text.trim() || `Export failed (${response.status})`, {
status: response.status,
data: text,
})
}
const type = response.headers.get('content-type') || ''
if (type.includes('application/json')) {
const data = await response.json()
const url = data?.url || data?.download_url || data?.data?.url || data?.data?.download_url
// No link: the export runs in the background and Zino emails the file to
// the signed-in user (the owner of the token).
if (!url) return { emailed: true, data }
saveFile(url, data?.file_name || fileName)
return { emailed: false }
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
saveFile(url, fileNameFrom(response.headers.get('content-disposition')) || fileName)
// Give the browser a moment to start the download before freeing the blob.
setTimeout(() => URL.revokeObjectURL(url), 1000)
return { emailed: false }
}
/** 'attachment; filename="assets.xlsx"' -> 'assets.xlsx'. */
function fileNameFrom(disposition) {
if (!disposition) return ''
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(disposition)
if (utf8) return decodeURIComponent(utf8[1])
const plain = /filename="?([^";]+)"?/i.exec(disposition)
return plain ? plain[1].trim() : ''
}
function saveFile(url, fileName) {
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
link.remove()
}
/** Field types the backend reports that should render as a date. */
const DATE_TYPES = new Set(['date', 'timestamp without time zone', 'timestamp with time zone'])

View File

@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'
import {
AssetStateBadge,
Avatar,
Button,
EmployeeStatusBadge,
EmptyState,
HistoryActionBadge,
@ -17,11 +16,16 @@ import { FileLinks, imageSources } from './FieldValue'
/**
* Renders a record view straight from the backend response: the columns are
* whatever `config.fields` returns, in that order, with the backend's own
* labels. Paging, sorting and search are handled by the server.
* labels. Sorting and search are handled by the server; instead of page
* buttons, a "Rows" selector sets how many records the server returns
* (search_query.limit), so picking 5 shows 5 rows and picking 100 shows 100.
* `toolbar` adds controls (e.g. a filter) next to the search box. A field with
* `sortable: false` can't be sorted. A rich column (see api/richColumns.js)
* shows several fields in one cell and sorts by its `sort_key`.
*/
/** How many rows the "Rows" selector can ask the server for. */
const ROW_OPTIONS = [5, 10, 25, 50, 100]
export default function RecordView({
fields,
rows,
@ -29,7 +33,7 @@ export default function RecordView({
loading,
error,
query,
setPage,
setLimit,
setSearch,
setSort,
rowKey = 'instance_id',
@ -46,6 +50,8 @@ export default function RecordView({
return () => clearTimeout(id)
}, [term, query.search, setSearch])
// Names the placeholder only: the backend searches its own columns whatever
// is_search says (it returns false on every field), so the box always shows.
// A rich column's fields stay searchable.
const searchable = fields
.flatMap((f) => (f.data_type === 'rich' ? [...f.title, ...f.subtitle] : [f]))
@ -59,20 +65,20 @@ export default function RecordView({
setSort(sortKey(field), dir)
}
const page = pagination?.page ?? 1
const totalPages = pagination?.total_pages ?? 1
const total = pagination?.total_count ?? rows.length
return (
<>
<Toolbar count={total} countLabel="records">
{searchable.length > 0 && (
<SearchInput
value={term}
onChange={setTerm}
placeholder={`Search by ${searchable.map((f) => displayLabel(f.output_label)).join(', ')}…`}
/>
)}
<SearchInput
value={term}
onChange={setTerm}
placeholder={
searchable.length > 0
? `Search by ${searchable.map((f) => displayLabel(f.output_label)).join(', ')}…`
: 'Search…'
}
/>
{toolbar}
{loading && <span className="toolbar__count">Loading…</span>}
</Toolbar>
@ -139,26 +145,26 @@ export default function RecordView({
</table>
</div>
{totalPages > 1 && (
{total > 0 && (
<div className="pager">
<Button
size="sm"
icon="chevronLeft"
disabled={page <= 1 || loading}
onClick={() => setPage(page - 1)}
>
Previous
</Button>
<label className="pager__label">
Rows{' '}
<select
className="pager__select"
value={query.limit}
disabled={loading}
onChange={(e) => setLimit(Number(e.target.value))}
>
{ROW_OPTIONS.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
<span className="pager__label">
Page {page} of {totalPages}
Showing {Math.min(rows.length, total)} of {total} records
</span>
<Button
size="sm"
disabled={page >= totalPages || loading}
onClick={() => setPage(page + 1)}
>
Next
</Button>
</div>
)}
</>

View File

@ -24,6 +24,7 @@ const PATHS = {
repair: 'M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z',
laptop: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v11H3V5zM2 20h20',
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
download: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3',
}
export default function Icon({ name, size = 16, strokeWidth = 1.8, ...rest }) {

View File

@ -71,6 +71,7 @@ export default function useRecordView(
error,
query,
setPage: (page) => setQuery((q) => ({ ...q, page })),
setLimit: (limit) => setQuery((q) => ({ ...q, limit, page: 1 })),
setSearch: (search) => setQuery((q) => ({ ...q, search, page: 1 })),
setSort: (sortBy, sortDir) => setQuery((q) => ({ ...q, sortBy, sortDir, page: 1 })),
setFilter: (filter) => setQuery((q) => ({ ...q, filter, page: 1 })),

View File

@ -12,10 +12,18 @@ import {
import { emptyValues, validateActivity } from '../../api/activityForm'
import { useAuth } from '../../store/AuthContext'
// Asset Brand and Asset Location come first, as on the Edit form; the rest
// keep the builder's order.
const FIRST_FIELDS = ['field_59', 'field_61']
const FORM_FIELDS = [
...FIRST_FIELDS.map((id) => ASSET_FIELDS.find((f) => f.id === id)),
...ASSET_FIELDS.filter((f) => !FIRST_FIELDS.includes(f.id)),
]
// Asset Owner sits after Condition, as in the builder.
const OWNER_AFTER = ASSET_FIELDS.findIndex((f) => f.id === 'field_47') + 1
const FIELDS_BEFORE_OWNER = ASSET_FIELDS.slice(0, OWNER_AFTER)
const FIELDS_AFTER_OWNER = ASSET_FIELDS.slice(OWNER_AFTER)
const OWNER_AFTER = FORM_FIELDS.findIndex((f) => f.id === 'field_47') + 1
const FIELDS_BEFORE_OWNER = FORM_FIELDS.slice(0, OWNER_AFTER)
const FIELDS_AFTER_OWNER = FORM_FIELDS.slice(OWNER_AFTER)
const upload = (fieldId, file) => uploadAssetFile(ASSET_INIT_ACTIVITY_ID, fieldId, file)

View File

@ -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 (
<Modal
@ -262,7 +325,7 @@ export default function AssetActivityModal({
form={formId}
variant={activity.variant ?? 'primary'}
icon={submitting ? undefined : 'check'}
disabled={submitting || loadingEmployees}
disabled={submitting || loadingEmployees || loadingOptions}
>
{submitting ? activity.submitting : activity.submit}
</Button>

View File

@ -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}
/>

View File

@ -3,8 +3,10 @@ import { useNavigate } from 'react-router-dom'
import { Button, Card, Icon, PageHeader } from '../../components/ui'
import RecordView from '../../components/activity/RecordView'
import useRecordView from '../../hooks/useRecordView'
import { RECORD_VIEWS } from '../../api/recordView'
import { ASSET_EXPORT_COLUMNS, RECORD_VIEWS, downloadRecordView } from '../../api/recordView'
import { assetRowId, withAssetListDisplay } from '../../api/assets'
import { useAuth } from '../../store/AuthContext'
import { todayISO } from '../../utils/format'
import AddAssetModal from './AddAssetModal'
const VIEW = RECORD_VIEWS.assets
@ -20,9 +22,38 @@ export default function AssetList() {
sortBy: 'idgen_2',
sortDir: 'desc',
})
const { logout, user } = useAuth()
const [adding, setAdding] = useState(false)
const [exporting, setExporting] = useState(false)
const [notice, setNotice] = useState(null)
/** Downloads every asset (all pages) from the asset table view. */
async function handleExport() {
setExporting(true)
try {
const { emailed } = await downloadRecordView(
RECORD_VIEWS.assetTable.templateUid,
ASSET_EXPORT_COLUMNS,
{ fileName: `assets-${todayISO()}.xlsx` },
)
if (emailed) {
setNotice({
tone: 'success',
text: `Export started. The file will be emailed to ${user?.email || 'you'}.`,
})
}
} catch (err) {
if (err?.status === 401) {
logout()
navigate('/login', { replace: true })
return
}
setNotice({ tone: 'danger', text: err?.message || 'Could not download the assets.' })
} finally {
setExporting(false)
}
}
/** `values` is keyed by Zino field id — see ASSET_FIELDS in api/assets.js. */
function handleCreated(values) {
setNotice({
@ -39,9 +70,14 @@ export default function AssetList() {
title="Assets"
subtitle="Every device in the inventory, read live from the asset workflow."
actions={
<Button variant="primary" icon="plus" onClick={() => setAdding(true)}>
Add Asset
</Button>
<>
<Button icon="download" onClick={handleExport} disabled={exporting}>
{exporting ? 'Downloading…' : 'Export'}
</Button>
<Button variant="primary" icon="plus" onClick={() => setAdding(true)}>
Add Asset
</Button>
</>
}
/>

View File

@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { Button, Card, Icon, PageHeader, SelectInput } from '../../components/ui'
import RecordView from '../../components/activity/RecordView'
import useRecordView from '../../hooks/useRecordView'
import { RECORD_VIEWS } from '../../api/recordView'
import { EMPLOYEE_EXPORT_COLUMNS, RECORD_VIEWS, downloadRecordView } from '../../api/recordView'
import {
EMPLOYEE_RICH_COLUMNS,
EMPLOYMENT_FILTERS,
@ -12,13 +12,14 @@ import {
} from '../../api/employees'
import { withRichColumns } from '../../api/richColumns'
import { useAuth } from '../../store/AuthContext'
import { todayISO } from '../../utils/format'
import AddEmployeeModal from './AddEmployeeModal'
const VIEW = RECORD_VIEWS.employees
export default function EmployeeList() {
const navigate = useNavigate()
const { logout } = useAuth()
const { logout, user } = useAuth()
// The table misses Update fields Zino doesn't map and knows nothing of the
// workflow state, so this fetcher overlays both and filters Active/Inactive.
// Related columns (name and code, email and phone…) are grouped into one.
@ -29,8 +30,34 @@ export default function EmployeeList() {
})
const [adding, setAdding] = useState(false)
const [opening, setOpening] = useState(false)
const [exporting, setExporting] = useState(false)
const [notice, setNotice] = useState(null)
/** Exports every employee (all pages) from the employee view. */
async function handleExport() {
setExporting(true)
try {
const { emailed } = await downloadRecordView(VIEW.templateUid, EMPLOYEE_EXPORT_COLUMNS, {
fileName: `employees-${todayISO()}.xlsx`,
})
if (emailed) {
setNotice({
tone: 'success',
text: `Export started. The file will be emailed to ${user?.email || 'you'}.`,
})
}
} catch (err) {
if (err?.status === 401) {
logout()
navigate('/login', { replace: true })
return
}
setNotice({ tone: 'danger', text: err?.message || 'Could not download the employees.' })
} finally {
setExporting(false)
}
}
/** `values` is keyed by Zino field id — see EMPLOYEE_FIELDS in api/employees.js. */
function handleCreated(values) {
const name = [values.field_1, values.field_34].filter(Boolean).join(' ').trim()
@ -78,9 +105,14 @@ export default function EmployeeList() {
title="Employees"
subtitle="All staff on record, read live from the employee workflow."
actions={
<Button variant="primary" icon="plus" onClick={() => setAdding(true)}>
Add Employee
</Button>
<>
<Button icon="download" onClick={handleExport} disabled={exporting}>
{exporting ? 'Exporting…' : 'Export'}
</Button>
<Button variant="primary" icon="plus" onClick={() => setAdding(true)}>
Add Employee
</Button>
</>
}
/>

View File

@ -1197,6 +1197,17 @@ button {
color: var(--text-muted);
}
.pager__select {
margin-left: 4px;
padding: 4px 8px;
font-size: 12.5px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
}
.cell-clamp {
max-width: 220px;
}

View File

@ -4,6 +4,9 @@ import react from "@vitejs/plugin-react";
export default defineConfig(({ command }) => ({
// Relative base: frontgen places one artifact at any mount path and injects <base href>.
base: "./",
// Not "assets": the app has an /assets route, and nginx answers 403 for a real
// folder with no index.html, so reloading /asset-management/assets/ broke.
build: { assetsDir: "static" },
plugins: [react()],
server: {
port: 5173,