324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Search, SlidersHorizontal } 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';
|
||
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[];
|
||
/** 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;
|
||
/** Custom render function for the entire row card body */
|
||
renderItem?: (row: Record<string, unknown>, 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;
|
||
/** Pass a preset alias to apply server-side presets */
|
||
preset_alias?: string;
|
||
}
|
||
|
||
/**
|
||
* 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 = 20,
|
||
onRowClick,
|
||
rowKey,
|
||
headerActions,
|
||
rowActions,
|
||
refreshKey,
|
||
renderItem,
|
||
sortBy,
|
||
sortDir,
|
||
hideChart = false,
|
||
preset_alias,
|
||
}: RecordViewProps) {
|
||
const [page, setPage] = useState(1);
|
||
const [search, setSearch] = useState('');
|
||
const [debounced, setDebounced] = useState('');
|
||
const [activeFilters, setActiveFilters] = useState<Record<string, string>>({});
|
||
const [pendingFilters, setPendingFilters] = useState<Record<string, string>>({});
|
||
const [showFilters, setShowFilters] = useState(false);
|
||
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, { preset_alias, page, limit: pageSize, 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, pageSize, debounced, filtersParam, refreshKey, sortBy, sortDir]);
|
||
|
||
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} />
|
||
|
||
{!hideChart && <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="flex items-center gap-2">
|
||
<div className="relative flex-1 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 && (
|
||
<button
|
||
onClick={() => {
|
||
setPendingFilters(activeFilters);
|
||
setShowFilters(true);
|
||
}}
|
||
className={cn(
|
||
"relative flex items-center justify-center w-11 h-11 rounded-lg transition-colors shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-[var(--z-bg-primary-400)]",
|
||
"bg-[var(--z-bg-primary-400)] text-white border border-[var(--z-bg-primary-400)] hover:opacity-90"
|
||
)}
|
||
>
|
||
<SlidersHorizontal size={16} className="text-white" />
|
||
{Object.values(activeFilters).filter(Boolean).length > 0 && (
|
||
<span className="absolute -top-1 -right-1 bg-white text-[var(--z-text-primary-400)] text-[10px] w-4 h-4 flex items-center justify-center rounded-full font-bold shadow-sm border border-[var(--z-bg-primary-400)]">
|
||
{Object.values(activeFilters).filter(Boolean).length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{filterEntries.length > 0 && (
|
||
<Modal
|
||
open={showFilters}
|
||
onClose={() => setShowFilters(false)}
|
||
title="Filters"
|
||
width="sm"
|
||
actions={
|
||
Object.values(pendingFilters).some(Boolean) && (
|
||
<button
|
||
onClick={() => {
|
||
setPendingFilters({});
|
||
setActiveFilters({});
|
||
setPage(1);
|
||
setShowFilters(false);
|
||
}}
|
||
className="text-xs font-semibold text-ruby-600 hover:text-ruby-700 bg-ruby-50 px-3 py-1.5 rounded-md"
|
||
>
|
||
Clear All
|
||
</button>
|
||
)
|
||
}
|
||
>
|
||
<div className="flex flex-col gap-5">
|
||
{filterEntries.map(([key, options]) => {
|
||
const fieldDef = resp?.config.fields.find(f => f.field_key === key);
|
||
const label = fieldDef?.output_label || key;
|
||
const isDateField = fieldDef?.data_type === 'date' || key.toLowerCase().includes('date');
|
||
|
||
if (isDateField) {
|
||
return (
|
||
<Input
|
||
key={key}
|
||
type="date"
|
||
label={label.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase())}
|
||
value={pendingFilters[key] || ''}
|
||
onChange={(e) => {
|
||
setPendingFilters(prev => ({ ...prev, [key]: e.target.value }));
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Select
|
||
key={key}
|
||
label={label.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase())}
|
||
value={pendingFilters[key] || ''}
|
||
onChange={(e) => {
|
||
setPendingFilters(prev => ({ ...prev, [key]: e.target.value }));
|
||
}}
|
||
options={[{ value: '', label: 'All' }, ...opts]}
|
||
/>
|
||
);
|
||
})}
|
||
|
||
<button
|
||
onClick={() => {
|
||
setActiveFilters(pendingFilters);
|
||
setPage(1);
|
||
setShowFilters(false);
|
||
}}
|
||
className="mt-4 w-full h-11 bg-slate-800 text-white font-bold rounded-lg hover:bg-slate-700 transition-colors shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-800"
|
||
>
|
||
Apply Filters
|
||
</button>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
</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(
|
||
'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]',
|
||
)}
|
||
>
|
||
<div className={cn(renderItem ? "" : "flex items-center gap-3")}>
|
||
<div className="min-w-0 flex-1 w-full">
|
||
{renderItem ? (
|
||
renderItem(row, fields)
|
||
) : (
|
||
<>
|
||
{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>
|
||
</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>
|
||
);
|
||
}
|