From 056239d18627e3627fdba0cdef55204e57862dee Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 11 Aug 2026 15:20:30 -0400 Subject: [PATCH 01/18] 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 02/18] 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 03/18] 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 04/18] 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 0ded294835a92cdeb1d628428726ae3fb48a0974 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 11 Aug 2026 15:20:31 -0400 Subject: [PATCH 05/18] feat(web): add Action and Vendor filters to invoice processing Refs: AP-43 --- e2e/invoice-filters.spec.ts | 19 +++++ src/dashboard/DashboardGrids.tsx | 77 +++++++++++------ src/dashboard/InvoiceFilterToolbar.tsx | 112 +++++++++++++++++++++++++ src/dashboard/invoice-filters.ts | 17 ++++ 4 files changed, 199 insertions(+), 26 deletions(-) create mode 100644 e2e/invoice-filters.spec.ts create mode 100644 src/dashboard/InvoiceFilterToolbar.tsx create mode 100644 src/dashboard/invoice-filters.ts diff --git a/e2e/invoice-filters.spec.ts b/e2e/invoice-filters.spec.ts new file mode 100644 index 0000000..49f1db1 --- /dev/null +++ b/e2e/invoice-filters.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Invoice Processing filters (AP-43)", () => { + test("Action and Vendor filters reduce grid rows and badge counts", async ({ page }) => { + await page.goto("/"); + const grid = page.getByTestId("grid-pending"); + await expect(grid.locator(".MuiDataGrid-row").first()).toBeVisible(); + const before = await grid.locator(".MuiDataGrid-row").count(); + expect(before).toBeGreaterThan(0); + + await page.getByTestId("action-filter").click(); + await page.getByRole("option", { name: "Approve invoice" }).click(); + await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("1"); + + await page.getByTestId("vendor-filter").fill("Harbor"); + await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("2"); + await expect(grid.locator(".MuiDataGrid-row")).toHaveCount(1); + }); +}); diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index e6b97f9..ba23661 100644 --- a/src/dashboard/DashboardGrids.tsx +++ b/src/dashboard/DashboardGrids.tsx @@ -1,16 +1,7 @@ import { useMemo, useState } from "react"; -import { - Alert, - Box, - Button, - Chip, - CircularProgress, - Stack, - Tab, - Tabs, - Typography, -} from "@mui/material"; +import { Alert, Box, CircularProgress, Stack, Tab, Tabs, Typography } from "@mui/material"; import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid"; +import { InvoiceFilterToolbar } from "@/dashboard/InvoiceFilterToolbar"; import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton"; import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid"; import { @@ -18,6 +9,7 @@ import { invoiceAttentionColumns, readyToPayColumns, } from "@/dashboard/columns"; +import { EMPTY_INVOICE_FILTERS, type InvoiceFilterState } from "@/dashboard/invoice-filters"; import { countSelectedRows } from "@/dashboard/selection"; import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices"; import { areMocksEnabled, type MockInvoice } from "@/mocks"; @@ -30,6 +22,8 @@ const emptySelection: GridRowSelectionModel = { ids: new Set(), }; +const emptyFilters = EMPTY_INVOICE_FILTERS; + const rowClickSx = { border: 1, borderColor: "divider", @@ -38,10 +32,26 @@ const rowClickSx = { borderRadius: 1, } as const; +function applyInvoiceFilters(rows: MockInvoice[], filters: InvoiceFilterState): MockInvoice[] { + const vendorQuery = filters.vendorQuery.trim().toLowerCase(); + return rows.filter((invoice) => { + if (filters.action && invoice.actionLabel !== filters.action) { + return false; + } + if (vendorQuery && !invoice.vendorName.toLowerCase().includes(vendorQuery)) { + return false; + } + return true; + }); +} + export function DashboardGrids() { const navigate = useNavigate(); const [tab, setTab] = useState("attention"); const [selection, setSelection] = useState(emptySelection); + const [filters, setFilters] = useState(emptyFilters); + const [, setColumnsDrawerOpen] = useState(false); + const [, setFilterDrawerOpen] = useState(false); const { data: invoices = [], isLoading, isError, error } = useInvoices(); const updatePayDate = useUpdateInvoicePayDate(); @@ -69,9 +79,24 @@ export function DashboardGrids() { ); }, [invoices]); + const filteredAttention = useMemo( + () => applyInvoiceFilters(attentionRows, filters), + [attentionRows, filters], + ); + const filteredAll = useMemo(() => applyInvoiceFilters(invoices, filters), [invoices, filters]); + const filteredApproved = useMemo( + () => applyInvoiceFilters(approvedRows, filters), + [approvedRows, filters], + ); + const filteredRejected = useMemo( + () => applyInvoiceFilters(rejectedRows, filters), + [rejectedRows, filters], + ); + const filteredLate = useMemo(() => applyInvoiceFilters(lateRows, filters), [lateRows, filters]); + const selectedCount = countSelectedRows( selection, - approvedRows.map((invoice) => invoice.id), + filteredApproved.map((invoice) => invoice.id), ); const processRowUpdate = async (newRow: MockInvoice, oldRow: MockInvoice) => { @@ -152,20 +177,20 @@ export function DashboardGrids() { ) : null} - - - - - - {tab === "approved" ? : null} - + setFilterDrawerOpen(true)} + onOpenColumnsDrawer={() => setColumnsDrawerOpen(true)} + trailingActions={ + tab === "approved" ? : null + } + /> {tab === "attention" && ( void; + onOpenFilterDrawer: () => void; + onOpenColumnsDrawer: () => void; + trailingActions?: ReactElement | null; +}; + +export function InvoiceFilterToolbar({ + filters, + onFiltersChange, + onOpenFilterDrawer, + onOpenColumnsDrawer, + trailingActions = null, +}: InvoiceFilterToolbarProps): ReactElement { + const activeCount = countActiveFilters(filters); + + return ( + + + onFiltersChange({ ...filters, action: value })} + renderInput={(params) => } + /> + + onFiltersChange({ ...filters, vendorQuery: event.target.value })} + sx={{ minWidth: 180 }} + slotProps={{ + htmlInput: { "data-testid": "vendor-filter" }, + input: { + endAdornment: filters.vendorQuery ? ( + + + + ) : undefined, + }, + }} + /> + + + + + + + + + + + + + + {trailingActions} + + ); +} diff --git a/src/dashboard/invoice-filters.ts b/src/dashboard/invoice-filters.ts new file mode 100644 index 0000000..5504bcc --- /dev/null +++ b/src/dashboard/invoice-filters.ts @@ -0,0 +1,17 @@ +export type InvoiceFilterState = { + action: string | null; + vendorQuery: string; +}; + +export function countActiveFilters(filters: InvoiceFilterState): number { + let count = 0; + if (filters.action) { + count += 1; + } + if (filters.vendorQuery.trim()) { + count += 1; + } + return count; +} + +export const EMPTY_INVOICE_FILTERS: InvoiceFilterState = { action: null, vendorQuery: "" }; From bf555a346ec6d59ea4f00011bc3c30d0f1765384 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 11 Aug 2026 15:36:25 -0400 Subject: [PATCH 06/18] 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", From 36d9fcdc6c06d4a9bee13a75d1e90dff5e292ea5 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 11 Aug 2026 15:40:25 -0400 Subject: [PATCH 07/18] feat(web): align filter toolbar layout, field shapes, and tabs Refs: AP-43 --- src/dashboard/DashboardGrids.tsx | 108 +++++++++++++---- src/dashboard/InvoiceFilterToolbar.tsx | 156 +++++++++++++------------ src/dashboard/invoice-tabs.tsx | 40 +++++++ 3 files changed, 209 insertions(+), 95 deletions(-) create mode 100644 src/dashboard/invoice-tabs.tsx diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index ba23661..e7d8a55 100644 --- a/src/dashboard/DashboardGrids.tsx +++ b/src/dashboard/DashboardGrids.tsx @@ -10,6 +10,7 @@ import { readyToPayColumns, } from "@/dashboard/columns"; import { EMPTY_INVOICE_FILTERS, type InvoiceFilterState } from "@/dashboard/invoice-filters"; +import { InvoiceTabLabel } from "@/dashboard/invoice-tabs"; import { countSelectedRows } from "@/dashboard/selection"; import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices"; import { areMocksEnabled, type MockInvoice } from "@/mocks"; @@ -24,6 +25,27 @@ const emptySelection: GridRowSelectionModel = { const emptyFilters = EMPTY_INVOICE_FILTERS; +const invoiceTabsSx = { + minHeight: 44, + "& .MuiTab-root": { + bgcolor: "rgba(47,128,237,0.12)", + mr: 0.5, + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + color: "text.primary", + textTransform: "none", + }, + "& .Mui-selected": { + bgcolor: "#fff !important", + color: "text.primary !important", + }, + "& .MuiTabs-indicator": { + height: 3, + borderRadius: 1, + backgroundColor: "#7B61FF", + }, +} as const; + const rowClickSx = { border: 1, borderColor: "divider", @@ -134,47 +156,87 @@ export function DashboardGrids() { }} variant="scrollable" aria-label="Invoice Processing tabs" - sx={{ - minHeight: 44, - "& .MuiTab-root": { - bgcolor: "rgba(47,128,237,0.12)", - mr: 0.5, - borderTopLeftRadius: 6, - borderTopRightRadius: 6, - color: "text.primary", - }, - "& .Mui-selected": { - bgcolor: "#fff !important", - color: "text.primary !important", - }, - "& .MuiTabs-indicator": { display: "none" }, - }} + sx={invoiceTabsSx} > + } value="attention" data-testid="tab-pending" /> - + } + value="all" + data-testid="tab-all" + /> + + } value="approved" data-testid="tab-ready" /> + } value="rejected" data-testid="tab-rejected" /> - + + } + value="late" + data-testid="tab-late" + /> {attentionRows.length > 0 && tab === "attention" ? ( - - {attentionRows.length} invoices require approval. Show Invoices ready to approve. - + + {attentionRows.length} invoices require approval. Show invoices ready to approve. + ) : null} - - + + + onFiltersChange({ ...filters, action: value })} + renderInput={(params) => ( + + )} + /> + + onFiltersChange({ ...filters, action: value })} - renderInput={(params) => } + name="vendor" + id="vendor" + placeholder="Vendor" + value={filters.vendorQuery} + onChange={(event) => onFiltersChange({ ...filters, vendorQuery: event.target.value })} + sx={fieldSx} + slotProps={{ + htmlInput: { "data-testid": "vendor-filter", "aria-label": "Vendor" }, + input: { + endAdornment: filters.vendorQuery ? ( + + + + ) : undefined, + }, + }} /> - - onFiltersChange({ ...filters, vendorQuery: event.target.value })} - sx={{ minWidth: 180 }} - slotProps={{ - htmlInput: { "data-testid": "vendor-filter" }, - input: { - endAdornment: filters.vendorQuery ? ( - - - - ) : undefined, - }, - }} - /> - - - - - - - - - - - - - - {trailingActions} + + + + + + + + + + + + + + + + + {trailingActions} + ); } diff --git a/src/dashboard/invoice-tabs.tsx b/src/dashboard/invoice-tabs.tsx new file mode 100644 index 0000000..6e18d1f --- /dev/null +++ b/src/dashboard/invoice-tabs.tsx @@ -0,0 +1,40 @@ +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; +import { Box, Stack } from "@mui/material"; +import type { ReactElement } from "react"; + +export function InvoiceTabLabel({ + title, + count, + active = false, + urgency = false, +}: { + title: string; + count: number; + active?: boolean; + urgency?: boolean; +}): ReactElement { + return ( + + {title} + {urgency ? : null} + + {count} + + + ); +} From 630f40baaeab94241680784bea3c6adeef0791d6 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 16:35:40 -0400 Subject: [PATCH 08/18] fix(web): address review feedback --- e2e/dashboard-grids.spec.ts | 4 +- src/dashboard/columns.test.ts | 13 ++++ src/dashboard/invoice-date-format.ts | 30 ++++++-- src/mocks/index.test.ts | 32 ++++++++ src/mocks/invoices.ts | 110 +++++++++++++++++++-------- 5 files changed, 147 insertions(+), 42 deletions(-) diff --git a/e2e/dashboard-grids.spec.ts b/e2e/dashboard-grids.spec.ts index 160476a..29bc24c 100644 --- a/e2e/dashboard-grids.spec.ts +++ b/e2e/dashboard-grids.spec.ts @@ -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"); }); }); diff --git a/src/dashboard/columns.test.ts b/src/dashboard/columns.test.ts index 3688c34..a0cdeb1 100644 --- a/src/dashboard/columns.test.ts +++ b/src/dashboard/columns.test.ts @@ -12,4 +12,17 @@ describe("invoice column date helpers (AP-47)", () => { 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"); + }); }); diff --git a/src/dashboard/invoice-date-format.ts b/src/dashboard/invoice-date-format.ts index a3ff5e3..f02ade0 100644 --- a/src/dashboard/invoice-date-format.ts +++ b/src/dashboard/invoice-date-format.ts @@ -1,3 +1,12 @@ +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 "—"; @@ -24,19 +33,24 @@ export function formatRelativeFromIsoDate( 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) { + const days = Math.round( + (startOfLocalDay(now).getTime() - startOfLocalDay(parsed).getTime()) / dayMs, + ); + if (days === 0) { return "today"; } - if (days < 30) { - return days === 1 ? "1 day ago" : `${days} days ago`; + + const future = days < 0; + const absDays = Math.abs(days); + if (absDays < 30) { + return formatUnit(absDays, "day", future); } - const months = Math.floor(days / 30); + const months = Math.floor(absDays / 30); if (months < 12) { - return months === 1 ? "1 month ago" : `${months} months ago`; + return formatUnit(months, "month", future); } const years = Math.floor(months / 12); - return years === 1 ? "1 year ago" : `${years} years ago`; + return formatUnit(years, "year", future); } diff --git a/src/mocks/index.test.ts b/src/mocks/index.test.ts index 4edda0d..774dcd9 100644 --- a/src/mocks/index.test.ts +++ b/src/mocks/index.test.ts @@ -81,6 +81,8 @@ describe("mocks module", () => { } 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); }); it("seeds at least two invoices per dashboard tab", () => { @@ -123,6 +125,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", [ { @@ -135,7 +138,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", () => { @@ -150,13 +155,40 @@ 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 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(); }); diff --git a/src/mocks/invoices.ts b/src/mocks/invoices.ts index 48eb505..81cc2b5 100644 --- a/src/mocks/invoices.ts +++ b/src/mocks/invoices.ts @@ -104,36 +104,67 @@ type MockInvoiceCore = Omit< | "rowCount" >; -function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice { - const actionLabel = - core.status === "pending_approval" - ? "Approve invoice" - : INVOICE_ACTION_OPTIONS[index % INVOICE_ACTION_OPTIONS.length]; - const latestActivity = core.activity[core.activity.length - 1]; - const lastUpdateAt = latestActivity?.createdAt ?? `${core.dueDate}T12:00:00.000Z`; +/** Non-pending rows never show the pending-only "Approve invoice" action. */ +const NON_PENDING_ACTION_OPTIONS = INVOICE_ACTION_OPTIONS.filter( + (option) => option !== "Approve invoice", +); + +function actionLabelForStatus(status: MockInvoiceStatus, index: number): string { + if (status === "pending_approval") { + return "Approve invoice"; + } + return NON_PENDING_ACTION_OPTIONS[index % NON_PENDING_ACTION_OPTIONS.length]; +} + +function derivedActivityFields(invoice: Pick) { + const latestActivity = invoice.activity[invoice.activity.length - 1]; + const lastUpdateAt = latestActivity?.createdAt ?? `${invoice.dueDate}T12:00:00.000Z`; return { - ...core, - hasBolt: index % 3 === 0, - hasBell: index % 2 === 0, - hasEye: true, - actionLabel, lastUpdate: lastUpdateAt.slice(0, 10), lastUpdateMessage: latestActivity?.message ?? "Invoice updated", lastUpdateActorName: latestActivity?.actorName ?? "System", lastUpdateAt, - invoiceDate: core.dueDate, - processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate, - tagging: index % 2 === 0 ? "Ops" : "", - fs: index % 2 === 0 ? "synced" : "", - description: core.memo, - invoiceType: "Standard", - location: "Ronkonkoma", - paymentAccount: core.paymentMethod === "ach" ? "Operating ACH" : "Operating Check", - relatedInvoice: null, - rowCount: core.lines.length, }; } +/** Keep catalog-derived fields in sync after mock mutations. */ +function withRefreshedCatalogFields(invoice: MockInvoice, index: number): MockInvoice { + return { + ...invoice, + actionLabel: actionLabelForStatus(invoice.status, index), + ...derivedActivityFields(invoice), + description: invoice.memo, + 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, index), + lastUpdate: "", + lastUpdateMessage: "", + lastUpdateActorName: "", + lastUpdateAt: "", + invoiceDate: core.dueDate, + processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate, + tagging: index % 2 === 0 ? "Ops" : "", + fs: index % 2 === 0 ? "synced" : "", + description: core.memo, + invoiceType: "Standard", + location: "Ronkonkoma", + paymentAccount: core.paymentMethod === "ach" ? "Operating ACH" : "Operating Check", + relatedInvoice: null, + rowCount: core.lines.length, + }, + index, + ); +} + function cloneInvoice(invoice: MockInvoice): MockInvoice { return { ...invoice, @@ -527,7 +558,10 @@ 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 }, index), + ); } export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): MockInvoice { @@ -536,10 +570,16 @@ 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 })), + }, + index, + ), + ); } export function addMockInvoiceComment( @@ -591,11 +631,17 @@ 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], + }, + index, + ), + ); if (status === "approved" || status === "rejected") { syncMockApprovalsForInvoice(id, status); From ebdee0292ce7d18ee0628a1eb0e495e7343a1c93 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 16:45:14 -0400 Subject: [PATCH 09/18] fix(web): address review feedback --- e2e/invoice-filters.spec.ts | 2 ++ src/dashboard/DashboardGrids.tsx | 6 +++--- src/dashboard/InvoiceFilterToolbar.tsx | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/e2e/invoice-filters.spec.ts b/e2e/invoice-filters.spec.ts index 49f1db1..2b5588f 100644 --- a/e2e/invoice-filters.spec.ts +++ b/e2e/invoice-filters.spec.ts @@ -8,6 +8,7 @@ test.describe("Invoice Processing filters (AP-43)", () => { const before = await grid.locator(".MuiDataGrid-row").count(); expect(before).toBeGreaterThan(0); + await expect(page.getByTestId("action-filter").getByPlaceholder("Search")).toBeVisible(); await page.getByTestId("action-filter").click(); await page.getByRole("option", { name: "Approve invoice" }).click(); await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("1"); @@ -15,5 +16,6 @@ test.describe("Invoice Processing filters (AP-43)", () => { await page.getByTestId("vendor-filter").fill("Harbor"); await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("2"); await expect(grid.locator(".MuiDataGrid-row")).toHaveCount(1); + await expect(page.getByTestId("attention-banner")).toContainText("1 invoices require approval"); }); }); diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index e7d8a55..a51520b 100644 --- a/src/dashboard/DashboardGrids.tsx +++ b/src/dashboard/DashboardGrids.tsx @@ -118,7 +118,7 @@ export function DashboardGrids() { const selectedCount = countSelectedRows( selection, - filteredApproved.map((invoice) => invoice.id), + approvedRows.map((invoice) => invoice.id), ); const processRowUpdate = async (newRow: MockInvoice, oldRow: MockInvoice) => { @@ -218,7 +218,7 @@ export function DashboardGrids() { - {attentionRows.length > 0 && tab === "attention" ? ( + {filteredAttention.length > 0 && tab === "attention" ? ( - {attentionRows.length} invoices require approval. Show invoices ready to approve. + {filteredAttention.length} invoices require approval. Show invoices ready to approve. ) : null} diff --git a/src/dashboard/InvoiceFilterToolbar.tsx b/src/dashboard/InvoiceFilterToolbar.tsx index ee1f548..5e6a0c2 100644 --- a/src/dashboard/InvoiceFilterToolbar.tsx +++ b/src/dashboard/InvoiceFilterToolbar.tsx @@ -52,7 +52,7 @@ export function InvoiceFilterToolbar({ renderInput={(params) => ( From dd95dab697515d605f6ad964f380e7d72b6a0f68 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 16:50:19 -0400 Subject: [PATCH 10/18] ci: remove the pr policy caller --- .github/workflows/policy.yaml | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 .github/workflows/policy.yaml diff --git a/.github/workflows/policy.yaml b/.github/workflows/policy.yaml deleted file mode 100644 index 8603b05..0000000 --- a/.github/workflows/policy.yaml +++ /dev/null @@ -1,29 +0,0 @@ -name: PR Policy - -on: - pull_request: - types: - - opened - - reopened - - synchronize - - edited - - labeled - - unlabeled - - ready_for_review - -concurrency: - group: "policy-${{ github.event.pull_request.number }}" - cancel-in-progress: true - -permissions: - contents: read - issues: read - pull-requests: read - -jobs: - policy: - uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@e5691d8a7f96ac4d5a841a82975ff0a4354d53ac # v1.0.7 - secrets: - JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }} - JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }} - JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} From 070730e4ee2cdb3fa46582eb25c21919bd664f75 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 17:03:05 -0400 Subject: [PATCH 11/18] fix(web): keep mock catalog dates and updates in sync --- src/mocks/index.test.ts | 21 +++++++++++ src/mocks/invoices.ts | 80 ++++++++++++++++++++++++++++++++++------- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/src/mocks/index.test.ts b/src/mocks/index.test.ts index 774dcd9..506a8f0 100644 --- a/src/mocks/index.test.ts +++ b/src/mocks/index.test.ts @@ -67,6 +67,7 @@ describe("mocks module", () => { 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"); @@ -101,6 +102,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"); @@ -148,6 +151,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); @@ -168,6 +172,23 @@ describe("mocks module", () => { 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); diff --git a/src/mocks/invoices.ts b/src/mocks/invoices.ts index 81cc2b5..a565190 100644 --- a/src/mocks/invoices.ts +++ b/src/mocks/invoices.ts @@ -116,17 +116,46 @@ function actionLabelForStatus(status: MockInvoiceStatus, index: number): string return NON_PENDING_ACTION_OPTIONS[index % NON_PENDING_ACTION_OPTIONS.length]; } -function derivedActivityFields(invoice: Pick) { - const latestActivity = invoice.activity[invoice.activity.length - 1]; - const lastUpdateAt = latestActivity?.createdAt ?? `${invoice.dueDate}T12:00:00.000Z`; +function derivedActivityFields(invoice: Pick) { + 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: latestActivity?.message ?? "Invoice updated", - lastUpdateActorName: latestActivity?.actorName ?? "System", + 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, index: number): MockInvoice { return { @@ -134,6 +163,7 @@ function withRefreshedCatalogFields(invoice: MockInvoice, index: number): MockIn actionLabel: actionLabelForStatus(invoice.status, index), ...derivedActivityFields(invoice), description: invoice.memo, + paymentAccount: paymentAccountFor(invoice.paymentMethod), rowCount: invoice.lines.length, }; } @@ -150,14 +180,14 @@ function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice { lastUpdateMessage: "", lastUpdateActorName: "", lastUpdateAt: "", - invoiceDate: core.dueDate, + 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: core.paymentMethod === "ach" ? "Operating ACH" : "Operating Check", + paymentAccount: paymentAccountFor(core.paymentMethod), relatedInvoice: null, rowCount: core.lines.length, }, @@ -549,7 +579,27 @@ 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(), + }, + ], + }, + index, + ), + ); } export function updateMockInvoiceHeader(id: string, patch: MockInvoiceHeaderPatch): MockInvoice { @@ -603,10 +653,16 @@ 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], + }, + index, + ), + ); } export function transitionMockInvoiceStatus( From 291f46ce7e7f31f89d8aeae0357399d5b2001aa3 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 17:03:19 -0400 Subject: [PATCH 12/18] fix(web): render the attention banner as text --- src/dashboard/DashboardGrids.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index a51520b..ce55c3f 100644 --- a/src/dashboard/DashboardGrids.tsx +++ b/src/dashboard/DashboardGrids.tsx @@ -220,18 +220,12 @@ export function DashboardGrids() { {filteredAttention.length > 0 && tab === "attention" ? ( From 98e2fadea16884c7db945d5888c6f437c6f34155 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 17:13:37 -0400 Subject: [PATCH 13/18] fix(web): make the attention banner non-interactive copy --- src/dashboard/DashboardGrids.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index ce55c3f..31c6766 100644 --- a/src/dashboard/DashboardGrids.tsx +++ b/src/dashboard/DashboardGrids.tsx @@ -222,14 +222,10 @@ export function DashboardGrids() { - {filteredAttention.length} invoices require approval. Show invoices ready to approve. + {filteredAttention.length} invoices require approval. ) : null} From f076b91aff3ec7b1c3cc59d88537267cdb282c76 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 17:13:37 -0400 Subject: [PATCH 14/18] fix(web): derive mock action labels from invoice status --- src/mocks/index.test.ts | 20 ++++++++++++++++++++ src/mocks/invoices.ts | 23 +++++++++++------------ 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/mocks/index.test.ts b/src/mocks/index.test.ts index 506a8f0..0729bd8 100644 --- a/src/mocks/index.test.ts +++ b/src/mocks/index.test.ts @@ -84,6 +84,26 @@ describe("mocks module", () => { 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", () => { diff --git a/src/mocks/invoices.ts b/src/mocks/invoices.ts index a565190..3ff6ff0 100644 --- a/src/mocks/invoices.ts +++ b/src/mocks/invoices.ts @@ -1,4 +1,3 @@ -import { INVOICE_ACTION_OPTIONS } from "@/dashboard/column-catalog"; import { syncMockApprovalsForInvoice } from "@/mocks/approvals"; import { areMocksEnabled } from "@/mocks/env"; @@ -104,16 +103,16 @@ type MockInvoiceCore = Omit< | "rowCount" >; -/** Non-pending rows never show the pending-only "Approve invoice" action. */ -const NON_PENDING_ACTION_OPTIONS = INVOICE_ACTION_OPTIONS.filter( - (option) => option !== "Approve invoice", -); +const ACTION_LABEL_BY_STATUS: Record = { + pending_approval: "Approve invoice", + approved: "Approved", + scheduled: "Scheduled", + paid: "Paid", + rejected: "Rejected", +}; -function actionLabelForStatus(status: MockInvoiceStatus, index: number): string { - if (status === "pending_approval") { - return "Approve invoice"; - } - return NON_PENDING_ACTION_OPTIONS[index % NON_PENDING_ACTION_OPTIONS.length]; +function actionLabelForStatus(status: MockInvoiceStatus): string { + return ACTION_LABEL_BY_STATUS[status]; } function derivedActivityFields(invoice: Pick) { @@ -160,7 +159,7 @@ function invoiceDateBeforeDue(dueDate: string, index: number): string { function withRefreshedCatalogFields(invoice: MockInvoice, index: number): MockInvoice { return { ...invoice, - actionLabel: actionLabelForStatus(invoice.status, index), + actionLabel: actionLabelForStatus(invoice.status), ...derivedActivityFields(invoice), description: invoice.memo, paymentAccount: paymentAccountFor(invoice.paymentMethod), @@ -175,7 +174,7 @@ function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice { hasBolt: index % 3 === 0, hasBell: index % 2 === 0, hasEye: true, - actionLabel: actionLabelForStatus(core.status, index), + actionLabel: actionLabelForStatus(core.status), lastUpdate: "", lastUpdateMessage: "", lastUpdateActorName: "", From 1faee81fbbc763051c8c16c63928e4fb06a40320 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 17:20:11 -0400 Subject: [PATCH 15/18] fix(web): restore link-style attention banner copy --- e2e/invoice-filters.spec.ts | 4 +++- src/dashboard/DashboardGrids.tsx | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/e2e/invoice-filters.spec.ts b/e2e/invoice-filters.spec.ts index 2b5588f..dedd371 100644 --- a/e2e/invoice-filters.spec.ts +++ b/e2e/invoice-filters.spec.ts @@ -16,6 +16,8 @@ test.describe("Invoice Processing filters (AP-43)", () => { await page.getByTestId("vendor-filter").fill("Harbor"); await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("2"); await expect(grid.locator(".MuiDataGrid-row")).toHaveCount(1); - await expect(page.getByTestId("attention-banner")).toContainText("1 invoices require approval"); + await expect(page.getByTestId("attention-banner")).toContainText( + "1 invoices require approval. Show invoices ready to approve.", + ); }); }); diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index 31c6766..97a0b2f 100644 --- a/src/dashboard/DashboardGrids.tsx +++ b/src/dashboard/DashboardGrids.tsx @@ -222,10 +222,12 @@ export function DashboardGrids() { - {filteredAttention.length} invoices require approval. + {filteredAttention.length} invoices require approval.{" "} + + Show invoices ready to approve. + ) : null} From 3179ef848b5b1e5c8e1aeeb402c3dd7d7fef6ddd Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 17:20:11 -0400 Subject: [PATCH 16/18] fix(ci): drop the unused catalog refresh index --- src/mocks/invoices.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/mocks/invoices.ts b/src/mocks/invoices.ts index 3ff6ff0..f4ef455 100644 --- a/src/mocks/invoices.ts +++ b/src/mocks/invoices.ts @@ -156,7 +156,7 @@ function invoiceDateBeforeDue(dueDate: string, index: number): string { } /** Keep catalog-derived fields in sync after mock mutations. */ -function withRefreshedCatalogFields(invoice: MockInvoice, index: number): MockInvoice { +function withRefreshedCatalogFields(invoice: MockInvoice): MockInvoice { return { ...invoice, actionLabel: actionLabelForStatus(invoice.status), @@ -190,7 +190,6 @@ function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice { relatedInvoice: null, rowCount: core.lines.length, }, - index, ); } @@ -596,7 +595,6 @@ export function updateMockInvoicePayDate(id: string, payDate: string | null): Mo }, ], }, - index, ), ); } @@ -609,7 +607,7 @@ export function updateMockInvoiceHeader(id: string, patch: MockInvoiceHeaderPatc return replaceInvoice( index, - withRefreshedCatalogFields({ ...invoiceStore[index], ...patch }, index), + withRefreshedCatalogFields({ ...invoiceStore[index], ...patch }), ); } @@ -626,7 +624,6 @@ export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): Mo ...invoiceStore[index], lines: lines.map((line) => ({ ...line })), }, - index, ), ); } @@ -659,7 +656,6 @@ export function addMockInvoiceComment( ...current, comments: [...current.comments, nextComment], }, - index, ), ); } @@ -694,7 +690,6 @@ export function transitionMockInvoiceStatus( status, activity: [...current.activity, nextActivity], }, - index, ), ); From 83529e635486fd942de13290f0a52be147bb3240 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 17:25:19 -0400 Subject: [PATCH 17/18] fix(ci): format invoice mock helpers --- src/mocks/invoices.ts | 111 +++++++++++++++++++----------------------- 1 file changed, 49 insertions(+), 62 deletions(-) diff --git a/src/mocks/invoices.ts b/src/mocks/invoices.ts index f4ef455..8f7b4b5 100644 --- a/src/mocks/invoices.ts +++ b/src/mocks/invoices.ts @@ -168,29 +168,27 @@ function withRefreshedCatalogFields(invoice: MockInvoice): MockInvoice { } 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, - }, - ); + 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 { @@ -580,22 +578,20 @@ export function updateMockInvoicePayDate(id: string, payDate: string | null): Mo 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(), - }, - ], - }, - ), + 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(), + }, + ], + }), ); } @@ -605,10 +601,7 @@ export function updateMockInvoiceHeader(id: string, patch: MockInvoiceHeaderPatc throw new Error(`Mock invoice not found: ${id}`); } - return replaceInvoice( - index, - withRefreshedCatalogFields({ ...invoiceStore[index], ...patch }), - ); + return replaceInvoice(index, withRefreshedCatalogFields({ ...invoiceStore[index], ...patch })); } export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): MockInvoice { @@ -619,12 +612,10 @@ export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): Mo return replaceInvoice( index, - withRefreshedCatalogFields( - { - ...invoiceStore[index], - lines: lines.map((line) => ({ ...line })), - }, - ), + withRefreshedCatalogFields({ + ...invoiceStore[index], + lines: lines.map((line) => ({ ...line })), + }), ); } @@ -651,12 +642,10 @@ export function addMockInvoiceComment( return replaceInvoice( index, - withRefreshedCatalogFields( - { - ...current, - comments: [...current.comments, nextComment], - }, - ), + withRefreshedCatalogFields({ + ...current, + comments: [...current.comments, nextComment], + }), ); } @@ -684,13 +673,11 @@ export function transitionMockInvoiceStatus( const updated = replaceInvoice( index, - withRefreshedCatalogFields( - { - ...current, - status, - activity: [...current.activity, nextActivity], - }, - ), + withRefreshedCatalogFields({ + ...current, + status, + activity: [...current.activity, nextActivity], + }), ); if (status === "approved" || status === "rejected") { From 1ab723f68d3039f63432c785eade0a0e1ef8772b Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 17:43:27 -0400 Subject: [PATCH 18/18] fix(web): count filtered rows on invoice tabs --- e2e/invoice-filters.spec.ts | 1 + src/dashboard/DashboardGrids.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/e2e/invoice-filters.spec.ts b/e2e/invoice-filters.spec.ts index dedd371..2f340d3 100644 --- a/e2e/invoice-filters.spec.ts +++ b/e2e/invoice-filters.spec.ts @@ -16,6 +16,7 @@ test.describe("Invoice Processing filters (AP-43)", () => { await page.getByTestId("vendor-filter").fill("Harbor"); await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("2"); await expect(grid.locator(".MuiDataGrid-row")).toHaveCount(1); + await expect(page.getByTestId("tab-pending").locator("span").last()).toHaveText("1"); await expect(page.getByTestId("attention-banner")).toContainText( "1 invoices require approval. Show invoices ready to approve.", ); diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index 97a0b2f..9d767fa 100644 --- a/src/dashboard/DashboardGrids.tsx +++ b/src/dashboard/DashboardGrids.tsx @@ -162,7 +162,7 @@ export function DashboardGrids() { label={ } @@ -173,7 +173,7 @@ export function DashboardGrids() { label={ } @@ -184,7 +184,7 @@ export function DashboardGrids() { label={ } @@ -195,7 +195,7 @@ export function DashboardGrids() { label={ } @@ -206,7 +206,7 @@ export function DashboardGrids() { label={