202 lines
8.1 KiB
TypeScript
202 lines
8.1 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { cn } from '../../lib/cn';
|
||
import { formatValue } from '../../lib/format';
|
||
import type { ZinoClient } from '../../api/client';
|
||
import { APP_ID } from '../../api/config';
|
||
import { Card } from '../reusable/Card';
|
||
import { Spinner } from '../reusable/Spinner';
|
||
import { EmptyState } from '../reusable/EmptyState';
|
||
|
||
export interface DetailViewProps {
|
||
/** Workflow-bound client (see api/clients.ts). */
|
||
client: ZinoClient;
|
||
/** detailview template uid. */
|
||
dvUid?: string;
|
||
/** Instance to render. */
|
||
instanceId: number | string;
|
||
/** Card header title. */
|
||
title?: string;
|
||
/** Restrict/order visible fields by field_key. Defaults to all. */
|
||
fields?: string[];
|
||
/** Columns in the definition grid. @default 2 */
|
||
columns?: 1 | 2 | 3;
|
||
}
|
||
|
||
|
||
/**
|
||
* Generic Zino detail view. Fetches `GET /app/{id}/view/detailview/{dvUid}` and
|
||
* renders the data as a labeled definition grid.
|
||
*/
|
||
export function DetailView({ client, dvUid, instanceId, title, fields, columns = 2 }: DetailViewProps) {
|
||
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
let live = true;
|
||
async function run() {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
if (!dvUid) throw new Error("dvUid is required to fetch detail view data.");
|
||
const r = await client.detailView(dvUid, instanceId);
|
||
if (live) {
|
||
setData(r.data || {});
|
||
}
|
||
} catch (e) {
|
||
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
||
} finally {
|
||
if (live) setLoading(false);
|
||
}
|
||
}
|
||
run();
|
||
return () => {
|
||
live = false;
|
||
};
|
||
}, [client, dvUid, instanceId]);
|
||
|
||
const gridCols = { 1: 'grid-cols-1', 2: 'grid-cols-1 sm:grid-cols-2', 3: 'grid-cols-1 sm:grid-cols-3' }[columns];
|
||
|
||
if (error) {
|
||
return (
|
||
<Card title={title}>
|
||
<EmptyState title="Couldn’t load record" hint={error} />
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<Card title={title}>
|
||
<div className="py-8 flex justify-center">
|
||
<Spinner label="Loading…" />
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
if (!data || Object.keys(data).length === 0) {
|
||
return (
|
||
<Card title={title}>
|
||
<EmptyState title="No details found" />
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<Card
|
||
title={title || 'Details'}
|
||
className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md"
|
||
bodyClassName="bg-slate-50/30"
|
||
>
|
||
<dl className={cn('grid gap-x-6 gap-y-6', gridCols)}>
|
||
{Object.entries(data).map(([key, value]) => {
|
||
if (fields && !fields.includes(key)) return null;
|
||
const lowerKey = key.toLowerCase();
|
||
if (lowerKey.includes('uuid') || /^\d+$/.test(key)) return null;
|
||
|
||
const isGridArray =
|
||
Array.isArray(value) &&
|
||
value.length > 0 &&
|
||
typeof value[0] === 'object' &&
|
||
value[0] !== null &&
|
||
!('original_name' in value[0]) &&
|
||
!('url' in value[0]);
|
||
|
||
if (isGridArray) {
|
||
const rows = value as Record<string, unknown>[];
|
||
// Use all unique keys found across all rows just in case they differ slightly
|
||
const headers = Array.from(new Set(rows.flatMap(r => Object.keys(r))));
|
||
|
||
return (
|
||
<div key={key} className="flex flex-col gap-2 min-w-0 col-span-full mt-2 mb-4">
|
||
<dt className="text-[11px] font-bold uppercase tracking-[0.08em] text-[var(--z-bg-primary)] opacity-80">
|
||
{key.replace(/_/g, ' ')}
|
||
</dt>
|
||
<dd className="m-0 text-sm text-strong font-medium overflow-x-auto rounded border border-border-subtle shadow-sm">
|
||
<table className="min-w-full divide-y divide-border-subtle text-left bg-[var(--tiles-card-bg)]">
|
||
<thead className="bg-slate-50">
|
||
<tr>
|
||
{headers.map(h => (
|
||
<th key={h} className="px-4 py-2 text-[10px] font-bold uppercase text-muted tracking-wide whitespace-nowrap">
|
||
{h.replace(/_/g, ' ')}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border-subtle">
|
||
{rows.map((row, idx) => (
|
||
<tr key={idx} className="hover:bg-slate-50/50">
|
||
{headers.map(h => (
|
||
<td key={h} className="px-4 py-2 whitespace-nowrap text-sm text-strong">
|
||
{formatValue(row[h])}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const isFileArray =
|
||
Array.isArray(value) &&
|
||
value.length > 0 &&
|
||
typeof value[0] === 'object' &&
|
||
value[0] !== null &&
|
||
('original_name' in value[0] || 'uuid' in value[0]);
|
||
|
||
if (isFileArray) {
|
||
return (
|
||
<div key={key} className="flex flex-col gap-2 min-w-0 col-span-full mt-2 mb-4">
|
||
<dt className="text-[11px] font-bold uppercase tracking-[0.08em] text-[var(--z-bg-primary)] opacity-80">
|
||
{key.replace(/_/g, ' ')}
|
||
</dt>
|
||
<dd className="m-0 flex flex-wrap gap-4">
|
||
{(value as any[]).map((file, idx) => {
|
||
const previewUrl = `${client.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`;
|
||
return (
|
||
<div key={file.uuid || idx} className="relative w-32 h-32 rounded-lg border border-border-subtle overflow-hidden bg-slate-100 flex items-center justify-center group shadow-sm">
|
||
<img
|
||
src={previewUrl}
|
||
alt={file.original_name || 'Attached File'}
|
||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||
onError={(e) => {
|
||
(e.target as HTMLImageElement).style.display = 'none';
|
||
(e.target as HTMLImageElement).parentElement!.innerHTML = `<span class="text-[10px] text-faint text-center px-2 break-all font-mono">${file.original_name || 'File'}</span>`;
|
||
}}
|
||
/>
|
||
<a
|
||
href={previewUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="absolute inset-0 z-10"
|
||
></a>
|
||
</div>
|
||
);
|
||
})}
|
||
</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div key={key} className="flex flex-col gap-1.5 min-w-0">
|
||
<dt className="text-[11px] font-bold uppercase tracking-[0.08em] text-[var(--z-bg-primary)] opacity-80">
|
||
{key.replace(/_/g, ' ')}
|
||
</dt>
|
||
<dd className="m-0 text-sm text-strong font-medium break-words">
|
||
{formatValue(value)}
|
||
</dd>
|
||
</div>
|
||
);
|
||
})}
|
||
</dl>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|