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:
vamsi.balaga 2026-10-09 11:32:19 +05:30
parent 48b8f05204
commit f845af5125
24 changed files with 1005 additions and 265 deletions

View File

@ -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

View File

@ -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"] }
}
}
]
}

View File

@ -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
}

View File

@ -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 }
}

View File

@ -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) {

View File

@ -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),
})),
)

View File

@ -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. */

View File

@ -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',

View File

@ -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
View 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 } }
}

View File

@ -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) => {
e.target.value = ''
setSlot(slot, '')
window.alert(err.message)
},
)
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))}
disabled={disabled}
aria-label={`Remove ${other} photo ${i + 1}`}
>
×
</button>
</div>
{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}
removeLabel={`Remove ${other} photo ${i + 1}`}
/>
))}
</div>
) : (

View File

@ -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 list = Array.isArray(v) ? v : [v]
return list.map((s) => (typeof s === 'string' ? s.trim() : s?.url)).filter(Boolean)
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)
}
/** 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 (

View File

@ -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))
}

View File

@ -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 = [

View File

@ -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('')

View File

@ -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) => (

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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)

View File

@ -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)
}

View File

@ -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;

View File

@ -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()

View File

@ -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/, ""),
},
},
},
}));