feat: asset table view, file uploads, rich list columns and label cleanup
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 <noreply@anthropic.com>
This commit is contained in:
parent
48b8f05204
commit
f845af5125
@ -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
|
||||
|
||||
@ -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"] }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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 <JWT>
|
||||
* 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
|
||||
}
|
||||
|
||||
@ -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 <JWT>
|
||||
* { 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 <JWT>
|
||||
* { 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 <JWT>
|
||||
* { 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 <JWT>
|
||||
* { 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 }
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
})),
|
||||
)
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
59
src/api/richColumns.js
Normal file
59
src/api/richColumns.js
Normal file
@ -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 } }
|
||||
}
|
||||
@ -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 (
|
||||
<Field
|
||||
key={field.id}
|
||||
label={field.label.trim()}
|
||||
label={fieldLabel(field)}
|
||||
error={errors[field.id]}
|
||||
hint={hintFor(field)}
|
||||
full={isWide}
|
||||
>
|
||||
{(id) => renderControl(field, id, value, set, disabled)}
|
||||
{(id) => renderControl(field, id, value, set, disabled, upload)}
|
||||
</Field>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** 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 ? (
|
||||
<ImageSlots id={id} value={value} onChange={set} disabled={disabled} field={field} />
|
||||
<ImageSlots id={id} value={value} onChange={set} disabled={disabled} field={field} upload={upload} />
|
||||
) : (
|
||||
<ImageInput id={id} value={value} onChange={set} disabled={disabled} field={field} />
|
||||
<ImageInput id={id} value={value} onChange={set} disabled={disabled} field={field} upload={upload} />
|
||||
)
|
||||
|
||||
case 'file':
|
||||
return <FileInput id={id} value={value} onChange={set} disabled={disabled} field={field} upload={upload} />
|
||||
|
||||
default:
|
||||
return <TextInput {...common} value={value} onChange={(e) => 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 (
|
||||
<div className="image-input">
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
className="input"
|
||||
type="file"
|
||||
accept={DOCUMENT_TYPES}
|
||||
onChange={handleFile}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{value && (
|
||||
<div className="file-chip">
|
||||
<span className="file-chip__name">📄 {value.original_name || 'File'}</span>
|
||||
{value.uploading && <span className="cell-muted">Uploading…</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="file-chip__remove"
|
||||
onClick={remove}
|
||||
disabled={disabled}
|
||||
aria-label="Remove file"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`image-slot__thumb${photo.uploading ? ' image-slot__thumb--uploading' : ''}`}>
|
||||
<img className={className} src={photo.preview} alt={alt} />
|
||||
{photo.uploading && <span className="image-slot__status">Uploading…</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="image-slot__remove"
|
||||
onClick={onRemove}
|
||||
disabled={disabled}
|
||||
aria-label={removeLabel}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="image-input">
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
className="input"
|
||||
type="file"
|
||||
@ -148,7 +301,18 @@ function ImageInput({ id, value, onChange, disabled, field }) {
|
||||
onChange={handleFile}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{value && <img className="image-input__preview" src={value} alt="" />}
|
||||
{value && (
|
||||
<div className="image-input__thumb">
|
||||
<Thumb
|
||||
photo={value}
|
||||
alt=""
|
||||
className="image-input__preview"
|
||||
onRemove={remove}
|
||||
disabled={disabled}
|
||||
removeLabel="Remove photo"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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 <label>; don't let it open the picker.
|
||||
e.preventDefault()
|
||||
if (inputs.current[slot]) inputs.current[slot].value = ''
|
||||
setSlot(slot, '')
|
||||
}
|
||||
|
||||
function addOthers(e) {
|
||||
const files = [...(e.target.files ?? [])]
|
||||
e.target.value = ''
|
||||
if (!files.length) return
|
||||
Promise.all(files.map((file) => readImage(file, field))).then(
|
||||
(urls) => setSlot(other, [...others, ...urls]),
|
||||
(err) => window.alert(err.message),
|
||||
try {
|
||||
files.forEach((file) => checkImage(file, field, upload))
|
||||
} catch (err) {
|
||||
window.alert(err.message)
|
||||
return
|
||||
}
|
||||
const added = files.map((file) => {
|
||||
const pending = startUpload(file, field, upload, (photo) => {
|
||||
const list = latest.current[other] ?? []
|
||||
setSlot(
|
||||
other,
|
||||
photo
|
||||
? list.map((p) => (p.key === pending.key ? photo : p))
|
||||
: list.filter((p) => p.key !== pending.key),
|
||||
)
|
||||
})
|
||||
return pending
|
||||
})
|
||||
setSlot(other, [...(latest.current[other] ?? []), ...added])
|
||||
}
|
||||
|
||||
function handleFile(slot, e) {
|
||||
@ -187,14 +379,21 @@ function ImageSlots({ id, value, onChange, disabled, field }) {
|
||||
setSlot(slot, '')
|
||||
return
|
||||
}
|
||||
readImage(file, field).then(
|
||||
(url) => setSlot(slot, url),
|
||||
(err) => {
|
||||
try {
|
||||
checkImage(file, field, upload)
|
||||
} catch (err) {
|
||||
e.target.value = ''
|
||||
setSlot(slot, '')
|
||||
window.alert(err.message)
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
const pending = startUpload(file, field, upload, (photo) => {
|
||||
// Skip it when this photo was removed or replaced meanwhile.
|
||||
if (latest.current[slot]?.key !== pending.key) return
|
||||
if (!photo && inputs.current[slot]) inputs.current[slot].value = ''
|
||||
setSlot(slot, photo)
|
||||
})
|
||||
setSlot(slot, pending)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -203,11 +402,19 @@ function ImageSlots({ id, value, onChange, disabled, field }) {
|
||||
<label key={slot} className="image-slot">
|
||||
<span className="image-slot__label">{slot}</span>
|
||||
{images[slot] ? (
|
||||
<img className="image-slot__preview" src={images[slot]} alt={`${slot} view`} />
|
||||
<Thumb
|
||||
photo={images[slot]}
|
||||
alt={`${slot} view`}
|
||||
className="image-slot__preview"
|
||||
onRemove={(e) => removeSlot(slot, e)}
|
||||
disabled={disabled}
|
||||
removeLabel={`Remove ${slot} photo`}
|
||||
/>
|
||||
) : (
|
||||
<span className="image-slot__empty">No photo</span>
|
||||
)}
|
||||
<input
|
||||
ref={(el) => (inputs.current[slot] = el)}
|
||||
id={i === 0 ? id : undefined}
|
||||
className="input"
|
||||
type="file"
|
||||
@ -226,19 +433,15 @@ function ImageSlots({ id, value, onChange, disabled, field }) {
|
||||
</span>
|
||||
{others.length ? (
|
||||
<div className="image-slot__grid">
|
||||
{others.map((url, i) => (
|
||||
<div key={i} className="image-slot__thumb">
|
||||
<img src={url} alt={`${other} ${i + 1}`} />
|
||||
<button
|
||||
type="button"
|
||||
className="image-slot__remove"
|
||||
onClick={() => setSlot(other, others.filter((_, j) => j !== i))}
|
||||
{others.map((photo, i) => (
|
||||
<Thumb
|
||||
key={photo.key}
|
||||
photo={photo}
|
||||
alt={`${other} ${i + 1}`}
|
||||
onRemove={() => setSlot(other, others.filter((p) => p.key !== photo.key))}
|
||||
disabled={disabled}
|
||||
aria-label={`Remove ${other} photo ${i + 1}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
removeLabel={`Remove ${other} photo ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { isDateType, toISODate } from '../../api/recordView'
|
||||
import { APP_API_BASE_URL } from '../../api/client'
|
||||
import { RECORD_VIEW_APP_ID, isDateType, toISODate } from '../../api/recordView'
|
||||
import { formatDate } from '../../utils/format'
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}(T|\s|$)/
|
||||
@ -8,20 +9,88 @@ const IMAGE_URL = /^https?:\/\/\S+\.(png|jpe?g|gif|webp|svg)(\?\S*)?$/i
|
||||
const isImageSrc = (s) => typeof s === 'string' && (IMAGE_URL.test(s.trim()) || s.startsWith('data:image/'))
|
||||
|
||||
/**
|
||||
* The image sources in an image value: one URL, a list of them (photo slots),
|
||||
* or that list as JSON text.
|
||||
* A viewable URL for an uploaded file object ({ uuid, blob_path, … }), built
|
||||
* the way the Zino app does. Both preview routes are public, so they work as
|
||||
* a plain <img src>:
|
||||
* GET {APP_API_BASE_URL}/app/{APP_ID}/view/files/{uuid}/preview
|
||||
* GET {APP_API_BASE_URL}/app/{APP_ID}/view/files/preview?path=<blob_path>
|
||||
*/
|
||||
export function fileUrl(file) {
|
||||
if (!file || typeof file !== 'object') return null
|
||||
if (file.preview_url) return file.preview_url
|
||||
const base = `${APP_API_BASE_URL}/app/${RECORD_VIEW_APP_ID}/view/files`
|
||||
const uuid = file.uuid || file.repo_file_uuid
|
||||
if (uuid) return `${base}/${encodeURIComponent(uuid)}/preview`
|
||||
if (file.blob_path) return `${base}/preview?path=${encodeURIComponent(file.blob_path)}`
|
||||
return file.url || null
|
||||
}
|
||||
|
||||
/** Parse a value that may arrive as JSON text ('[{…}]', '{…}'). */
|
||||
function parseJson(v) {
|
||||
if (typeof v !== 'string' || !/^[[{]/.test(v.trim())) return v
|
||||
try {
|
||||
return JSON.parse(v)
|
||||
} catch {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The image sources in an image value: one URL or uploaded file object, a
|
||||
* list of them (photo slots), or that list as JSON text.
|
||||
*/
|
||||
export function imageSources(value) {
|
||||
let v = value
|
||||
if (typeof v === 'string' && v.trim().startsWith('[')) {
|
||||
try {
|
||||
v = JSON.parse(v)
|
||||
} catch {
|
||||
/* not a list */
|
||||
const v = parseJson(value)
|
||||
return toList(v)
|
||||
.map(parseJson)
|
||||
.flatMap(toList)
|
||||
.map((s) => (typeof s === 'string' ? s.trim() : fileUrl(s)))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/** The documents in a file value: [{ url, name }], from file objects or URLs. */
|
||||
export function fileLinks(value) {
|
||||
return toList(parseJson(value))
|
||||
.map(parseJson)
|
||||
.flatMap(toList)
|
||||
.map((f) =>
|
||||
typeof f === 'string'
|
||||
? { url: f.trim(), name: f.trim().split('/').pop() }
|
||||
: { url: fileUrl(f), name: f?.original_name || 'File' },
|
||||
)
|
||||
.filter((f) => f.url)
|
||||
}
|
||||
const list = Array.isArray(v) ? v : [v]
|
||||
return list.map((s) => (typeof s === 'string' ? s.trim() : s?.url)).filter(Boolean)
|
||||
|
||||
/** Links that open each document in a new tab. Clicks don't reach a clickable table row. */
|
||||
export function FileLinks({ value, label }) {
|
||||
const links = fileLinks(value)
|
||||
if (!links.length) return <span className="cell-muted">—</span>
|
||||
return (
|
||||
<span className="file-links">
|
||||
{links.map((f, i) => (
|
||||
<a
|
||||
key={i}
|
||||
className="link"
|
||||
href={f.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={f.name}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
📄 {label ?? f.name}
|
||||
</a>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const isFile = (o) => ['uuid', 'repo_file_uuid', 'blob_path', 'preview_url', 'url'].some((k) => o[k])
|
||||
|
||||
/** A list as-is; photo slots ({ Front: {…}, Other: [...] }) as their photos; else one item. */
|
||||
function toList(v) {
|
||||
if (Array.isArray(v)) return v
|
||||
if (v && typeof v === 'object' && !isFile(v)) return Object.values(v).flat()
|
||||
return [v]
|
||||
}
|
||||
|
||||
/**
|
||||
@ -38,6 +107,8 @@ export default function FieldValue({ field }) {
|
||||
}
|
||||
const s = typeof value === 'string' ? value.trim() : value
|
||||
|
||||
if (type === 'file') return <FileLinks value={s} />
|
||||
|
||||
const images = type === 'image' || isImageSrc(s) || (Array.isArray(s) && s.every(isImageSrc))
|
||||
if (images) {
|
||||
return (
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
AssetStateBadge,
|
||||
Avatar,
|
||||
Button,
|
||||
EmployeeStatusBadge,
|
||||
EmptyState,
|
||||
@ -10,15 +11,16 @@ import {
|
||||
Toolbar,
|
||||
} from '../ui'
|
||||
import { isDateType, isNumericType, toISODate } from '../../api/recordView'
|
||||
import { formatDate } from '../../utils/format'
|
||||
import { imageSources } from './FieldValue'
|
||||
import { displayLabel, formatDate } from '../../utils/format'
|
||||
import { FileLinks, imageSources } from './FieldValue'
|
||||
|
||||
/**
|
||||
* Renders a record view straight from the backend response: the columns are
|
||||
* whatever `config.fields` returns, in that order, with the backend's own
|
||||
* labels. Paging, sorting and search are handled by the server.
|
||||
* `toolbar` adds controls (e.g. a filter) next to the search box. A field with
|
||||
* `sortable: false` can't be sorted.
|
||||
* `sortable: false` can't be sorted. A rich column (see api/richColumns.js)
|
||||
* shows several fields in one cell and sorts by its `sort_key`.
|
||||
*/
|
||||
export default function RecordView({
|
||||
fields,
|
||||
@ -44,12 +46,17 @@ export default function RecordView({
|
||||
return () => clearTimeout(id)
|
||||
}, [term, query.search, setSearch])
|
||||
|
||||
const searchable = fields.filter((f) => f.is_search)
|
||||
// A rich column's fields stay searchable.
|
||||
const searchable = fields
|
||||
.flatMap((f) => (f.data_type === 'rich' ? [...f.title, ...f.subtitle] : [f]))
|
||||
.filter((f) => f.is_search)
|
||||
const columns = fields.filter((f) => f.field_key !== 'id')
|
||||
|
||||
const sortKey = (field) => field.sort_key ?? field.field_key
|
||||
|
||||
function toggleSort(field) {
|
||||
const dir = query.sortBy === field.field_key && query.sortDir === 'asc' ? 'desc' : 'asc'
|
||||
setSort(field.field_key, dir)
|
||||
const dir = query.sortBy === sortKey(field) && query.sortDir === 'asc' ? 'desc' : 'asc'
|
||||
setSort(sortKey(field), dir)
|
||||
}
|
||||
|
||||
const page = pagination?.page ?? 1
|
||||
@ -63,7 +70,7 @@ export default function RecordView({
|
||||
<SearchInput
|
||||
value={term}
|
||||
onChange={setTerm}
|
||||
placeholder={`Search by ${searchable.map((f) => f.output_label).join(', ')}…`}
|
||||
placeholder={`Search by ${searchable.map((f) => displayLabel(f.output_label)).join(', ')}…`}
|
||||
/>
|
||||
)}
|
||||
{toolbar}
|
||||
@ -82,9 +89,9 @@ export default function RecordView({
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((field) => {
|
||||
const label = String(field.output_label || field.field_key).trim()
|
||||
const label = displayLabel(field.output_label || field.field_key)
|
||||
if (field.sortable === false) return <th key={field.field_key}>{label}</th>
|
||||
const sorted = query.sortBy === field.field_key
|
||||
const sorted = query.sortBy === sortKey(field)
|
||||
return (
|
||||
<th
|
||||
key={field.field_key}
|
||||
@ -120,7 +127,9 @@ export default function RecordView({
|
||||
>
|
||||
{columns.map((field) => (
|
||||
<td key={field.field_key} className={cellClass(field)}>
|
||||
{renderCell(field, row[field.field_key])}
|
||||
{field.data_type === 'rich'
|
||||
? renderRichCell(field, row)
|
||||
: renderCell(field, row[field.field_key])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
@ -162,6 +171,35 @@ function cellClass(field) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const present = (v) => v !== null && v !== undefined && v !== ''
|
||||
|
||||
/** A rich column: its title fields in bold, its subtitle fields in a line below. */
|
||||
function renderRichCell(field, row) {
|
||||
const title = field.title.filter((p) => present(row[p.field_key]))
|
||||
const subtitle = field.subtitle.filter((p) => present(row[p.field_key]))
|
||||
if (!title.length && !subtitle.length) return <span className="cell-muted">—</span>
|
||||
|
||||
const join = (parts, sep) =>
|
||||
parts.map((p, i) => (
|
||||
<span key={p.field_key}>
|
||||
{i > 0 && sep}
|
||||
{p.prefix && `${p.prefix} `}
|
||||
{renderCell(p, row[p.field_key])}
|
||||
</span>
|
||||
))
|
||||
const name = title.map((p) => String(row[p.field_key])).join(' ')
|
||||
|
||||
return (
|
||||
<div className="cell-person">
|
||||
{field.avatar && name && <Avatar name={name} />}
|
||||
<div>
|
||||
{title.length > 0 && <div className="cell-person__name">{join(title, ' ')}</div>}
|
||||
{subtitle.length > 0 && <div className="cell-person__sub">{join(subtitle, ' · ')}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderCell(field, value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return <span className="cell-muted">—</span>
|
||||
@ -170,7 +208,11 @@ function renderCell(field, value) {
|
||||
if (field.data_type === 'image') {
|
||||
// Several photos (Front, Back…): show the first and how many more there are.
|
||||
const [first, ...more] = imageSources(value)
|
||||
if (!first) return <span className="cell-muted">—</span>
|
||||
if (!first) {
|
||||
// Uploaded file objects ({ blob_path, original_name… }) carry no URL to show.
|
||||
const count = Array.isArray(value) ? value.length : 0
|
||||
return <span className="cell-muted">{count ? `${count} photo${count > 1 ? 's' : ''}` : '—'}</span>
|
||||
}
|
||||
return (
|
||||
<span className="record-thumbs">
|
||||
<img className="record-thumb" src={first} alt="" loading="lazy" />
|
||||
@ -178,6 +220,9 @@ function renderCell(field, value) {
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (field.data_type === 'file') {
|
||||
return <FileLinks value={value} label="View" />
|
||||
}
|
||||
if (isDateType(field.data_type)) {
|
||||
return formatDate(toISODate(value))
|
||||
}
|
||||
|
||||
@ -59,11 +59,41 @@ export const DEPARTMENTS = [
|
||||
'Engineering',
|
||||
'Product',
|
||||
'Design',
|
||||
'Quality Assurance',
|
||||
'Data & Analytics',
|
||||
'Research & Development',
|
||||
'IT Support',
|
||||
'Information Security',
|
||||
'Sales',
|
||||
'Marketing',
|
||||
'Business Development',
|
||||
'Customer Support',
|
||||
'Customer Success',
|
||||
'Human Resources',
|
||||
'Recruitment',
|
||||
'Training & Development',
|
||||
'Finance',
|
||||
'IT Support',
|
||||
'Accounts',
|
||||
'Legal',
|
||||
'Compliance',
|
||||
'Administration',
|
||||
'Operations',
|
||||
'Procurement',
|
||||
'Supply Chain & Logistics',
|
||||
'Facilities',
|
||||
'Management',
|
||||
]
|
||||
|
||||
/** Employee type select values, lowercase like the builder's "permanent". */
|
||||
export const EMPLOYEE_TYPES = [
|
||||
'permanent',
|
||||
'probation',
|
||||
'contract',
|
||||
'intern',
|
||||
'trainee',
|
||||
'part-time',
|
||||
'consultant',
|
||||
'freelancer',
|
||||
]
|
||||
|
||||
export const LOCATIONS = [
|
||||
|
||||
@ -9,11 +9,12 @@ import { useAuth } from '../store/AuthContext'
|
||||
* record is created. `transform(res)` may enrich each response (async ok).
|
||||
* `fetcher(templateUid, query)` replaces the plain record view request, e.g.
|
||||
* to filter on something the server can't; it gets `query.filter`, which
|
||||
* starts as `filter` and changes with `setFilter()`.
|
||||
* starts as `filter` and changes with `setFilter()`. `sortBy` / `sortDir` set
|
||||
* the initial order (e.g. newest first) until a column header is clicked.
|
||||
*/
|
||||
export default function useRecordView(
|
||||
templateUid,
|
||||
{ limit = DEFAULT_PAGE_SIZE, transform, fetcher = fetchRecordView, filter = '' } = {},
|
||||
{ limit = DEFAULT_PAGE_SIZE, transform, fetcher = fetchRecordView, filter = '', sortBy = '', sortDir = '' } = {},
|
||||
) {
|
||||
const navigate = useNavigate()
|
||||
const { logout } = useAuth()
|
||||
@ -23,7 +24,7 @@ export default function useRecordView(
|
||||
const fetcherRef = useRef(fetcher)
|
||||
fetcherRef.current = fetcher
|
||||
|
||||
const [query, setQuery] = useState({ page: 1, limit, sortBy: '', sortDir: '', search: '', filter })
|
||||
const [query, setQuery] = useState({ page: 1, limit, sortBy, sortDir, search: '', filter })
|
||||
const [result, setResult] = useState({ config: null, data: [], pagination: null })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@ -2,7 +2,13 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button, Field, Icon, Modal, SelectInput } from '../../components/ui'
|
||||
import ActivityFields from '../../components/activity/ActivityFields'
|
||||
import { ASSET_FIELDS, createAsset, fetchAssignableEmployees } from '../../api/assets'
|
||||
import {
|
||||
ASSET_FIELDS,
|
||||
ASSET_INIT_ACTIVITY_ID,
|
||||
createAsset,
|
||||
fetchAssignableEmployees,
|
||||
uploadAssetFile,
|
||||
} from '../../api/assets'
|
||||
import { emptyValues, validateActivity } from '../../api/activityForm'
|
||||
import { useAuth } from '../../store/AuthContext'
|
||||
|
||||
@ -128,6 +134,7 @@ export default function AddAssetModal({ open, onClose, onCreated }) {
|
||||
errors={errors}
|
||||
onChange={handleChange}
|
||||
disabled={submitting}
|
||||
upload={(fieldId, file) => uploadAssetFile(ASSET_INIT_ACTIVITY_ID, fieldId, file)}
|
||||
/>
|
||||
<Field label="Asset Owner">
|
||||
{(id) => (
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
PageHeader,
|
||||
} from '../../components/ui'
|
||||
import FieldValue, { formatMeta } from '../../components/activity/FieldValue'
|
||||
import { displayLabel } from '../../utils/format'
|
||||
import Timeline from '../../components/common/Timeline'
|
||||
import { instanceState } from '../../api/activityForm'
|
||||
import {
|
||||
@ -127,8 +128,8 @@ export default function AssetDetails() {
|
||||
|
||||
const { config, row } = data
|
||||
|
||||
// The asset view's own columns, in its order and with its labels, then any
|
||||
// Add Asset field the view has no column for.
|
||||
// Every Add Asset field, in the form's order and with its labels, then the
|
||||
// view's other columns.
|
||||
const withForm = withAssetFormColumns({ config, data: [row] })
|
||||
const formRow = withForm.data[0]
|
||||
const assetFields = (withForm.config?.fields ?? [])
|
||||
@ -149,9 +150,7 @@ export default function AssetDetails() {
|
||||
|
||||
const code = assetCode(config, row)
|
||||
const title = code || 'Asset'
|
||||
const subtitle = [assetInitValue(config, row, 'Category'), assetInitValue(config, row, 'Model')]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
const subtitle = assetInitValue(config, row, 'Model')
|
||||
// Newest first, like the Asset History page.
|
||||
const history = assetEvents(config, row)
|
||||
.reverse()
|
||||
@ -302,7 +301,7 @@ export default function AssetDetails() {
|
||||
<Card title="Record info" flush>
|
||||
<InfoList>
|
||||
{meta.map((m) => (
|
||||
<InfoRow key={m.key} label={m.label}>
|
||||
<InfoRow key={m.key} label={displayLabel(m.label)}>
|
||||
<span className="cell-mono">{formatMeta(m.value)}</span>
|
||||
</InfoRow>
|
||||
))}
|
||||
@ -352,11 +351,6 @@ export default function AssetDetails() {
|
||||
<DetailFields fields={lostFields} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<details className="detail-raw">
|
||||
<summary>Raw response</summary>
|
||||
<pre>{JSON.stringify(row, null, 2)}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -382,7 +376,7 @@ function DetailFields({ fields }) {
|
||||
<dl className="detail-fields">
|
||||
{fields.map((f) => (
|
||||
<div key={f.key ?? f.label} className="detail-field">
|
||||
<dt className="detail-field__label">{f.label}</dt>
|
||||
<dt className="detail-field__label">{displayLabel(f.label)}</dt>
|
||||
<dd className="detail-field__value">
|
||||
<FieldValue field={f} />
|
||||
</dd>
|
||||
|
||||
@ -4,7 +4,7 @@ import { Button, Card, Icon, PageHeader } from '../../components/ui'
|
||||
import RecordView from '../../components/activity/RecordView'
|
||||
import useRecordView from '../../hooks/useRecordView'
|
||||
import { RECORD_VIEWS } from '../../api/recordView'
|
||||
import { withAssetListDisplay } from '../../api/assets'
|
||||
import { assetRowId, withAssetListDisplay } from '../../api/assets'
|
||||
import AddAssetModal from './AddAssetModal'
|
||||
|
||||
const VIEW = RECORD_VIEWS.assets
|
||||
@ -12,8 +12,13 @@ const VIEW = RECORD_VIEWS.assets
|
||||
export default function AssetList() {
|
||||
const navigate = useNavigate()
|
||||
// Every field the Add Asset form fills gets a column, even if the view lacks
|
||||
// one, and Asset Owner shows the employee's name.
|
||||
const view = useRecordView(VIEW.templateUid, { transform: withAssetListDisplay })
|
||||
// one, and Asset Owner shows the employee's name. Newest first, so a just
|
||||
// created asset is on page 1 (the view itself lists oldest first).
|
||||
const view = useRecordView(VIEW.templateUid, {
|
||||
transform: withAssetListDisplay,
|
||||
sortBy: 'id',
|
||||
sortDir: 'desc',
|
||||
})
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [notice, setNotice] = useState(null)
|
||||
|
||||
@ -60,7 +65,7 @@ export default function AssetList() {
|
||||
rowKey={VIEW.rowKey}
|
||||
emptyText="No assets yet."
|
||||
// Details show the asset's assignment and offer Assign when it's Available.
|
||||
onRowClick={(row) => navigate(`/assets/${encodeURIComponent(row[VIEW.rowKey])}`)}
|
||||
onRowClick={(row) => navigate(`/assets/${encodeURIComponent(assetRowId(row))}`)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ import { DETAIL_VIEWS, detailValue } from '../../api/detailView'
|
||||
import { toISODate } from '../../api/recordView'
|
||||
import { instanceState, isEndState } from '../../api/activityForm'
|
||||
import FieldValue, { formatMeta } from '../../components/activity/FieldValue'
|
||||
import { displayLabel } from '../../utils/format'
|
||||
import {
|
||||
ALL_EMPLOYEE_FIELDS,
|
||||
EMPLOYEE_UPDATE_FIELDS,
|
||||
@ -129,7 +130,7 @@ export default function EmployeeDetails() {
|
||||
? {
|
||||
tone: 'warning',
|
||||
text:
|
||||
`Saved, but the workflow still returns the old value for ${stale.join(', ')}. ` +
|
||||
`Saved, but the workflow still returns the old value for ${stale.map(displayLabel).join(', ')}. ` +
|
||||
'The Zino views are not reading these Update fields yet.',
|
||||
}
|
||||
: { tone: 'success', text: 'Employee was updated.' },
|
||||
@ -224,7 +225,7 @@ export default function EmployeeDetails() {
|
||||
<Card title="Record info" flush>
|
||||
<InfoList>
|
||||
{view.meta.map((m) => (
|
||||
<InfoRow key={m.key} label={m.label}>
|
||||
<InfoRow key={m.key} label={displayLabel(m.label)}>
|
||||
<span className="cell-mono">{formatMeta(m.value)}</span>
|
||||
</InfoRow>
|
||||
))}
|
||||
@ -249,7 +250,7 @@ export default function EmployeeDetails() {
|
||||
<dl className="detail-fields">
|
||||
{section.fields.map((f) => (
|
||||
<div key={f.key ?? f.label} className="detail-field">
|
||||
<dt className="detail-field__label">{f.label}</dt>
|
||||
<dt className="detail-field__label">{displayLabel(f.label)}</dt>
|
||||
<dd className="detail-field__value">
|
||||
<FieldValue field={f} />
|
||||
</dd>
|
||||
@ -259,13 +260,6 @@ export default function EmployeeDetails() {
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
|
||||
<details className="detail-raw">
|
||||
<summary>Raw response</summary>
|
||||
<pre>
|
||||
{JSON.stringify({ detail_view: view.raw, workflow_instance: view.instance ?? null }, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -5,10 +5,12 @@ import RecordView from '../../components/activity/RecordView'
|
||||
import useRecordView from '../../hooks/useRecordView'
|
||||
import { RECORD_VIEWS } from '../../api/recordView'
|
||||
import {
|
||||
EMPLOYEE_RICH_COLUMNS,
|
||||
EMPLOYMENT_FILTERS,
|
||||
fetchEmployeeListPage,
|
||||
resolveEmployeeInstanceId,
|
||||
} from '../../api/employees'
|
||||
import { withRichColumns } from '../../api/richColumns'
|
||||
import { useAuth } from '../../store/AuthContext'
|
||||
import AddEmployeeModal from './AddEmployeeModal'
|
||||
|
||||
@ -19,7 +21,12 @@ export default function EmployeeList() {
|
||||
const { logout } = useAuth()
|
||||
// The table misses Update fields Zino doesn't map and knows nothing of the
|
||||
// workflow state, so this fetcher overlays both and filters Active/Inactive.
|
||||
const view = useRecordView(VIEW.templateUid, { fetcher: fetchEmployeeListPage, filter: 'active' })
|
||||
// Related columns (name and code, email and phone…) are grouped into one.
|
||||
const view = useRecordView(VIEW.templateUid, {
|
||||
fetcher: fetchEmployeeListPage,
|
||||
filter: 'active',
|
||||
transform: (res) => withRichColumns(res, EMPLOYEE_RICH_COLUMNS),
|
||||
})
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [opening, setOpening] = useState(false)
|
||||
const [notice, setNotice] = useState(null)
|
||||
|
||||
@ -166,7 +166,16 @@ function initialFromRow(row) {
|
||||
for (const field of EMPLOYEE_UPDATE_FIELDS) {
|
||||
const raw = row[field.label] ?? row[field.id]
|
||||
if (raw === undefined || raw === null || raw === '') continue
|
||||
values[field.id] = field.type === 'date' ? toISODate(raw) : String(raw)
|
||||
if (field.type === 'date') values[field.id] = toISODate(raw)
|
||||
else if (field.type === 'select') values[field.id] = matchOption(field, raw)
|
||||
else values[field.id] = String(raw)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/** The option a saved value belongs to, ignoring case and spacing ("it support" → "IT Support"). */
|
||||
function matchOption(field, raw) {
|
||||
const norm = (v) => String(v).trim().toLowerCase()
|
||||
const hit = field.options?.find((o) => norm(o.value) === norm(raw) || norm(o.label) === norm(raw))
|
||||
return hit ? hit.value : String(raw)
|
||||
}
|
||||
|
||||
@ -672,6 +672,45 @@ button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---------- Uploaded documents ---------- */
|
||||
.file-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
font-size: 13px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.file-chip__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-chip__remove {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-chip__remove:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.file-links {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
}
|
||||
|
||||
/* ---------- Empty state ---------- */
|
||||
.empty {
|
||||
padding: 46px 20px;
|
||||
@ -875,26 +914,6 @@ button {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-raw {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.detail-raw summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-raw pre {
|
||||
margin: 8px 0 0;
|
||||
padding: 12px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* ---------- Tabs ---------- */
|
||||
.tabs {
|
||||
display: flex;
|
||||
@ -1233,7 +1252,12 @@ button {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.image-input__thumb {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.image-input__preview {
|
||||
display: block;
|
||||
max-height: 140px;
|
||||
width: auto;
|
||||
align-self: flex-start;
|
||||
@ -1266,6 +1290,7 @@ button {
|
||||
|
||||
.image-slot__preview,
|
||||
.image-slot__empty {
|
||||
display: block;
|
||||
height: 110px;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
@ -1309,6 +1334,21 @@ button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-slot__thumb--uploading img {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.image-slot__status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-slot__remove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
|
||||
@ -11,6 +11,17 @@ export function formatDate(iso) {
|
||||
return `${String(d).padStart(2, '0')}-${MONTHS[m - 1]}-${y}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A field or option name as people read it: 'first_name' -> 'First name',
|
||||
* 'joining_date' -> 'Joining date', 'permanent' -> 'Permanent'. Words already
|
||||
* in capitals (CPU, ID) stay so. Display only: the builder label is still what
|
||||
* the code matches data on, so never feed this back into a lookup.
|
||||
*/
|
||||
export function displayLabel(label) {
|
||||
const s = String(label ?? '').replace(/[_\s]+/g, ' ').trim()
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
|
||||
/** Today as an ISO date string, used as the default for date inputs. */
|
||||
export function todayISO() {
|
||||
const now = new Date()
|
||||
|
||||
@ -8,5 +8,15 @@ export default defineConfig(({ command }) => ({
|
||||
server: {
|
||||
port: 5173,
|
||||
open: true,
|
||||
// TEMP (dev only): lets the browser read /api/apps/531/rv-screens, which
|
||||
// dev-apps.getzino.in blocks from localhost via CORS. Remove once the
|
||||
// asset record view id is found.
|
||||
proxy: {
|
||||
"/zino-apps": {
|
||||
target: "https://dev-apps.getzino.in",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/zino-apps/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user