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

Refs: AP-47
This commit is contained in:
Adam Moussa 2026-08-11 15:36:25 -04:00
parent 2f55c36409
commit bf555a346e
No known key found for this signature in database
6 changed files with 180 additions and 31 deletions

View file

@ -38,7 +38,7 @@ export const INVOICE_COLUMN_CATALOG: InvoiceColumnCatalogEntry[] = [
{ key: "lastUpdate", label: "Last Update", locked: false, defaultVisible: true }, { key: "lastUpdate", label: "Last Update", locked: false, defaultVisible: true },
{ key: "invoiceDate", label: "Invoice Date", locked: false, defaultVisible: true }, { key: "invoiceDate", label: "Invoice Date", locked: false, defaultVisible: true },
{ key: "dueDate", label: "Due Date", locked: false, defaultVisible: true }, { key: "dueDate", label: "Due Date", locked: false, defaultVisible: true },
{ key: "amount", label: "Sum", locked: false, defaultVisible: true }, { key: "amount", label: "Σ Sum", locked: false, defaultVisible: true },
{ key: "tagging", label: "Tagging", locked: false, defaultVisible: true }, { key: "tagging", label: "Tagging", locked: false, defaultVisible: true },
{ key: "fs", label: "F.S.", locked: false, defaultVisible: true }, { key: "fs", label: "F.S.", locked: false, defaultVisible: true },
{ key: "eye", label: "Eye", locked: false, defaultVisible: true }, { key: "eye", label: "Eye", locked: false, defaultVisible: true },

View file

@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format";
describe("invoice column date helpers (AP-47)", () => {
it("formats ISO dates as MM/DD/YYYY", () => {
expect(formatSlashDate("2026-04-10")).toBe("04/10/2026");
});
it("formats relative ages from a fixed now", () => {
const now = new Date("2026-08-11T12:00:00");
expect(formatRelativeFromIsoDate("2026-08-11", now)).toBe("today");
expect(formatRelativeFromIsoDate("2026-08-10", now)).toBe("1 day ago");
expect(formatRelativeFromIsoDate("2026-02-11", now)).toBe("6 months ago");
});
});

View file

@ -1,39 +1,42 @@
import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined"; import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined"; import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import { Link as MuiLink } from "@mui/material"; import { Box, Link as MuiLink, Stack, Typography } from "@mui/material";
import type { GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import type { GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import type { InvoiceColumnKey } from "@/dashboard/column-catalog"; import type { InvoiceColumnKey } from "@/dashboard/column-catalog";
import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format";
import type { MockInvoice } from "@/mocks"; import type { MockInvoice } from "@/mocks";
import { Link } from "@/router"; import { Link } from "@/router";
const currencyFormatter = new Intl.NumberFormat("en-US", { const dateTimeFormatter = new Intl.DateTimeFormat("en-US", {
style: "currency",
currency: "USD",
});
const dateFormatter = new Intl.DateTimeFormat("en-US", {
year: "numeric", year: "numeric",
month: "short", month: "2-digit",
day: "numeric", day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}); });
function formatIsoDate(value: string | null | undefined): string { function formatAmountUsd(value: number | null | undefined): string {
if (!value) { if (value == null) {
return "—"; return "—";
} }
const parsed = new Date(`${value}T00:00:00`); return `${value.toLocaleString("en-US", {
if (Number.isNaN(parsed.getTime())) { minimumFractionDigits: 2,
return value; maximumFractionDigits: 2,
} })} USD`;
return dateFormatter.format(parsed); }
function formatIsoDate(value: string | null | undefined): string {
return formatSlashDate(value);
} }
function formatCurrency(value: number | null | undefined): string { function formatCurrency(value: number | null | undefined): string {
if (value == null) { if (value == null) {
return "—"; return "—";
} }
return currencyFormatter.format(value); return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(value);
} }
const moneyColumn = (field: keyof MockInvoice, headerName: string): GridColDef<MockInvoice> => ({ const moneyColumn = (field: keyof MockInvoice, headerName: string): GridColDef<MockInvoice> => ({
@ -58,6 +61,22 @@ const dateColumn = (
valueFormatter: (value: string | null | undefined) => formatIsoDate(value), 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>) { function invoiceNumberLink(params: GridRenderCellParams<MockInvoice, string>) {
return ( return (
<MuiLink <MuiLink
@ -67,6 +86,7 @@ function invoiceNumberLink(params: GridRenderCellParams<MockInvoice, string>) {
underline="hover" underline="hover"
data-testid={`invoice-link-${params.row.id}`} data-testid={`invoice-link-${params.row.id}`}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
sx={{ fontWeight: 700, color: "text.primary" }}
> >
{params.value} {params.value}
</MuiLink> </MuiLink>
@ -111,21 +131,38 @@ export const invoiceAttentionColumns: GridColDef<MockInvoice>[] = [
const iconColumn = ( const iconColumn = (
field: "hasBolt" | "hasBell" | "hasEye", field: "hasBolt" | "hasBell" | "hasEye",
headerName: string,
Icon: typeof BoltOutlinedIcon, Icon: typeof BoltOutlinedIcon,
): GridColDef<MockInvoice> => ({ ): GridColDef<MockInvoice> => ({
field, field,
headerName, headerName: "",
width: 56, width: 56,
sortable: false, sortable: false,
filterable: false, filterable: false,
renderHeader: () => <Icon fontSize="small" color="action" />,
renderCell: (params) => (params.value ? <Icon fontSize="small" color="action" /> : null), 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). */ /** Full catalog column defs keyed by AP-41 HTML column keys (AP-47). */
export const catalogColumnDefs: Record<InvoiceColumnKey, GridColDef<MockInvoice>> = { export const catalogColumnDefs: Record<InvoiceColumnKey, GridColDef<MockInvoice>> = {
bolt: iconColumn("hasBolt", "", BoltOutlinedIcon), bolt: iconColumn("hasBolt", BoltOutlinedIcon),
bell: iconColumn("hasBell", "", NotificationsNoneOutlinedIcon), bell: iconColumn("hasBell", NotificationsNoneOutlinedIcon),
invoiceId: { invoiceId: {
field: "invoiceNumbers", field: "invoiceNumbers",
headerName: "Invoice", headerName: "Invoice",
@ -134,14 +171,55 @@ export const catalogColumnDefs: Record<InvoiceColumnKey, GridColDef<MockInvoice>
renderCell: invoiceNumberLink, renderCell: invoiceNumberLink,
}, },
userStatus: { field: "actionLabel", headerName: "Action", flex: 1.2, minWidth: 140 }, userStatus: { field: "actionLabel", headerName: "Action", flex: 1.2, minWidth: 140 },
vendor: { field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 }, vendor: {
lastUpdate: dateColumn("lastUpdate", "Last Update"), field: "vendorName",
invoiceDate: dateColumn("invoiceDate", "Invoice Date"), headerName: "Vendor",
dueDate: dateColumn("dueDate", "Due Date"), flex: 1.4,
amount: moneyColumn("amountDue", "Sum"), 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 }, tagging: { field: "tagging", headerName: "Tagging", flex: 0.8, minWidth: 100 },
fs: { field: "fs", headerName: "F.S.", flex: 0.6, minWidth: 80 }, fs: {
eye: iconColumn("hasEye", "", VisibilityOutlinedIcon), 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 }, description: { field: "description", headerName: "Description", flex: 1.4, minWidth: 160 },
type: { field: "invoiceType", headerName: "Invoice Type", flex: 1, minWidth: 120 }, type: { field: "invoiceType", headerName: "Invoice Type", flex: 1, minWidth: 120 },
location: { field: "location", headerName: "Location", flex: 1, minWidth: 120 }, location: { field: "location", headerName: "Location", flex: 1, minWidth: 120 },

View file

@ -0,0 +1,42 @@
export function formatSlashDate(value: string | null | undefined): string {
if (!value) {
return "—";
}
const parsed = new Date(`${value.slice(0, 10)}T00:00:00`);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(parsed);
}
export function formatRelativeFromIsoDate(
value: string | null | undefined,
now: Date = new Date(),
): string {
if (!value) {
return "";
}
const parsed = new Date(`${value.slice(0, 10)}T00:00:00`);
if (Number.isNaN(parsed.getTime())) {
return "";
}
const diffMs = now.getTime() - parsed.getTime();
const dayMs = 24 * 60 * 60 * 1000;
const days = Math.max(0, Math.floor(diffMs / dayMs));
if (days < 1) {
return "today";
}
if (days < 30) {
return days === 1 ? "1 day ago" : `${days} days ago`;
}
const months = Math.floor(days / 30);
if (months < 12) {
return months === 1 ? "1 month ago" : `${months} months ago`;
}
const years = Math.floor(months / 12);
return years === 1 ? "1 year ago" : `${years} years ago`;
}

View file

@ -63,6 +63,9 @@ describe("mocks module", () => {
expect(typeof invoice.hasEye).toBe("boolean"); expect(typeof invoice.hasEye).toBe("boolean");
expect(invoice.actionLabel.length).toBeGreaterThan(0); expect(invoice.actionLabel.length).toBeGreaterThan(0);
expect(invoice.lastUpdate).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(invoice.lastUpdate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(invoice.lastUpdateMessage.length).toBeGreaterThan(0);
expect(invoice.lastUpdateActorName.length).toBeGreaterThan(0);
expect(invoice.lastUpdateAt.length).toBeGreaterThan(0);
expect(invoice.invoiceDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(invoice.invoiceDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(invoice.processingBegan).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(invoice.processingBegan).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(typeof invoice.tagging).toBe("string"); expect(typeof invoice.tagging).toBe("string");

View file

@ -51,6 +51,9 @@ export type MockInvoice = {
hasEye: boolean; hasEye: boolean;
actionLabel: string; actionLabel: string;
lastUpdate: string; lastUpdate: string;
lastUpdateMessage: string;
lastUpdateActorName: string;
lastUpdateAt: string;
invoiceDate: string; invoiceDate: string;
processingBegan: string; processingBegan: string;
tagging: string; tagging: string;
@ -86,6 +89,9 @@ type MockInvoiceCore = Omit<
| "hasEye" | "hasEye"
| "actionLabel" | "actionLabel"
| "lastUpdate" | "lastUpdate"
| "lastUpdateMessage"
| "lastUpdateActorName"
| "lastUpdateAt"
| "invoiceDate" | "invoiceDate"
| "processingBegan" | "processingBegan"
| "tagging" | "tagging"
@ -103,17 +109,22 @@ function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice {
core.status === "pending_approval" core.status === "pending_approval"
? "Approve invoice" ? "Approve invoice"
: INVOICE_ACTION_OPTIONS[index % INVOICE_ACTION_OPTIONS.length]; : INVOICE_ACTION_OPTIONS[index % INVOICE_ACTION_OPTIONS.length];
const latestActivity = core.activity[core.activity.length - 1];
const lastUpdateAt = latestActivity?.createdAt ?? `${core.dueDate}T12:00:00.000Z`;
return { return {
...core, ...core,
hasBolt: index % 3 === 0, hasBolt: index % 3 === 0,
hasBell: index % 2 === 0, hasBell: index % 2 === 0,
hasEye: true, hasEye: true,
actionLabel, actionLabel,
lastUpdate: core.activity[core.activity.length - 1]?.createdAt?.slice(0, 10) ?? core.dueDate, lastUpdate: lastUpdateAt.slice(0, 10),
lastUpdateMessage: latestActivity?.message ?? "Invoice updated",
lastUpdateActorName: latestActivity?.actorName ?? "System",
lastUpdateAt,
invoiceDate: core.dueDate, invoiceDate: core.dueDate,
processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate, processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate,
tagging: index % 2 === 0 ? "Ops" : "", tagging: index % 2 === 0 ? "Ops" : "",
fs: index % 2 === 0 ? "FS-1" : "", fs: index % 2 === 0 ? "synced" : "",
description: core.memo, description: core.memo,
invoiceType: "Standard", invoiceType: "Standard",
location: "Ronkonkoma", location: "Ronkonkoma",