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)
786 lines
29 KiB
TypeScript
786 lines
29 KiB
TypeScript
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];
|
||
}
|