- 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>
253 lines
7.9 KiB
JavaScript
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
|
|
}
|