asset-management/src/pages/assets/AssetDetails.jsx
vamsi.balaga a1f841ac72 fix: pre-fill the Edit Asset form with the asset's values
Edit form
- Look up each value under every key the details page reads: the Edit
  field id, the INIT field id, the view's column for the field, and a
  key named after its label.
- Match select values by value or label in any case or spacing, again
  once the builder's options load; keep an unmatched saved value as an
  option so it isn't shown blank.
- Read dates in ISO, dd-mm-yyyy and epoch form, and costs with
  formatting, into what the inputs hold.
- Pick Asset Owner by name when the view gives no employee id.

Asset values
- Read the INIT activity's record view (asset table, de18263e-…) and
  fill in each asset's Add Asset values where the workflow views have
  none, matched by Asset ID, serial number or asset identifier. Edited
  values still win.
- Keep detail view values under their field ids, and show an Edit
  column's INIT value until the asset is edited.

Activity forms and lists
- Take select options from the builder's activity fields; leave out a
  field whose options can't be confirmed (Recived by).
- Show which asset each assignment and return row is, filled in from
  the other asset workflow views and the detail view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:35:16 +05:30

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>
)
}