From 5c82936e50a2ab208254994b46afd7bd3c0cfe4f Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:22:22 -0400 Subject: [PATCH] feat(frontend): add invoice detail 3-pane shell (AP-6) (#9) Prove invoice coding and collaboration layout on mocks with a fixture PDF viewer, editable header/lines, Comments/Activity feeds, and role-aware action stubs via mock session switching. --- e2e/invoice-detail.spec.ts | 52 +++ public/fixtures/sample-invoice.pdf | 21 ++ src/dashboard/DashboardGrids.tsx | 29 +- src/dashboard/columns.tsx | 43 ++- src/domain/invoices/query-keys.ts | 1 + src/domain/invoices/use-invoices.ts | 108 +++++- src/layout/AppShell.tsx | 28 +- src/mocks/approvals.ts | 17 + src/mocks/index.test.ts | 72 ++++ src/mocks/index.ts | 17 + src/mocks/invoices.ts | 406 +++++++++++++++++++- src/mocks/session.ts | 72 ++++ src/pages/approvals/index.tsx | 15 +- src/pages/invoices/[id].tsx | 508 ++++++++++++++++++++++++++ src/providers/AppProviders.tsx | 7 +- src/providers/MockSessionProvider.tsx | 19 + src/providers/mock-session-context.ts | 9 + src/providers/use-mock-session.ts | 10 + 18 files changed, 1412 insertions(+), 22 deletions(-) create mode 100644 e2e/invoice-detail.spec.ts create mode 100644 public/fixtures/sample-invoice.pdf create mode 100644 src/mocks/session.ts create mode 100644 src/pages/invoices/[id].tsx create mode 100644 src/providers/MockSessionProvider.tsx create mode 100644 src/providers/mock-session-context.ts create mode 100644 src/providers/use-mock-session.ts diff --git a/e2e/invoice-detail.spec.ts b/e2e/invoice-detail.spec.ts new file mode 100644 index 0000000..165b09e --- /dev/null +++ b/e2e/invoice-detail.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test"; + +test.describe("invoice detail 3-pane shell", () => { + test("dashboard row opens invoice detail with document, coding, and feeds", async ({ page }) => { + await page.goto("/"); + + await page.getByTestId("grid-pending").locator(".MuiDataGrid-row").first().click(); + await expect(page).toHaveURL(/\/invoices\/inv-/); + await expect(page.getByTestId("invoice-detail-title")).toBeVisible(); + await expect(page.getByTestId("invoice-document-viewer")).toBeVisible(); + await expect(page.getByTestId("invoice-coding-pane")).toBeVisible(); + await expect(page.getByTestId("invoice-feed-pane")).toBeVisible(); + + await expect(page.getByTestId("invoice-action-submit")).toBeVisible(); + await expect(page.getByTestId("invoice-action-approve")).toHaveCount(0); + + const memo = page.getByTestId("invoice-header-memo").locator("textarea").first(); + await memo.fill("Updated from e2e"); + await page.getByTestId("invoice-save-coding").click(); + await expect(page.getByTestId("invoice-detail-snackbar")).toContainText("Coding saved"); + + await expect(page.getByTestId("invoice-comments-feed")).toBeVisible(); + await page.getByTestId("invoice-tab-activity").click(); + await expect(page.getByTestId("invoice-activity-feed")).toBeVisible(); + }); + + test("mock user switcher changes role-aware action bar", async ({ page }) => { + await page.goto("/invoices/inv-2"); + + await expect(page.getByTestId("invoice-action-submit")).toBeVisible(); + + await page.getByTestId("mock-user-switcher").click(); + await page.getByRole("option", { name: /Jordan Lee \(approver\)/ }).click(); + + await expect(page.getByTestId("invoice-action-approve")).toBeVisible(); + await expect(page.getByTestId("invoice-action-reject")).toBeVisible(); + await expect(page.getByTestId("invoice-action-submit")).toHaveCount(0); + }); + + test("approvals inbox row opens linked invoice detail", async ({ page }) => { + await page.goto("/approvals"); + await page.getByTestId("approvals-inbox-grid").locator(".MuiDataGrid-row").first().click(); + await expect(page).toHaveURL(/\/invoices\/inv-/); + await expect(page.getByTestId("invoice-document-viewer")).toBeVisible(); + }); + + test("unknown invoice id shows not-found warning", async ({ page }) => { + await page.goto("/invoices/missing-invoice"); + await expect(page.getByTestId("invoice-not-found")).toBeVisible(); + await expect(page.getByTestId("invoice-document-viewer")).toHaveCount(0); + }); +}); diff --git a/public/fixtures/sample-invoice.pdf b/public/fixtures/sample-invoice.pdf new file mode 100644 index 0000000..3a38a43 --- /dev/null +++ b/public/fixtures/sample-invoice.pdf @@ -0,0 +1,21 @@ +%PDF-1.4 +1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj +2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj +3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources<< /Font<< /F1 5 0 R >> >> >>endobj +4 0 obj<< /Length 55 >>stream +BT /F1 24 Tf 72 720 Td (Sea Haven sample invoice) Tj ET +endstream +endobj +5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000266 00000 n +0000000371 00000 n +trailer<< /Size 6 /Root 1 0 R >> +startxref +444 +%%EOF diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index a120d5a..c7fea2d 100644 --- a/src/dashboard/DashboardGrids.tsx +++ b/src/dashboard/DashboardGrids.tsx @@ -1,12 +1,13 @@ import { useMemo, useState } from "react"; import { Alert, Box, CircularProgress, Stack, Tab, Tabs, Typography } from "@mui/material"; -import type { GridRowSelectionModel } from "@mui/x-data-grid"; +import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid"; import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton"; import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid"; import { allInvoicesColumns, pendingApprovalColumns, readyToPayColumns } from "@/dashboard/columns"; import { countSelectedRows } from "@/dashboard/selection"; import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices"; import { areMocksEnabled, type MockInvoice } from "@/mocks"; +import { useNavigate } from "@/router"; type DashboardTab = "pending" | "ready" | "all"; @@ -15,12 +16,24 @@ const emptySelection: GridRowSelectionModel = { ids: new Set(), }; +const rowClickSx = { + border: 1, + borderColor: "divider", + backgroundColor: "background.paper", + cursor: "pointer", +} as const; + export function DashboardGrids() { + const navigate = useNavigate(); const [tab, setTab] = useState("pending"); const [selection, setSelection] = useState(emptySelection); const { data: invoices = [], isLoading, isError, error } = useInvoices(); const updatePayDate = useUpdateInvoicePayDate(); + const openInvoice = (params: GridRowParams) => { + navigate("/invoices/:id", { params: { id: params.row.id } }); + }; + const pendingRows = useMemo( () => invoices.filter((invoice) => invoice.status === "pending_approval"), [invoices], @@ -84,6 +97,8 @@ export function DashboardGrids() { data-testid="grid-pending" rows={pendingRows} columns={pendingApprovalColumns} + onRowClick={openInvoice} + sx={rowClickSx} /> )} @@ -103,15 +118,23 @@ export function DashboardGrids() { onProcessRowUpdateError={(processError) => { console.error(processError); }} + sx={rowClickSx} /> - Double-click Pay Date to edit (ISO YYYY-MM-DD). Changes update mock state only. + Double-click Pay Date to edit (ISO YYYY-MM-DD). Changes update mock state only. Open + detail from the Invoice # link. )} {tab === "all" && ( - + )} diff --git a/src/dashboard/columns.tsx b/src/dashboard/columns.tsx index 2797681..730f3b5 100644 --- a/src/dashboard/columns.tsx +++ b/src/dashboard/columns.tsx @@ -1,5 +1,7 @@ -import type { GridColDef } from "@mui/x-data-grid"; +import { Link as MuiLink } from "@mui/material"; +import type { GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import type { MockInvoice } from "@/mocks"; +import { Link } from "@/router"; const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", @@ -52,8 +54,29 @@ const dateColumn = ( valueFormatter: (value: string | null | undefined) => formatIsoDate(value), }); +function invoiceNumberLink(params: GridRenderCellParams) { + return ( + event.stopPropagation()} + > + {params.value} + + ); +} + export const pendingApprovalColumns: GridColDef[] = [ - { field: "invoiceNumbers", headerName: "Invoice #", flex: 1, minWidth: 120 }, + { + field: "invoiceNumbers", + headerName: "Invoice #", + flex: 1, + minWidth: 120, + renderCell: invoiceNumberLink, + }, { field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 }, dateColumn("dueDate", "Due Date"), dateColumn("sendPaymentOn", "Send Payment On"), @@ -66,12 +89,24 @@ export const readyToPayColumns: GridColDef[] = [ dateColumn("dueDate", "Due Date"), dateColumn("payDate", "Pay Date", true), { field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 }, - { field: "invoiceNumber", headerName: "Invoice #", flex: 1, minWidth: 120 }, + { + field: "invoiceNumber", + headerName: "Invoice #", + flex: 1, + minWidth: 120, + renderCell: invoiceNumberLink, + }, moneyColumn("amountDue", "Amount Due"), ]; export const allInvoicesColumns: GridColDef[] = [ - { field: "invoiceNumber", headerName: "Invoice #", flex: 1, minWidth: 120 }, + { + field: "invoiceNumber", + headerName: "Invoice #", + flex: 1, + minWidth: 120, + renderCell: invoiceNumberLink, + }, { field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 }, { field: "status", headerName: "Status", flex: 1, minWidth: 140 }, dateColumn("dueDate", "Due Date"), diff --git a/src/domain/invoices/query-keys.ts b/src/domain/invoices/query-keys.ts index 7311c5d..1707c1b 100644 --- a/src/domain/invoices/query-keys.ts +++ b/src/domain/invoices/query-keys.ts @@ -1,3 +1,4 @@ export const invoiceQueryKeys = { all: ["invoices"] as const, + detail: (id: string) => [...invoiceQueryKeys.all, id] as const, }; diff --git a/src/domain/invoices/use-invoices.ts b/src/domain/invoices/use-invoices.ts index b77d202..e5ad475 100644 --- a/src/domain/invoices/use-invoices.ts +++ b/src/domain/invoices/use-invoices.ts @@ -1,7 +1,31 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { getMockInvoices, updateMockInvoicePayDate } from "@/mocks"; +import { + addMockInvoiceComment, + getMockInvoice, + getMockInvoices, + transitionMockInvoiceStatus, + updateMockInvoiceHeader, + updateMockInvoiceLines, + updateMockInvoicePayDate, + type MockInvoiceActivity, + type MockInvoiceComment, + type MockInvoiceHeaderPatch, + type MockInvoiceLine, + type MockInvoiceStatus, +} from "@/mocks"; +import { approvalQueryKeys } from "@/domain/approvals/query-keys"; import { invoiceQueryKeys } from "@/domain/invoices/query-keys"; +async function invalidateInvoiceQueries( + queryClient: ReturnType, + id?: string, +) { + await queryClient.invalidateQueries({ queryKey: invoiceQueryKeys.all }); + if (id) { + await queryClient.invalidateQueries({ queryKey: invoiceQueryKeys.detail(id) }); + } +} + export function useInvoices() { return useQuery({ queryKey: invoiceQueryKeys.all, @@ -9,14 +33,92 @@ export function useInvoices() { }); } +export function useInvoice(id: string | undefined) { + return useQuery({ + queryKey: invoiceQueryKeys.detail(id ?? ""), + queryFn: () => { + if (!id) { + return null; + } + return getMockInvoice(id) ?? null; + }, + enabled: Boolean(id), + }); +} + export function useUpdateInvoicePayDate() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, payDate }: { id: string; payDate: string | null }) => Promise.resolve(updateMockInvoicePayDate(id, payDate)), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: invoiceQueryKeys.all }); + onSuccess: async (_data, variables) => { + await invalidateInvoiceQueries(queryClient, variables.id); + }, + }); +} + +export function useUpdateInvoiceCoding() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + id, + header, + lines, + }: { + id: string; + header: MockInvoiceHeaderPatch; + lines: MockInvoiceLine[]; + }) => { + updateMockInvoiceHeader(id, header); + return Promise.resolve(updateMockInvoiceLines(id, lines)); + }, + onSuccess: async (_data, variables) => { + await invalidateInvoiceQueries(queryClient, variables.id); + }, + }); +} + +export function useAddInvoiceComment() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + id, + comment, + }: { + id: string; + comment: Omit & { + id?: string; + createdAt?: string; + }; + }) => Promise.resolve(addMockInvoiceComment(id, comment)), + onSuccess: async (_data, variables) => { + await invalidateInvoiceQueries(queryClient, variables.id); + }, + }); +} + +export function useTransitionInvoiceStatus() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + id, + status, + activity, + }: { + id: string; + status: MockInvoiceStatus; + activity: Omit & { + id?: string; + createdAt?: string; + }; + }) => Promise.resolve(transitionMockInvoiceStatus(id, status, activity)), + onSuccess: async (_data, variables) => { + await invalidateInvoiceQueries(queryClient, variables.id); + await queryClient.invalidateQueries({ queryKey: approvalQueryKeys.all }); }, }); } diff --git a/src/layout/AppShell.tsx b/src/layout/AppShell.tsx index 6f96bb2..4123e79 100644 --- a/src/layout/AppShell.tsx +++ b/src/layout/AppShell.tsx @@ -2,15 +2,21 @@ import { AppBar, Box, Drawer, + FormControl, + InputLabel, List, ListItemButton, ListItemText, + MenuItem, + Select, Toolbar, Typography, } from "@mui/material"; import type { ReactNode } from "react"; import { useLocation } from "react-router"; import { useNavigate, type Path } from "@/router"; +import { getMockUsers } from "@/mocks"; +import { useMockSession } from "@/providers/use-mock-session"; const DRAWER_WIDTH = 240; @@ -33,6 +39,8 @@ type AppShellProps = { export function AppShell({ children }: AppShellProps) { const location = useLocation(); const navigate = useNavigate(); + const { user, setCurrentUserId } = useMockSession(); + const users = getMockUsers(); return ( @@ -47,10 +55,26 @@ export function AppShell({ children }: AppShellProps) { color: "text.primary", }} > - - + + Sea Haven AP + + Mock user + + diff --git a/src/mocks/approvals.ts b/src/mocks/approvals.ts index 6a114b9..2040ca7 100644 --- a/src/mocks/approvals.ts +++ b/src/mocks/approvals.ts @@ -69,6 +69,23 @@ export function updateMockApprovalStatus( return { ...updated }; } +/** Keep inbox rows aligned when invoice detail stubs transition status. */ +export function syncMockApprovalsForInvoice( + invoiceId: string, + status: MockApprovalStatus, +): MockApprovalItem[] { + const updated: MockApprovalItem[] = []; + approvalStore = approvalStore.map((item) => { + if (item.invoiceId !== invoiceId) { + return item; + } + const next = { ...item, status }; + updated.push({ ...next }); + return next; + }); + return updated; +} + export function resetMockApprovals(): void { approvalStore = seedApprovals.map((item) => ({ ...item })); } diff --git a/src/mocks/index.test.ts b/src/mocks/index.test.ts index 4c5ed17..f3e8692 100644 --- a/src/mocks/index.test.ts +++ b/src/mocks/index.test.ts @@ -1,12 +1,15 @@ import { afterEach, describe, expect, it } from "vitest"; import { + addMockInvoiceComment, areMocksEnabled, getMockApprovalPolicies, getMockApprovals, getMockDepartments, getMockGlAccounts, + getMockInvoice, getMockInvoices, getMockPendingApprovals, + getMockSessionUser, getMockUsers, getMockVendors, resetMockApprovalPolicies, @@ -14,9 +17,14 @@ import { resetMockDepartments, resetMockGlAccounts, resetMockInvoices, + resetMockSession, resetMockUsers, resetMockVendors, + setMockSessionUserId, + transitionMockInvoiceStatus, updateMockApprovalStatus, + updateMockInvoiceHeader, + updateMockInvoiceLines, updateMockInvoicePayDate, upsertMockDepartment, upsertMockGlAccount, @@ -34,6 +42,7 @@ describe("mocks module", () => { resetMockUsers(); resetMockApprovalPolicies(); resetMockApprovals(); + resetMockSession(); }); it("defaults to mocks enabled", () => { @@ -72,6 +81,69 @@ describe("mocks module", () => { ); }); + it("returns invoice detail with lines, comments, and activity", () => { + const invoice = getMockInvoice("inv-2"); + expect(invoice).toBeDefined(); + expect(invoice?.documentUrl).toBe("/fixtures/sample-invoice.pdf"); + expect(invoice?.lines.length).toBeGreaterThan(0); + expect(invoice?.comments.length).toBeGreaterThan(0); + expect(invoice?.activity.length).toBeGreaterThan(0); + }); + + it("updates header and lines coding against mocks", () => { + const updated = updateMockInvoiceHeader("inv-2", { memo: "Updated memo", amountDue: 4700 }); + expect(updated.memo).toBe("Updated memo"); + expect(updated.amountDue).toBe(4700); + + const withLines = updateMockInvoiceLines("inv-2", [ + { + id: "inv-2-line-1", + description: "Updated line", + amount: 4700, + glAccountId: "gl-2", + departmentId: "dept-1", + }, + ]); + expect(withLines.lines).toHaveLength(1); + expect(withLines.lines[0].description).toBe("Updated line"); + expect(getMockInvoice("inv-2")?.lines[0].description).toBe("Updated line"); + }); + + it("adds comments and transitions status with activity", () => { + const withComment = addMockInvoiceComment("inv-2", { + authorUserId: "user-1", + authorName: "Alex Rivera", + body: "Need another look", + }); + expect(withComment.comments.some((comment) => comment.body === "Need another look")).toBe(true); + + const approved = transitionMockInvoiceStatus("inv-2", "approved", { + actorUserId: "user-2", + actorName: "Jordan Lee", + message: "Approved", + }); + expect(approved.status).toBe("approved"); + expect(approved.activity.some((entry) => entry.message === "Approved")).toBe(true); + expect(getMockPendingApprovals().some((item) => item.invoiceId === "inv-2")).toBe(false); + expect(getMockApprovals().find((item) => item.invoiceId === "inv-2")?.status).toBe("approved"); + }); + + it("returns undefined for unknown invoice ids", () => { + expect(getMockInvoice("missing-invoice")).toBeUndefined(); + }); + + it("switches mock session user", () => { + expect(getMockSessionUser().id).toBe("user-1"); + const next = setMockSessionUserId("user-2"); + expect(next.role).toBe("approver"); + expect(getMockSessionUser().id).toBe("user-2"); + }); + + it("includes inv-8 referenced by approvals", () => { + expect(getMockInvoice("inv-8")).toBeDefined(); + expect(getMockApprovals().some((item) => item.invoiceId === "inv-8")).toBe(true); + }); + it("seeds settings entities and supports upsert", () => { expect(getMockVendors().length).toBeGreaterThanOrEqual(4); expect(getMockGlAccounts().length).toBeGreaterThanOrEqual(3); diff --git a/src/mocks/index.ts b/src/mocks/index.ts index 117d454..fa79186 100644 --- a/src/mocks/index.ts +++ b/src/mocks/index.ts @@ -1,11 +1,27 @@ export { areMocksEnabled } from "@/mocks/env"; export { + addMockInvoiceComment, + getMockInvoice, getMockInvoices, resetMockInvoices, + transitionMockInvoiceStatus, + updateMockInvoiceHeader, + updateMockInvoiceLines, updateMockInvoicePayDate, type MockInvoice, + type MockInvoiceActivity, + type MockInvoiceComment, + type MockInvoiceHeaderPatch, + type MockInvoiceLine, type MockInvoiceStatus, } from "@/mocks/invoices"; +export { + DEFAULT_MOCK_USER_ID, + getMockSessionUser, + getMockSessionUserId, + resetMockSession, + setMockSessionUserId, +} from "@/mocks/session"; export { getMockVendor, getMockVendors, @@ -48,6 +64,7 @@ export { getMockApprovals, getMockPendingApprovals, resetMockApprovals, + syncMockApprovalsForInvoice, updateMockApprovalStatus, type MockApprovalItem, type MockApprovalStatus, diff --git a/src/mocks/invoices.ts b/src/mocks/invoices.ts index adaac0b..c324ad7 100644 --- a/src/mocks/invoices.ts +++ b/src/mocks/invoices.ts @@ -1,6 +1,31 @@ +import { syncMockApprovalsForInvoice } from "@/mocks/approvals"; import { areMocksEnabled } from "@/mocks/env"; -export type MockInvoiceStatus = "pending_approval" | "approved" | "scheduled" | "paid"; +export type MockInvoiceStatus = "pending_approval" | "approved" | "scheduled" | "paid" | "rejected"; + +export type MockInvoiceLine = { + id: string; + description: string; + amount: number; + glAccountId: string; + departmentId: string; +}; + +export type MockInvoiceComment = { + id: string; + authorUserId: string; + authorName: string; + body: string; + createdAt: string; +}; + +export type MockInvoiceActivity = { + id: string; + actorUserId: string; + actorName: string; + message: string; + createdAt: string; +}; export type MockInvoice = { id: string; @@ -15,8 +40,38 @@ export type MockInvoice = { sendPaymentOn: string | null; status: MockInvoiceStatus; paymentMethod: "check" | "ach"; + documentUrl: string; + memo: string; + lines: MockInvoiceLine[]; + comments: MockInvoiceComment[]; + activity: MockInvoiceActivity[]; }; +export type MockInvoiceHeaderPatch = Partial< + Pick< + MockInvoice, + | "vendorName" + | "amountDue" + | "amount" + | "dueDate" + | "payDate" + | "sendPaymentOn" + | "paymentMethod" + | "memo" + > +>; + +const SAMPLE_DOCUMENT_URL = "/fixtures/sample-invoice.pdf"; + +function cloneInvoice(invoice: MockInvoice): MockInvoice { + return { + ...invoice, + lines: invoice.lines.map((line) => ({ ...line })), + comments: invoice.comments.map((comment) => ({ ...comment })), + activity: invoice.activity.map((entry) => ({ ...entry })), + }; +} + const seedInvoices: MockInvoice[] = [ { id: "inv-1", @@ -31,6 +86,49 @@ const seedInvoices: MockInvoice[] = [ sendPaymentOn: "2026-08-15", status: "approved", paymentMethod: "check", + documentUrl: SAMPLE_DOCUMENT_URL, + memo: "Office supplies restock", + lines: [ + { + id: "inv-1-line-1", + description: "Printer paper (case)", + amount: 450.5, + glAccountId: "gl-1", + departmentId: "dept-4", + }, + { + id: "inv-1-line-2", + description: "Toner cartridges", + amount: 800, + glAccountId: "gl-1", + departmentId: "dept-4", + }, + ], + comments: [ + { + id: "inv-1-c1", + authorUserId: "user-1", + authorName: "Alex Rivera", + body: "Coded to Administration office supplies.", + createdAt: "2026-08-05T14:20:00.000Z", + }, + ], + activity: [ + { + id: "inv-1-a1", + actorUserId: "user-1", + actorName: "Alex Rivera", + message: "Submitted for approval", + createdAt: "2026-08-05T15:00:00.000Z", + }, + { + id: "inv-1-a2", + actorUserId: "user-2", + actorName: "Jordan Lee", + message: "Approved", + createdAt: "2026-08-06T10:12:00.000Z", + }, + ], }, { id: "inv-2", @@ -45,6 +143,42 @@ const seedInvoices: MockInvoice[] = [ sendPaymentOn: "2026-08-20", status: "pending_approval", paymentMethod: "ach", + documentUrl: SAMPLE_DOCUMENT_URL, + memo: "Dock repair materials", + lines: [ + { + id: "inv-2-line-1", + description: "Dock board replacement", + amount: 3200, + glAccountId: "gl-2", + departmentId: "dept-1", + }, + { + id: "inv-2-line-2", + description: "Labor", + amount: 1600, + glAccountId: "gl-4", + departmentId: "dept-1", + }, + ], + comments: [ + { + id: "inv-2-c1", + authorUserId: "user-1", + authorName: "Alex Rivera", + body: "Please confirm GL split before approve.", + createdAt: "2026-08-08T11:05:00.000Z", + }, + ], + activity: [ + { + id: "inv-2-a1", + actorUserId: "user-1", + actorName: "Alex Rivera", + message: "Submitted for approval", + createdAt: "2026-08-08T11:10:00.000Z", + }, + ], }, { id: "inv-3", @@ -59,6 +193,34 @@ const seedInvoices: MockInvoice[] = [ sendPaymentOn: "2026-08-10", status: "approved", paymentMethod: "check", + documentUrl: SAMPLE_DOCUMENT_URL, + memo: "July utilities", + lines: [ + { + id: "inv-3-line-1", + description: "Electric service", + amount: 312.75, + glAccountId: "gl-3", + departmentId: "dept-1", + }, + ], + comments: [], + activity: [ + { + id: "inv-3-a1", + actorUserId: "user-1", + actorName: "Alex Rivera", + message: "Submitted for approval", + createdAt: "2026-08-01T09:00:00.000Z", + }, + { + id: "inv-3-a2", + actorUserId: "user-3", + actorName: "Sam Patel", + message: "Approved", + createdAt: "2026-08-02T16:40:00.000Z", + }, + ], }, { id: "inv-4", @@ -73,6 +235,27 @@ const seedInvoices: MockInvoice[] = [ sendPaymentOn: "2026-08-27", status: "pending_approval", paymentMethod: "ach", + documentUrl: SAMPLE_DOCUMENT_URL, + memo: "Desk chairs", + lines: [ + { + id: "inv-4-line-1", + description: "Ergonomic chairs (3)", + amount: 900, + glAccountId: "gl-1", + departmentId: "dept-3", + }, + ], + comments: [], + activity: [ + { + id: "inv-4-a1", + actorUserId: "user-1", + actorName: "Alex Rivera", + message: "Submitted for approval", + createdAt: "2026-08-09T13:22:00.000Z", + }, + ], }, { id: "inv-5", @@ -87,6 +270,34 @@ const seedInvoices: MockInvoice[] = [ sendPaymentOn: "2026-07-25", status: "scheduled", paymentMethod: "check", + documentUrl: SAMPLE_DOCUMENT_URL, + memo: "Quarterly stock", + lines: [ + { + id: "inv-5-line-1", + description: "Bulk supplies", + amount: 2100, + glAccountId: "gl-1", + departmentId: "dept-2", + }, + ], + comments: [], + activity: [ + { + id: "inv-5-a1", + actorUserId: "user-2", + actorName: "Jordan Lee", + message: "Approved", + createdAt: "2026-07-20T12:00:00.000Z", + }, + { + id: "inv-5-a2", + actorUserId: "user-1", + actorName: "Alex Rivera", + message: "Scheduled for payment", + createdAt: "2026-07-22T09:30:00.000Z", + }, + ], }, { id: "inv-6", @@ -101,6 +312,27 @@ const seedInvoices: MockInvoice[] = [ sendPaymentOn: "2026-07-10", status: "paid", paymentMethod: "ach", + documentUrl: SAMPLE_DOCUMENT_URL, + memo: "Emergency pump repair", + lines: [ + { + id: "inv-6-line-1", + description: "Pump repair", + amount: 1550, + glAccountId: "gl-2", + departmentId: "dept-1", + }, + ], + comments: [], + activity: [ + { + id: "inv-6-a1", + actorUserId: "user-1", + actorName: "Alex Rivera", + message: "Marked paid", + createdAt: "2026-07-11T08:00:00.000Z", + }, + ], }, { id: "inv-7", @@ -115,17 +347,94 @@ const seedInvoices: MockInvoice[] = [ sendPaymentOn: "2026-08-22", status: "approved", paymentMethod: "ach", + documentUrl: SAMPLE_DOCUMENT_URL, + memo: "August utilities", + lines: [ + { + id: "inv-7-line-1", + description: "Water and sewer", + amount: 645.1, + glAccountId: "gl-3", + departmentId: "dept-1", + }, + ], + comments: [], + activity: [ + { + id: "inv-7-a1", + actorUserId: "user-3", + actorName: "Sam Patel", + message: "Approved", + createdAt: "2026-08-18T17:05:00.000Z", + }, + ], + }, + { + id: "inv-8", + invoiceNumber: "INV-1008", + invoiceNumbers: "INV-1008", + vendorId: "vendor-1", + vendorName: "Acme Supplies Co", + amountDue: 275.5, + amount: 275.5, + dueDate: "2026-08-30", + payDate: null, + sendPaymentOn: "2026-08-28", + status: "pending_approval", + paymentMethod: "check", + documentUrl: SAMPLE_DOCUMENT_URL, + memo: "Misc small order", + lines: [ + { + id: "inv-8-line-1", + description: "Labels and binders", + amount: 275.5, + glAccountId: "gl-1", + departmentId: "dept-2", + }, + ], + comments: [ + { + id: "inv-8-c1", + authorUserId: "user-1", + authorName: "Alex Rivera", + body: "Under threshold policy.", + createdAt: "2026-08-07T10:00:00.000Z", + }, + ], + activity: [ + { + id: "inv-8-a1", + actorUserId: "user-1", + actorName: "Alex Rivera", + message: "Submitted for approval", + createdAt: "2026-08-07T10:05:00.000Z", + }, + ], }, ]; /** Mutable in-memory invoice store for mock POC interactions. */ -let invoiceStore: MockInvoice[] = seedInvoices.map((invoice) => ({ ...invoice })); +let invoiceStore: MockInvoice[] = seedInvoices.map((invoice) => cloneInvoice(invoice)); + +function replaceInvoice(index: number, next: MockInvoice): MockInvoice { + invoiceStore = [...invoiceStore.slice(0, index), next, ...invoiceStore.slice(index + 1)]; + return cloneInvoice(next); +} export function getMockInvoices(): MockInvoice[] { if (!areMocksEnabled()) { return []; } - return invoiceStore.map((invoice) => ({ ...invoice })); + return invoiceStore.map((invoice) => cloneInvoice(invoice)); +} + +export function getMockInvoice(id: string): MockInvoice | undefined { + if (!areMocksEnabled()) { + return undefined; + } + const found = invoiceStore.find((invoice) => invoice.id === id); + return found ? cloneInvoice(found) : undefined; } export function updateMockInvoicePayDate(id: string, payDate: string | null): MockInvoice { @@ -134,12 +443,95 @@ export function updateMockInvoicePayDate(id: string, payDate: string | null): Mo throw new Error(`Mock invoice not found: ${id}`); } - const updated: MockInvoice = { ...invoiceStore[index], payDate }; - invoiceStore = [...invoiceStore.slice(0, index), updated, ...invoiceStore.slice(index + 1)]; - return { ...updated }; + return replaceInvoice(index, { ...invoiceStore[index], payDate }); +} + +export function updateMockInvoiceHeader(id: string, patch: MockInvoiceHeaderPatch): MockInvoice { + const index = invoiceStore.findIndex((invoice) => invoice.id === id); + if (index === -1) { + throw new Error(`Mock invoice not found: ${id}`); + } + + return replaceInvoice(index, { ...invoiceStore[index], ...patch }); +} + +export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): MockInvoice { + const index = invoiceStore.findIndex((invoice) => invoice.id === id); + if (index === -1) { + throw new Error(`Mock invoice not found: ${id}`); + } + + return replaceInvoice(index, { + ...invoiceStore[index], + lines: lines.map((line) => ({ ...line })), + }); +} + +export function addMockInvoiceComment( + id: string, + comment: Omit & { + id?: string; + createdAt?: string; + }, +): MockInvoice { + const index = invoiceStore.findIndex((invoice) => invoice.id === id); + if (index === -1) { + throw new Error(`Mock invoice not found: ${id}`); + } + + const current = invoiceStore[index]; + const nextComment: MockInvoiceComment = { + id: comment.id ?? `comment-${Date.now()}`, + authorUserId: comment.authorUserId, + authorName: comment.authorName, + body: comment.body, + createdAt: comment.createdAt ?? new Date().toISOString(), + }; + + return replaceInvoice(index, { + ...current, + comments: [...current.comments, nextComment], + }); +} + +export function transitionMockInvoiceStatus( + id: string, + status: MockInvoiceStatus, + activity: Omit & { + id?: string; + createdAt?: string; + }, +): MockInvoice { + const index = invoiceStore.findIndex((invoice) => invoice.id === id); + if (index === -1) { + throw new Error(`Mock invoice not found: ${id}`); + } + + const current = invoiceStore[index]; + const nextActivity: MockInvoiceActivity = { + id: activity.id ?? `activity-${Date.now()}`, + actorUserId: activity.actorUserId, + actorName: activity.actorName, + message: activity.message, + createdAt: activity.createdAt ?? new Date().toISOString(), + }; + + const updated = replaceInvoice(index, { + ...current, + status, + activity: [...current.activity, nextActivity], + }); + + if (status === "approved" || status === "rejected") { + syncMockApprovalsForInvoice(id, status); + } else if (status === "pending_approval") { + syncMockApprovalsForInvoice(id, "pending"); + } + + return updated; } /** Test helper to restore seed data between unit tests. */ export function resetMockInvoices(): void { - invoiceStore = seedInvoices.map((invoice) => ({ ...invoice })); + invoiceStore = seedInvoices.map((invoice) => cloneInvoice(invoice)); } diff --git a/src/mocks/session.ts b/src/mocks/session.ts new file mode 100644 index 0000000..41ae337 --- /dev/null +++ b/src/mocks/session.ts @@ -0,0 +1,72 @@ +import { getMockUser, type MockUser } from "@/mocks/users"; + +export const DEFAULT_MOCK_USER_ID = "user-1"; +const SESSION_STORAGE_KEY = "seahaven-ap.mockUserId"; + +let currentUserId = DEFAULT_MOCK_USER_ID; + +function readStoredUserId(): string | null { + if (typeof sessionStorage === "undefined") { + return null; + } + try { + return sessionStorage.getItem(SESSION_STORAGE_KEY); + } catch { + return null; + } +} + +function writeStoredUserId(userId: string): void { + if (typeof sessionStorage === "undefined") { + return; + } + try { + sessionStorage.setItem(SESSION_STORAGE_KEY, userId); + } catch { + // Ignore quota / private-mode failures in POC. + } +} + +export function getMockSessionUserId(): string { + const stored = readStoredUserId(); + if (stored && getMockUser(stored)) { + currentUserId = stored; + return stored; + } + return currentUserId; +} + +export function getMockSessionUser(): MockUser { + const userId = getMockSessionUserId(); + const user = getMockUser(userId); + if (user) { + return user; + } + const fallback = getMockUser(DEFAULT_MOCK_USER_ID); + if (!fallback) { + throw new Error("Default mock user is missing from the seed store"); + } + return fallback; +} + +export function setMockSessionUserId(userId: string): MockUser { + const user = getMockUser(userId); + if (!user) { + throw new Error(`Mock user not found: ${userId}`); + } + currentUserId = userId; + writeStoredUserId(userId); + return { ...user }; +} + +export function resetMockSession(): void { + currentUserId = DEFAULT_MOCK_USER_ID; + if (typeof sessionStorage === "undefined") { + return; + } + try { + sessionStorage.removeItem(SESSION_STORAGE_KEY); + } catch { + // Ignore storage failures in POC. + } +} diff --git a/src/pages/approvals/index.tsx b/src/pages/approvals/index.tsx index 60c3afd..5963f42 100644 --- a/src/pages/approvals/index.tsx +++ b/src/pages/approvals/index.tsx @@ -9,10 +9,11 @@ import { Stack, Typography, } from "@mui/material"; -import type { GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import type { GridColDef, GridRenderCellParams, GridRowParams } from "@mui/x-data-grid"; import { MockDataGrid } from "@/components/MockDataGrid"; import { usePendingApprovals, useUpdateApprovalStatus } from "@/domain/approvals/use-approvals"; import type { MockApprovalItem } from "@/mocks"; +import { useNavigate } from "@/router"; const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", @@ -34,10 +35,15 @@ function formatIsoDate(value: string): string { } export default function ApprovalsInboxPage() { + const navigate = useNavigate(); const { data = [], isLoading, isError, error } = usePendingApprovals(); const updateStatus = useUpdateApprovalStatus(); const [snackbar, setSnackbar] = useState(null); + const openInvoice = (params: GridRowParams) => { + navigate("/invoices/:id", { params: { id: params.row.invoiceId } }); + }; + const columns: GridColDef[] = [ { field: "invoiceNumber", headerName: "Invoice #", flex: 1, minWidth: 120 }, { field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 }, @@ -127,6 +133,13 @@ export default function ApprovalsInboxPage() { rows={data} columns={columns} getRowHeight={() => 56} + onRowClick={openInvoice} + sx={{ + border: 1, + borderColor: "divider", + backgroundColor: "background.paper", + cursor: "pointer", + }} /> ) : null} diff --git a/src/pages/invoices/[id].tsx b/src/pages/invoices/[id].tsx new file mode 100644 index 0000000..94e6e6e --- /dev/null +++ b/src/pages/invoices/[id].tsx @@ -0,0 +1,508 @@ +import { useState } from "react"; +import { + Alert, + Box, + Button, + CircularProgress, + Divider, + FormControl, + InputLabel, + MenuItem, + Select, + Snackbar, + Stack, + Tab, + Tabs, + TextField, + Typography, +} from "@mui/material"; +import { useDepartments } from "@/domain/departments/use-departments"; +import { useGlAccounts } from "@/domain/gl-accounts/use-gl-accounts"; +import { + useAddInvoiceComment, + useInvoice, + useTransitionInvoiceStatus, + useUpdateInvoiceCoding, +} from "@/domain/invoices/use-invoices"; +import type { MockInvoice, MockInvoiceLine, MockUserRole } from "@/mocks"; +import { useMockSession } from "@/providers/use-mock-session"; +import { useParams } from "@/router"; + +type FeedTab = "comments" | "activity"; + +function roleActions(role: MockUserRole): Array<"submit" | "approve" | "reject"> { + if (role === "ap_operator") { + return ["submit"]; + } + return ["approve", "reject"]; +} + +type InvoiceDetailViewProps = { + invoice: MockInvoice; +}; + +function InvoiceDetailView({ invoice }: InvoiceDetailViewProps) { + const { user } = useMockSession(); + const { data: glAccounts = [] } = useGlAccounts(); + const { data: departments = [] } = useDepartments(); + const updateCoding = useUpdateInvoiceCoding(); + const addComment = useAddInvoiceComment(); + const transition = useTransitionInvoiceStatus(); + + const [vendorName, setVendorName] = useState(invoice.vendorName); + const [memo, setMemo] = useState(invoice.memo); + const [amount, setAmount] = useState(String(invoice.amount)); + const [amountDue, setAmountDue] = useState(String(invoice.amountDue)); + const [dueDate, setDueDate] = useState(invoice.dueDate); + const [payDate, setPayDate] = useState(invoice.payDate ?? ""); + const [sendPaymentOn, setSendPaymentOn] = useState(invoice.sendPaymentOn ?? ""); + const [paymentMethod, setPaymentMethod] = useState<"check" | "ach">(invoice.paymentMethod); + const [lines, setLines] = useState(() => + invoice.lines.map((line) => ({ ...line })), + ); + const [feedTab, setFeedTab] = useState("comments"); + const [commentBody, setCommentBody] = useState(""); + const [snackbar, setSnackbar] = useState(null); + + const actions = roleActions(user.role); + + const saveCoding = async () => { + await updateCoding.mutateAsync({ + id: invoice.id, + header: { + vendorName: vendorName.trim(), + memo: memo.trim(), + amount: Number(amount) || 0, + amountDue: Number(amountDue) || 0, + dueDate, + payDate: payDate.trim() ? payDate.trim() : null, + sendPaymentOn: sendPaymentOn.trim() ? sendPaymentOn.trim() : null, + paymentMethod, + }, + lines, + }); + setSnackbar("Coding saved (mock)"); + }; + + const runTransition = async (action: "submit" | "approve" | "reject") => { + const status = + action === "submit" ? "pending_approval" : action === "approve" ? "approved" : "rejected"; + const message = + action === "submit" + ? "Submitted for approval" + : action === "approve" + ? "Approved" + : "Rejected"; + + await transition.mutateAsync({ + id: invoice.id, + status, + activity: { + actorUserId: user.id, + actorName: user.name, + message, + }, + }); + setSnackbar(`${message} (mock)`); + }; + + const submitComment = async () => { + const body = commentBody.trim(); + if (!body) { + return; + } + await addComment.mutateAsync({ + id: invoice.id, + comment: { + authorUserId: user.id, + authorName: user.name, + body, + }, + }); + setCommentBody(""); + setSnackbar("Comment added (mock)"); + }; + + const updateLine = (lineId: string, patch: Partial) => { + setLines((current) => + current.map((line) => (line.id === lineId ? { ...line, ...patch } : line)), + ); + }; + + return ( + + + + + + {invoice.invoiceNumber} + + + Status: {invoice.status.replace(/_/g, " ")} · Viewing as {user.name} ({user.role}) + + + + {actions.includes("submit") ? ( + + ) : null} + {actions.includes("approve") ? ( + + ) : null} + {actions.includes("reject") ? ( + + ) : null} + + + + + +