mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 03:23:16 +00:00
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:
parent
fd903c6367
commit
5c82936e50
18 changed files with 1412 additions and 22 deletions
52
e2e/invoice-detail.spec.ts
Normal file
52
e2e/invoice-detail.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
public/fixtures/sample-invoice.pdf
Normal file
21
public/fixtures/sample-invoice.pdf
Normal 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
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export const invoiceQueryKeys = {
|
||||
all: ["invoices"] as const,
|
||||
detail: (id: string) => [...invoiceQueryKeys.all, id] as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
72
src/mocks/session.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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
508
src/pages/invoices/[id].tsx
Normal 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 & 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} />;
|
||||
}
|
||||
|
|
@ -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 />
|
||||
{children}
|
||||
{import.meta.env.DEV ? <ReactQueryDevtools initialIsOpen={false} /> : null}
|
||||
<MockSessionProvider>
|
||||
{children}
|
||||
{import.meta.env.DEV ? <ReactQueryDevtools initialIsOpen={false} /> : null}
|
||||
</MockSessionProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
|
|
|||
19
src/providers/MockSessionProvider.tsx
Normal file
19
src/providers/MockSessionProvider.tsx
Normal 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>;
|
||||
}
|
||||
9
src/providers/mock-session-context.ts
Normal file
9
src/providers/mock-session-context.ts
Normal 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);
|
||||
10
src/providers/use-mock-session.ts
Normal file
10
src/providers/use-mock-session.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue