mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 03:23:16 +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",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"generate:router": "node scripts/generate-router.mjs",
|
||||||
|
"build": "npm run generate:router && tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
|
|
||||||
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";
|
export { MockDataGrid as InvoiceDataGrid } from "@/components/MockDataGrid";
|
||||||
|
|
||||||
type InvoiceDataGridProps<R extends GridValidRowModel> = DataGridProps<R> & {
|
|
||||||
"data-testid": string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function InvoiceDataGrid<R extends GridValidRowModel>({
|
|
||||||
"data-testid": testId,
|
|
||||||
...props
|
|
||||||
}: InvoiceDataGridProps<R>) {
|
|
||||||
return (
|
|
||||||
<div data-testid={testId}>
|
|
||||||
<DataGrid
|
|
||||||
autoHeight
|
|
||||||
disableColumnMenu
|
|
||||||
disableRowSelectionOnClick
|
|
||||||
pageSizeOptions={[10, 25]}
|
|
||||||
initialState={{
|
|
||||||
pagination: { paginationModel: { pageSize: 10, page: 0 } },
|
|
||||||
}}
|
|
||||||
getRowId={(row) => row.id as string}
|
|
||||||
sx={{
|
|
||||||
border: 1,
|
|
||||||
borderColor: "divider",
|
|
||||||
backgroundColor: "background.paper",
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
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 { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
areMocksEnabled,
|
areMocksEnabled,
|
||||||
|
getMockApprovalPolicies,
|
||||||
|
getMockApprovals,
|
||||||
|
getMockDepartments,
|
||||||
|
getMockGlAccounts,
|
||||||
getMockInvoices,
|
getMockInvoices,
|
||||||
|
getMockPendingApprovals,
|
||||||
|
getMockUsers,
|
||||||
getMockVendors,
|
getMockVendors,
|
||||||
|
resetMockApprovalPolicies,
|
||||||
|
resetMockApprovals,
|
||||||
|
resetMockDepartments,
|
||||||
|
resetMockGlAccounts,
|
||||||
resetMockInvoices,
|
resetMockInvoices,
|
||||||
|
resetMockUsers,
|
||||||
|
resetMockVendors,
|
||||||
|
updateMockApprovalStatus,
|
||||||
updateMockInvoicePayDate,
|
updateMockInvoicePayDate,
|
||||||
|
upsertMockDepartment,
|
||||||
|
upsertMockGlAccount,
|
||||||
|
upsertMockVendor,
|
||||||
|
upsertMockUser,
|
||||||
|
upsertMockApprovalPolicy,
|
||||||
} from "@/mocks";
|
} from "@/mocks";
|
||||||
|
|
||||||
describe("mocks module", () => {
|
describe("mocks module", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
resetMockInvoices();
|
resetMockInvoices();
|
||||||
|
resetMockVendors();
|
||||||
|
resetMockGlAccounts();
|
||||||
|
resetMockDepartments();
|
||||||
|
resetMockUsers();
|
||||||
|
resetMockApprovalPolicies();
|
||||||
|
resetMockApprovals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to mocks enabled", () => {
|
it("defaults to mocks enabled", () => {
|
||||||
|
|
@ -47,4 +71,59 @@ describe("mocks module", () => {
|
||||||
/Mock invoice not found/,
|
/Mock invoice not found/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("seeds settings entities and supports upsert", () => {
|
||||||
|
expect(getMockVendors().length).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(getMockGlAccounts().length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(getMockDepartments().length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(getMockUsers().length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(getMockApprovalPolicies().length).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
|
const vendor = upsertMockVendor({
|
||||||
|
id: "vendor-new",
|
||||||
|
name: "New Vendor",
|
||||||
|
email: "new@example.com",
|
||||||
|
defaultPaymentMethod: "ach",
|
||||||
|
});
|
||||||
|
expect(getMockVendors().some((row) => row.id === vendor.id)).toBe(true);
|
||||||
|
|
||||||
|
upsertMockGlAccount({ id: "gl-new", code: "9999", name: "Misc" });
|
||||||
|
expect(getMockGlAccounts().some((row) => row.id === "gl-new")).toBe(true);
|
||||||
|
|
||||||
|
upsertMockDepartment({ id: "dept-new", code: "NEW", name: "New Dept" });
|
||||||
|
expect(getMockDepartments().some((row) => row.id === "dept-new")).toBe(true);
|
||||||
|
|
||||||
|
upsertMockUser({
|
||||||
|
id: "user-new",
|
||||||
|
name: "New User",
|
||||||
|
email: "new.user@seahaven.example",
|
||||||
|
role: "approver",
|
||||||
|
});
|
||||||
|
expect(getMockUsers().some((row) => row.id === "user-new")).toBe(true);
|
||||||
|
|
||||||
|
upsertMockApprovalPolicy({
|
||||||
|
id: "policy-new",
|
||||||
|
name: "Ad hoc",
|
||||||
|
amountThreshold: 250,
|
||||||
|
approverRole: "admin",
|
||||||
|
});
|
||||||
|
expect(getMockApprovalPolicies().some((row) => row.id === "policy-new")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists pending approvals and updates status", () => {
|
||||||
|
const pending = getMockPendingApprovals();
|
||||||
|
expect(pending.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(getMockApprovals().length).toBeGreaterThanOrEqual(pending.length);
|
||||||
|
|
||||||
|
const first = pending[0];
|
||||||
|
const updated = updateMockApprovalStatus(first.id, "approved");
|
||||||
|
expect(updated.status).toBe("approved");
|
||||||
|
expect(getMockPendingApprovals().some((row) => row.id === first.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when updating an unknown approval", () => {
|
||||||
|
expect(() => updateMockApprovalStatus("missing", "rejected")).toThrow(
|
||||||
|
/Mock approval not found/,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,167 +1,54 @@
|
||||||
export type MockVendor = {
|
export { areMocksEnabled } from "@/mocks/env";
|
||||||
id: string;
|
export {
|
||||||
name: string;
|
getMockInvoices,
|
||||||
};
|
resetMockInvoices,
|
||||||
|
updateMockInvoicePayDate,
|
||||||
export type MockInvoiceStatus = "pending_approval" | "approved" | "scheduled" | "paid";
|
type MockInvoice,
|
||||||
|
type MockInvoiceStatus,
|
||||||
export type MockInvoice = {
|
} from "@/mocks/invoices";
|
||||||
id: string;
|
export {
|
||||||
invoiceNumber: string;
|
getMockVendor,
|
||||||
invoiceNumbers: string;
|
getMockVendors,
|
||||||
vendorId: string;
|
mockVendors,
|
||||||
vendorName: string;
|
resetMockVendors,
|
||||||
amountDue: number;
|
upsertMockVendor,
|
||||||
amount: number;
|
type MockPaymentMethod,
|
||||||
dueDate: string;
|
type MockVendor,
|
||||||
payDate: string | null;
|
} from "@/mocks/vendors";
|
||||||
sendPaymentOn: string | null;
|
export {
|
||||||
status: MockInvoiceStatus;
|
getMockGlAccount,
|
||||||
paymentMethod: "check" | "ach";
|
getMockGlAccounts,
|
||||||
};
|
resetMockGlAccounts,
|
||||||
|
upsertMockGlAccount,
|
||||||
export const mockVendors: MockVendor[] = [
|
type MockGlAccount,
|
||||||
{ id: "vendor-1", name: "Acme Supplies Co" },
|
} from "@/mocks/gl-accounts";
|
||||||
{ id: "vendor-2", name: "Harbor Maintenance LLC" },
|
export {
|
||||||
{ id: "vendor-3", name: "Northwind Utilities" },
|
getMockDepartment,
|
||||||
{ id: "vendor-4", name: "Coastal Office Supply" },
|
getMockDepartments,
|
||||||
];
|
resetMockDepartments,
|
||||||
|
upsertMockDepartment,
|
||||||
const seedInvoices: MockInvoice[] = [
|
type MockDepartment,
|
||||||
{
|
} from "@/mocks/departments";
|
||||||
id: "inv-1",
|
export {
|
||||||
invoiceNumber: "INV-1001",
|
getMockUser,
|
||||||
invoiceNumbers: "INV-1001",
|
getMockUsers,
|
||||||
vendorId: "vendor-1",
|
resetMockUsers,
|
||||||
vendorName: "Acme Supplies Co",
|
upsertMockUser,
|
||||||
amountDue: 1250.5,
|
type MockUser,
|
||||||
amount: 1250.5,
|
type MockUserRole,
|
||||||
dueDate: "2026-08-20",
|
} from "@/mocks/users";
|
||||||
payDate: "2026-08-15",
|
export {
|
||||||
sendPaymentOn: "2026-08-15",
|
getMockApprovalPolicies,
|
||||||
status: "approved",
|
getMockApprovalPolicy,
|
||||||
paymentMethod: "check",
|
resetMockApprovalPolicies,
|
||||||
},
|
upsertMockApprovalPolicy,
|
||||||
{
|
type MockApprovalPolicy,
|
||||||
id: "inv-2",
|
} from "@/mocks/approval-policies";
|
||||||
invoiceNumber: "INV-1002",
|
export {
|
||||||
invoiceNumbers: "INV-1002",
|
getMockApprovals,
|
||||||
vendorId: "vendor-2",
|
getMockPendingApprovals,
|
||||||
vendorName: "Harbor Maintenance LLC",
|
resetMockApprovals,
|
||||||
amountDue: 4800,
|
updateMockApprovalStatus,
|
||||||
amount: 4800,
|
type MockApprovalItem,
|
||||||
dueDate: "2026-08-25",
|
type MockApprovalStatus,
|
||||||
payDate: null,
|
} from "@/mocks/approvals";
|
||||||
sendPaymentOn: "2026-08-20",
|
|
||||||
status: "pending_approval",
|
|
||||||
paymentMethod: "ach",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "inv-3",
|
|
||||||
invoiceNumber: "INV-1003",
|
|
||||||
invoiceNumbers: "INV-1003",
|
|
||||||
vendorId: "vendor-3",
|
|
||||||
vendorName: "Northwind Utilities",
|
|
||||||
amountDue: 312.75,
|
|
||||||
amount: 312.75,
|
|
||||||
dueDate: "2026-08-12",
|
|
||||||
payDate: "2026-08-10",
|
|
||||||
sendPaymentOn: "2026-08-10",
|
|
||||||
status: "approved",
|
|
||||||
paymentMethod: "check",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "inv-4",
|
|
||||||
invoiceNumber: "INV-1004",
|
|
||||||
invoiceNumbers: "INV-1004",
|
|
||||||
vendorId: "vendor-4",
|
|
||||||
vendorName: "Coastal Office Supply",
|
|
||||||
amountDue: 890.25,
|
|
||||||
amount: 900,
|
|
||||||
dueDate: "2026-09-01",
|
|
||||||
payDate: null,
|
|
||||||
sendPaymentOn: "2026-08-27",
|
|
||||||
status: "pending_approval",
|
|
||||||
paymentMethod: "ach",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "inv-5",
|
|
||||||
invoiceNumber: "INV-1005",
|
|
||||||
invoiceNumbers: "INV-1005",
|
|
||||||
vendorId: "vendor-1",
|
|
||||||
vendorName: "Acme Supplies Co",
|
|
||||||
amountDue: 2100,
|
|
||||||
amount: 2100,
|
|
||||||
dueDate: "2026-07-30",
|
|
||||||
payDate: "2026-07-25",
|
|
||||||
sendPaymentOn: "2026-07-25",
|
|
||||||
status: "scheduled",
|
|
||||||
paymentMethod: "check",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "inv-6",
|
|
||||||
invoiceNumber: "INV-1006",
|
|
||||||
invoiceNumbers: "INV-1006",
|
|
||||||
vendorId: "vendor-2",
|
|
||||||
vendorName: "Harbor Maintenance LLC",
|
|
||||||
amountDue: 0,
|
|
||||||
amount: 1550,
|
|
||||||
dueDate: "2026-07-15",
|
|
||||||
payDate: "2026-07-10",
|
|
||||||
sendPaymentOn: "2026-07-10",
|
|
||||||
status: "paid",
|
|
||||||
paymentMethod: "ach",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "inv-7",
|
|
||||||
invoiceNumber: "INV-1007",
|
|
||||||
invoiceNumbers: "INV-1007",
|
|
||||||
vendorId: "vendor-3",
|
|
||||||
vendorName: "Northwind Utilities",
|
|
||||||
amountDue: 645.1,
|
|
||||||
amount: 645.1,
|
|
||||||
dueDate: "2026-08-28",
|
|
||||||
payDate: "2026-08-22",
|
|
||||||
sendPaymentOn: "2026-08-22",
|
|
||||||
status: "approved",
|
|
||||||
paymentMethod: "ach",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Mutable in-memory invoice store for mock POC interactions. */
|
|
||||||
let invoiceStore: MockInvoice[] = seedInvoices.map((invoice) => ({ ...invoice }));
|
|
||||||
|
|
||||||
/** Mocks are the default data path until the API is wired. */
|
|
||||||
export function areMocksEnabled(): boolean {
|
|
||||||
return import.meta.env.VITE_USE_MOCKS !== "false";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMockInvoices(): MockInvoice[] {
|
|
||||||
if (!areMocksEnabled()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return invoiceStore.map((invoice) => ({ ...invoice }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMockVendors(): MockVendor[] {
|
|
||||||
if (!areMocksEnabled()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return mockVendors;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateMockInvoicePayDate(id: string, payDate: string | null): MockInvoice {
|
|
||||||
const index = invoiceStore.findIndex((invoice) => invoice.id === id);
|
|
||||||
if (index === -1) {
|
|
||||||
throw new Error(`Mock invoice not found: ${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated: MockInvoice = { ...invoiceStore[index], payDate };
|
|
||||||
invoiceStore = [...invoiceStore.slice(0, index), updated, ...invoiceStore.slice(index + 1)];
|
|
||||||
return { ...updated };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Test helper to restore seed data between unit tests. */
|
|
||||||
export function resetMockInvoices(): void {
|
|
||||||
invoiceStore = seedInvoices.map((invoice) => ({ ...invoice }));
|
|
||||||
}
|
|
||||||
|
|
|
||||||
145
src/mocks/invoices.ts
Normal file
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() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<Box component="main" sx={{ minHeight: "100vh", py: 4 }}>
|
<Box sx={{ py: 4 }}>
|
||||||
<Container maxWidth="xl">
|
<Container maxWidth="xl">
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
<Typography component="h1" variant="h1">
|
<Typography component="h1" variant="h1">
|
||||||
Sea Haven AP
|
Dashboard
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography color="text.secondary">Internal accounts payable automation.</Typography>
|
<Typography color="text.secondary">Internal accounts payable automation.</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
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