From 20d6f56f7ecd9bede85bf6f73322cdf3ac6c43ce Mon Sep 17 00:00:00 2001
From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
Date: Mon, 10 Aug 2026 19:06:14 -0400
Subject: [PATCH] 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.
---
.env.example | 4 +
e2e/dashboard-grids.spec.ts | 12 +-
e2e/home.spec.ts | 7 +-
e2e/settings-approvals.spec.ts | 8 +-
src/components/ModuleStubPage.tsx | 38 +++
src/dashboard/DashboardGrids.tsx | 198 ++++++++++-----
src/dashboard/PaymentProcessingGrids.tsx | 237 ++++++++++++++++++
src/dashboard/columns.tsx | 35 ++-
src/layout/AppShell.tsx | 300 +++++++++++++++++------
src/layout/nav.ts | 33 +++
src/lib/brand.ts | 29 +++
src/lib/mui-theme.ts | 56 ++++-
src/pages/_app.tsx | 7 +-
src/pages/billing/index.tsx | 12 +
src/pages/index.tsx | 21 +-
src/pages/login.tsx | 49 ++++
src/pages/past-invoices/index.tsx | 12 +
src/pages/payments/index.tsx | 15 ++
src/pages/procurement/index.tsx | 12 +
src/pages/recurring/index.tsx | 12 +
src/pages/reports/index.tsx | 12 +
src/vite-env.d.ts | 1 +
22 files changed, 948 insertions(+), 162 deletions(-)
create mode 100644 src/components/ModuleStubPage.tsx
create mode 100644 src/dashboard/PaymentProcessingGrids.tsx
create mode 100644 src/layout/nav.ts
create mode 100644 src/lib/brand.ts
create mode 100644 src/pages/billing/index.tsx
create mode 100644 src/pages/login.tsx
create mode 100644 src/pages/past-invoices/index.tsx
create mode 100644 src/pages/payments/index.tsx
create mode 100644 src/pages/procurement/index.tsx
create mode 100644 src/pages/recurring/index.tsx
create mode 100644 src/pages/reports/index.tsx
diff --git a/.env.example b/.env.example
index 9a89fbd..2cec3c4 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/e2e/dashboard-grids.spec.ts b/e2e/dashboard-grids.spec.ts
index 3991192..160476a 100644
--- a/e2e/dashboard-grids.spec.ts
+++ b/e2e/dashboard-grids.spec.ts
@@ -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,
}) => {
diff --git a/e2e/home.spec.ts b/e2e/home.spec.ts
index c4a6043..f77e657 100644
--- a/e2e/home.spec.ts
+++ b/e2e/home.spec.ts
@@ -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");
});
diff --git a/e2e/settings-approvals.spec.ts b/e2e/settings-approvals.spec.ts
index d43a361..e3db14b 100644
--- a/e2e/settings-approvals.spec.ts
+++ b/e2e/settings-approvals.spec.ts
@@ -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();
diff --git a/src/components/ModuleStubPage.tsx b/src/components/ModuleStubPage.tsx
new file mode 100644
index 0000000..4124747
--- /dev/null
+++ b/src/components/ModuleStubPage.tsx
@@ -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 (
+
+
+
+ {title}
+
+
+ {headline}
+
+ {body}
+
+ parity stub — interactive UI without live module backend
+
+
+
+
+
+
+ );
+}
diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx
index c7fea2d..e6b97f9 100644
--- a/src/dashboard/DashboardGrids.tsx
+++ b/src/dashboard/DashboardGrids.tsx
@@ -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("pending");
+ const [tab, setTab] = useState("attention");
const [selection, setSelection] = useState(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 (
-
- {
- setTab(value);
- setSelection(emptySelection);
- }}
- aria-label="Invoice dashboard tabs"
- >
-
-
-
-
+
+
+ {
+ 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 === "pending" && (
-
- )}
+
+ {attentionRows.length > 0 && tab === "attention" ? (
+
+ {attentionRows.length} invoices require approval. Show Invoices ready to approve.
+
+ ) : null}
- {tab === "ready" && (
-
-
-
-
+
+
+
+
+
+ {tab === "approved" ? : null}
+
+
+ {tab === "attention" && (
+
+ )}
+
+ {tab === "all" && (
+
+ )}
+
+ {tab === "approved" && (
-
- Double-click Pay Date to edit (ISO YYYY-MM-DD). Changes update mock state only. Open
- detail from the Invoice # link.
-
-
- )}
+ )}
- {tab === "all" && (
-
- )}
+ {tab === "rejected" && (
+
+ )}
-
- Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices
-
+ {tab === "late" && (
+
+ )}
+
+
+ Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices
+
+
);
}
diff --git a/src/dashboard/PaymentProcessingGrids.tsx b/src/dashboard/PaymentProcessingGrids.tsx
new file mode 100644
index 0000000..edcbc70
--- /dev/null
+++ b/src/dashboard/PaymentProcessingGrids.tsx
@@ -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("select");
+ const [selection, setSelection] = useState(emptySelection);
+ const { data: invoices = [], isLoading, isError, error } = useInvoices();
+ const updatePayDate = useUpdateInvoicePayDate();
+
+ const openInvoice = (params: GridRowParams) => {
+ 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 (
+
+
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+ {error instanceof Error ? error.message : "Failed to load payments"}
+
+ );
+ }
+
+ return (
+
+
+ {
+ 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" },
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ Expedited payments are currently unavailable for you due to various factors. (parity stub)
+
+
+
+
+
+
+
+
+
+
+
+
+ {tab === "select" && (
+ setSelection(model)}
+ processRowUpdate={processRowUpdate}
+ onProcessRowUpdateError={(processError) => {
+ console.error(processError);
+ }}
+ onRowClick={openInvoice}
+ sx={rowClickSx}
+ />
+ )}
+
+ {tab === "pending" && (
+
+ )}
+
+ {tab === "scheduled" && (
+
+ )}
+
+ {(tab === "outside" || tab === "errors" || tab === "outbox") && (
+
+ No Result Found
+
+ parity stub
+
+
+ )}
+
+
+
+
+ Total Due:{" "}
+
+
+ {totalDue.toLocaleString("en-US", { style: "currency", currency: "USD" })}
+
+
+
+
+ Total To Pay:{" "}
+
+ $0.00
+
+
+
+ Estimated USD:{" "}
+
+ $0.00
+
+
+
+
+ );
+}
diff --git a/src/dashboard/columns.tsx b/src/dashboard/columns.tsx
index 730f3b5..686468e 100644
--- a/src/dashboard/columns.tsx
+++ b/src/dashboard/columns.tsx
@@ -85,17 +85,40 @@ export const pendingApprovalColumns: GridColDef[] = [
moneyColumn("amount", "Amount"),
];
-export const readyToPayColumns: GridColDef[] = [
- 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[] = [
{
- 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[] = [
+ {
+ 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"),
];
diff --git a/src/layout/AppShell.tsx b/src/layout/AppShell.tsx
index 4123e79..df48886 100644
--- a/src/layout/AppShell.tsx
+++ b/src/layout/AppShell.tsx
@@ -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 = {
+ "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 (
-
+
theme.zIndex.drawer + 1,
- borderBottom: 1,
- borderColor: "divider",
- bgcolor: "background.paper",
- color: "text.primary",
+ background: brand.headerGradient,
+ color: "#fff",
}}
>
-
-
- Sea Haven AP
+
+
+ {brand.wordmark}
-
- Mock user
-
-
+
+
+
+
+ ),
+ endAdornment: (
+
+ Advanced
+
+ ),
+ },
+ }}
+ />
+
+
+
+ Billy coded 1,693 fields on 99 invoices this month | Learn more
+
+
+
+
+
+
+
+
+
+ {brand.orgLabel}
+
+
+ {user.name}
+
+
+
+
+ Mock user
+
+
+
+
- theme.zIndex.appBar - 1,
}}
>
-
-
-
- {navItems.map((item) => {
- const selected =
- item.to === "/"
- ? location.pathname === "/"
- : location.pathname === item.to || location.pathname.startsWith(`${item.to}/`);
-
+
+
+
+
+
+
+ {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 (
- navigate(item.to)}
- >
-
-
+
+ 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",
+ }}
+ >
+
+
+
+
+
+
+ {item.label}
+
+
+
);
})}
-
-
-
+
+
diff --git a/src/layout/nav.ts b/src/layout/nav.ts
new file mode 100644
index 0000000..1e64be8
--- /dev/null
+++ b/src/layout/nav.ts
@@ -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" },
+];
diff --git a/src/lib/brand.ts b/src/lib/brand.ts
new file mode 100644
index 0000000..da1664a
--- /dev/null
+++ b/src/lib/brand.ts
@@ -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,
+ };
diff --git a/src/lib/mui-theme.ts b/src/lib/mui-theme.ts
index 7a43b1a..ab28023 100644
--- a/src/lib/mui-theme.ts
+++ b/src/lib/mui-theme.ts
@@ -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" },
+ },
+ },
+ },
},
});
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index ad26712..d6688f8 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -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 ;
+ }
+
return (
diff --git a/src/pages/billing/index.tsx b/src/pages/billing/index.tsx
new file mode 100644
index 0000000..d4d16c8
--- /dev/null
+++ b/src/pages/billing/index.tsx
@@ -0,0 +1,12 @@
+import { ModuleStubPage } from "@/components/ModuleStubPage";
+
+export default function BillingPage() {
+ return (
+
+ );
+}
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index 38dd613..454b975 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -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 (
-
-
-
-
-
- Dashboard
-
- Internal accounts payable automation.
-
-
-
-
+
+
+
+ Invoice Processing
+
+
+
);
}
diff --git a/src/pages/login.tsx b/src/pages/login.tsx
new file mode 100644
index 0000000..7c25cfd
--- /dev/null
+++ b/src/pages/login.tsx
@@ -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 (
+
+
+
+
+ {brand.wordmark}
+
+ Sign in (mock auth — Cognito in AP-14)
+
+
+
+
+
+ Forgot password?
+
+
+
+
+ );
+}
diff --git a/src/pages/past-invoices/index.tsx b/src/pages/past-invoices/index.tsx
new file mode 100644
index 0000000..8294dc4
--- /dev/null
+++ b/src/pages/past-invoices/index.tsx
@@ -0,0 +1,12 @@
+import { ModuleStubPage } from "@/components/ModuleStubPage";
+
+export default function PastInvoicesPage() {
+ return (
+
+ );
+}
diff --git a/src/pages/payments/index.tsx b/src/pages/payments/index.tsx
new file mode 100644
index 0000000..c360766
--- /dev/null
+++ b/src/pages/payments/index.tsx
@@ -0,0 +1,15 @@
+import { Box, Typography } from "@mui/material";
+import { PaymentProcessingGrids } from "@/dashboard/PaymentProcessingGrids";
+
+export default function PaymentsPage() {
+ return (
+
+
+
+ Payment Processing
+
+
+
+
+ );
+}
diff --git a/src/pages/procurement/index.tsx b/src/pages/procurement/index.tsx
new file mode 100644
index 0000000..d04c052
--- /dev/null
+++ b/src/pages/procurement/index.tsx
@@ -0,0 +1,12 @@
+import { ModuleStubPage } from "@/components/ModuleStubPage";
+
+export default function ProcurementPage() {
+ return (
+
+ );
+}
diff --git a/src/pages/recurring/index.tsx b/src/pages/recurring/index.tsx
new file mode 100644
index 0000000..41ab074
--- /dev/null
+++ b/src/pages/recurring/index.tsx
@@ -0,0 +1,12 @@
+import { ModuleStubPage } from "@/components/ModuleStubPage";
+
+export default function RecurringPage() {
+ return (
+
+ );
+}
diff --git a/src/pages/reports/index.tsx b/src/pages/reports/index.tsx
new file mode 100644
index 0000000..22113e0
--- /dev/null
+++ b/src/pages/reports/index.tsx
@@ -0,0 +1,12 @@
+import { ModuleStubPage } from "@/components/ModuleStubPage";
+
+export default function ReportsPage() {
+ return (
+
+ );
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index d7dbac9..1d76bec 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_USE_MOCKS: string;
+ readonly VITE_SEA_HAVEN_BRAND?: string;
}
interface ImportMeta {