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.
This commit is contained in:
Adam Moussa 2026-08-10 18:22:22 -04:00 • committed by GitHub
parent fd903c6367
commit 5c82936e50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1412 additions and 22 deletions

View file

@ -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);
});
});

View file

@ -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

View file

@ -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<DashboardTab>("pending");
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
const { data: invoices = [], isLoading, isError, error } = useInvoices();
const updatePayDate = useUpdateInvoicePayDate();
const openInvoice = (params: GridRowParams<MockInvoice>) => {
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}
/>
<Typography variant="caption" color="text.secondary">
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.
</Typography>
</Stack>
)}
{tab === "all" && (
<InvoiceDataGrid data-testid="grid-all" rows={invoices} columns={allInvoicesColumns} />
<InvoiceDataGrid
data-testid="grid-all"
rows={invoices}
columns={allInvoicesColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
)}
<Typography variant="body2" data-testid="mocks-status">

View file

@ -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<MockInvoice, string>) {
return (
<MuiLink
component={Link}
to="/invoices/:id"
params={{ id: params.row.id }}
underline="hover"
data-testid={`invoice-link-${params.row.id}`}
onClick={(event) => event.stopPropagation()}
>
{params.value}
</MuiLink>
);
}
export const pendingApprovalColumns: GridColDef<MockInvoice>[] = [
{ 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<MockInvoice>[] = [
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<MockInvoice>[] = [
{ 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"),

View file

@ -1,3 +1,4 @@
export const invoiceQueryKeys = {
all: ["invoices"] as const,
detail: (id: string) => [...invoiceQueryKeys.all, id] as const,
};

View file

@ -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<typeof useQueryClient>,
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<MockInvoiceComment, "id" | "createdAt"> & {
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<MockInvoiceActivity, "id" | "createdAt"> & {
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 });
},
});
}

View file

@ -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 (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
@ -47,10 +55,26 @@ export function AppShell({ children }: AppShellProps) {
color: "text.primary",
}}
>
<Toolbar>
<Typography variant="h6" sx={{ fontFamily: "inherit", fontWeight: 600 }}>
<Toolbar sx={{ gap: 2 }}>
<Typography variant="h6" sx={{ fontFamily: "inherit", fontWeight: 600, flexGrow: 1 }}>
Sea Haven AP
</Typography>
<FormControl size="small" sx={{ minWidth: 260 }}>
<InputLabel id="mock-user-label">Mock user</InputLabel>
<Select
labelId="mock-user-label"
label="Mock user"
value={user.id}
data-testid="mock-user-switcher"
onChange={(event) => setCurrentUserId(String(event.target.value))}
>
{users.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name} ({option.role})
</MenuItem>
))}
</Select>
</FormControl>
</Toolbar>
</AppBar>

View file

@ -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 }));
}

View file

@ -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);

View file

@ -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,

View file

@ -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<MockInvoiceComment, "id" | "createdAt"> & {
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<MockInvoiceActivity, "id" | "createdAt"> & {
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));
}

72
src/mocks/session.ts Normal file
View file

@ -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.
}
}

View file

@ -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<string | null>(null);
const openInvoice = (params: GridRowParams<MockApprovalItem>) => {
navigate("/invoices/:id", { params: { id: params.row.invoiceId } });
};
const columns: GridColDef<MockApprovalItem>[] = [
{ 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}
</Stack>

508
src/pages/invoices/[id].tsx Normal file
View file

@ -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<MockInvoiceLine[]>(() =>
invoice.lines.map((line) => ({ ...line })),
);
const [feedTab, setFeedTab] = useState<FeedTab>("comments");
const [commentBody, setCommentBody] = useState("");
const [snackbar, setSnackbar] = useState<string | null>(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<MockInvoiceLine>) => {
setLines((current) =>
current.map((line) => (line.id === lineId ? { ...line, ...patch } : line)),
);
};
return (
<Box sx={{ p: { xs: 2, md: 3 }, height: "calc(100vh - 64px)", boxSizing: "border-box" }}>
<Stack spacing={2} sx={{ height: "100%" }}>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
justifyContent="space-between"
alignItems={{ xs: "flex-start", sm: "center" }}
>
<Stack spacing={0.5}>
<Typography component="h1" variant="h1" data-testid="invoice-detail-title">
{invoice.invoiceNumber}
</Typography>
<Typography color="text.secondary">
Status: {invoice.status.replace(/_/g, " ")} · Viewing as {user.name} ({user.role})
</Typography>
</Stack>
<Stack direction="row" spacing={1} data-testid="invoice-action-bar">
{actions.includes("submit") ? (
<Button
variant="contained"
data-testid="invoice-action-submit"
disabled={transition.isPending}
onClick={() => void runTransition("submit")}
>
Submit
</Button>
) : null}
{actions.includes("approve") ? (
<Button
variant="contained"
data-testid="invoice-action-approve"
disabled={transition.isPending}
onClick={() => void runTransition("approve")}
>
Approve
</Button>
) : null}
{actions.includes("reject") ? (
<Button
variant="outlined"
color="error"
data-testid="invoice-action-reject"
disabled={transition.isPending}
onClick={() => void runTransition("reject")}
>
Reject
</Button>
) : null}
</Stack>
</Stack>
<Box
data-testid="invoice-detail-shell"
sx={{
flex: 1,
minHeight: 0,
display: "grid",
gap: 2,
gridTemplateColumns: {
xs: "1fr",
md: "minmax(240px, 1.1fr) minmax(280px, 1.3fr) minmax(240px, 1fr)",
},
gridTemplateRows: { xs: "auto", md: "1fr" },
}}
>
<Box
sx={{
border: 1,
borderColor: "divider",
bgcolor: "background.paper",
borderRadius: 1,
overflow: "hidden",
minHeight: { xs: 320, md: 0 },
}}
>
<iframe
title={`Invoice document ${invoice.invoiceNumber}`}
src={invoice.documentUrl}
data-testid="invoice-document-viewer"
style={{ width: "100%", height: "100%", minHeight: 320, border: 0 }}
/>
</Box>
<Box
sx={{
border: 1,
borderColor: "divider",
bgcolor: "background.paper",
borderRadius: 1,
p: 2,
overflow: "auto",
minHeight: 0,
}}
data-testid="invoice-coding-pane"
>
<Stack spacing={2}>
<Typography variant="h6">Header &amp; lines</Typography>
<TextField
label="Vendor"
value={vendorName}
onChange={(event) => setVendorName(event.target.value)}
data-testid="invoice-header-vendor"
fullWidth
size="small"
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<TextField
label="Amount"
value={amount}
onChange={(event) => setAmount(event.target.value)}
data-testid="invoice-header-amount"
fullWidth
size="small"
/>
<TextField
label="Amount due"
value={amountDue}
onChange={(event) => setAmountDue(event.target.value)}
data-testid="invoice-header-amount-due"
fullWidth
size="small"
/>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<TextField
label="Due date"
value={dueDate}
onChange={(event) => setDueDate(event.target.value)}
data-testid="invoice-header-due-date"
fullWidth
size="small"
/>
<TextField
label="Pay date"
value={payDate}
onChange={(event) => setPayDate(event.target.value)}
data-testid="invoice-header-pay-date"
fullWidth
size="small"
/>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<TextField
label="Send payment on"
value={sendPaymentOn}
onChange={(event) => setSendPaymentOn(event.target.value)}
data-testid="invoice-header-send-payment-on"
fullWidth
size="small"
/>
<FormControl fullWidth size="small">
<InputLabel id="payment-method-label">Payment method</InputLabel>
<Select
labelId="payment-method-label"
label="Payment method"
value={paymentMethod}
data-testid="invoice-header-payment-method"
onChange={(event) => setPaymentMethod(event.target.value as "check" | "ach")}
>
<MenuItem value="check">Check</MenuItem>
<MenuItem value="ach">ACH</MenuItem>
</Select>
</FormControl>
</Stack>
<TextField
label="Memo"
value={memo}
onChange={(event) => setMemo(event.target.value)}
data-testid="invoice-header-memo"
fullWidth
size="small"
multiline
minRows={2}
/>
<Divider />
<Typography variant="subtitle1">Line coding</Typography>
{lines.map((line) => (
<Stack
key={line.id}
spacing={1}
sx={{ border: 1, borderColor: "divider", borderRadius: 1, p: 1.5 }}
data-testid={`invoice-line-${line.id}`}
>
<TextField
label="Description"
value={line.description}
onChange={(event) => updateLine(line.id, { description: event.target.value })}
fullWidth
size="small"
data-testid={`invoice-line-description-${line.id}`}
/>
<TextField
label="Amount"
value={String(line.amount)}
onChange={(event) =>
updateLine(line.id, { amount: Number(event.target.value) || 0 })
}
fullWidth
size="small"
data-testid={`invoice-line-amount-${line.id}`}
/>
<FormControl fullWidth size="small">
<InputLabel id={`gl-${line.id}`}>GL account</InputLabel>
<Select
labelId={`gl-${line.id}`}
label="GL account"
value={line.glAccountId}
data-testid={`invoice-line-gl-${line.id}`}
onChange={(event) =>
updateLine(line.id, { glAccountId: String(event.target.value) })
}
>
{glAccounts.map((account) => (
<MenuItem key={account.id} value={account.id}>
{account.code} — {account.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel id={`dept-${line.id}`}>Department</InputLabel>
<Select
labelId={`dept-${line.id}`}
label="Department"
value={line.departmentId}
data-testid={`invoice-line-dept-${line.id}`}
onChange={(event) =>
updateLine(line.id, { departmentId: String(event.target.value) })
}
>
{departments.map((department) => (
<MenuItem key={department.id} value={department.id}>
{department.code} — {department.name}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
))}
<Button
variant="contained"
onClick={() => void saveCoding()}
disabled={updateCoding.isPending}
data-testid="invoice-save-coding"
>
Save coding
</Button>
</Stack>
</Box>
<Box
sx={{
border: 1,
borderColor: "divider",
bgcolor: "background.paper",
borderRadius: 1,
p: 2,
overflow: "auto",
minHeight: 0,
display: "flex",
flexDirection: "column",
gap: 2,
}}
data-testid="invoice-feed-pane"
>
<Tabs
value={feedTab}
onChange={(_event, value: FeedTab) => setFeedTab(value)}
aria-label="Invoice collaboration tabs"
>
<Tab label="Comments" value="comments" data-testid="invoice-tab-comments" />
<Tab label="Activity" value="activity" data-testid="invoice-tab-activity" />
</Tabs>
{feedTab === "comments" ? (
<Stack spacing={2} data-testid="invoice-comments-feed">
{invoice.comments.length === 0 ? (
<Typography color="text.secondary">No comments yet.</Typography>
) : (
invoice.comments.map((comment) => (
<Box key={comment.id} data-testid={`invoice-comment-${comment.id}`}>
<Typography variant="subtitle2">{comment.authorName}</Typography>
<Typography variant="body2" color="text.secondary">
{new Date(comment.createdAt).toLocaleString()}
</Typography>
<Typography variant="body1" sx={{ mt: 0.5 }}>
{comment.body}
</Typography>
</Box>
))
)}
<TextField
label="Add comment"
value={commentBody}
onChange={(event) => setCommentBody(event.target.value)}
data-testid="invoice-comment-input"
fullWidth
size="small"
multiline
minRows={2}
/>
<Button
variant="outlined"
onClick={() => void submitComment()}
disabled={addComment.isPending || !commentBody.trim()}
data-testid="invoice-comment-submit"
>
Post comment
</Button>
</Stack>
) : (
<Stack spacing={2} data-testid="invoice-activity-feed">
{invoice.activity.length === 0 ? (
<Typography color="text.secondary">No activity yet.</Typography>
) : (
invoice.activity.map((entry) => (
<Box key={entry.id} data-testid={`invoice-activity-${entry.id}`}>
<Typography variant="subtitle2">{entry.actorName}</Typography>
<Typography variant="body2" color="text.secondary">
{new Date(entry.createdAt).toLocaleString()}
</Typography>
<Typography variant="body1" sx={{ mt: 0.5 }}>
{entry.message}
</Typography>
</Box>
))
)}
</Stack>
)}
</Box>
</Box>
</Stack>
<Snackbar
open={Boolean(snackbar)}
autoHideDuration={2500}
onClose={() => setSnackbar(null)}
message={snackbar}
data-testid="invoice-detail-snackbar"
/>
</Box>
);
}
export default function InvoiceDetailPage() {
const { id } = useParams("/invoices/:id");
const { data: invoice, isLoading, isError, error } = useInvoice(id);
if (isLoading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
<CircularProgress aria-label="Loading invoice" />
</Box>
);
}
if (isError) {
return (
<Alert severity="error" sx={{ m: 3 }}>
{error instanceof Error ? error.message : "Failed to load invoice"}
</Alert>
);
}
if (!invoice) {
return (
<Alert severity="warning" sx={{ m: 3 }} data-testid="invoice-not-found">
Invoice not found.
</Alert>
);
}
return <InvoiceDetailView key={invoice.id} invoice={invoice} />;
}

View file

@ -3,14 +3,17 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { CssBaseline, ThemeProvider } from "@mui/material";
import { queryClient } from "@/lib/query-client";
import { muiTheme } from "@/lib/mui-theme";
import { MockSessionProvider } from "@/providers/MockSessionProvider";
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={muiTheme}>
<CssBaseline />
<MockSessionProvider>
{children}
{import.meta.env.DEV ? <ReactQueryDevtools initialIsOpen={false} /> : null}
</MockSessionProvider>
</ThemeProvider>
</QueryClientProvider>
);

View file

@ -0,0 +1,19 @@
import { useMemo, useState, type ReactNode } from "react";
import { getMockSessionUser, setMockSessionUserId, type MockUser } from "@/mocks";
import { MockSessionContext, type MockSessionContextValue } from "@/providers/mock-session-context";
export function MockSessionProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<MockUser>(() => getMockSessionUser());
const value = useMemo<MockSessionContextValue>(
() => ({
user,
setCurrentUserId: (nextUserId: string) => {
setUser(setMockSessionUserId(nextUserId));
},
}),
[user],
);
return <MockSessionContext.Provider value={value}>{children}</MockSessionContext.Provider>;
}

View file

@ -0,0 +1,9 @@
import { createContext } from "react";
import type { MockUser } from "@/mocks";
export type MockSessionContextValue = {
user: MockUser;
setCurrentUserId: (userId: string) => void;
};
export const MockSessionContext = createContext<MockSessionContextValue | null>(null);

View file

@ -0,0 +1,10 @@
import { useContext } from "react";
import { MockSessionContext, type MockSessionContextValue } from "@/providers/mock-session-context";
export function useMockSession(): MockSessionContextValue {
const context = useContext(MockSessionContext);
if (!context) {
throw new Error("useMockSession must be used within MockSessionProvider");
}
return context;
}