import { APP_API_BASE_URL, ApiError, apiFetch, getStoredToken } from './client' /** * Record view data source. * * POST {RUNTIME}/app/{APP_ID}/view/recordview * Authorization: Bearer * { * rv_template_uid, preset_alias, params, * search_query: { page, limit, sort_by, sort_dir, search, filters } * } * * The response carries its own column config, so a view always renders exactly * the fields the backend returns: * { config: { fields: [...] }, data: [...], pagination: {...} } */ export const RECORD_VIEW_APP_ID = '531' /** Record view templates, found via GET /api/apps/531/rv-screens. */ export const RECORD_VIEWS = { employees: { templateUid: 'f3b07dfd-ddcf-4039-ba82-078179a16f21', screen: 'emp', rowKey: '_pk_value', }, assets: { // The "ddddd" view on the asset workflow: every row is an instance and // carries its instance_id, which Edit and the other activities need. Its // columns are the "Asset details" (Edit) activity's fields (field_2, // field_3, field_13…). It replaced the asset table view de18263e-…. templateUid: 'cdba05a2-a993-4de0-9364-c6ea048bd775', screen: 'ddddd', rowKey: 'instance_id', }, assetTable: { // The asset table: the INIT (Add Asset) activity's values, columns named // after the builder labels (asset_brand, serial_number…). Rows are keyed // by _pk_value and carry no instance_id, so they are matched to a // workflow instance by Asset ID or serial number (see withTableValues). templateUid: 'de18263e-dc82-4d4d-98a2-99607104373b', rowKey: '_pk_value', }, assetAssignments: { // The asset workflow view the Assignment list reads; its rows carry the // instance_id the ASSIGNED and Reassigned activities run on. templateUid: '08438179-a28f-4d92-b340-97c3028cece2', rowKey: 'instance_id', }, assetReturns: { // The asset workflow view the Return list reads; its rows carry the // instance_id the RETURNED activity runs on. templateUid: '45ea19d6-8729-438e-8c4e-488a9d1ffddb', rowKey: 'instance_id', }, } export const DEFAULT_PAGE_SIZE = 10 /** * Fetch one page of a record view. * @returns {Promise<{config: {fields: Array}, data: Array, pagination: object}>} */ export function fetchRecordView( templateUid, { page = 1, limit = DEFAULT_PAGE_SIZE, sortBy = '', sortDir = '', search = '', filters = [], params = {}, presetAlias = '' } = {}, ) { return apiFetch(`${APP_API_BASE_URL}/app/${RECORD_VIEW_APP_ID}/view/recordview`, { method: 'POST', body: { rv_template_uid: templateUid, preset_alias: presetAlias, params, search_query: { page, limit, sort_by: sortBy, sort_dir: sortDir, search, filters, }, }, }) } const ALL_ROWS_PAGE_SIZE = 200 const MAX_ALL_ROWS_PAGES = 10 /** * Every row of a record view, paging through all. `query` (search, sort…) is * passed on to each page request. * @returns {Promise<{config: object|null, rows: Array}>} */ export async function fetchAllRecordRows(templateUid, query = {}) { const rows = [] let config = null for (let page = 1; page <= MAX_ALL_ROWS_PAGES; page++) { const res = await fetchRecordView(templateUid, { ...query, page, limit: ALL_ROWS_PAGE_SIZE }) config ??= res?.config ?? null const data = Array.isArray(res?.data) ? res.data : [] rows.push(...data) if (data.length < ALL_ROWS_PAGE_SIZE || page >= (res?.pagination?.total_pages ?? 1)) break } return { config, rows } } /** * Columns the Zino "Asset" screen exports from the asset table view * (RECORD_VIEWS.assetTable). Names are the backend's own, typos included * (warrenty_end_date, warrenty_status). */ export const ASSET_EXPORT_COLUMNS = [ 'monthly_allowance', 'asset_classification', 'warranty_start_date', 'asset_identifier', 'created_at', 'updated_at', 'asset_details', 'model', 'serial_number', 'purchase_date', 'purchase_cost', 'asset_state', 'warrenty_end_date', 'hard_drive', 'asset_owner', 'image', 'warrenty_status', 'asset_brand', 'asset_location', 'cpu', ] /** Columns the Zino "Employee" screen exports from RECORD_VIEWS.employees. */ export const EMPLOYEE_EXPORT_COLUMNS = [ 'id', 'employee_code', 'first_name', 'last_name', 'email', 'phone', 'department', 'designation', 'joining_date', 'created_at', 'updated_at', ] /** * Download every row of a record view as a file, the way the Zino screen's * export button does: * * POST {RUNTIME}/app/{APP_ID}/view/recordview/export * { rv_template_uid, columns: [{ name, value_path }], preset_alias, params, search, filters } * * The file is saved as `fileName` unless the server names it * (Content-Disposition). A JSON reply carrying a link is followed instead; one * without a link means the file will be emailed. * @returns {Promise<{emailed: boolean, data?: object}>} */ export async function downloadRecordView( templateUid, columns, { fileName = 'export.xlsx', search = '', filters = [], params = {}, presetAlias = '' } = {}, ) { const token = getStoredToken() let response try { response = await fetch(`${APP_API_BASE_URL}/app/${RECORD_VIEW_APP_ID}/view/recordview/export`, { method: 'POST', headers: { accept: '*/*', 'content-type': 'application/json', ...(token && { authorization: `Bearer ${token}` }), }, body: JSON.stringify({ rv_template_uid: templateUid, columns: columns.map((name) => ({ name, value_path: name })), preset_alias: presetAlias, params, search, filters, }), }) } catch (err) { throw new ApiError('Unable to reach the server. Check your connection and try again.', { status: 0, data: err, }) } if (!response.ok) { const text = await response.text().catch(() => '') throw new ApiError(text.trim() || `Export failed (${response.status})`, { status: response.status, data: text, }) } const type = response.headers.get('content-type') || '' if (type.includes('application/json')) { const data = await response.json() const url = data?.url || data?.download_url || data?.data?.url || data?.data?.download_url // No link: the export runs in the background and Zino emails the file to // the signed-in user (the owner of the token). if (!url) return { emailed: true, data } saveFile(url, data?.file_name || fileName) return { emailed: false } } const blob = await response.blob() const url = URL.createObjectURL(blob) saveFile(url, fileNameFrom(response.headers.get('content-disposition')) || fileName) // Give the browser a moment to start the download before freeing the blob. setTimeout(() => URL.revokeObjectURL(url), 1000) return { emailed: false } } /** 'attachment; filename="assets.xlsx"' -> 'assets.xlsx'. */ function fileNameFrom(disposition) { if (!disposition) return '' const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(disposition) if (utf8) return decodeURIComponent(utf8[1]) const plain = /filename="?([^";]+)"?/i.exec(disposition) return plain ? plain[1].trim() : '' } function saveFile(url, fileName) { const link = document.createElement('a') link.href = url link.download = fileName document.body.appendChild(link) link.click() link.remove() } /** Field types the backend reports that should render as a date. */ const DATE_TYPES = new Set(['date', 'timestamp without time zone', 'timestamp with time zone']) /** Field types that should render right-aligned / monospaced. */ const NUMERIC_TYPES = new Set(['number', 'bigint', 'integer', 'numeric', 'double precision']) export const isDateType = (t) => DATE_TYPES.has(t) export const isNumericType = (t) => NUMERIC_TYPES.has(t) /** '2026-09-22T00:00:00Z' -> '2026-09-22' so the shared date formatter works. */ export function toISODate(value) { if (!value) return '' const s = String(value) return s.length >= 10 ? s.slice(0, 10) : s }