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>
249 lines
8.0 KiB
JavaScript
249 lines
8.0 KiB
JavaScript
import { useEffect, useState } from 'react'
|
|
import {
|
|
AssetStateBadge,
|
|
Avatar,
|
|
Button,
|
|
EmployeeStatusBadge,
|
|
EmptyState,
|
|
HistoryActionBadge,
|
|
Icon,
|
|
SearchInput,
|
|
Toolbar,
|
|
} from '../ui'
|
|
import { isDateType, isNumericType, toISODate } from '../../api/recordView'
|
|
import { displayLabel, formatDate } from '../../utils/format'
|
|
import { FileLinks, imageSources } from './FieldValue'
|
|
|
|
/**
|
|
* Renders a record view straight from the backend response: the columns are
|
|
* whatever `config.fields` returns, in that order, with the backend's own
|
|
* labels. Paging, sorting and search are handled by the server.
|
|
* `toolbar` adds controls (e.g. a filter) next to the search box. A field with
|
|
* `sortable: false` can't be sorted. A rich column (see api/richColumns.js)
|
|
* shows several fields in one cell and sorts by its `sort_key`.
|
|
*/
|
|
export default function RecordView({
|
|
fields,
|
|
rows,
|
|
pagination,
|
|
loading,
|
|
error,
|
|
query,
|
|
setPage,
|
|
setSearch,
|
|
setSort,
|
|
rowKey = 'instance_id',
|
|
onRowClick,
|
|
emptyText = 'No records yet.',
|
|
toolbar,
|
|
}) {
|
|
const [term, setTerm] = useState(query.search)
|
|
|
|
// Debounce typing so we don't fire a request per keystroke.
|
|
useEffect(() => {
|
|
if (term === query.search) return undefined
|
|
const id = setTimeout(() => setSearch(term), 350)
|
|
return () => clearTimeout(id)
|
|
}, [term, query.search, setSearch])
|
|
|
|
// A rich column's fields stay searchable.
|
|
const searchable = fields
|
|
.flatMap((f) => (f.data_type === 'rich' ? [...f.title, ...f.subtitle] : [f]))
|
|
.filter((f) => f.is_search)
|
|
const columns = fields.filter((f) => f.field_key !== 'id')
|
|
|
|
const sortKey = (field) => field.sort_key ?? field.field_key
|
|
|
|
function toggleSort(field) {
|
|
const dir = query.sortBy === sortKey(field) && query.sortDir === 'asc' ? 'desc' : 'asc'
|
|
setSort(sortKey(field), dir)
|
|
}
|
|
|
|
const page = pagination?.page ?? 1
|
|
const totalPages = pagination?.total_pages ?? 1
|
|
const total = pagination?.total_count ?? rows.length
|
|
|
|
return (
|
|
<>
|
|
<Toolbar count={total} countLabel="records">
|
|
{searchable.length > 0 && (
|
|
<SearchInput
|
|
value={term}
|
|
onChange={setTerm}
|
|
placeholder={`Search by ${searchable.map((f) => displayLabel(f.output_label)).join(', ')}…`}
|
|
/>
|
|
)}
|
|
{toolbar}
|
|
{loading && <span className="toolbar__count">Loading…</span>}
|
|
</Toolbar>
|
|
|
|
{error && (
|
|
<div className="notice notice--danger" role="alert">
|
|
<Icon name="alert" size={15} />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="table-wrap">
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
{columns.map((field) => {
|
|
const label = displayLabel(field.output_label || field.field_key)
|
|
if (field.sortable === false) return <th key={field.field_key}>{label}</th>
|
|
const sorted = query.sortBy === sortKey(field)
|
|
return (
|
|
<th
|
|
key={field.field_key}
|
|
className={`is-sortable${sorted ? ' is-sorted' : ''}`}
|
|
onClick={() => toggleSort(field)}
|
|
>
|
|
{label}
|
|
<span className="table__sort">
|
|
{sorted ? (query.sortDir === 'asc' ? '▲' : '▼') : '⇅'}
|
|
</span>
|
|
</th>
|
|
)
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.length === 0 ? (
|
|
<tr className="table__empty">
|
|
<td colSpan={Math.max(columns.length, 1)}>
|
|
<EmptyState
|
|
icon={loading ? '⏳' : '📭'}
|
|
title={loading ? 'Loading records' : 'No records'}
|
|
text={loading ? 'Fetching from the workflow…' : emptyText}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
rows.map((row, i) => (
|
|
<tr
|
|
key={row[rowKey] ?? row._pk_value ?? row.id ?? i}
|
|
className={onRowClick ? 'is-clickable' : undefined}
|
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
>
|
|
{columns.map((field) => (
|
|
<td key={field.field_key} className={cellClass(field)}>
|
|
{field.data_type === 'rich'
|
|
? renderRichCell(field, row)
|
|
: renderCell(field, row[field.field_key])}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="pager">
|
|
<Button
|
|
size="sm"
|
|
icon="chevronLeft"
|
|
disabled={page <= 1 || loading}
|
|
onClick={() => setPage(page - 1)}
|
|
>
|
|
Previous
|
|
</Button>
|
|
<span className="pager__label">
|
|
Page {page} of {totalPages}
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
disabled={page >= totalPages || loading}
|
|
onClick={() => setPage(page + 1)}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function cellClass(field) {
|
|
if (field.data_type === 'id_gen') return 'cell-mono'
|
|
if (isNumericType(field.data_type)) return 'cell-mono'
|
|
return undefined
|
|
}
|
|
|
|
const present = (v) => v !== null && v !== undefined && v !== ''
|
|
|
|
/** A rich column: its title fields in bold, its subtitle fields in a line below. */
|
|
function renderRichCell(field, row) {
|
|
const title = field.title.filter((p) => present(row[p.field_key]))
|
|
const subtitle = field.subtitle.filter((p) => present(row[p.field_key]))
|
|
if (!title.length && !subtitle.length) return <span className="cell-muted">—</span>
|
|
|
|
const join = (parts, sep) =>
|
|
parts.map((p, i) => (
|
|
<span key={p.field_key}>
|
|
{i > 0 && sep}
|
|
{p.prefix && `${p.prefix} `}
|
|
{renderCell(p, row[p.field_key])}
|
|
</span>
|
|
))
|
|
const name = title.map((p) => String(row[p.field_key])).join(' ')
|
|
|
|
return (
|
|
<div className="cell-person">
|
|
{field.avatar && name && <Avatar name={name} />}
|
|
<div>
|
|
{title.length > 0 && <div className="cell-person__name">{join(title, ' ')}</div>}
|
|
{subtitle.length > 0 && <div className="cell-person__sub">{join(subtitle, ' · ')}</div>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function renderCell(field, value) {
|
|
if (value === null || value === undefined || value === '') {
|
|
return <span className="cell-muted">—</span>
|
|
}
|
|
|
|
if (field.data_type === 'image') {
|
|
// Several photos (Front, Back…): show the first and how many more there are.
|
|
const [first, ...more] = imageSources(value)
|
|
if (!first) {
|
|
// Uploaded file objects ({ blob_path, original_name… }) carry no URL to show.
|
|
const count = Array.isArray(value) ? value.length : 0
|
|
return <span className="cell-muted">{count ? `${count} photo${count > 1 ? 's' : ''}` : '—'}</span>
|
|
}
|
|
return (
|
|
<span className="record-thumbs">
|
|
<img className="record-thumb" src={first} alt="" loading="lazy" />
|
|
{more.length > 0 && <span className="cell-muted">+{more.length}</span>}
|
|
</span>
|
|
)
|
|
}
|
|
if (field.data_type === 'file') {
|
|
return <FileLinks value={value} label="View" />
|
|
}
|
|
if (isDateType(field.data_type)) {
|
|
return formatDate(toISODate(value))
|
|
}
|
|
if (field.data_type === 'email') {
|
|
return <span className="cell-muted">{String(value)}</span>
|
|
}
|
|
if (field.data_type === 'id_gen') {
|
|
return <span className="cell-primary">{String(value)}</span>
|
|
}
|
|
if (field.data_type === 'employee_status') {
|
|
return <EmployeeStatusBadge status={String(value)} />
|
|
}
|
|
if (field.data_type === 'asset_state') {
|
|
return <AssetStateBadge state={String(value)} />
|
|
}
|
|
if (field.data_type === 'history_action') {
|
|
return <HistoryActionBadge action={String(value)} />
|
|
}
|
|
if (typeof value === 'object') {
|
|
return <span className="cell-muted">{JSON.stringify(value)}</span>
|
|
}
|
|
return String(value)
|
|
}
|