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>
147 lines
5.1 KiB
JavaScript
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
|
|
}
|