Compare commits
No commits in common. "main" and "feat/assignment-list-employee-data" have entirely different histories.
main
...
feat/assig
@ -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, '']))
|
||||||
|
|||||||
@ -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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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] },
|
||||||
|
|||||||
@ -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'])
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
<SearchInput
|
{searchable.length > 0 && (
|
||||||
value={term}
|
<SearchInput
|
||||||
onChange={setTerm}
|
value={term}
|
||||||
placeholder={
|
onChange={setTerm}
|
||||||
searchable.length > 0
|
placeholder={`Search by ${searchable.map((f) => displayLabel(f.output_label)).join(', ')}…`}
|
||||||
? `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))}
|
Previous
|
||||||
>
|
</Button>
|
||||||
{ROW_OPTIONS.map((n) => (
|
|
||||||
<option key={n} value={n}>
|
|
||||||
{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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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 }) {
|
||||||
|
|||||||
@ -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 })),
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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 variant="primary" icon="plus" onClick={() => setAdding(true)}>
|
||||||
<Button icon="download" onClick={handleExport} disabled={exporting}>
|
Add Asset
|
||||||
{exporting ? 'Downloading…' : 'Export'}
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button variant="primary" icon="plus" onClick={() => setAdding(true)}>
|
|
||||||
Add Asset
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -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 variant="primary" icon="plus" onClick={() => setAdding(true)}>
|
||||||
<Button icon="download" onClick={handleExport} disabled={exporting}>
|
Add Employee
|
||||||
{exporting ? 'Exporting…' : 'Export'}
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button variant="primary" icon="plus" onClick={() => setAdding(true)}>
|
|
||||||
Add Employee
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user