mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 05:43:18 +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 { useMemo, useState } from "react";
|
||||||
import { Alert, Box, CircularProgress, Stack, Tab, Tabs, Typography } from "@mui/material";
|
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 { CreatePayRunButton } from "@/dashboard/CreatePayRunButton";
|
||||||
import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid";
|
import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid";
|
||||||
import { allInvoicesColumns, pendingApprovalColumns, readyToPayColumns } from "@/dashboard/columns";
|
import { allInvoicesColumns, pendingApprovalColumns, readyToPayColumns } from "@/dashboard/columns";
|
||||||
import { countSelectedRows } from "@/dashboard/selection";
|
import { countSelectedRows } from "@/dashboard/selection";
|
||||||
import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices";
|
import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices";
|
||||||
import { areMocksEnabled, type MockInvoice } from "@/mocks";
|
import { areMocksEnabled, type MockInvoice } from "@/mocks";
|
||||||
|
import { useNavigate } from "@/router";
|
||||||
|
|
||||||
type DashboardTab = "pending" | "ready" | "all";
|
type DashboardTab = "pending" | "ready" | "all";
|
||||||
|
|
||||||
|
|
@ -15,12 +16,24 @@ const emptySelection: GridRowSelectionModel = {
|
||||||
ids: new Set(),
|
ids: new Set(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rowClickSx = {
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
backgroundColor: "background.paper",
|
||||||
|
cursor: "pointer",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export function DashboardGrids() {
|
export function DashboardGrids() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [tab, setTab] = useState<DashboardTab>("pending");
|
const [tab, setTab] = useState<DashboardTab>("pending");
|
||||||
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
|
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
|
||||||
const { data: invoices = [], isLoading, isError, error } = useInvoices();
|
const { data: invoices = [], isLoading, isError, error } = useInvoices();
|
||||||
const updatePayDate = useUpdateInvoicePayDate();
|
const updatePayDate = useUpdateInvoicePayDate();
|
||||||
|
|
||||||
|
const openInvoice = (params: GridRowParams<MockInvoice>) => {
|
||||||
|
navigate("/invoices/:id", { params: { id: params.row.id } });
|
||||||
|
};
|
||||||
|
|
||||||
const pendingRows = useMemo(
|
const pendingRows = useMemo(
|
||||||
() => invoices.filter((invoice) => invoice.status === "pending_approval"),
|
() => invoices.filter((invoice) => invoice.status === "pending_approval"),
|
||||||
[invoices],
|
[invoices],
|
||||||
|
|
@ -84,6 +97,8 @@ export function DashboardGrids() {
|
||||||
data-testid="grid-pending"
|
data-testid="grid-pending"
|
||||||
rows={pendingRows}
|
rows={pendingRows}
|
||||||
columns={pendingApprovalColumns}
|
columns={pendingApprovalColumns}
|
||||||
|
onRowClick={openInvoice}
|
||||||
|
sx={rowClickSx}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -103,15 +118,23 @@ export function DashboardGrids() {
|
||||||
onProcessRowUpdateError={(processError) => {
|
onProcessRowUpdateError={(processError) => {
|
||||||
console.error(processError);
|
console.error(processError);
|
||||||
}}
|
}}
|
||||||
|
sx={rowClickSx}
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<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>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "all" && (
|
{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">
|
<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 type { MockInvoice } from "@/mocks";
|
||||||
|
import { Link } from "@/router";
|
||||||
|
|
||||||
const currencyFormatter = new Intl.NumberFormat("en-US", {
|
const currencyFormatter = new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
|
|
@ -52,8 +54,29 @@ const dateColumn = (
|
||||||
valueFormatter: (value: string | null | undefined) => formatIsoDate(value),
|
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>[] = [
|
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 },
|
{ field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
|
||||||
dateColumn("dueDate", "Due Date"),
|
dateColumn("dueDate", "Due Date"),
|
||||||
dateColumn("sendPaymentOn", "Send Payment On"),
|
dateColumn("sendPaymentOn", "Send Payment On"),
|
||||||
|
|
@ -66,12 +89,24 @@ export const readyToPayColumns: GridColDef<MockInvoice>[] = [
|
||||||
dateColumn("dueDate", "Due Date"),
|
dateColumn("dueDate", "Due Date"),
|
||||||
dateColumn("payDate", "Pay Date", true),
|
dateColumn("payDate", "Pay Date", true),
|
||||||
{ field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
|
{ 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"),
|
moneyColumn("amountDue", "Amount Due"),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const allInvoicesColumns: GridColDef<MockInvoice>[] = [
|
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: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
|
||||||
{ field: "status", headerName: "Status", flex: 1, minWidth: 140 },
|
{ field: "status", headerName: "Status", flex: 1, minWidth: 140 },
|
||||||
dateColumn("dueDate", "Due Date"),
|
dateColumn("dueDate", "Due Date"),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export const invoiceQueryKeys = {
|
export const invoiceQueryKeys = {
|
||||||
all: ["invoices"] as const,
|
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 { 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";
|
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() {
|
export function useInvoices() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: invoiceQueryKeys.all,
|
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() {
|
export function useUpdateInvoicePayDate() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, payDate }: { id: string; payDate: string | null }) =>
|
mutationFn: ({ id, payDate }: { id: string; payDate: string | null }) =>
|
||||||
Promise.resolve(updateMockInvoicePayDate(id, payDate)),
|
Promise.resolve(updateMockInvoicePayDate(id, payDate)),
|
||||||
onSuccess: async () => {
|
onSuccess: async (_data, variables) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: invoiceQueryKeys.all });
|
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,
|
AppBar,
|
||||||
Box,
|
Box,
|
||||||
Drawer,
|
Drawer,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
List,
|
List,
|
||||||
ListItemButton,
|
ListItemButton,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { useNavigate, type Path } from "@/router";
|
import { useNavigate, type Path } from "@/router";
|
||||||
|
import { getMockUsers } from "@/mocks";
|
||||||
|
import { useMockSession } from "@/providers/use-mock-session";
|
||||||
|
|
||||||
const DRAWER_WIDTH = 240;
|
const DRAWER_WIDTH = 240;
|
||||||
|
|
||||||
|
|
@ -33,6 +39,8 @@ type AppShellProps = {
|
||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user, setCurrentUserId } = useMockSession();
|
||||||
|
const users = getMockUsers();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: "flex", minHeight: "100vh" }}>
|
<Box sx={{ display: "flex", minHeight: "100vh" }}>
|
||||||
|
|
@ -47,10 +55,26 @@ export function AppShell({ children }: AppShellProps) {
|
||||||
color: "text.primary",
|
color: "text.primary",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Toolbar>
|
<Toolbar sx={{ gap: 2 }}>
|
||||||
<Typography variant="h6" sx={{ fontFamily: "inherit", fontWeight: 600 }}>
|
<Typography variant="h6" sx={{ fontFamily: "inherit", fontWeight: 600, flexGrow: 1 }}>
|
||||||
Sea Haven AP
|
Sea Haven AP
|
||||||
</Typography>
|
</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>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,23 @@ export function updateMockApprovalStatus(
|
||||||
return { ...updated };
|
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 {
|
export function resetMockApprovals(): void {
|
||||||
approvalStore = seedApprovals.map((item) => ({ ...item }));
|
approvalStore = seedApprovals.map((item) => ({ ...item }));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
addMockInvoiceComment,
|
||||||
areMocksEnabled,
|
areMocksEnabled,
|
||||||
getMockApprovalPolicies,
|
getMockApprovalPolicies,
|
||||||
getMockApprovals,
|
getMockApprovals,
|
||||||
getMockDepartments,
|
getMockDepartments,
|
||||||
getMockGlAccounts,
|
getMockGlAccounts,
|
||||||
|
getMockInvoice,
|
||||||
getMockInvoices,
|
getMockInvoices,
|
||||||
getMockPendingApprovals,
|
getMockPendingApprovals,
|
||||||
|
getMockSessionUser,
|
||||||
getMockUsers,
|
getMockUsers,
|
||||||
getMockVendors,
|
getMockVendors,
|
||||||
resetMockApprovalPolicies,
|
resetMockApprovalPolicies,
|
||||||
|
|
@ -14,9 +17,14 @@ import {
|
||||||
resetMockDepartments,
|
resetMockDepartments,
|
||||||
resetMockGlAccounts,
|
resetMockGlAccounts,
|
||||||
resetMockInvoices,
|
resetMockInvoices,
|
||||||
|
resetMockSession,
|
||||||
resetMockUsers,
|
resetMockUsers,
|
||||||
resetMockVendors,
|
resetMockVendors,
|
||||||
|
setMockSessionUserId,
|
||||||
|
transitionMockInvoiceStatus,
|
||||||
updateMockApprovalStatus,
|
updateMockApprovalStatus,
|
||||||
|
updateMockInvoiceHeader,
|
||||||
|
updateMockInvoiceLines,
|
||||||
updateMockInvoicePayDate,
|
updateMockInvoicePayDate,
|
||||||
upsertMockDepartment,
|
upsertMockDepartment,
|
||||||
upsertMockGlAccount,
|
upsertMockGlAccount,
|
||||||
|
|
@ -34,6 +42,7 @@ describe("mocks module", () => {
|
||||||
resetMockUsers();
|
resetMockUsers();
|
||||||
resetMockApprovalPolicies();
|
resetMockApprovalPolicies();
|
||||||
resetMockApprovals();
|
resetMockApprovals();
|
||||||
|
resetMockSession();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to mocks enabled", () => {
|
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", () => {
|
it("seeds settings entities and supports upsert", () => {
|
||||||
expect(getMockVendors().length).toBeGreaterThanOrEqual(4);
|
expect(getMockVendors().length).toBeGreaterThanOrEqual(4);
|
||||||
expect(getMockGlAccounts().length).toBeGreaterThanOrEqual(3);
|
expect(getMockGlAccounts().length).toBeGreaterThanOrEqual(3);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,27 @@
|
||||||
export { areMocksEnabled } from "@/mocks/env";
|
export { areMocksEnabled } from "@/mocks/env";
|
||||||
export {
|
export {
|
||||||
|
addMockInvoiceComment,
|
||||||
|
getMockInvoice,
|
||||||
getMockInvoices,
|
getMockInvoices,
|
||||||
resetMockInvoices,
|
resetMockInvoices,
|
||||||
|
transitionMockInvoiceStatus,
|
||||||
|
updateMockInvoiceHeader,
|
||||||
|
updateMockInvoiceLines,
|
||||||
updateMockInvoicePayDate,
|
updateMockInvoicePayDate,
|
||||||
type MockInvoice,
|
type MockInvoice,
|
||||||
|
type MockInvoiceActivity,
|
||||||
|
type MockInvoiceComment,
|
||||||
|
type MockInvoiceHeaderPatch,
|
||||||
|
type MockInvoiceLine,
|
||||||
type MockInvoiceStatus,
|
type MockInvoiceStatus,
|
||||||
} from "@/mocks/invoices";
|
} from "@/mocks/invoices";
|
||||||
|
export {
|
||||||
|
DEFAULT_MOCK_USER_ID,
|
||||||
|
getMockSessionUser,
|
||||||
|
getMockSessionUserId,
|
||||||
|
resetMockSession,
|
||||||
|
setMockSessionUserId,
|
||||||
|
} from "@/mocks/session";
|
||||||
export {
|
export {
|
||||||
getMockVendor,
|
getMockVendor,
|
||||||
getMockVendors,
|
getMockVendors,
|
||||||
|
|
@ -48,6 +64,7 @@ export {
|
||||||
getMockApprovals,
|
getMockApprovals,
|
||||||
getMockPendingApprovals,
|
getMockPendingApprovals,
|
||||||
resetMockApprovals,
|
resetMockApprovals,
|
||||||
|
syncMockApprovalsForInvoice,
|
||||||
updateMockApprovalStatus,
|
updateMockApprovalStatus,
|
||||||
type MockApprovalItem,
|
type MockApprovalItem,
|
||||||
type MockApprovalStatus,
|
type MockApprovalStatus,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,31 @@
|
||||||
|
import { syncMockApprovalsForInvoice } from "@/mocks/approvals";
|
||||||
import { areMocksEnabled } from "@/mocks/env";
|
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 = {
|
export type MockInvoice = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -15,8 +40,38 @@ export type MockInvoice = {
|
||||||
sendPaymentOn: string | null;
|
sendPaymentOn: string | null;
|
||||||
status: MockInvoiceStatus;
|
status: MockInvoiceStatus;
|
||||||
paymentMethod: "check" | "ach";
|
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[] = [
|
const seedInvoices: MockInvoice[] = [
|
||||||
{
|
{
|
||||||
id: "inv-1",
|
id: "inv-1",
|
||||||
|
|
@ -31,6 +86,49 @@ const seedInvoices: MockInvoice[] = [
|
||||||
sendPaymentOn: "2026-08-15",
|
sendPaymentOn: "2026-08-15",
|
||||||
status: "approved",
|
status: "approved",
|
||||||
paymentMethod: "check",
|
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",
|
id: "inv-2",
|
||||||
|
|
@ -45,6 +143,42 @@ const seedInvoices: MockInvoice[] = [
|
||||||
sendPaymentOn: "2026-08-20",
|
sendPaymentOn: "2026-08-20",
|
||||||
status: "pending_approval",
|
status: "pending_approval",
|
||||||
paymentMethod: "ach",
|
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",
|
id: "inv-3",
|
||||||
|
|
@ -59,6 +193,34 @@ const seedInvoices: MockInvoice[] = [
|
||||||
sendPaymentOn: "2026-08-10",
|
sendPaymentOn: "2026-08-10",
|
||||||
status: "approved",
|
status: "approved",
|
||||||
paymentMethod: "check",
|
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",
|
id: "inv-4",
|
||||||
|
|
@ -73,6 +235,27 @@ const seedInvoices: MockInvoice[] = [
|
||||||
sendPaymentOn: "2026-08-27",
|
sendPaymentOn: "2026-08-27",
|
||||||
status: "pending_approval",
|
status: "pending_approval",
|
||||||
paymentMethod: "ach",
|
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",
|
id: "inv-5",
|
||||||
|
|
@ -87,6 +270,34 @@ const seedInvoices: MockInvoice[] = [
|
||||||
sendPaymentOn: "2026-07-25",
|
sendPaymentOn: "2026-07-25",
|
||||||
status: "scheduled",
|
status: "scheduled",
|
||||||
paymentMethod: "check",
|
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",
|
id: "inv-6",
|
||||||
|
|
@ -101,6 +312,27 @@ const seedInvoices: MockInvoice[] = [
|
||||||
sendPaymentOn: "2026-07-10",
|
sendPaymentOn: "2026-07-10",
|
||||||
status: "paid",
|
status: "paid",
|
||||||
paymentMethod: "ach",
|
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",
|
id: "inv-7",
|
||||||
|
|
@ -115,17 +347,94 @@ const seedInvoices: MockInvoice[] = [
|
||||||
sendPaymentOn: "2026-08-22",
|
sendPaymentOn: "2026-08-22",
|
||||||
status: "approved",
|
status: "approved",
|
||||||
paymentMethod: "ach",
|
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. */
|
/** 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[] {
|
export function getMockInvoices(): MockInvoice[] {
|
||||||
if (!areMocksEnabled()) {
|
if (!areMocksEnabled()) {
|
||||||
return [];
|
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 {
|
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}`);
|
throw new Error(`Mock invoice not found: ${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated: MockInvoice = { ...invoiceStore[index], payDate };
|
return replaceInvoice(index, { ...invoiceStore[index], payDate });
|
||||||
invoiceStore = [...invoiceStore.slice(0, index), updated, ...invoiceStore.slice(index + 1)];
|
}
|
||||||
return { ...updated };
|
|
||||||
|
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. */
|
/** Test helper to restore seed data between unit tests. */
|
||||||
export function resetMockInvoices(): void {
|
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,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} 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 { MockDataGrid } from "@/components/MockDataGrid";
|
||||||
import { usePendingApprovals, useUpdateApprovalStatus } from "@/domain/approvals/use-approvals";
|
import { usePendingApprovals, useUpdateApprovalStatus } from "@/domain/approvals/use-approvals";
|
||||||
import type { MockApprovalItem } from "@/mocks";
|
import type { MockApprovalItem } from "@/mocks";
|
||||||
|
import { useNavigate } from "@/router";
|
||||||
|
|
||||||
const currencyFormatter = new Intl.NumberFormat("en-US", {
|
const currencyFormatter = new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
|
|
@ -34,10 +35,15 @@ function formatIsoDate(value: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ApprovalsInboxPage() {
|
export default function ApprovalsInboxPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data = [], isLoading, isError, error } = usePendingApprovals();
|
const { data = [], isLoading, isError, error } = usePendingApprovals();
|
||||||
const updateStatus = useUpdateApprovalStatus();
|
const updateStatus = useUpdateApprovalStatus();
|
||||||
const [snackbar, setSnackbar] = useState<string | null>(null);
|
const [snackbar, setSnackbar] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openInvoice = (params: GridRowParams<MockApprovalItem>) => {
|
||||||
|
navigate("/invoices/:id", { params: { id: params.row.invoiceId } });
|
||||||
|
};
|
||||||
|
|
||||||
const columns: GridColDef<MockApprovalItem>[] = [
|
const columns: GridColDef<MockApprovalItem>[] = [
|
||||||
{ field: "invoiceNumber", headerName: "Invoice #", flex: 1, minWidth: 120 },
|
{ field: "invoiceNumber", headerName: "Invoice #", flex: 1, minWidth: 120 },
|
||||||
{ field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
|
{ field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
|
||||||
|
|
@ -127,6 +133,13 @@ export default function ApprovalsInboxPage() {
|
||||||
rows={data}
|
rows={data}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
getRowHeight={() => 56}
|
getRowHeight={() => 56}
|
||||||
|
onRowClick={openInvoice}
|
||||||
|
sx={{
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
backgroundColor: "background.paper",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</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 { CssBaseline, ThemeProvider } from "@mui/material";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import { muiTheme } from "@/lib/mui-theme";
|
import { muiTheme } from "@/lib/mui-theme";
|
||||||
|
import { MockSessionProvider } from "@/providers/MockSessionProvider";
|
||||||
|
|
||||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider theme={muiTheme}>
|
<ThemeProvider theme={muiTheme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
|
<MockSessionProvider>
|
||||||
{children}
|
{children}
|
||||||
{import.meta.env.DEV ? <ReactQueryDevtools initialIsOpen={false} /> : null}
|
{import.meta.env.DEV ? <ReactQueryDevtools initialIsOpen={false} /> : null}
|
||||||
|
</MockSessionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</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