krishnasales_mobile/src/components/rv/RecordView.tsx
2026-07-29 15:43:01 +05:30

438 lines
17 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, 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="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={activePageSize}
total={total}
onPage={setPage}
onPageSizeChange={(size) => {
setActivePageSize(size);
setPage(1);
}}
/>
</div>
);
}