438 lines
17 KiB
TypeScript
438 lines
17 KiB
TypeScript
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<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;
|
||
/** 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<string, string | string[]>;
|
||
}
|
||
|
||
/**
|
||
* 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<string, string[]> = {};
|
||
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<Record<string, string[]>>(initialFiltersNormalized);
|
||
const [pendingFilters, setPendingFilters] = useState<Record<string, string[]>>(initialFiltersNormalized);
|
||
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 activeFiltersStr = JSON.stringify(activeFilters);
|
||
|
||
const filtersParam = useMemo(() => {
|
||
const parsed = JSON.parse(activeFiltersStr) as Record<string, string[]>;
|
||
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<string>();
|
||
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 (
|
||
<div className="flex flex-col gap-5 w-full max-w-full overflow-x-hidden">
|
||
{!hideTiles && <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 w-full max-w-full">
|
||
<div className="flex items-center gap-2 w-full pr-1.5">
|
||
<div className="relative flex-1 focus-ring rounded-pill border border-border-subtle bg-card shadow-sm min-w-0">
|
||
<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-primary cursor-pointer shrink-0",
|
||
"bg-primary text-white border border-primary hover:opacity-90"
|
||
)}
|
||
>
|
||
<SlidersHorizontal size={16} className="text-white" />
|
||
{activeFilterCount > 0 && (
|
||
<span className="absolute top-1.5 right-1.5 w-2.5 h-2.5 bg-amber-400 rounded-full ring-2 ring-primary shadow-xs" />
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Active Filter Chips & Clear All */}
|
||
{Object.entries(activeFilters).some(([_, vals]) => vals && vals.length > 0) && (
|
||
<div className="flex flex-wrap items-center gap-2 px-3 py-2 bg-slate-100/80 rounded-lg border border-border-subtle max-w-full overflow-hidden">
|
||
<span className="text-xs font-semibold text-slate-600 mr-0.5 shrink-0">Filters:</span>
|
||
{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) => (
|
||
<span
|
||
key={`${key}-${val}`}
|
||
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-navy-50 text-navy-900 border border-navy-200/80 shadow-xs max-w-full truncate"
|
||
>
|
||
<span className="font-bold text-navy-950 truncate max-w-[110px]">{label}:</span>
|
||
<span className="truncate max-w-[130px]">{val}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setActiveFilters((prev) => {
|
||
const nextVals = (prev[key] || []).filter((v) => v !== val);
|
||
const next = { ...prev };
|
||
if (nextVals.length === 0) {
|
||
delete next[key];
|
||
} else {
|
||
next[key] = nextVals;
|
||
}
|
||
return next;
|
||
});
|
||
setPage(1);
|
||
}}
|
||
className="hover:bg-navy-200/60 rounded-full p-0.5 text-navy-700 hover:text-navy-950 cursor-pointer shrink-0"
|
||
title={`Remove ${val}`}
|
||
>
|
||
<X size={12} />
|
||
</button>
|
||
</span>
|
||
));
|
||
})}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setActiveFilters({});
|
||
setPage(1);
|
||
}}
|
||
className="ml-auto text-xs font-bold text-ruby-600 hover:text-ruby-700 bg-ruby-50 hover:bg-ruby-100 border border-ruby-200 px-2.5 py-0.5 rounded-md transition-colors cursor-pointer shrink-0"
|
||
>
|
||
Clear All
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{filterEntries.length > 0 && (
|
||
<Modal
|
||
open={showFilters}
|
||
onClose={() => setShowFilters(false)}
|
||
title="Filters"
|
||
width="sm"
|
||
actions={
|
||
Object.values(pendingFilters).some((vals) => vals && vals.length > 0) && (
|
||
<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 cursor-pointer"
|
||
>
|
||
Clear All
|
||
</button>
|
||
)
|
||
}
|
||
>
|
||
<div className="flex flex-col gap-5">
|
||
{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 (
|
||
<Input
|
||
key={key}
|
||
type="date"
|
||
label={label}
|
||
value={pendingFilters[key]?.[0] || ''}
|
||
onChange={(e) => {
|
||
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 (
|
||
<MultiSelect
|
||
key={key}
|
||
label={label}
|
||
placeholder={`Select ${label}...`}
|
||
value={pendingFilters[key] || []}
|
||
onChange={(newVals) => {
|
||
setPendingFilters((prev) => ({ ...prev, [key]: newVals }));
|
||
}}
|
||
options={opts}
|
||
/>
|
||
);
|
||
})}
|
||
|
||
<button
|
||
onClick={() => {
|
||
setActiveFilters(pendingFilters);
|
||
setPage(1);
|
||
setShowFilters(false);
|
||
}}
|
||
className="mt-4 w-full h-11 bg-primary text-white font-bold rounded-lg hover:opacity-90 transition-colors shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary cursor-pointer"
|
||
>
|
||
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={activePageSize}
|
||
total={total}
|
||
onPage={setPage}
|
||
onPageSizeChange={(size) => {
|
||
setActivePageSize(size);
|
||
setPage(1);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|