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
This commit is contained in:
Adam Moussa 2026-09-25 21:51:59 +00:00 • committed by GitHub
parent df6ccc7ade
commit c6867e6bec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 650 additions and 37 deletions

View file

@ -61,12 +61,12 @@ test.describe("dashboard grids", () => {
await editor.fill("2026-09-01");
await editor.press("Enter");
await expect(payDateCell).toContainText("Sep 1, 2026");
await expect(payDateCell).toContainText("09/01/2026");
await page.getByTestId("tab-pending").click();
await page.getByTestId("tab-ready").click();
await expect(
page.getByTestId("grid-ready").locator('[data-field="payDate"]').nth(1),
).toContainText("Sep 1, 2026");
).toContainText("09/01/2026");
});
});

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,28 @@
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");
});
it("uses calendar days so early hours still treat yesterday as 1 day ago", () => {
const earlyMorning = new Date("2026-08-11T01:15:00");
expect(formatRelativeFromIsoDate("2026-08-10", earlyMorning)).toBe("1 day ago");
expect(formatRelativeFromIsoDate("2026-08-11", earlyMorning)).toBe("today");
});
it("formats future invoice and due dates instead of clamping to today", () => {
const now = new Date("2026-08-11T12:00:00");
expect(formatRelativeFromIsoDate("2026-08-12", now)).toBe("in 1 day");
expect(formatRelativeFromIsoDate("2026-08-16", now)).toBe("in 5 days");
expect(formatRelativeFromIsoDate("2026-10-11", now)).toBe("in 2 months");
});
});

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 `${value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} USD`;
}
return dateFormatter.format(parsed);
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,56 @@
function startOfLocalDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function formatUnit(count: number, unit: "day" | "month" | "year", future: boolean): string {
const noun = count === 1 ? unit : `${unit}s`;
return future ? `in ${count} ${noun}` : `${count} ${noun} ago`;
}
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 dayMs = 24 * 60 * 60 * 1000;
const days = Math.round(
(startOfLocalDay(now).getTime() - startOfLocalDay(parsed).getTime()) / dayMs,
);
if (days === 0) {
return "today";
}
const future = days < 0;
const absDays = Math.abs(days);
if (absDays < 30) {
return formatUnit(absDays, "day", future);
}
const months = Math.floor(absDays / 30);
if (months < 12) {
return formatUnit(months, "month", future);
}
const years = Math.floor(months / 12);
return formatUnit(years, "year", future);
}

View file

@ -54,6 +54,58 @@ 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.invoiceDate).not.toBe(invoice.dueDate);
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);
const nonPending = invoices.filter((invoice) => invoice.status !== "pending_approval");
expect(nonPending.every((invoice) => invoice.actionLabel !== "Approve invoice")).toBe(true);
expect(
invoices
.filter((invoice) => invoice.status === "approved")
.every((invoice) => invoice.actionLabel === "Approved"),
).toBe(true);
expect(
invoices
.filter((invoice) => invoice.status === "paid")
.every((invoice) => invoice.actionLabel === "Paid"),
).toBe(true);
expect(
invoices
.filter((invoice) => invoice.status === "scheduled")
.every((invoice) => invoice.actionLabel === "Scheduled"),
).toBe(true);
expect(
invoices
.filter((invoice) => invoice.status === "rejected")
.every((invoice) => invoice.actionLabel === "Rejected"),
).toBe(true);
});
it("seeds at least two invoices per dashboard tab", () => {
const invoices = getMockInvoices();
const pending = invoices.filter((invoice) => invoice.status === "pending_approval");
@ -70,6 +122,8 @@ describe("mocks module", () => {
const updated = updateMockInvoicePayDate("inv-1", "2026-08-18");
expect(updated.payDate).toBe("2026-08-18");
expect(updated.lastUpdateMessage).toBe("Pay date updated");
expect(updated.lastUpdateAt).not.toBe(before?.lastUpdateAt);
const after = getMockInvoices().find((invoice) => invoice.id === "inv-1");
expect(after?.payDate).toBe("2026-08-18");
@ -94,6 +148,7 @@ describe("mocks module", () => {
const updated = updateMockInvoiceHeader("inv-2", { memo: "Updated memo", amountDue: 4700 });
expect(updated.memo).toBe("Updated memo");
expect(updated.amountDue).toBe(4700);
expect(updated.description).toBe("Updated memo");
const withLines = updateMockInvoiceLines("inv-2", [
{
@ -106,7 +161,9 @@ describe("mocks module", () => {
]);
expect(withLines.lines).toHaveLength(1);
expect(withLines.lines[0].description).toBe("Updated line");
expect(withLines.rowCount).toBe(1);
expect(getMockInvoice("inv-2")?.lines[0].description).toBe("Updated line");
expect(getMockInvoice("inv-2")?.rowCount).toBe(1);
});
it("adds comments and transitions status with activity", () => {
@ -114,6 +171,7 @@ describe("mocks module", () => {
authorUserId: "user-1",
authorName: "Alex Rivera",
body: "Need another look",
createdAt: "2026-08-11T09:00:00.000Z",
});
expect(withComment.comments.some((comment) => comment.body === "Need another look")).toBe(true);
@ -121,13 +179,57 @@ describe("mocks module", () => {
actorUserId: "user-2",
actorName: "Jordan Lee",
message: "Approved",
createdAt: "2026-08-12T09:00:00.000Z",
});
expect(approved.status).toBe("approved");
expect(approved.activity.some((entry) => entry.message === "Approved")).toBe(true);
expect(approved.lastUpdateMessage).toBe("Approved");
expect(approved.lastUpdateActorName).toBe("Jordan Lee");
expect(approved.lastUpdateAt).toBe("2026-08-12T09:00:00.000Z");
expect(approved.lastUpdate).toBe("2026-08-12");
expect(approved.actionLabel).not.toBe("Approve invoice");
expect(getMockPendingApprovals().some((item) => item.invoiceId === "inv-2")).toBe(false);
expect(getMockApprovals().find((item) => item.invoiceId === "inv-2")?.status).toBe("approved");
});
it("refreshes last update from a comment and payment account from the method", () => {
const commented = addMockInvoiceComment("inv-1", {
authorUserId: "user-1",
authorName: "Alex Rivera",
body: "Checked the total",
createdAt: "2026-09-01T15:00:00.000Z",
});
expect(commented.lastUpdateAt).toBe("2026-09-01T15:00:00.000Z");
expect(commented.lastUpdate).toBe("2026-09-01");
expect(commented.lastUpdateMessage).toBe("Checked the total");
expect(commented.lastUpdateActorName).toBe("Alex Rivera");
const method = updateMockInvoiceHeader("inv-1", { paymentMethod: "ach" });
expect(method.paymentAccount).toBe("Operating ACH");
expect(getMockInvoice("inv-1")?.paymentAccount).toBe("Operating ACH");
});
it("refreshes description and rowCount when header or lines change", () => {
const before = getMockInvoice("inv-1");
expect(before?.rowCount).toBe(2);
expect(before?.description).toBe("Office supplies restock");
const header = updateMockInvoiceHeader("inv-1", { memo: "Revised supplies memo" });
expect(header.description).toBe("Revised supplies memo");
const lines = updateMockInvoiceLines("inv-1", [
{
id: "inv-1-line-1",
description: "Only line",
amount: 100,
glAccountId: "gl-1",
departmentId: "dept-4",
},
]);
expect(lines.rowCount).toBe(1);
expect(getMockInvoice("inv-1")?.rowCount).toBe(1);
});
it("returns undefined for unknown invoice ids", () => {
expect(getMockInvoice("missing-invoice")).toBeUndefined();
});

View file

@ -45,6 +45,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 +81,116 @@ 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"
>;
const ACTION_LABEL_BY_STATUS: Record<MockInvoiceStatus, string> = {
pending_approval: "Approve invoice",
approved: "Approved",
scheduled: "Scheduled",
paid: "Paid",
rejected: "Rejected",
};
function actionLabelForStatus(status: MockInvoiceStatus): string {
return ACTION_LABEL_BY_STATUS[status];
}
function derivedActivityFields(invoice: Pick<MockInvoice, "activity" | "comments" | "dueDate">) {
const events = [
...invoice.activity.map((entry) => ({
at: entry.createdAt,
message: entry.message,
actor: entry.actorName,
})),
...invoice.comments.map((comment) => ({
at: comment.createdAt,
message: comment.body,
actor: comment.authorName,
})),
];
const latest = events.reduce<(typeof events)[number] | undefined>((current, event) => {
if (!current || event.at > current.at) {
return event;
}
return current;
}, undefined);
const lastUpdateAt = latest?.at ?? `${invoice.dueDate}T12:00:00.000Z`;
return {
lastUpdate: lastUpdateAt.slice(0, 10),
lastUpdateMessage: latest?.message ?? "Invoice updated",
lastUpdateActorName: latest?.actor ?? "System",
lastUpdateAt,
};
}
function paymentAccountFor(paymentMethod: MockInvoice["paymentMethod"]): string {
return paymentMethod === "ach" ? "Operating ACH" : "Operating Check";
}
/** Invoice date is independent of the due date. Offset varies per seed. */
function invoiceDateBeforeDue(dueDate: string, index: number): string {
const [year, month, day] = dueDate.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
date.setUTCDate(date.getUTCDate() - (14 + index));
return date.toISOString().slice(0, 10);
}
/** Keep catalog-derived fields in sync after mock mutations. */
function withRefreshedCatalogFields(invoice: MockInvoice): MockInvoice {
return {
...invoice,
actionLabel: actionLabelForStatus(invoice.status),
...derivedActivityFields(invoice),
description: invoice.memo,
paymentAccount: paymentAccountFor(invoice.paymentMethod),
rowCount: invoice.lines.length,
};
}
function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice {
return withRefreshedCatalogFields({
...core,
hasBolt: index % 3 === 0,
hasBell: index % 2 === 0,
hasEye: true,
actionLabel: actionLabelForStatus(core.status),
lastUpdate: "",
lastUpdateMessage: "",
lastUpdateActorName: "",
lastUpdateAt: "",
invoiceDate: invoiceDateBeforeDue(core.dueDate, index),
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: paymentAccountFor(core.paymentMethod),
relatedInvoice: null,
rowCount: core.lines.length,
});
}
function cloneInvoice(invoice: MockInvoice): MockInvoice {
return {
...invoice,
@ -72,7 +200,7 @@ function cloneInvoice(invoice: MockInvoice): MockInvoice {
};
}
const seedInvoices: MockInvoice[] = [
const seedInvoiceCores: MockInvoiceCore[] = [
{
id: "inv-1",
invoiceNumber: "INV-1001",
@ -414,6 +542,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));
@ -443,7 +575,24 @@ export function updateMockInvoicePayDate(id: string, payDate: string | null): Mo
throw new Error(`Mock invoice not found: ${id}`);
}
return replaceInvoice(index, { ...invoiceStore[index], payDate });
const current = invoiceStore[index];
return replaceInvoice(
index,
withRefreshedCatalogFields({
...current,
payDate,
activity: [
...current.activity,
{
id: `activity-pay-date-${Date.now()}`,
actorUserId: "user-1",
actorName: "System",
message: "Pay date updated",
createdAt: new Date().toISOString(),
},
],
}),
);
}
export function updateMockInvoiceHeader(id: string, patch: MockInvoiceHeaderPatch): MockInvoice {
@ -452,7 +601,7 @@ export function updateMockInvoiceHeader(id: string, patch: MockInvoiceHeaderPatc
throw new Error(`Mock invoice not found: ${id}`);
}
return replaceInvoice(index, { ...invoiceStore[index], ...patch });
return replaceInvoice(index, withRefreshedCatalogFields({ ...invoiceStore[index], ...patch }));
}
export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): MockInvoice {
@ -461,10 +610,13 @@ export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): Mo
throw new Error(`Mock invoice not found: ${id}`);
}
return replaceInvoice(index, {
return replaceInvoice(
index,
withRefreshedCatalogFields({
...invoiceStore[index],
lines: lines.map((line) => ({ ...line })),
});
}),
);
}
export function addMockInvoiceComment(
@ -488,10 +640,13 @@ export function addMockInvoiceComment(
createdAt: comment.createdAt ?? new Date().toISOString(),
};
return replaceInvoice(index, {
return replaceInvoice(
index,
withRefreshedCatalogFields({
...current,
comments: [...current.comments, nextComment],
});
}),
);
}
export function transitionMockInvoiceStatus(
@ -516,11 +671,14 @@ export function transitionMockInvoiceStatus(
createdAt: activity.createdAt ?? new Date().toISOString(),
};
const updated = replaceInvoice(index, {
const updated = replaceInvoice(
index,
withRefreshedCatalogFields({
...current,
status,
activity: [...current.activity, nextActivity],
});
}),
);
if (status === "approved" || status === "rejected") {
syncMockApprovalsForInvoice(id, status);