From 056239d18627e3627fdba0cdef55204e57862dee Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 11 Aug 2026 15:20:30 -0400 Subject: [PATCH 1/5] feat(web): fix shell chrome, logo home, and live nav badges Refs: AP-42 --- e2e/home.spec.ts | 18 +++++++++ playwright.config.ts | 2 +- src/layout/AppShell.tsx | 75 ++++++++++++++++++++++-------------- src/layout/nav-badge.test.ts | 19 +++++++++ src/layout/nav.ts | 16 +++++--- 5 files changed, 96 insertions(+), 34 deletions(-) create mode 100644 src/layout/nav-badge.test.ts diff --git a/e2e/home.spec.ts b/e2e/home.spec.ts index f77e657..939e52b 100644 --- a/e2e/home.spec.ts +++ b/e2e/home.spec.ts @@ -8,3 +8,21 @@ test("home page shows Stampli-parity chrome and mocks", async ({ page }) => { await expect(page.getByTestId("page-title")).toHaveText("Invoice Processing"); await expect(page.getByTestId("mocks-status")).toContainText("Mocks enabled"); }); + +test("AP-42 shell chrome: wordmark home, no org label, single nav badge", async ({ page }) => { + await page.goto("/payments"); + await expect(page.getByTestId("payment-processing")).toBeVisible(); + + await page.getByTestId("app-wordmark").click(); + await expect(page.getByTestId("page-title")).toHaveText("Invoice Processing"); + + await expect(page.getByTestId("header-user-name")).toBeVisible(); + await expect(page.getByText("Seahaven", { exact: true })).toHaveCount(0); + + const invoiceNav = page.getByTestId("nav-invoice-processing"); + await expect(invoiceNav.locator(".MuiBadge-badge")).toHaveCount(1); + await expect(invoiceNav.locator(".MuiBadge-badge")).toHaveText(/^\d+$|^99\+$/); + + const paymentNav = page.getByTestId("nav-payment-processing"); + await expect(paymentNav.locator(".MuiBadge-badge")).toHaveCount(1); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 93d2bfa..37e011d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ ], webServer: [ { - command: "npm run dev -- --port 4173 --strictPort", + command: "VITE_SEA_HAVEN_BRAND=false npm run dev -- --port 4173 --strictPort", url: "http://localhost:4173", reuseExistingServer: !process.env.CI, timeout: 120_000, diff --git a/src/layout/AppShell.tsx b/src/layout/AppShell.tsx index bcc7448..a0c3d71 100644 --- a/src/layout/AppShell.tsx +++ b/src/layout/AppShell.tsx @@ -9,6 +9,7 @@ import ReceiptLongOutlinedIcon from "@mui/icons-material/ReceiptLongOutlined"; import SearchIcon from "@mui/icons-material/Search"; import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined"; +import { isReadyToPay } from "@seahaven-ap/shared"; import { AppBar, Badge, @@ -26,10 +27,11 @@ import { Typography, } from "@mui/material"; import type { ReactElement, ReactNode } from "react"; +import { useMemo } from "react"; import { useLocation } from "react-router"; import { brand } from "@/lib/brand"; -import { navModules } from "@/layout/nav"; -import { getMockUsers } from "@/mocks"; +import { formatNavBadgeCount, navModules } from "@/layout/nav"; +import { getMockInvoices, getMockUsers } from "@/mocks"; import { useMockSession } from "@/providers/use-mock-session"; import { useNavigate, type Path } from "@/router"; @@ -64,6 +66,18 @@ export function AppShell({ children }: AppShellProps): ReactElement { const { user, setCurrentUserId } = useMockSession(); const users = getMockUsers(); + const badgeByTestId = useMemo(() => { + const invoices = getMockInvoices(); + const attentionCount = invoices.filter( + (invoice) => invoice.status === "pending_approval", + ).length; + const readyToPayCount = invoices.filter((invoice) => isReadyToPay(invoice.status)).length; + return { + "nav-invoice-processing": formatNavBadgeCount(attentionCount), + "nav-payment-processing": formatNavBadgeCount(readyToPayCount), + } as Record; + }, []); + return ( navigate("/")} sx={{ fontWeight: 800, letterSpacing: "0.08em", fontSize: "1.05rem", mr: 1, flexShrink: 0, + border: 0, + background: "transparent", + color: "inherit", + cursor: "pointer", + p: 0, + fontFamily: "inherit", }} > {brand.wordmark} @@ -136,19 +158,25 @@ export function AppShell({ children }: AppShellProps): ReactElement { - - - - - - - {brand.orgLabel} - - - {user.name} - - - + + + + + + + + {user.name} + + Mock user @@ -204,6 +232,7 @@ export function AppShell({ children }: AppShellProps): ReactElement { .map((item) => { const selected = isSelected(location.pathname, item.to); const Icon = iconByTestId[item.testId] ?? ReceiptLongOutlinedIcon; + const badgeContent = badgeByTestId[item.testId]; return ( - - - + { + it("hides zero", () => { + expect(formatNavBadgeCount(0)).toBeUndefined(); + }); + + it("shows exact counts under 100", () => { + expect(formatNavBadgeCount(1)).toBe("1"); + expect(formatNavBadgeCount(6)).toBe("6"); + expect(formatNavBadgeCount(99)).toBe("99"); + }); + + it("caps at 99+", () => { + expect(formatNavBadgeCount(100)).toBe("99+"); + expect(formatNavBadgeCount(250)).toBe("99+"); + }); +}); diff --git a/src/layout/nav.ts b/src/layout/nav.ts index cd38a6d..a16ca2b 100644 --- a/src/layout/nav.ts +++ b/src/layout/nav.ts @@ -4,8 +4,6 @@ export type NavModule = { label: string; to: Path; testId: string; - badge?: string; - secondaryBadge?: string; }; export const navModules: NavModule[] = [ @@ -14,14 +12,11 @@ export const navModules: NavModule[] = [ label: "Invoice Processing", to: "/", testId: "nav-invoice-processing", - badge: "99+", - secondaryBadge: "6", }, { label: "Payment Processing", to: "/payments", testId: "nav-payment-processing", - badge: "99+", }, { label: "Approvals", to: "/approvals", testId: "nav-approvals" }, { label: "Vendor Management", to: "/settings/vendors", testId: "nav-vendors" }, @@ -30,3 +25,14 @@ export const navModules: NavModule[] = [ { label: "Dashboards & Reports", to: "/reports", testId: "nav-reports" }, { label: "System Settings", to: "/settings", testId: "nav-settings" }, ]; + +/** Format a nav count badge: hide at 0, cap at 99+. */ +export function formatNavBadgeCount(count: number): string | undefined { + if (count <= 0) { + return undefined; + } + if (count >= 100) { + return "99+"; + } + return String(count); +} From 8b758353101163708452cc6fb374cc771700a788 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 11 Aug 2026 15:20:30 -0400 Subject: [PATCH 2/5] feat(web): add invoice grid lines and bold headers Refs: AP-44 --- src/components/MockDataGrid.tsx | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/components/MockDataGrid.tsx b/src/components/MockDataGrid.tsx index b2d4c15..50a6b9b 100644 --- a/src/components/MockDataGrid.tsx +++ b/src/components/MockDataGrid.tsx @@ -6,6 +6,7 @@ type MockDataGridProps = DataGridProps & { export function MockDataGrid({ "data-testid": testId, + sx, ...props }: MockDataGridProps) { return ( @@ -19,11 +20,24 @@ export function MockDataGrid({ pagination: { paginationModel: { pageSize: 10, page: 0 } }, }} getRowId={(row) => row.id as string} - sx={{ - border: 1, - borderColor: "divider", - backgroundColor: "background.paper", - }} + sx={[ + { + border: 1, + borderColor: "divider", + backgroundColor: "background.paper", + "& .MuiDataGrid-columnHeaderTitle": { + fontWeight: 700, + }, + "& .MuiDataGrid-cell": { + borderBottom: "1px solid", + borderColor: "divider", + }, + "& .MuiDataGrid-row:last-of-type .MuiDataGrid-cell": { + borderBottom: "none", + }, + }, + ...(Array.isArray(sx) ? sx : sx ? [sx] : []), + ]} {...props} /> From e23ba0f4bb38b794a185e6b68595f23c92b2d7fe Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 11 Aug 2026 15:35:27 -0400 Subject: [PATCH 3/5] feat(web): support auto row height for multi-line invoice cells Refs: AP-44 --- src/components/MockDataGrid.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/components/MockDataGrid.tsx b/src/components/MockDataGrid.tsx index 50a6b9b..9a1af67 100644 --- a/src/components/MockDataGrid.tsx +++ b/src/components/MockDataGrid.tsx @@ -7,6 +7,7 @@ type MockDataGridProps = DataGridProps & { export function MockDataGrid({ "data-testid": testId, sx, + getRowHeight, ...props }: MockDataGridProps) { return ( @@ -20,6 +21,7 @@ export function MockDataGrid({ pagination: { paginationModel: { pageSize: 10, page: 0 } }, }} getRowId={(row) => row.id as string} + getRowHeight={getRowHeight ?? (() => "auto")} sx={[ { border: 1, @@ -31,6 +33,15 @@ export function MockDataGrid({ "& .MuiDataGrid-cell": { borderBottom: "1px solid", borderColor: "divider", + py: 1, + alignItems: "flex-start", + display: "flex", + }, + "& .MuiDataGrid-cellContent": { + whiteSpace: "normal", + wordBreak: "break-word", + lineHeight: 1.35, + width: "100%", }, "& .MuiDataGrid-row:last-of-type .MuiDataGrid-cell": { borderBottom: "none", From 2f55c36409eef9fe77e07ec2fd00dd664e859144 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 11 Aug 2026 15:20:30 -0400 Subject: [PATCH 4/5] feat(web): expand mock invoice fields for Stampli column catalog Refs: AP-47 --- src/dashboard/column-catalog.test.ts | 48 +++++++++++++++++ src/dashboard/column-catalog.ts | 77 ++++++++++++++++++++++++++++ src/dashboard/columns.tsx | 70 ++++++++++++++++++++++++- src/mocks/index.test.ts | 26 ++++++++++ src/mocks/invoices.ts | 66 +++++++++++++++++++++++- 5 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 src/dashboard/column-catalog.test.ts create mode 100644 src/dashboard/column-catalog.ts diff --git a/src/dashboard/column-catalog.test.ts b/src/dashboard/column-catalog.test.ts new file mode 100644 index 0000000..19c69df --- /dev/null +++ b/src/dashboard/column-catalog.test.ts @@ -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"); + }); +}); diff --git a/src/dashboard/column-catalog.ts b/src/dashboard/column-catalog.ts new file mode 100644 index 0000000..99b37c5 --- /dev/null +++ b/src/dashboard/column-catalog.ts @@ -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]; diff --git a/src/dashboard/columns.tsx b/src/dashboard/columns.tsx index 686468e..ed4f772 100644 --- a/src/dashboard/columns.tsx +++ b/src/dashboard/columns.tsx @@ -1,5 +1,9 @@ +import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined"; +import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { Link as MuiLink } from "@mui/material"; import type { GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import type { InvoiceColumnKey } from "@/dashboard/column-catalog"; import type { MockInvoice } from "@/mocks"; import { Link } from "@/router"; @@ -95,17 +99,79 @@ export const invoiceAttentionColumns: GridColDef[] = [ 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", + headerName: string, + Icon: typeof BoltOutlinedIcon, +): GridColDef => ({ + field, + headerName, + width: 56, + sortable: false, + filterable: false, + renderCell: (params) => (params.value ? : null), +}); + +/** Full catalog column defs keyed by AP-41 HTML column keys (AP-47). */ +export const catalogColumnDefs: Record> = { + 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 }, + lastUpdate: dateColumn("lastUpdate", "Last Update"), + invoiceDate: dateColumn("invoiceDate", "Invoice Date"), + dueDate: dateColumn("dueDate", "Due Date"), + amount: moneyColumn("amountDue", "Sum"), + tagging: { field: "tagging", headerName: "Tagging", flex: 0.8, minWidth: 100 }, + fs: { field: "fs", headerName: "F.S.", flex: 0.6, minWidth: 80 }, + 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[] { + return keys.map((key) => catalogColumnDefs[key]); +} + export const readyToPayColumns: GridColDef[] = [ { field: "invoiceNumber", diff --git a/src/mocks/index.test.ts b/src/mocks/index.test.ts index f3e8692..7888a6f 100644 --- a/src/mocks/index.test.ts +++ b/src/mocks/index.test.ts @@ -54,6 +54,32 @@ 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.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"); diff --git a/src/mocks/invoices.ts b/src/mocks/invoices.ts index c324ad7..86a5167 100644 --- a/src/mocks/invoices.ts +++ b/src/mocks/invoices.ts @@ -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,21 @@ export type MockInvoice = { lines: MockInvoiceLine[]; comments: MockInvoiceComment[]; activity: MockInvoiceActivity[]; + hasBolt: boolean; + hasBell: boolean; + hasEye: boolean; + actionLabel: string; + lastUpdate: 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 +79,50 @@ export type MockInvoiceHeaderPatch = Partial< const SAMPLE_DOCUMENT_URL = "/fixtures/sample-invoice.pdf"; +type MockInvoiceCore = Omit< + MockInvoice, + | "hasBolt" + | "hasBell" + | "hasEye" + | "actionLabel" + | "lastUpdate" + | "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]; + return { + ...core, + hasBolt: index % 3 === 0, + hasBell: index % 2 === 0, + hasEye: true, + actionLabel, + lastUpdate: core.activity[core.activity.length - 1]?.createdAt?.slice(0, 10) ?? core.dueDate, + invoiceDate: core.dueDate, + processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate, + tagging: index % 2 === 0 ? "Ops" : "", + fs: index % 2 === 0 ? "FS-1" : "", + 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 +132,7 @@ function cloneInvoice(invoice: MockInvoice): MockInvoice { }; } -const seedInvoices: MockInvoice[] = [ +const seedInvoiceCores: MockInvoiceCore[] = [ { id: "inv-1", invoiceNumber: "INV-1001", @@ -414,6 +474,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)); From bf555a346ec6d59ea4f00011bc3c30d0f1765384 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 11 Aug 2026 15:36:25 -0400 Subject: [PATCH 5/5] feat(web): add multi-line Stampli invoice cell renderers Refs: AP-47 --- src/dashboard/column-catalog.ts | 2 +- src/dashboard/columns.test.ts | 15 +++ src/dashboard/columns.tsx | 134 +++++++++++++++++++++------ src/dashboard/invoice-date-format.ts | 42 +++++++++ src/mocks/index.test.ts | 3 + src/mocks/invoices.ts | 15 ++- 6 files changed, 180 insertions(+), 31 deletions(-) create mode 100644 src/dashboard/columns.test.ts create mode 100644 src/dashboard/invoice-date-format.ts diff --git a/src/dashboard/column-catalog.ts b/src/dashboard/column-catalog.ts index 99b37c5..4ef3d06 100644 --- a/src/dashboard/column-catalog.ts +++ b/src/dashboard/column-catalog.ts @@ -38,7 +38,7 @@ export const INVOICE_COLUMN_CATALOG: InvoiceColumnCatalogEntry[] = [ { 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: "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 }, diff --git a/src/dashboard/columns.test.ts b/src/dashboard/columns.test.ts new file mode 100644 index 0000000..3688c34 --- /dev/null +++ b/src/dashboard/columns.test.ts @@ -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"); + }); +}); diff --git a/src/dashboard/columns.tsx b/src/dashboard/columns.tsx index ed4f772..3b1ded2 100644 --- a/src/dashboard/columns.tsx +++ b/src/dashboard/columns.tsx @@ -1,39 +1,42 @@ 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 { 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 { 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 => ({ @@ -58,6 +61,22 @@ const dateColumn = ( valueFormatter: (value: string | null | undefined) => formatIsoDate(value), }); +function twoLineCell(primary: string, secondary: string, primaryBold = false) { + return ( + + + {primary} + + + {secondary} + + + ); +} + function invoiceNumberLink(params: GridRenderCellParams) { return ( ) { underline="hover" data-testid={`invoice-link-${params.row.id}`} onClick={(event) => event.stopPropagation()} + sx={{ fontWeight: 700, color: "text.primary" }} > {params.value} @@ -111,21 +131,38 @@ export const invoiceAttentionColumns: GridColDef[] = [ const iconColumn = ( field: "hasBolt" | "hasBell" | "hasEye", - headerName: string, Icon: typeof BoltOutlinedIcon, ): GridColDef => ({ field, - headerName, + headerName: "", width: 56, sortable: false, filterable: false, + renderHeader: () => , renderCell: (params) => (params.value ? : null), }); +function dualDateColumn( + field: "invoiceDate" | "dueDate", + headerName: string, +): GridColDef { + 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> = { - bolt: iconColumn("hasBolt", "", BoltOutlinedIcon), - bell: iconColumn("hasBell", "", NotificationsNoneOutlinedIcon), + bolt: iconColumn("hasBolt", BoltOutlinedIcon), + bell: iconColumn("hasBell", NotificationsNoneOutlinedIcon), invoiceId: { field: "invoiceNumbers", headerName: "Invoice", @@ -134,14 +171,55 @@ export const catalogColumnDefs: Record renderCell: invoiceNumberLink, }, userStatus: { field: "actionLabel", headerName: "Action", flex: 1.2, minWidth: 140 }, - vendor: { field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 }, - lastUpdate: dateColumn("lastUpdate", "Last Update"), - invoiceDate: dateColumn("invoiceDate", "Invoice Date"), - dueDate: dateColumn("dueDate", "Due Date"), - amount: moneyColumn("amountDue", "Sum"), + 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: () => ( + + Σ Sum + + ), + renderCell: (params) => ( + + {formatAmountUsd(params.row.amountDue)} + + ), + }, tagging: { field: "tagging", headerName: "Tagging", flex: 0.8, minWidth: 100 }, - fs: { field: "fs", headerName: "F.S.", flex: 0.6, minWidth: 80 }, - eye: iconColumn("hasEye", "", VisibilityOutlinedIcon), + fs: { + field: "fs", + headerName: "F.S.", + flex: 0.6, + minWidth: 80, + renderCell: (params) => + params.row.fs ? : 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 }, diff --git a/src/dashboard/invoice-date-format.ts b/src/dashboard/invoice-date-format.ts new file mode 100644 index 0000000..a3ff5e3 --- /dev/null +++ b/src/dashboard/invoice-date-format.ts @@ -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`; +} diff --git a/src/mocks/index.test.ts b/src/mocks/index.test.ts index 7888a6f..4edda0d 100644 --- a/src/mocks/index.test.ts +++ b/src/mocks/index.test.ts @@ -63,6 +63,9 @@ describe("mocks module", () => { 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"); diff --git a/src/mocks/invoices.ts b/src/mocks/invoices.ts index 86a5167..48eb505 100644 --- a/src/mocks/invoices.ts +++ b/src/mocks/invoices.ts @@ -51,6 +51,9 @@ export type MockInvoice = { hasEye: boolean; actionLabel: string; lastUpdate: string; + lastUpdateMessage: string; + lastUpdateActorName: string; + lastUpdateAt: string; invoiceDate: string; processingBegan: string; tagging: string; @@ -86,6 +89,9 @@ type MockInvoiceCore = Omit< | "hasEye" | "actionLabel" | "lastUpdate" + | "lastUpdateMessage" + | "lastUpdateActorName" + | "lastUpdateAt" | "invoiceDate" | "processingBegan" | "tagging" @@ -103,17 +109,22 @@ function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice { 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: 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, processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate, tagging: index % 2 === 0 ? "Ops" : "", - fs: index % 2 === 0 ? "FS-1" : "", + fs: index % 2 === 0 ? "synced" : "", description: core.memo, invoiceType: "Standard", location: "Ronkonkoma",