seahaven-ap/src/dashboard/columns.tsx
Adam Moussa c6867e6bec
feat(web): expand mock invoice fields and multi-line cells (AP-47) (#16)
* feat(web): fix shell chrome, logo home, and live nav badges

Refs: AP-42

* feat(web): add invoice grid lines and bold headers

Refs: AP-44

* feat(web): support auto row height for multi-line invoice cells

Refs: AP-44

* feat(web): expand mock invoice fields for Stampli column catalog

Refs: AP-47

* feat(web): add multi-line Stampli invoice cell renderers

Refs: AP-47

* fix(web): address review feedback

* ci: remove the pr policy caller

* fix(web): keep mock catalog dates and updates in sync

* fix(web): derive mock action labels from invoice status

* fix(ci): drop the unused catalog refresh index

* fix(ci): format invoice mock helpers
2026-09-25 21:51:59 +00:00

284 lines
8.3 KiB
TypeScript

import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import { Box, Link as MuiLink, Stack, Typography } from "@mui/material";
import type { GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import type { InvoiceColumnKey } from "@/dashboard/column-catalog";
import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format";
import type { MockInvoice } from "@/mocks";
import { Link } from "@/router";
const dateTimeFormatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
function formatAmountUsd(value: number | null | undefined): string {
if (value == null) {
return "—";
}
return `${value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} USD`;
}
function formatIsoDate(value: string | null | undefined): string {
return formatSlashDate(value);
}
function formatCurrency(value: number | null | undefined): string {
if (value == null) {
return "—";
}
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(value);
}
const moneyColumn = (field: keyof MockInvoice, headerName: string): GridColDef<MockInvoice> => ({
field,
headerName,
flex: 1,
minWidth: 120,
type: "number",
valueFormatter: (value: number | null | undefined) => formatCurrency(value),
});
const dateColumn = (
field: keyof MockInvoice,
headerName: string,
editable = false,
): GridColDef<MockInvoice> => ({
field,
headerName,
flex: 1,
minWidth: 130,
editable,
valueFormatter: (value: string | null | undefined) => formatIsoDate(value),
});
function twoLineCell(primary: string, secondary: string, primaryBold = false) {
return (
<Stack spacing={0.25} sx={{ py: 0.25, lineHeight: 1.3 }}>
<Typography
variant="body2"
sx={{ fontWeight: primaryBold ? 700 : 400, color: "text.primary" }}
>
{primary}
</Typography>
<Typography variant="caption" color="text.secondary">
{secondary}
</Typography>
</Stack>
);
}
function invoiceNumberLink(params: GridRenderCellParams<MockInvoice, string>) {
return (
<MuiLink
component={Link}
to="/invoices/:id"
params={{ id: params.row.id }}
underline="hover"
data-testid={`invoice-link-${params.row.id}`}
onClick={(event) => event.stopPropagation()}
sx={{ fontWeight: 700, color: "text.primary" }}
>
{params.value}
</MuiLink>
);
}
export const pendingApprovalColumns: GridColDef<MockInvoice>[] = [
{
field: "invoiceNumbers",
headerName: "Invoice #",
flex: 1,
minWidth: 120,
renderCell: invoiceNumberLink,
},
{ field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
dateColumn("dueDate", "Due Date"),
dateColumn("sendPaymentOn", "Send Payment On"),
{ field: "paymentMethod", headerName: "Payment Method", flex: 1, minWidth: 130 },
moneyColumn("amountDue", "Amount Due"),
moneyColumn("amount", "Amount"),
];
/** Invoice Processing — For My Attention columns (AP-26). */
export const invoiceAttentionColumns: GridColDef<MockInvoice>[] = [
{
field: "invoiceNumbers",
headerName: "Invoice",
flex: 1,
minWidth: 120,
renderCell: invoiceNumberLink,
},
{
field: "actionLabel",
headerName: "Action",
flex: 1.2,
minWidth: 140,
},
{ field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
dateColumn("dueDate", "Due Date"),
moneyColumn("amountDue", "Σ Sum"),
];
const iconColumn = (
field: "hasBolt" | "hasBell" | "hasEye",
Icon: typeof BoltOutlinedIcon,
): GridColDef<MockInvoice> => ({
field,
headerName: "",
width: 56,
sortable: false,
filterable: false,
renderHeader: () => <Icon fontSize="small" color="action" />,
renderCell: (params) => (params.value ? <Icon fontSize="small" color="action" /> : null),
});
function dualDateColumn(
field: "invoiceDate" | "dueDate",
headerName: string,
): GridColDef<MockInvoice> {
return {
field,
headerName,
flex: 1,
minWidth: 120,
renderCell: (params) =>
twoLineCell(
formatSlashDate(params.value as string),
formatRelativeFromIsoDate(params.value as string),
),
};
}
/** Full catalog column defs keyed by AP-41 HTML column keys (AP-47). */
export const catalogColumnDefs: Record<InvoiceColumnKey, GridColDef<MockInvoice>> = {
bolt: iconColumn("hasBolt", BoltOutlinedIcon),
bell: iconColumn("hasBell", NotificationsNoneOutlinedIcon),
invoiceId: {
field: "invoiceNumbers",
headerName: "Invoice",
flex: 1,
minWidth: 120,
renderCell: invoiceNumberLink,
},
userStatus: { field: "actionLabel", headerName: "Action", flex: 1.2, minWidth: 140 },
vendor: {
field: "vendorName",
headerName: "Vendor",
flex: 1.4,
minWidth: 160,
renderCell: (params) => twoLineCell(params.row.vendorName, params.row.vendorId, true),
},
lastUpdate: {
field: "lastUpdate",
headerName: "Last Update",
flex: 1.6,
minWidth: 180,
renderCell: (params) => {
const stamp = dateTimeFormatter.format(new Date(params.row.lastUpdateAt));
return twoLineCell(
params.row.lastUpdateMessage,
`${stamp} ${params.row.lastUpdateActorName}`,
);
},
},
invoiceDate: dualDateColumn("invoiceDate", "Invoice Date"),
dueDate: dualDateColumn("dueDate", "Due Date"),
amount: {
field: "amountDue",
headerName: "Σ Sum",
flex: 1,
minWidth: 120,
type: "number",
renderHeader: () => (
<Box component="span" sx={{ fontWeight: 700 }}>
Σ Sum
</Box>
),
renderCell: (params) => (
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatAmountUsd(params.row.amountDue)}
</Typography>
),
},
tagging: { field: "tagging", headerName: "Tagging", flex: 0.8, minWidth: 100 },
fs: {
field: "fs",
headerName: "F.S.",
flex: 0.6,
minWidth: 80,
renderCell: (params) =>
params.row.fs ? <CheckCircleIcon fontSize="small" color="success" /> : null,
},
eye: iconColumn("hasEye", VisibilityOutlinedIcon),
description: { field: "description", headerName: "Description", flex: 1.4, minWidth: 160 },
type: { field: "invoiceType", headerName: "Invoice Type", flex: 1, minWidth: 120 },
location: { field: "location", headerName: "Location", flex: 1, minWidth: 120 },
paymentAccount: {
field: "paymentAccount",
headerName: "Payment Account",
flex: 1.2,
minWidth: 140,
},
paymentDate: dateColumn("payDate", "Payment Date"),
processingBegan: dateColumn("processingBegan", "Processing Began"),
relatedInvoice: {
field: "relatedInvoice",
headerName: "Related Invoice",
flex: 1,
minWidth: 120,
valueFormatter: (value: string | null) => value ?? "—",
},
rowCount: {
field: "rowCount",
headerName: "Row count",
width: 100,
type: "number",
},
};
export function columnsFromCatalogKeys(keys: InvoiceColumnKey[]): GridColDef<MockInvoice>[] {
return keys.map((key) => catalogColumnDefs[key]);
}
export const readyToPayColumns: GridColDef<MockInvoice>[] = [
{
field: "invoiceNumber",
headerName: "Invoice",
flex: 1,
minWidth: 120,
renderCell: invoiceNumberLink,
},
{ field: "vendorName", headerName: "Vendor Name", flex: 1.4, minWidth: 160 },
{ field: "paymentMethod", headerName: "Payment Method", flex: 1, minWidth: 130 },
dateColumn("sendPaymentOn", "Send Payment On"),
dateColumn("dueDate", "Due Date"),
dateColumn("payDate", "Pay Date", true),
moneyColumn("amountDue", "Amount Due"),
];
export const allInvoicesColumns: GridColDef<MockInvoice>[] = [
{
field: "invoiceNumber",
headerName: "Invoice #",
flex: 1,
minWidth: 120,
renderCell: invoiceNumberLink,
},
{ field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
{ field: "status", headerName: "Status", flex: 1, minWidth: 140 },
dateColumn("dueDate", "Due Date"),
dateColumn("payDate", "Pay Date"),
{ field: "paymentMethod", headerName: "Payment Method", flex: 1, minWidth: 130 },
moneyColumn("amountDue", "Amount Due"),
moneyColumn("amount", "Amount"),
];