feat: workflow-backed asset lists with employee names and assigned-by

- Read assets, assignments and returns from their asset workflow record views
- Show employee names instead of raw lookup ids in the activity lists, and
  fall back to the assignee's department when none was picked
- Merge derived activity columns into the view's matching columns instead of
  showing them twice
- Offer active employees for "Assigned by" and default it to the signed-in user
- Edit activity uploads photos and pre-fills saved ones

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vamsi.balaga 2026-10-27 12:38:02 +05:30
parent d9f614857a
commit 1004ad4895
8 changed files with 706 additions and 210 deletions

File diff suppressed because it is too large Load Diff

View File

@ -25,11 +25,25 @@ export const RECORD_VIEWS = {
rowKey: '_pk_value',
},
assets: {
// Reads the asset table (rows keyed by id / _pk_value). It has no
// instance_id or workflow state; the old workflow view c91be68b-… is gone.
templateUid: 'de18263e-dc82-4d4d-98a2-99607104373b',
screen: 'Asset_management',
rowKey: '_pk_value',
// The "ddddd" view on the asset workflow: every row is an instance and
// carries its instance_id, which Edit and the other activities need. Its
// columns are the "Asset details" (Edit) activity's fields (field_2,
// field_3, field_13…). It replaced the asset table view de18263e-….
templateUid: 'cdba05a2-a993-4de0-9364-c6ea048bd775',
screen: 'ddddd',
rowKey: 'instance_id',
},
assetAssignments: {
// The asset workflow view the Assignment list reads; its rows carry the
// instance_id the ASSIGNED and Reassigned activities run on.
templateUid: '08438179-a28f-4d92-b340-97c3028cece2',
rowKey: 'instance_id',
},
assetReturns: {
// The asset workflow view the Return list reads; its rows carry the
// instance_id the RETURNED activity runs on.
templateUid: '45ea19d6-8729-438e-8c4e-488a9d1ffddb',
rowKey: 'instance_id',
},
}

View File

@ -1,6 +1,7 @@
import { useRef } from 'react'
import { Field, SelectInput, TextArea, TextInput } from '../ui'
import { displayLabel } from '../../utils/format'
import { fileUrl } from './FieldValue'
/**
* Renders one Zino activity field per builder definition, matching its
@ -232,11 +233,15 @@ function startUpload(file, field, upload, done) {
return pending
}
/** A photo's thumbnail with its remove button, dimmed while it uploads. */
/**
* A photo's thumbnail with its remove button, dimmed while it uploads. A photo
* already saved on the record (e.g. pre-filled on edit) has no local preview,
* so it shows from the stored file.
*/
function Thumb({ photo, alt, className, onRemove, disabled, removeLabel }) {
return (
<div className={`image-slot__thumb${photo.uploading ? ' image-slot__thumb--uploading' : ''}`}>
<img className={className} src={photo.preview} alt={alt} />
<img className={className} src={photo.preview ?? fileUrl(photo)} alt={alt} />
{photo.uploading && <span className="image-slot__status">Uploading…</span>}
<button
type="button"

View File

@ -12,6 +12,13 @@ import {
import { emptyValues, validateActivity } from '../../api/activityForm'
import { useAuth } from '../../store/AuthContext'
// Asset Owner sits after Condition, as in the builder.
const OWNER_AFTER = ASSET_FIELDS.findIndex((f) => f.id === 'field_47') + 1
const FIELDS_BEFORE_OWNER = ASSET_FIELDS.slice(0, OWNER_AFTER)
const FIELDS_AFTER_OWNER = ASSET_FIELDS.slice(OWNER_AFTER)
const upload = (fieldId, file) => uploadAssetFile(ASSET_INIT_ACTIVITY_ID, fieldId, file)
/**
* "Add Asset" dialog. Fields are rendered straight from the Zino builder
* definition in api/assets.js, so the form always matches the activity.
@ -129,12 +136,12 @@ export default function AddAssetModal({ open, onClose, onCreated }) {
<form id="add-asset-form" className="form__grid" onSubmit={handleSubmit} noValidate>
<ActivityFields
fields={ASSET_FIELDS}
fields={FIELDS_BEFORE_OWNER}
values={values}
errors={errors}
onChange={handleChange}
disabled={submitting}
upload={(fieldId, file) => uploadAssetFile(ASSET_INIT_ACTIVITY_ID, fieldId, file)}
upload={upload}
/>
<Field label="Asset Owner">
{(id) => (
@ -154,6 +161,14 @@ export default function AddAssetModal({ open, onClose, onCreated }) {
/>
)}
</Field>
<ActivityFields
fields={FIELDS_AFTER_OWNER}
values={values}
errors={errors}
onChange={handleChange}
disabled={submitting}
upload={upload}
/>
</form>
</Modal>
)

View File

@ -24,6 +24,8 @@ const LISTS = {
title: 'Asset Assignment',
subtitle: 'Which employee has which asset, read live from the asset workflow.',
fetcher: fetchAssignmentListPage,
// Its own record view, not the asset list's.
view: RECORD_VIEWS.assetAssignments,
filters: ASSIGNMENT_FILTERS,
empty: {
assigned: 'No assets are assigned right now.',
@ -34,6 +36,8 @@ const LISTS = {
title: 'Asset Return',
subtitle: 'Assets waiting to come back, and the ones already returned.',
fetcher: fetchReturnListPage,
// Its own record view, not the asset list's.
view: RECORD_VIEWS.assetReturns,
filters: RETURN_FILTERS,
empty: {
assigned: 'No assets are out with employees right now.',
@ -59,8 +63,9 @@ const LISTS = {
*/
export default function AssetActivityList({ kind }) {
const list = LISTS[kind]
const source = list.view ?? VIEW
const navigate = useNavigate()
const view = useRecordView(VIEW.templateUid, {
const view = useRecordView(source.templateUid, {
fetcher: list.fetcher,
filter: list.filters[0].value,
})
@ -72,7 +77,7 @@ export default function AssetActivityList({ kind }) {
<Card flush>
<RecordView
{...view}
rowKey={list.rowKey ?? VIEW.rowKey}
rowKey={list.rowKey ?? source.rowKey}
emptyText={list.empty[view.query.filter] ?? 'No assets yet.'}
onRowClick={(row) => navigate(`/assets/${encodeURIComponent(assetRowId(row))}`)}
toolbar={

View File

@ -4,6 +4,7 @@ import { Button, Field, Icon, Modal, SelectInput } from '../../components/ui'
import ActivityFields from '../../components/activity/ActivityFields'
import {
ASSET_ASSIGN_FIELDS,
ASSET_EDIT_ACTIVITY_ID,
ASSET_EDIT_FIELDS,
ASSET_LOST_FIELDS,
ASSET_REASSIGN_FIELDS,
@ -58,7 +59,7 @@ const ACTIVITIES = {
return: {
title: 'Return Asset',
subtitle: 'Runs the RETURNED activity in the asset workflow.',
// Photo uploads are tagged with this activity's id.
// Return photos are uploaded tagged with this activity's id.
activityId: ASSET_RETURN_ACTIVITY_ID,
fields: ASSET_RETURN_FIELDS,
employeeLabel: 'Returned by',
@ -85,9 +86,12 @@ const ACTIVITIES = {
},
edit: {
title: 'Edit Asset',
subtitle: 'Runs the Edit activity in the asset workflow. The asset keeps its state.',
subtitle: 'Runs the Asset details activity in the asset workflow. The asset keeps its state.',
// Photo uploads are tagged with this activity's id.
activityId: ASSET_EDIT_ACTIVITY_ID,
fields: ASSET_EDIT_FIELDS,
employee: false,
employeeLabel: 'Asset Owner',
employeeRequired: false,
submit: 'Save Changes',
submitting: 'Saving…',
failed: 'Could not save the changes. Please try again.',
@ -118,12 +122,14 @@ const ACTIVITIES = {
/**
* Dialog for one asset activity. `kind` is a key of ACTIVITIES;
* `defaultEmployeeId` preselects an employee (e.g. the assignee on return);
* `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.
*/
export default function AssetActivityModal({
kind,
open,
instanceId,
instanceProblem,
defaultEmployeeId,
initialValues,
onClose,
@ -133,7 +139,7 @@ export default function AssetActivityModal({
const hasEmployee = activity.employee !== false
const employeeRequired = hasEmployee && activity.employeeRequired !== false
const navigate = useNavigate()
const { logout } = useAuth()
const { logout, user } = useAuth()
const [employees, setEmployees] = useState([])
const [loadingEmployees, setLoadingEmployees] = useState(false)
@ -159,7 +165,17 @@ export default function AssetActivityModal({
let cancelled = false
setLoadingEmployees(true)
fetchAssignableEmployees()
.then((list) => !cancelled && setEmployees(list))
.then((list) => {
if (cancelled) return
setEmployees(list)
// A field that names an employee (Assigned by) starts as the signed-in user.
const me = list.find(
(emp) => (user?.email && emp.email === user.email) || (user?.name && emp.name === user.name),
)
if (!me) return
const mine = activity.fields.filter((f) => f.employeeOptions).map((f) => f.id)
setValues((v) => ({ ...v, ...Object.fromEntries(mine.filter((id) => !v[id]).map((id) => [id, me.name])) }))
})
.catch((err) => {
if (cancelled) return
if (err?.status === 401) {
@ -173,7 +189,7 @@ export default function AssetActivityModal({
return () => {
cancelled = true
}
}, [open, activity, hasEmployee, defaultEmployeeId, logout, navigate])
}, [open, activity, hasEmployee, defaultEmployeeId, logout, navigate, user])
function close() {
if (submitting) return
@ -189,7 +205,11 @@ export default function AssetActivityModal({
async function handleSubmit(e) {
e.preventDefault()
if (instanceId == null || instanceId === '') {
setServerError('This asset has no instance id, so the activity cannot run.')
// Sending the table id instead fails with NOT_FOUND (see ASSET_WORKFLOW_VIEW_UID).
setServerError(
"This asset's workflow instance could not be found, so the activity cannot run. " +
(instanceProblem || 'Check ASSET_WORKFLOW_VIEW_UID in src/api/assets.js.'),
)
return
}
const employee = employees.find((emp) => String(emp.id) === employeeId) ?? null
@ -220,6 +240,10 @@ export default function AssetActivityModal({
label: emp.code ? `${emp.name} (${emp.code})` : emp.name,
}))
const formId = `${kind}-asset-form`
// Fields with no builder options that pick an employee by name (Assigned by).
const fields = activity.fields.map((f) =>
f.employeeOptions ? { ...f, options: employees.map((emp) => ({ label: emp.name, value: emp.name })) } : f,
)
return (
<Modal
@ -278,7 +302,7 @@ export default function AssetActivityModal({
</Field>
)}
<ActivityFields
fields={activity.fields}
fields={fields}
values={values}
errors={errors}
onChange={handleChange}

View File

@ -14,7 +14,7 @@ import {
import FieldValue, { formatMeta } from '../../components/activity/FieldValue'
import { displayLabel } from '../../utils/format'
import Timeline from '../../components/common/Timeline'
import { instanceState } from '../../api/activityForm'
import { instanceState, isEndState } from '../../api/activityForm'
import {
ASSET_ASSIGN_FIELDS,
ASSET_LOST_FIELDS,
@ -37,6 +37,7 @@ import {
isAssetState,
lookupName,
optionLabel,
rowFieldValue,
withAssetFormColumns,
} from '../../api/assets'
import { useAuth } from '../../store/AuthContext'
@ -49,7 +50,7 @@ const META_KEY = /^(instance_id|current_state_name|(created|updated|modified)_\w
* 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 workflow instance id.
* Route: /assets/:assetId — the asset's table id (not its workflow instance id).
*/
export default function AssetDetails() {
const { assetId } = useParams()
@ -167,13 +168,14 @@ export default function AssetDetails() {
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 and RETURNED in
// Assigned; Lost in either; Repair Completed in Under Repair; Edit in all
// three. If the view sends no state, let the runtime decide rather than
// 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 = !state || isAssigned
const canReturn = !isAssetState(row, ASSET_STATE.CLOSED) && !isEndState(row)
const canReportLost = !state || isAvailable || isAssigned
const canSendForRepair = !state || isAvailable
const canCompleteRepair = !state || isUnderRepair
@ -182,8 +184,18 @@ export default function AssetDetails() {
// 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.label, type: f.type, value: row[f.id] }))
defs.map((f) => ({
key: f.id,
label: 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),
@ -388,10 +400,17 @@ export default function AssetDetails() {
<AssetActivityModal
kind={activity}
open
instanceId={assetId}
// On return or loss, the person who has it now is the likely one.
// 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 === 'return' || activity === 'lost') && isAssigned ? current.id : null
activity === 'edit'
? data.ownerId
: (activity === 'return' || activity === 'lost') && isAssigned
? current.id
: null
}
initialValues={activity === 'edit' ? assetEditValues(row) : undefined}
onClose={() => setActivity(null)}

View File

@ -13,10 +13,11 @@ export default function AssetList() {
const navigate = useNavigate()
// Every field the Add Asset form fills gets a column, even if the view lacks
// one, and Asset Owner shows the employee's name. Newest first, so a just
// created asset is on page 1 (the view itself lists oldest first).
// created asset is on page 1 (the view itself lists oldest first). idgen_2 is
// the view's Asset id column (ID-00001…).
const view = useRecordView(VIEW.templateUid, {
transform: withAssetListDisplay,
sortBy: 'id',
sortBy: 'idgen_2',
sortDir: 'desc',
})
const [adding, setAdding] = useState(false)