feat: asset edit, repair flow, return photos and new INIT fields

- Return activity: "Return images" photo field (Front/Back/Screen/Keyboard
  + Other) uploaded via /upload, and a "Received by" dropdown
  (Praveen, Akshay, Prasad)
- Add Asset (INIT): "Monthly allowance" and "Repair status" fields
- Edit Asset activity: updates location, state, priority, condition,
  repair status, allowance and warranty; pre-filled from current values,
  and edited values replace INIT values in the list and detail page
- Repair flow: new "Under Repair" state with Send for Repair and
  Repair Completed activities, a Repair card, history events and filters,
  and an Under repair tile and chart color on the dashboard
- Activity list rows open the asset by assetRowId

New builder field and activity ids are placeholders (marked PLACEHOLDER
in api/assets.js) until they are created in the Zino builder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vamsi.balaga 2026-10-21 15:04:24 +05:30
parent f845af5125
commit d9f614857a
9 changed files with 348 additions and 37 deletions

View File

@ -44,6 +44,21 @@ export const ASSET_INIT_ACTIVITY_ID = 'a2405547-0db4-474b-aa76-af1246d01a6a'
*/ */
export const ASSET_INVOICE_FIELD = 'field_62' export const ASSET_INVOICE_FIELD = 'field_62'
/**
* The monthly allowance paid for the asset. PLACEHOLDER id: add a number field
* labelled "Monthly allowance" to the INIT activity in the Zino builder,
* deploy, and put its id here. Until then the runtime may reject it.
*/
export const ASSET_MONTHLY_ALLOWANCE_FIELD = 'field_65'
/**
* Where the asset stands in the repair flow. PLACEHOLDER id: add a dropdown
* field labelled "Repair status" to the INIT activity in the Zino builder,
* with the option values below, deploy, and put its id here. Until then the
* runtime may reject it.
*/
export const ASSET_REPAIR_STATUS_FIELD = 'field_80'
export const ASSET_FIELDS = [ export const ASSET_FIELDS = [
{ {
// The asset table stores it in its asset_details column. formLabel also // The asset table stores it in its asset_details column. formLabel also
@ -67,6 +82,7 @@ export const ASSET_FIELDS = [
{ id: 'field_44', label: 'Serial number', type: 'text' }, { id: 'field_44', label: 'Serial number', type: 'text' },
{ id: 'field_45', label: 'Purchase date', type: 'date' }, { id: 'field_45', label: 'Purchase date', type: 'date' },
{ id: 'field_46', label: 'Purchase cost', type: 'number' }, { id: 'field_46', label: 'Purchase cost', type: 'number' },
{ id: ASSET_MONTHLY_ALLOWANCE_FIELD, label: 'Monthly allowance', type: 'number' },
// Option values must match the builder exactly. // Option values must match the builder exactly.
{ {
id: 'field_56', id: 'field_56',
@ -89,6 +105,17 @@ export const ASSET_FIELDS = [
{ label: 'High', value: 'high' }, { label: 'High', value: 'high' },
], ],
}, },
{
id: ASSET_REPAIR_STATUS_FIELD,
label: 'Repair status',
type: 'select',
options: [
{ label: 'Not required', value: 'not_required' },
{ label: 'Needs repair', value: 'needs_repair' },
{ label: 'Under repair', value: 'under_repair' },
{ label: 'Repaired', value: 'repaired' },
],
},
{ {
id: 'field_47', id: 'field_47',
label: 'Condition', label: 'Condition',
@ -284,19 +311,54 @@ export const REASSIGN_EMPLOYEE_FIELD = 'field_23'
* POST {APP_API_BASE_URL}/app/{APP_ID}/activity * POST {APP_API_BASE_URL}/app/{APP_ID}/activity
* Authorization: Bearer <JWT> * Authorization: Bearer <JWT>
* { activity_id, workflow_uuid, instance_id, * { activity_id, workflow_uuid, instance_id,
* data: { field_8, field_9, field_10, field_28: { id, first_name } } } * data: { field_8, field_9, field_10, field_28: { id, first_name },
* [ASSET_RETURN_IMAGES_FIELD]: [{ blob_path, mime_type, original_name, size_bytes, uuid }, …] } }
*
* Each return photo is uploaded first (POST /app/{APP_ID}/upload with this
* activity's id and the field id), and the activity sends the returned file
* objects, as the start/activity endpoints expect.
* *
* Source: the asset workflow "RETURNED" activity, Fields tab, in the Zino * Source: the asset workflow "RETURNED" activity, Fields tab, in the Zino
* builder (…/activities/7f7b43fb-…). * builder (…/activities/7f7b43fb-…).
*/ */
export const ASSET_RETURN_ACTIVITY_ID = '7f7b43fb-557c-483a-97a5-09b0a8080622' export const ASSET_RETURN_ACTIVITY_ID = '7f7b43fb-557c-483a-97a5-09b0a8080622'
/**
* Photos of the laptop as it comes back. PLACEHOLDER id: add an image field
* labelled "Return images" to the RETURNED activity in the Zino builder,
* deploy, and put its id here. Until then, picking a photo fails to upload;
* returns without photos save as before.
*/
export const ASSET_RETURN_IMAGES_FIELD = 'field_63'
/**
* Who takes the asset back. PLACEHOLDER id: add a dropdown field labelled
* "Received by" to the RETURNED activity in the Zino builder, with the options
* below, deploy, and put its id here. Until then the runtime may reject it.
*/
export const ASSET_RETURN_RECEIVER_FIELD = 'field_64'
/** Fields the user fills in. */ /** Fields the user fills in. */
export const ASSET_RETURN_FIELDS = [ export const ASSET_RETURN_FIELDS = [
{ id: 'field_8', label: 'Returned date', type: 'date' }, { id: 'field_8', label: 'Returned date', type: 'date' },
{
id: ASSET_RETURN_RECEIVER_FIELD,
label: 'Received by',
type: 'select',
options: ['Praveen', 'Akshay', 'Prasad'].map((name) => ({ label: name, value: name })),
},
// Condition (field_29) is left out of the form on purpose. // Condition (field_29) is left out of the form on purpose.
{ id: 'field_9', label: 'Damage details', type: 'longtext' }, { id: 'field_9', label: 'Damage details', type: 'longtext' },
{ id: 'field_10', label: 'Remarks', type: 'longtext' }, { id: 'field_10', label: 'Remarks', type: 'longtext' },
// One photo per view, then any number of others; sent as one list in this order.
{
id: ASSET_RETURN_IMAGES_FIELD,
label: 'Return images',
type: 'image',
maxSizeMb: 5,
slots: ['Front', 'Back', 'Screen', 'Keyboard'],
otherSlot: 'Other',
},
] ]
/** "Returned by": a lookup into the employee table, like "Employee data". */ /** "Returned by": a lookup into the employee table, like "Employee data". */
@ -414,10 +476,129 @@ export function reportAssetLost(instanceId, values, employee) {
) )
} }
/**
* The "Edit" activity of the asset workflow. It updates the details of an
* asset that change over time, without moving it to another state. It should
* be offered in Available, Assigned and Under Repair. Like ASSIGNED it posts
* to /activity with the asset's instance_id.
*
* POST {APP_API_BASE_URL}/app/{APP_ID}/activity
* { activity_id, workflow_uuid, instance_id, data: { field_66 … field_72, field_81 } }
*
* Like the employee Update activity, it has its own field ids; `initId` is the
* INIT field each one updates, so the asset shows the edited value in its
* place (see withEditedValues).
*
* PLACEHOLDER ids: add the "Edit" activity and its fields (same labels, types
* and options as the INIT fields) in the Zino builder, deploy, and put the
* activity id and field ids here. Until then, running it fails.
*/
export const ASSET_EDIT_ACTIVITY_ID = 'TODO-edit-activity-id'
/** A copy of an INIT field for the Edit activity, under the Edit field's id. */
const editFieldOf = (initId, id) => ({ ...ASSET_FIELDS.find((f) => f.id === initId), id, initId })
/** Fields the user fills in, pre-filled with the asset's current values. */
export const ASSET_EDIT_FIELDS = [
editFieldOf('field_61', 'field_66'), // Asset Location
editFieldOf('field_56', 'field_67'), // Asset State
editFieldOf('field_58', 'field_68'), // Asset Priority
editFieldOf('field_47', 'field_69'), // Condition
editFieldOf(ASSET_REPAIR_STATUS_FIELD, 'field_81'),
editFieldOf(ASSET_MONTHLY_ALLOWANCE_FIELD, 'field_70'),
editFieldOf('field_48', 'field_71'), // Warranty Status
editFieldOf('field_55', 'field_72'), // Warranty End date
]
/**
* The repair flow: "Send for Repair" moves an Available asset to Under Repair,
* and "Repair Completed" moves it back to Available. Assigned assets are
* returned first, so the return records the damage. Both post to /activity
* with the asset's instance_id.
*
* POST {APP_API_BASE_URL}/app/{APP_ID}/activity
* { activity_id, workflow_uuid, instance_id, data: { field_73 … field_76 } }
* { activity_id, workflow_uuid, instance_id, data: { field_77 … field_79 } }
*
* PLACEHOLDER ids: add an "Under Repair" state and these two activities, with
* these fields, in the Zino builder, deploy, and put their ids here. Until
* then, running them fails.
*/
export const ASSET_REPAIR_ACTIVITY_ID = 'TODO-send-for-repair-activity-id'
/** Send for Repair fields. */
export const ASSET_REPAIR_FIELDS = [
{ id: 'field_73', label: 'Repair date', type: 'date' },
{ id: 'field_74', label: 'Issue details', type: 'longtext' },
{ id: 'field_75', label: 'Repair vendor', type: 'text' },
{ id: 'field_76', label: 'Estimated cost', type: 'number' },
]
export const ASSET_REPAIRED_ACTIVITY_ID = 'TODO-repair-completed-activity-id'
/** Repair Completed fields. */
export const ASSET_REPAIRED_FIELDS = [
{ id: 'field_77', label: 'Repaired date', type: 'date' },
{ id: 'field_78', label: 'Repair cost', type: 'number' },
{ id: 'field_79', label: 'Repair remarks', type: 'longtext' },
]
/** Run the Edit activity on an asset instance. */
export function editAsset(instanceId, values) {
return runAssetActivity(ASSET_EDIT_ACTIVITY_ID, ASSET_EDIT_FIELDS, instanceId, values)
}
/** Run Send for Repair on an Available asset instance. */
export function sendAssetForRepair(instanceId, values) {
return runAssetActivity(ASSET_REPAIR_ACTIVITY_ID, ASSET_REPAIR_FIELDS, instanceId, values)
}
/** Run Repair Completed on an asset instance that is Under Repair. */
export function completeAssetRepair(instanceId, values) {
return runAssetActivity(ASSET_REPAIRED_ACTIVITY_ID, ASSET_REPAIRED_FIELDS, instanceId, values)
}
/**
* The Edit form's starting values: each field's current value on the asset
* (its latest edit, else its INIT value), as the form holds it.
*/
export function assetEditValues(row) {
const norm = (v) => String(v).trim().toLowerCase()
return Object.fromEntries(
ASSET_EDIT_FIELDS.map((f) => {
const init = ASSET_FIELDS.find((d) => d.id === f.initId)
const raw = present(row?.[f.id]) ? row[f.id] : row?.[initValueKey(init, row ?? {})]
if (!present(raw)) return [f.id, '']
if (f.type === 'date') return [f.id, toISODate(raw)]
if (f.type === 'select') {
const hit = f.options.find((o) => norm(o.value) === norm(raw) || norm(o.label) === norm(raw))
return [f.id, hit ? hit.value : String(raw)]
}
return [f.id, String(raw)]
}),
)
}
/**
* A row with each edited value in place of its INIT value, so the asset shows
* its latest details. The INIT value stays when the asset was never edited.
*/
export function withEditedValues(row) {
if (!row) return row
const out = { ...row }
for (const f of ASSET_EDIT_FIELDS) {
if (!present(row[f.id])) continue
const init = ASSET_FIELDS.find((d) => d.id === f.initId)
out[initValueKey(init, row)] = row[f.id]
}
return out
}
/** Asset workflow states, as named in the builder diagram. */ /** Asset workflow states, as named in the builder diagram. */
export const ASSET_STATE = { export const ASSET_STATE = {
AVAILABLE: 'Available', AVAILABLE: 'Available',
ASSIGNED: 'Assigned', ASSIGNED: 'Assigned',
UNDER_REPAIR: 'Under Repair',
CLOSED: 'Closed', CLOSED: 'Closed',
} }
@ -662,11 +843,21 @@ export const ASSET_RICH_COLUMNS = [
key: 'status', key: 'status',
label: 'Status', label: 'Status',
title: ['Asset State'], title: ['Asset State'],
subtitle: [{ label: 'Asset Priority', prefix: 'Priority' }, 'Condition', 'Asset Classification'], subtitle: [
{ label: 'Asset Priority', prefix: 'Priority' },
'Condition',
{ label: 'Repair status', prefix: 'Repair' },
'Asset Classification',
],
}, },
{ key: 'owner', label: 'Owner', title: ['Asset Owner'], subtitle: ['Asset Location'] }, { key: 'owner', label: 'Owner', title: ['Asset Owner'], subtitle: ['Asset Location'] },
{ key: 'specs', label: 'Specs', title: ['CPU'], subtitle: ['Hard Drive'] }, { key: 'specs', label: 'Specs', title: ['CPU'], subtitle: ['Hard Drive'] },
{ key: 'purchase', label: 'Purchase', title: ['Purchase cost'], subtitle: ['Purchase date'] }, {
key: 'purchase',
label: 'Purchase',
title: ['Purchase cost'],
subtitle: ['Purchase date', { label: 'Monthly allowance', prefix: 'Allowance' }],
},
{ {
key: 'warranty', key: 'warranty',
label: 'Warranty', label: 'Warranty',
@ -684,7 +875,8 @@ export const ASSET_RICH_COLUMNS = [
*/ */
export async function withAssetListDisplay(res) { export async function withAssetListDisplay(res) {
const rows = Array.isArray(res?.data) ? res.data : [] const rows = Array.isArray(res?.data) ? res.data : []
const display = withAssetFormColumns({ ...res, data: await withOwnerNames(res?.config, rows) }) const named = await withOwnerNames(res?.config, rows.map(withEditedValues))
const display = withAssetFormColumns({ ...res, data: named })
return withRichColumns(display, ASSET_RICH_COLUMNS) return withRichColumns(display, ASSET_RICH_COLUMNS)
} }
@ -847,6 +1039,26 @@ export function assetEvents(config, row) {
) )
} }
if (present(val('field_73'))) {
add(
HISTORY_ACTION.REPAIR,
val('field_73'),
'',
joinParts(val('field_75'), present(val('field_76')) && `Estimate ${val('field_76')}`),
val('field_74'),
)
}
if (present(val('field_77'))) {
add(
HISTORY_ACTION.REPAIRED,
val('field_77'),
'',
joinParts(present(val('field_78')) && `Cost ${val('field_78')}`),
val('field_79'),
)
}
const lastOwner = lookupName(row, LOST_EMPLOYEE_FIELD) const lastOwner = lookupName(row, LOST_EMPLOYEE_FIELD)
if (present(val('field_36')) || lastOwner) { if (present(val('field_36')) || lastOwner) {
add( add(
@ -877,6 +1089,8 @@ export const HISTORY_FILTERS = [
{ value: HISTORY_ACTION.ASSIGNED, label: 'Assigned' }, { value: HISTORY_ACTION.ASSIGNED, label: 'Assigned' },
{ value: HISTORY_ACTION.REASSIGNED, label: 'Reassigned' }, { value: HISTORY_ACTION.REASSIGNED, label: 'Reassigned' },
{ value: HISTORY_ACTION.RETURNED, label: 'Returned' }, { value: HISTORY_ACTION.RETURNED, label: 'Returned' },
{ value: HISTORY_ACTION.REPAIR, label: 'Sent for repair' },
{ value: HISTORY_ACTION.REPAIRED, label: 'Repaired' },
{ value: HISTORY_ACTION.LOST, label: 'Lost' }, { value: HISTORY_ACTION.LOST, label: 'Lost' },
] ]
@ -963,6 +1177,6 @@ export async function fetchAssetInstance(instanceId) {
const row = { ...detailValues } const row = { ...detailValues }
for (const [k, v] of Object.entries(listRow ?? {})) if (present(v) || !(k in row)) row[k] = v for (const [k, v] of Object.entries(listRow ?? {})) if (present(v) || !(k in row)) row[k] = v
const [named] = await withOwnerNames(config, [row]) const [named] = await withOwnerNames(config, [withEditedValues(row)])
return { config, row: named } return { config, row: named }
} }

View File

@ -47,6 +47,7 @@ export async function fetchDashboard() {
const stateCounts = new Map([ const stateCounts = new Map([
[ASSET_STATE.ASSIGNED, 0], [ASSET_STATE.ASSIGNED, 0],
[ASSET_STATE.AVAILABLE, 0], [ASSET_STATE.AVAILABLE, 0],
[ASSET_STATE.UNDER_REPAIR, 0],
[ASSET_STATE.CLOSED, 0], [ASSET_STATE.CLOSED, 0],
]) ])
const categoryCounts = new Map() const categoryCounts = new Map()

View File

@ -20,6 +20,8 @@ const PATHS = {
building: 'M3 21h18M5 21V7l7-4 7 4v14M9 9h.01M9 13h.01M9 17h.01M15 9h.01M15 13h.01M15 17h.01', building: 'M3 21h18M5 21V7l7-4 7 4v14M9 9h.01M9 13h.01M9 17h.01M15 9h.01M15 13h.01M15 17h.01',
tag: 'M20.59 13.41 13.42 20.6a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82zM7 7h.01', tag: 'M20.59 13.41 13.42 20.6a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82zM7 7h.01',
transfer: 'M16 3h5v5M21 3l-7 7M8 21H3v-5M3 21l7-7', transfer: 'M16 3h5v5M21 3l-7 7M8 21H3v-5M3 21l7-7',
edit: 'M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z',
repair: 'M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z',
laptop: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v11H3V5zM2 20h20', laptop: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v11H3V5zM2 20h20',
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9', logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
} }

View File

@ -26,6 +26,7 @@ export const ASSET_STATUS_TONE = {
export const ASSET_STATE_TONE = { export const ASSET_STATE_TONE = {
available: 'success', available: 'success',
assigned: 'info', assigned: 'info',
'under repair': 'warning',
closed: 'neutral', closed: 'neutral',
} }
@ -116,6 +117,8 @@ export const HISTORY_ACTION = {
RETURNED: 'Returned', RETURNED: 'Returned',
TRANSFERRED: 'Transferred', TRANSFERRED: 'Transferred',
MAINTENANCE: 'Maintenance', MAINTENANCE: 'Maintenance',
REPAIR: 'Sent for repair',
REPAIRED: 'Repaired',
LOST: 'Lost', LOST: 'Lost',
RETIRED: 'Retired', RETIRED: 'Retired',
} }
@ -130,5 +133,7 @@ export const HISTORY_ACTION_TONE = {
[HISTORY_ACTION.RETURNED]: 'neutral', [HISTORY_ACTION.RETURNED]: 'neutral',
[HISTORY_ACTION.TRANSFERRED]: 'warning', [HISTORY_ACTION.TRANSFERRED]: 'warning',
[HISTORY_ACTION.MAINTENANCE]: 'warning', [HISTORY_ACTION.MAINTENANCE]: 'warning',
[HISTORY_ACTION.REPAIR]: 'warning',
[HISTORY_ACTION.REPAIRED]: 'success',
[HISTORY_ACTION.RETIRED]: 'danger', [HISTORY_ACTION.RETIRED]: 'danger',
} }

View File

@ -16,6 +16,7 @@ import { formatCurrency } from '../utils/format'
const STATE_COLOR = { const STATE_COLOR = {
[ASSET_STATE.ASSIGNED]: 'var(--series-1)', [ASSET_STATE.ASSIGNED]: 'var(--series-1)',
[ASSET_STATE.AVAILABLE]: 'var(--series-3)', [ASSET_STATE.AVAILABLE]: 'var(--series-3)',
[ASSET_STATE.UNDER_REPAIR]: 'var(--series-4)',
[ASSET_STATE.CLOSED]: 'var(--series-2)', [ASSET_STATE.CLOSED]: 'var(--series-2)',
} }
const OTHER_COLOR = 'var(--border-strong)' const OTHER_COLOR = 'var(--border-strong)'
@ -106,6 +107,7 @@ export default function Dashboard() {
<StatTile label="Total assets" value={data.total} to="/assets" /> <StatTile label="Total assets" value={data.total} to="/assets" />
<StatTile label="Assigned" value={count(ASSET_STATE.ASSIGNED)} to="/assign" /> <StatTile label="Assigned" value={count(ASSET_STATE.ASSIGNED)} to="/assign" />
<StatTile label="Available" value={count(ASSET_STATE.AVAILABLE)} to="/assign" /> <StatTile label="Available" value={count(ASSET_STATE.AVAILABLE)} to="/assign" />
<StatTile label="Under repair" value={count(ASSET_STATE.UNDER_REPAIR)} to="/assets" />
<StatTile label="Closed (lost or retired)" value={count(ASSET_STATE.CLOSED)} to="/history" /> <StatTile label="Closed (lost or retired)" value={count(ASSET_STATE.CLOSED)} to="/history" />
<StatTile <StatTile
label="Active employees" label="Active employees"

View File

@ -7,6 +7,7 @@ import {
ASSIGNMENT_FILTERS, ASSIGNMENT_FILTERS,
HISTORY_FILTERS, HISTORY_FILTERS,
RETURN_FILTERS, RETURN_FILTERS,
assetRowId,
fetchAssignmentListPage, fetchAssignmentListPage,
fetchHistoryListPage, fetchHistoryListPage,
fetchReturnListPage, fetchReturnListPage,
@ -73,7 +74,7 @@ export default function AssetActivityList({ kind }) {
{...view} {...view}
rowKey={list.rowKey ?? VIEW.rowKey} rowKey={list.rowKey ?? VIEW.rowKey}
emptyText={list.empty[view.query.filter] ?? 'No assets yet.'} emptyText={list.empty[view.query.filter] ?? 'No assets yet.'}
onRowClick={(row) => navigate(`/assets/${encodeURIComponent(row.instance_id)}`)} onRowClick={(row) => navigate(`/assets/${encodeURIComponent(assetRowId(row))}`)}
toolbar={ toolbar={
<div className="filter"> <div className="filter">
<SelectInput <SelectInput

View File

@ -1,17 +1,25 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Button, Field, Icon, Modal, SelectInput } from '../../components/ui' 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_FIELDS,
ASSET_LOST_FIELDS, ASSET_LOST_FIELDS,
ASSET_REASSIGN_FIELDS, ASSET_REASSIGN_FIELDS,
ASSET_REPAIRED_FIELDS,
ASSET_REPAIR_FIELDS,
ASSET_RETURN_ACTIVITY_ID,
ASSET_RETURN_FIELDS, ASSET_RETURN_FIELDS,
assignAsset, assignAsset,
completeAssetRepair,
editAsset,
fetchAssignableEmployees, fetchAssignableEmployees,
reassignAsset, reassignAsset,
reportAssetLost, reportAssetLost,
returnAsset, returnAsset,
sendAssetForRepair,
uploadAssetFile,
} from '../../api/assets' } from '../../api/assets'
import { emptyValues, validateActivity } from '../../api/activityForm' import { emptyValues, validateActivity } from '../../api/activityForm'
import { useAuth } from '../../store/AuthContext' import { useAuth } from '../../store/AuthContext'
@ -20,8 +28,9 @@ import { useAuth } from '../../store/AuthContext'
* The asset activities that run against one existing asset and carry an * The asset activities that run against one existing asset and carry an
* employee lookup. The employee is picked from a list of active employees; the * employee lookup. The employee is picked from a list of active employees; the
* other fields come from api/assets.js, so each form matches its activity. * other fields come from api/assets.js, so each form matches its activity.
* `employeeRequired: false` lets the form run without one; `warning` is shown * `employeeRequired: false` lets the form run without one, and `employee: false`
* above the form for an activity that can't be undone. * leaves the picker out; `warning` is shown above the form for an activity
* that can't be undone.
*/ */
const ACTIVITIES = { const ACTIVITIES = {
assign: { assign: {
@ -49,6 +58,8 @@ 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.
activityId: ASSET_RETURN_ACTIVITY_ID,
fields: ASSET_RETURN_FIELDS, fields: ASSET_RETURN_FIELDS,
employeeLabel: 'Returned by', employeeLabel: 'Returned by',
employeeError: 'Choose who returned this asset.', employeeError: 'Choose who returned this asset.',
@ -72,22 +83,55 @@ const ACTIVITIES = {
failed: 'Could not report the asset lost. Please try again.', failed: 'Could not report the asset lost. Please try again.',
run: reportAssetLost, run: reportAssetLost,
}, },
edit: {
title: 'Edit Asset',
subtitle: 'Runs the Edit activity in the asset workflow. The asset keeps its state.',
fields: ASSET_EDIT_FIELDS,
employee: false,
submit: 'Save Changes',
submitting: 'Saving…',
failed: 'Could not save the changes. Please try again.',
run: editAsset,
},
repair: {
title: 'Send for Repair',
subtitle: 'Runs the Send for Repair activity. The asset moves to Under Repair.',
fields: ASSET_REPAIR_FIELDS,
employee: false,
submit: 'Send for Repair',
submitting: 'Sending…',
failed: 'Could not send the asset for repair. Please try again.',
run: sendAssetForRepair,
},
repaired: {
title: 'Repair Completed',
subtitle: 'Runs the Repair Completed activity. The asset is Available again.',
fields: ASSET_REPAIRED_FIELDS,
employee: false,
submit: 'Complete Repair',
submitting: 'Saving…',
failed: 'Could not complete the repair. Please try again.',
run: completeAssetRepair,
},
} }
/** /**
* 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).
*/ */
export default function AssetActivityModal({ export default function AssetActivityModal({
kind, kind,
open, open,
instanceId, instanceId,
defaultEmployeeId, defaultEmployeeId,
initialValues,
onClose, onClose,
onDone, onDone,
}) { }) {
const activity = ACTIVITIES[kind] const activity = ACTIVITIES[kind]
const employeeRequired = activity.employeeRequired !== false const hasEmployee = activity.employee !== false
const employeeRequired = hasEmployee && activity.employeeRequired !== false
const navigate = useNavigate() const navigate = useNavigate()
const { logout } = useAuth() const { logout } = useAuth()
@ -98,15 +142,19 @@ export default function AssetActivityModal({
const [errors, setErrors] = useState({}) const [errors, setErrors] = useState({})
const [serverError, setServerError] = useState('') const [serverError, setServerError] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
// Read when the dialog opens; a new object on each parent render must not reset the form.
const initial = useRef(initialValues)
initial.current = initialValues
// Start from a blank form and a fresh employee list each time the dialog opens. // Start from a fresh form and employee list each time the dialog opens.
useEffect(() => { useEffect(() => {
if (!open) return undefined if (!open) return undefined
setEmployeeId(defaultEmployeeId != null ? String(defaultEmployeeId) : '') setEmployeeId(defaultEmployeeId != null ? String(defaultEmployeeId) : '')
setValues(emptyValues(activity.fields)) setValues({ ...emptyValues(activity.fields), ...initial.current })
setErrors({}) setErrors({})
setServerError('') setServerError('')
setSubmitting(false) setSubmitting(false)
if (!hasEmployee) return undefined
let cancelled = false let cancelled = false
setLoadingEmployees(true) setLoadingEmployees(true)
@ -125,7 +173,7 @@ export default function AssetActivityModal({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [open, activity, defaultEmployeeId, logout, navigate]) }, [open, activity, hasEmployee, defaultEmployeeId, logout, navigate])
function close() { function close() {
if (submitting) return if (submitting) return
@ -212,6 +260,7 @@ export default function AssetActivityModal({
)} )}
<form id={formId} className="form__grid" onSubmit={handleSubmit} noValidate> <form id={formId} className="form__grid" onSubmit={handleSubmit} noValidate>
{hasEmployee && (
<Field label={activity.employeeLabel} required={employeeRequired} error={errors.employee}> <Field label={activity.employeeLabel} required={employeeRequired} error={errors.employee}>
{(id) => ( {(id) => (
<SelectInput <SelectInput
@ -227,12 +276,17 @@ export default function AssetActivityModal({
/> />
)} )}
</Field> </Field>
)}
<ActivityFields <ActivityFields
fields={activity.fields} fields={activity.fields}
values={values} values={values}
errors={errors} errors={errors}
onChange={handleChange} onChange={handleChange}
disabled={submitting} disabled={submitting}
upload={
activity.activityId &&
((fieldId, file) => uploadAssetFile(activity.activityId, fieldId, file))
}
/> />
</form> </form>
</Modal> </Modal>

View File

@ -19,6 +19,8 @@ import {
ASSET_ASSIGN_FIELDS, ASSET_ASSIGN_FIELDS,
ASSET_LOST_FIELDS, ASSET_LOST_FIELDS,
ASSET_REASSIGN_FIELDS, ASSET_REASSIGN_FIELDS,
ASSET_REPAIRED_FIELDS,
ASSET_REPAIR_FIELDS,
ASSET_RETURN_FIELDS, ASSET_RETURN_FIELDS,
ASSET_STATE, ASSET_STATE,
ASSIGN_EMPLOYEE_FIELD, ASSIGN_EMPLOYEE_FIELD,
@ -26,6 +28,7 @@ import {
REASSIGN_EMPLOYEE_FIELD, REASSIGN_EMPLOYEE_FIELD,
RETURN_EMPLOYEE_FIELD, RETURN_EMPLOYEE_FIELD,
assetCode, assetCode,
assetEditValues,
assetEvents, assetEvents,
assetFieldDef, assetFieldDef,
assetInitValue, assetInitValue,
@ -56,7 +59,7 @@ export default function AssetDetails() {
const [data, setData] = useState(null) const [data, setData] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
// The activity dialog that's open: 'assign', 'reassign', 'return', 'lost' or null. // The activity dialog that's open: a key of AssetActivityModal's ACTIVITIES, or null.
const [activity, setActivity] = useState(null) const [activity, setActivity] = useState(null)
const [notice, setNotice] = useState(null) const [notice, setNotice] = useState(null)
@ -163,13 +166,18 @@ export default function AssetDetails() {
const state = instanceState(row) const state = instanceState(row)
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)
// ASSIGNED runs in Available; Reassigned and RETURNED in Assigned; Lost in const isUnderRepair = isAssetState(row, ASSET_STATE.UNDER_REPAIR)
// either. If the view sends no state, let the runtime decide rather than // 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
// 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 = !state || isAssigned
const canReportLost = !state || isAvailable || isAssigned 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. // Assigned or reassigned, whichever is newer, is who has it now.
const current = currentAssignment(row) const current = currentAssignment(row)
@ -188,6 +196,7 @@ export default function AssetDetails() {
{ key: RETURN_EMPLOYEE_FIELD, label: 'Returned by', type: 'text', value: lookupName(row, RETURN_EMPLOYEE_FIELD) }, { key: RETURN_EMPLOYEE_FIELD, label: 'Returned by', type: 'text', value: lookupName(row, RETURN_EMPLOYEE_FIELD) },
...toFields(ASSET_RETURN_FIELDS), ...toFields(ASSET_RETURN_FIELDS),
] ]
const repairFields = [...toFields(ASSET_REPAIR_FIELDS), ...toFields(ASSET_REPAIRED_FIELDS)]
const lostFields = [ const lostFields = [
{ key: LOST_EMPLOYEE_FIELD, label: 'Last owner', type: 'text', value: lookupName(row, LOST_EMPLOYEE_FIELD) }, { key: LOST_EMPLOYEE_FIELD, label: 'Last owner', type: 'text', value: lookupName(row, LOST_EMPLOYEE_FIELD) },
...toFields(ASSET_LOST_FIELDS), ...toFields(ASSET_LOST_FIELDS),
@ -210,6 +219,9 @@ export default function AssetDetails() {
reassign: `Asset reassigned to ${emp?.name}.`, reassign: `Asset reassigned to ${emp?.name}.`,
return: `Asset returned by ${emp?.name}.`, return: `Asset returned by ${emp?.name}.`,
lost: 'Asset was reported lost and closed.', 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] }[activity]
setNotice({ tone: 'success', text }) setNotice({ tone: 'success', text })
load() load()
@ -227,6 +239,9 @@ export default function AssetDetails() {
<Button icon="history" onClick={load} disabled={loading}> <Button icon="history" onClick={load} disabled={loading}>
{loading ? 'Refreshing…' : 'Refresh'} {loading ? 'Refreshing…' : 'Refresh'}
</Button> </Button>
<Button icon="edit" onClick={() => openActivity('edit')} disabled={!canEdit}>
Edit
</Button>
<Button <Button
variant="danger" variant="danger"
icon="alert" icon="alert"
@ -235,6 +250,15 @@ export default function AssetDetails() {
> >
Report Lost Report Lost
</Button> </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}> <Button icon="return" onClick={() => openActivity('return')} disabled={!canReturn}>
Return Asset Return Asset
</Button> </Button>
@ -253,12 +277,12 @@ export default function AssetDetails() {
} }
/> />
{!canAssign && !canReturn && !canReportLost && ( {!canEdit && (
<div className="notice notice--warning" role="status"> <div className="notice notice--warning" role="status">
<Icon name="alert" size={15} /> <Icon name="alert" size={15} />
<span> <span>
This asset is in "{state}", so its workflow has finished. It can't be assigned, This asset is in "{state}", so its workflow has finished. It can't be assigned, edited,
returned or reported lost any more. repaired, returned or reported lost any more.
</span> </span>
</div> </div>
)} )}
@ -346,6 +370,12 @@ export default function AssetDetails() {
</Card> </Card>
)} )}
{hasValues(repairFields) && (
<Card title={isUnderRepair ? 'Repair' : 'Last repair'} flush>
<DetailFields fields={repairFields} />
</Card>
)}
{hasValues(lostFields) && ( {hasValues(lostFields) && (
<Card title="Loss report" flush> <Card title="Loss report" flush>
<DetailFields fields={lostFields} /> <DetailFields fields={lostFields} />
@ -363,6 +393,7 @@ export default function AssetDetails() {
defaultEmployeeId={ defaultEmployeeId={
(activity === 'return' || activity === 'lost') && isAssigned ? current.id : null (activity === 'return' || activity === 'lost') && isAssigned ? current.id : null
} }
initialValues={activity === 'edit' ? assetEditValues(row) : undefined}
onClose={() => setActivity(null)} onClose={() => setActivity(null)}
onDone={handleDone} onDone={handleDone}
/> />

View File

@ -1445,10 +1445,11 @@ button {
/* ---------- Dashboard ---------- */ /* ---------- Dashboard ---------- */
.dash { .dash {
/* Categorical slots 1–3, validated all-pairs on the white card surface. */ /* Categorical slots 1–4, validated on the white card surface. */
--series-1: #2a78d6; --series-1: #2a78d6;
--series-2: #eb6834; --series-2: #eb6834;
--series-3: #1baf7a; --series-3: #1baf7a;
--series-4: #eda100;
transition: opacity 0.15s; transition: opacity 0.15s;
} }