From f845af5125f01097fb7a5840e7f1922902698af7 Mon Sep 17 00:00:00 2001 From: "vamsi.balaga" Date: Fri, 9 Oct 2026 11:32:19 +0530 Subject: [PATCH] feat: asset table view, file uploads, rich list columns and label cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assets - Read assets from the asset table record view (de18263e-…), keyed by _pk_value; rows open by assetRowId and the list sorts newest first. - Update INIT fields to the builder: Asset Details (was Category), Asset State/Priority/Classification options, warranty fields, Brand, ID, Location, and the images field (field_53); Asset Owner is field_51. - Upload photos and an Invoice document before the activity runs, and send the returned file objects. The Invoice field id is a placeholder until the field exists in the builder. - Show every Add Asset field as a list and details column, with option labels, and group related columns into rich cells. - Use the INIT activity's detail view template (a573754f-…). - Drop Assigned by, Condition and Type from the reassign, return and lost forms; add Lost manager name options. Employees - Department is a select from DEPARTMENTS; more employee types; status removed from the forms; rich columns in the employee list. Shared - apiFetch sends FormData bodies as multipart. - Default workflow host is dev-apps.getzino.in. - displayLabel() for readable field labels; Raw response panels removed. - useRecordView accepts an initial sort. - Temporary dev proxy in vite.config.js for rv-screens lookups. Co-Authored-By: Claude Opus 5.5 --- .env.example | 7 +- Employee_data.postman_collection.json | 10 +- src/api/activityForm.js | 90 +++++- src/api/assets.js | 327 ++++++++++++++------ src/api/client.js | 8 +- src/api/dashboard.js | 9 +- src/api/detailView.js | 4 +- src/api/employees.js | 45 ++- src/api/recordView.js | 6 +- src/api/richColumns.js | 59 ++++ src/components/activity/ActivityFields.jsx | 327 ++++++++++++++++---- src/components/activity/FieldValue.jsx | 97 +++++- src/components/activity/RecordView.jsx | 67 +++- src/constants.js | 32 +- src/hooks/useRecordView.js | 7 +- src/pages/assets/AddAssetModal.jsx | 9 +- src/pages/assets/AssetDetails.jsx | 18 +- src/pages/assets/AssetList.jsx | 13 +- src/pages/employees/EmployeeDetails.jsx | 14 +- src/pages/employees/EmployeeList.jsx | 9 +- src/pages/employees/UpdateEmployeeModal.jsx | 11 +- src/styles/global.css | 80 +++-- src/utils/format.js | 11 + vite.config.js | 10 + 24 files changed, 1005 insertions(+), 265 deletions(-) create mode 100644 src/api/richColumns.js diff --git a/.env.example b/.env.example index 0753eaa..1410693 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,4 @@ VITE_API_BASE_URL=https://dev-apps.getzino.in # App/workflow host — POST /app/{APP_ID}/start -VITE_APP_API_BASE_URL=https://dev.getzino.in - -# Asset workflow — take workflow_uuid from the asset activity's sample cURL in -# the Zino editor. The uuid in the editor URL is a different id and is rejected. -# VITE_ASSET_WORKFLOW_UUID= -# VITE_ASSET_WORKFLOW_VERSION=1 +VITE_APP_API_BASE_URL=https://dev-apps.getzino.in diff --git a/Employee_data.postman_collection.json b/Employee_data.postman_collection.json index f89f1cd..6717619 100644 --- a/Employee_data.postman_collection.json +++ b/Employee_data.postman_collection.json @@ -65,7 +65,7 @@ "url": { "raw": "{{app_url}}/app/531/view/recordview", "host": ["{{app_url}}"], "path": ["app", "531", "view", "recordview"] }, "body": { "mode": "raw", - "raw": "{\n \"rv_template_uid\": \"c91be68b-2650-44ef-b566-8f7ca3b85b28\",\n \"preset_alias\": \"\",\n \"params\": {},\n \"search_query\": { \"page\": 1, \"limit\": 10, \"sort_by\": \"\", \"sort_dir\": \"\", \"search\": \"\", \"filters\": [] }\n}" + "raw": "{\n \"rv_template_uid\": \"de18263e-dc82-4d4d-98a2-99607104373b\",\n \"preset_alias\": \"\",\n \"params\": {},\n \"search_query\": { \"page\": 1, \"limit\": 10, \"sort_by\": \"\", \"sort_dir\": \"\", \"search\": \"\", \"filters\": [] }\n}" } } }, @@ -128,6 +128,14 @@ "raw": "{\n \"activity_id\": \"7dcde7c9-82ce-4dd5-bcca-68fca6904eb4\",\n \"data\": {\n \"field_16\": \"2026-09-25\",\n \"field_33\": \"Reason for leaving\",\n \"field_39\": { \"id\": 8 },\n \"field_40\": { \"id\": 8, \"employee_code\": \"7656565\" }\n },\n \"instance_id\": \"INSTANCE_ID_FROM_INIT\",\n \"workflow_uuid\": \"f4a303c2-869a-47d5-a5f3-1d2f7d5ae58e\"\n}" } } + }, + { + "name": "8. List Record Views (find rv_template_uid)", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{token}}" }], + "url": { "raw": "{{auth_url}}/api/apps/531/rv-screens", "host": ["{{auth_url}}"], "path": ["api", "apps", "531", "rv-screens"] } + } } ] } diff --git a/src/api/activityForm.js b/src/api/activityForm.js index 74bba86..38f8b5f 100644 --- a/src/api/activityForm.js +++ b/src/api/activityForm.js @@ -1,13 +1,73 @@ +import { APP_API_BASE_URL, apiFetch } from './client' + /** * Helpers shared by every Zino activity form. A field definition mirrors what * the builder stores: * { id, label, type, options?, computed?, maxSizeMb?, slots? } * where `type` is the builder's data_type: text, email, number, date, select, - * longtext, geolocation, image, id_gen. `slots` (image only) names one photo + * longtext, geolocation, image, file, id_gen. `file` is a document (PDF or + * image, e.g. an invoice) uploaded like a photo. `slots` (image only) names one photo * per view, e.g. ['Front', 'Back', 'Side']; `otherSlot` names a box for any * number of extra photos. */ +/** + * Upload one file for an image field, before the activity runs. The activity + * then sends the returned file objects (not the image itself) as the field's + * value, always as a list: + * + * POST {APP_API_BASE_URL}/app/{APP_ID}/upload (multipart/form-data) + * Authorization: Bearer + * file, workflow_uuid, activity_id, field_id, version_uuid + * + * → { blob_path, mime_type, original_name, size_bytes, uuid } + * + * Matches the request the Zino app itself sends when a photo is picked. + */ +export async function uploadActivityFile({ appId, workflowUuid, versionUuid, activityId, fieldId, file }) { + const form = new FormData() + form.append('file', file, file.name) + form.append('workflow_uuid', workflowUuid) + form.append('activity_id', activityId) + form.append('field_id', fieldId) + form.append('version_uuid', versionUuid) + + const res = await apiFetch(`${APP_API_BASE_URL}/app/${appId}/upload`, { method: 'POST', body: form }) + // The file object may come back bare, wrapped in `data`, or as a one-item list. + let meta = res?.data ?? res + if (Array.isArray(meta)) meta = meta[0] + if (!meta?.blob_path) throw new Error('The upload did not return a file. Please try again.') + return uploadedFile({ + mime_type: file.type, + original_name: file.name, + size_bytes: file.size, + ...meta, + }) +} + +/** The file object an activity sends for an uploaded image. */ +const uploadedFile = ({ blob_path, mime_type, original_name, size_bytes, uuid }) => ({ + blob_path, + mime_type, + original_name, + size_bytes, + uuid, +}) + +/** + * The photos held by an image field's form value, in send order: a single + * photo, or photo slots ({ Front, Back, …, Other: [...] }) with the other + * photos last. Each is { key, preview, uploading } while it uploads, then the + * uploaded file object plus { key, preview }. + */ +export function imageEntries(field, raw) { + if (!raw) return [] + const list = field.slots?.length + ? [...field.slots.map((slot) => raw[slot]), ...(field.otherSlot ? raw[field.otherSlot] ?? [] : [])] + : [raw] + return list.filter(Boolean) +} + /** * Turn a raw backend error into something actionable. * @@ -68,14 +128,17 @@ export function buildActivityData(fields, values) { if (Number.isFinite(n)) data[field.id] = n continue } - // Photo slots ({ Front, Back, …, Other: [...] }) go out as one list, in - // slot order, with the other photos last. - if (field.type === 'image' && field.slots?.length) { - const images = [ - ...field.slots.map((slot) => raw[slot]), - ...(field.otherSlot ? raw[field.otherSlot] ?? [] : []), - ].filter(Boolean) - if (images.length) data[field.id] = images + // Photos go out as a list of uploaded file objects — one photo or several + // (slot order, other photos last). One still uploading is left out; + // validateActivity stops the submit before that happens. + if (field.type === 'image') { + const files = imageEntries(field, raw).filter((e) => !e.uploading).map(uploadedFile) + if (files.length) data[field.id] = files + continue + } + // A document goes out the same way, as a one-item list. + if (field.type === 'file') { + if (!raw.uploading) data[field.id] = [uploadedFile(raw)] continue } data[field.id] = typeof raw === 'string' ? raw.trim() : raw @@ -114,6 +177,13 @@ export function validateActivity(fields, values) { // Past this, JS rounds the value (e.g. 8.95e+26), so what we'd send is not // what was typed — and it would overflow the backend's column anyway. else if (Math.abs(n) > Number.MAX_SAFE_INTEGER) errors[field.id] = 'This number is too long.' - } } + } + if (field.type === 'image' && imageEntries(field, raw).some((e) => e.uploading)) { + errors[field.id] = 'Wait for the photo to finish uploading.' + } + if (field.type === 'file' && raw.uploading) { + errors[field.id] = 'Wait for the file to finish uploading.' + } + } return errors } diff --git a/src/api/assets.js b/src/api/assets.js index d2847fb..4cb0a5f 100644 --- a/src/api/assets.js +++ b/src/api/assets.js @@ -1,7 +1,8 @@ import { APP_API_BASE_URL, apiFetch } from './client' -import { buildActivityData, explainActivityError, instanceState } from './activityForm' +import { buildActivityData, explainActivityError, instanceState, uploadActivityFile } from './activityForm' import { fetchEmployeeListPage } from './employees' import { RECORD_VIEWS, fetchAllRecordRows, toISODate } from './recordView' +import { withRichColumns } from './richColumns' import { DETAIL_VIEWS, fetchDetailView, normLabel, normalizeDetailView } from './detailView' import { HISTORY_ACTION } from '../constants' @@ -12,30 +13,44 @@ import { HISTORY_ACTION } from '../constants' * POST {APP_API_BASE_URL}/app/{APP_ID}/start * Authorization: Bearer * { activity_id, workflow_uuid, version, - * data: { field_42 … field_58, field_59: { id, first_name } } } + * data: { field_42 … field_61, field_51: { id, first_name } } } + * + * Matches the INIT activity's sample cURL in the builder. version_uuid and + * version_id are sent too, as the Zino app itself does. * * NOTE ON IDS - * 762affb9-… in the editor URL is the workflow *version* uid. The workflow - * uuid the start endpoint wants is b5ea718c-… (from GET /api/apps/531/workflows, - * where this workflow is named "asset workflow"). + * 762affb9-… in the editor URL is the workflow *version* uid (version_uuid); + * 150002 is that version's numeric id (version_id). The workflow uuid is + * b5ea718c-… (from GET /api/apps/531/workflows, "asset workflow"). */ export const ASSET_APP_ID = '531' export const ASSET_WORKFLOW_UUID = 'b5ea718c-e1a3-4d73-ac72-d0fc25f8b611' -export const ASSET_VERSION_UID = '762affb9-ed54-40e5-9958-b27ff6772893' -export const ASSET_INIT_ACTIVITY_ID = 'a2405547-0db4-474b-aa76-af1246d01a6a' export const ASSET_WORKFLOW_VERSION = 1 +export const ASSET_VERSION_UID = '762affb9-ed54-40e5-9958-b27ff6772893' +export const ASSET_VERSION_ID = 150002 +export const ASSET_INIT_ACTIVITY_ID = 'a2405547-0db4-474b-aa76-af1246d01a6a' /** * INIT activity fields, exactly as configured in the Zino builder — same ids, - * labels, types, option values and order. - * Source: GET /api/apps/531/versions/{ASSET_VERSION_UID}/activities/{id}/data-fields + * labels, types, option values and order. "Asset_Owner" (field_51) is a lookup + * the form picks separately; see ASSET_OWNER_FIELD. + * Source: the INIT activity's Fields tab and sample cURL in the builder. */ +/** + * The Invoice file field. PLACEHOLDER id: add a file field labelled "Invoice" + * to the INIT activity in the Zino builder, deploy, and put its id here. Until + * then, picking an invoice fails to upload; assets without one save as before. + */ +export const ASSET_INVOICE_FIELD = 'field_62' + export const ASSET_FIELDS = [ - { id: 'idgen_1', label: 'Asset ID', type: 'id_gen', computed: true }, { + // The asset table stores it in its asset_details column. formLabel also + // renames a view column of this field that is still labelled "Category". id: 'field_43', - label: 'Category', + label: 'Asset Details', + formLabel: 'Asset Details', type: 'select', options: [ { label: 'Desktop', value: 'desktop' }, @@ -52,6 +67,28 @@ 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' }, + // Option values must match the builder exactly. + { + id: 'field_56', + label: 'Asset State', + type: 'select', + options: [ + { label: 'In use', value: 'in_use' }, + { label: 'Available', value: 'available' }, + { label: 'Under maintenance', value: 'under_maintenance' }, + { label: 'Retired', value: 'retired' }, + ], + }, + { + id: 'field_58', + label: 'Asset Priority', + type: 'select', + options: [ + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium' }, + { label: 'High', value: 'high' }, + ], + }, { id: 'field_47', label: 'Condition', @@ -61,50 +98,74 @@ export const ASSET_FIELDS = [ { label: 'damage', value: 'damage' }, ], }, - // One photo per view, then any number of others; sent as one list in this order. + { id: 'field_50', label: 'CPU', type: 'text' }, + { id: 'field_54', label: 'Warranty Start date', type: 'date' }, + { + id: 'field_57', + label: 'Asset Classification', + type: 'select', + options: [ + { label: 'Internal', value: 'internal' }, + { label: 'External', value: 'external' }, + ], + }, + { id: 'field_52', label: 'Hard Drive', type: 'text' }, + { id: 'field_49', label: 'Asset identifier', type: 'text' }, + // `label` keeps the builder's spelling (the asset table's columns are named + // after it); `formLabel` is what the app shows. + { id: 'field_55', label: 'Warrenty End date', formLabel: 'Warranty End date', type: 'date' }, { id: 'field_48', - label: 'image', + label: 'Warrenty Status', + formLabel: 'Warranty Status', + type: 'select', + // under_warrenty is the builder's value (from the sample cURL), misspelling included. + options: [ + { label: 'Under warranty', value: 'under_warrenty' }, + { label: 'Out of warranty', value: 'out_of_the_warrenty' }, + { label: 'In AMC', value: 'in_amc' }, + { label: 'Not in AMC', value: 'not_in_amc' }, + ], + }, + // One photo per view, then any number of others; sent as one list in this order. + // `column` is the asset table's column for it, where it isn't named after the label. + { + id: 'field_53', + label: 'images', + formLabel: 'Images', + column: 'image', type: 'image', maxSizeMb: 5, slots: ['Front', 'Back', 'Side'], otherSlot: 'Other', }, - { id: 'field_50', label: 'CPU', type: 'text' }, - { id: 'field_51', label: 'Warranty status', type: 'text' }, - { id: 'field_52', label: 'Hard Drive', type: 'text' }, - // No options are configured for this select in the builder. - { id: 'field_53', label: 'New Activity Field', type: 'select', options: [] }, - { id: 'field_54', label: 'Warranty Start date', type: 'date' }, - { id: 'field_55', label: 'Warrenty End date', type: 'date' }, - // Only the first option of each select below is known (from the API sample) — - // copy the rest from the builder. - { - id: 'field_56', - label: 'Asset State', - type: 'select', - options: [{ label: 'In use', value: 'in_use' }], - }, - { - id: 'field_57', - label: 'Asset Classification', - type: 'select', - options: [{ label: 'Internal', value: 'internal' }], - }, - { - id: 'field_58', - label: 'Asset Priority', - type: 'select', - options: [{ label: 'Medium', value: 'medium' }], - }, + { id: 'field_59', label: 'Asset Brand', type: 'text' }, + { id: 'field_60', label: 'Asset ID', type: 'text' }, + { id: 'field_61', label: 'Asset Location', type: 'text' }, + // The purchase invoice, to check what was bought, when and for how much. + { id: ASSET_INVOICE_FIELD, label: 'Invoice', type: 'file', maxSizeMb: 10 }, ] +/** + * Upload a photo for an image field of one of the asset workflow's activities. + * Resolves with the file object the activity sends for it. + */ +export const uploadAssetFile = (activityId, fieldId, file) => + uploadActivityFile({ + appId: ASSET_APP_ID, + workflowUuid: ASSET_WORKFLOW_UUID, + versionUuid: ASSET_VERSION_UID, + activityId, + fieldId, + file, + }) + /** * "Asset Owner": a lookup into the employee table, like ASSIGNED's "Employee * data". The API sample shows only { id }; it is sent as { id, first_name } * because the runtime checks lookups against their storage columns. */ -export const ASSET_OWNER_FIELD = 'field_59' +export const ASSET_OWNER_FIELD = 'field_51' /** * Start the asset INIT activity. `owner` is one entry from @@ -117,12 +178,14 @@ export async function createAsset(values, owner = null) { method: 'POST', body: { activity_id: ASSET_INIT_ACTIVITY_ID, - workflow_uuid: ASSET_WORKFLOW_UUID, - version: ASSET_WORKFLOW_VERSION, data: { ...buildActivityData(ASSET_FIELDS, values), ...(owner ? { [ASSET_OWNER_FIELD]: employeeLookup(owner) } : {}), }, + version: ASSET_WORKFLOW_VERSION, + workflow_uuid: ASSET_WORKFLOW_UUID, + version_uuid: ASSET_VERSION_UID, + version_id: ASSET_VERSION_ID, }, }) } catch (err) { @@ -196,7 +259,7 @@ export async function fetchAssignableEmployees() { * POST {APP_API_BASE_URL}/app/{APP_ID}/activity * Authorization: Bearer * { activity_id, workflow_uuid, instance_id, - * data: { field_4, field_11, field_12, field_23: { id, first_name } } } + * data: { field_4, field_12, field_23: { id, first_name } } } * * Source: the asset workflow "Reassigned" activity, Fields tab, in the Zino * builder (…/activities/db4463c4-…). @@ -206,8 +269,7 @@ export const ASSET_REASSIGN_ACTIVITY_ID = 'db4463c4-033b-496d-9d21-88a6e5159028' /** Fields the user fills in. */ export const ASSET_REASSIGN_FIELDS = [ { id: 'field_4', label: 'Assigned date', type: 'date' }, - // Options aren't known yet — copy them from the builder. - { id: 'field_11', label: 'Assigned by', type: 'select', options: [] }, + // Assigned by (field_11) is left out of the form on purpose. { id: 'field_12', label: 'Remarks', type: 'longtext' }, ] @@ -222,7 +284,7 @@ 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_29, field_28: { id, first_name } } } + * data: { field_8, field_9, field_10, field_28: { id, first_name } } } * * Source: the asset workflow "RETURNED" activity, Fields tab, in the Zino * builder (…/activities/7f7b43fb-…). @@ -232,8 +294,7 @@ export const ASSET_RETURN_ACTIVITY_ID = '7f7b43fb-557c-483a-97a5-09b0a8080622' /** Fields the user fills in. */ export const ASSET_RETURN_FIELDS = [ { id: 'field_8', label: 'Returned date', type: 'date' }, - // Options aren't known yet — copy them from the builder. - { id: 'field_29', label: 'Condition', type: 'select', options: [] }, + // 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' }, ] @@ -250,7 +311,7 @@ export const RETURN_EMPLOYEE_FIELD = 'field_28' * POST {APP_API_BASE_URL}/app/{APP_ID}/activity * Authorization: Bearer * { activity_id, workflow_uuid, instance_id, - * data: { field_36 … field_41, field_35: { id, first_name } } } + * data: { field_36, field_38 … field_41, field_35: { id, first_name } } } * * Source: the asset workflow "Lost" activity, Fields tab, in the Zino builder * (…/activities/5e9b6f96-…). @@ -260,11 +321,24 @@ export const ASSET_LOST_ACTIVITY_ID = '5e9b6f96-24d2-4412-a41c-dad21e6ae70c' /** Fields the user fills in. */ export const ASSET_LOST_FIELDS = [ { id: 'field_36', label: 'Date reported', type: 'date' }, - // Options for both selects aren't known yet — copy them from the builder. - { id: 'field_37', label: 'Type', type: 'select', options: [] }, + // Type (field_37) is left out of the form on purpose. { id: 'field_39', label: 'Reference number', type: 'number' }, { id: 'field_40', label: 'Recovery cost', type: 'number' }, - { id: 'field_41', label: 'Manager name', type: 'select', options: [] }, + { + id: 'field_41', + label: 'Manager name', + type: 'select', + // The builder has no options yet; add these there too if the runtime rejects them. + options: [ + 'Reporting Manager', + 'HR Manager', + 'IT Manager', + 'Admin Manager', + 'Finance Manager', + 'Operations Manager', + 'Department Head', + ].map((m) => ({ label: m, value: m })), + }, { id: 'field_38', label: 'Remarks', type: 'longtext' }, ] @@ -513,7 +587,7 @@ export const fetchReturnListPage = (templateUid, query = {}) => fetchAssetActivityPage(templateUid, query, { columns: RETURN_COLUMNS, filters: RETURN_FILTERS }) /** - * An INIT value of an asset row by builder label ('Category', 'Asset ID'…): + * An INIT value of an asset row by builder label ('Asset Details', 'Asset ID'…): * from the asset view's column with that label, else from the raw field id. */ export function assetInitValue(config, row, label) { @@ -521,7 +595,7 @@ export function assetInitValue(config, row, label) { const v = col ? row[col.field_key] : undefined if (v != null && v !== '') return v const def = ASSET_FIELDS.find((f) => normLabel(f.label) === normLabel(label)) - return def ? row[def.id] : undefined + return def ? row[initValueKey(def, row)] : undefined } /** Every INIT field the form fills, plus Asset Owner, as { id, label, type, options?, slots? }. */ @@ -532,9 +606,13 @@ const ASSET_FORM_FIELDS = [ /** The Add Asset field a view column shows, matched by field id or label. */ export function assetFieldDef(key, label) { - return ASSET_FORM_FIELDS.find((d) => d.id === key || normLabel(d.label) === normLabel(label)) + return ASSET_FORM_FIELDS.find((d) => d.id === key || hasLabel(d, label)) } +/** True when `label` is the field's builder label, display label or table column. */ +const hasLabel = (def, label) => + [def.label, def.formLabel, def.column].some((l) => l && normLabel(l) === normLabel(label)) + /** A select's option label for `value`, else `value` itself. */ export const optionLabel = (def, value) => def?.type === 'select' ? def.options?.find((o) => o.value === value)?.label ?? value : value @@ -572,45 +650,110 @@ export async function withOwnerNames(config, rows) { }) } -/** Asset list rows for display: owner names, plus a column for every Add Asset field. */ +/** + * Related asset list columns shown together, one cell each (see + * api/richColumns.js). Labels are the Add Asset form's, which + * withAssetFormColumns() gives every column. + */ +export const ASSET_RICH_COLUMNS = [ + { key: 'asset', label: 'Asset', title: ['Model'], subtitle: ['Asset ID', 'Asset Details', 'Asset Brand'] }, + { key: 'serial', label: 'Serial', title: ['Serial number'], subtitle: ['Asset identifier'] }, + { + key: 'status', + label: 'Status', + title: ['Asset State'], + subtitle: [{ label: 'Asset Priority', prefix: 'Priority' }, 'Condition', '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: 'warranty', + label: 'Warranty', + title: ['Warranty Status'], + subtitle: [ + { label: 'Warranty Start date', prefix: 'From' }, + { label: 'Warranty End date', prefix: 'to' }, + ], + }, +] + +/** + * Asset list rows for display: owner names, a column for every Add Asset + * field, and related columns grouped (ASSET_RICH_COLUMNS). + */ export async function withAssetListDisplay(res) { const rows = Array.isArray(res?.data) ? res.data : [] - return withAssetFormColumns({ ...res, data: await withOwnerNames(res?.config, rows) }) + const display = withAssetFormColumns({ ...res, data: await withOwnerNames(res?.config, rows) }) + return withRichColumns(display, ASSET_RICH_COLUMNS) +} + +/** + * The row key holding an INIT field's value. Workflow-backed views send it by + * field id (field_43); table-backed views send the storage column, named after + * the builder label (category, warranty_status…). + */ +function initValueKey(def, row) { + if (present(row[def.id])) return def.id + const names = [def.label, def.column].filter(Boolean).map(normLabel) + return Object.keys(row).find((k) => names.includes(normLabel(k)) && present(row[k])) ?? def.id } /** What an INIT field shows: a select's option label, the owner's name. */ function initDisplayValue(def, row) { - const v = row[def.id] - if (def.type === 'lookup') return lookupName(row, def.id) - return optionLabel(def, v) ?? '' + const key = initValueKey(def, row) + if (def.type === 'lookup') return lookupName(row, key) + return optionLabel(def, row[key]) ?? '' } /** - * Add a column for every INIT field the asset record view doesn't show yet - * (e.g. fields added in the builder after the view was set up), so everything - * the Add Asset form fills appears. A field counts as shown when the view has - * a column with its id or its label. The values come from the row's raw field - * ids, which the workflow-backed view sends along with its own columns. + * One column per field the Add Asset form fills, in the form's order, then the + * view's other columns (created_at…). A view column belongs to a field when it + * has the field's id, label or table column name; it shows the form's label + * (the table view labels columns by storage name, e.g. asset_brand), a select + * shows its option label ("Under warranty", not "under_warrenty"), and a photo + * column shows photos. + * + * A field the view has no column for (e.g. added in the builder after the + * table was set up) still gets one, filled from the row's raw field id when + * the view sends it — otherwise it stays empty until the table has a column. */ export function withAssetFormColumns(res) { - const fields = res?.config?.fields ?? [] - const shown = (def) => - fields.some((c) => c.field_key === def.id || normLabel(c.output_label) === normLabel(def.label)) - const missing = ASSET_FORM_FIELDS.filter((def) => !shown(def)) - if (!missing.length) return res + const isDef = (def, c) => c.field_key === def.id || hasLabel(def, c.output_label) + const viewFields = res?.config?.fields ?? [] + const defOf = new Map(viewFields.map((c) => [c, ASSET_FORM_FIELDS.find((d) => isDef(d, c))])) + const formLabel = (def) => (def.formLabel ?? def.label).trim() + const shownAs = (c, def) => ({ + ...c, + output_label: formLabel(def), + ...(def.type === 'image' && { data_type: 'image', slots: def.slots, otherSlot: def.otherSlot }), + ...(def.type === 'file' && { data_type: 'file' }), + }) const key = (def) => `init_${def.id}` - const columns = missing.map((def) => ({ - ...derived(key(def), def.label.trim(), ['select', 'lookup'].includes(def.type) ? 'text' : def.type), + const added = (def) => ({ + ...derived(key(def), formLabel(def), ['select', 'lookup'].includes(def.type) ? 'text' : def.type), slots: def.slots, otherSlot: def.otherSlot, - })) + }) + + const missing = [] + const formColumns = ASSET_FORM_FIELDS.flatMap((def) => { + const own = viewFields.filter((c) => defOf.get(c) === def) + if (own.length) return own.map((c) => shownAs(c, def)) + missing.push(def) + return [added(def)] + }) + const otherColumns = viewFields.filter((c) => !defOf.get(c)) + const selectColumns = viewFields.filter((c) => defOf.get(c)?.type === 'select') + const rows = Array.isArray(res?.data) ? res.data : [] return { ...res, - config: { ...res?.config, fields: [...fields, ...columns] }, + config: { ...res?.config, fields: [...formColumns, ...otherColumns] }, data: rows.map((row) => ({ ...row, + ...Object.fromEntries(selectColumns.map((c) => [c.field_key, optionLabel(defOf.get(c), row[c.field_key])])), ...Object.fromEntries(missing.map((def) => [key(def), initDisplayValue(def, row)])), })), } @@ -623,6 +766,15 @@ export function assetCode(config, row) { } const present = (v) => v !== undefined && v !== null && v !== '' + +/** + * The id an asset row is opened by (/assets/:id) and that the detail view gets + * as `instance_id`. The asset record view reads the asset table, whose rows + * carry `_pk_value` / `id` rather than `instance_id`. + */ +export function assetRowId(row) { + return [row?.instance_id, row?._pk_value, row?.id].find(present) +} /** Non-empty parts joined with ' · '; `false` (from `cond && part`) is skipped. */ const joinParts = (...parts) => parts.filter((p) => p !== false && present(p)).join(' · ') @@ -639,7 +791,7 @@ export function assetEvents(config, row) { const events = [] const add = (action, date, employee, meta, notes) => events.push({ - id: `${row.instance_id}-${action}`, + id: `${assetRowId(row)}-${action}`, action, date: present(date) ? toISODate(date) : '', employee: employee || '', @@ -655,7 +807,7 @@ export function assetEvents(config, row) { purchased, '', joinParts( - assetInitValue(config, row, 'Category'), + assetInitValue(config, row, 'Asset Details'), assetInitValue(config, row, 'Model'), present(cost) && `Cost ${cost}`, ), @@ -732,7 +884,7 @@ export const HISTORY_FILTERS = [ const HISTORY_COLUMNS = [ derived('date', 'Date', 'date'), derived('asset_code', 'Asset ID', 'id_gen'), - derived('category', 'Category'), + derived('category', 'Asset Details'), derived('model', 'Model'), derived('action', 'Event', 'history_action'), derived('employee', 'Employee'), @@ -752,9 +904,9 @@ export async function fetchHistoryListPage(templateUid, query = {}) { const data = rows .flatMap((row) => { const shared = { - instance_id: row.instance_id, + instance_id: assetRowId(row), asset_code: assetCode(config, row), - category: assetInitValue(config, row, 'Category') ?? '', + category: assetInitValue(config, row, 'Asset Details') ?? '', model: assetInitValue(config, row, 'Model') ?? '', state: instanceState(row), } @@ -785,8 +937,8 @@ export async function fetchHistoryListPage(templateUid, query = {}) { * The row comes from the asset record view (it carries the state and the * activity values), with the asset detail view's values filled in where the * record view has none — the detail view also holds fields the record view has - * no column for. `config` is the column config the details page shows: the - * detail view's fields when it sends any, else the record view's columns. + * no column for. `config` is the record view's column config, so the details + * page shows the same fields as the asset list. * If the detail view fails, the page still works from the record view alone. * @returns {Promise<{config: object|null, row: object|null}>} */ @@ -798,7 +950,7 @@ export async function fetchAssetInstance(instanceId) { return null }), ]) - const listRow = rows.find((r) => String(r.instance_id) === String(instanceId)) || null + const listRow = rows.find((r) => String(assetRowId(r)) === String(instanceId)) || null const view = detail ? normalizeDetailView(detail, { instanceId, fieldDefs: ASSET_FORM_FIELDS }) : null const detailFields = (view?.sections ?? []).flatMap((s) => s.fields).filter((f) => f.key) @@ -811,11 +963,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 detailConfig = detailFields.length && { - ...config, - fields: detailFields.map((f) => ({ field_key: f.key, output_label: f.label, data_type: f.type })), - } - const shownConfig = detailConfig || config - const [named] = await withOwnerNames(shownConfig, [row]) - return { config: shownConfig, row: named } + const [named] = await withOwnerNames(config, [row]) + return { config, row: named } } diff --git a/src/api/client.js b/src/api/client.js index c4867e4..217a623 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -10,7 +10,7 @@ export const API_BASE_URL = ( /** App/workflow host (activities such as "start"). */ export const APP_API_BASE_URL = ( - import.meta.env.VITE_APP_API_BASE_URL || 'https://dev.getzino.in' + import.meta.env.VITE_APP_API_BASE_URL || 'https://dev-apps.getzino.in' ).replace(/\/+$/, '') export const TOKEN_KEY = 'oam.auth.token' @@ -36,10 +36,12 @@ export function getStoredToken() { /** * JSON request helper. Attaches the bearer token when one is stored, * parses JSON responses and throws ApiError on non-2xx. + * A FormData body is sent as multipart; the browser sets its boundary. */ export async function apiFetch(path, { method = 'GET', body, headers, auth = true, ...rest } = {}) { const url = path.startsWith('http') ? path : `${API_BASE_URL}${path}` const token = auth ? getStoredToken() : null + const isForm = body instanceof FormData let response try { @@ -47,11 +49,11 @@ export async function apiFetch(path, { method = 'GET', body, headers, auth = tru method, headers: { accept: 'application/json, text/plain, */*', - ...(body !== undefined && { 'content-type': 'application/json' }), + ...(body !== undefined && !isForm && { 'content-type': 'application/json' }), ...(token && { authorization: `Bearer ${token}` }), ...headers, }, - body: body !== undefined ? JSON.stringify(body) : undefined, + body: isForm ? body : body !== undefined ? JSON.stringify(body) : undefined, ...rest, }) } catch (err) { diff --git a/src/api/dashboard.js b/src/api/dashboard.js index 95a763d..f6ba82e 100644 --- a/src/api/dashboard.js +++ b/src/api/dashboard.js @@ -5,6 +5,7 @@ import { assetCode, assetEvents, assetInitValue, + assetRowId, currentAssignment, } from './assets' import { fetchEmployeeListPage } from './employees' @@ -15,9 +16,9 @@ import { RECORD_VIEWS, fetchAllRecordRows } from './recordView' * read (assets, employees), so the numbers always agree with them. */ -/** 'mobile_phone' -> 'Mobile phone', via the Category select's own labels. */ +/** 'mobile_phone' -> 'Mobile phone', via the Asset Details select's own labels. */ const CATEGORY_LABELS = new Map( - (ASSET_FIELDS.find((f) => f.label === 'Category')?.options ?? []).map((o) => [o.value, o.label]), + (ASSET_FIELDS.find((f) => f.id === 'field_43')?.options ?? []).map((o) => [o.value, o.label]), ) const toNumber = (v) => { @@ -57,7 +58,7 @@ export async function fetchDashboard() { const state = [...stateCounts.keys()].find((s) => s.toLowerCase() === raw.toLowerCase()) ?? (raw || 'Unknown') stateCounts.set(state, (stateCounts.get(state) ?? 0) + 1) - const cat = assetInitValue(config, row, 'Category') + const cat = assetInitValue(config, row, 'Asset Details') const label = cat ? CATEGORY_LABELS.get(cat) ?? String(cat) : 'Uncategorised' categoryCounts.set(label, (categoryCounts.get(label) ?? 0) + 1) @@ -79,7 +80,7 @@ export async function fetchDashboard() { .flatMap((row) => assetEvents(config, row).map((e) => ({ ...e, - instance_id: row.instance_id, + instance_id: assetRowId(row), asset_code: assetCode(config, row), })), ) diff --git a/src/api/detailView.js b/src/api/detailView.js index 9bb3943..92b7c14 100644 --- a/src/api/detailView.js +++ b/src/api/detailView.js @@ -16,8 +16,8 @@ export const DETAIL_VIEW_APP_ID = '531' export const DETAIL_VIEWS = { employees: { templateUid: 'd21498b4-55a3-4910-bbd4-57623b7b5c42' }, - // From the asset detail view's API sample (…/view/detailview/112771?instance_id=…). - assets: { templateUid: '112771' }, + // From the asset INIT activity's detail view cURL (…/view/detailview/a573754f-…?instance_id=…). + assets: { templateUid: 'a573754f-df0e-4941-bee6-bad8ac48aa8b' }, } /** Fetch one record through a detail view template. */ diff --git a/src/api/employees.js b/src/api/employees.js index f76477a..d7341b7 100644 --- a/src/api/employees.js +++ b/src/api/employees.js @@ -2,6 +2,7 @@ import { APP_API_BASE_URL, ApiError, apiFetch } from './client' import { buildActivityData, explainActivityError, isEndState, latestByLabel } from './activityForm' import { normLabel } from './detailView' import { RECORD_VIEWS, fetchAllRecordRows } from './recordView' +import { DEPARTMENTS, EMPLOYEE_TYPES } from '../constants' /** * Employee onboarding lives in a Zino workflow. Creating an employee means @@ -18,6 +19,18 @@ import { RECORD_VIEWS, fetchAllRecordRows } from './recordView' */ export const EMPLOYEE_APP_ID = '531' + +/** + * Department is a text field in the builder; the form offers a fixed list + * (constants.js) so every employee gets a consistent name. + */ +const DEPARTMENT_OPTIONS = DEPARTMENTS.map((d) => ({ label: d, value: d })) + +/** + * Employee type options. The builder lists only "permanent"; add the others + * there too (same lowercase values) if the runtime rejects them. + */ +const EMPLOYEE_TYPE_OPTIONS = EMPLOYEE_TYPES.map((t) => ({ label: t, value: t })) export const EMPLOYEE_WORKFLOW_UUID = 'f4a303c2-869a-47d5-a5f3-1d2f7d5ae58e' export const EMPLOYEE_VERSION_UID = 'cbd1b20f-d79f-40c2-91e9-7001de283328' export const EMPLOYEE_INIT_ACTIVITY_ID = 'd42f026c-f9ac-4170-91c4-f1570d815d7a' @@ -25,12 +38,15 @@ export const EMPLOYEE_WORKFLOW_VERSION = 1 /** * INIT activity fields, exactly as configured in the Zino builder — same ids, - * labels, types, option values and order. + * labels, types and option values. The order is the form's: first and last + * name share the first row (values are sent by id, so order doesn't matter to + * the activity). * Source: GET /api/apps/531/versions/{EMPLOYEE_VERSION_UID}/activities/{id}/data-fields */ export const EMPLOYEE_FIELDS = [ { id: 'idgen_1', label: 'ID Generator', type: 'id_gen', computed: true }, { id: 'field_1', label: 'first_name', type: 'text' }, + { id: 'field_34', label: 'last_name', type: 'text' }, { id: 'field_2', label: 'email', type: 'email' }, { id: 'field_3', label: 'phone', type: 'number' }, { id: 'field_4', label: 'joining_date', type: 'date' }, @@ -38,12 +54,10 @@ export const EMPLOYEE_FIELDS = [ id: 'field_6', label: 'Employee type', type: 'select', - options: [{ label: 'permanent', value: 'permanent' }], + options: EMPLOYEE_TYPE_OPTIONS, }, - { id: 'field_34', label: 'last_name', type: 'text' }, - { id: 'field_35', label: 'department', type: 'text' }, + { id: 'field_35', label: 'department', type: 'select', options: DEPARTMENT_OPTIONS }, { id: 'field_36', label: 'designation', type: 'text' }, - { id: 'field_37', label: 'status', type: 'text' }, { id: 'field_38', label: 'employee_code', type: 'text' }, ] @@ -90,11 +104,10 @@ export const EMPLOYEE_UPDATE_FIELDS = [ id: 'field_7', label: 'Employee type', type: 'select', - options: [{ label: 'permanent', value: 'permanent' }], + options: EMPLOYEE_TYPE_OPTIONS, }, - { id: 'field_12', label: 'department', type: 'text' }, + { id: 'field_12', label: 'department', type: 'select', options: DEPARTMENT_OPTIONS }, { id: 'field_13', label: 'designation', type: 'text' }, - { id: 'field_14', label: 'status', type: 'text' }, { id: 'field_15', label: 'employee_code', type: 'text' }, ] @@ -313,6 +326,22 @@ export const EMPLOYMENT_FILTERS = [ { value: 'all', label: 'All employees' }, ] +/** + * Related employee list columns shown together, one cell each (see + * api/richColumns.js). Labels are the table's column names. + */ +export const EMPLOYEE_RICH_COLUMNS = [ + { key: 'employee', label: 'Employee', avatar: true, title: ['first_name', 'last_name'], subtitle: ['employee_code'] }, + { key: 'contact', label: 'Contact', title: ['email'], subtitle: ['phone'] }, + { key: 'role', label: 'Role', title: ['designation'], subtitle: ['department'] }, + { + key: 'type', + label: 'Type', + title: ['Employee type'], + subtitle: [{ label: 'joining_date', prefix: 'Joined' }], + }, +] + /** Derived column showing each row's Active / Inactive badge. */ const EMPLOYMENT_COLUMN = { type: 'derived', diff --git a/src/api/recordView.js b/src/api/recordView.js index ea92f5f..290af12 100644 --- a/src/api/recordView.js +++ b/src/api/recordView.js @@ -25,9 +25,11 @@ export const RECORD_VIEWS = { rowKey: '_pk_value', }, assets: { - templateUid: 'c91be68b-2650-44ef-b566-8f7ca3b85b28', + // 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: 'instance_id', + rowKey: '_pk_value', }, } diff --git a/src/api/richColumns.js b/src/api/richColumns.js new file mode 100644 index 0000000..500cc19 --- /dev/null +++ b/src/api/richColumns.js @@ -0,0 +1,59 @@ +import { normLabel } from './detailView' + +/** + * Rich columns: several related record view columns shown as one cell — a + * bold title line and a smaller line under it (e.g. name over employee code). + * + * A group is + * { key, label, avatar?, title: [label…], subtitle: [label | { label, prefix }…] } + * where each label names a view column by its output_label or field_key + * ('first_name', 'Asset Brand'…, compared loosely). Title parts are joined + * with a space, subtitle parts with ' · '; `prefix` goes before a value + * ("Ends 12 Oct 2026"). `avatar` shows the title's initials. + * + * The member columns are replaced by the rich column, placed where its first + * member was. A group none of whose columns the view has is skipped; columns + * no group takes stay as they are. Rows are not changed — the cell reads each + * member's own value. + */ +export function withRichColumns(res, groups) { + const fields = res?.config?.fields ?? [] + const matches = (col, label) => + [col.output_label, col.field_key].some((l) => l && normLabel(l) === normLabel(label)) + const taken = new Set() + const take = (spec) => { + const { label, prefix } = typeof spec === 'string' ? { label: spec } : spec + const col = fields.find((c) => !taken.has(c) && matches(c, label)) + if (!col) return [] + taken.add(col) + return [{ ...col, prefix }] + } + + const richAt = new Map() + for (const group of groups) { + const title = group.title.flatMap(take) + const subtitle = (group.subtitle ?? []).flatMap(take) + const members = [...title, ...subtitle] + if (!members.length) continue + // Sorting by the column sorts by its first title field (or first member). + const sortBy = title[0] ?? members[0] + const first = fields.findIndex((c) => members.some((m) => m.field_key === c.field_key)) + richAt.set(first, { + type: 'rich', + field_key: `rich_${group.key}`, + output_label: group.label, + data_type: 'rich', + avatar: Boolean(group.avatar), + title, + subtitle, + sort_key: sortBy.field_key, + sortable: sortBy.sortable !== false && sortBy.type !== 'derived', + }) + } + + const next = fields.flatMap((c, i) => { + if (richAt.has(i)) return [richAt.get(i)] + return taken.has(c) ? [] : [c] + }) + return { ...res, config: { ...res?.config, fields: next } } +} diff --git a/src/components/activity/ActivityFields.jsx b/src/components/activity/ActivityFields.jsx index 5ec2cdd..3f888f6 100644 --- a/src/components/activity/ActivityFields.jsx +++ b/src/components/activity/ActivityFields.jsx @@ -1,4 +1,6 @@ +import { useRef } from 'react' import { Field, SelectInput, TextArea, TextInput } from '../ui' +import { displayLabel } from '../../utils/format' /** * Renders one Zino activity field per builder definition, matching its @@ -7,27 +9,33 @@ import { Field, SelectInput, TextArea, TextInput } from '../ui' * * Computed fields (id_gen) are skipped — the builder's Fields tab does not * list them and the workflow fills them in. + * + * `upload(fieldId, file)` uploads a picked photo and resolves with its file + * object; image fields need it. */ -export default function ActivityFields({ fields, values, errors = {}, onChange, disabled }) { +export default function ActivityFields({ fields, values, errors = {}, onChange, disabled, upload }) { return fields.filter((f) => !f.computed).map((field) => { const value = values[field.id] ?? '' const set = (v) => onChange(field.id, v) - const isWide = field.type === 'longtext' || field.type === 'image' + const isWide = field.type === 'longtext' || field.type === 'image' || field.type === 'file' return ( - {(id) => renderControl(field, id, value, set, disabled)} + {(id) => renderControl(field, id, value, set, disabled, upload)} ) }) } +/** The name a field shows: its formLabel when set, else its builder label made readable. */ +const fieldLabel = (field) => field.formLabel?.trim() || displayLabel(field.label) + function hintFor(field) { switch (field.type) { case 'geolocation': @@ -35,6 +43,8 @@ function hintFor(field) { case 'image': if (field.slots?.length) return `One photo per view, each up to ${field.maxSizeMb || 5} MB.` return field.maxSizeMb ? `Image up to ${field.maxSizeMb} MB.` : 'Image' + case 'file': + return `PDF or image, up to ${field.maxSizeMb || 10} MB.` case 'select': return field.options?.length ? undefined : 'No options configured in the builder.' default: @@ -42,7 +52,7 @@ function hintFor(field) { } } -function renderControl(field, id, value, set, disabled) { +function renderControl(field, id, value, set, disabled, upload) { const common = { id, disabled } switch (field.type) { @@ -52,8 +62,9 @@ function renderControl(field, id, value, set, disabled) { {...common} value={value} onChange={(e) => set(e.target.value)} - options={field.options || []} - placeholder={field.options?.length ? `Select ${field.label.trim()}` : 'No options'} + // Only the shown text changes; each option still sends its builder value. + options={(field.options || []).map((o) => ({ ...o, label: displayLabel(o.label) }))} + placeholder={field.options?.length ? `Select ${fieldLabel(field)}` : 'No options'} disabled={disabled || !field.options?.length} /> ) @@ -99,48 +110,190 @@ function renderControl(field, id, value, set, disabled) { case 'image': return field.slots?.length ? ( - + ) : ( - + ) + case 'file': + return + default: return set(e.target.value)} /> } } -/** Reads `file` as a data URL, or rejects when it is over the builder's size cap. */ -function readImage(file, field) { - const maxMb = field.maxSizeMb || 5 - if (file.size > maxMb * 1024 * 1024) { - return Promise.reject(new Error(`Image must be ${maxMb} MB or smaller.`)) - } - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(String(reader.result)) - reader.onerror = () => reject(reader.error) - reader.readAsDataURL(file) - }) -} +/** File types a document field takes. */ +const DOCUMENT_TYPES = 'application/pdf,image/*' + +/** + * Picker for one document (PDF or image), uploaded as soon as it is picked. + * The value is { key, original_name, uploading } while it uploads, then the + * uploaded file object; buildActivityData sends it as a one-item list. + */ +function FileInput({ id, value, onChange, disabled, field, upload }) { + const inputRef = useRef(null) + // The upload finishes after later renders; read the value from here, not the closure. + const current = useRef(value) + current.current = value + const set = (v) => { + current.current = v + onChange(v) + } + + function remove() { + if (inputRef.current) inputRef.current.value = '' + set('') + } -/** File picker that enforces the builder's size cap and holds a data URL. */ -function ImageInput({ id, value, onChange, disabled, field }) { function handleFile(e) { const file = e.target.files?.[0] if (!file) { - onChange('') + set('') return } - readImage(file, field).then(onChange, (err) => { + const maxMb = field.maxSizeMb || 10 + const problem = !upload + ? 'File upload is not set up for this form.' + : file.size > maxMb * 1024 * 1024 && `File must be ${maxMb} MB or smaller.` + if (problem) { e.target.value = '' - onChange('') - window.alert(err.message) - }) + set('') + window.alert(problem) + return + } + const pending = { key: ++nextKey, original_name: file.name, uploading: true } + set(pending) + upload(field.id, file).then( + (meta) => { + // Skip it when this file was removed or replaced meanwhile. + if (current.current?.key === pending.key) set({ ...meta, key: pending.key }) + }, + (err) => { + if (current.current?.key === pending.key) remove() + window.alert(err?.message || 'Could not upload the file. Please try again.') + }, + ) } return (
+ {value && ( +
+ 📄 {value.original_name || 'File'} + {value.uploading && Uploading…} + +
+ )} +
+ ) +} + +let nextKey = 0 + +/** Throws when photos can't be taken: no uploader, or a file over the builder's size cap. */ +function checkImage(file, field, upload) { + if (!upload) throw new Error('Photo upload is not set up for this form.') + const maxMb = field.maxSizeMb || 5 + if (file.size > maxMb * 1024 * 1024) throw new Error(`Image must be ${maxMb} MB or smaller.`) +} + +/** + * Starts uploading `file` and returns the pending photo { key, preview, uploading } + * at once. `done` gets the uploaded photo (its file object plus key and + * preview), or null when the upload failed. + */ +function startUpload(file, field, upload, done) { + const pending = { key: ++nextKey, preview: URL.createObjectURL(file), uploading: true } + upload(field.id, file).then( + (meta) => done({ ...meta, key: pending.key, preview: pending.preview }), + (err) => { + done(null) + window.alert(err?.message || 'Could not upload the photo. Please try again.') + }, + ) + return pending +} + +/** A photo's thumbnail with its remove button, dimmed while it uploads. */ +function Thumb({ photo, alt, className, onRemove, disabled, removeLabel }) { + return ( +
+ {alt} + {photo.uploading && Uploading…} + +
+ ) +} + +/** File picker that uploads the photo as soon as it is picked. */ +function ImageInput({ id, value, onChange, disabled, field, upload }) { + const inputRef = useRef(null) + // The upload finishes after later renders; read the value from here, not the closure. + const current = useRef(value) + current.current = value + const set = (v) => { + current.current = v + onChange(v) + } + + function remove() { + if (inputRef.current) inputRef.current.value = '' + set('') + } + + function handleFile(e) { + const file = e.target.files?.[0] + if (!file) { + set('') + return + } + try { + checkImage(file, field, upload) + } catch (err) { + e.target.value = '' + set('') + window.alert(err.message) + return + } + const pending = startUpload(file, field, upload, (photo) => { + // Skip it when this photo was removed or replaced meanwhile. + if (current.current?.key !== pending.key) return + if (photo) set(photo) + else remove() + }) + set(pending) + } + + return ( +
+ - {value && } + {value && ( +
+ +
+ )}
) } @@ -156,29 +320,57 @@ function ImageInput({ id, value, onChange, disabled, field }) { /** * One picker per photo in `field.slots` (Front, Back, Side…), plus, when * `field.otherSlot` names one, a box that takes any number of extra photos. - * The value is an object { Front: dataUrl, …, Other: [dataUrl, …] }; - * buildActivityData turns it into the list sent. + * Each photo uploads as soon as it is picked. The value is an object + * { Front: photo, …, Other: [photo, …] }; buildActivityData turns it into the + * list of file objects sent. */ -function ImageSlots({ id, value, onChange, disabled, field }) { +function ImageSlots({ id, value, onChange, disabled, field, upload }) { const images = value && typeof value === 'object' ? value : {} const other = field.otherSlot const others = (other && images[other]) || [] + const inputs = useRef({}) + // Uploads finish after later renders; read the value from here, not the closure. + const latest = useRef(images) + latest.current = images - const setSlot = (slot, url) => { - const next = { ...images } - if (url && url.length) next[slot] = url + const setSlot = (slot, photo) => { + const next = { ...latest.current } + if (photo && (!Array.isArray(photo) || photo.length)) next[slot] = photo else delete next[slot] + latest.current = next onChange(Object.keys(next).length ? next : '') } + function removeSlot(slot, e) { + // The button sits inside the slot's