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', rowKey: '_pk_value',
}, },
assets: { assets: {
// Reads the asset table (rows keyed by id / _pk_value). It has no // The "ddddd" view on the asset workflow: every row is an instance and
// instance_id or workflow state; the old workflow view c91be68b-… is gone. // carries its instance_id, which Edit and the other activities need. Its
templateUid: 'de18263e-dc82-4d4d-98a2-99607104373b', // columns are the "Asset details" (Edit) activity's fields (field_2,
screen: 'Asset_management', // field_3, field_13…). It replaced the asset table view de18263e-….
rowKey: '_pk_value', 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 { useRef } from 'react'
import { Field, SelectInput, TextArea, TextInput } from '../ui' import { Field, SelectInput, TextArea, TextInput } from '../ui'
import { displayLabel } from '../../utils/format' import { displayLabel } from '../../utils/format'
import { fileUrl } from './FieldValue'
/** /**
* Renders one Zino activity field per builder definition, matching its * Renders one Zino activity field per builder definition, matching its
@ -232,11 +233,15 @@ function startUpload(file, field, upload, done) {
return pending 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 }) { function Thumb({ photo, alt, className, onRemove, disabled, removeLabel }) {
return ( return (
<div className={`image-slot__thumb${photo.uploading ? ' image-slot__thumb--uploading' : ''}`}> <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>} {photo.uploading && <span className="image-slot__status">Uploading…</span>}
<button <button
type="button" type="button"

View File

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

View File

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

View File

@ -4,6 +4,7 @@ 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_FIELDS, ASSET_ASSIGN_FIELDS,
ASSET_EDIT_ACTIVITY_ID,
ASSET_EDIT_FIELDS, ASSET_EDIT_FIELDS,
ASSET_LOST_FIELDS, ASSET_LOST_FIELDS,
ASSET_REASSIGN_FIELDS, ASSET_REASSIGN_FIELDS,
@ -58,7 +59,7 @@ const ACTIVITIES = {
return: { return: {
title: 'Return Asset', title: 'Return Asset',
subtitle: 'Runs the RETURNED activity in the asset workflow.', 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, activityId: ASSET_RETURN_ACTIVITY_ID,
fields: ASSET_RETURN_FIELDS, fields: ASSET_RETURN_FIELDS,
employeeLabel: 'Returned by', employeeLabel: 'Returned by',
@ -85,9 +86,12 @@ const ACTIVITIES = {
}, },
edit: { edit: {
title: 'Edit Asset', 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, fields: ASSET_EDIT_FIELDS,
employee: false, employeeLabel: 'Asset Owner',
employeeRequired: false,
submit: 'Save Changes', submit: 'Save Changes',
submitting: 'Saving…', submitting: 'Saving…',
failed: 'Could not save the changes. Please try again.', 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; * 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);
* `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({ export default function AssetActivityModal({
kind, kind,
open, open,
instanceId, instanceId,
instanceProblem,
defaultEmployeeId, defaultEmployeeId,
initialValues, initialValues,
onClose, onClose,
@ -133,7 +139,7 @@ export default function AssetActivityModal({
const hasEmployee = activity.employee !== false const hasEmployee = activity.employee !== false
const employeeRequired = hasEmployee && activity.employeeRequired !== false const employeeRequired = hasEmployee && activity.employeeRequired !== false
const navigate = useNavigate() const navigate = useNavigate()
const { logout } = useAuth() const { logout, user } = useAuth()
const [employees, setEmployees] = useState([]) const [employees, setEmployees] = useState([])
const [loadingEmployees, setLoadingEmployees] = useState(false) const [loadingEmployees, setLoadingEmployees] = useState(false)
@ -159,7 +165,17 @@ export default function AssetActivityModal({
let cancelled = false let cancelled = false
setLoadingEmployees(true) setLoadingEmployees(true)
fetchAssignableEmployees() 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) => { .catch((err) => {
if (cancelled) return if (cancelled) return
if (err?.status === 401) { if (err?.status === 401) {
@ -173,7 +189,7 @@ export default function AssetActivityModal({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [open, activity, hasEmployee, defaultEmployeeId, logout, navigate]) }, [open, activity, hasEmployee, defaultEmployeeId, logout, navigate, user])
function close() { function close() {
if (submitting) return if (submitting) return
@ -189,7 +205,11 @@ export default function AssetActivityModal({
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault() e.preventDefault()
if (instanceId == null || instanceId === '') { 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 return
} }
const employee = employees.find((emp) => String(emp.id) === employeeId) ?? null 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, label: emp.code ? `${emp.name} (${emp.code})` : emp.name,
})) }))
const formId = `${kind}-asset-form` 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 ( return (
<Modal <Modal
@ -278,7 +302,7 @@ export default function AssetActivityModal({
</Field> </Field>
)} )}
<ActivityFields <ActivityFields
fields={activity.fields} fields={fields}
values={values} values={values}
errors={errors} errors={errors}
onChange={handleChange} onChange={handleChange}

View File

@ -14,7 +14,7 @@ import {
import FieldValue, { formatMeta } from '../../components/activity/FieldValue' import FieldValue, { formatMeta } from '../../components/activity/FieldValue'
import { displayLabel } from '../../utils/format' import { displayLabel } from '../../utils/format'
import Timeline from '../../components/common/Timeline' import Timeline from '../../components/common/Timeline'
import { instanceState } from '../../api/activityForm' import { instanceState, isEndState } from '../../api/activityForm'
import { import {
ASSET_ASSIGN_FIELDS, ASSET_ASSIGN_FIELDS,
ASSET_LOST_FIELDS, ASSET_LOST_FIELDS,
@ -37,6 +37,7 @@ import {
isAssetState, isAssetState,
lookupName, lookupName,
optionLabel, optionLabel,
rowFieldValue,
withAssetFormColumns, withAssetFormColumns,
} from '../../api/assets' } from '../../api/assets'
import { useAuth } from '../../store/AuthContext' 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, * 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 * and its workflow state. There is no asset detail view template, so the record comes
* from the asset record view (see fetchAssetInstance). * 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() { export default function AssetDetails() {
const { assetId } = useParams() const { assetId } = useParams()
@ -167,13 +168,14 @@ export default function AssetDetails() {
const isAssigned = isAssetState(row, ASSET_STATE.ASSIGNED) const isAssigned = isAssetState(row, ASSET_STATE.ASSIGNED)
const isAvailable = isAssetState(row, ASSET_STATE.AVAILABLE) const isAvailable = isAssetState(row, ASSET_STATE.AVAILABLE)
const isUnderRepair = isAssetState(row, ASSET_STATE.UNDER_REPAIR) const isUnderRepair = isAssetState(row, ASSET_STATE.UNDER_REPAIR)
// ASSIGNED and Send for Repair run in Available; Reassigned and RETURNED in // ASSIGNED and Send for Repair run in Available; Reassigned in Assigned; Lost
// Assigned; Lost in either; Repair Completed in Under Repair; Edit in all // in either; Repair Completed in Under Repair; Edit in all three. RETURNED
// three. If the view sends no state, let the runtime decide rather than // 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. // hiding the buttons.
const canAssign = !state || isAvailable const canAssign = !state || isAvailable
const canReassign = !state || isAssigned const canReassign = !state || isAssigned
const canReturn = !state || isAssigned const canReturn = !isAssetState(row, ASSET_STATE.CLOSED) && !isEndState(row)
const canReportLost = !state || isAvailable || isAssigned const canReportLost = !state || isAvailable || isAssigned
const canSendForRepair = !state || isAvailable const canSendForRepair = !state || isAvailable
const canCompleteRepair = !state || isUnderRepair const canCompleteRepair = !state || isUnderRepair
@ -182,8 +184,18 @@ export default function AssetDetails() {
// Assigned or reassigned, whichever is newer, is who has it now. // Assigned or reassigned, whichever is newer, is who has it now.
const current = currentAssignment(row) const current = currentAssignment(row)
const employee = current.name 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) => 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 = [ const assignmentFields = [
{ key: ASSIGN_EMPLOYEE_FIELD, label: 'Employee data', type: 'text', value: lookupName(row, ASSIGN_EMPLOYEE_FIELD) }, { key: ASSIGN_EMPLOYEE_FIELD, label: 'Employee data', type: 'text', value: lookupName(row, ASSIGN_EMPLOYEE_FIELD) },
...toFields(ASSET_ASSIGN_FIELDS), ...toFields(ASSET_ASSIGN_FIELDS),
@ -388,10 +400,17 @@ export default function AssetDetails() {
<AssetActivityModal <AssetActivityModal
kind={activity} kind={activity}
open open
instanceId={assetId} // The workflow instance id, not the table id in the URL (see fetchAssetInstance).
// On return or loss, the person who has it now is the likely one. 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={ 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} initialValues={activity === 'edit' ? assetEditValues(row) : undefined}
onClose={() => setActivity(null)} onClose={() => setActivity(null)}

View File

@ -13,10 +13,11 @@ export default function AssetList() {
const navigate = useNavigate() const navigate = useNavigate()
// Every field the Add Asset form fills gets a column, even if the view lacks // 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 // 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, { const view = useRecordView(VIEW.templateUid, {
transform: withAssetListDisplay, transform: withAssetListDisplay,
sortBy: 'id', sortBy: 'idgen_2',
sortDir: 'desc', sortDir: 'desc',
}) })
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)