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 * { 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 * { 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 * { 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) }), }, }) }