asset-management/src/api/recordView.js
vamsi.balaga b8f50efaf0 fix: tidy the Employees list columns and export
- Show "Joined <date>" as the main text in the Date column, with the
  employee type below it (was the other way around)
- Hide the raw status column from the list; the Employment badge
  already shows Active / Inactive
- Drop the duplicate status column from the employee Excel export

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-02 16:34:11 +05:30

253 lines
7.9 KiB
JavaScript

import { APP_API_BASE_URL, ApiError, apiFetch, getStoredToken } from './client'
/**
* Record view data source.
*
* POST {RUNTIME}/app/{APP_ID}/view/recordview
* Authorization: Bearer <JWT>
* {
* 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
}