Compare commits

..

No commits in common. "main" and "feat/assignment-list-employee-data" have entirely different histories.

14 changed files with 116 additions and 838 deletions

View File

@ -68,96 +68,6 @@ 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.
* *
@ -184,13 +94,6 @@ 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
} }
@ -203,43 +106,6 @@ 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,13 +1,5 @@
import { APP_API_BASE_URL, apiFetch } from './client' import { APP_API_BASE_URL, apiFetch } from './client'
import { import { buildActivityData, explainActivityError, instanceState, uploadActivityFile } from './activityForm'
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'
@ -144,8 +136,6 @@ export const ASSET_FIELDS = [
}, },
{ id: 'field_59', label: 'Asset Brand', type: 'text' }, { id: 'field_59', label: 'Asset Brand', type: 'text' },
{ id: 'field_61', label: 'Asset Location', 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. */ /** INIT's photo field, whose uploads the runtime always accepts. */
@ -186,13 +176,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". Sent as { id, first_name } — see employeeLookup. * data". The API sample shows only { id }; it is sent as { id, first_name }
* because the runtime checks lookups against their storage columns.
*/ */
export const ASSET_OWNER_FIELD = 'field_51' export const ASSET_OWNER_FIELD = 'field_51'
@ -242,10 +229,9 @@ export const ASSET_ASSIGN_ACTIVITY_ID = 'd18815a4-4e74-480d-a943-67dce894412f'
/** Fields the user fills in. */ /** Fields the user fills in. */
export const ASSET_ASSIGN_FIELDS = [ export const ASSET_ASSIGN_FIELDS = [
{ id: 'field_1', label: 'Assigned date', type: 'date' }, { id: 'field_1', label: 'Assigned date', type: 'date' },
// Assigned by (field_5) is left out of the form on purpose: it is a select in // The builder has no options for Assigned by, so the form offers the active
// the builder and the runtime rejects any value not in its options ("Assigned // employees' names (`employeeOptions`, filled in by the dialog).
// by must be one of the listed options"), so employee names can't go there. { id: 'field_5', label: 'Assigned by', type: 'select', options: [], employeeOptions: true },
// Add it back, with `employeeOptions: true`, once the builder field is text.
{ id: 'field_6', label: 'Remarks', type: 'longtext' }, { id: 'field_6', label: 'Remarks', type: 'longtext' },
{ {
id: 'field_21', id: 'field_21',
@ -265,9 +251,11 @@ export const ASSET_ASSIGN_FIELDS = [
] ]
/** /**
* "Employee data": a lookup into the employee table, sent as { id, first_name } * "Employee data": a lookup into the employee table. The runtime checks it
* like every employee lookup (see employeeLookup). It is picked from a list * against the lookup's storage columns (a Retired attempt failed with
* of active employees, so it is sent alongside the form data. * "missing required storage column 'first_name'"), so it carries
* { id, first_name }, not the { id, name } the API sample shows. It is picked
* from a list of active employees, so it is sent alongside the form data.
*/ */
export const ASSIGN_EMPLOYEE_FIELD = 'field_22' export const ASSIGN_EMPLOYEE_FIELD = 'field_22'
@ -378,17 +366,12 @@ export const ASSET_RETURN_FIELDS = [
otherSlot: 'Other', otherSlot: 'Other',
}, },
{ {
// The builder spells it "Recived by". Its option values aren't known here // UNCONFIRMED: the sample cURL shows only "option_value". Copy this field's
// (the sample cURL shows only "option_value"), and guessed names were // label and option values from the builder exactly, or the runtime rejects it.
// 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: 'Recived by', label: 'Received by',
formLabel: 'Received by',
type: 'select', type: 'select',
options: [], options: ['Praveen', 'Akshay', 'Prasad'].map((name) => ({ label: name, value: name })),
unconfirmedOptions: true,
}, },
] ]
@ -438,17 +421,8 @@ export const ASSET_LOST_FIELDS = [
/** "Last owner": a lookup into the employee table, like "Employee data". */ /** "Last owner": a lookup into the employee table, like "Employee data". */
export const LOST_EMPLOYEE_FIELD = 'field_35' export const LOST_EMPLOYEE_FIELD = 'field_35'
/** /** An employee lookup value, in the shape the runtime validates. */
* An employee lookup value, in the shape the runtime validates: { id, const employeeLookup = (employee) => ({ id: employee.id, first_name: employee.first_name })
* 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. * Run a later activity (ASSIGNED, RETURNED, …) on an existing asset instance.
@ -592,23 +566,21 @@ const EDITED_FIELDS = [
* with the asset's instance_id. * with the asset's instance_id.
* *
* POST {APP_API_BASE_URL}/app/{APP_ID}/activity * POST {APP_API_BASE_URL}/app/{APP_ID}/activity
* { activity_id, workflow_uuid, instance_id, data: { field_80 … field_83 } } * { activity_id, workflow_uuid, instance_id, data: { field_73 … field_76 } }
* { activity_id, workflow_uuid, instance_id, data: { field_77 … field_79 } } * { activity_id, workflow_uuid, instance_id, data: { field_77 … field_79 } }
* *
* PLACEHOLDER ids: add an "Under Repair" state and these two activities, with * 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 * these fields, in the Zino builder, deploy, and put their ids here. Until
* then, running them fails. (The send fields were field_73 … field_76, but the * then, running them fails.
* 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' export const ASSET_REPAIR_ACTIVITY_ID = 'TODO-send-for-repair-activity-id'
/** Send for Repair fields. */ /** Send for Repair fields. */
export const ASSET_REPAIR_FIELDS = [ export const ASSET_REPAIR_FIELDS = [
{ id: 'field_80', label: 'Repair date', type: 'date' }, { id: 'field_73', label: 'Repair date', type: 'date' },
{ id: 'field_81', label: 'Issue details', type: 'longtext' }, { id: 'field_74', label: 'Issue details', type: 'longtext' },
{ id: 'field_82', label: 'Repair vendor', type: 'text' }, { id: 'field_75', label: 'Repair vendor', type: 'text' },
{ id: 'field_83', label: 'Estimated cost', type: 'number' }, { id: 'field_76', label: 'Estimated cost', type: 'number' },
] ]
export const ASSET_REPAIRED_ACTIVITY_ID = 'TODO-repair-completed-activity-id' export const ASSET_REPAIRED_ACTIVITY_ID = 'TODO-repair-completed-activity-id'
@ -645,47 +617,26 @@ 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. `row` and * (its latest edit, else its INIT value), as the form holds it.
* `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(config, row) { export function assetEditValues(row) {
const columns = config?.fields ?? [] const norm = (v) => String(v).trim().toLowerCase()
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 keys = [ const raw = present(row?.[f.id]) ? row[f.id] : row?.[initValueKey(init, row ?? {})]
f.id, if (!present(raw)) return [f.id, '']
...EDITED_FIELDS.filter((e) => e.initId === init.id && e.id !== f.id).map((e) => e.id), if (f.type === 'image') return [f.id, savedPhotos(f, raw)]
init.id, if (f.type === 'date') return [f.id, toISODate(raw)]
...columns.filter((c) => hasLabel(init, c.output_label)).map((c) => c.field_key), if (f.type === 'select') {
initValueKey(init, row ?? {}), const hit = f.options.find((o) => norm(o.value) === norm(raw) || norm(o.label) === norm(raw))
] return [f.id, hit ? hit.value : String(raw)]
const raw = keys.map((k) => row?.[k]).find(present) }
return [f.id, present(raw) ? editFormValue(f, raw) : ''] return [f.id, String(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
@ -732,16 +683,11 @@ 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. */
* The employee-table id of the asset's current owner (its latest edit, else export function assetOwnerId(row) {
* 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)
const keys = [initValueKey(owner, edited), ...ownerKeys(config)] return lookupId(edited, initValueKey(owner, edited))
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. */
@ -859,11 +805,7 @@ 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,
@ -892,13 +834,6 @@ 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],
@ -910,7 +845,6 @@ 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'] }),
@ -920,7 +854,6 @@ 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'] }),
@ -959,20 +892,13 @@ 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: own }, people, others] = await Promise.all([ const [{ config, rows }, people] = 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
@ -1014,103 +940,14 @@ 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: { ...config, fields: [...fields, ...added, ...imageColumns] },
...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 })
@ -1219,7 +1056,7 @@ export const ASSET_RICH_COLUMNS = [
title: ['Warranty Status'], title: ['Warranty Status'],
subtitle: [ subtitle: [
{ label: 'Warranty Start date', prefix: 'From' }, { label: 'Warranty Start date', prefix: 'From' },
'Warranty End date', { label: 'Warranty End date', prefix: 'to' },
], ],
}, },
] ]
@ -1255,75 +1092,12 @@ 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 table = await fetchAssetTableIndex() const rows = Array.isArray(res?.data) ? res.data : []
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
@ -1388,18 +1162,8 @@ 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.map(withInit) : [] const rows = Array.isArray(res?.data) ? res.data : []
return { return {
...res, ...res,
config: { ...res?.config, fields: [...formColumns, ...otherColumns] }, config: { ...res?.config, fields: [...formColumns, ...otherColumns] },
@ -1680,13 +1444,13 @@ export function assetEvents(config, row) {
) )
} }
if (present(val('field_80'))) { if (present(val('field_73'))) {
add( add(
HISTORY_ACTION.REPAIR, HISTORY_ACTION.REPAIR,
val('field_80'), val('field_73'),
'', '',
joinParts(val('field_82'), present(val('field_83')) && `Estimate ${val('field_83')}`), joinParts(val('field_75'), present(val('field_76')) && `Estimate ${val('field_76')}`),
val('field_81'), val('field_74'),
) )
} }
@ -1792,22 +1556,19 @@ 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 — and the asset table's Add Asset values where neither has * no column for. `config` is the record view's column config, so the details
* one (see withTableValues). `config` is the record view's column config, so * page shows the same fields as the asset list.
* 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.
* `ownerName` is that name. * @returns {Promise<{config: object|null, row: object|null, ownerId?: number|string|null, instanceProblem?: string}>}
* @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, table] = await Promise.all([ const [{ config, rows }, detail] = 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
@ -1821,16 +1582,6 @@ 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)
@ -1838,18 +1589,7 @@ 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)])
const owner = ASSET_FORM_FIELDS.find((d) => d.id === ASSET_OWNER_FIELD) // Read before withOwnerNames swaps the owner lookup for a name.
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 { return { config, row: named, ownerId: assetOwnerId(row), instanceProblem: lookup.problem }
config,
row: named,
ownerId: assetOwnerId(row, config),
ownerName: ownerName?.trim() ?? '',
instanceProblem: lookup.problem,
}
} }

View File

@ -223,22 +223,6 @@ export async function fetchEmployeeInstance(instanceId) {
return rows.find((r) => String(r.instance_id) === String(instanceId)) || null 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. * Show the latest saved values on the details page.
* *
@ -258,13 +242,7 @@ export async function withLatestInstanceValues(view, instanceId) {
} }
if (!instance) return view if (!instance) return view
let tableRow = null const record = { ...view.record, ...instance }
try {
tableRow = await fetchEmployeeTableRow(instance)
} catch {
// Keep the instance values.
}
const record = { ...view.record, ...instance, ...updateValuesFromTable(tableRow) }
const latest = new Map( const latest = new Map(
[...latestByLabel(ALL_EMPLOYEE_FIELDS, record)].map(([label, v]) => [normLabel(label), v]), [...latestByLabel(ALL_EMPLOYEE_FIELDS, record)].map(([label, v]) => [normLabel(label), v]),
) )
@ -357,10 +335,10 @@ export const EMPLOYEE_RICH_COLUMNS = [
{ key: 'contact', label: 'Contact', title: ['email'], subtitle: ['phone'] }, { key: 'contact', label: 'Contact', title: ['email'], subtitle: ['phone'] },
{ key: 'role', label: 'Role', title: ['designation'], subtitle: ['department'] }, { key: 'role', label: 'Role', title: ['designation'], subtitle: ['department'] },
{ {
key: 'date', key: 'type',
label: 'Date', label: 'Type',
title: [{ label: 'joining_date', prefix: 'Joined' }], title: ['Employee type'],
subtitle: ['Employee type'], subtitle: [{ label: 'joining_date', prefix: 'Joined' }],
}, },
] ]
@ -390,11 +368,7 @@ export async function fetchEmployeeListPage(templateUid, query = {}) {
.map((row) => ({ ...row, employment: isEndState(row) ? 'Inactive' : 'Active' })) .map((row) => ({ ...row, employment: isEndState(row) ? 'Inactive' : 'Active' }))
.filter((row) => filter === 'all' || row.employment.toLowerCase() === filter) .filter((row) => filter === 'all' || row.employment.toLowerCase() === filter)
// The view's own "status" column isn't wanted in the list — the derived const fields = config?.fields ?? []
// 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)) const totalPages = Math.max(1, Math.ceil(data.length / limit))
return { return {
config: { ...config, fields: [...fields, EMPLOYMENT_COLUMN] }, config: { ...config, fields: [...fields, EMPLOYMENT_COLUMN] },

View File

@ -1,4 +1,4 @@
import { APP_API_BASE_URL, ApiError, apiFetch, getStoredToken } from './client' import { APP_API_BASE_URL, apiFetch } from './client'
/** /**
* Record view data source. * Record view data source.
@ -33,14 +33,6 @@ 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.
@ -104,137 +96,6 @@ export async function fetchAllRecordRows(templateUid, query = {}) {
return { config, rows } 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. */ /** 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']) const DATE_TYPES = new Set(['date', 'timestamp without time zone', 'timestamp with time zone'])

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { import {
AssetStateBadge, AssetStateBadge,
Avatar, Avatar,
Button,
EmployeeStatusBadge, EmployeeStatusBadge,
EmptyState, EmptyState,
HistoryActionBadge, HistoryActionBadge,
@ -16,16 +17,11 @@ import { FileLinks, imageSources } from './FieldValue'
/** /**
* Renders a record view straight from the backend response: the columns are * Renders a record view straight from the backend response: the columns are
* whatever `config.fields` returns, in that order, with the backend's own * whatever `config.fields` returns, in that order, with the backend's own
* labels. Sorting and search are handled by the server; instead of page * labels. Paging, sorting and search are handled by the server.
* 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 * `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) * `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`. * 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({ export default function RecordView({
fields, fields,
rows, rows,
@ -33,7 +29,7 @@ export default function RecordView({
loading, loading,
error, error,
query, query,
setLimit, setPage,
setSearch, setSearch,
setSort, setSort,
rowKey = 'instance_id', rowKey = 'instance_id',
@ -50,8 +46,6 @@ export default function RecordView({
return () => clearTimeout(id) return () => clearTimeout(id)
}, [term, query.search, setSearch]) }, [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. // A rich column's fields stay searchable.
const searchable = fields const searchable = fields
.flatMap((f) => (f.data_type === 'rich' ? [...f.title, ...f.subtitle] : [f])) .flatMap((f) => (f.data_type === 'rich' ? [...f.title, ...f.subtitle] : [f]))
@ -65,20 +59,20 @@ export default function RecordView({
setSort(sortKey(field), dir) setSort(sortKey(field), dir)
} }
const page = pagination?.page ?? 1
const totalPages = pagination?.total_pages ?? 1
const total = pagination?.total_count ?? rows.length const total = pagination?.total_count ?? rows.length
return ( return (
<> <>
<Toolbar count={total} countLabel="records"> <Toolbar count={total} countLabel="records">
{searchable.length > 0 && (
<SearchInput <SearchInput
value={term} value={term}
onChange={setTerm} onChange={setTerm}
placeholder={ placeholder={`Search by ${searchable.map((f) => displayLabel(f.output_label)).join(', ')}…`}
searchable.length > 0
? `Search by ${searchable.map((f) => displayLabel(f.output_label)).join(', ')}…`
: 'Search…'
}
/> />
)}
{toolbar} {toolbar}
{loading && <span className="toolbar__count">Loading…</span>} {loading && <span className="toolbar__count">Loading…</span>}
</Toolbar> </Toolbar>
@ -145,26 +139,26 @@ export default function RecordView({
</table> </table>
</div> </div>
{total > 0 && ( {totalPages > 1 && (
<div className="pager"> <div className="pager">
<label className="pager__label"> <Button
Rows{' '} size="sm"
<select icon="chevronLeft"
className="pager__select" disabled={page <= 1 || loading}
value={query.limit} onClick={() => setPage(page - 1)}
disabled={loading}
onChange={(e) => setLimit(Number(e.target.value))}
> >
{ROW_OPTIONS.map((n) => ( Previous
<option key={n} value={n}> </Button>
{n}
</option>
))}
</select>
</label>
<span className="pager__label"> <span className="pager__label">
Showing {Math.min(rows.length, total)} of {total} records Page {page} of {totalPages}
</span> </span>
<Button
size="sm"
disabled={page >= totalPages || loading}
onClick={() => setPage(page + 1)}
>
Next
</Button>
</div> </div>
)} )}
</> </>

View File

@ -24,7 +24,6 @@ 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', 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', 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', 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 }) { export default function Icon({ name, size = 16, strokeWidth = 1.8, ...rest }) {

View File

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

View File

@ -12,18 +12,10 @@ import {
import { emptyValues, validateActivity } from '../../api/activityForm' import { emptyValues, validateActivity } from '../../api/activityForm'
import { useAuth } from '../../store/AuthContext' 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. // Asset Owner sits after Condition, as in the builder.
const OWNER_AFTER = FORM_FIELDS.findIndex((f) => f.id === 'field_47') + 1 const OWNER_AFTER = ASSET_FIELDS.findIndex((f) => f.id === 'field_47') + 1
const FIELDS_BEFORE_OWNER = FORM_FIELDS.slice(0, OWNER_AFTER) const FIELDS_BEFORE_OWNER = ASSET_FIELDS.slice(0, OWNER_AFTER)
const FIELDS_AFTER_OWNER = FORM_FIELDS.slice(OWNER_AFTER) const FIELDS_AFTER_OWNER = ASSET_FIELDS.slice(OWNER_AFTER)
const upload = (fieldId, file) => uploadAssetFile(ASSET_INIT_ACTIVITY_ID, fieldId, file) const upload = (fieldId, file) => uploadAssetFile(ASSET_INIT_ACTIVITY_ID, fieldId, file)

View File

@ -3,13 +3,10 @@ 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,
@ -18,7 +15,6 @@ import {
assignAsset, assignAsset,
completeAssetRepair, completeAssetRepair,
editAsset, editAsset,
fetchAssetActivityFieldDefs,
fetchAssignableEmployees, fetchAssignableEmployees,
reassignAsset, reassignAsset,
reportAssetLost, reportAssetLost,
@ -26,7 +22,7 @@ import {
sendAssetForRepair, sendAssetForRepair,
uploadAssetFile, uploadAssetFile,
} from '../../api/assets' } from '../../api/assets'
import { emptyValues, matchOption, validateActivity, withBuilderOptions } from '../../api/activityForm' import { emptyValues, validateActivity } from '../../api/activityForm'
import { useAuth } from '../../store/AuthContext' import { useAuth } from '../../store/AuthContext'
/** /**
@ -35,14 +31,12 @@ 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. `activityId` tags photo uploads, and its select * that can't be undone.
* 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.',
@ -54,7 +48,6 @@ 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.',
@ -79,7 +72,6 @@ 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,
@ -129,8 +121,7 @@ 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.
*/ */
@ -140,7 +131,6 @@ export default function AssetActivityModal({
instanceId, instanceId,
instanceProblem, instanceProblem,
defaultEmployeeId, defaultEmployeeId,
defaultEmployeeName,
initialValues, initialValues,
onClose, onClose,
onDone, onDone,
@ -154,9 +144,6 @@ 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('')
@ -173,34 +160,14 @@ 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),
@ -222,27 +189,7 @@ export default function AssetActivityModal({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [open, activity, hasEmployee, defaultEmployeeId, defaultEmployeeName, logout, navigate, user]) }, [open, activity, hasEmployee, defaultEmployeeId, 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
@ -266,7 +213,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(fields, values) const nextErrors = validateActivity(activity.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
@ -274,10 +221,7 @@ export default function AssetActivityModal({
setSubmitting(true) setSubmitting(true)
setServerError('') setServerError('')
try { try {
// Only what the form shows is sent; a field left out has no options the runtime accepts. const response = await activity.run(instanceId, values, employee)
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) {
@ -297,16 +241,9 @@ 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).
// A pre-filled select value that's in none of the options (the asset was const fields = activity.fields.map((f) =>
// saved with it) is still offered, so the form shows it instead of blank. f.employeeOptions ? { ...f, options: employees.map((emp) => ({ label: emp.name, value: emp.name })) } : f,
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
@ -325,7 +262,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 || loadingOptions} disabled={submitting || loadingEmployees}
> >
{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.formLabel ?? f.label, label: f.label,
type: f.type, type: f.type,
slots: f.slots, slots: f.slots,
otherSlot: f.otherSlot, otherSlot: f.otherSlot,
@ -412,9 +412,7 @@ export default function AssetDetails() {
? current.id ? current.id
: null : null
} }
// Picks the owner by name when the view gives no employee id. initialValues={activity === 'edit' ? assetEditValues(row) : undefined}
defaultEmployeeName={activity === 'edit' ? data.ownerName : undefined}
initialValues={activity === 'edit' ? assetEditValues(config, row) : undefined}
onClose={() => setActivity(null)} onClose={() => setActivity(null)}
onDone={handleDone} onDone={handleDone}
/> />

View File

@ -3,10 +3,8 @@ import { useNavigate } from 'react-router-dom'
import { Button, Card, Icon, PageHeader } from '../../components/ui' import { Button, Card, Icon, PageHeader } from '../../components/ui'
import RecordView from '../../components/activity/RecordView' import RecordView from '../../components/activity/RecordView'
import useRecordView from '../../hooks/useRecordView' import useRecordView from '../../hooks/useRecordView'
import { ASSET_EXPORT_COLUMNS, RECORD_VIEWS, downloadRecordView } from '../../api/recordView' import { RECORD_VIEWS } from '../../api/recordView'
import { assetRowId, withAssetListDisplay } from '../../api/assets' import { assetRowId, withAssetListDisplay } from '../../api/assets'
import { useAuth } from '../../store/AuthContext'
import { todayISO } from '../../utils/format'
import AddAssetModal from './AddAssetModal' import AddAssetModal from './AddAssetModal'
const VIEW = RECORD_VIEWS.assets const VIEW = RECORD_VIEWS.assets
@ -22,38 +20,9 @@ export default function AssetList() {
sortBy: 'idgen_2', sortBy: 'idgen_2',
sortDir: 'desc', sortDir: 'desc',
}) })
const { logout, user } = useAuth()
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [exporting, setExporting] = useState(false)
const [notice, setNotice] = useState(null) 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. */ /** `values` is keyed by Zino field id — see ASSET_FIELDS in api/assets.js. */
function handleCreated(values) { function handleCreated(values) {
setNotice({ setNotice({
@ -70,14 +39,9 @@ export default function AssetList() {
title="Assets" title="Assets"
subtitle="Every device in the inventory, read live from the asset workflow." subtitle="Every device in the inventory, read live from the asset workflow."
actions={ actions={
<>
<Button icon="download" onClick={handleExport} disabled={exporting}>
{exporting ? 'Downloading…' : 'Export'}
</Button>
<Button variant="primary" icon="plus" onClick={() => setAdding(true)}> <Button variant="primary" icon="plus" onClick={() => setAdding(true)}>
Add Asset Add Asset
</Button> </Button>
</>
} }
/> />

View File

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

View File

@ -1197,17 +1197,6 @@ button {
color: var(--text-muted); 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 { .cell-clamp {
max-width: 220px; max-width: 220px;
} }

View File

@ -4,9 +4,6 @@ import react from "@vitejs/plugin-react";
export default defineConfig(({ command }) => ({ export default defineConfig(({ command }) => ({
// Relative base: frontgen places one artifact at any mount path and injects <base href>. // Relative base: frontgen places one artifact at any mount path and injects <base href>.
base: "./", 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()], plugins: [react()],
server: { server: {
port: 5173, port: 5173,