From fd903c6367d3fbcdd795f8429a5120c06c78859f Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:00:51 -0400 Subject: [PATCH] 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. --- e2e/settings-approvals.spec.ts | 61 +++++ package.json | 3 +- scripts/generate-router.mjs | 10 + src/components/MockDataGrid.tsx | 31 +++ src/dashboard/InvoiceDataGrid.tsx | 32 +-- src/domain/approval-policies/query-keys.ts | 3 + .../use-approval-policies.ts | 39 ++++ src/domain/approvals/query-keys.ts | 3 + src/domain/approvals/use-approvals.ts | 26 +++ src/domain/departments/query-keys.ts | 3 + src/domain/departments/use-departments.ts | 39 ++++ src/domain/gl-accounts/query-keys.ts | 3 + src/domain/gl-accounts/use-gl-accounts.ts | 39 ++++ src/domain/users/query-keys.ts | 3 + src/domain/users/use-users.ts | 34 +++ src/domain/vendors/query-keys.ts | 3 + src/domain/vendors/use-vendors.ts | 34 +++ src/layout/AppShell.tsx | 107 +++++++++ src/mocks/approval-policies.ts | 58 +++++ src/mocks/approvals.ts | 74 ++++++ src/mocks/departments.ts | 46 ++++ src/mocks/env.ts | 4 + src/mocks/gl-accounts.ts | 42 ++++ src/mocks/index.test.ts | 79 +++++++ src/mocks/index.ts | 221 +++++------------- src/mocks/invoices.ts | 145 ++++++++++++ src/mocks/users.ts | 65 ++++++ src/mocks/vendors.ts | 68 ++++++ src/pages/_app.tsx | 10 + src/pages/approvals/index.tsx | 143 ++++++++++++ src/pages/index.tsx | 4 +- src/pages/settings/approval-policies/[id].tsx | 62 +++++ .../settings/approval-policies/index.tsx | 48 ++++ src/pages/settings/approval-policies/new.tsx | 47 ++++ src/pages/settings/departments/[id].tsx | 49 ++++ src/pages/settings/departments/index.tsx | 33 +++ src/pages/settings/departments/new.tsx | 38 +++ src/pages/settings/gl-accounts/[id].tsx | 49 ++++ src/pages/settings/gl-accounts/index.tsx | 33 +++ src/pages/settings/gl-accounts/new.tsx | 38 +++ src/pages/settings/index.tsx | 73 ++++++ src/pages/settings/users/[id].tsx | 59 +++++ src/pages/settings/users/index.tsx | 34 +++ src/pages/settings/users/new.tsx | 47 ++++ src/pages/settings/vendors/[id].tsx | 60 +++++ src/pages/settings/vendors/index.tsx | 34 +++ src/pages/settings/vendors/new.tsx | 48 ++++ src/settings/SettingsFormShell.tsx | 201 ++++++++++++++++ src/settings/SettingsListPage.tsx | 78 +++++++ 49 files changed, 2262 insertions(+), 201 deletions(-) create mode 100644 e2e/settings-approvals.spec.ts create mode 100644 scripts/generate-router.mjs create mode 100644 src/components/MockDataGrid.tsx create mode 100644 src/domain/approval-policies/query-keys.ts create mode 100644 src/domain/approval-policies/use-approval-policies.ts create mode 100644 src/domain/approvals/query-keys.ts create mode 100644 src/domain/approvals/use-approvals.ts create mode 100644 src/domain/departments/query-keys.ts create mode 100644 src/domain/departments/use-departments.ts create mode 100644 src/domain/gl-accounts/query-keys.ts create mode 100644 src/domain/gl-accounts/use-gl-accounts.ts create mode 100644 src/domain/users/query-keys.ts create mode 100644 src/domain/users/use-users.ts create mode 100644 src/domain/vendors/query-keys.ts create mode 100644 src/domain/vendors/use-vendors.ts create mode 100644 src/layout/AppShell.tsx create mode 100644 src/mocks/approval-policies.ts create mode 100644 src/mocks/approvals.ts create mode 100644 src/mocks/departments.ts create mode 100644 src/mocks/env.ts create mode 100644 src/mocks/gl-accounts.ts create mode 100644 src/mocks/invoices.ts create mode 100644 src/mocks/users.ts create mode 100644 src/mocks/vendors.ts create mode 100644 src/pages/_app.tsx create mode 100644 src/pages/approvals/index.tsx create mode 100644 src/pages/settings/approval-policies/[id].tsx create mode 100644 src/pages/settings/approval-policies/index.tsx create mode 100644 src/pages/settings/approval-policies/new.tsx create mode 100644 src/pages/settings/departments/[id].tsx create mode 100644 src/pages/settings/departments/index.tsx create mode 100644 src/pages/settings/departments/new.tsx create mode 100644 src/pages/settings/gl-accounts/[id].tsx create mode 100644 src/pages/settings/gl-accounts/index.tsx create mode 100644 src/pages/settings/gl-accounts/new.tsx create mode 100644 src/pages/settings/index.tsx create mode 100644 src/pages/settings/users/[id].tsx create mode 100644 src/pages/settings/users/index.tsx create mode 100644 src/pages/settings/users/new.tsx create mode 100644 src/pages/settings/vendors/[id].tsx create mode 100644 src/pages/settings/vendors/index.tsx create mode 100644 src/pages/settings/vendors/new.tsx create mode 100644 src/settings/SettingsFormShell.tsx create mode 100644 src/settings/SettingsListPage.tsx diff --git a/e2e/settings-approvals.spec.ts b/e2e/settings-approvals.spec.ts new file mode 100644 index 0000000..d43a361 --- /dev/null +++ b/e2e/settings-approvals.spec.ts @@ -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(); + } + }); +}); diff --git a/package.json b/package.json index 5c8b699..e3d2df5 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "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", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/generate-router.mjs b/scripts/generate-router.mjs new file mode 100644 index 0000000..29acc4a --- /dev/null +++ b/scripts/generate-router.mjs @@ -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, +}); diff --git a/src/components/MockDataGrid.tsx b/src/components/MockDataGrid.tsx new file mode 100644 index 0000000..b2d4c15 --- /dev/null +++ b/src/components/MockDataGrid.tsx @@ -0,0 +1,31 @@ +import { DataGrid, type DataGridProps, type GridValidRowModel } from "@mui/x-data-grid"; + +type MockDataGridProps = DataGridProps & { + "data-testid": string; +}; + +export function MockDataGrid({ + "data-testid": testId, + ...props +}: MockDataGridProps) { + return ( +
+ row.id as string} + sx={{ + border: 1, + borderColor: "divider", + backgroundColor: "background.paper", + }} + {...props} + /> +
+ ); +} diff --git a/src/dashboard/InvoiceDataGrid.tsx b/src/dashboard/InvoiceDataGrid.tsx index 81f2359..c77a0da 100644 --- a/src/dashboard/InvoiceDataGrid.tsx +++ b/src/dashboard/InvoiceDataGrid.tsx @@ -1,31 +1 @@ -import { DataGrid, type DataGridProps, type GridValidRowModel } from "@mui/x-data-grid"; - -type InvoiceDataGridProps = DataGridProps & { - "data-testid": string; -}; - -export function InvoiceDataGrid({ - "data-testid": testId, - ...props -}: InvoiceDataGridProps) { - return ( -
- row.id as string} - sx={{ - border: 1, - borderColor: "divider", - backgroundColor: "background.paper", - }} - {...props} - /> -
- ); -} +export { MockDataGrid as InvoiceDataGrid } from "@/components/MockDataGrid"; diff --git a/src/domain/approval-policies/query-keys.ts b/src/domain/approval-policies/query-keys.ts new file mode 100644 index 0000000..6f77c72 --- /dev/null +++ b/src/domain/approval-policies/query-keys.ts @@ -0,0 +1,3 @@ +export const approvalPolicyQueryKeys = { + all: ["approval-policies"] as const, +}; diff --git a/src/domain/approval-policies/use-approval-policies.ts b/src/domain/approval-policies/use-approval-policies.ts new file mode 100644 index 0000000..d27a5ad --- /dev/null +++ b/src/domain/approval-policies/use-approval-policies.ts @@ -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 }); + }, + }); +} diff --git a/src/domain/approvals/query-keys.ts b/src/domain/approvals/query-keys.ts new file mode 100644 index 0000000..dece15f --- /dev/null +++ b/src/domain/approvals/query-keys.ts @@ -0,0 +1,3 @@ +export const approvalQueryKeys = { + all: ["approvals"] as const, +}; diff --git a/src/domain/approvals/use-approvals.ts b/src/domain/approvals/use-approvals.ts new file mode 100644 index 0000000..e6d7695 --- /dev/null +++ b/src/domain/approvals/use-approvals.ts @@ -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 }) => + Promise.resolve(updateMockApprovalStatus(id, status)), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: approvalQueryKeys.all }); + }, + }); +} diff --git a/src/domain/departments/query-keys.ts b/src/domain/departments/query-keys.ts new file mode 100644 index 0000000..d8f74d5 --- /dev/null +++ b/src/domain/departments/query-keys.ts @@ -0,0 +1,3 @@ +export const departmentQueryKeys = { + all: ["departments"] as const, +}; diff --git a/src/domain/departments/use-departments.ts b/src/domain/departments/use-departments.ts new file mode 100644 index 0000000..7bc2909 --- /dev/null +++ b/src/domain/departments/use-departments.ts @@ -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 }); + }, + }); +} diff --git a/src/domain/gl-accounts/query-keys.ts b/src/domain/gl-accounts/query-keys.ts new file mode 100644 index 0000000..bb1ecf0 --- /dev/null +++ b/src/domain/gl-accounts/query-keys.ts @@ -0,0 +1,3 @@ +export const glAccountQueryKeys = { + all: ["gl-accounts"] as const, +}; diff --git a/src/domain/gl-accounts/use-gl-accounts.ts b/src/domain/gl-accounts/use-gl-accounts.ts new file mode 100644 index 0000000..29be7fe --- /dev/null +++ b/src/domain/gl-accounts/use-gl-accounts.ts @@ -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 }); + }, + }); +} diff --git a/src/domain/users/query-keys.ts b/src/domain/users/query-keys.ts new file mode 100644 index 0000000..ff999c7 --- /dev/null +++ b/src/domain/users/query-keys.ts @@ -0,0 +1,3 @@ +export const userQueryKeys = { + all: ["users"] as const, +}; diff --git a/src/domain/users/use-users.ts b/src/domain/users/use-users.ts new file mode 100644 index 0000000..4fcabb1 --- /dev/null +++ b/src/domain/users/use-users.ts @@ -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 }); + }, + }); +} diff --git a/src/domain/vendors/query-keys.ts b/src/domain/vendors/query-keys.ts new file mode 100644 index 0000000..b022f34 --- /dev/null +++ b/src/domain/vendors/query-keys.ts @@ -0,0 +1,3 @@ +export const vendorQueryKeys = { + all: ["vendors"] as const, +}; diff --git a/src/domain/vendors/use-vendors.ts b/src/domain/vendors/use-vendors.ts new file mode 100644 index 0000000..24b437e --- /dev/null +++ b/src/domain/vendors/use-vendors.ts @@ -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 }); + }, + }); +} diff --git a/src/layout/AppShell.tsx b/src/layout/AppShell.tsx new file mode 100644 index 0000000..6f96bb2 --- /dev/null +++ b/src/layout/AppShell.tsx @@ -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 ( + + theme.zIndex.drawer + 1, + borderBottom: 1, + borderColor: "divider", + bgcolor: "background.paper", + color: "text.primary", + }} + > + + + Sea Haven AP + + + + + + + + + {navItems.map((item) => { + const selected = + item.to === "/" + ? location.pathname === "/" + : location.pathname === item.to || location.pathname.startsWith(`${item.to}/`); + + return ( + navigate(item.to)} + > + + + ); + })} + + + + + + + {children} + + + ); +} diff --git a/src/mocks/approval-policies.ts b/src/mocks/approval-policies.ts new file mode 100644 index 0000000..67ac8c1 --- /dev/null +++ b/src/mocks/approval-policies.ts @@ -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 })); +} diff --git a/src/mocks/approvals.ts b/src/mocks/approvals.ts new file mode 100644 index 0000000..6a114b9 --- /dev/null +++ b/src/mocks/approvals.ts @@ -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, +): 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 })); +} diff --git a/src/mocks/departments.ts b/src/mocks/departments.ts new file mode 100644 index 0000000..27971c1 --- /dev/null +++ b/src/mocks/departments.ts @@ -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 })); +} diff --git a/src/mocks/env.ts b/src/mocks/env.ts new file mode 100644 index 0000000..2c8cf6a --- /dev/null +++ b/src/mocks/env.ts @@ -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"; +} diff --git a/src/mocks/gl-accounts.ts b/src/mocks/gl-accounts.ts new file mode 100644 index 0000000..3aa6e8d --- /dev/null +++ b/src/mocks/gl-accounts.ts @@ -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 })); +} diff --git a/src/mocks/index.test.ts b/src/mocks/index.test.ts index 8ba47bb..4c5ed17 100644 --- a/src/mocks/index.test.ts +++ b/src/mocks/index.test.ts @@ -1,15 +1,39 @@ import { afterEach, describe, expect, it } from "vitest"; import { areMocksEnabled, + getMockApprovalPolicies, + getMockApprovals, + getMockDepartments, + getMockGlAccounts, getMockInvoices, + getMockPendingApprovals, + getMockUsers, getMockVendors, + resetMockApprovalPolicies, + resetMockApprovals, + resetMockDepartments, + resetMockGlAccounts, resetMockInvoices, + resetMockUsers, + resetMockVendors, + updateMockApprovalStatus, updateMockInvoicePayDate, + upsertMockDepartment, + upsertMockGlAccount, + upsertMockVendor, + upsertMockUser, + upsertMockApprovalPolicy, } from "@/mocks"; describe("mocks module", () => { afterEach(() => { resetMockInvoices(); + resetMockVendors(); + resetMockGlAccounts(); + resetMockDepartments(); + resetMockUsers(); + resetMockApprovalPolicies(); + resetMockApprovals(); }); it("defaults to mocks enabled", () => { @@ -47,4 +71,59 @@ describe("mocks module", () => { /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/, + ); + }); }); diff --git a/src/mocks/index.ts b/src/mocks/index.ts index b432420..117d454 100644 --- a/src/mocks/index.ts +++ b/src/mocks/index.ts @@ -1,167 +1,54 @@ -export type MockVendor = { - id: string; - name: string; -}; - -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"; -}; - -export const mockVendors: MockVendor[] = [ - { id: "vendor-1", name: "Acme Supplies Co" }, - { id: "vendor-2", name: "Harbor Maintenance LLC" }, - { id: "vendor-3", name: "Northwind Utilities" }, - { id: "vendor-4", name: "Coastal Office Supply" }, -]; - -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 })); - -/** 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 })); -} +export { areMocksEnabled } from "@/mocks/env"; +export { + getMockInvoices, + resetMockInvoices, + updateMockInvoicePayDate, + type MockInvoice, + type MockInvoiceStatus, +} from "@/mocks/invoices"; +export { + getMockVendor, + getMockVendors, + mockVendors, + resetMockVendors, + upsertMockVendor, + type MockPaymentMethod, + type MockVendor, +} from "@/mocks/vendors"; +export { + getMockGlAccount, + getMockGlAccounts, + resetMockGlAccounts, + upsertMockGlAccount, + type MockGlAccount, +} from "@/mocks/gl-accounts"; +export { + getMockDepartment, + getMockDepartments, + resetMockDepartments, + upsertMockDepartment, + type MockDepartment, +} from "@/mocks/departments"; +export { + getMockUser, + getMockUsers, + resetMockUsers, + upsertMockUser, + type MockUser, + type MockUserRole, +} from "@/mocks/users"; +export { + getMockApprovalPolicies, + getMockApprovalPolicy, + resetMockApprovalPolicies, + upsertMockApprovalPolicy, + type MockApprovalPolicy, +} from "@/mocks/approval-policies"; +export { + getMockApprovals, + getMockPendingApprovals, + resetMockApprovals, + updateMockApprovalStatus, + type MockApprovalItem, + type MockApprovalStatus, +} from "@/mocks/approvals"; diff --git a/src/mocks/invoices.ts b/src/mocks/invoices.ts new file mode 100644 index 0000000..adaac0b --- /dev/null +++ b/src/mocks/invoices.ts @@ -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 })); +} diff --git a/src/mocks/users.ts b/src/mocks/users.ts new file mode 100644 index 0000000..8891089 --- /dev/null +++ b/src/mocks/users.ts @@ -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 })); +} diff --git a/src/mocks/vendors.ts b/src/mocks/vendors.ts new file mode 100644 index 0000000..ae1268b --- /dev/null +++ b/src/mocks/vendors.ts @@ -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 })); +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx new file mode 100644 index 0000000..ad26712 --- /dev/null +++ b/src/pages/_app.tsx @@ -0,0 +1,10 @@ +import { Outlet } from "react-router"; +import { AppShell } from "@/layout/AppShell"; + +export default function App() { + return ( + + + + ); +} diff --git a/src/pages/approvals/index.tsx b/src/pages/approvals/index.tsx new file mode 100644 index 0000000..60c3afd --- /dev/null +++ b/src/pages/approvals/index.tsx @@ -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(null); + + const columns: GridColDef[] = [ + { 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) => ( + + + + + ), + }, + ]; + + return ( + + + + + Approvals + + Approver inbox of mock pending invoices. + + + {isLoading ? ( + + + + ) : null} + + {isError ? ( + + {error instanceof Error ? error.message : "Failed to load approvals"} + + ) : null} + + {!isLoading && !isError ? ( + 56} + /> + ) : null} + + + setSnackbar(null)} + message={snackbar} + data-testid="approvals-snackbar" + /> + + ); +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index fb1644a..38dd613 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -3,12 +3,12 @@ import { DashboardGrids } from "@/dashboard/DashboardGrids"; export default function HomePage() { return ( - + - Sea Haven AP + Dashboard Internal accounts payable automation. diff --git a/src/pages/settings/approval-policies/[id].tsx b/src/pages/settings/approval-policies/[id].tsx new file mode 100644 index 0000000..5c8d9b3 --- /dev/null +++ b/src/pages/settings/approval-policies/[id].tsx @@ -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 ( + + 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"); + }} + /> + + ); +} diff --git a/src/pages/settings/approval-policies/index.tsx b/src/pages/settings/approval-policies/index.tsx new file mode 100644 index 0000000..26ebe92 --- /dev/null +++ b/src/pages/settings/approval-policies/index.tsx @@ -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[] = [ + { 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 ( + + navigate("/settings/approval-policies/new")} + onRowClick={(row) => + navigate("/settings/approval-policies/:id", { params: { id: row.id } }) + } + /> + + ); +} diff --git a/src/pages/settings/approval-policies/new.tsx b/src/pages/settings/approval-policies/new.tsx new file mode 100644 index 0000000..3151b66 --- /dev/null +++ b/src/pages/settings/approval-policies/new.tsx @@ -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 ( + + 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"); + }} + /> + + ); +} diff --git a/src/pages/settings/departments/[id].tsx b/src/pages/settings/departments/[id].tsx new file mode 100644 index 0000000..95f9938 --- /dev/null +++ b/src/pages/settings/departments/[id].tsx @@ -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 ( + + navigate("/settings/departments")} + onSave={async (values) => { + await upsert.mutateAsync({ + id, + code: values.code.trim(), + name: values.name.trim(), + }); + navigate("/settings/departments"); + }} + /> + + ); +} diff --git a/src/pages/settings/departments/index.tsx b/src/pages/settings/departments/index.tsx new file mode 100644 index 0000000..8c91812 --- /dev/null +++ b/src/pages/settings/departments/index.tsx @@ -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[] = [ + { 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 ( + + navigate("/settings/departments/new")} + onRowClick={(row) => navigate("/settings/departments/:id", { params: { id: row.id } })} + /> + + ); +} diff --git a/src/pages/settings/departments/new.tsx b/src/pages/settings/departments/new.tsx new file mode 100644 index 0000000..a767c89 --- /dev/null +++ b/src/pages/settings/departments/new.tsx @@ -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 ( + + navigate("/settings/departments")} + onSave={async (values) => { + await upsert.mutateAsync({ + id: createId(), + code: values.code.trim(), + name: values.name.trim(), + }); + navigate("/settings/departments"); + }} + /> + + ); +} diff --git a/src/pages/settings/gl-accounts/[id].tsx b/src/pages/settings/gl-accounts/[id].tsx new file mode 100644 index 0000000..67a13f2 --- /dev/null +++ b/src/pages/settings/gl-accounts/[id].tsx @@ -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 ( + + navigate("/settings/gl-accounts")} + onSave={async (values) => { + await upsert.mutateAsync({ + id, + code: values.code.trim(), + name: values.name.trim(), + }); + navigate("/settings/gl-accounts"); + }} + /> + + ); +} diff --git a/src/pages/settings/gl-accounts/index.tsx b/src/pages/settings/gl-accounts/index.tsx new file mode 100644 index 0000000..91e4c1d --- /dev/null +++ b/src/pages/settings/gl-accounts/index.tsx @@ -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[] = [ + { 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 ( + + navigate("/settings/gl-accounts/new")} + onRowClick={(row) => navigate("/settings/gl-accounts/:id", { params: { id: row.id } })} + /> + + ); +} diff --git a/src/pages/settings/gl-accounts/new.tsx b/src/pages/settings/gl-accounts/new.tsx new file mode 100644 index 0000000..8ac91ba --- /dev/null +++ b/src/pages/settings/gl-accounts/new.tsx @@ -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 ( + + navigate("/settings/gl-accounts")} + onSave={async (values) => { + await upsert.mutateAsync({ + id: createId(), + code: values.code.trim(), + name: values.name.trim(), + }); + navigate("/settings/gl-accounts"); + }} + /> + + ); +} diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx new file mode 100644 index 0000000..eb8317f --- /dev/null +++ b/src/pages/settings/index.tsx @@ -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 ( + + + + + Settings + + + Mock CRUD shells for master data used across Sea Haven AP. + + + + + {settingsAreas.map((area) => ( + navigate(area.to)} + data-testid={area.testId} + sx={{ + border: 1, + borderColor: "divider", + borderRadius: 1, + mb: 1, + bgcolor: "background.paper", + }} + > + + + ))} + + + + ); +} diff --git a/src/pages/settings/users/[id].tsx b/src/pages/settings/users/[id].tsx new file mode 100644 index 0000000..9c9141c --- /dev/null +++ b/src/pages/settings/users/[id].tsx @@ -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 ( + + 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"); + }} + /> + + ); +} diff --git a/src/pages/settings/users/index.tsx b/src/pages/settings/users/index.tsx new file mode 100644 index 0000000..e3c8e7e --- /dev/null +++ b/src/pages/settings/users/index.tsx @@ -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[] = [ + { 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 ( + + navigate("/settings/users/new")} + onRowClick={(row) => navigate("/settings/users/:id", { params: { id: row.id } })} + /> + + ); +} diff --git a/src/pages/settings/users/new.tsx b/src/pages/settings/users/new.tsx new file mode 100644 index 0000000..f855278 --- /dev/null +++ b/src/pages/settings/users/new.tsx @@ -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 ( + + 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"); + }} + /> + + ); +} diff --git a/src/pages/settings/vendors/[id].tsx b/src/pages/settings/vendors/[id].tsx new file mode 100644 index 0000000..bf69d40 --- /dev/null +++ b/src/pages/settings/vendors/[id].tsx @@ -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 ( + + 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"); + }} + /> + + ); +} diff --git a/src/pages/settings/vendors/index.tsx b/src/pages/settings/vendors/index.tsx new file mode 100644 index 0000000..57e9e8d --- /dev/null +++ b/src/pages/settings/vendors/index.tsx @@ -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[] = [ + { 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 ( + + navigate("/settings/vendors/new")} + onRowClick={(row) => navigate("/settings/vendors/:id", { params: { id: row.id } })} + /> + + ); +} diff --git a/src/pages/settings/vendors/new.tsx b/src/pages/settings/vendors/new.tsx new file mode 100644 index 0000000..781bc05 --- /dev/null +++ b/src/pages/settings/vendors/new.tsx @@ -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 ( + + 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"); + }} + /> + + ); +} diff --git a/src/settings/SettingsFormShell.tsx b/src/settings/SettingsFormShell.tsx new file mode 100644 index 0000000..c313b46 --- /dev/null +++ b/src/settings/SettingsFormShell.tsx @@ -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; + formKey?: string; + isLoading?: boolean; + isError?: boolean; + errorMessage?: string; + notFound?: boolean; + onCancel: () => void; + onSave: (values: Record) => Promise; + extra?: ReactNode; +}; + +type SettingsFormFieldsProps = { + title: string; + testId: string; + fields: SettingsFormField[]; + initialValues: Record; + onCancel: () => void; + onSave: (values: Record) => Promise; + 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(null); + const [validationError, setValidationError] = useState(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 ( + + + {title} + + + {validationError ? {validationError} : null} + + + {fields.map((field) => { + if (field.type === "select") { + return ( + + setValues((current) => ({ ...current, [field.name]: event.target.value })) + } + required={field.required !== false} + data-testid={`${testId}-field-${field.name}`} + > + {field.options.map((option) => ( + + {option.label} + + ))} + + ); + } + + return ( + + setValues((current) => ({ ...current, [field.name]: event.target.value })) + } + required={field.required !== false} + data-testid={`${testId}-field-${field.name}`} + /> + ); + })} + {extra} + + + + + + + + setSnackbar(null)} + message={snackbar} + data-testid={`${testId}-snackbar`} + /> + + ); +} + +export function SettingsFormShell({ + title, + testId, + fields, + initialValues, + formKey = "new", + isLoading = false, + isError = false, + errorMessage, + notFound = false, + onCancel, + onSave, + extra, +}: SettingsFormShellProps) { + if (isLoading) { + return ( + + + + ); + } + + if (isError) { + return ( + {errorMessage ?? `Failed to load ${title.toLowerCase()}`} + ); + } + + if (notFound) { + return Record not found.; + } + + return ( + + ); +} diff --git a/src/settings/SettingsListPage.tsx b/src/settings/SettingsListPage.tsx new file mode 100644 index 0000000..d91aba7 --- /dev/null +++ b/src/settings/SettingsListPage.tsx @@ -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 = { + title: string; + description?: string; + testId: string; + rows: R[]; + columns: GridColDef[]; + isLoading: boolean; + isError: boolean; + error: Error | null; + onNew: () => void; + onRowClick: (row: R) => void; + actions?: ReactNode; +}; + +export function SettingsListPage({ + title, + description, + testId, + rows, + columns, + isLoading, + isError, + error, + onNew, + onRowClick, + actions, +}: SettingsListPageProps) { + if (isLoading) { + return ( + + + + ); + } + + if (isError) { + return ( + {error?.message ?? `Failed to load ${title.toLowerCase()}`} + ); + } + + return ( + + + + + {title} + + {description ? {description} : null} + + + {actions} + + + + + ) => onRowClick(params.row)} + sx={{ + border: 1, + borderColor: "divider", + backgroundColor: "background.paper", + cursor: "pointer", + }} + /> + + ); +}