mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 05:43:18 +00:00
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:
parent
9c792b0bbb
commit
fd903c6367
49 changed files with 2262 additions and 201 deletions
61
e2e/settings-approvals.spec.ts
Normal file
61
e2e/settings-approvals.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
10
scripts/generate-router.mjs
Normal file
10
scripts/generate-router.mjs
Normal 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,
|
||||
});
|
||||
31
src/components/MockDataGrid.tsx
Normal file
31
src/components/MockDataGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +1 @@
|
|||
import { DataGrid, type DataGridProps, type GridValidRowModel } from "@mui/x-data-grid";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
export { MockDataGrid as InvoiceDataGrid } from "@/components/MockDataGrid";
|
||||
|
|
|
|||
3
src/domain/approval-policies/query-keys.ts
Normal file
3
src/domain/approval-policies/query-keys.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const approvalPolicyQueryKeys = {
|
||||
all: ["approval-policies"] as const,
|
||||
};
|
||||
39
src/domain/approval-policies/use-approval-policies.ts
Normal file
39
src/domain/approval-policies/use-approval-policies.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
3
src/domain/approvals/query-keys.ts
Normal file
3
src/domain/approvals/query-keys.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const approvalQueryKeys = {
|
||||
all: ["approvals"] as const,
|
||||
};
|
||||
26
src/domain/approvals/use-approvals.ts
Normal file
26
src/domain/approvals/use-approvals.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
3
src/domain/departments/query-keys.ts
Normal file
3
src/domain/departments/query-keys.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const departmentQueryKeys = {
|
||||
all: ["departments"] as const,
|
||||
};
|
||||
39
src/domain/departments/use-departments.ts
Normal file
39
src/domain/departments/use-departments.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
3
src/domain/gl-accounts/query-keys.ts
Normal file
3
src/domain/gl-accounts/query-keys.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const glAccountQueryKeys = {
|
||||
all: ["gl-accounts"] as const,
|
||||
};
|
||||
39
src/domain/gl-accounts/use-gl-accounts.ts
Normal file
39
src/domain/gl-accounts/use-gl-accounts.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
3
src/domain/users/query-keys.ts
Normal file
3
src/domain/users/query-keys.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const userQueryKeys = {
|
||||
all: ["users"] as const,
|
||||
};
|
||||
34
src/domain/users/use-users.ts
Normal file
34
src/domain/users/use-users.ts
Normal 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
3
src/domain/vendors/query-keys.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const vendorQueryKeys = {
|
||||
all: ["vendors"] as const,
|
||||
};
|
||||
34
src/domain/vendors/use-vendors.ts
vendored
Normal file
34
src/domain/vendors/use-vendors.ts
vendored
Normal 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
107
src/layout/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/mocks/approval-policies.ts
Normal file
58
src/mocks/approval-policies.ts
Normal 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
74
src/mocks/approvals.ts
Normal 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
46
src/mocks/departments.ts
Normal 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
4
src/mocks/env.ts
Normal 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
42
src/mocks/gl-accounts.ts
Normal 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 }));
|
||||
}
|
||||
|
|
@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
145
src/mocks/invoices.ts
Normal file
145
src/mocks/invoices.ts
Normal 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
65
src/mocks/users.ts
Normal 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
68
src/mocks/vendors.ts
Normal 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
10
src/pages/_app.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Outlet } from "react-router";
|
||||
import { AppShell } from "@/layout/AppShell";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
143
src/pages/approvals/index.tsx
Normal file
143
src/pages/approvals/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,12 +3,12 @@ import { DashboardGrids } from "@/dashboard/DashboardGrids";
|
|||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<Box component="main" sx={{ minHeight: "100vh", py: 4 }}>
|
||||
<Box sx={{ py: 4 }}>
|
||||
<Container maxWidth="xl">
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={1}>
|
||||
<Typography component="h1" variant="h1">
|
||||
Sea Haven AP
|
||||
Dashboard
|
||||
</Typography>
|
||||
<Typography color="text.secondary">Internal accounts payable automation.</Typography>
|
||||
</Stack>
|
||||
|
|
|
|||
62
src/pages/settings/approval-policies/[id].tsx
Normal file
62
src/pages/settings/approval-policies/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/pages/settings/approval-policies/index.tsx
Normal file
48
src/pages/settings/approval-policies/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/pages/settings/approval-policies/new.tsx
Normal file
47
src/pages/settings/approval-policies/new.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/pages/settings/departments/[id].tsx
Normal file
49
src/pages/settings/departments/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/pages/settings/departments/index.tsx
Normal file
33
src/pages/settings/departments/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/pages/settings/departments/new.tsx
Normal file
38
src/pages/settings/departments/new.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/pages/settings/gl-accounts/[id].tsx
Normal file
49
src/pages/settings/gl-accounts/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/pages/settings/gl-accounts/index.tsx
Normal file
33
src/pages/settings/gl-accounts/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/pages/settings/gl-accounts/new.tsx
Normal file
38
src/pages/settings/gl-accounts/new.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/pages/settings/index.tsx
Normal file
73
src/pages/settings/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/pages/settings/users/[id].tsx
Normal file
59
src/pages/settings/users/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/pages/settings/users/index.tsx
Normal file
34
src/pages/settings/users/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/pages/settings/users/new.tsx
Normal file
47
src/pages/settings/users/new.tsx
Normal 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
60
src/pages/settings/vendors/[id].tsx
vendored
Normal 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
34
src/pages/settings/vendors/index.tsx
vendored
Normal 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
48
src/pages/settings/vendors/new.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
201
src/settings/SettingsFormShell.tsx
Normal file
201
src/settings/SettingsFormShell.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
78
src/settings/SettingsListPage.tsx
Normal file
78
src/settings/SettingsListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue