This commit is contained in:
Adam Moussa 2026-09-23 17:48:50 -04:00 • committed by GitHub
commit 6e3d1fa91b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 450 additions and 20 deletions

View file

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_VISIBLE_COLUMN_KEYS,
INVOICE_ACTION_OPTIONS,
INVOICE_COLUMN_CATALOG,
LOCKED_COLUMN_KEYS,
} from "@/dashboard/column-catalog";
describe("INVOICE_COLUMN_CATALOG", () => {
it("locks Stampli Visible defaults and locked columns", () => {
expect(DEFAULT_VISIBLE_COLUMN_KEYS).toEqual([
"bolt",
"bell",
"invoiceId",
"userStatus",
"vendor",
"lastUpdate",
"invoiceDate",
"dueDate",
"amount",
"tagging",
"fs",
"eye",
]);
expect(LOCKED_COLUMN_KEYS).toEqual(["bolt", "bell", "invoiceId", "vendor"]);
});
it("includes Hidden catalog entries", () => {
const hidden = INVOICE_COLUMN_CATALOG.filter((entry) => !entry.defaultVisible).map(
(entry) => entry.key,
);
expect(hidden).toEqual([
"description",
"type",
"location",
"paymentAccount",
"paymentDate",
"processingBegan",
"relatedInvoice",
"rowCount",
]);
});
it("locks the ten Action filter options", () => {
expect(INVOICE_ACTION_OPTIONS).toHaveLength(10);
expect(INVOICE_ACTION_OPTIONS).toContain("Approve invoice");
});
});

View file

@ -0,0 +1,77 @@
/** Locked Stampli HTML column catalog (AP-41 / AP-47). */
export type InvoiceColumnKey =
| "bolt"
| "bell"
| "invoiceId"
| "userStatus"
| "vendor"
| "lastUpdate"
| "invoiceDate"
| "dueDate"
| "amount"
| "tagging"
| "fs"
| "eye"
| "description"
| "type"
| "location"
| "paymentAccount"
| "paymentDate"
| "processingBegan"
| "relatedInvoice"
| "rowCount";
export type InvoiceColumnCatalogEntry = {
key: InvoiceColumnKey;
label: string;
locked: boolean;
defaultVisible: boolean;
};
export const INVOICE_COLUMN_CATALOG: InvoiceColumnCatalogEntry[] = [
{ key: "bolt", label: "Bolt", locked: true, defaultVisible: true },
{ key: "bell", label: "Bell", locked: true, defaultVisible: true },
{ key: "invoiceId", label: "Invoice", locked: true, defaultVisible: true },
{ key: "userStatus", label: "Action", locked: false, defaultVisible: true },
{ key: "vendor", label: "Vendor", locked: true, defaultVisible: true },
{ key: "lastUpdate", label: "Last Update", locked: false, defaultVisible: true },
{ key: "invoiceDate", label: "Invoice Date", locked: false, defaultVisible: true },
{ key: "dueDate", label: "Due Date", locked: false, defaultVisible: true },
{ key: "amount", label: "Σ Sum", locked: false, defaultVisible: true },
{ key: "tagging", label: "Tagging", locked: false, defaultVisible: true },
{ key: "fs", label: "F.S.", locked: false, defaultVisible: true },
{ key: "eye", label: "Eye", locked: false, defaultVisible: true },
{ key: "description", label: "Description", locked: false, defaultVisible: false },
{ key: "type", label: "Invoice Type", locked: false, defaultVisible: false },
{ key: "location", label: "Location", locked: false, defaultVisible: false },
{ key: "paymentAccount", label: "Payment Account", locked: false, defaultVisible: false },
{ key: "paymentDate", label: "Payment Date", locked: false, defaultVisible: false },
{ key: "processingBegan", label: "Processing Began", locked: false, defaultVisible: false },
{ key: "relatedInvoice", label: "Related Invoice", locked: false, defaultVisible: false },
{ key: "rowCount", label: "Row count", locked: false, defaultVisible: false },
];
export const DEFAULT_VISIBLE_COLUMN_KEYS: InvoiceColumnKey[] = INVOICE_COLUMN_CATALOG.filter(
(entry) => entry.defaultVisible,
).map((entry) => entry.key);
export const LOCKED_COLUMN_KEYS: InvoiceColumnKey[] = INVOICE_COLUMN_CATALOG.filter(
(entry) => entry.locked,
).map((entry) => entry.key);
/** Locked Action filter options from Stampli HTML (AP-41 / AP-43). */
export const INVOICE_ACTION_OPTIONS = [
"A field update request has been submitted",
"A question has been submitted",
"Approve invoice",
"Attach receipt",
"Filed for my attention",
"Inquiry for you",
"Recalled invoice",
"Received response",
"View Comment",
"Waiting for response",
] as const;
export type InvoiceActionOption = (typeof INVOICE_ACTION_OPTIONS)[number];

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,35 +1,42 @@
import { Link as MuiLink } from "@mui/material";
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 currencyFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
const dateFormatter = new Intl.DateTimeFormat("en-US", {
const dateTimeFormatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
function formatIsoDate(value: string | null | undefined): string {
if (!value) {
function formatAmountUsd(value: number | null | undefined): string {
if (value == null) {
return "—";
}
const parsed = new Date(`${value}T00:00:00`);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return dateFormatter.format(parsed);
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 currencyFormatter.format(value);
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(value);
}
const moneyColumn = (field: keyof MockInvoice, headerName: string): GridColDef<MockInvoice> => ({
@ -54,6 +61,22 @@ const dateColumn = (
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
@ -63,6 +86,7 @@ function invoiceNumberLink(params: GridRenderCellParams<MockInvoice, string>) {
underline="hover"
data-testid={`invoice-link-${params.row.id}`}
onClick={(event) => event.stopPropagation()}
sx={{ fontWeight: 700, color: "text.primary" }}
>
{params.value}
</MuiLink>
@ -95,17 +119,137 @@ export const invoiceAttentionColumns: GridColDef<MockInvoice>[] = [
renderCell: invoiceNumberLink,
},
{
field: "status",
field: "actionLabel",
headerName: "Action",
flex: 1.2,
minWidth: 140,
valueFormatter: (value: string) => (value === "pending_approval" ? "Approve invoice" : value),
},
{ 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",

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

@ -54,6 +54,35 @@ describe("mocks module", () => {
expect(getMockInvoices().length).toBeGreaterThanOrEqual(6);
});
it("seeds Stampli catalog fields on every invoice (AP-47)", () => {
const invoices = getMockInvoices();
expect(invoices.length).toBeGreaterThan(0);
for (const invoice of invoices) {
expect(typeof invoice.hasBolt).toBe("boolean");
expect(typeof invoice.hasBell).toBe("boolean");
expect(typeof invoice.hasEye).toBe("boolean");
expect(invoice.actionLabel.length).toBeGreaterThan(0);
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.processingBegan).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(typeof invoice.tagging).toBe("string");
expect(typeof invoice.fs).toBe("string");
expect(typeof invoice.description).toBe("string");
expect(typeof invoice.invoiceType).toBe("string");
expect(typeof invoice.location).toBe("string");
expect(typeof invoice.paymentAccount).toBe("string");
expect(invoice.relatedInvoice === null || typeof invoice.relatedInvoice === "string").toBe(
true,
);
expect(invoice.rowCount).toBe(invoice.lines.length);
}
const pending = invoices.filter((invoice) => invoice.status === "pending_approval");
expect(pending.every((invoice) => invoice.actionLabel === "Approve invoice")).toBe(true);
});
it("seeds at least two invoices per dashboard tab", () => {
const invoices = getMockInvoices();
const pending = invoices.filter((invoice) => invoice.status === "pending_approval");

View file

@ -1,3 +1,4 @@
import { INVOICE_ACTION_OPTIONS } from "@/dashboard/column-catalog";
import { syncMockApprovalsForInvoice } from "@/mocks/approvals";
import { areMocksEnabled } from "@/mocks/env";
@ -45,6 +46,24 @@ export type MockInvoice = {
lines: MockInvoiceLine[];
comments: MockInvoiceComment[];
activity: MockInvoiceActivity[];
hasBolt: boolean;
hasBell: boolean;
hasEye: boolean;
actionLabel: string;
lastUpdate: string;
lastUpdateMessage: string;
lastUpdateActorName: string;
lastUpdateAt: string;
invoiceDate: string;
processingBegan: string;
tagging: string;
fs: string;
description: string;
invoiceType: string;
location: string;
paymentAccount: string;
relatedInvoice: string | null;
rowCount: number;
};
export type MockInvoiceHeaderPatch = Partial<
@ -63,6 +82,58 @@ export type MockInvoiceHeaderPatch = Partial<
const SAMPLE_DOCUMENT_URL = "/fixtures/sample-invoice.pdf";
type MockInvoiceCore = Omit<
MockInvoice,
| "hasBolt"
| "hasBell"
| "hasEye"
| "actionLabel"
| "lastUpdate"
| "lastUpdateMessage"
| "lastUpdateActorName"
| "lastUpdateAt"
| "invoiceDate"
| "processingBegan"
| "tagging"
| "fs"
| "description"
| "invoiceType"
| "location"
| "paymentAccount"
| "relatedInvoice"
| "rowCount"
>;
function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice {
const actionLabel =
core.status === "pending_approval"
? "Approve invoice"
: 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 {
...core,
hasBolt: index % 3 === 0,
hasBell: index % 2 === 0,
hasEye: true,
actionLabel,
lastUpdate: lastUpdateAt.slice(0, 10),
lastUpdateMessage: latestActivity?.message ?? "Invoice updated",
lastUpdateActorName: latestActivity?.actorName ?? "System",
lastUpdateAt,
invoiceDate: core.dueDate,
processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate,
tagging: index % 2 === 0 ? "Ops" : "",
fs: index % 2 === 0 ? "synced" : "",
description: core.memo,
invoiceType: "Standard",
location: "Ronkonkoma",
paymentAccount: core.paymentMethod === "ach" ? "Operating ACH" : "Operating Check",
relatedInvoice: null,
rowCount: core.lines.length,
};
}
function cloneInvoice(invoice: MockInvoice): MockInvoice {
return {
...invoice,
@ -72,7 +143,7 @@ function cloneInvoice(invoice: MockInvoice): MockInvoice {
};
}
const seedInvoices: MockInvoice[] = [
const seedInvoiceCores: MockInvoiceCore[] = [
{
id: "inv-1",
invoiceNumber: "INV-1001",
@ -414,6 +485,10 @@ const seedInvoices: MockInvoice[] = [
},
];
const seedInvoices: MockInvoice[] = seedInvoiceCores.map((core, index) =>
enrichInvoice(core, index),
);
/** Mutable in-memory invoice store for mock POC interactions. */
let invoiceStore: MockInvoice[] = seedInvoices.map((invoice) => cloneInvoice(invoice));