krishnasales_mobile/src/components/rv/RecordView.tsx

324 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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="Couldnt 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>
);
}