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).
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 }) => {
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(
@ -18,6 +18,16 @@ test.describe("dashboard grids", () => {
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 ({
page,
}) => {

View file

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

View file

@ -1,12 +1,12 @@
import { expect, test } from "@playwright/test";
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 expect(page.getByRole("heading", { name: "Sea Haven AP" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
await expect(page.getByTestId("nav-dashboard")).toBeVisible();
await expect(page.getByTestId("app-wordmark")).toContainText("STAMPLI");
await expect(page.getByTestId("page-title")).toHaveText("Invoice Processing");
await expect(page.getByTestId("nav-invoice-processing")).toBeVisible();
await expect(page.getByTestId("nav-settings")).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 { 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 { CreatePayRunButton } from "@/dashboard/CreatePayRunButton";
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 { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices";
import { areMocksEnabled, type MockInvoice } from "@/mocks";
import { useNavigate } from "@/router";
type DashboardTab = "pending" | "ready" | "all";
type InvoiceTab = "attention" | "all" | "approved" | "rejected" | "late";
const emptySelection: GridRowSelectionModel = {
type: "include",
@ -21,11 +35,12 @@ const rowClickSx = {
borderColor: "divider",
backgroundColor: "background.paper",
cursor: "pointer",
borderRadius: 1,
} as const;
export function DashboardGrids() {
const navigate = useNavigate();
const [tab, setTab] = useState<DashboardTab>("pending");
const [tab, setTab] = useState<InvoiceTab>("attention");
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
const { data: invoices = [], isLoading, isError, error } = useInvoices();
const updatePayDate = useUpdateInvoicePayDate();
@ -34,25 +49,35 @@ export function DashboardGrids() {
navigate("/invoices/:id", { params: { id: params.row.id } });
};
const pendingRows = useMemo(
const attentionRows = useMemo(
() => invoices.filter((invoice) => invoice.status === "pending_approval"),
[invoices],
);
const readyRows = useMemo(
const approvedRows = useMemo(
() => invoices.filter((invoice) => invoice.status === "approved"),
[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(
selection,
readyRows.map((invoice) => invoice.id),
approvedRows.map((invoice) => invoice.id),
);
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 });
};
@ -74,42 +99,93 @@ export function DashboardGrids() {
}
return (
<Stack spacing={2}>
<Tabs
value={tab}
onChange={(_event, value: DashboardTab) => {
setTab(value);
setSelection(emptySelection);
}}
aria-label="Invoice dashboard tabs"
>
<Tab
label={`Pending Approval (${pendingRows.length})`}
value="pending"
data-testid="tab-pending"
/>
<Tab label={`Ready to Pay (${readyRows.length})`} value="ready" data-testid="tab-ready" />
<Tab label={`All Invoices (${invoices.length})`} value="all" data-testid="tab-all" />
</Tabs>
<Stack spacing={0} data-testid="invoice-processing">
<Box sx={{ bgcolor: "#E8EEF7", px: 1, pt: 1 }}>
<Tabs
value={tab}
onChange={(_event, value: InvoiceTab) => {
setTab(value);
setSelection(emptySelection);
}}
variant="scrollable"
aria-label="Invoice Processing tabs"
sx={{
minHeight: 44,
"& .MuiTab-root": {
bgcolor: "rgba(47,128,237,0.12)",
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={`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" && (
<InvoiceDataGrid
data-testid="grid-pending"
rows={pendingRows}
columns={pendingApprovalColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
)}
<Box sx={{ bgcolor: "background.paper", p: 2 }}>
{attentionRows.length > 0 && tab === "attention" ? (
<Alert severity="info" sx={{ mb: 2 }} data-testid="attention-banner">
{attentionRows.length} invoices require approval. Show Invoices ready to approve.
</Alert>
) : null}
{tab === "ready" && (
<Stack spacing={1.5}>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<CreatePayRunButton selectedCount={selectedCount} />
</Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
<Chip label="Action" variant="outlined" size="small" />
<Chip label="Vendor" variant="outlined" size="small" />
<Box sx={{ flexGrow: 1 }} />
<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
data-testid="grid-ready"
rows={readyRows}
rows={approvedRows}
columns={readyToPayColumns}
checkboxSelection
rowSelectionModel={selection}
@ -120,26 +196,32 @@ export function DashboardGrids() {
}}
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" && (
<InvoiceDataGrid
data-testid="grid-all"
rows={invoices}
columns={allInvoicesColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
)}
{tab === "rejected" && (
<InvoiceDataGrid
data-testid="grid-rejected"
rows={rejectedRows}
columns={allInvoicesColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
)}
<Typography variant="body2" data-testid="mocks-status">
Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices
</Typography>
{tab === "late" && (
<InvoiceDataGrid
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>
);
}

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"),
];
export const readyToPayColumns: GridColDef<MockInvoice>[] = [
dateColumn("dueDate", "Due Date"),
dateColumn("payDate", "Pay Date", true),
{ field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
/** Invoice Processing — For My Attention columns (AP-26). */
export const invoiceAttentionColumns: GridColDef<MockInvoice>[] = [
{
field: "invoiceNumber",
headerName: "Invoice #",
field: "invoiceNumbers",
headerName: "Invoice",
flex: 1,
minWidth: 120,
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"),
];

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 {
AppBar,
Badge,
Box,
Drawer,
FormControl,
IconButton,
InputAdornment,
InputLabel,
List,
ListItemButton,
ListItemText,
MenuItem,
Select,
Stack,
TextField,
Toolbar,
Tooltip,
Typography,
} from "@mui/material";
import type { ReactNode } from "react";
import type { ReactElement, ReactNode } from "react";
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 { useMockSession } from "@/providers/use-mock-session";
import { useNavigate, type Path } from "@/router";
const DRAWER_WIDTH = 240;
const RAIL_WIDTH = 96;
type NavItem = {
label: string;
to: Path;
testId: string;
const iconByTestId: Record<string, typeof ReceiptLongOutlinedIcon> = {
"nav-procurement": StorefrontOutlinedIcon,
"nav-invoice-processing": ReceiptLongOutlinedIcon,
"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 = {
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 navigate = useNavigate();
const { user, setCurrentUserId } = useMockSession();
const users = getMockUsers();
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
<Box sx={{ display: "flex", minHeight: "100vh", bgcolor: "background.default" }}>
<AppBar
position="fixed"
elevation={0}
sx={{
zIndex: (theme) => theme.zIndex.drawer + 1,
borderBottom: 1,
borderColor: "divider",
bgcolor: "background.paper",
color: "text.primary",
background: brand.headerGradient,
color: "#fff",
}}
>
<Toolbar sx={{ gap: 2 }}>
<Typography variant="h6" sx={{ fontFamily: "inherit", fontWeight: 600, flexGrow: 1 }}>
Sea Haven AP
<Toolbar sx={{ gap: 2, minHeight: 56, px: 2 }}>
<Typography
component="div"
data-testid="app-wordmark"
sx={{
fontWeight: 800,
letterSpacing: "0.08em",
fontSize: "1.05rem",
mr: 1,
flexShrink: 0,
}}
>
{brand.wordmark}
</Typography>
<FormControl size="small" sx={{ minWidth: 260 }}>
<InputLabel id="mock-user-label">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))}
>
{users.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name} ({option.role})
</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
placeholder="Search"
data-testid="global-search"
sx={{
width: 280,
bgcolor: "rgba(255,255,255,0.92)",
borderRadius: 1,
"& .MuiOutlinedInput-notchedOutline": { border: "none" },
}}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<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>
</AppBar>
<Drawer
variant="permanent"
<Box
component="nav"
data-testid="module-rail"
sx={{
width: DRAWER_WIDTH,
width: RAIL_WIDTH,
flexShrink: 0,
[`& .MuiDrawer-paper`]: {
width: DRAWER_WIDTH,
boxSizing: "border-box",
borderRight: 1,
borderColor: "divider",
},
position: "fixed",
top: 56,
bottom: 0,
left: 0,
borderRight: 1,
borderColor: "divider",
bgcolor: "#F7F9FC",
pt: 1,
overflowY: "auto",
zIndex: (theme) => theme.zIndex.appBar - 1,
}}
>
<Toolbar />
<Box sx={{ overflow: "auto", py: 1 }}>
<List dense>
{navItems.map((item) => {
const selected =
item.to === "/"
? location.pathname === "/"
: location.pathname === item.to || location.pathname.startsWith(`${item.to}/`);
<Box sx={{ display: "flex", justifyContent: "center", mb: 1 }}>
<IconButton size="small" aria-label="Collapse navigation" color="primary">
<ChevronRightIcon fontSize="small" />
</IconButton>
</Box>
<Stack spacing={0.5} alignItems="stretch" px={0.5}>
{navModules
.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 (
<ListItemButton
key={item.to}
selected={selected}
data-testid={item.testId}
onClick={() => navigate(item.to)}
>
<ListItemText primary={item.label} />
</ListItemButton>
<Tooltip key={item.testId} title={item.label} placement="right">
<Box
component="button"
type="button"
data-testid={item.testId}
onClick={() => navigate(item.to)}
sx={{
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>
</Box>
</Drawer>
</Stack>
</Box>
<Box
component="main"
sx={{
flexGrow: 1,
width: `calc(100% - ${DRAWER_WIDTH}px)`,
width: `calc(100% - ${RAIL_WIDTH}px)`,
ml: `${RAIL_WIDTH}px`,
bgcolor: "background.default",
minHeight: "100vh",
}}
>
<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";
/** Stampli-parity tokens from AP-29 capture (original recreation, not scraped CSS). */
export const muiTheme = createTheme({
palette: {
mode: "light",
primary: {
main: "#0b3d5c",
main: "#2F80ED",
dark: "#2167AE",
light: "#5B9CF2",
contrastText: "#ffffff",
},
secondary: {
main: "#1f6f8b",
main: "#7A2B8F",
},
error: {
main: "#E74C3C",
},
warning: {
main: "#F5A623",
},
background: {
default: "#f5f7fa",
default: "#F4F6F9",
paper: "#ffffff",
},
text: {
primary: "#1D2B50",
secondary: "#5A6A85",
},
divider: "#E2E8F0",
},
typography: {
fontFamily: '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif',
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,
},
},
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";
export default function App() {
const location = useLocation();
if (location.pathname === "/login") {
return <Outlet />;
}
return (
<AppShell>
<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";
export default function HomePage() {
return (
<Box sx={{ py: 4 }}>
<Container maxWidth="xl">
<Stack spacing={3}>
<Stack spacing={1}>
<Typography component="h1" variant="h1">
Dashboard
</Typography>
<Typography color="text.secondary">Internal accounts payable automation.</Typography>
</Stack>
<DashboardGrids />
</Stack>
</Container>
<Box>
<Box sx={{ px: 2, pt: 1.5, pb: 0.5 }}>
<Typography component="h1" variant="h1" data-testid="page-title">
Invoice Processing
</Typography>
</Box>
<DashboardGrids />
</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 {
readonly VITE_USE_MOCKS: string;
readonly VITE_SEA_HAVEN_BRAND?: string;
}
interface ImportMeta {