asset-management/src/api/employees.js
vamsi.balaga f845af5125 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>
2026-10-09 11:32:19 +05:30

435 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
* starting the workflow's INIT activity with the form data.
*
* POST {APP_API_BASE_URL}/app/{APP_ID}/start
* Authorization: Bearer <JWT>
* { activity_id, workflow_uuid, version, data: { field_1 … field_12 } }
*
* NOTE ON IDS
* The uuid in the Zino editor URL is the workflow *version* uid, not the
* workflow uuid this endpoint wants. Both are listed by
* GET /api/apps/{appId}/workflows.
*/
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'
export const EMPLOYEE_WORKFLOW_VERSION = 1
/**
* INIT activity fields, exactly as configured in the Zino builder — same ids,
* 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' },
{
id: 'field_6',
label: 'Employee type',
type: 'select',
options: EMPLOYEE_TYPE_OPTIONS,
},
{ id: 'field_35', label: 'department', type: 'select', options: DEPARTMENT_OPTIONS },
{ id: 'field_36', label: 'designation', type: 'text' },
{ id: 'field_38', label: 'employee_code', type: 'text' },
]
/** Start the employee INIT activity. Resolves with the backend response. */
export async function createEmployee(values) {
try {
return await apiFetch(`${APP_API_BASE_URL}/app/${EMPLOYEE_APP_ID}/start`, {
method: 'POST',
body: {
activity_id: EMPLOYEE_INIT_ACTIVITY_ID,
workflow_uuid: EMPLOYEE_WORKFLOW_UUID,
version: EMPLOYEE_WORKFLOW_VERSION,
data: buildActivityData(EMPLOYEE_FIELDS, values),
},
})
} catch (err) {
throw explainActivityError(err)
}
}
/**
* The "Update" activity in the Active state of the same employee workflow.
* Unlike INIT (which uses /start), a later activity runs against an existing
* record, so it posts to /activity and must carry the record's instance_id.
*
* POST {APP_API_BASE_URL}/app/{APP_ID}/activity
* Authorization: Bearer <JWT>
* { activity_id, workflow_uuid, instance_id, data: { field_5, field_7 … field_15 } }
*
* NOTE ON IDS
* The Update activity has its own field ids (field_5, field_7–field_15), which
* differ from the INIT activity's ids above. Source: the Employee workflow
* "Update" activity, Fields tab, in the Zino builder.
*/
export const EMPLOYEE_UPDATE_ACTIVITY_ID = '95d39136-519f-4558-95e4-df326f6fb68c'
export const EMPLOYEE_UPDATE_FIELDS = [
{ id: 'field_9', label: 'first_name', type: 'text' },
{ id: 'field_8', label: 'last_name', type: 'text' },
{ id: 'field_10', label: 'email', type: 'email' },
{ id: 'field_11', label: 'phone', type: 'number' },
{ id: 'field_5', label: 'joining_date', type: 'date' },
{
id: 'field_7',
label: 'Employee type',
type: 'select',
options: EMPLOYEE_TYPE_OPTIONS,
},
{ id: 'field_12', label: 'department', type: 'select', options: DEPARTMENT_OPTIONS },
{ id: 'field_13', label: 'designation', type: 'text' },
{ id: 'field_15', label: 'employee_code', type: 'text' },
]
/**
* The employee LIST the app shows is an rdbms record view: its rows carry only
* a table id (e.g. 2), NOT the workflow instance_id the Update activity needs.
* The workflow-backed employee view below does return instance_id, so we match
* a table row to its instance by a stable field (email, then phone).
*
* Source: the "Employees_details" screen's template (source_type: workflow).
*/
export const EMPLOYEE_WORKFLOW_VIEW_UID = '14d51468-d908-4cc3-a79c-e005b8fac5da'
const normEmail = (v) => String(v ?? '').trim().toLowerCase()
const normPhone = (v) => String(v ?? '').replace(/\D/g, '')
/** Every field id (INIT and Update) that carries a given label, e.g. 'email'. */
const idsForLabel = (label) =>
[...EMPLOYEE_FIELDS, ...EMPLOYEE_UPDATE_FIELDS].filter((f) => f.label === label).map((f) => f.id)
const EMAIL_IDS = idsForLabel('email')
const PHONE_IDS = idsForLabel('phone')
/** First non-empty value a row holds for a label (by label key, then by field id). */
const pick = (row, label, ids) =>
[row[label], ...ids.map((id) => row[id])].find((v) => v !== undefined && v !== null && v !== '')
/** Every employee workflow instance (field-id keyed rows). */
const fetchEmployeeInstances = async () =>
(await fetchAllRecordRows(EMPLOYEE_WORKFLOW_VIEW_UID)).rows
/**
* The instance a list row belongs to, matched by email, then phone. An
* instance may hold the INIT value (field_2) and/or a later Update value
* (field_10), so compare against all of them.
*/
function matchInstance(row, instances) {
const email = normEmail(pick(row, 'email', EMAIL_IDS))
const phone = normPhone(pick(row, 'phone', PHONE_IDS))
const hasEmail = (r) => EMAIL_IDS.some((id) => normEmail(r[id]) === email)
const hasPhone = (r) => PHONE_IDS.some((id) => normPhone(r[id]) === phone)
return (email && instances.find(hasEmail)) || (phone && instances.find(hasPhone)) || null
}
/**
* Resolve an employee list row to its workflow instance_id.
* Returns the id, or null when no matching instance is found.
*/
export async function resolveEmployeeInstanceId(row) {
if (!row) return null
// A workflow-backed row already carries the id — use it directly.
if (row.instance_id != null && row.instance_id !== '') return row.instance_id
const match = matchInstance(row, await fetchEmployeeInstances())
return match ? match.instance_id : null
}
/**
* The employee-table row (asset_management.employees) an instance belongs to —
* the reverse of matchInstance(), by email then phone. Null if none matches.
*/
async function fetchEmployeeTableRow(instance) {
const emails = EMAIL_IDS.map((id) => normEmail(instance[id])).filter(Boolean)
const phones = PHONE_IDS.map((id) => normPhone(instance[id])).filter(Boolean)
const { rows } = await fetchAllRecordRows(RECORD_VIEWS.employees.templateUid)
return (
rows.find((r) => emails.includes(normEmail(r.email))) ||
rows.find((r) => phones.includes(normPhone(r.phone))) ||
null
)
}
/**
* The "inactive" activity in the ACTIVE state of the same employee workflow.
* It moves the instance to End State, which has no activities, so it can run
* only once per employee. Like Update it posts to /activity with instance_id.
*
* POST {APP_API_BASE_URL}/app/{APP_ID}/activity
* Authorization: Bearer <JWT>
* { activity_id, workflow_uuid, instance_id,
* data: { field_16, field_33, field_39: { id }, field_40: { id, employee_code } } }
*
* NOTE ON IDS
* Other activities in this version reuse similar names (Resign, Terminate…);
* only this uid is the "inactive" one. Source:
* GET /api/apps/531/versions/{EMPLOYEE_VERSION_UID}/activities/{id}/data-fields
*/
export const EMPLOYEE_INACTIVE_ACTIVITY_ID = '7dcde7c9-82ce-4dd5-bcca-68fca6904eb4'
/** Fields the user fills in. */
export const EMPLOYEE_INACTIVE_FIELDS = [
{ id: 'field_16', label: 'exit date', type: 'date' },
{ id: 'field_33', label: 'reason', type: 'longtext' },
]
/**
* Lookups into the asset_management.employees table, keyed by its bigint id.
* They identify the employee being deactivated, so they are filled from that
* employee's table row rather than the form.
*/
const INACTIVE_EMPLOYEE_ID_FIELD = 'field_39' // "Employee Id": { id }
const INACTIVE_EMPLOYEE_NAME_FIELD = 'field_40' // "Employee name": { id, employee_code }
/** INIT, Update, then Inactive fields — oldest activity first, as latestByLabel() expects. */
export const ALL_EMPLOYEE_FIELDS = [
...EMPLOYEE_FIELDS,
...EMPLOYEE_UPDATE_FIELDS,
...EMPLOYEE_INACTIVE_FIELDS,
]
/** One employee workflow instance (field-id keyed), or null if not found. */
export async function fetchEmployeeInstance(instanceId) {
const rows = await fetchEmployeeInstances()
return rows.find((r) => String(r.instance_id) === String(instanceId)) || null
}
/**
* Show the latest saved values on the details page.
*
* The detail view template may read the INIT fields, or leave fields out, so
* after an Update it can still return old data. Merge in the workflow
* instance and give every field its newest value; fields the template leaves
* out but the instance holds are added at the end. If the instance can't be
* read, the view is returned as-is.
*/
export async function withLatestInstanceValues(view, instanceId) {
if (!view) return view
let instance
try {
instance = await fetchEmployeeInstance(instanceId)
} catch {
return view
}
if (!instance) return view
const record = { ...view.record, ...instance }
const latest = new Map(
[...latestByLabel(ALL_EMPLOYEE_FIELDS, record)].map(([label, v]) => [normLabel(label), v]),
)
const labelOf = new Map(ALL_EMPLOYEE_FIELDS.map((f) => [f.id, f.label]))
const shown = new Set()
const sections = view.sections.map((s) => ({
...s,
fields: s.fields.map((f) => {
const hit = [labelOf.get(f.key), f.key, f.label].map(normLabel).find((l) => latest.has(l))
if (!hit) return f
shown.add(hit)
return { ...f, value: latest.get(hit) }
}),
}))
// One entry per label, typed from its newest field definition.
const missing = []
for (const def of [...ALL_EMPLOYEE_FIELDS].reverse()) {
const n = normLabel(def.label)
if (shown.has(n) || !latest.has(n)) continue
shown.add(n)
missing.unshift({ key: def.id, label: def.label, type: def.type, value: latest.get(n) })
}
if (missing.length) {
if (sections.length) sections[0] = { ...sections[0], fields: [...sections[0].fields, ...missing] }
else sections.push({ title: '', fields: missing })
}
return { ...view, sections, record, instance }
}
/**
* Show the latest Update values in the employee list.
*
* The list is a table, and Zino copies an Update into it only for the fields
* mapped in the Update activity — unmapped ones keep their old value. The
* workflow instance always has what was saved, so each table row takes its
* instance's Update values (field_5, field_7 … field_15), its instance_id and
* its current_state_name. If the instances can't be read, the table is
* returned as-is.
*/
export async function withLatestEmployeeValues(res) {
const rows = Array.isArray(res?.data) ? res.data : []
if (!rows.length) return res
let instances
try {
instances = await fetchEmployeeInstances()
} catch {
return res
}
const columns = res?.config?.fields ?? []
const columnFor = (label) =>
columns.find(
(c) => normLabel(c.field_key) === normLabel(label) || normLabel(c.output_label) === normLabel(label),
)?.field_key
const data = rows.map((row) => {
const instance = matchInstance(row, instances)
if (!instance) return row
const next = {
...row,
instance_id: row.instance_id ?? instance.instance_id,
current_state_name: instance.current_state_name,
}
for (const [label, value] of latestByLabel(EMPLOYEE_UPDATE_FIELDS, instance)) {
const key = columnFor(label)
if (key) next[key] = value
}
return next
})
return { ...res, data }
}
/** Employee list filter values: by workflow state (Inactive = End State). */
export const EMPLOYMENT_FILTERS = [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ 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',
field_key: 'employment',
output_label: 'Employment',
data_type: 'employee_status',
sortable: false,
}
/**
* One page of the employee list, filtered by workflow state.
*
* The table knows nothing about the workflow, so the server can't filter on
* it: load every row (with the same search and sort), attach each one's state,
* filter, then page here. `query.filter` is one of EMPLOYMENT_FILTERS; rows
* with no matching instance count as Active.
*/
export async function fetchEmployeeListPage(templateUid, query = {}) {
const { page = 1, limit = 10, filter = 'active', ...rest } = query
const { config, rows } = await fetchAllRecordRows(templateUid, rest)
const res = await withLatestEmployeeValues({ config, data: rows })
const data = res.data
.map((row) => ({ ...row, employment: isEndState(row) ? 'Inactive' : 'Active' }))
.filter((row) => filter === 'all' || row.employment.toLowerCase() === filter)
const fields = config?.fields ?? []
const totalPages = Math.max(1, Math.ceil(data.length / limit))
return {
config: { ...config, fields: [...fields, EMPLOYMENT_COLUMN] },
data: data.slice((page - 1) * limit, page * limit),
pagination: { page, limit, total_count: data.length, total_pages: totalPages },
}
}
/**
* Run a later activity (Update, Inactive, …) on an existing employee instance.
* `extra` is merged into `data` as-is, for values the form doesn't hold.
*/
async function runEmployeeActivity(activityId, fields, instanceId, values, extra = {}) {
try {
return await apiFetch(`${APP_API_BASE_URL}/app/${EMPLOYEE_APP_ID}/activity`, {
// The runtime only accepts POST here — PUT returns 405 Method Not Allowed.
method: 'POST',
body: {
activity_id: activityId,
data: { ...buildActivityData(fields, values), ...extra },
// The runtime decodes this as int64, so "58950" (e.g. from a URL) is rejected.
instance_id: /^\d+$/.test(String(instanceId)) ? Number(instanceId) : instanceId,
workflow_uuid: EMPLOYEE_WORKFLOW_UUID,
},
})
} catch (err) {
throw explainActivityError(err)
}
}
/**
* Run the Update activity on an existing employee instance (the one created by
* INIT). Resolves with the backend response.
*/
export function updateEmployee(instanceId, values) {
return runEmployeeActivity(EMPLOYEE_UPDATE_ACTIVITY_ID, EMPLOYEE_UPDATE_FIELDS, instanceId, values)
}
/**
* Run the Inactive activity on an existing employee instance. The Employee Id
* and Employee name lookups are filled from the employee's table row.
*/
export async function inactivateEmployee(instanceId, values) {
const instance = await fetchEmployeeInstance(instanceId)
if (!instance) {
throw new ApiError('Could not find this employee in the workflow, so it cannot be marked inactive.')
}
const row = await fetchEmployeeTableRow(instance)
if (!row || row.id == null) {
throw new ApiError(
'Could not find this employee in the employee table (matched by email, then phone), ' +
'so Employee Id cannot be filled in.',
)
}
const id = Number(row.id)
return runEmployeeActivity(EMPLOYEE_INACTIVE_ACTIVITY_ID, EMPLOYEE_INACTIVE_FIELDS, instanceId, values, {
[INACTIVE_EMPLOYEE_ID_FIELD]: { id },
[INACTIVE_EMPLOYEE_NAME_FIELD]: {
id,
...(row.employee_code != null && { employee_code: String(row.employee_code) }),
},
})
}