- StatsTiles: on-brand compact tiles (token accents) vs generic gradients - RecordView: pill search, filter chips, elevated list cards w/ chevron - Bottom nav: active sunrise pill + elevation
228 lines
8.4 KiB
TypeScript
228 lines
8.4 KiB
TypeScript
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<string, unknown>, index: number) => void;
|
||
/** Extract a stable key for a row. @default row.instance_id ?? index */
|
||
rowKey?: (row: Record<string, unknown>, 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<string, unknown>) => 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<Record<string, string>>({});
|
||
const [resp, setResp] = useState<RecordViewResponse | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Debounce the search box; reset to page 1 whenever the term changes.
|
||
const timer = useRef<ReturnType<typeof setTimeout> | 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 (
|
||
<div className="flex flex-col gap-5 w-full">
|
||
<StatsTiles tiles={tileValues} />
|
||
|
||
<AnalyticsChart data={chartData} />
|
||
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h2 className="text-lg font-bold tracking-tight text-strong">{title}</h2>
|
||
{headerActions}
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2.5">
|
||
<div className="relative focus-ring rounded-pill border border-border-subtle bg-card shadow-sm">
|
||
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-faint pointer-events-none" />
|
||
<input
|
||
value={search}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
{filterEntries.length > 0 && (
|
||
<div className="flex gap-2 overflow-x-auto scrollbar-slim -mx-1 px-1 pb-1">
|
||
{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 (
|
||
<Select
|
||
key={key}
|
||
value={activeFilters[key] || ''}
|
||
onChange={(e) => {
|
||
setActiveFilters(prev => ({ ...prev, [key]: e.target.value }));
|
||
setPage(1);
|
||
}}
|
||
options={[{ value: '', label: `All ${label}` }, ...opts]}
|
||
className="w-40 shrink-0"
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{error ? (
|
||
<EmptyState title="Couldn’t load records" hint={error} />
|
||
) : loading && !resp ? (
|
||
<div className="p-10 flex justify-center">
|
||
<Spinner label="Loading records…" />
|
||
</div>
|
||
) : rows.length === 0 ? (
|
||
<EmptyState title="No records" hint={debounced ? 'Nothing matches your search.' : undefined} />
|
||
) : (
|
||
<div className="flex flex-col gap-3">
|
||
{rows.map((row, i) => {
|
||
const [primary, ...secondary] = fields;
|
||
return (
|
||
<div
|
||
key={rowKey ? rowKey(row, i) : String(row.instance_id ?? i)}
|
||
onClick={onRowClick ? () => onRowClick(row, i) : undefined}
|
||
className={cn(
|
||
'rounded-xl border border-border-subtle bg-card p-4 shadow-sm transition-transform duration-150',
|
||
onRowClick && 'cursor-pointer active:scale-[0.99]',
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
{primary && (
|
||
<p className="text-[15px] font-semibold text-strong truncate">
|
||
{formatValue(row[primary.field_key])}
|
||
</p>
|
||
)}
|
||
{secondary.length > 0 && (
|
||
<div className="mt-1 flex flex-col gap-0.5">
|
||
{secondary.map((f) => (
|
||
<p key={f.field_key} className="text-xs text-muted truncate">
|
||
<span className="text-faint">{f.output_label}:</span> {formatValue(row[f.field_key])}
|
||
</p>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{onRowClick && (
|
||
<ChevronRight size={18} className="shrink-0 text-slate-300" />
|
||
)}
|
||
</div>
|
||
{rowActions && (
|
||
<div
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="mt-3 pt-3 border-t border-border-subtle flex justify-end gap-2"
|
||
>
|
||
{rowActions(row)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
<Pagination page={page} pageSize={pageSize} total={total} onPage={setPage} />
|
||
</div>
|
||
);
|
||
}
|