krishnasales_mobile/src/components/rv/RecordView.tsx
Bhanu Prakash Sai Potteri b85775a413 Modernize mobile UI
- 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
2026-07-08 11:34:43 +05:30

228 lines
8.4 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, 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="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(
'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>
);
}