import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useQueryStates } from "nuqs"; import { useQueryClient } from "@tanstack/react-query"; import { flexRender, getCoreRowModel, useReactTable, type ColumnDef, } from "@tanstack/react-table"; import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, Inbox, MoreHorizontal, Plus, RefreshCw, Search, TriangleAlert, X, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import StatusBadge from "./StatusBadge"; import FormModal from "./FormModal"; import { TableSkeleton } from "./Skeleton"; import { useRVScreen, useRecordViewData } from "@/hooks/useRecordView"; import { tableParams, tableParamsOptions, DENSITY_CELL, type Density } from "@/lib/tableParams"; import { formatCurrency, formatDate, formatTime, isMoneyField } from "@/lib/format"; import { colorForName } from "@/lib/palette"; import { actionsForStateName, type StateAction } from "@/lib/workflow"; import type { RecordViewField, SearchQuery } from "@/api/viewService"; const STATE_FIELD = "current_state_name"; /** Candidate keys for the vendor/company column, best first. */ const VENDOR_KEYS = ["vendor_name", "company_name", "supplier_name", "vendor"]; interface Props { viewId: number | string; onRowClick?: (instanceId: string) => void; toolbarAction?: React.ReactNode; } type Row = Record; const rowId = (row: Row) => String(row.instance_id ?? row._pk_value ?? ""); export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: Props) { const navigate = useNavigate(); const queryClient = useQueryClient(); const [params, setParams] = useQueryStates(tableParams, tableParamsOptions); const [searchInput, setSearchInput] = useState(params.q); const [formModal, setFormModal] = useState<{ activityId: string; instanceId?: string; title: string; } | null>(null); const searchRef = useRef(null); const screen = useRVScreen(viewId); const query: SearchQuery = useMemo( () => ({ page: params.page, limit: params.limit, sort_by: params.sort || undefined, sort_dir: params.dir, search: params.q || undefined, // One entry per selected state. NOTE: this assumes the view-service ORs // repeated filters on the same field_key — verify against the live API, // since an AND would make any multi-select return zero rows. filters: params.status.map((value) => ({ field_key: STATE_FIELD, value, data_type: "text", })), }), [params], ); const { data, isPending, isError, error, isFetching, isPlaceholderData, refetch } = useRecordViewData(screen.data?.templateId, viewId, query); // Reflect an externally-changed search param (command palette, shared link). useEffect(() => setSearchInput(params.q), [params.q]); // "/" focuses search — this is a keyboard-driven triage queue. useEffect(() => { const onKey = (e: KeyboardEvent) => { const el = document.activeElement; const typing = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement | null)?.isContentEditable; if (e.key === "/" && !typing) { e.preventDefault(); searchRef.current?.focus(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); // The served field order puts the status chip in column two, where it // dominates the row before the reader knows what the row is. Reorder so the // identifying fields lead and the state sits fourth among the data columns. const fields = useMemo(() => { // The raw instance UUID identifies nothing to a reader — the company does. let raw = (data?.fields ?? []).filter((f) => f.field_key !== "instance_id"); const sample = data?.rows?.[0]; const vendorKey = VENDOR_KEYS.find((k) => raw.some((f) => f.field_key === k)) ?? (sample ? VENDOR_KEYS.find((k) => k in sample) : undefined); // Promote the company to second column, synthesising it when the view // serves the value on the row but doesn't list it as a field. if (vendorKey) { const existing = raw.find((f) => f.field_key === vendorKey); const col: RecordViewField = existing ?? { field_key: vendorKey, output_label: "Company", data_type: "text", is_filter: false, is_search: true, }; raw = [ ...raw.filter((f) => f.field_key !== vendorKey).slice(0, 1), col, ...raw.filter((f) => f.field_key !== vendorKey).slice(1), ]; } const idx = raw.findIndex((f) => f.field_key === STATE_FIELD); if (idx === -1) return raw; const rest = raw.filter((_, i) => i !== idx); const pos = Math.min(3, rest.length); return [...rest.slice(0, pos), raw[idx], ...rest.slice(pos)]; }, [data?.fields, data?.rows]); const rows = data?.rows ?? []; // Money columns get a footer total. Scoped to the current page and labelled // as such — summing only what's on screen is the honest thing a client can do // without a server-side aggregate. const pageTotals = useMemo(() => { const out: Record = {}; for (const f of fields) { if (f.data_type !== "number" || !isMoneyField(f.field_key)) continue; out[f.field_key] = rows.reduce((sum, r) => sum + (Number(r[f.field_key]) || 0), 0); } return out; }, [fields, rows]); const openRow = (row: Row) => { const id = rowId(row); if (!id) return; onRowClick ? onRowClick(id) : navigate(`/detail/${id}`); }; const afterAction = () => { setFormModal(null); refetch(); // Tiles and charts aggregate the same underlying view. queryClient.invalidateQueries({ queryKey: ["invoice-analytics"] }); }; // ── Columns ─────────────────────────────────────────────────────────────── const columns = useMemo[]>(() => { if (fields.length === 0) return []; const primaryKey = fields.find( (f) => f.field_key !== STATE_FIELD && f.field_key !== "instance_id" && f.data_type === "text", )?.field_key; const dataCols: ColumnDef[] = fields.map((f) => ({ id: f.field_key, accessorFn: (r) => r[f.field_key], header: f.output_label, // `numeric` drives right-alignment on the header, the cell and the footer // total — figures only line up for comparison when they share an edge. meta: { field: f, numeric: f.data_type === "number" }, cell: ({ getValue }) => renderCell(getValue(), f, f.field_key === primaryKey), })); const actions: ColumnDef = { id: "__actions", size: 72, enableHiding: false, header: () => Row actions, cell: ({ row }) => ( setFormModal({ activityId: a.activityId, instanceId: rowId(row.original), title: a.label, }) } onOpen={() => openRow(row.original)} /> ), }; return [...dataCols, actions]; // eslint-disable-next-line react-hooks/exhaustive-deps }, [fields]); const table = useReactTable({ data: rows, columns, getCoreRowModel: getCoreRowModel(), getRowId: (row, i) => rowId(row) || String(i), // Sorting and pagination happen server-side; the table is a render model. manualSorting: true, manualPagination: true, }); // ── Loading / error ─────────────────────────────────────────────────────── if (screen.isPending || (isPending && !data)) { return ; } if (screen.isError || isError) { const message = (screen.error as Error)?.message ?? (error as Error)?.message ?? "Something went wrong"; return (

{message}

); } const { page, limit, total_count, total_pages } = data!.pagination; const from = total_count > 0 ? (page - 1) * limit + 1 : 0; const to = Math.min(page * limit, total_count); const filtersActive = params.status.length > 0 || !!params.q; return (
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
{/* Search leads the toolbar — it's the control reached for most often, with the status filter immediately beside it. */}
{ e.preventDefault(); setParams({ q: searchInput, page: 1 }); }} className="relative" > setSearchInput(e.target.value)} placeholder="Search… /" aria-label="Search records" className="h-8 w-44 pl-8 pr-7" /> {searchInput && ( )}
{screen.data?.startButtons.map((b) => ( ))} {toolbarAction} Refresh
{/* ── Active filter chips ──────────────────────────────────────────── */} {filtersActive && (
Filtered {params.q && ( setParams({ q: "", page: 1 })}> search: {params.q} )} {params.status.map((s) => ( setParams({ status: params.status.filter((x) => x !== s), page: 1 })} > {s} ))}
)} {/* ── Table ────────────────────────────────────────────────────────── */}
{table.getHeaderGroups()[0]?.headers.map((header, i) => { const field = (header.column.columnDef.meta as any)?.field as | RecordViewField | undefined; const sortable = !!field; const sorted = params.sort === header.column.id; const dir = sorted ? params.dir : undefined; const numeric = !!(header.column.columnDef.meta as any)?.numeric; return ( ); })} {rows.length === 0 ? ( ) : ( table.getRowModel().rows.map((row) => { const selected = row.getIsSelected(); return ( . tabIndex={0} role="link" aria-label={`Open record ${row.id}`} onClick={() => openRow(row.original)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openRow(row.original); } }} className={cn( "group/row cursor-pointer transition-colors", selected ? "bg-accent/60" : "hover:bg-accent/40", )} > {row.getVisibleCells().map((cell, ci) => ( ))} ); }) )} {/* Totals for the rows actually on screen. Explicitly labelled "this page" so it can't be mistaken for a total across the filter. */} {rows.length > 0 && Object.keys(pageTotals).length > 0 && ( {table.getVisibleLeafColumns().map((col, ci) => { const total = pageTotals[col.id]; return ( ); })} )}
{sortable ? ( ) : (
{flexRender(header.column.columnDef.header, header.getContext())}
)}
setParams({ status: [], q: "", page: 1 })} />
e.stopPropagation() : undefined } className={cn( "border-b border-border/60 text-base", DENSITY_CELL[params.density as Density], ci === 0 ? "pl-4 pr-3.5" : "px-3.5", (cell.column.columnDef.meta as any)?.numeric && "text-right", cell.column.id === "__actions" && "text-right", )} > {flexRender(cell.column.columnDef.cell, cell.getContext())}
{total != null ? ( {formatCurrency(total)} ) : ci === 0 ? ( Page total · {rows.length} rows ) : null}
{/* ── Pagination ───────────────────────────────────────────────────── */} {total_count > 0 && (
Rows
{from}–{to} of {total_count}
{total_pages > 1 && ( )}
)} {formModal && ( setFormModal(null)} onSuccess={afterAction} /> )}
); } // --------------------------------------------------------------------------- // Toolbar pieces // --------------------------------------------------------------------------- function Chip({ children, onRemove }: { children: React.ReactNode; onRemove: () => void }) { return ( {children} ); } // --------------------------------------------------------------------------- // Row actions // --------------------------------------------------------------------------- function RowActions({ row, onAction, onOpen, }: { row: Row; onAction: (a: StateAction) => void; onOpen: () => void; }) { const actions = actionsForStateName(String(row[STATE_FIELD] ?? "")); return (
{actions.length > 0 && ( {actions.map((a) => ( onAction(a)} > {a.label} ))} )}
); } // --------------------------------------------------------------------------- // Cells / empty state // --------------------------------------------------------------------------- function renderCell(value: unknown, field: RecordViewField, isPrimary: boolean) { const { field_key: key, data_type: type } = field; if (key === STATE_FIELD) return ; if (value == null || value === "") { return ; } if (VENDOR_KEYS.includes(key)) return ; if (type === "number") { const n = Number(value); if (!Number.isFinite(n)) return String(value); return isMoneyField(key) ? ( {formatCurrency(n)} ) : ( {n.toLocaleString("en-IN")} ); } if (type === "date" || type === "datetime") { const time = formatTime(String(value)); return ( {formatDate(String(value))} {time && {time}} ); } return ( {String(value)} ); } /** Company name with an initials avatar — gives every row a visual anchor. */ function VendorCell({ name }: { name: string }) { const initials = name .split(/\s+/) .filter((w) => /[a-z0-9]/i.test(w)) .slice(0, 2) .map((w) => w[0]!.toUpperCase()) .join(""); const tint = colorForName(name); return ( {initials || "?"} {name} ); } function EmptyState({ filtered, onClear }: { filtered: boolean; onClear: () => void }) { return (

No records found

{filtered ? "No invoices match the current filters." : "Submit a new invoice to get started."}

{filtered && ( )}
); } function buildPageRange(current: number, total: number): Array { if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1); if (current <= 4) return [1, 2, 3, 4, 5, "…", total]; if (current >= total - 3) return [1, "…", total - 4, total - 3, total - 2, total - 1, total]; return [1, "…", current - 1, current, current + 1, "…", total]; }