import { useEffect, useMemo, useRef, useState } from 'react'; import { Search, SlidersHorizontal, X } 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 { MultiSelect } from '../reusable/Select'; import { Input } from '../reusable/Input'; import { Modal } from '../reusable/Modal'; 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[]; /** Columns to hide. */ omitColumns?: string[]; /** Rows per page. @default 10 */ 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; /** Custom render function for the entire row card body */ renderItem?: (row: Record, fields: RecordViewField[]) => React.ReactNode; /** Sort by column key */ sortBy?: string; /** Sort direction */ sortDir?: 'asc' | 'desc'; /** If true, the analytics chart will not be rendered even if data is returned */ hideChart?: boolean; /** If true, the tiles will not be rendered even if data is returned */ hideTiles?: boolean; /** Pass a preset alias to apply server-side presets */ preset_alias?: string; /** Default filters to apply initially. */ initialFilters?: Record; } /** * Generic Zino record-view table for mobile. Fetches `POST /app/{id}/view/recordview` * (server-side paginated + searchable) and renders cards or table items. */ export function RecordView({ client, rvUid, title = 'Records', columns, omitColumns, pageSize = 10, onRowClick, rowKey, headerActions, rowActions, refreshKey, renderItem, sortBy, sortDir, hideChart = false, hideTiles = false, preset_alias, initialFilters = {}, }: RecordViewProps) { const [page, setPage] = useState(1); const [activePageSize, setActivePageSize] = useState(pageSize); const [search, setSearch] = useState(''); const [debounced, setDebounced] = useState(''); const initialFiltersNormalized = useMemo(() => { const res: Record = {}; if (initialFilters) { Object.entries(initialFilters).forEach(([k, v]) => { if (Array.isArray(v)) { res[k] = v; } else if (v) { res[k] = [v]; } }); } return res; }, [initialFilters]); const [activeFilters, setActiveFilters] = useState>(initialFiltersNormalized); const [pendingFilters, setPendingFilters] = useState>(initialFiltersNormalized); const [showFilters, setShowFilters] = useState(false); 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 activeFiltersStr = JSON.stringify(activeFilters); const filtersParam = useMemo(() => { const parsed = JSON.parse(activeFiltersStr) as Record; return Object.entries(parsed) .filter(([_, vals]) => vals && vals.length > 0) .map(([key, vals]) => ({ field_key: key, values: vals, data_type: 'character varying', })); }, [activeFiltersStr]); useEffect(() => { let live = true; async function run() { setLoading(true); setError(null); try { const r = await client.recordView(rvUid, { preset_alias, page, limit: activePageSize, search: debounced, filters: filtersParam, sortBy, sortDir }); 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, preset_alias, page, activePageSize, debounced, filtersParam, refreshKey, sortBy, sortDir]); const fields: RecordViewField[] = useMemo(() => { const all = resp?.config.fields ?? []; let result = all; if (columns) { const byKey = new Map(all.map((f) => [f.field_key, f])); result = 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); } if (omitColumns) { const omitSet = new Set(omitColumns); result = result.filter((f) => !omitSet.has(f.field_key)); } return result; }, [resp, columns, omitColumns]); const rows = resp?.data ?? []; const total = resp?.pagination?.total_count ?? rows.length; const tileValues = resp?.tile_values; const chartData = resp?.chart_data; const filterEntries = useMemo(() => { if (!resp?.config) return []; const keys = new Set(); resp.config.fields.forEach((f) => { if (f.is_filter) keys.add(f.field_key); }); if (resp.config.filter_options) { Object.keys(resp.config.filter_options).forEach((k) => keys.add(k)); } return Array.from(keys); }, [resp]); const activeFilterCount = useMemo(() => { return Object.values(activeFilters).reduce((acc, vals) => acc + (vals ? vals.length : 0), 0); }, [activeFilters]); return (
{!hideTiles && } {!hideChart && }

{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 && ( )}
{/* Active Filter Chips & Clear All */} {Object.entries(activeFilters).some(([_, vals]) => vals && vals.length > 0) && (
Filters: {Object.entries(activeFilters).flatMap(([key, vals]) => { const fieldDef = resp?.config?.fields?.find((f) => f.field_key === key); const label = fieldDef?.output_label || key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); return (vals || []).map((val) => ( {label}: {val} )); })}
)} {filterEntries.length > 0 && ( setShowFilters(false)} title="Filters" width="sm" actions={ Object.values(pendingFilters).some((vals) => vals && vals.length > 0) && ( ) } >
{filterEntries.map((key) => { const fieldDef = resp?.config.fields.find((f) => f.field_key === key); const label = fieldDef?.output_label || key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); const isDateField = fieldDef?.data_type === 'date' || fieldDef?.data_type === 'datetime' || key.toLowerCase().includes('date') || key.toLowerCase() === 'created_at' || key.toLowerCase() === 'updated_at'; if (isDateField) { return ( { const val = e.target.value; setPendingFilters((prev) => ({ ...prev, [key]: val ? [val] : [], })); }} /> ); } const options = resp?.config.filter_options?.[key] || []; const opts = options.map((o) => ({ value: o, label: o.replace(/[_\-\s]*\(?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\)?/i, '').trim() || o, })); return ( { setPendingFilters((prev) => ({ ...prev, [key]: newVals })); }} options={opts} /> ); })}
)}
{error ? ( ) : loading && !resp ? (
) : rows.length === 0 ? ( ) : (
{rows.map((row, i) => { const [primary, ...secondary] = fields; return (
onRowClick(row, i) : undefined} className={cn( 'transition-transform duration-150 relative w-full', !renderItem && 'rounded-xl border border-border-subtle bg-card p-4 shadow-sm', onRowClick && 'cursor-pointer active:scale-[0.99]', )} >
{renderItem ? ( renderItem(row, fields) ) : ( <> {primary && (

{formatValue(row[primary.field_key])}

)} {secondary.length > 0 && (
{secondary.map((f) => (

{f.output_label}: {formatValue(row[f.field_key])}

))}
)} )}
{rowActions && (
e.stopPropagation()} className="mt-3 pt-3 border-t border-border-subtle flex justify-end gap-2" > {rowActions(row)}
)}
); })}
)} { setActivePageSize(size); setPage(1); }} />
); }