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>
435 lines
16 KiB
JavaScript
435 lines
16 KiB
JavaScript
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) }),
|
||
},
|
||
})
|
||
}
|