From e507a6d48b9ec5d23d08262d46b26b386d305e95 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:28:35 -0400 Subject: [PATCH 1/4] feat(web): fix shell chrome, logo home, and live nav badges (#14) 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 3ac40de291467f549ccc462e09d26eb179547ccb Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:41:13 -0400 Subject: [PATCH 2/4] feat(web): add invoice grid lines and bold headers (AP-44) (#15) * feat(web): fix shell chrome, logo home, and live nav badges Refs: AP-42 * feat(web): add invoice grid lines and bold headers Refs: AP-44 * feat(web): support auto row height for multi-line invoice cells Refs: AP-44 --- src/components/MockDataGrid.tsx | 35 ++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/components/MockDataGrid.tsx b/src/components/MockDataGrid.tsx index b2d4c15..9a1af67 100644 --- a/src/components/MockDataGrid.tsx +++ b/src/components/MockDataGrid.tsx @@ -6,6 +6,8 @@ type MockDataGridProps = DataGridProps & { export function MockDataGrid({ "data-testid": testId, + sx, + getRowHeight, ...props }: MockDataGridProps) { return ( @@ -19,11 +21,34 @@ export function MockDataGrid({ pagination: { paginationModel: { pageSize: 10, page: 0 } }, }} getRowId={(row) => row.id as string} - sx={{ - border: 1, - borderColor: "divider", - backgroundColor: "background.paper", - }} + getRowHeight={getRowHeight ?? (() => "auto")} + sx={[ + { + border: 1, + borderColor: "divider", + backgroundColor: "background.paper", + "& .MuiDataGrid-columnHeaderTitle": { + fontWeight: 700, + }, + "& .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", + }, + }, + ...(Array.isArray(sx) ? sx : sx ? [sx] : []), + ]} {...props} /> From 630f40baaeab94241680784bea3c6adeef0791d6 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 16:35:40 -0400 Subject: [PATCH 3/4] 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 dd95dab697515d605f6ad964f380e7d72b6a0f68 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 16:50:19 -0400 Subject: [PATCH 4/4] 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 }}