diff --git a/src/api/assets.js b/src/api/assets.js index 4cb0a5f..c0983df 100644 --- a/src/api/assets.js +++ b/src/api/assets.js @@ -44,6 +44,21 @@ export const ASSET_INIT_ACTIVITY_ID = 'a2405547-0db4-474b-aa76-af1246d01a6a' */ 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 = [ { // 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_45', label: 'Purchase date', type: 'date' }, { 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. { id: 'field_56', @@ -89,6 +105,17 @@ export const ASSET_FIELDS = [ { 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', label: 'Condition', @@ -284,19 +311,54 @@ export const REASSIGN_EMPLOYEE_FIELD = 'field_23' * POST {APP_API_BASE_URL}/app/{APP_ID}/activity * Authorization: Bearer * { 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 * builder (…/activities/7f7b43fb-…). */ 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. */ export const ASSET_RETURN_FIELDS = [ { 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. { id: 'field_9', label: 'Damage details', 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". */ @@ -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. */ export const ASSET_STATE = { AVAILABLE: 'Available', ASSIGNED: 'Assigned', + UNDER_REPAIR: 'Under Repair', CLOSED: 'Closed', } @@ -662,11 +843,21 @@ export const ASSET_RICH_COLUMNS = [ key: 'status', label: 'Status', 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: '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', label: 'Warranty', @@ -684,7 +875,8 @@ export const ASSET_RICH_COLUMNS = [ */ export async function withAssetListDisplay(res) { 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) } @@ -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) if (present(val('field_36')) || lastOwner) { add( @@ -877,6 +1089,8 @@ export const HISTORY_FILTERS = [ { value: HISTORY_ACTION.ASSIGNED, label: 'Assigned' }, { value: HISTORY_ACTION.REASSIGNED, label: 'Reassigned' }, { 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' }, ] @@ -963,6 +1177,6 @@ export async function fetchAssetInstance(instanceId) { const row = { ...detailValues } 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 } } diff --git a/src/api/dashboard.js b/src/api/dashboard.js index f6ba82e..2f7159d 100644 --- a/src/api/dashboard.js +++ b/src/api/dashboard.js @@ -47,6 +47,7 @@ export async function fetchDashboard() { const stateCounts = new Map([ [ASSET_STATE.ASSIGNED, 0], [ASSET_STATE.AVAILABLE, 0], + [ASSET_STATE.UNDER_REPAIR, 0], [ASSET_STATE.CLOSED, 0], ]) const categoryCounts = new Map() diff --git a/src/components/ui/Icon.jsx b/src/components/ui/Icon.jsx index 6885783..7361793 100644 --- a/src/components/ui/Icon.jsx +++ b/src/components/ui/Icon.jsx @@ -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', 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', + 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', logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9', } diff --git a/src/constants.js b/src/constants.js index eb63cc5..ef8b75e 100644 --- a/src/constants.js +++ b/src/constants.js @@ -26,6 +26,7 @@ export const ASSET_STATUS_TONE = { export const ASSET_STATE_TONE = { available: 'success', assigned: 'info', + 'under repair': 'warning', closed: 'neutral', } @@ -116,6 +117,8 @@ export const HISTORY_ACTION = { RETURNED: 'Returned', TRANSFERRED: 'Transferred', MAINTENANCE: 'Maintenance', + REPAIR: 'Sent for repair', + REPAIRED: 'Repaired', LOST: 'Lost', RETIRED: 'Retired', } @@ -130,5 +133,7 @@ export const HISTORY_ACTION_TONE = { [HISTORY_ACTION.RETURNED]: 'neutral', [HISTORY_ACTION.TRANSFERRED]: 'warning', [HISTORY_ACTION.MAINTENANCE]: 'warning', + [HISTORY_ACTION.REPAIR]: 'warning', + [HISTORY_ACTION.REPAIRED]: 'success', [HISTORY_ACTION.RETIRED]: 'danger', } diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 5354fba..912ba4c 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -16,6 +16,7 @@ import { formatCurrency } from '../utils/format' const STATE_COLOR = { [ASSET_STATE.ASSIGNED]: 'var(--series-1)', [ASSET_STATE.AVAILABLE]: 'var(--series-3)', + [ASSET_STATE.UNDER_REPAIR]: 'var(--series-4)', [ASSET_STATE.CLOSED]: 'var(--series-2)', } const OTHER_COLOR = 'var(--border-strong)' @@ -106,6 +107,7 @@ export default function Dashboard() { + navigate(`/assets/${encodeURIComponent(row.instance_id)}`)} + onRowClick={(row) => navigate(`/assets/${encodeURIComponent(assetRowId(row))}`)} toolbar={
{ if (!open) return undefined setEmployeeId(defaultEmployeeId != null ? String(defaultEmployeeId) : '') - setValues(emptyValues(activity.fields)) + setValues({ ...emptyValues(activity.fields), ...initial.current }) setErrors({}) setServerError('') setSubmitting(false) + if (!hasEmployee) return undefined let cancelled = false setLoadingEmployees(true) @@ -125,7 +173,7 @@ export default function AssetActivityModal({ return () => { cancelled = true } - }, [open, activity, defaultEmployeeId, logout, navigate]) + }, [open, activity, hasEmployee, defaultEmployeeId, logout, navigate]) function close() { if (submitting) return @@ -212,27 +260,33 @@ export default function AssetActivityModal({ )}
- - {(id) => ( - { - setEmployeeId(e.target.value) - if (errors.employee) setErrors((er) => ({ ...er, employee: undefined })) - }} - options={employeeOptions} - placeholder={loadingEmployees ? 'Loading employees…' : 'Select employee'} - disabled={submitting || loadingEmployees} - /> - )} - + {hasEmployee && ( + + {(id) => ( + { + setEmployeeId(e.target.value) + if (errors.employee) setErrors((er) => ({ ...er, employee: undefined })) + }} + options={employeeOptions} + placeholder={loadingEmployees ? 'Loading employees…' : 'Select employee'} + disabled={submitting || loadingEmployees} + /> + )} + + )} uploadAssetFile(activity.activityId, fieldId, file)) + } /> diff --git a/src/pages/assets/AssetDetails.jsx b/src/pages/assets/AssetDetails.jsx index c670c0b..9798c9e 100644 --- a/src/pages/assets/AssetDetails.jsx +++ b/src/pages/assets/AssetDetails.jsx @@ -19,6 +19,8 @@ import { ASSET_ASSIGN_FIELDS, ASSET_LOST_FIELDS, ASSET_REASSIGN_FIELDS, + ASSET_REPAIRED_FIELDS, + ASSET_REPAIR_FIELDS, ASSET_RETURN_FIELDS, ASSET_STATE, ASSIGN_EMPLOYEE_FIELD, @@ -26,6 +28,7 @@ import { REASSIGN_EMPLOYEE_FIELD, RETURN_EMPLOYEE_FIELD, assetCode, + assetEditValues, assetEvents, assetFieldDef, assetInitValue, @@ -56,7 +59,7 @@ export default function AssetDetails() { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) 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 [notice, setNotice] = useState(null) @@ -163,13 +166,18 @@ export default function AssetDetails() { const state = instanceState(row) const isAssigned = isAssetState(row, ASSET_STATE.ASSIGNED) const isAvailable = isAssetState(row, ASSET_STATE.AVAILABLE) - // ASSIGNED runs in Available; Reassigned and RETURNED in Assigned; Lost in - // either. If the view sends no state, let the runtime decide rather than + 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 // hiding the buttons. const canAssign = !state || isAvailable const canReassign = !state || isAssigned const canReturn = !state || 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. 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) }, ...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), @@ -210,6 +219,9 @@ export default function AssetDetails() { 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() @@ -227,6 +239,9 @@ export default function AssetDetails() { + + {isUnderRepair ? ( + + ) : ( + + )} @@ -253,12 +277,12 @@ export default function AssetDetails() { } /> - {!canAssign && !canReturn && !canReportLost && ( + {!canEdit && (
- This asset is in "{state}", so its workflow has finished. It can't be assigned, - returned or reported lost any more. + This asset is in "{state}", so its workflow has finished. It can't be assigned, edited, + repaired, returned or reported lost any more.
)} @@ -346,6 +370,12 @@ export default function AssetDetails() { )} + {hasValues(repairFields) && ( + + + + )} + {hasValues(lostFields) && ( @@ -363,6 +393,7 @@ export default function AssetDetails() { defaultEmployeeId={ (activity === 'return' || activity === 'lost') && isAssigned ? current.id : null } + initialValues={activity === 'edit' ? assetEditValues(row) : undefined} onClose={() => setActivity(null)} onDone={handleDone} /> diff --git a/src/styles/global.css b/src/styles/global.css index 15da89e..2e11040 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1445,10 +1445,11 @@ button { /* ---------- Dashboard ---------- */ .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-2: #eb6834; --series-3: #1baf7a; + --series-4: #eda100; transition: opacity 0.15s; }