asset-management/src/components/activity/RecordView.jsx
vamsi.balaga f845af5125 feat: asset table view, file uploads, rich list columns and label cleanup
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>
2026-10-09 11:32:19 +05:30

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)
}