asset-management/src/components/activity/FieldValue.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

147 lines
5.1 KiB
JavaScript

import { APP_API_BASE_URL } from '../../api/client'
import { RECORD_VIEW_APP_ID, isDateType, toISODate } from '../../api/recordView'
import { formatDate } from '../../utils/format'
const ISO_DATE = /^\d{4}-\d{2}-\d{2}(T|\s|$)/
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const IMAGE_URL = /^https?:\/\/\S+\.(png|jpe?g|gif|webp|svg)(\?\S*)?$/i
const isImageSrc = (s) => typeof s === 'string' && (IMAGE_URL.test(s.trim()) || s.startsWith('data:image/'))
/**
* A viewable URL for an uploaded file object ({ uuid, blob_path, … }), built
* the way the Zino app does. Both preview routes are public, so they work as
* a plain <img src>:
* GET {APP_API_BASE_URL}/app/{APP_ID}/view/files/{uuid}/preview
* GET {APP_API_BASE_URL}/app/{APP_ID}/view/files/preview?path=<blob_path>
*/
export function fileUrl(file) {
if (!file || typeof file !== 'object') return null
if (file.preview_url) return file.preview_url
const base = `${APP_API_BASE_URL}/app/${RECORD_VIEW_APP_ID}/view/files`
const uuid = file.uuid || file.repo_file_uuid
if (uuid) return `${base}/${encodeURIComponent(uuid)}/preview`
if (file.blob_path) return `${base}/preview?path=${encodeURIComponent(file.blob_path)}`
return file.url || null
}
/** Parse a value that may arrive as JSON text ('[{…}]', '{…}'). */
function parseJson(v) {
if (typeof v !== 'string' || !/^[[{]/.test(v.trim())) return v
try {
return JSON.parse(v)
} catch {
return v
}
}
/**
* The image sources in an image value: one URL or uploaded file object, a
* list of them (photo slots), or that list as JSON text.
*/
export function imageSources(value) {
const v = parseJson(value)
return toList(v)
.map(parseJson)
.flatMap(toList)
.map((s) => (typeof s === 'string' ? s.trim() : fileUrl(s)))
.filter(Boolean)
}
/** The documents in a file value: [{ url, name }], from file objects or URLs. */
export function fileLinks(value) {
return toList(parseJson(value))
.map(parseJson)
.flatMap(toList)
.map((f) =>
typeof f === 'string'
? { url: f.trim(), name: f.trim().split('/').pop() }
: { url: fileUrl(f), name: f?.original_name || 'File' },
)
.filter((f) => f.url)
}
/** Links that open each document in a new tab. Clicks don't reach a clickable table row. */
export function FileLinks({ value, label }) {
const links = fileLinks(value)
if (!links.length) return <span className="cell-muted">—</span>
return (
<span className="file-links">
{links.map((f, i) => (
<a
key={i}
className="link"
href={f.url}
target="_blank"
rel="noreferrer"
title={f.name}
onClick={(e) => e.stopPropagation()}
>
📄 {label ?? f.name}
</a>
))}
</span>
)
}
const isFile = (o) => ['uuid', 'repo_file_uuid', 'blob_path', 'preview_url', 'url'].some((k) => o[k])
/** A list as-is; photo slots ({ Front: {…}, Other: [...] }) as their photos; else one item. */
function toList(v) {
if (Array.isArray(v)) return v
if (v && typeof v === 'object' && !isFile(v)) return Object.values(v).flat()
return [v]
}
/**
* Renders one detail field ({ value, type, slots?, otherSlot? }) by its type,
* falling back to what the value looks like. Shared by every details page.
* `slots` names the photos of an image list (Front, Back…), in order; any
* photos past them are named `otherSlot`.
*/
export default function FieldValue({ field }) {
const { value, type, slots, otherSlot } = field
const caption = (i) => (slots ? slots[i] ?? otherSlot : undefined)
if (value === undefined || value === null || value === '') {
return <span className="cell-muted">—</span>
}
const s = typeof value === 'string' ? value.trim() : value
if (type === 'file') return <FileLinks value={s} />
const images = type === 'image' || isImageSrc(s) || (Array.isArray(s) && s.every(isImageSrc))
if (images) {
return (
<div className="record-thumbs">
{imageSources(s).map((src, i) => (
<figure key={i} className="record-thumbs__item">
<img className="record-thumb" src={src} alt={caption(i) ?? ''} loading="lazy" />
{caption(i) && <figcaption>{caption(i)}</figcaption>}
</figure>
))}
</div>
)
}
if (isDateType(type) || (typeof s === 'string' && ISO_DATE.test(s))) {
return formatDate(toISODate(s))
}
if (type === 'email' || (typeof s === 'string' && EMAIL.test(s))) {
return <a className="link" href={`mailto:${s}`}>{s}</a>
}
if (typeof s === 'boolean') return s ? 'Yes' : 'No'
if (type === 'geolocation' && typeof s === 'object') {
const lat = s.lat ?? s.latitude
const lng = s.lng ?? s.lon ?? s.longitude
if (lat != null && lng != null) return `${lat}, ${lng}`
}
if (Array.isArray(s) && s.every((v) => typeof v !== 'object')) return s.join(', ')
if (typeof s === 'object') return <span className="cell-muted">{JSON.stringify(s)}</span>
return String(s)
}
/** Timestamps read as dates; everything else as-is. */
export function formatMeta(value) {
const s = String(value)
return ISO_DATE.test(s) ? `${formatDate(toISODate(s))}${s.length > 10 ? ` ${s.slice(11, 16)}` : ''}` : s
}