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:
Adam Moussa 2026-08-10 19:06:14 -04:00 • committed by GitHub
parent 5c82936e50
commit 20d6f56f7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 948 additions and 162 deletions

View file

@ -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

View file

@ -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,
}) => { }) => {

View file

@ -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");
}); });

View file

@ -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();

View 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>
);
}

View file

@ -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>
); );
} }

View 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>
);
}

View file

@ -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"),
]; ];

View file

@ -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
View 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
View 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,
};

View file

@ -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" },
},
},
},
}, },
}); });

View file

@ -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 />

View 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."
/>
);
}

View file

@ -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
View 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>
);
}

View 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."
/>
);
}

View 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>
);
}

View 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."
/>
);
}

View 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."
/>
);
}

View 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
View file

@ -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 {