mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 05:43:18 +00:00
feat(frontend): add Stampli visual-parity chrome (AP-22) (#10)
Rebuild the mock SPA shell to match the live Stampli product map (header, module rail, invoice/payment tabs, stubs) and add a Sea Haven brand flag for AP-23 cutover.
This commit is contained in:
parent
5c82936e50
commit
20d6f56f7e
22 changed files with 948 additions and 162 deletions
|
|
@ -1,2 +1,6 @@
|
||||||
# Default data path until the API is wired (AP later tickets).
|
# Default data path until the API is wired (AP later tickets).
|
||||||
VITE_USE_MOCKS=true
|
VITE_USE_MOCKS=true
|
||||||
|
|
||||||
|
# AP-22 visual parity uses Stampli wordmark when false/unset.
|
||||||
|
# AP-38 Sea Haven branding cutover: set true.
|
||||||
|
VITE_SEA_HAVEN_BRAND=false
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ test.describe("dashboard grids", () => {
|
||||||
test("home page shows brand and three invoice grids", async ({ page }) => {
|
test("home page shows brand and three invoice grids", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Sea Haven AP" })).toBeVisible();
|
await expect(page.getByTestId("app-wordmark")).toContainText("STAMPLI");
|
||||||
await expect(page.getByTestId("mocks-status")).toContainText("Mocks enabled");
|
await expect(page.getByTestId("mocks-status")).toContainText("Mocks enabled");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|
@ -18,6 +18,16 @@ test.describe("dashboard grids", () => {
|
||||||
await expect(page.getByTestId("grid-all").locator(".MuiDataGrid-row").first()).toBeVisible();
|
await expect(page.getByTestId("grid-all").locator(".MuiDataGrid-row").first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("payment processing select to pay is reachable from module rail", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByTestId("nav-payment-processing").click();
|
||||||
|
await expect(page.getByTestId("payment-processing")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("tab-select-to-pay")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("grid-select-to-pay").locator(".MuiDataGrid-row").first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("ready to pay supports selection, create pay run stub, and pay-date edit", async ({
|
test("ready to pay supports selection, create pay run stub, and pay-date edit", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test("home page shows Sea Haven AP brand", async ({ page }) => {
|
test("home page shows Stampli-parity chrome and mocks", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.getByRole("heading", { name: "Sea Haven AP" })).toBeVisible();
|
await expect(page.getByTestId("app-wordmark")).toContainText("STAMPLI");
|
||||||
|
await expect(page.getByTestId("module-rail")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("nav-invoice-processing")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("page-title")).toHaveText("Invoice Processing");
|
||||||
await expect(page.getByTestId("mocks-status")).toContainText("Mocks enabled");
|
await expect(page.getByTestId("mocks-status")).toContainText("Mocks enabled");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test.describe("settings and approvals shells", () => {
|
test.describe("settings and approvals shells", () => {
|
||||||
test("demo path dashboard → settings → approvals works on mocks", async ({ page }) => {
|
test("demo path invoice processing → settings → approvals works on mocks", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Sea Haven AP" })).toBeVisible();
|
await expect(page.getByTestId("app-wordmark")).toContainText("STAMPLI");
|
||||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
await expect(page.getByTestId("page-title")).toHaveText("Invoice Processing");
|
||||||
await expect(page.getByTestId("nav-dashboard")).toBeVisible();
|
await expect(page.getByTestId("nav-invoice-processing")).toBeVisible();
|
||||||
await expect(page.getByTestId("nav-settings")).toBeVisible();
|
await expect(page.getByTestId("nav-settings")).toBeVisible();
|
||||||
await expect(page.getByTestId("nav-approvals")).toBeVisible();
|
await expect(page.getByTestId("nav-approvals")).toBeVisible();
|
||||||
|
|
||||||
|
|
|
||||||
38
src/components/ModuleStubPage.tsx
Normal file
38
src/components/ModuleStubPage.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Box, Button, Stack, Typography } from "@mui/material";
|
||||||
|
|
||||||
|
type ModuleStubProps = {
|
||||||
|
title: string;
|
||||||
|
headline: string;
|
||||||
|
body: string;
|
||||||
|
testId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ModuleStubPage({ title, headline, body, testId }: ModuleStubProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
data-testid={testId}
|
||||||
|
sx={{
|
||||||
|
minHeight: "calc(100vh - 56px)",
|
||||||
|
background: "linear-gradient(135deg, #E8F1FB 0%, #F7F9FC 55%, #F3E9F7 100%)",
|
||||||
|
px: { xs: 3, md: 8 },
|
||||||
|
py: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={2} maxWidth={640}>
|
||||||
|
<Typography color="primary.dark" fontWeight={700}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3" component="h1" sx={{ fontWeight: 800, color: "text.primary" }}>
|
||||||
|
{headline}
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">{body}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
parity stub — interactive UI without live module backend
|
||||||
|
</Typography>
|
||||||
|
<Box>
|
||||||
|
<Button variant="contained">Contact me about {title}</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,29 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Alert, Box, CircularProgress, Stack, Tab, Tabs, Typography } from "@mui/material";
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Stack,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid";
|
import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid";
|
||||||
import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton";
|
import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton";
|
||||||
import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid";
|
import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid";
|
||||||
import { allInvoicesColumns, pendingApprovalColumns, readyToPayColumns } from "@/dashboard/columns";
|
import {
|
||||||
|
allInvoicesColumns,
|
||||||
|
invoiceAttentionColumns,
|
||||||
|
readyToPayColumns,
|
||||||
|
} from "@/dashboard/columns";
|
||||||
import { countSelectedRows } from "@/dashboard/selection";
|
import { countSelectedRows } from "@/dashboard/selection";
|
||||||
import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices";
|
import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices";
|
||||||
import { areMocksEnabled, type MockInvoice } from "@/mocks";
|
import { areMocksEnabled, type MockInvoice } from "@/mocks";
|
||||||
import { useNavigate } from "@/router";
|
import { useNavigate } from "@/router";
|
||||||
|
|
||||||
type DashboardTab = "pending" | "ready" | "all";
|
type InvoiceTab = "attention" | "all" | "approved" | "rejected" | "late";
|
||||||
|
|
||||||
const emptySelection: GridRowSelectionModel = {
|
const emptySelection: GridRowSelectionModel = {
|
||||||
type: "include",
|
type: "include",
|
||||||
|
|
@ -21,11 +35,12 @@ const rowClickSx = {
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
backgroundColor: "background.paper",
|
backgroundColor: "background.paper",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
borderRadius: 1,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function DashboardGrids() {
|
export function DashboardGrids() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [tab, setTab] = useState<DashboardTab>("pending");
|
const [tab, setTab] = useState<InvoiceTab>("attention");
|
||||||
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
|
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
|
||||||
const { data: invoices = [], isLoading, isError, error } = useInvoices();
|
const { data: invoices = [], isLoading, isError, error } = useInvoices();
|
||||||
const updatePayDate = useUpdateInvoicePayDate();
|
const updatePayDate = useUpdateInvoicePayDate();
|
||||||
|
|
@ -34,25 +49,35 @@ export function DashboardGrids() {
|
||||||
navigate("/invoices/:id", { params: { id: params.row.id } });
|
navigate("/invoices/:id", { params: { id: params.row.id } });
|
||||||
};
|
};
|
||||||
|
|
||||||
const pendingRows = useMemo(
|
const attentionRows = useMemo(
|
||||||
() => invoices.filter((invoice) => invoice.status === "pending_approval"),
|
() => invoices.filter((invoice) => invoice.status === "pending_approval"),
|
||||||
[invoices],
|
[invoices],
|
||||||
);
|
);
|
||||||
const readyRows = useMemo(
|
const approvedRows = useMemo(
|
||||||
() => invoices.filter((invoice) => invoice.status === "approved"),
|
() => invoices.filter((invoice) => invoice.status === "approved"),
|
||||||
[invoices],
|
[invoices],
|
||||||
);
|
);
|
||||||
|
const rejectedRows = useMemo(
|
||||||
|
() => invoices.filter((invoice) => invoice.status === "rejected"),
|
||||||
|
[invoices],
|
||||||
|
);
|
||||||
|
const lateRows = useMemo(() => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
return invoices.filter(
|
||||||
|
(invoice) =>
|
||||||
|
invoice.dueDate < today && invoice.status !== "paid" && invoice.status !== "rejected",
|
||||||
|
);
|
||||||
|
}, [invoices]);
|
||||||
|
|
||||||
const selectedCount = countSelectedRows(
|
const selectedCount = countSelectedRows(
|
||||||
selection,
|
selection,
|
||||||
readyRows.map((invoice) => invoice.id),
|
approvedRows.map((invoice) => invoice.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
const processRowUpdate = async (newRow: MockInvoice, oldRow: MockInvoice) => {
|
const processRowUpdate = async (newRow: MockInvoice, oldRow: MockInvoice) => {
|
||||||
if (newRow.payDate === oldRow.payDate) {
|
if (newRow.payDate === oldRow.payDate) {
|
||||||
return oldRow;
|
return oldRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextPayDate = newRow.payDate?.trim() ? newRow.payDate.trim() : null;
|
const nextPayDate = newRow.payDate?.trim() ? newRow.payDate.trim() : null;
|
||||||
return updatePayDate.mutateAsync({ id: newRow.id, payDate: nextPayDate });
|
return updatePayDate.mutateAsync({ id: newRow.id, payDate: nextPayDate });
|
||||||
};
|
};
|
||||||
|
|
@ -74,42 +99,93 @@ export function DashboardGrids() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={0} data-testid="invoice-processing">
|
||||||
<Tabs
|
<Box sx={{ bgcolor: "#E8EEF7", px: 1, pt: 1 }}>
|
||||||
value={tab}
|
<Tabs
|
||||||
onChange={(_event, value: DashboardTab) => {
|
value={tab}
|
||||||
setTab(value);
|
onChange={(_event, value: InvoiceTab) => {
|
||||||
setSelection(emptySelection);
|
setTab(value);
|
||||||
}}
|
setSelection(emptySelection);
|
||||||
aria-label="Invoice dashboard tabs"
|
}}
|
||||||
>
|
variant="scrollable"
|
||||||
<Tab
|
aria-label="Invoice Processing tabs"
|
||||||
label={`Pending Approval (${pendingRows.length})`}
|
sx={{
|
||||||
value="pending"
|
minHeight: 44,
|
||||||
data-testid="tab-pending"
|
"& .MuiTab-root": {
|
||||||
/>
|
bgcolor: "rgba(47,128,237,0.12)",
|
||||||
<Tab label={`Ready to Pay (${readyRows.length})`} value="ready" data-testid="tab-ready" />
|
mr: 0.5,
|
||||||
<Tab label={`All Invoices (${invoices.length})`} value="all" data-testid="tab-all" />
|
borderTopLeftRadius: 6,
|
||||||
</Tabs>
|
borderTopRightRadius: 6,
|
||||||
|
color: "text.primary",
|
||||||
|
},
|
||||||
|
"& .Mui-selected": {
|
||||||
|
bgcolor: "#fff !important",
|
||||||
|
color: "text.primary !important",
|
||||||
|
},
|
||||||
|
"& .MuiTabs-indicator": { display: "none" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
label={`For My Attention (${attentionRows.length})`}
|
||||||
|
value="attention"
|
||||||
|
data-testid="tab-pending"
|
||||||
|
/>
|
||||||
|
<Tab label={`New Invoices (${invoices.length})`} value="all" data-testid="tab-all" />
|
||||||
|
<Tab
|
||||||
|
label={`Approved Invoices (${approvedRows.length})`}
|
||||||
|
value="approved"
|
||||||
|
data-testid="tab-ready"
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
label={`Rejected Invoices (${rejectedRows.length})`}
|
||||||
|
value="rejected"
|
||||||
|
data-testid="tab-rejected"
|
||||||
|
/>
|
||||||
|
<Tab label={`Late Invoices (${lateRows.length})`} value="late" data-testid="tab-late" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{tab === "pending" && (
|
<Box sx={{ bgcolor: "background.paper", p: 2 }}>
|
||||||
<InvoiceDataGrid
|
{attentionRows.length > 0 && tab === "attention" ? (
|
||||||
data-testid="grid-pending"
|
<Alert severity="info" sx={{ mb: 2 }} data-testid="attention-banner">
|
||||||
rows={pendingRows}
|
{attentionRows.length} invoices require approval. Show Invoices ready to approve.
|
||||||
columns={pendingApprovalColumns}
|
</Alert>
|
||||||
onRowClick={openInvoice}
|
) : null}
|
||||||
sx={rowClickSx}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "ready" && (
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||||
<Stack spacing={1.5}>
|
<Chip label="Action" variant="outlined" size="small" />
|
||||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
<Chip label="Vendor" variant="outlined" size="small" />
|
||||||
<CreatePayRunButton selectedCount={selectedCount} />
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
</Box>
|
<Button variant="contained" size="small" data-testid="review-all-items">
|
||||||
|
Review All Items
|
||||||
|
</Button>
|
||||||
|
{tab === "approved" ? <CreatePayRunButton selectedCount={selectedCount} /> : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{tab === "attention" && (
|
||||||
|
<InvoiceDataGrid
|
||||||
|
data-testid="grid-pending"
|
||||||
|
rows={attentionRows}
|
||||||
|
columns={invoiceAttentionColumns}
|
||||||
|
onRowClick={openInvoice}
|
||||||
|
sx={rowClickSx}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "all" && (
|
||||||
|
<InvoiceDataGrid
|
||||||
|
data-testid="grid-all"
|
||||||
|
rows={invoices}
|
||||||
|
columns={allInvoicesColumns}
|
||||||
|
onRowClick={openInvoice}
|
||||||
|
sx={rowClickSx}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "approved" && (
|
||||||
<InvoiceDataGrid
|
<InvoiceDataGrid
|
||||||
data-testid="grid-ready"
|
data-testid="grid-ready"
|
||||||
rows={readyRows}
|
rows={approvedRows}
|
||||||
columns={readyToPayColumns}
|
columns={readyToPayColumns}
|
||||||
checkboxSelection
|
checkboxSelection
|
||||||
rowSelectionModel={selection}
|
rowSelectionModel={selection}
|
||||||
|
|
@ -120,26 +196,32 @@ export function DashboardGrids() {
|
||||||
}}
|
}}
|
||||||
sx={rowClickSx}
|
sx={rowClickSx}
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" color="text.secondary">
|
)}
|
||||||
Double-click Pay Date to edit (ISO YYYY-MM-DD). Changes update mock state only. Open
|
|
||||||
detail from the Invoice # link.
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "all" && (
|
{tab === "rejected" && (
|
||||||
<InvoiceDataGrid
|
<InvoiceDataGrid
|
||||||
data-testid="grid-all"
|
data-testid="grid-rejected"
|
||||||
rows={invoices}
|
rows={rejectedRows}
|
||||||
columns={allInvoicesColumns}
|
columns={allInvoicesColumns}
|
||||||
onRowClick={openInvoice}
|
onRowClick={openInvoice}
|
||||||
sx={rowClickSx}
|
sx={rowClickSx}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Typography variant="body2" data-testid="mocks-status">
|
{tab === "late" && (
|
||||||
Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices
|
<InvoiceDataGrid
|
||||||
</Typography>
|
data-testid="grid-late"
|
||||||
|
rows={lateRows}
|
||||||
|
columns={allInvoicesColumns}
|
||||||
|
onRowClick={openInvoice}
|
||||||
|
sx={rowClickSx}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography variant="body2" data-testid="mocks-status" sx={{ mt: 2 }}>
|
||||||
|
Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
237
src/dashboard/PaymentProcessingGrids.tsx
Normal file
237
src/dashboard/PaymentProcessingGrids.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Stack,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid";
|
||||||
|
import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton";
|
||||||
|
import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid";
|
||||||
|
import { pendingApprovalColumns, readyToPayColumns } from "@/dashboard/columns";
|
||||||
|
import { countSelectedRows } from "@/dashboard/selection";
|
||||||
|
import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices";
|
||||||
|
import type { MockInvoice } from "@/mocks";
|
||||||
|
import { useNavigate } from "@/router";
|
||||||
|
|
||||||
|
type PaymentTab = "outside" | "select" | "pending" | "scheduled" | "errors" | "outbox";
|
||||||
|
|
||||||
|
const emptySelection: GridRowSelectionModel = {
|
||||||
|
type: "include",
|
||||||
|
ids: new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowClickSx = {
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
backgroundColor: "background.paper",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderRadius: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function PaymentProcessingGrids() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [tab, setTab] = useState<PaymentTab>("select");
|
||||||
|
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
|
||||||
|
const { data: invoices = [], isLoading, isError, error } = useInvoices();
|
||||||
|
const updatePayDate = useUpdateInvoicePayDate();
|
||||||
|
|
||||||
|
const openInvoice = (params: GridRowParams<MockInvoice>) => {
|
||||||
|
navigate("/invoices/:id", { params: { id: params.row.id } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectRows = useMemo(
|
||||||
|
() => invoices.filter((invoice) => invoice.status === "approved"),
|
||||||
|
[invoices],
|
||||||
|
);
|
||||||
|
const pendingRows = useMemo(
|
||||||
|
() => invoices.filter((invoice) => invoice.status === "pending_approval"),
|
||||||
|
[invoices],
|
||||||
|
);
|
||||||
|
const scheduledRows = useMemo(
|
||||||
|
() => invoices.filter((invoice) => invoice.status === "scheduled"),
|
||||||
|
[invoices],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedCount = countSelectedRows(
|
||||||
|
selection,
|
||||||
|
selectRows.map((invoice) => invoice.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalDue = selectRows.reduce((sum, row) => sum + row.amountDue, 0);
|
||||||
|
|
||||||
|
const processRowUpdate = async (newRow: MockInvoice, oldRow: MockInvoice) => {
|
||||||
|
if (newRow.payDate === oldRow.payDate) {
|
||||||
|
return oldRow;
|
||||||
|
}
|
||||||
|
const nextPayDate = newRow.payDate?.trim() ? newRow.payDate.trim() : null;
|
||||||
|
return updatePayDate.mutateAsync({ id: newRow.id, payDate: nextPayDate });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
|
||||||
|
<CircularProgress aria-label="Loading payments" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert severity="error">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load payments"}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={0} data-testid="payment-processing">
|
||||||
|
<Box sx={{ bgcolor: "#E8EEF7", px: 1, pt: 1 }}>
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onChange={(_event, value: PaymentTab) => {
|
||||||
|
setTab(value);
|
||||||
|
setSelection(emptySelection);
|
||||||
|
}}
|
||||||
|
variant="scrollable"
|
||||||
|
aria-label="Payment Processing tabs"
|
||||||
|
sx={{
|
||||||
|
minHeight: 44,
|
||||||
|
"& .MuiTab-root": {
|
||||||
|
bgcolor: "rgba(47,128,237,0.18)",
|
||||||
|
mr: 0.5,
|
||||||
|
borderTopLeftRadius: 6,
|
||||||
|
borderTopRightRadius: 6,
|
||||||
|
color: "text.primary",
|
||||||
|
},
|
||||||
|
"& .Mui-selected": {
|
||||||
|
bgcolor: "#fff !important",
|
||||||
|
color: "text.primary !important",
|
||||||
|
},
|
||||||
|
"& .MuiTabs-indicator": { display: "none" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab label="Paying Outside Stampli (0)" value="outside" data-testid="tab-pay-outside" />
|
||||||
|
<Tab
|
||||||
|
label={`Select to Pay (${selectRows.length})`}
|
||||||
|
value="select"
|
||||||
|
data-testid="tab-select-to-pay"
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
label={`Pending Approval (${pendingRows.length})`}
|
||||||
|
value="pending"
|
||||||
|
data-testid="tab-pay-pending"
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
label={`Scheduled (${scheduledRows.length})`}
|
||||||
|
value="scheduled"
|
||||||
|
data-testid="tab-scheduled"
|
||||||
|
/>
|
||||||
|
<Tab label="Payment Export Errors (0)" value="errors" data-testid="tab-export-errors" />
|
||||||
|
<Tab label="Outbox (0)" value="outbox" data-testid="tab-outbox" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ bgcolor: "background.paper", p: 2 }}>
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }} data-testid="expedite-banner">
|
||||||
|
Expedited payments are currently unavailable for you due to various factors. (parity stub)
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }} flexWrap="wrap">
|
||||||
|
<Chip label="Vendor Name" variant="outlined" size="small" />
|
||||||
|
<Chip label="Payment Method" variant="outlined" size="small" />
|
||||||
|
<Chip label="Bank Account" variant="outlined" size="small" />
|
||||||
|
<Chip label="Status" variant="outlined" size="small" />
|
||||||
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
<Button variant="outlined" size="small" data-testid="bulk-edit">
|
||||||
|
Bulk Edit
|
||||||
|
</Button>
|
||||||
|
<CreatePayRunButton selectedCount={selectedCount} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{tab === "select" && (
|
||||||
|
<InvoiceDataGrid
|
||||||
|
data-testid="grid-select-to-pay"
|
||||||
|
rows={selectRows}
|
||||||
|
columns={readyToPayColumns}
|
||||||
|
checkboxSelection
|
||||||
|
rowSelectionModel={selection}
|
||||||
|
onRowSelectionModelChange={(model) => setSelection(model)}
|
||||||
|
processRowUpdate={processRowUpdate}
|
||||||
|
onProcessRowUpdateError={(processError) => {
|
||||||
|
console.error(processError);
|
||||||
|
}}
|
||||||
|
onRowClick={openInvoice}
|
||||||
|
sx={rowClickSx}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "pending" && (
|
||||||
|
<InvoiceDataGrid
|
||||||
|
data-testid="grid-pay-pending"
|
||||||
|
rows={pendingRows}
|
||||||
|
columns={pendingApprovalColumns}
|
||||||
|
onRowClick={openInvoice}
|
||||||
|
sx={rowClickSx}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "scheduled" && (
|
||||||
|
<InvoiceDataGrid
|
||||||
|
data-testid="grid-scheduled"
|
||||||
|
rows={scheduledRows}
|
||||||
|
columns={readyToPayColumns}
|
||||||
|
onRowClick={openInvoice}
|
||||||
|
sx={rowClickSx}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(tab === "outside" || tab === "errors" || tab === "outbox") && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
py: 8,
|
||||||
|
textAlign: "center",
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
data-testid="payment-empty-stub"
|
||||||
|
>
|
||||||
|
<Typography color="text.secondary">No Result Found</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
parity stub
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={4} sx={{ mt: 2 }} data-testid="payment-footer-totals">
|
||||||
|
<Typography variant="body2">
|
||||||
|
<Box component="span" color="text.secondary">
|
||||||
|
Total Due:{" "}
|
||||||
|
</Box>
|
||||||
|
<strong>
|
||||||
|
{totalDue.toLocaleString("en-US", { style: "currency", currency: "USD" })}
|
||||||
|
</strong>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<Box component="span" color="text.secondary">
|
||||||
|
Total To Pay:{" "}
|
||||||
|
</Box>
|
||||||
|
<strong>$0.00</strong>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<Box component="span" color="text.secondary">
|
||||||
|
Estimated USD:{" "}
|
||||||
|
</Box>
|
||||||
|
<strong>$0.00</strong>
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -85,17 +85,40 @@ export const pendingApprovalColumns: GridColDef<MockInvoice>[] = [
|
||||||
moneyColumn("amount", "Amount"),
|
moneyColumn("amount", "Amount"),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const readyToPayColumns: GridColDef<MockInvoice>[] = [
|
/** Invoice Processing — For My Attention columns (AP-26). */
|
||||||
dateColumn("dueDate", "Due Date"),
|
export const invoiceAttentionColumns: GridColDef<MockInvoice>[] = [
|
||||||
dateColumn("payDate", "Pay Date", true),
|
|
||||||
{ field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
|
|
||||||
{
|
{
|
||||||
field: "invoiceNumber",
|
field: "invoiceNumbers",
|
||||||
headerName: "Invoice #",
|
headerName: "Invoice",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
renderCell: invoiceNumberLink,
|
renderCell: invoiceNumberLink,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: "status",
|
||||||
|
headerName: "Action",
|
||||||
|
flex: 1.2,
|
||||||
|
minWidth: 140,
|
||||||
|
valueFormatter: (value: string) => (value === "pending_approval" ? "Approve invoice" : value),
|
||||||
|
},
|
||||||
|
{ field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
|
||||||
|
dateColumn("dueDate", "Due Date"),
|
||||||
|
moneyColumn("amountDue", "Σ Sum"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const readyToPayColumns: GridColDef<MockInvoice>[] = [
|
||||||
|
{
|
||||||
|
field: "invoiceNumber",
|
||||||
|
headerName: "Invoice",
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 120,
|
||||||
|
renderCell: invoiceNumberLink,
|
||||||
|
},
|
||||||
|
{ field: "vendorName", headerName: "Vendor Name", flex: 1.4, minWidth: 160 },
|
||||||
|
{ field: "paymentMethod", headerName: "Payment Method", flex: 1, minWidth: 130 },
|
||||||
|
dateColumn("sendPaymentOn", "Send Payment On"),
|
||||||
|
dateColumn("dueDate", "Due Date"),
|
||||||
|
dateColumn("payDate", "Pay Date", true),
|
||||||
moneyColumn("amountDue", "Amount Due"),
|
moneyColumn("amountDue", "Amount Due"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,126 +1,282 @@
|
||||||
|
import AccountBalanceWalletOutlinedIcon from "@mui/icons-material/AccountBalanceWalletOutlined";
|
||||||
|
import AssessmentOutlinedIcon from "@mui/icons-material/AssessmentOutlined";
|
||||||
|
import AutorenewOutlinedIcon from "@mui/icons-material/AutorenewOutlined";
|
||||||
|
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||||
|
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
|
||||||
|
import Inventory2OutlinedIcon from "@mui/icons-material/Inventory2Outlined";
|
||||||
|
import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
|
||||||
|
import PaidOutlinedIcon from "@mui/icons-material/PaidOutlined";
|
||||||
|
import ReceiptLongOutlinedIcon from "@mui/icons-material/ReceiptLongOutlined";
|
||||||
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
|
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
||||||
|
import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Drawer,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
InputAdornment,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
List,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemText,
|
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { useNavigate, type Path } from "@/router";
|
import { brand } from "@/lib/brand";
|
||||||
|
import { navModules } from "@/layout/nav";
|
||||||
import { getMockUsers } from "@/mocks";
|
import { getMockUsers } from "@/mocks";
|
||||||
import { useMockSession } from "@/providers/use-mock-session";
|
import { useMockSession } from "@/providers/use-mock-session";
|
||||||
|
import { useNavigate, type Path } from "@/router";
|
||||||
|
|
||||||
const DRAWER_WIDTH = 240;
|
const RAIL_WIDTH = 96;
|
||||||
|
|
||||||
type NavItem = {
|
const iconByTestId: Record<string, typeof ReceiptLongOutlinedIcon> = {
|
||||||
label: string;
|
"nav-procurement": StorefrontOutlinedIcon,
|
||||||
to: Path;
|
"nav-invoice-processing": ReceiptLongOutlinedIcon,
|
||||||
testId: string;
|
"nav-payment-processing": AccountBalanceWalletOutlinedIcon,
|
||||||
|
"nav-approvals": FolderOutlinedIcon,
|
||||||
|
"nav-vendors": Inventory2OutlinedIcon,
|
||||||
|
"nav-recurring": AutorenewOutlinedIcon,
|
||||||
|
"nav-past-invoices": FolderOutlinedIcon,
|
||||||
|
"nav-reports": AssessmentOutlinedIcon,
|
||||||
|
"nav-settings": SettingsOutlinedIcon,
|
||||||
|
"nav-billing": PaidOutlinedIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = {
|
type AppShellProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppShell({ children }: AppShellProps) {
|
function isSelected(pathname: string, to: Path): boolean {
|
||||||
|
if (to === "/") {
|
||||||
|
return pathname === "/" || pathname.startsWith("/invoices/");
|
||||||
|
}
|
||||||
|
return pathname === to || pathname.startsWith(`${to}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell({ children }: AppShellProps): ReactElement {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, setCurrentUserId } = useMockSession();
|
const { user, setCurrentUserId } = useMockSession();
|
||||||
const users = getMockUsers();
|
const users = getMockUsers();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: "flex", minHeight: "100vh" }}>
|
<Box sx={{ display: "flex", minHeight: "100vh", bgcolor: "background.default" }}>
|
||||||
<AppBar
|
<AppBar
|
||||||
position="fixed"
|
position="fixed"
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||||
borderBottom: 1,
|
background: brand.headerGradient,
|
||||||
borderColor: "divider",
|
color: "#fff",
|
||||||
bgcolor: "background.paper",
|
|
||||||
color: "text.primary",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Toolbar sx={{ gap: 2 }}>
|
<Toolbar sx={{ gap: 2, minHeight: 56, px: 2 }}>
|
||||||
<Typography variant="h6" sx={{ fontFamily: "inherit", fontWeight: 600, flexGrow: 1 }}>
|
<Typography
|
||||||
Sea Haven AP
|
component="div"
|
||||||
|
data-testid="app-wordmark"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
fontSize: "1.05rem",
|
||||||
|
mr: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{brand.wordmark}
|
||||||
</Typography>
|
</Typography>
|
||||||
<FormControl size="small" sx={{ minWidth: 260 }}>
|
|
||||||
<InputLabel id="mock-user-label">Mock user</InputLabel>
|
<TextField
|
||||||
<Select
|
size="small"
|
||||||
labelId="mock-user-label"
|
placeholder="Search"
|
||||||
label="Mock user"
|
data-testid="global-search"
|
||||||
value={user.id}
|
sx={{
|
||||||
data-testid="mock-user-switcher"
|
width: 280,
|
||||||
onChange={(event) => setCurrentUserId(String(event.target.value))}
|
bgcolor: "rgba(255,255,255,0.92)",
|
||||||
>
|
borderRadius: 1,
|
||||||
{users.map((option) => (
|
"& .MuiOutlinedInput-notchedOutline": { border: "none" },
|
||||||
<MenuItem key={option.id} value={option.id}>
|
}}
|
||||||
{option.name} ({option.role})
|
slotProps={{
|
||||||
</MenuItem>
|
input: {
|
||||||
))}
|
startAdornment: (
|
||||||
</Select>
|
<InputAdornment position="start">
|
||||||
</FormControl>
|
<SearchIcon color="primary" fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: (
|
||||||
|
<Typography variant="caption" color="primary" sx={{ pr: 1, cursor: "pointer" }}>
|
||||||
|
Advanced
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
data-testid="billy-banner"
|
||||||
|
sx={{
|
||||||
|
display: brand.showBillyBanner ? { xs: "none", md: "flex" } : "none",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: "rgba(255,255,255,0.18)",
|
||||||
|
flexGrow: 1,
|
||||||
|
maxWidth: 520,
|
||||||
|
mx: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ color: "#fff" }}>
|
||||||
|
Billy coded 1,693 fields on 99 invoices this month | Learn more
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ ml: "auto" }}>
|
||||||
|
<Badge badgeContent={12} color="error" data-testid="notif-badge">
|
||||||
|
<NotificationsNoneOutlinedIcon />
|
||||||
|
</Badge>
|
||||||
|
<Box sx={{ textAlign: "right", lineHeight: 1.2 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
|
{brand.orgLabel}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ opacity: 0.9 }}>
|
||||||
|
{user.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||||
|
<InputLabel id="mock-user-label" sx={{ color: "rgba(255,255,255,0.8)" }}>
|
||||||
|
Mock user
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="mock-user-label"
|
||||||
|
label="Mock user"
|
||||||
|
value={user.id}
|
||||||
|
data-testid="mock-user-switcher"
|
||||||
|
onChange={(event) => setCurrentUserId(String(event.target.value))}
|
||||||
|
sx={{
|
||||||
|
color: "#fff",
|
||||||
|
".MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255,255,255,0.35)" },
|
||||||
|
".MuiSvgIcon-root": { color: "#fff" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{users.map((option) => (
|
||||||
|
<MenuItem key={option.id} value={option.id}>
|
||||||
|
{option.name} ({option.role})
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
<Drawer
|
<Box
|
||||||
variant="permanent"
|
component="nav"
|
||||||
|
data-testid="module-rail"
|
||||||
sx={{
|
sx={{
|
||||||
width: DRAWER_WIDTH,
|
width: RAIL_WIDTH,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
[`& .MuiDrawer-paper`]: {
|
position: "fixed",
|
||||||
width: DRAWER_WIDTH,
|
top: 56,
|
||||||
boxSizing: "border-box",
|
bottom: 0,
|
||||||
borderRight: 1,
|
left: 0,
|
||||||
borderColor: "divider",
|
borderRight: 1,
|
||||||
},
|
borderColor: "divider",
|
||||||
|
bgcolor: "#F7F9FC",
|
||||||
|
pt: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
zIndex: (theme) => theme.zIndex.appBar - 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Toolbar />
|
<Box sx={{ display: "flex", justifyContent: "center", mb: 1 }}>
|
||||||
<Box sx={{ overflow: "auto", py: 1 }}>
|
<IconButton size="small" aria-label="Collapse navigation" color="primary">
|
||||||
<List dense>
|
<ChevronRightIcon fontSize="small" />
|
||||||
{navItems.map((item) => {
|
</IconButton>
|
||||||
const selected =
|
</Box>
|
||||||
item.to === "/"
|
<Stack spacing={0.5} alignItems="stretch" px={0.5}>
|
||||||
? location.pathname === "/"
|
{navModules
|
||||||
: location.pathname === item.to || location.pathname.startsWith(`${item.to}/`);
|
.filter((item) => !brand.hideModuleTestIds.includes(item.testId as never))
|
||||||
|
.map((item) => {
|
||||||
|
const selected = isSelected(location.pathname, item.to);
|
||||||
|
const Icon = iconByTestId[item.testId] ?? ReceiptLongOutlinedIcon;
|
||||||
return (
|
return (
|
||||||
<ListItemButton
|
<Tooltip key={item.testId} title={item.label} placement="right">
|
||||||
key={item.to}
|
<Box
|
||||||
selected={selected}
|
component="button"
|
||||||
data-testid={item.testId}
|
type="button"
|
||||||
onClick={() => navigate(item.to)}
|
data-testid={item.testId}
|
||||||
>
|
onClick={() => navigate(item.to)}
|
||||||
<ListItemText primary={item.label} />
|
sx={{
|
||||||
</ListItemButton>
|
border: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
bgcolor: selected ? "rgba(47,128,237,0.12)" : "transparent",
|
||||||
|
color: selected ? "primary.main" : "text.secondary",
|
||||||
|
borderLeft: selected ? "3px solid" : "3px solid transparent",
|
||||||
|
borderColor: selected ? "primary.main" : "transparent",
|
||||||
|
borderRadius: 1,
|
||||||
|
py: 1,
|
||||||
|
px: 0.5,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
badgeContent={item.badge}
|
||||||
|
color="primary"
|
||||||
|
overlap="circular"
|
||||||
|
sx={{
|
||||||
|
"& .MuiBadge-badge": { fontSize: "0.65rem", height: 16, minWidth: 16 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
badgeContent={item.secondaryBadge}
|
||||||
|
color="secondary"
|
||||||
|
overlap="circular"
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||||
|
sx={{
|
||||||
|
"& .MuiBadge-badge": { fontSize: "0.6rem", height: 14, minWidth: 14 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon fontSize="small" />
|
||||||
|
</Badge>
|
||||||
|
</Badge>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontSize: "0.62rem",
|
||||||
|
lineHeight: 1.15,
|
||||||
|
textAlign: "center",
|
||||||
|
px: 0.25,
|
||||||
|
fontWeight: selected ? 700 : 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</List>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
sx={{
|
sx={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
width: `calc(100% - ${DRAWER_WIDTH}px)`,
|
width: `calc(100% - ${RAIL_WIDTH}px)`,
|
||||||
|
ml: `${RAIL_WIDTH}px`,
|
||||||
bgcolor: "background.default",
|
bgcolor: "background.default",
|
||||||
|
minHeight: "100vh",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
|
|
|
||||||
33
src/layout/nav.ts
Normal file
33
src/layout/nav.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import type { Path } from "@/router";
|
||||||
|
|
||||||
|
export type NavModule = {
|
||||||
|
label: string;
|
||||||
|
to: Path;
|
||||||
|
testId: string;
|
||||||
|
badge?: string;
|
||||||
|
secondaryBadge?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const navModules: NavModule[] = [
|
||||||
|
{ label: "Procurement", to: "/procurement", testId: "nav-procurement" },
|
||||||
|
{
|
||||||
|
label: "Invoice Processing",
|
||||||
|
to: "/",
|
||||||
|
testId: "nav-invoice-processing",
|
||||||
|
badge: "99+",
|
||||||
|
secondaryBadge: "6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Payment Processing",
|
||||||
|
to: "/payments",
|
||||||
|
testId: "nav-payment-processing",
|
||||||
|
badge: "99+",
|
||||||
|
},
|
||||||
|
{ label: "Approvals", to: "/approvals", testId: "nav-approvals" },
|
||||||
|
{ label: "Vendor Management", to: "/settings/vendors", testId: "nav-vendors" },
|
||||||
|
{ label: "Recurring Invoices", to: "/recurring", testId: "nav-recurring" },
|
||||||
|
{ label: "My Past Invoices", to: "/past-invoices", testId: "nav-past-invoices" },
|
||||||
|
{ label: "Dashboards & Reports", to: "/reports", testId: "nav-reports" },
|
||||||
|
{ label: "System Settings", to: "/settings", testId: "nav-settings" },
|
||||||
|
{ label: "Billing", to: "/billing", testId: "nav-billing" },
|
||||||
|
];
|
||||||
29
src/lib/brand.ts
Normal file
29
src/lib/brand.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/**
|
||||||
|
* Visual-parity brand config (AP-22 / AP-38).
|
||||||
|
* Default: Stampli-parity wordmark for AP-22 mocks.
|
||||||
|
* Set `VITE_SEA_HAVEN_BRAND=true` for Sea Haven cutover (AP-38).
|
||||||
|
*/
|
||||||
|
export const useSeaHavenBrand = import.meta.env.VITE_SEA_HAVEN_BRAND === "true";
|
||||||
|
|
||||||
|
export const brand = useSeaHavenBrand
|
||||||
|
? {
|
||||||
|
productName: "Sea Haven AP",
|
||||||
|
wordmark: "SEA HAVEN",
|
||||||
|
orgLabel: "Sea Haven",
|
||||||
|
headerGradient: "linear-gradient(90deg, #0b3d5c 0%, #1f6f8b 100%)",
|
||||||
|
showBillyBanner: false,
|
||||||
|
hideModuleTestIds: [
|
||||||
|
"nav-procurement",
|
||||||
|
"nav-billing",
|
||||||
|
"nav-recurring",
|
||||||
|
"nav-reports",
|
||||||
|
] as const,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
productName: "STAMPLI",
|
||||||
|
wordmark: "STAMPLI",
|
||||||
|
orgLabel: "Seahaven",
|
||||||
|
headerGradient: "linear-gradient(90deg, #2167AE 0%, #7A2B8F 100%)",
|
||||||
|
showBillyBanner: true,
|
||||||
|
hideModuleTestIds: [] as const,
|
||||||
|
};
|
||||||
|
|
@ -1,27 +1,73 @@
|
||||||
import { createTheme } from "@mui/material/styles";
|
import { createTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
/** Stampli-parity tokens from AP-29 capture (original recreation, not scraped CSS). */
|
||||||
export const muiTheme = createTheme({
|
export const muiTheme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode: "light",
|
mode: "light",
|
||||||
primary: {
|
primary: {
|
||||||
main: "#0b3d5c",
|
main: "#2F80ED",
|
||||||
|
dark: "#2167AE",
|
||||||
|
light: "#5B9CF2",
|
||||||
|
contrastText: "#ffffff",
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
main: "#1f6f8b",
|
main: "#7A2B8F",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
main: "#E74C3C",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
main: "#F5A623",
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
default: "#f5f7fa",
|
default: "#F4F6F9",
|
||||||
paper: "#ffffff",
|
paper: "#ffffff",
|
||||||
},
|
},
|
||||||
|
text: {
|
||||||
|
primary: "#1D2B50",
|
||||||
|
secondary: "#5A6A85",
|
||||||
|
},
|
||||||
|
divider: "#E2E8F0",
|
||||||
},
|
},
|
||||||
typography: {
|
typography: {
|
||||||
fontFamily: '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif',
|
fontFamily: '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif',
|
||||||
h1: {
|
h1: {
|
||||||
fontFamily: '"IBM Plex Serif", Georgia, serif',
|
fontFamily: '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
textTransform: "none",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
shape: {
|
shape: {
|
||||||
borderRadius: 8,
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiCssBaseline: {
|
||||||
|
styleOverrides: {
|
||||||
|
body: {
|
||||||
|
backgroundColor: "#F4F6F9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTab: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
minHeight: 40,
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
boxShadow: "none",
|
||||||
|
"&:hover": { boxShadow: "none" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import { Outlet } from "react-router";
|
import { Outlet, useLocation } from "react-router";
|
||||||
import { AppShell } from "@/layout/AppShell";
|
import { AppShell } from "@/layout/AppShell";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const location = useLocation();
|
||||||
|
if (location.pathname === "/login") {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
|
||||||
12
src/pages/billing/index.tsx
Normal file
12
src/pages/billing/index.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { ModuleStubPage } from "@/components/ModuleStubPage";
|
||||||
|
|
||||||
|
export default function BillingPage() {
|
||||||
|
return (
|
||||||
|
<ModuleStubPage
|
||||||
|
testId="billing-page"
|
||||||
|
title="Billing"
|
||||||
|
headline="Subscription and billing settings."
|
||||||
|
body="Parity stub for Billing."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,15 @@
|
||||||
import { Box, Container, Stack, Typography } from "@mui/material";
|
import { Box, Typography } from "@mui/material";
|
||||||
import { DashboardGrids } from "@/dashboard/DashboardGrids";
|
import { DashboardGrids } from "@/dashboard/DashboardGrids";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ py: 4 }}>
|
<Box>
|
||||||
<Container maxWidth="xl">
|
<Box sx={{ px: 2, pt: 1.5, pb: 0.5 }}>
|
||||||
<Stack spacing={3}>
|
<Typography component="h1" variant="h1" data-testid="page-title">
|
||||||
<Stack spacing={1}>
|
Invoice Processing
|
||||||
<Typography component="h1" variant="h1">
|
</Typography>
|
||||||
Dashboard
|
</Box>
|
||||||
</Typography>
|
<DashboardGrids />
|
||||||
<Typography color="text.secondary">Internal accounts payable automation.</Typography>
|
|
||||||
</Stack>
|
|
||||||
<DashboardGrids />
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
49
src/pages/login.tsx
Normal file
49
src/pages/login.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { Box, Button, Link, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||||
|
import { brand } from "@/lib/brand";
|
||||||
|
import { useNavigate } from "@/router";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
data-testid="login-page"
|
||||||
|
sx={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: brand.headerGradient,
|
||||||
|
p: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper sx={{ width: 400, p: 4 }}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
sx={{ fontWeight: 800, letterSpacing: "0.06em", color: "primary.dark" }}
|
||||||
|
>
|
||||||
|
{brand.wordmark}
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">Sign in (mock auth — Cognito in AP-14)</Typography>
|
||||||
|
<TextField label="Email" size="small" fullWidth defaultValue="adam@seahavenind.com" />
|
||||||
|
<TextField label="Password" type="password" size="small" fullWidth defaultValue="mock" />
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
data-testid="login-submit"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" fullWidth>
|
||||||
|
SSO
|
||||||
|
</Button>
|
||||||
|
<Link href="#forgot" underline="hover" variant="body2">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/pages/past-invoices/index.tsx
Normal file
12
src/pages/past-invoices/index.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { ModuleStubPage } from "@/components/ModuleStubPage";
|
||||||
|
|
||||||
|
export default function PastInvoicesPage() {
|
||||||
|
return (
|
||||||
|
<ModuleStubPage
|
||||||
|
testId="past-invoices-page"
|
||||||
|
title="My Past Invoices"
|
||||||
|
headline="Review invoices you have already handled."
|
||||||
|
body="Parity stub for My Past Invoices."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/pages/payments/index.tsx
Normal file
15
src/pages/payments/index.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Box, Typography } from "@mui/material";
|
||||||
|
import { PaymentProcessingGrids } from "@/dashboard/PaymentProcessingGrids";
|
||||||
|
|
||||||
|
export default function PaymentsPage() {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ px: 2, pt: 1.5, pb: 0.5 }}>
|
||||||
|
<Typography component="h1" variant="h1" data-testid="page-title">
|
||||||
|
Payment Processing
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<PaymentProcessingGrids />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/pages/procurement/index.tsx
Normal file
12
src/pages/procurement/index.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { ModuleStubPage } from "@/components/ModuleStubPage";
|
||||||
|
|
||||||
|
export default function ProcurementPage() {
|
||||||
|
return (
|
||||||
|
<ModuleStubPage
|
||||||
|
testId="procurement-page"
|
||||||
|
title="Procurement"
|
||||||
|
headline="Anyone can ask. Everyone benefits."
|
||||||
|
body="Parity stub for Stampli Procurement. Request intake and purchasing workflows will wire later."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/pages/recurring/index.tsx
Normal file
12
src/pages/recurring/index.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { ModuleStubPage } from "@/components/ModuleStubPage";
|
||||||
|
|
||||||
|
export default function RecurringPage() {
|
||||||
|
return (
|
||||||
|
<ModuleStubPage
|
||||||
|
testId="recurring-page"
|
||||||
|
title="Recurring Invoices"
|
||||||
|
headline="Automate repeating vendor bills."
|
||||||
|
body="Parity stub for Recurring Invoices module."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/pages/reports/index.tsx
Normal file
12
src/pages/reports/index.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { ModuleStubPage } from "@/components/ModuleStubPage";
|
||||||
|
|
||||||
|
export default function ReportsPage() {
|
||||||
|
return (
|
||||||
|
<ModuleStubPage
|
||||||
|
testId="reports-page"
|
||||||
|
title="Dashboards & Reports"
|
||||||
|
headline="Operational AP reporting."
|
||||||
|
body="Parity stub for Dashboards & Reports."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_USE_MOCKS: string;
|
readonly VITE_USE_MOCKS: string;
|
||||||
|
readonly VITE_SEA_HAVEN_BRAND?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue