mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 03:23:16 +00:00
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:
parent
df6ccc7ade
commit
c6867e6bec
8 changed files with 650 additions and 37 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
48
src/dashboard/column-catalog.test.ts
Normal file
48
src/dashboard/column-catalog.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
77
src/dashboard/column-catalog.ts
Normal file
77
src/dashboard/column-catalog.ts
Normal 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];
|
||||
28
src/dashboard/columns.test.ts
Normal file
28
src/dashboard/columns.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
56
src/dashboard/invoice-date-format.ts
Normal file
56
src/dashboard/invoice-date-format.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
...invoiceStore[index],
|
||||
lines: lines.map((line) => ({ ...line })),
|
||||
});
|
||||
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, {
|
||||
...current,
|
||||
comments: [...current.comments, nextComment],
|
||||
});
|
||||
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, {
|
||||
...current,
|
||||
status,
|
||||
activity: [...current.activity, nextActivity],
|
||||
});
|
||||
const updated = replaceInvoice(
|
||||
index,
|
||||
withRefreshedCatalogFields({
|
||||
...current,
|
||||
status,
|
||||
activity: [...current.activity, nextActivity],
|
||||
}),
|
||||
);
|
||||
|
||||
if (status === "approved" || status === "rejected") {
|
||||
syncMockApprovalsForInvoice(id, status);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue