feat(settings): add settings and approvals POC shells (AP-7) (#8)

* feat(settings): add settings and approvals POC shells (AP-7)

Add app shell nav plus mock settings CRUD routes and an approvals inbox so the POC demo path works without an API.

* fix(build): generate router types before tsc

CI failed because src/router.ts is gitignored and tsc ran before Vite created it.
This commit is contained in:
Adam Moussa 2026-08-10 18:00:51 -04:00 • committed by GitHub
parent 9c792b0bbb
commit fd903c6367
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 2262 additions and 201 deletions

View file

@ -0,0 +1,61 @@
import { expect, test } from "@playwright/test";
test.describe("settings and approvals shells", () => {
test("demo path dashboard → settings → approvals works on mocks", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Sea Haven AP" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
await expect(page.getByTestId("nav-dashboard")).toBeVisible();
await expect(page.getByTestId("nav-settings")).toBeVisible();
await expect(page.getByTestId("nav-approvals")).toBeVisible();
await page.getByTestId("nav-settings").click();
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
await expect(page.getByTestId("settings-hub")).toBeVisible();
await page.getByTestId("settings-link-vendors").click();
await expect(page.getByRole("heading", { name: "Vendors" })).toBeVisible();
await expect(
page.getByTestId("settings-vendors-grid").locator(".MuiDataGrid-row").first(),
).toBeVisible();
await page.getByTestId("settings-vendors-grid-new").click();
await expect(page.getByRole("heading", { name: "New vendor" })).toBeVisible();
await expect(page.getByTestId("settings-vendor-form")).toBeVisible();
await page.getByTestId("nav-approvals").click();
await expect(page.getByRole("heading", { name: "Approvals" })).toBeVisible();
await expect(
page.getByTestId("approvals-inbox-grid").locator(".MuiDataGrid-row").first(),
).toBeVisible();
});
test("each settings area lists mock data", async ({ page }) => {
const areas = [
{
link: "settings-link-gl-accounts",
heading: "GL accounts",
grid: "settings-gl-accounts-grid",
},
{
link: "settings-link-departments",
heading: "Departments",
grid: "settings-departments-grid",
},
{ link: "settings-link-users", heading: "Users", grid: "settings-users-grid" },
{
link: "settings-link-approval-policies",
heading: "Approval policies",
grid: "settings-approval-policies-grid",
},
] as const;
for (const area of areas) {
await page.goto("/settings");
await page.getByTestId(area.link).click();
await expect(page.getByRole("heading", { name: area.heading })).toBeVisible();
await expect(page.getByTestId(area.grid).locator(".MuiDataGrid-row").first()).toBeVisible();
}
});
});

View file

@ -5,7 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "generate:router": "node scripts/generate-router.mjs",
"build": "npm run generate:router && tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",

View file

@ -0,0 +1,10 @@
import { generate } from "../node_modules/@generouted/react-router/dist/plugin/generate.js";
await generate({
source: {
routes: "./src/pages/**/[\\w[-]*.{jsx,tsx,mdx}",
modals: "./src/pages/**/[+]*.{jsx,tsx,mdx}",
},
output: "./src/router.ts",
format: true,
});

View file

@ -0,0 +1,31 @@
import { DataGrid, type DataGridProps, type GridValidRowModel } from "@mui/x-data-grid";
type MockDataGridProps<R extends GridValidRowModel> = DataGridProps<R> & {
"data-testid": string;
};
export function MockDataGrid<R extends GridValidRowModel>({
"data-testid": testId,
...props
}: MockDataGridProps<R>) {
return (
<div data-testid={testId}>
<DataGrid
autoHeight
disableColumnMenu
disableRowSelectionOnClick
pageSizeOptions={[10, 25]}
initialState={{
pagination: { paginationModel: { pageSize: 10, page: 0 } },
}}
getRowId={(row) => row.id as string}
sx={{
border: 1,
borderColor: "divider",
backgroundColor: "background.paper",
}}
{...props}
/>
</div>
);
}

View file

@ -1,31 +1 @@
import { DataGrid, type DataGridProps, type GridValidRowModel } from "@mui/x-data-grid"; export { MockDataGrid as InvoiceDataGrid } from "@/components/MockDataGrid";
type InvoiceDataGridProps<R extends GridValidRowModel> = DataGridProps<R> & {
"data-testid": string;
};
export function InvoiceDataGrid<R extends GridValidRowModel>({
"data-testid": testId,
...props
}: InvoiceDataGridProps<R>) {
return (
<div data-testid={testId}>
<DataGrid
autoHeight
disableColumnMenu
disableRowSelectionOnClick
pageSizeOptions={[10, 25]}
initialState={{
pagination: { paginationModel: { pageSize: 10, page: 0 } },
}}
getRowId={(row) => row.id as string}
sx={{
border: 1,
borderColor: "divider",
backgroundColor: "background.paper",
}}
{...props}
/>
</div>
);
}

View file

@ -0,0 +1,3 @@
export const approvalPolicyQueryKeys = {
all: ["approval-policies"] as const,
};

View file

@ -0,0 +1,39 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getMockApprovalPolicies,
getMockApprovalPolicy,
upsertMockApprovalPolicy,
type MockApprovalPolicy,
} from "@/mocks";
import { approvalPolicyQueryKeys } from "@/domain/approval-policies/query-keys";
export function useApprovalPolicies() {
return useQuery({
queryKey: approvalPolicyQueryKeys.all,
queryFn: getMockApprovalPolicies,
});
}
export function useApprovalPolicy(id: string | undefined) {
return useQuery({
queryKey: [...approvalPolicyQueryKeys.all, id] as const,
queryFn: () => {
if (!id) {
return undefined;
}
return getMockApprovalPolicy(id);
},
enabled: Boolean(id),
});
}
export function useUpsertApprovalPolicy() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (policy: MockApprovalPolicy) => Promise.resolve(upsertMockApprovalPolicy(policy)),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: approvalPolicyQueryKeys.all });
},
});
}

View file

@ -0,0 +1,3 @@
export const approvalQueryKeys = {
all: ["approvals"] as const,
};

View file

@ -0,0 +1,26 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getMockPendingApprovals,
updateMockApprovalStatus,
type MockApprovalStatus,
} from "@/mocks";
import { approvalQueryKeys } from "@/domain/approvals/query-keys";
export function usePendingApprovals() {
return useQuery({
queryKey: approvalQueryKeys.all,
queryFn: getMockPendingApprovals,
});
}
export function useUpdateApprovalStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: string; status: Exclude<MockApprovalStatus, "pending"> }) =>
Promise.resolve(updateMockApprovalStatus(id, status)),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: approvalQueryKeys.all });
},
});
}

View file

@ -0,0 +1,3 @@
export const departmentQueryKeys = {
all: ["departments"] as const,
};

View file

@ -0,0 +1,39 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getMockDepartment,
getMockDepartments,
upsertMockDepartment,
type MockDepartment,
} from "@/mocks";
import { departmentQueryKeys } from "@/domain/departments/query-keys";
export function useDepartments() {
return useQuery({
queryKey: departmentQueryKeys.all,
queryFn: getMockDepartments,
});
}
export function useDepartment(id: string | undefined) {
return useQuery({
queryKey: [...departmentQueryKeys.all, id] as const,
queryFn: () => {
if (!id) {
return undefined;
}
return getMockDepartment(id);
},
enabled: Boolean(id),
});
}
export function useUpsertDepartment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (department: MockDepartment) => Promise.resolve(upsertMockDepartment(department)),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: departmentQueryKeys.all });
},
});
}

View file

@ -0,0 +1,3 @@
export const glAccountQueryKeys = {
all: ["gl-accounts"] as const,
};

View file

@ -0,0 +1,39 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getMockGlAccount,
getMockGlAccounts,
upsertMockGlAccount,
type MockGlAccount,
} from "@/mocks";
import { glAccountQueryKeys } from "@/domain/gl-accounts/query-keys";
export function useGlAccounts() {
return useQuery({
queryKey: glAccountQueryKeys.all,
queryFn: getMockGlAccounts,
});
}
export function useGlAccount(id: string | undefined) {
return useQuery({
queryKey: [...glAccountQueryKeys.all, id] as const,
queryFn: () => {
if (!id) {
return undefined;
}
return getMockGlAccount(id);
},
enabled: Boolean(id),
});
}
export function useUpsertGlAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (account: MockGlAccount) => Promise.resolve(upsertMockGlAccount(account)),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: glAccountQueryKeys.all });
},
});
}

View file

@ -0,0 +1,3 @@
export const userQueryKeys = {
all: ["users"] as const,
};

View file

@ -0,0 +1,34 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getMockUser, getMockUsers, upsertMockUser, type MockUser } from "@/mocks";
import { userQueryKeys } from "@/domain/users/query-keys";
export function useUsers() {
return useQuery({
queryKey: userQueryKeys.all,
queryFn: getMockUsers,
});
}
export function useUser(id: string | undefined) {
return useQuery({
queryKey: [...userQueryKeys.all, id] as const,
queryFn: () => {
if (!id) {
return undefined;
}
return getMockUser(id);
},
enabled: Boolean(id),
});
}
export function useUpsertUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (user: MockUser) => Promise.resolve(upsertMockUser(user)),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
},
});
}

3
src/domain/vendors/query-keys.ts vendored Normal file
View file

@ -0,0 +1,3 @@
export const vendorQueryKeys = {
all: ["vendors"] as const,
};

34
src/domain/vendors/use-vendors.ts vendored Normal file
View file

@ -0,0 +1,34 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getMockVendor, getMockVendors, upsertMockVendor, type MockVendor } from "@/mocks";
import { vendorQueryKeys } from "@/domain/vendors/query-keys";
export function useVendors() {
return useQuery({
queryKey: vendorQueryKeys.all,
queryFn: getMockVendors,
});
}
export function useVendor(id: string | undefined) {
return useQuery({
queryKey: [...vendorQueryKeys.all, id] as const,
queryFn: () => {
if (!id) {
return undefined;
}
return getMockVendor(id);
},
enabled: Boolean(id),
});
}
export function useUpsertVendor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (vendor: MockVendor) => Promise.resolve(upsertMockVendor(vendor)),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: vendorQueryKeys.all });
},
});
}

107
src/layout/AppShell.tsx Normal file
View file

@ -0,0 +1,107 @@
import {
AppBar,
Box,
Drawer,
List,
ListItemButton,
ListItemText,
Toolbar,
Typography,
} from "@mui/material";
import type { ReactNode } from "react";
import { useLocation } from "react-router";
import { useNavigate, type Path } from "@/router";
const DRAWER_WIDTH = 240;
type NavItem = {
label: string;
to: Path;
testId: string;
};
const navItems: NavItem[] = [
{ label: "Dashboard", to: "/", testId: "nav-dashboard" },
{ label: "Approvals", to: "/approvals", testId: "nav-approvals" },
{ label: "Settings", to: "/settings", testId: "nav-settings" },
];
type AppShellProps = {
children: ReactNode;
};
export function AppShell({ children }: AppShellProps) {
const location = useLocation();
const navigate = useNavigate();
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
<AppBar
position="fixed"
elevation={0}
sx={{
zIndex: (theme) => theme.zIndex.drawer + 1,
borderBottom: 1,
borderColor: "divider",
bgcolor: "background.paper",
color: "text.primary",
}}
>
<Toolbar>
<Typography variant="h6" sx={{ fontFamily: "inherit", fontWeight: 600 }}>
Sea Haven AP
</Typography>
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
sx={{
width: DRAWER_WIDTH,
flexShrink: 0,
[`& .MuiDrawer-paper`]: {
width: DRAWER_WIDTH,
boxSizing: "border-box",
borderRight: 1,
borderColor: "divider",
},
}}
>
<Toolbar />
<Box sx={{ overflow: "auto", py: 1 }}>
<List dense>
{navItems.map((item) => {
const selected =
item.to === "/"
? location.pathname === "/"
: location.pathname === item.to || location.pathname.startsWith(`${item.to}/`);
return (
<ListItemButton
key={item.to}
selected={selected}
data-testid={item.testId}
onClick={() => navigate(item.to)}
>
<ListItemText primary={item.label} />
</ListItemButton>
);
})}
</List>
</Box>
</Drawer>
<Box
component="main"
sx={{
flexGrow: 1,
width: `calc(100% - ${DRAWER_WIDTH}px)`,
bgcolor: "background.default",
}}
>
<Toolbar />
{children}
</Box>
</Box>
);
}

View file

@ -0,0 +1,58 @@
import { areMocksEnabled } from "@/mocks/env";
import type { MockUserRole } from "@/mocks/users";
export type MockApprovalPolicy = {
id: string;
name: string;
amountThreshold: number;
approverRole: MockUserRole;
};
const seedPolicies: MockApprovalPolicy[] = [
{
id: "policy-1",
name: "Standard under $1,000",
amountThreshold: 1000,
approverRole: "approver",
},
{
id: "policy-2",
name: "Elevated $1,000–$5,000",
amountThreshold: 5000,
approverRole: "approver",
},
{
id: "policy-3",
name: "Executive over $5,000",
amountThreshold: 5000.01,
approverRole: "admin",
},
];
let policyStore: MockApprovalPolicy[] = seedPolicies.map((policy) => ({ ...policy }));
export function getMockApprovalPolicies(): MockApprovalPolicy[] {
if (!areMocksEnabled()) {
return [];
}
return policyStore.map((policy) => ({ ...policy }));
}
export function getMockApprovalPolicy(id: string): MockApprovalPolicy | undefined {
return getMockApprovalPolicies().find((policy) => policy.id === id);
}
export function upsertMockApprovalPolicy(policy: MockApprovalPolicy): MockApprovalPolicy {
const index = policyStore.findIndex((row) => row.id === policy.id);
const next = { ...policy };
if (index === -1) {
policyStore = [...policyStore, next];
} else {
policyStore = [...policyStore.slice(0, index), next, ...policyStore.slice(index + 1)];
}
return { ...next };
}
export function resetMockApprovalPolicies(): void {
policyStore = seedPolicies.map((policy) => ({ ...policy }));
}

74
src/mocks/approvals.ts Normal file
View file

@ -0,0 +1,74 @@
import { areMocksEnabled } from "@/mocks/env";
export type MockApprovalStatus = "pending" | "approved" | "rejected";
export type MockApprovalItem = {
id: string;
invoiceId: string;
invoiceNumber: string;
vendorName: string;
amount: number;
submittedAt: string;
status: MockApprovalStatus;
};
const seedApprovals: MockApprovalItem[] = [
{
id: "appr-1",
invoiceId: "inv-2",
invoiceNumber: "INV-1002",
vendorName: "Harbor Maintenance LLC",
amount: 4800,
submittedAt: "2026-08-08",
status: "pending",
},
{
id: "appr-2",
invoiceId: "inv-4",
invoiceNumber: "INV-1004",
vendorName: "Coastal Office Supply",
amount: 900,
submittedAt: "2026-08-09",
status: "pending",
},
{
id: "appr-3",
invoiceId: "inv-8",
invoiceNumber: "INV-1008",
vendorName: "Acme Supplies Co",
amount: 275.5,
submittedAt: "2026-08-07",
status: "pending",
},
];
let approvalStore: MockApprovalItem[] = seedApprovals.map((item) => ({ ...item }));
export function getMockApprovals(): MockApprovalItem[] {
if (!areMocksEnabled()) {
return [];
}
return approvalStore.map((item) => ({ ...item }));
}
export function getMockPendingApprovals(): MockApprovalItem[] {
return getMockApprovals().filter((item) => item.status === "pending");
}
export function updateMockApprovalStatus(
id: string,
status: Exclude<MockApprovalStatus, "pending">,
): MockApprovalItem {
const index = approvalStore.findIndex((item) => item.id === id);
if (index === -1) {
throw new Error(`Mock approval not found: ${id}`);
}
const updated: MockApprovalItem = { ...approvalStore[index], status };
approvalStore = [...approvalStore.slice(0, index), updated, ...approvalStore.slice(index + 1)];
return { ...updated };
}
export function resetMockApprovals(): void {
approvalStore = seedApprovals.map((item) => ({ ...item }));
}

46
src/mocks/departments.ts Normal file
View file

@ -0,0 +1,46 @@
import { areMocksEnabled } from "@/mocks/env";
export type MockDepartment = {
id: string;
code: string;
name: string;
};
const seedDepartments: MockDepartment[] = [
{ id: "dept-1", code: "OPS", name: "Operations" },
{ id: "dept-2", code: "FIN", name: "Finance" },
{ id: "dept-3", code: "ENG", name: "Engineering" },
{ id: "dept-4", code: "ADM", name: "Administration" },
];
let departmentStore: MockDepartment[] = seedDepartments.map((department) => ({ ...department }));
export function getMockDepartments(): MockDepartment[] {
if (!areMocksEnabled()) {
return [];
}
return departmentStore.map((department) => ({ ...department }));
}
export function getMockDepartment(id: string): MockDepartment | undefined {
return getMockDepartments().find((department) => department.id === id);
}
export function upsertMockDepartment(department: MockDepartment): MockDepartment {
const index = departmentStore.findIndex((row) => row.id === department.id);
const next = { ...department };
if (index === -1) {
departmentStore = [...departmentStore, next];
} else {
departmentStore = [
...departmentStore.slice(0, index),
next,
...departmentStore.slice(index + 1),
];
}
return { ...next };
}
export function resetMockDepartments(): void {
departmentStore = seedDepartments.map((department) => ({ ...department }));
}

4
src/mocks/env.ts Normal file
View file

@ -0,0 +1,4 @@
/** Mocks are the default data path until the API is wired. */
export function areMocksEnabled(): boolean {
return import.meta.env.VITE_USE_MOCKS !== "false";
}

42
src/mocks/gl-accounts.ts Normal file
View file

@ -0,0 +1,42 @@
import { areMocksEnabled } from "@/mocks/env";
export type MockGlAccount = {
id: string;
code: string;
name: string;
};
const seedGlAccounts: MockGlAccount[] = [
{ id: "gl-1", code: "5100", name: "Office Supplies" },
{ id: "gl-2", code: "5200", name: "Facilities Maintenance" },
{ id: "gl-3", code: "6100", name: "Utilities" },
{ id: "gl-4", code: "7100", name: "Professional Services" },
];
let glAccountStore: MockGlAccount[] = seedGlAccounts.map((account) => ({ ...account }));
export function getMockGlAccounts(): MockGlAccount[] {
if (!areMocksEnabled()) {
return [];
}
return glAccountStore.map((account) => ({ ...account }));
}
export function getMockGlAccount(id: string): MockGlAccount | undefined {
return getMockGlAccounts().find((account) => account.id === id);
}
export function upsertMockGlAccount(account: MockGlAccount): MockGlAccount {
const index = glAccountStore.findIndex((row) => row.id === account.id);
const next = { ...account };
if (index === -1) {
glAccountStore = [...glAccountStore, next];
} else {
glAccountStore = [...glAccountStore.slice(0, index), next, ...glAccountStore.slice(index + 1)];
}
return { ...next };
}
export function resetMockGlAccounts(): void {
glAccountStore = seedGlAccounts.map((account) => ({ ...account }));
}

View file

@ -1,15 +1,39 @@
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { import {
areMocksEnabled, areMocksEnabled,
getMockApprovalPolicies,
getMockApprovals,
getMockDepartments,
getMockGlAccounts,
getMockInvoices, getMockInvoices,
getMockPendingApprovals,
getMockUsers,
getMockVendors, getMockVendors,
resetMockApprovalPolicies,
resetMockApprovals,
resetMockDepartments,
resetMockGlAccounts,
resetMockInvoices, resetMockInvoices,
resetMockUsers,
resetMockVendors,
updateMockApprovalStatus,
updateMockInvoicePayDate, updateMockInvoicePayDate,
upsertMockDepartment,
upsertMockGlAccount,
upsertMockVendor,
upsertMockUser,
upsertMockApprovalPolicy,
} from "@/mocks"; } from "@/mocks";
describe("mocks module", () => { describe("mocks module", () => {
afterEach(() => { afterEach(() => {
resetMockInvoices(); resetMockInvoices();
resetMockVendors();
resetMockGlAccounts();
resetMockDepartments();
resetMockUsers();
resetMockApprovalPolicies();
resetMockApprovals();
}); });
it("defaults to mocks enabled", () => { it("defaults to mocks enabled", () => {
@ -47,4 +71,59 @@ describe("mocks module", () => {
/Mock invoice not found/, /Mock invoice not found/,
); );
}); });
it("seeds settings entities and supports upsert", () => {
expect(getMockVendors().length).toBeGreaterThanOrEqual(4);
expect(getMockGlAccounts().length).toBeGreaterThanOrEqual(3);
expect(getMockDepartments().length).toBeGreaterThanOrEqual(3);
expect(getMockUsers().length).toBeGreaterThanOrEqual(3);
expect(getMockApprovalPolicies().length).toBeGreaterThanOrEqual(3);
const vendor = upsertMockVendor({
id: "vendor-new",
name: "New Vendor",
email: "new@example.com",
defaultPaymentMethod: "ach",
});
expect(getMockVendors().some((row) => row.id === vendor.id)).toBe(true);
upsertMockGlAccount({ id: "gl-new", code: "9999", name: "Misc" });
expect(getMockGlAccounts().some((row) => row.id === "gl-new")).toBe(true);
upsertMockDepartment({ id: "dept-new", code: "NEW", name: "New Dept" });
expect(getMockDepartments().some((row) => row.id === "dept-new")).toBe(true);
upsertMockUser({
id: "user-new",
name: "New User",
email: "new.user@seahaven.example",
role: "approver",
});
expect(getMockUsers().some((row) => row.id === "user-new")).toBe(true);
upsertMockApprovalPolicy({
id: "policy-new",
name: "Ad hoc",
amountThreshold: 250,
approverRole: "admin",
});
expect(getMockApprovalPolicies().some((row) => row.id === "policy-new")).toBe(true);
});
it("lists pending approvals and updates status", () => {
const pending = getMockPendingApprovals();
expect(pending.length).toBeGreaterThanOrEqual(2);
expect(getMockApprovals().length).toBeGreaterThanOrEqual(pending.length);
const first = pending[0];
const updated = updateMockApprovalStatus(first.id, "approved");
expect(updated.status).toBe("approved");
expect(getMockPendingApprovals().some((row) => row.id === first.id)).toBe(false);
});
it("throws when updating an unknown approval", () => {
expect(() => updateMockApprovalStatus("missing", "rejected")).toThrow(
/Mock approval not found/,
);
});
}); });

View file

@ -1,167 +1,54 @@
export type MockVendor = { export { areMocksEnabled } from "@/mocks/env";
id: string; export {
name: string; getMockInvoices,
}; resetMockInvoices,
updateMockInvoicePayDate,
export type MockInvoiceStatus = "pending_approval" | "approved" | "scheduled" | "paid"; type MockInvoice,
type MockInvoiceStatus,
export type MockInvoice = { } from "@/mocks/invoices";
id: string; export {
invoiceNumber: string; getMockVendor,
invoiceNumbers: string; getMockVendors,
vendorId: string; mockVendors,
vendorName: string; resetMockVendors,
amountDue: number; upsertMockVendor,
amount: number; type MockPaymentMethod,
dueDate: string; type MockVendor,
payDate: string | null; } from "@/mocks/vendors";
sendPaymentOn: string | null; export {
status: MockInvoiceStatus; getMockGlAccount,
paymentMethod: "check" | "ach"; getMockGlAccounts,
}; resetMockGlAccounts,
upsertMockGlAccount,
export const mockVendors: MockVendor[] = [ type MockGlAccount,
{ id: "vendor-1", name: "Acme Supplies Co" }, } from "@/mocks/gl-accounts";
{ id: "vendor-2", name: "Harbor Maintenance LLC" }, export {
{ id: "vendor-3", name: "Northwind Utilities" }, getMockDepartment,
{ id: "vendor-4", name: "Coastal Office Supply" }, getMockDepartments,
]; resetMockDepartments,
upsertMockDepartment,
const seedInvoices: MockInvoice[] = [ type MockDepartment,
{ } from "@/mocks/departments";
id: "inv-1", export {
invoiceNumber: "INV-1001", getMockUser,
invoiceNumbers: "INV-1001", getMockUsers,
vendorId: "vendor-1", resetMockUsers,
vendorName: "Acme Supplies Co", upsertMockUser,
amountDue: 1250.5, type MockUser,
amount: 1250.5, type MockUserRole,
dueDate: "2026-08-20", } from "@/mocks/users";
payDate: "2026-08-15", export {
sendPaymentOn: "2026-08-15", getMockApprovalPolicies,
status: "approved", getMockApprovalPolicy,
paymentMethod: "check", resetMockApprovalPolicies,
}, upsertMockApprovalPolicy,
{ type MockApprovalPolicy,
id: "inv-2", } from "@/mocks/approval-policies";
invoiceNumber: "INV-1002", export {
invoiceNumbers: "INV-1002", getMockApprovals,
vendorId: "vendor-2", getMockPendingApprovals,
vendorName: "Harbor Maintenance LLC", resetMockApprovals,
amountDue: 4800, updateMockApprovalStatus,
amount: 4800, type MockApprovalItem,
dueDate: "2026-08-25", type MockApprovalStatus,
payDate: null, } from "@/mocks/approvals";
sendPaymentOn: "2026-08-20",
status: "pending_approval",
paymentMethod: "ach",
},
{
id: "inv-3",
invoiceNumber: "INV-1003",
invoiceNumbers: "INV-1003",
vendorId: "vendor-3",
vendorName: "Northwind Utilities",
amountDue: 312.75,
amount: 312.75,
dueDate: "2026-08-12",
payDate: "2026-08-10",
sendPaymentOn: "2026-08-10",
status: "approved",
paymentMethod: "check",
},
{
id: "inv-4",
invoiceNumber: "INV-1004",
invoiceNumbers: "INV-1004",
vendorId: "vendor-4",
vendorName: "Coastal Office Supply",
amountDue: 890.25,
amount: 900,
dueDate: "2026-09-01",
payDate: null,
sendPaymentOn: "2026-08-27",
status: "pending_approval",
paymentMethod: "ach",
},
{
id: "inv-5",
invoiceNumber: "INV-1005",
invoiceNumbers: "INV-1005",
vendorId: "vendor-1",
vendorName: "Acme Supplies Co",
amountDue: 2100,
amount: 2100,
dueDate: "2026-07-30",
payDate: "2026-07-25",
sendPaymentOn: "2026-07-25",
status: "scheduled",
paymentMethod: "check",
},
{
id: "inv-6",
invoiceNumber: "INV-1006",
invoiceNumbers: "INV-1006",
vendorId: "vendor-2",
vendorName: "Harbor Maintenance LLC",
amountDue: 0,
amount: 1550,
dueDate: "2026-07-15",
payDate: "2026-07-10",
sendPaymentOn: "2026-07-10",
status: "paid",
paymentMethod: "ach",
},
{
id: "inv-7",
invoiceNumber: "INV-1007",
invoiceNumbers: "INV-1007",
vendorId: "vendor-3",
vendorName: "Northwind Utilities",
amountDue: 645.1,
amount: 645.1,
dueDate: "2026-08-28",
payDate: "2026-08-22",
sendPaymentOn: "2026-08-22",
status: "approved",
paymentMethod: "ach",
},
];
/** Mutable in-memory invoice store for mock POC interactions. */
let invoiceStore: MockInvoice[] = seedInvoices.map((invoice) => ({ ...invoice }));
/** Mocks are the default data path until the API is wired. */
export function areMocksEnabled(): boolean {
return import.meta.env.VITE_USE_MOCKS !== "false";
}
export function getMockInvoices(): MockInvoice[] {
if (!areMocksEnabled()) {
return [];
}
return invoiceStore.map((invoice) => ({ ...invoice }));
}
export function getMockVendors(): MockVendor[] {
if (!areMocksEnabled()) {
return [];
}
return mockVendors;
}
export function updateMockInvoicePayDate(id: string, payDate: string | null): MockInvoice {
const index = invoiceStore.findIndex((invoice) => invoice.id === id);
if (index === -1) {
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 };
}
/** Test helper to restore seed data between unit tests. */
export function resetMockInvoices(): void {
invoiceStore = seedInvoices.map((invoice) => ({ ...invoice }));
}

145
src/mocks/invoices.ts Normal file
View file

@ -0,0 +1,145 @@
import { areMocksEnabled } from "@/mocks/env";
export type MockInvoiceStatus = "pending_approval" | "approved" | "scheduled" | "paid";
export type MockInvoice = {
id: string;
invoiceNumber: string;
invoiceNumbers: string;
vendorId: string;
vendorName: string;
amountDue: number;
amount: number;
dueDate: string;
payDate: string | null;
sendPaymentOn: string | null;
status: MockInvoiceStatus;
paymentMethod: "check" | "ach";
};
const seedInvoices: MockInvoice[] = [
{
id: "inv-1",
invoiceNumber: "INV-1001",
invoiceNumbers: "INV-1001",
vendorId: "vendor-1",
vendorName: "Acme Supplies Co",
amountDue: 1250.5,
amount: 1250.5,
dueDate: "2026-08-20",
payDate: "2026-08-15",
sendPaymentOn: "2026-08-15",
status: "approved",
paymentMethod: "check",
},
{
id: "inv-2",
invoiceNumber: "INV-1002",
invoiceNumbers: "INV-1002",
vendorId: "vendor-2",
vendorName: "Harbor Maintenance LLC",
amountDue: 4800,
amount: 4800,
dueDate: "2026-08-25",
payDate: null,
sendPaymentOn: "2026-08-20",
status: "pending_approval",
paymentMethod: "ach",
},
{
id: "inv-3",
invoiceNumber: "INV-1003",
invoiceNumbers: "INV-1003",
vendorId: "vendor-3",
vendorName: "Northwind Utilities",
amountDue: 312.75,
amount: 312.75,
dueDate: "2026-08-12",
payDate: "2026-08-10",
sendPaymentOn: "2026-08-10",
status: "approved",
paymentMethod: "check",
},
{
id: "inv-4",
invoiceNumber: "INV-1004",
invoiceNumbers: "INV-1004",
vendorId: "vendor-4",
vendorName: "Coastal Office Supply",
amountDue: 890.25,
amount: 900,
dueDate: "2026-09-01",
payDate: null,
sendPaymentOn: "2026-08-27",
status: "pending_approval",
paymentMethod: "ach",
},
{
id: "inv-5",
invoiceNumber: "INV-1005",
invoiceNumbers: "INV-1005",
vendorId: "vendor-1",
vendorName: "Acme Supplies Co",
amountDue: 2100,
amount: 2100,
dueDate: "2026-07-30",
payDate: "2026-07-25",
sendPaymentOn: "2026-07-25",
status: "scheduled",
paymentMethod: "check",
},
{
id: "inv-6",
invoiceNumber: "INV-1006",
invoiceNumbers: "INV-1006",
vendorId: "vendor-2",
vendorName: "Harbor Maintenance LLC",
amountDue: 0,
amount: 1550,
dueDate: "2026-07-15",
payDate: "2026-07-10",
sendPaymentOn: "2026-07-10",
status: "paid",
paymentMethod: "ach",
},
{
id: "inv-7",
invoiceNumber: "INV-1007",
invoiceNumbers: "INV-1007",
vendorId: "vendor-3",
vendorName: "Northwind Utilities",
amountDue: 645.1,
amount: 645.1,
dueDate: "2026-08-28",
payDate: "2026-08-22",
sendPaymentOn: "2026-08-22",
status: "approved",
paymentMethod: "ach",
},
];
/** Mutable in-memory invoice store for mock POC interactions. */
let invoiceStore: MockInvoice[] = seedInvoices.map((invoice) => ({ ...invoice }));
export function getMockInvoices(): MockInvoice[] {
if (!areMocksEnabled()) {
return [];
}
return invoiceStore.map((invoice) => ({ ...invoice }));
}
export function updateMockInvoicePayDate(id: string, payDate: string | null): MockInvoice {
const index = invoiceStore.findIndex((invoice) => invoice.id === id);
if (index === -1) {
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 };
}
/** Test helper to restore seed data between unit tests. */
export function resetMockInvoices(): void {
invoiceStore = seedInvoices.map((invoice) => ({ ...invoice }));
}

65
src/mocks/users.ts Normal file
View file

@ -0,0 +1,65 @@
import { areMocksEnabled } from "@/mocks/env";
export type MockUserRole = "ap_operator" | "approver" | "admin";
export type MockUser = {
id: string;
name: string;
email: string;
role: MockUserRole;
};
const seedUsers: MockUser[] = [
{
id: "user-1",
name: "Alex Rivera",
email: "alex.rivera@seahaven.example",
role: "ap_operator",
},
{
id: "user-2",
name: "Jordan Lee",
email: "jordan.lee@seahaven.example",
role: "approver",
},
{
id: "user-3",
name: "Sam Patel",
email: "sam.patel@seahaven.example",
role: "approver",
},
{
id: "user-4",
name: "Casey Morgan",
email: "casey.morgan@seahaven.example",
role: "admin",
},
];
let userStore: MockUser[] = seedUsers.map((user) => ({ ...user }));
export function getMockUsers(): MockUser[] {
if (!areMocksEnabled()) {
return [];
}
return userStore.map((user) => ({ ...user }));
}
export function getMockUser(id: string): MockUser | undefined {
return getMockUsers().find((user) => user.id === id);
}
export function upsertMockUser(user: MockUser): MockUser {
const index = userStore.findIndex((row) => row.id === user.id);
const next = { ...user };
if (index === -1) {
userStore = [...userStore, next];
} else {
userStore = [...userStore.slice(0, index), next, ...userStore.slice(index + 1)];
}
return { ...next };
}
export function resetMockUsers(): void {
userStore = seedUsers.map((user) => ({ ...user }));
}

68
src/mocks/vendors.ts Normal file
View file

@ -0,0 +1,68 @@
import { areMocksEnabled } from "@/mocks/env";
export type MockPaymentMethod = "check" | "ach";
export type MockVendor = {
id: string;
name: string;
email: string;
defaultPaymentMethod: MockPaymentMethod;
};
const seedVendors: MockVendor[] = [
{
id: "vendor-1",
name: "Acme Supplies Co",
email: "billing@acmesupplies.example",
defaultPaymentMethod: "check",
},
{
id: "vendor-2",
name: "Harbor Maintenance LLC",
email: "ap@harbormaintenance.example",
defaultPaymentMethod: "ach",
},
{
id: "vendor-3",
name: "Northwind Utilities",
email: "invoices@northwind.example",
defaultPaymentMethod: "check",
},
{
id: "vendor-4",
name: "Coastal Office Supply",
email: "accounts@coastaloffice.example",
defaultPaymentMethod: "ach",
},
];
/** @deprecated Prefer getMockVendors(); kept for seed reference. */
export const mockVendors: MockVendor[] = seedVendors.map((vendor) => ({ ...vendor }));
let vendorStore: MockVendor[] = seedVendors.map((vendor) => ({ ...vendor }));
export function getMockVendors(): MockVendor[] {
if (!areMocksEnabled()) {
return [];
}
return vendorStore.map((vendor) => ({ ...vendor }));
}
export function getMockVendor(id: string): MockVendor | undefined {
return getMockVendors().find((vendor) => vendor.id === id);
}
export function upsertMockVendor(vendor: MockVendor): MockVendor {
const index = vendorStore.findIndex((row) => row.id === vendor.id);
const next = { ...vendor };
if (index === -1) {
vendorStore = [...vendorStore, next];
} else {
vendorStore = [...vendorStore.slice(0, index), next, ...vendorStore.slice(index + 1)];
}
return { ...next };
}
export function resetMockVendors(): void {
vendorStore = seedVendors.map((vendor) => ({ ...vendor }));
}

10
src/pages/_app.tsx Normal file
View file

@ -0,0 +1,10 @@
import { Outlet } from "react-router";
import { AppShell } from "@/layout/AppShell";
export default function App() {
return (
<AppShell>
<Outlet />
</AppShell>
);
}

View file

@ -0,0 +1,143 @@
import { useState } from "react";
import {
Alert,
Box,
Button,
CircularProgress,
Container,
Snackbar,
Stack,
Typography,
} from "@mui/material";
import type { GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { MockDataGrid } from "@/components/MockDataGrid";
import { usePendingApprovals, useUpdateApprovalStatus } from "@/domain/approvals/use-approvals";
import type { MockApprovalItem } from "@/mocks";
const currencyFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
const dateFormatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
function formatIsoDate(value: string): string {
const parsed = new Date(`${value}T00:00:00`);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return dateFormatter.format(parsed);
}
export default function ApprovalsInboxPage() {
const { data = [], isLoading, isError, error } = usePendingApprovals();
const updateStatus = useUpdateApprovalStatus();
const [snackbar, setSnackbar] = useState<string | null>(null);
const columns: GridColDef<MockApprovalItem>[] = [
{ field: "invoiceNumber", headerName: "Invoice #", flex: 1, minWidth: 120 },
{ field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
{
field: "amount",
headerName: "Amount",
flex: 1,
minWidth: 120,
type: "number",
valueFormatter: (value: number) => currencyFormatter.format(value),
},
{
field: "submittedAt",
headerName: "Submitted",
flex: 1,
minWidth: 130,
valueFormatter: (value: string) => formatIsoDate(value),
},
{
field: "actions",
headerName: "Actions",
flex: 1.2,
minWidth: 180,
sortable: false,
filterable: false,
renderCell: (params: GridRenderCellParams<MockApprovalItem>) => (
<Stack direction="row" spacing={1} alignItems="center" sx={{ height: "100%" }}>
<Button
size="small"
variant="contained"
data-testid={`approve-${params.row.id}`}
disabled={updateStatus.isPending}
onClick={(event) => {
event.stopPropagation();
void updateStatus.mutateAsync({ id: params.row.id, status: "approved" }).then(() => {
setSnackbar(`Approved ${params.row.invoiceNumber} (mock)`);
});
}}
>
Approve
</Button>
<Button
size="small"
variant="outlined"
color="error"
data-testid={`reject-${params.row.id}`}
disabled={updateStatus.isPending}
onClick={(event) => {
event.stopPropagation();
void updateStatus.mutateAsync({ id: params.row.id, status: "rejected" }).then(() => {
setSnackbar(`Rejected ${params.row.invoiceNumber} (mock)`);
});
}}
>
Reject
</Button>
</Stack>
),
},
];
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Stack spacing={3}>
<Stack spacing={1}>
<Typography component="h1" variant="h1">
Approvals
</Typography>
<Typography color="text.secondary">Approver inbox of mock pending invoices.</Typography>
</Stack>
{isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
<CircularProgress aria-label="Loading approvals" />
</Box>
) : null}
{isError ? (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load approvals"}
</Alert>
) : null}
{!isLoading && !isError ? (
<MockDataGrid
data-testid="approvals-inbox-grid"
rows={data}
columns={columns}
getRowHeight={() => 56}
/>
) : null}
</Stack>
<Snackbar
open={Boolean(snackbar)}
autoHideDuration={2500}
onClose={() => setSnackbar(null)}
message={snackbar}
data-testid="approvals-snackbar"
/>
</Container>
);
}

View file

@ -3,12 +3,12 @@ import { DashboardGrids } from "@/dashboard/DashboardGrids";
export default function HomePage() { export default function HomePage() {
return ( return (
<Box component="main" sx={{ minHeight: "100vh", py: 4 }}> <Box sx={{ py: 4 }}>
<Container maxWidth="xl"> <Container maxWidth="xl">
<Stack spacing={3}> <Stack spacing={3}>
<Stack spacing={1}> <Stack spacing={1}>
<Typography component="h1" variant="h1"> <Typography component="h1" variant="h1">
Sea Haven AP Dashboard
</Typography> </Typography>
<Typography color="text.secondary">Internal accounts payable automation.</Typography> <Typography color="text.secondary">Internal accounts payable automation.</Typography>
</Stack> </Stack>

View file

@ -0,0 +1,62 @@
import { Container } from "@mui/material";
import { useMemo } from "react";
import {
useApprovalPolicy,
useUpsertApprovalPolicy,
} from "@/domain/approval-policies/use-approval-policies";
import type { MockUserRole } from "@/mocks";
import { useNavigate, useParams } from "@/router";
import { SettingsFormShell, type SettingsFormField } from "@/settings/SettingsFormShell";
const roleOptions = [
{ value: "ap_operator", label: "AP operator" },
{ value: "approver", label: "Approver" },
{ value: "admin", label: "Admin" },
];
const fields: SettingsFormField[] = [
{ name: "name", label: "Name" },
{ name: "amountThreshold", label: "Amount threshold", type: "number" },
{ name: "approverRole", label: "Approver role", type: "select", options: roleOptions },
];
export default function ApprovalPolicyEditPage() {
const navigate = useNavigate();
const { id } = useParams("/settings/approval-policies/:id");
const { data, isLoading, isError, error } = useApprovalPolicy(id);
const upsert = useUpsertApprovalPolicy();
const initialValues = useMemo(
() => ({
name: data?.name ?? "",
amountThreshold: data ? String(data.amountThreshold) : "",
approverRole: data?.approverRole ?? "approver",
}),
[data],
);
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<SettingsFormShell
title="Edit approval policy"
testId="settings-approval-policy-form"
fields={fields}
initialValues={initialValues}
isLoading={isLoading}
isError={isError}
errorMessage={error instanceof Error ? error.message : undefined}
notFound={!isLoading && !data}
onCancel={() => navigate("/settings/approval-policies")}
onSave={async (values) => {
await upsert.mutateAsync({
id,
name: values.name.trim(),
amountThreshold: Number(values.amountThreshold),
approverRole: values.approverRole as MockUserRole,
});
navigate("/settings/approval-policies");
}}
/>
</Container>
);
}

View file

@ -0,0 +1,48 @@
import { Container } from "@mui/material";
import type { GridColDef } from "@mui/x-data-grid";
import { useApprovalPolicies } from "@/domain/approval-policies/use-approval-policies";
import type { MockApprovalPolicy } from "@/mocks";
import { useNavigate } from "@/router";
import { SettingsListPage } from "@/settings/SettingsListPage";
const currencyFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
const columns: GridColDef<MockApprovalPolicy>[] = [
{ field: "name", headerName: "Name", flex: 1.4, minWidth: 180 },
{
field: "amountThreshold",
headerName: "Threshold",
flex: 1,
minWidth: 120,
type: "number",
valueFormatter: (value: number) => currencyFormatter.format(value),
},
{ field: "approverRole", headerName: "Approver role", flex: 1, minWidth: 130 },
];
export default function ApprovalPoliciesListPage() {
const navigate = useNavigate();
const { data = [], isLoading, isError, error } = useApprovalPolicies();
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<SettingsListPage
title="Approval policies"
description="Mock threshold and role rules."
testId="settings-approval-policies-grid"
rows={data}
columns={columns}
isLoading={isLoading}
isError={isError}
error={error instanceof Error ? error : null}
onNew={() => navigate("/settings/approval-policies/new")}
onRowClick={(row) =>
navigate("/settings/approval-policies/:id", { params: { id: row.id } })
}
/>
</Container>
);
}

View file

@ -0,0 +1,47 @@
import { Container } from "@mui/material";
import { useUpsertApprovalPolicy } from "@/domain/approval-policies/use-approval-policies";
import type { MockUserRole } from "@/mocks";
import { useNavigate } from "@/router";
import { SettingsFormShell, type SettingsFormField } from "@/settings/SettingsFormShell";
const roleOptions = [
{ value: "ap_operator", label: "AP operator" },
{ value: "approver", label: "Approver" },
{ value: "admin", label: "Admin" },
];
const fields: SettingsFormField[] = [
{ name: "name", label: "Name" },
{ name: "amountThreshold", label: "Amount threshold", type: "number" },
{ name: "approverRole", label: "Approver role", type: "select", options: roleOptions },
];
function createId(): string {
return `policy-${crypto.randomUUID().slice(0, 8)}`;
}
export default function ApprovalPolicyNewPage() {
const navigate = useNavigate();
const upsert = useUpsertApprovalPolicy();
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<SettingsFormShell
title="New approval policy"
testId="settings-approval-policy-form"
fields={fields}
initialValues={{ name: "", amountThreshold: "1000", approverRole: "approver" }}
onCancel={() => navigate("/settings/approval-policies")}
onSave={async (values) => {
await upsert.mutateAsync({
id: createId(),
name: values.name.trim(),
amountThreshold: Number(values.amountThreshold),
approverRole: values.approverRole as MockUserRole,
});
navigate("/settings/approval-policies");
}}
/>
</Container>
);
}

View file

@ -0,0 +1,49 @@
import { Container } from "@mui/material";
import { useMemo } from "react";
import { useDepartment, useUpsertDepartment } from "@/domain/departments/use-departments";
import { useNavigate, useParams } from "@/router";
import { SettingsFormShell, type SettingsFormField } from "@/settings/SettingsFormShell";
const fields: SettingsFormField[] = [
{ name: "code", label: "Code" },
{ name: "name", label: "Name" },
];
export default function DepartmentEditPage() {
const navigate = useNavigate();
const { id } = useParams("/settings/departments/:id");
const { data, isLoading, isError, error } = useDepartment(id);
const upsert = useUpsertDepartment();
const initialValues = useMemo(
() => ({
code: data?.code ?? "",
name: data?.name ?? "",
}),
[data],
);
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<SettingsFormShell
title="Edit department"
testId="settings-department-form"
fields={fields}
initialValues={initialValues}
isLoading={isLoading}
isError={isError}
errorMessage={error instanceof Error ? error.message : undefined}
notFound={!isLoading && !data}
onCancel={() => navigate("/settings/departments")}
onSave={async (values) => {
await upsert.mutateAsync({
id,
code: values.code.trim(),
name: values.name.trim(),
});
navigate("/settings/departments");
}}
/>
</Container>
);
}

View file

@ -0,0 +1,33 @@
import { Container } from "@mui/material";
import type { GridColDef } from "@mui/x-data-grid";
import { useDepartments } from "@/domain/departments/use-departments";
import type { MockDepartment } from "@/mocks";
import { useNavigate } from "@/router";
import { SettingsListPage } from "@/settings/SettingsListPage";
const columns: GridColDef<MockDepartment>[] = [
{ field: "code", headerName: "Code", flex: 0.8, minWidth: 100 },
{ field: "name", headerName: "Name", flex: 1.6, minWidth: 180 },
];
export default function DepartmentsListPage() {
const navigate = useNavigate();
const { data = [], isLoading, isError, error } = useDepartments();
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<SettingsListPage
title="Departments"
description="Mock department codes."
testId="settings-departments-grid"
rows={data}
columns={columns}
isLoading={isLoading}
isError={isError}
error={error instanceof Error ? error : null}
onNew={() => navigate("/settings/departments/new")}
onRowClick={(row) => navigate("/settings/departments/:id", { params: { id: row.id } })}
/>
</Container>
);
}

View file

@ -0,0 +1,38 @@
import { Container } from "@mui/material";
import { useUpsertDepartment } from "@/domain/departments/use-departments";
import { useNavigate } from "@/router";
import { SettingsFormShell, type SettingsFormField } from "@/settings/SettingsFormShell";
const fields: SettingsFormField[] = [
{ name: "code", label: "Code" },
{ name: "name", label: "Name" },
];
function createId(): string {
return `dept-${crypto.randomUUID().slice(0, 8)}`;
}
export default function DepartmentNewPage() {
const navigate = useNavigate();
const upsert = useUpsertDepartment();
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<SettingsFormShell
title="New department"
testId="settings-department-form"
fields={fields}
initialValues={{ code: "", name: "" }}
onCancel={() => navigate("/settings/departments")}
onSave={async (values) => {
await upsert.mutateAsync({
id: createId(),
code: values.code.trim(),
name: values.name.trim(),
});
navigate("/settings/departments");
}}
/>
</Container>
);
}

View file

@ -0,0 +1,49 @@
import { Container } from "@mui/material";
import { useMemo } from "react";
import { useGlAccount, useUpsertGlAccount } from "@/domain/gl-accounts/use-gl-accounts";
import { useNavigate, useParams } from "@/router";
import { SettingsFormShell, type SettingsFormField } from "@/settings/SettingsFormShell";
const fields: SettingsFormField[] = [
{ name: "code", label: "Code" },
{ name: "name", label: "Name" },
];
export default function GlAccountEditPage() {
const navigate = useNavigate();
const { id } = useParams("/settings/gl-accounts/:id");
const { data, isLoading, isError, error } = useGlAccount(id);
const upsert = useUpsertGlAccount();
const initialValues = useMemo(
() => ({
code: data?.code ?? "",
name: data?.name ?? "",
}),
[data],
);
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<SettingsFormShell
title="Edit GL account"
testId="settings-gl-account-form"
fields={fields}
initialValues={initialValues}
isLoading={isLoading}
isError={isError}
errorMessage={error instanceof Error ? error.message : undefined}
notFound={!isLoading && !data}
onCancel={() => navigate("/settings/gl-accounts")}
onSave={async (values) => {
await upsert.mutateAsync({
id,
code: values.code.trim(),
name: values.name.trim(),
});
navigate("/settings/gl-accounts");
}}
/>
</Container>
);
}

View file

@ -0,0 +1,33 @@
import { Container } from "@mui/material";
import type { GridColDef } from "@mui/x-data-grid";
import { useGlAccounts } from "@/domain/gl-accounts/use-gl-accounts";
import type { MockGlAccount } from "@/mocks";
import { useNavigate } from "@/router";
import { SettingsListPage } from "@/settings/SettingsListPage";
const columns: GridColDef<MockGlAccount>[] = [
{ field: "code", headerName: "Code", flex: 0.8, minWidth: 100 },
{ field: "name", headerName: "Name", flex: 1.6, minWidth: 180 },
];
export default function GlAccountsListPage() {
const navigate = useNavigate();
const { data = [], isLoading, isError, error } = useGlAccounts();
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<SettingsListPage
title="GL accounts"
description="Mock general ledger accounts."
testId="settings-gl-accounts-grid"
rows={data}
columns={columns}
isLoading={isLoading}
isError={isError}
error={error instanceof Error ? error : null}
onNew={() => navigate("/settings/gl-accounts/new")}
onRowClick={(row) => navigate("/settings/gl-accounts/:id", { params: { id: row.id } })}
/>
</Container>
);
}

View file

@ -0,0 +1,38 @@
import { Container } from "@mui/material";
import { useUpsertGlAccount } from "@/domain/gl-accounts/use-gl-accounts";
import { useNavigate } from "@/router";
import { SettingsFormShell, type SettingsFormField } from "@/settings/SettingsFormShell";
const fields: SettingsFormField[] = [
{ name: "code", label: "Code" },
{ name: "name", label: "Name" },
];
function createId(): string {
return `gl-${crypto.randomUUID().slice(0, 8)}`;
}
export default function GlAccountNewPage() {
const navigate = useNavigate();
const upsert = useUpsertGlAccount();
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<SettingsFormShell
title="New GL account"
testId="settings-gl-account-form"
fields={fields}
initialValues={{ code: "", name: "" }}
onCancel={() => navigate("/settings/gl-accounts")}
onSave={async (values) => {
await upsert.mutateAsync({
id: createId(),
code: values.code.trim(),
name: values.name.trim(),
});
navigate("/settings/gl-accounts");
}}
/>
</Container>
);
}

View file

@ -0,0 +1,73 @@
import { Container, List, ListItemButton, ListItemText, Stack, Typography } from "@mui/material";
import { useNavigate } from "@/router";
const settingsAreas = [
{
label: "Vendors",
description: "Vendor master data for payables.",
to: "/settings/vendors" as const,
testId: "settings-link-vendors",
},
{
label: "GL accounts",
description: "General ledger coding targets.",
to: "/settings/gl-accounts" as const,
testId: "settings-link-gl-accounts",
},
{
label: "Departments",
description: "Department codes for allocation.",
to: "/settings/departments" as const,
testId: "settings-link-departments",
},
{
label: "Users",
description: "People who operate and approve invoices.",
to: "/settings/users" as const,
testId: "settings-link-users",
},
{
label: "Approval policies",
description: "Threshold and role rules for approvals.",
to: "/settings/approval-policies" as const,
testId: "settings-link-approval-policies",
},
];
export default function SettingsHubPage() {
const navigate = useNavigate();
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<Stack spacing={3}>
<Stack spacing={1}>
<Typography component="h1" variant="h1">
Settings
</Typography>
<Typography color="text.secondary">
Mock CRUD shells for master data used across Sea Haven AP.
</Typography>
</Stack>
<List data-testid="settings-hub">
{settingsAreas.map((area) => (
<ListItemButton
key={area.to}
onClick={() => navigate(area.to)}
data-testid={area.testId}
sx={{
border: 1,
borderColor: "divider",
borderRadius: 1,
mb: 1,
bgcolor: "background.paper",
}}
>
<ListItemText primary={area.label} secondary={area.description} />
</ListItemButton>
))}
</List>
</Stack>
</Container>
);
}

View file

@ -0,0 +1,59 @@
import { Container } from "@mui/material";
import { useMemo } from "react";
import { useUpsertUser, useUser } from "@/domain/users/use-users";
import type { MockUserRole } from "@/mocks";
import { useNavigate, useParams } from "@/router";
import { SettingsFormShell, type SettingsFormField } from "@/settings/SettingsFormShell";
const roleOptions = [
{ value: "ap_operator", label: "AP operator" },
{ value: "approver", label: "Approver" },
{ value: "admin", label: "Admin" },
];
const fields: SettingsFormField[] = [
{ name: "name", label: "Name" },
{ name: "email", label: "Email", type: "email" },
{ name: "role", label: "Role", type: "select", options: roleOptions },
];
export default function UserEditPage() {
const navigate = useNavigate();
const { id } = useParams("/settings/users/:id");
const { data, isLoading, isError, error } = useUser(id);
const upsert = useUpsertUser();
const initialValues = useMemo(
() => ({
name: data?.name ?? "",
email: data?.email ?? "",
role: data?.role ?? "ap_operator",
}),
[data],
);
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<SettingsFormShell
title="Edit user"
testId="settings-user-form"
fields={fields}
initialValues={initialValues}
isLoading={isLoading}
isError={isError}
errorMessage={error instanceof Error ? error.message : undefined}
notFound={!isLoading && !data}
onCancel={() => navigate("/settings/users")}
onSave={async (values) => {
await upsert.mutateAsync({
id,
name: values.name.trim(),
email: values.email.trim(),
role: values.role as MockUserRole,
});
navigate("/settings/users");
}}
/>
</Container>
);
}

View file

@ -0,0 +1,34 @@
import { Container } from "@mui/material";
import type { GridColDef } from "@mui/x-data-grid";
import { useUsers } from "@/domain/users/use-users";
import type { MockUser } from "@/mocks";
import { useNavigate } from "@/router";
import { SettingsListPage } from "@/settings/SettingsListPage";
const columns: GridColDef<MockUser>[] = [
{ field: "name", headerName: "Name", flex: 1.2, minWidth: 140 },
{ field: "email", headerName: "Email", flex: 1.4, minWidth: 180 },
{ field: "role", headerName: "Role", flex: 1, minWidth: 120 },
];
export default function UsersListPage() {
const navigate = useNavigate();
const { data = [], isLoading, isError, error } = useUsers();
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<SettingsListPage
title="Users"
description="Mock operators, approvers, and admins."
testId="settings-users-grid"
rows={data}
columns={columns}
isLoading={isLoading}
isError={isError}
error={error instanceof Error ? error : null}
onNew={() => navigate("/settings/users/new")}
onRowClick={(row) => navigate("/settings/users/:id", { params: { id: row.id } })}
/>
</Container>
);
}

View file

@ -0,0 +1,47 @@
import { Container } from "@mui/material";
import { useUpsertUser } from "@/domain/users/use-users";
import type { MockUserRole } from "@/mocks";
import { useNavigate } from "@/router";
import { SettingsFormShell, type SettingsFormField } from "@/settings/SettingsFormShell";
const roleOptions = [
{ value: "ap_operator", label: "AP operator" },
{ value: "approver", label: "Approver" },
{ value: "admin", label: "Admin" },
];
const fields: SettingsFormField[] = [
{ name: "name", label: "Name" },
{ name: "email", label: "Email", type: "email" },
{ name: "role", label: "Role", type: "select", options: roleOptions },
];
function createId(): string {
return `user-${crypto.randomUUID().slice(0, 8)}`;
}
export default function UserNewPage() {
const navigate = useNavigate();
const upsert = useUpsertUser();
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<SettingsFormShell
title="New user"
testId="settings-user-form"
fields={fields}
initialValues={{ name: "", email: "", role: "ap_operator" }}
onCancel={() => navigate("/settings/users")}
onSave={async (values) => {
await upsert.mutateAsync({
id: createId(),
name: values.name.trim(),
email: values.email.trim(),
role: values.role as MockUserRole,
});
navigate("/settings/users");
}}
/>
</Container>
);
}

60
src/pages/settings/vendors/[id].tsx vendored Normal file
View file

@ -0,0 +1,60 @@
import { Container } from "@mui/material";
import { useMemo } from "react";
import { useUpsertVendor, useVendor } from "@/domain/vendors/use-vendors";
import { useNavigate, useParams } from "@/router";
import { SettingsFormShell, type SettingsFormField } from "@/settings/SettingsFormShell";
const fields: SettingsFormField[] = [
{ name: "name", label: "Name" },
{ name: "email", label: "Email", type: "email" },
{
name: "defaultPaymentMethod",
label: "Default payment method",
type: "select",
options: [
{ value: "check", label: "Check" },
{ value: "ach", label: "ACH" },
],
},
];
export default function VendorEditPage() {
const navigate = useNavigate();
const { id } = useParams("/settings/vendors/:id");
const { data, isLoading, isError, error } = useVendor(id);
const upsert = useUpsertVendor();
const initialValues = useMemo(
() => ({
name: data?.name ?? "",
email: data?.email ?? "",
defaultPaymentMethod: data?.defaultPaymentMethod ?? "check",
}),
[data],
);
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<SettingsFormShell
title="Edit vendor"
testId="settings-vendor-form"
fields={fields}
initialValues={initialValues}
isLoading={isLoading}
isError={isError}
errorMessage={error instanceof Error ? error.message : undefined}
notFound={!isLoading && !data}
onCancel={() => navigate("/settings/vendors")}
onSave={async (values) => {
await upsert.mutateAsync({
id,
name: values.name.trim(),
email: values.email.trim(),
defaultPaymentMethod: values.defaultPaymentMethod as "check" | "ach",
});
navigate("/settings/vendors");
}}
/>
</Container>
);
}

34
src/pages/settings/vendors/index.tsx vendored Normal file
View file

@ -0,0 +1,34 @@
import { Container } from "@mui/material";
import type { GridColDef } from "@mui/x-data-grid";
import { useVendors } from "@/domain/vendors/use-vendors";
import type { MockVendor } from "@/mocks";
import { useNavigate } from "@/router";
import { SettingsListPage } from "@/settings/SettingsListPage";
const columns: GridColDef<MockVendor>[] = [
{ field: "name", headerName: "Name", flex: 1.4, minWidth: 160 },
{ field: "email", headerName: "Email", flex: 1.4, minWidth: 180 },
{ field: "defaultPaymentMethod", headerName: "Payment method", flex: 1, minWidth: 130 },
];
export default function VendorsListPage() {
const navigate = useNavigate();
const { data = [], isLoading, isError, error } = useVendors();
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<SettingsListPage
title="Vendors"
description="Mock vendor master data."
testId="settings-vendors-grid"
rows={data}
columns={columns}
isLoading={isLoading}
isError={isError}
error={error instanceof Error ? error : null}
onNew={() => navigate("/settings/vendors/new")}
onRowClick={(row) => navigate("/settings/vendors/:id", { params: { id: row.id } })}
/>
</Container>
);
}

48
src/pages/settings/vendors/new.tsx vendored Normal file
View file

@ -0,0 +1,48 @@
import { Container } from "@mui/material";
import { useUpsertVendor } from "@/domain/vendors/use-vendors";
import { useNavigate } from "@/router";
import { SettingsFormShell, type SettingsFormField } from "@/settings/SettingsFormShell";
const fields: SettingsFormField[] = [
{ name: "name", label: "Name" },
{ name: "email", label: "Email", type: "email" },
{
name: "defaultPaymentMethod",
label: "Default payment method",
type: "select",
options: [
{ value: "check", label: "Check" },
{ value: "ach", label: "ACH" },
],
},
];
function createId(): string {
return `vendor-${crypto.randomUUID().slice(0, 8)}`;
}
export default function VendorNewPage() {
const navigate = useNavigate();
const upsert = useUpsertVendor();
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<SettingsFormShell
title="New vendor"
testId="settings-vendor-form"
fields={fields}
initialValues={{ name: "", email: "", defaultPaymentMethod: "check" }}
onCancel={() => navigate("/settings/vendors")}
onSave={async (values) => {
await upsert.mutateAsync({
id: createId(),
name: values.name.trim(),
email: values.email.trim(),
defaultPaymentMethod: values.defaultPaymentMethod as "check" | "ach",
});
navigate("/settings/vendors");
}}
/>
</Container>
);
}

View file

@ -0,0 +1,201 @@
import {
Alert,
Box,
Button,
CircularProgress,
MenuItem,
Snackbar,
Stack,
TextField,
Typography,
} from "@mui/material";
import { useState, type ReactNode } from "react";
export type SettingsFormField =
| {
name: string;
label: string;
type?: "text" | "email" | "number";
required?: boolean;
}
| {
name: string;
label: string;
type: "select";
required?: boolean;
options: Array<{ value: string; label: string }>;
};
type SettingsFormShellProps = {
title: string;
testId: string;
fields: SettingsFormField[];
initialValues: Record<string, string>;
formKey?: string;
isLoading?: boolean;
isError?: boolean;
errorMessage?: string;
notFound?: boolean;
onCancel: () => void;
onSave: (values: Record<string, string>) => Promise<void>;
extra?: ReactNode;
};
type SettingsFormFieldsProps = {
title: string;
testId: string;
fields: SettingsFormField[];
initialValues: Record<string, string>;
onCancel: () => void;
onSave: (values: Record<string, string>) => Promise<void>;
extra?: ReactNode;
};
function SettingsFormFields({
title,
testId,
fields,
initialValues,
onCancel,
onSave,
extra,
}: SettingsFormFieldsProps) {
const [values, setValues] = useState(initialValues);
const [saving, setSaving] = useState(false);
const [snackbar, setSnackbar] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const handleSave = async () => {
for (const field of fields) {
if (field.required !== false && !values[field.name]?.trim()) {
setValidationError(`${field.label} is required.`);
return;
}
}
setValidationError(null);
setSaving(true);
try {
await onSave(values);
setSnackbar("Saved to mock store");
} catch (error) {
setValidationError(error instanceof Error ? error.message : "Save failed");
} finally {
setSaving(false);
}
};
return (
<Stack spacing={3} data-testid={testId}>
<Typography component="h1" variant="h1">
{title}
</Typography>
{validationError ? <Alert severity="error">{validationError}</Alert> : null}
<Stack spacing={2} maxWidth={480}>
{fields.map((field) => {
if (field.type === "select") {
return (
<TextField
key={field.name}
select
label={field.label}
value={values[field.name] ?? ""}
onChange={(event) =>
setValues((current) => ({ ...current, [field.name]: event.target.value }))
}
required={field.required !== false}
data-testid={`${testId}-field-${field.name}`}
>
{field.options.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
);
}
return (
<TextField
key={field.name}
label={field.label}
type={field.type ?? "text"}
value={values[field.name] ?? ""}
onChange={(event) =>
setValues((current) => ({ ...current, [field.name]: event.target.value }))
}
required={field.required !== false}
data-testid={`${testId}-field-${field.name}`}
/>
);
})}
{extra}
</Stack>
<Stack direction="row" spacing={1}>
<Button variant="contained" onClick={() => void handleSave()} disabled={saving}>
Save
</Button>
<Button variant="outlined" onClick={onCancel} disabled={saving}>
Cancel
</Button>
</Stack>
<Snackbar
open={Boolean(snackbar)}
autoHideDuration={2500}
onClose={() => setSnackbar(null)}
message={snackbar}
data-testid={`${testId}-snackbar`}
/>
</Stack>
);
}
export function SettingsFormShell({
title,
testId,
fields,
initialValues,
formKey = "new",
isLoading = false,
isError = false,
errorMessage,
notFound = false,
onCancel,
onSave,
extra,
}: SettingsFormShellProps) {
if (isLoading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
<CircularProgress aria-label={`Loading ${title}`} />
</Box>
);
}
if (isError) {
return (
<Alert severity="error">{errorMessage ?? `Failed to load ${title.toLowerCase()}`}</Alert>
);
}
if (notFound) {
return <Alert severity="warning">Record not found.</Alert>;
}
return (
<SettingsFormFields
key={formKey}
title={title}
testId={testId}
fields={fields}
initialValues={initialValues}
onCancel={onCancel}
onSave={onSave}
extra={extra}
/>
);
}

View file

@ -0,0 +1,78 @@
import { Alert, Box, Button, CircularProgress, Stack, Typography } from "@mui/material";
import type { GridColDef, GridRowParams, GridValidRowModel } from "@mui/x-data-grid";
import type { ReactNode } from "react";
import { MockDataGrid } from "@/components/MockDataGrid";
type SettingsListPageProps<R extends GridValidRowModel> = {
title: string;
description?: string;
testId: string;
rows: R[];
columns: GridColDef<R>[];
isLoading: boolean;
isError: boolean;
error: Error | null;
onNew: () => void;
onRowClick: (row: R) => void;
actions?: ReactNode;
};
export function SettingsListPage<R extends GridValidRowModel & { id: string }>({
title,
description,
testId,
rows,
columns,
isLoading,
isError,
error,
onNew,
onRowClick,
actions,
}: SettingsListPageProps<R>) {
if (isLoading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
<CircularProgress aria-label={`Loading ${title}`} />
</Box>
);
}
if (isError) {
return (
<Alert severity="error">{error?.message ?? `Failed to load ${title.toLowerCase()}`}</Alert>
);
}
return (
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={2}>
<Stack spacing={0.5}>
<Typography component="h1" variant="h1">
{title}
</Typography>
{description ? <Typography color="text.secondary">{description}</Typography> : null}
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
{actions}
<Button variant="contained" onClick={onNew} data-testid={`${testId}-new`}>
New
</Button>
</Stack>
</Stack>
<MockDataGrid
data-testid={testId}
rows={rows}
columns={columns}
onRowClick={(params: GridRowParams<R>) => onRowClick(params.row)}
sx={{
border: 1,
borderColor: "divider",
backgroundColor: "background.paper",
cursor: "pointer",
}}
/>
</Stack>
);
}