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:
parent
d9f614857a
commit
1004ad4895
File diff suppressed because it is too large
Load Diff
@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user