Edit form - Look up each value under every key the details page reads: the Edit field id, the INIT field id, the view's column for the field, and a key named after its label. - Match select values by value or label in any case or spacing, again once the builder's options load; keep an unmatched saved value as an option so it isn't shown blank. - Read dates in ISO, dd-mm-yyyy and epoch form, and costs with formatting, into what the inputs hold. - Pick Asset Owner by name when the view gives no employee id. Asset values - Read the INIT activity's record view (asset table, de18263e-…) and fill in each asset's Add Asset values where the workflow views have none, matched by Asset ID, serial number or asset identifier. Edited values still win. - Keep detail view values under their field ids, and show an Edit column's INIT value until the asset is edited. Activity forms and lists - Take select options from the builder's activity fields; leave out a field whose options can't be confirmed (Recived by). - Show which asset each assignment and return row is, filled in from the other asset workflow views and the detail view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
440 lines
15 KiB
JavaScript
440 lines
15 KiB
JavaScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { useNavigate, useParams } from 'react-router-dom'
|
|
import {
|
|
AssetStateBadge,
|
|
BackLink,
|
|
Button,
|
|
Card,
|
|
EmptyState,
|
|
Icon,
|
|
InfoList,
|
|
InfoRow,
|
|
PageHeader,
|
|
} from '../../components/ui'
|
|
import FieldValue, { formatMeta } from '../../components/activity/FieldValue'
|
|
import { displayLabel } from '../../utils/format'
|
|
import Timeline from '../../components/common/Timeline'
|
|
import { instanceState, isEndState } from '../../api/activityForm'
|
|
import {
|
|
ASSET_ASSIGN_FIELDS,
|
|
ASSET_LOST_FIELDS,
|
|
ASSET_REASSIGN_FIELDS,
|
|
ASSET_REPAIRED_FIELDS,
|
|
ASSET_REPAIR_FIELDS,
|
|
ASSET_RETURN_FIELDS,
|
|
ASSET_STATE,
|
|
ASSIGN_EMPLOYEE_FIELD,
|
|
LOST_EMPLOYEE_FIELD,
|
|
REASSIGN_EMPLOYEE_FIELD,
|
|
RETURN_EMPLOYEE_FIELD,
|
|
assetCode,
|
|
assetEditValues,
|
|
assetEvents,
|
|
assetFieldDef,
|
|
assetInitValue,
|
|
currentAssignment,
|
|
fetchAssetInstance,
|
|
isAssetState,
|
|
lookupName,
|
|
optionLabel,
|
|
rowFieldValue,
|
|
withAssetFormColumns,
|
|
} from '../../api/assets'
|
|
import { useAuth } from '../../store/AuthContext'
|
|
import AssetActivityModal from './AssetActivityModal'
|
|
|
|
/** Keys shown as record info rather than as asset fields. */
|
|
const META_KEY = /^(instance_id|current_state_name|(created|updated|modified)_\w+)$/i
|
|
|
|
/**
|
|
* Asset details: the asset's INIT values, its latest assignment and return,
|
|
* and its workflow state. There is no asset detail view template, so the record comes
|
|
* from the asset record view (see fetchAssetInstance).
|
|
* Route: /assets/:assetId — the asset's table id (not its workflow instance id).
|
|
*/
|
|
export default function AssetDetails() {
|
|
const { assetId } = useParams()
|
|
const navigate = useNavigate()
|
|
const { logout } = useAuth()
|
|
|
|
const [data, setData] = useState(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
// The activity dialog that's open: a key of AssetActivityModal's ACTIVITIES, or null.
|
|
const [activity, setActivity] = useState(null)
|
|
const [notice, setNotice] = useState(null)
|
|
|
|
// Guards against a slow earlier request overwriting a newer one.
|
|
const requestId = useRef(0)
|
|
|
|
const load = useCallback(async () => {
|
|
const id = ++requestId.current
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
const res = await fetchAssetInstance(assetId)
|
|
if (id !== requestId.current) return
|
|
if (!res.row) setError('No asset in the workflow has this id.')
|
|
setData(res.row ? res : null)
|
|
} catch (err) {
|
|
if (id !== requestId.current) return
|
|
if (err?.status === 401) {
|
|
logout()
|
|
navigate('/login', { replace: true })
|
|
return
|
|
}
|
|
setError(err?.message || 'Could not load this asset.')
|
|
setData(null)
|
|
} finally {
|
|
if (id === requestId.current) setLoading(false)
|
|
}
|
|
}, [assetId, logout, navigate])
|
|
|
|
useEffect(() => {
|
|
load()
|
|
}, [load])
|
|
|
|
const back = () => navigate(-1)
|
|
|
|
if (loading && !data) {
|
|
return (
|
|
<>
|
|
<BackLink onClick={back}>Back</BackLink>
|
|
<Card>
|
|
<EmptyState icon="⏳" title="Loading asset" text="Fetching from the workflow…" />
|
|
</Card>
|
|
</>
|
|
)
|
|
}
|
|
|
|
if (error || !data) {
|
|
return (
|
|
<>
|
|
<BackLink onClick={back}>Back</BackLink>
|
|
<Card>
|
|
<EmptyState
|
|
icon="🙁"
|
|
title="Could not load this asset"
|
|
text={error || 'The asset view returned nothing for this record.'}
|
|
action={
|
|
<div className="row">
|
|
<Button onClick={load}>Try again</Button>
|
|
<Button variant="primary" onClick={() => navigate('/assets')}>
|
|
Back to Assets
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
</Card>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const { config, row } = data
|
|
|
|
// Every Add Asset field, in the form's order and with its labels, then the
|
|
// view's other columns.
|
|
const withForm = withAssetFormColumns({ config, data: [row] })
|
|
const formRow = withForm.data[0]
|
|
const assetFields = (withForm.config?.fields ?? [])
|
|
.filter((f) => f.field_key !== 'id' && !META_KEY.test(f.field_key))
|
|
.map((f) => {
|
|
const label = String(f.output_label || f.field_key).trim()
|
|
const def = assetFieldDef(f.field_key, label)
|
|
return {
|
|
key: f.field_key,
|
|
label,
|
|
type: f.data_type,
|
|
slots: f.slots ?? def?.slots,
|
|
otherSlot: f.otherSlot ?? def?.otherSlot,
|
|
// A select shows its option label ("In use"), not its value ("in_use").
|
|
value: optionLabel(def, formRow[f.field_key]),
|
|
}
|
|
})
|
|
|
|
const code = assetCode(config, row)
|
|
const title = code || 'Asset'
|
|
const subtitle = assetInitValue(config, row, 'Model')
|
|
// Newest first, like the Asset History page.
|
|
const history = assetEvents(config, row)
|
|
.reverse()
|
|
.map((e) => ({
|
|
...e,
|
|
title: e.employee ? `${e.action} · ${e.employee}` : e.action,
|
|
meta: e.meta || undefined,
|
|
}))
|
|
|
|
const state = instanceState(row)
|
|
const isAssigned = isAssetState(row, ASSET_STATE.ASSIGNED)
|
|
const isAvailable = isAssetState(row, ASSET_STATE.AVAILABLE)
|
|
const isUnderRepair = isAssetState(row, ASSET_STATE.UNDER_REPAIR)
|
|
// ASSIGNED and Send for Repair run in Available; Reassigned in Assigned; Lost
|
|
// in either; Repair Completed in Under Repair; Edit in all three. RETURNED
|
|
// runs in any state but Closed, and the runtime says if the state doesn't
|
|
// allow it. If the view sends no state, let the runtime decide rather than
|
|
// hiding the buttons.
|
|
const canAssign = !state || isAvailable
|
|
const canReassign = !state || isAssigned
|
|
const canReturn = !isAssetState(row, ASSET_STATE.CLOSED) && !isEndState(row)
|
|
const canReportLost = !state || isAvailable || isAssigned
|
|
const canSendForRepair = !state || isAvailable
|
|
const canCompleteRepair = !state || isUnderRepair
|
|
const canEdit = !state || isAvailable || isAssigned || isUnderRepair
|
|
|
|
// Assigned or reassigned, whichever is newer, is who has it now.
|
|
const current = currentAssignment(row)
|
|
const employee = current.name
|
|
// Photos show like the asset's own: captioned by slot (Front, Back…) and
|
|
// found by field id or by the view's column for them. Other values go by id
|
|
// only — several activities share labels like "Remarks".
|
|
const toFields = (defs) =>
|
|
defs.map((f) => ({
|
|
key: f.id,
|
|
label: f.formLabel ?? f.label,
|
|
type: f.type,
|
|
slots: f.slots,
|
|
otherSlot: f.otherSlot,
|
|
value: f.type === 'image' ? rowFieldValue(f, row) : row[f.id],
|
|
}))
|
|
const assignmentFields = [
|
|
{ key: ASSIGN_EMPLOYEE_FIELD, label: 'Employee data', type: 'text', value: lookupName(row, ASSIGN_EMPLOYEE_FIELD) },
|
|
...toFields(ASSET_ASSIGN_FIELDS),
|
|
]
|
|
const reassignmentFields = [
|
|
{ key: REASSIGN_EMPLOYEE_FIELD, label: 'Employee data', type: 'text', value: lookupName(row, REASSIGN_EMPLOYEE_FIELD) },
|
|
...toFields(ASSET_REASSIGN_FIELDS),
|
|
]
|
|
const returnFields = [
|
|
{ key: RETURN_EMPLOYEE_FIELD, label: 'Returned by', type: 'text', value: lookupName(row, RETURN_EMPLOYEE_FIELD) },
|
|
...toFields(ASSET_RETURN_FIELDS),
|
|
]
|
|
const repairFields = [...toFields(ASSET_REPAIR_FIELDS), ...toFields(ASSET_REPAIRED_FIELDS)]
|
|
const lostFields = [
|
|
{ key: LOST_EMPLOYEE_FIELD, label: 'Last owner', type: 'text', value: lookupName(row, LOST_EMPLOYEE_FIELD) },
|
|
...toFields(ASSET_LOST_FIELDS),
|
|
]
|
|
const hasValues = (fields) => fields.some((f) => f.value != null && f.value !== '')
|
|
|
|
const meta = Object.keys(row)
|
|
.filter((k) => META_KEY.test(k) && row[k] != null && row[k] !== '' && typeof row[k] !== 'object')
|
|
.map((k) => ({ key: k, label: k.replace(/_/g, ' '), value: row[k] }))
|
|
|
|
function openActivity(kind) {
|
|
setNotice(null)
|
|
setActivity(kind)
|
|
}
|
|
|
|
/** `emp` is the employee picked in the dialog; null when Lost ran without one. */
|
|
function handleDone(emp) {
|
|
const text = {
|
|
assign: `Asset assigned to ${emp?.name}.`,
|
|
reassign: `Asset reassigned to ${emp?.name}.`,
|
|
return: `Asset returned by ${emp?.name}.`,
|
|
lost: 'Asset was reported lost and closed.',
|
|
edit: 'Asset details saved.',
|
|
repair: 'Asset sent for repair.',
|
|
repaired: 'Repair completed. The asset is available again.',
|
|
}[activity]
|
|
setNotice({ tone: 'success', text })
|
|
load()
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<BackLink onClick={back}>Back</BackLink>
|
|
|
|
<PageHeader
|
|
title={title}
|
|
subtitle={subtitle || undefined}
|
|
actions={
|
|
<>
|
|
<Button icon="history" onClick={load} disabled={loading}>
|
|
{loading ? 'Refreshing…' : 'Refresh'}
|
|
</Button>
|
|
<Button icon="edit" onClick={() => openActivity('edit')} disabled={!canEdit}>
|
|
Edit
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
icon="alert"
|
|
onClick={() => openActivity('lost')}
|
|
disabled={!canReportLost}
|
|
>
|
|
Report Lost
|
|
</Button>
|
|
{isUnderRepair ? (
|
|
<Button icon="check" onClick={() => openActivity('repaired')} disabled={!canCompleteRepair}>
|
|
Complete Repair
|
|
</Button>
|
|
) : (
|
|
<Button icon="repair" onClick={() => openActivity('repair')} disabled={!canSendForRepair}>
|
|
Send for Repair
|
|
</Button>
|
|
)}
|
|
<Button icon="return" onClick={() => openActivity('return')} disabled={!canReturn}>
|
|
Return Asset
|
|
</Button>
|
|
<Button icon="assign" onClick={() => openActivity('reassign')} disabled={!canReassign}>
|
|
Reassign
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
icon="check"
|
|
onClick={() => openActivity('assign')}
|
|
disabled={!canAssign}
|
|
>
|
|
Assign Asset
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{!canEdit && (
|
|
<div className="notice notice--warning" role="status">
|
|
<Icon name="alert" size={15} />
|
|
<span>
|
|
This asset is in "{state}", so its workflow has finished. It can't be assigned, edited,
|
|
repaired, returned or reported lost any more.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{notice && (
|
|
<div className={`notice notice--${notice.tone}`} role="status">
|
|
<Icon name={notice.tone === 'success' ? 'check' : 'alert'} size={15} />
|
|
<span>{notice.text}</span>
|
|
<button
|
|
type="button"
|
|
className="notice__close"
|
|
onClick={() => setNotice(null)}
|
|
aria-label="Dismiss"
|
|
>
|
|
<Icon name="close" size={14} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="detail-grid">
|
|
{/* ---------- Left: state + record info ---------- */}
|
|
<div className="stack">
|
|
<Card title="Status" flush>
|
|
<InfoList>
|
|
<InfoRow label="State">{state ? <AssetStateBadge state={state} /> : null}</InfoRow>
|
|
{/* After a return the old assignee stays on the record; it's not current. */}
|
|
<InfoRow label="Assigned to">{isAssigned && employee ? employee : null}</InfoRow>
|
|
</InfoList>
|
|
</Card>
|
|
|
|
<Card title="History">
|
|
{history.length ? (
|
|
<Timeline events={history} />
|
|
) : (
|
|
<EmptyState icon="🕘" title="No history yet" text="Nothing has happened to this asset yet." />
|
|
)}
|
|
</Card>
|
|
|
|
{meta.length > 0 && (
|
|
<Card title="Record info" flush>
|
|
<InfoList>
|
|
{meta.map((m) => (
|
|
<InfoRow key={m.key} label={displayLabel(m.label)}>
|
|
<span className="cell-mono">{formatMeta(m.value)}</span>
|
|
</InfoRow>
|
|
))}
|
|
</InfoList>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
{/* ---------- Right: asset + assignment fields ---------- */}
|
|
<div className="stack">
|
|
<Card title="Asset details" flush>
|
|
<DetailFields fields={assetFields} />
|
|
</Card>
|
|
|
|
<Card
|
|
title={isAssigned && current.source === 'assign' ? 'Assignment' : 'Last assignment'}
|
|
flush
|
|
>
|
|
{hasValues(assignmentFields) ? (
|
|
<DetailFields fields={assignmentFields} />
|
|
) : (
|
|
<EmptyState
|
|
icon="📦"
|
|
title="Not assigned"
|
|
text="This asset hasn't been assigned to anyone yet."
|
|
/>
|
|
)}
|
|
</Card>
|
|
|
|
{hasValues(reassignmentFields) && (
|
|
<Card
|
|
title={isAssigned && current.source === 'reassign' ? 'Reassignment' : 'Last reassignment'}
|
|
flush
|
|
>
|
|
<DetailFields fields={reassignmentFields} />
|
|
</Card>
|
|
)}
|
|
|
|
{hasValues(returnFields) && (
|
|
<Card title="Last return" flush>
|
|
<DetailFields fields={returnFields} />
|
|
</Card>
|
|
)}
|
|
|
|
{hasValues(repairFields) && (
|
|
<Card title={isUnderRepair ? 'Repair' : 'Last repair'} flush>
|
|
<DetailFields fields={repairFields} />
|
|
</Card>
|
|
)}
|
|
|
|
{hasValues(lostFields) && (
|
|
<Card title="Loss report" flush>
|
|
<DetailFields fields={lostFields} />
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{activity && (
|
|
<AssetActivityModal
|
|
kind={activity}
|
|
open
|
|
// The workflow instance id, not the table id in the URL (see fetchAssetInstance).
|
|
instanceId={row.instance_id}
|
|
instanceProblem={data.instanceProblem}
|
|
// On return or loss, the person who has it now is the likely one; on
|
|
// edit, the asset's current owner.
|
|
defaultEmployeeId={
|
|
activity === 'edit'
|
|
? data.ownerId
|
|
: (activity === 'return' || activity === 'lost') && isAssigned
|
|
? current.id
|
|
: null
|
|
}
|
|
// Picks the owner by name when the view gives no employee id.
|
|
defaultEmployeeName={activity === 'edit' ? data.ownerName : undefined}
|
|
initialValues={activity === 'edit' ? assetEditValues(config, row) : undefined}
|
|
onClose={() => setActivity(null)}
|
|
onDone={handleDone}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function DetailFields({ fields }) {
|
|
return (
|
|
<dl className="detail-fields">
|
|
{fields.map((f) => (
|
|
<div key={f.key ?? f.label} className="detail-field">
|
|
<dt className="detail-field__label">{displayLabel(f.label)}</dt>
|
|
<dd className="detail-field__value">
|
|
<FieldValue field={f} />
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
)
|
|
}
|