import { useEffect, useMemo, useRef, useState } from 'react'; import { Search, ChevronRight } from 'lucide-react'; import { cn } from '../../lib/cn'; import { formatValue } from '../../lib/format'; import type { ZinoClient } from '../../api/client'; import type { RecordViewField, RecordViewResponse } from '../../api/types'; import { Pagination } from '../reusable/Pagination'; import { Spinner } from '../reusable/Spinner'; import { EmptyState } from '../reusable/EmptyState'; import { StatsTiles } from '../reusable/StatsTiles'; import { AnalyticsChart } from '../reusable/AnalyticsChart'; import { Select } from '../reusable/Select'; export interface RecordViewProps { /** Workflow-bound client (see api/clients.ts). */ client: ZinoClient; /** rv_template_uid for this view. */ rvUid: string; /** Card header title. */ title?: string; /** Restrict/order visible columns by field_key. Defaults to all fields. */ columns?: string[]; /** Rows per page. @default 25 */ pageSize?: number; /** Click handler — receives the raw row + index. */ onRowClick?: (row: Record, index: number) => void; /** Extract a stable key for a row. @default row.instance_id ?? index */ rowKey?: (row: Record, index: number) => string; /** Additional actions to render in the header next to search box */ headerActions?: React.ReactNode; /** Custom actions to render at the end of each row. */ rowActions?: (row: Record) => React.ReactNode; /** Pass a new value to trigger a refresh. */ refreshKey?: number; } /** * Generic Zino record-view table. Fetches `POST /app/{id}/view/recordview` * (server-side paginated + searchable) and renders config.fields as columns. * Wire it to a specific view via the thin wrappers in this folder. */ export function RecordView({ client, rvUid, title = 'Records', columns, pageSize = 50, onRowClick, rowKey, headerActions, rowActions, refreshKey, }: RecordViewProps) { const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [debounced, setDebounced] = useState(''); const [activeFilters, setActiveFilters] = useState>({}); const [resp, setResp] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Debounce the search box; reset to page 1 whenever the term changes. const timer = useRef | null>(null); useEffect(() => { if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(() => { setDebounced(search); setPage(1); }, 300); return () => { if (timer.current) clearTimeout(timer.current); }; }, [search]); const filtersParam = useMemo(() => { return Object.entries(activeFilters) .filter(([, val]) => val) .map(([key, val]) => ({ field_key: key, value: val, data_type: 'string', })); }, [activeFilters]); useEffect(() => { let live = true; async function run() { setLoading(true); setError(null); try { const r = await client.recordView(rvUid, { page, limit: pageSize, search: debounced, filters: filtersParam }); if (live) setResp(r); } catch (e) { if (live) setError((e as { message?: string })?.message ?? 'Failed to load'); } finally { if (live) setLoading(false); } } run(); return () => { live = false; }; }, [client, rvUid, page, pageSize, debounced, filtersParam, refreshKey]); const fields: RecordViewField[] = useMemo(() => { const all = resp?.config.fields ?? []; if (!columns) return all; const byKey = new Map(all.map((f) => [f.field_key, f])); return columns .map((k) => byKey.get(k) ?? ({ field_key: k, output_label: k, data_type: 'string', is_filter: false, is_search: false } as RecordViewField)) .filter(Boolean); }, [resp, columns]); const rows = resp?.data ?? []; const total = resp?.pagination?.total_count ?? rows.length; const tileValues = resp?.tile_values; const chartData = resp?.chart_data; const filterEntries = resp?.config?.filter_options ? Object.entries(resp.config.filter_options).filter(([, o]) => o && o.length > 0) : []; return (

{title}

{headerActions}
setSearch(e.target.value)} placeholder="Search…" className="w-full h-11 rounded-pill bg-transparent pl-11 pr-4 font-sans text-sm text-strong outline-none placeholder:text-faint" />
{filterEntries.length > 0 && (
{filterEntries.map(([key, options]) => { const fieldDef = resp?.config.fields.find(f => f.field_key === key); const label = fieldDef?.output_label || key; const opts = options!.map(o => ({ value: o, label: o })); return (