p2p/src/components/RecordViewTable.tsx
Bhanu Prakash Sai Potteri f5bc1fa119 feat: rebuild the UI on Tailwind v4 + shadcn/ui
Migrates Tailwind v3 -> v4 (CSS-first, theme tokens for brand, workflow
states and charts) and adds 23 shadcn/ui components on Radix.

- lib/workflow.ts is now the single source of truth for state name, colour,
  icon and available actions; "Financial Review" is the canonical served
  name, with "Finance Review" kept as an alias so those rows stop falling
  through to the unstyled fallback and dropping out of every count
- record view: TanStack Table with server-side sort/paginate, company column
  with initials avatar, page totals, density and a URL-synced query state
- dashboard: one cached analytics fetch shared by tiles and charts, donut +
  aging + vendor spend, click-to-filter
- detail view: rebuilt against the live detailview payload — restores the PO
  number and payment terms that the old key heuristics silently dropped,
  parses match_summary into a 3-way-match checklist, and separates the
  department's and reviewer's own words from AI-generated content
- form modal on Radix Dialog (focus trap, scroll lock, escape handling)
2026-07-29 19:47:59 +05:30

786 lines
29 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 { 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<string, unknown>;
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<HTMLInputElement>(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<string, number> = {};
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<ColumnDef<Row>[]>(() => {
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<Row>[] = 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<Row> = {
id: "__actions",
size: 72,
enableHiding: false,
header: () => <span className="sr-only">Row actions</span>,
cell: ({ row }) => (
<RowActions
row={row.original}
onAction={(a) =>
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 <TableSkeleton rows={6} cols={6} />;
}
if (screen.isError || isError) {
const message =
(screen.error as Error)?.message ?? (error as Error)?.message ?? "Something went wrong";
return (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-10 text-center">
<TriangleAlert className="mx-auto mb-3 size-5 text-destructive" aria-hidden />
<p className="mb-3 text-base text-destructive">{message}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Try again
</Button>
</div>
);
}
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 (
<div className="rounded-xl border border-border bg-card">
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5">
<div className="flex items-center gap-1.5">
{/* Search leads the toolbar — it's the control reached for most often,
with the status filter immediately beside it. */}
<form
onSubmit={(e) => {
e.preventDefault();
setParams({ q: searchInput, page: 1 });
}}
className="relative"
>
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
ref={searchRef}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search… /"
aria-label="Search records"
className="h-8 w-44 pl-8 pr-7"
/>
{searchInput && (
<button
type="button"
onClick={() => {
setSearchInput("");
setParams({ q: "", page: 1 });
}}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded text-muted-foreground hover:text-foreground"
>
<X className="size-3" aria-hidden />
<span className="sr-only">Clear search</span>
</button>
)}
</form>
</div>
<div className="ml-auto flex items-center gap-1.5">
{screen.data?.startButtons.map((b) => (
<Button
key={b.key}
size="sm"
onClick={() => setFormModal({ activityId: b.activityId, title: b.label })}
className="h-8 gap-1.5"
>
<Plus className="size-3.5" strokeWidth={2.5} aria-hidden />
{b.label}
</Button>
))}
{toolbarAction}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8"
disabled={isFetching}
onClick={() => refetch()}
>
<RefreshCw className={cn("size-4", isFetching && "animate-spin")} aria-hidden />
<span className="sr-only">Refresh</span>
</Button>
</TooltipTrigger>
<TooltipContent>Refresh</TooltipContent>
</Tooltip>
</div>
</div>
{/* ── Active filter chips ──────────────────────────────────────────── */}
{filtersActive && (
<div className="flex flex-wrap items-center gap-1.5 border-t border-border px-4 py-2">
<span className="text-2xs uppercase tracking-wider text-muted-foreground">Filtered</span>
{params.q && (
<Chip onRemove={() => setParams({ q: "", page: 1 })}>
search: <span className="font-semibold">{params.q}</span>
</Chip>
)}
{params.status.map((s) => (
<Chip
key={s}
onRemove={() => setParams({ status: params.status.filter((x) => x !== s), page: 1 })}
>
{s}
</Chip>
))}
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-2xs text-muted-foreground"
onClick={() => setParams({ status: [], q: "", page: 1 })}
>
Clear all
</Button>
</div>
)}
{/* ── Table ────────────────────────────────────────────────────────── */}
<div className="scrollbar-thin relative overflow-x-auto border-t border-border">
<table className="w-full border-separate border-spacing-0">
<thead>
<tr>
{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 (
<th
key={header.id}
scope="col"
// aria-sort belongs on the header cell, not the button inside.
aria-sort={
sorted
? dir === "asc"
? "ascending"
: "descending"
: sortable
? "none"
: undefined
}
className={cn(
"sticky top-0 z-10 whitespace-nowrap border-b border-border bg-accent",
// Violet-on-violet keeps the header part of the theme
// rather than a grey band bolted onto it.
"text-xs font-semibold uppercase tracking-wider text-accent-foreground",
numeric ? "text-right" : "text-left",
i === 0 ? "pl-4 pr-3.5" : "px-3.5",
)}
style={{ width: header.getSize() === 150 ? undefined : header.getSize() }}
>
{sortable ? (
<button
type="button"
onClick={() =>
setParams({
sort: header.column.id,
dir: sorted && params.dir === "asc" ? "desc" : "asc",
page: 1,
})
}
className={cn(
"group/th inline-flex items-center gap-1 py-3 transition-colors hover:text-foreground",
sorted && "text-primary",
)}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{sorted ? (
dir === "asc" ? (
<ArrowUp className="size-3" aria-hidden />
) : (
<ArrowDown className="size-3" aria-hidden />
)
) : (
<ArrowUpDown
className="size-3 opacity-0 transition-opacity group-hover/th:opacity-60"
aria-hidden
/>
)}
</button>
) : (
<div className="py-3">
{flexRender(header.column.columnDef.header, header.getContext())}
</div>
)}
</th>
);
})}
</tr>
</thead>
<tbody className={cn(isPlaceholderData && "opacity-50 transition-opacity")}>
{rows.length === 0 ? (
<tr>
<td colSpan={table.getVisibleLeafColumns().length} className="px-4">
<EmptyState
filtered={filtersActive}
onClear={() => setParams({ status: [], q: "", page: 1 })}
/>
</td>
</tr>
) : (
table.getRowModel().rows.map((row) => {
const selected = row.getIsSelected();
return (
<tr
key={row.id}
// Rows are focusable and Enter/Space activatable; previously
// the click handler sat on a non-focusable <tr>.
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) => (
<td
key={cell.id}
onClick={
// The action cell must not open the row.
cell.column.id === "__actions"
? (e) => 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())}
</td>
))}
</tr>
);
})
)}
</tbody>
{/* 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 && (
<tfoot>
<tr>
{table.getVisibleLeafColumns().map((col, ci) => {
const total = pageTotals[col.id];
return (
<td
key={col.id}
className={cn(
"border-t border-border bg-muted/40 py-2.5",
ci === 0 ? "pl-4 pr-3.5" : "px-3.5",
total != null && "text-right",
)}
>
{total != null ? (
<span className="text-base font-bold tabular-nums text-primary">
{formatCurrency(total)}
</span>
) : ci === 0 ? (
<span className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
Page total · {rows.length} rows
</span>
) : null}
</td>
);
})}
</tr>
</tfoot>
)}
</table>
</div>
{/* ── Pagination ───────────────────────────────────────────────────── */}
{total_count > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-2.5">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<span>Rows</span>
<Select
value={String(params.limit)}
onValueChange={(v) => setParams({ limit: Number(v), page: 1 })}
>
<SelectTrigger size="sm" className="h-7 w-[68px]" aria-label="Rows per page">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[10, 25, 50, 100].map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator orientation="vertical" className="h-4" />
<span className="tabular-nums">
{from}{to} of {total_count}
</span>
</div>
{total_pages > 1 && (
<nav aria-label="Pagination" className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="size-7"
disabled={page <= 1}
onClick={() => setParams({ page: page - 1 })}
>
<ChevronLeft className="size-4" aria-hidden />
<span className="sr-only">Previous page</span>
</Button>
{buildPageRange(page, total_pages).map((p, idx) =>
p === "…" ? (
<span
key={`gap-${idx}`}
className="flex size-7 select-none items-center justify-center text-sm text-muted-foreground"
>
</span>
) : (
<Button
key={p}
variant={p === page ? "default" : "ghost"}
size="icon"
className="size-7 text-sm tabular-nums"
aria-current={p === page ? "page" : undefined}
onClick={() => setParams({ page: p as number })}
>
{p}
</Button>
),
)}
<Button
variant="ghost"
size="icon"
className="size-7"
disabled={page >= total_pages}
onClick={() => setParams({ page: page + 1 })}
>
<ChevronRight className="size-4" aria-hidden />
<span className="sr-only">Next page</span>
</Button>
</nav>
)}
</div>
)}
{formModal && (
<FormModal
activityId={formModal.activityId}
instanceId={formModal.instanceId}
title={formModal.title}
onClose={() => setFormModal(null)}
onSuccess={afterAction}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Toolbar pieces
// ---------------------------------------------------------------------------
function Chip({ children, onRemove }: { children: React.ReactNode; onRemove: () => void }) {
return (
<span className="inline-flex items-center gap-1 rounded-md border border-primary/25 bg-primary/10 px-2 py-0.5 text-xs text-primary">
{children}
<button onClick={onRemove} className="rounded hover:opacity-60">
<X className="size-2.5" aria-hidden />
<span className="sr-only">Remove filter</span>
</button>
</span>
);
}
// ---------------------------------------------------------------------------
// Row actions
// ---------------------------------------------------------------------------
function RowActions({
row,
onAction,
onOpen,
}: {
row: Row;
onAction: (a: StateAction) => void;
onOpen: () => void;
}) {
const actions = actionsForStateName(String(row[STATE_FIELD] ?? ""));
return (
<div className="flex items-center justify-end gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/row:opacity-100">
{actions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontal className="size-4" aria-hidden />
<span className="sr-only">Actions for this record</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{actions.map((a) => (
<DropdownMenuItem
key={a.activityId}
variant={a.destructive ? "destructive" : "default"}
onClick={() => onAction(a)}
>
<a.icon className="size-3.5" aria-hidden />
{a.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<Button variant="ghost" size="icon" className="size-7" onClick={onOpen}>
<ChevronRight className="size-4" aria-hidden />
<span className="sr-only">Open record</span>
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// Cells / empty state
// ---------------------------------------------------------------------------
function renderCell(value: unknown, field: RecordViewField, isPrimary: boolean) {
const { field_key: key, data_type: type } = field;
if (key === STATE_FIELD) return <StatusBadge value={String(value ?? "")} />;
if (value == null || value === "") {
return <span className="text-muted-foreground/40"></span>;
}
if (VENDOR_KEYS.includes(key)) return <VendorCell name={String(value)} />;
if (type === "number") {
const n = Number(value);
if (!Number.isFinite(n)) return String(value);
return isMoneyField(key) ? (
<span className="font-medium tabular-nums text-foreground">{formatCurrency(n)}</span>
) : (
<span className="tabular-nums">{n.toLocaleString("en-IN")}</span>
);
}
if (type === "date" || type === "datetime") {
const time = formatTime(String(value));
return (
<span className="block whitespace-nowrap leading-tight">
{formatDate(String(value))}
{time && <span className="block text-2xs text-muted-foreground">{time}</span>}
</span>
);
}
return (
<span className={cn("block max-w-[28ch] truncate", isPrimary && "font-semibold text-foreground")}>
{String(value)}
</span>
);
}
/** 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 (
<span className="flex items-center gap-2.5">
<span
aria-hidden
className="flex size-9 shrink-0 items-center justify-center rounded-full text-xs font-bold"
style={{ background: `color-mix(in oklab, ${tint} 15%, transparent)`, color: tint }}
>
{initials || "?"}
</span>
<span className="block max-w-[22ch] truncate font-medium text-foreground">{name}</span>
</span>
);
}
function EmptyState({ filtered, onClear }: { filtered: boolean; onClear: () => void }) {
return (
<div className="flex flex-col items-center gap-3 py-16 text-center">
<div className="flex size-12 items-center justify-center rounded-xl bg-accent">
<Inbox className="size-5 text-primary" aria-hidden />
</div>
<div>
<p className="text-md font-semibold text-foreground">No records found</p>
<p className="mt-0.5 text-sm text-muted-foreground">
{filtered
? "No invoices match the current filters."
: "Submit a new invoice to get started."}
</p>
</div>
{filtered && (
<Button variant="outline" size="sm" onClick={onClear}>
Clear filters
</Button>
)}
</div>
);
}
function buildPageRange(current: number, total: number): Array<number | ""> {
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];
}