mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 04:33:15 +00:00
feat(frontend): add Stampli visual-parity chrome (AP-22) (#10)
Rebuild the mock SPA shell to match the live Stampli product map (header, module rail, invoice/payment tabs, stubs) and add a Sea Haven brand flag for AP-23 cutover.
This commit is contained in:
parent
5c82936e50
commit
20d6f56f7e
22 changed files with 948 additions and 162 deletions
|
|
@ -1,2 +1,6 @@
|
|||
# Default data path until the API is wired (AP later tickets).
|
||||
VITE_USE_MOCKS=true
|
||||
|
||||
# AP-22 visual parity uses Stampli wordmark when false/unset.
|
||||
# AP-38 Sea Haven branding cutover: set true.
|
||||
VITE_SEA_HAVEN_BRAND=false
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ test.describe("dashboard grids", () => {
|
|||
test("home page shows brand and three invoice grids", async ({ page }) => {
|
||||
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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
38
src/components/ModuleStubPage.tsx
Normal file
38
src/components/ModuleStubPage.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Box, Button, Stack, Typography } from "@mui/material";
|
||||
|
||||
type ModuleStubProps = {
|
||||
title: string;
|
||||
headline: string;
|
||||
body: string;
|
||||
testId: string;
|
||||
};
|
||||
|
||||
export function ModuleStubPage({ title, headline, body, testId }: ModuleStubProps) {
|
||||
return (
|
||||
<Box
|
||||
data-testid={testId}
|
||||
sx={{
|
||||
minHeight: "calc(100vh - 56px)",
|
||||
background: "linear-gradient(135deg, #E8F1FB 0%, #F7F9FC 55%, #F3E9F7 100%)",
|
||||
px: { xs: 3, md: 8 },
|
||||
py: 6,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} maxWidth={640}>
|
||||
<Typography color="primary.dark" fontWeight={700}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="h3" component="h1" sx={{ fontWeight: 800, color: "text.primary" }}>
|
||||
{headline}
|
||||
</Typography>
|
||||
<Typography color="text.secondary">{body}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
parity stub — interactive UI without live module backend
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button variant="contained">Contact me about {title}</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +1,29 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
237
src/dashboard/PaymentProcessingGrids.tsx
Normal file
237
src/dashboard/PaymentProcessingGrids.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid";
|
||||
import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton";
|
||||
import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid";
|
||||
import { pendingApprovalColumns, readyToPayColumns } from "@/dashboard/columns";
|
||||
import { countSelectedRows } from "@/dashboard/selection";
|
||||
import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices";
|
||||
import type { MockInvoice } from "@/mocks";
|
||||
import { useNavigate } from "@/router";
|
||||
|
||||
type PaymentTab = "outside" | "select" | "pending" | "scheduled" | "errors" | "outbox";
|
||||
|
||||
const emptySelection: GridRowSelectionModel = {
|
||||
type: "include",
|
||||
ids: new Set(),
|
||||
};
|
||||
|
||||
const rowClickSx = {
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
backgroundColor: "background.paper",
|
||||
cursor: "pointer",
|
||||
borderRadius: 1,
|
||||
} as const;
|
||||
|
||||
export function PaymentProcessingGrids() {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<PaymentTab>("select");
|
||||
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
|
||||
const { data: invoices = [], isLoading, isError, error } = useInvoices();
|
||||
const updatePayDate = useUpdateInvoicePayDate();
|
||||
|
||||
const openInvoice = (params: GridRowParams<MockInvoice>) => {
|
||||
navigate("/invoices/:id", { params: { id: params.row.id } });
|
||||
};
|
||||
|
||||
const selectRows = useMemo(
|
||||
() => invoices.filter((invoice) => invoice.status === "approved"),
|
||||
[invoices],
|
||||
);
|
||||
const pendingRows = useMemo(
|
||||
() => invoices.filter((invoice) => invoice.status === "pending_approval"),
|
||||
[invoices],
|
||||
);
|
||||
const scheduledRows = useMemo(
|
||||
() => invoices.filter((invoice) => invoice.status === "scheduled"),
|
||||
[invoices],
|
||||
);
|
||||
|
||||
const selectedCount = countSelectedRows(
|
||||
selection,
|
||||
selectRows.map((invoice) => invoice.id),
|
||||
);
|
||||
|
||||
const totalDue = selectRows.reduce((sum, row) => sum + row.amountDue, 0);
|
||||
|
||||
const processRowUpdate = async (newRow: MockInvoice, oldRow: MockInvoice) => {
|
||||
if (newRow.payDate === oldRow.payDate) {
|
||||
return oldRow;
|
||||
}
|
||||
const nextPayDate = newRow.payDate?.trim() ? newRow.payDate.trim() : null;
|
||||
return updatePayDate.mutateAsync({ id: newRow.id, payDate: nextPayDate });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
|
||||
<CircularProgress aria-label="Loading payments" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load payments"}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={0} data-testid="payment-processing">
|
||||
<Box sx={{ bgcolor: "#E8EEF7", px: 1, pt: 1 }}>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_event, value: PaymentTab) => {
|
||||
setTab(value);
|
||||
setSelection(emptySelection);
|
||||
}}
|
||||
variant="scrollable"
|
||||
aria-label="Payment Processing tabs"
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
"& .MuiTab-root": {
|
||||
bgcolor: "rgba(47,128,237,0.18)",
|
||||
mr: 0.5,
|
||||
borderTopLeftRadius: 6,
|
||||
borderTopRightRadius: 6,
|
||||
color: "text.primary",
|
||||
},
|
||||
"& .Mui-selected": {
|
||||
bgcolor: "#fff !important",
|
||||
color: "text.primary !important",
|
||||
},
|
||||
"& .MuiTabs-indicator": { display: "none" },
|
||||
}}
|
||||
>
|
||||
<Tab label="Paying Outside Stampli (0)" value="outside" data-testid="tab-pay-outside" />
|
||||
<Tab
|
||||
label={`Select to Pay (${selectRows.length})`}
|
||||
value="select"
|
||||
data-testid="tab-select-to-pay"
|
||||
/>
|
||||
<Tab
|
||||
label={`Pending Approval (${pendingRows.length})`}
|
||||
value="pending"
|
||||
data-testid="tab-pay-pending"
|
||||
/>
|
||||
<Tab
|
||||
label={`Scheduled (${scheduledRows.length})`}
|
||||
value="scheduled"
|
||||
data-testid="tab-scheduled"
|
||||
/>
|
||||
<Tab label="Payment Export Errors (0)" value="errors" data-testid="tab-export-errors" />
|
||||
<Tab label="Outbox (0)" value="outbox" data-testid="tab-outbox" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ bgcolor: "background.paper", p: 2 }}>
|
||||
<Alert severity="warning" sx={{ mb: 2 }} data-testid="expedite-banner">
|
||||
Expedited payments are currently unavailable for you due to various factors. (parity stub)
|
||||
</Alert>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }} flexWrap="wrap">
|
||||
<Chip label="Vendor Name" variant="outlined" size="small" />
|
||||
<Chip label="Payment Method" variant="outlined" size="small" />
|
||||
<Chip label="Bank Account" variant="outlined" size="small" />
|
||||
<Chip label="Status" variant="outlined" size="small" />
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button variant="outlined" size="small" data-testid="bulk-edit">
|
||||
Bulk Edit
|
||||
</Button>
|
||||
<CreatePayRunButton selectedCount={selectedCount} />
|
||||
</Stack>
|
||||
|
||||
{tab === "select" && (
|
||||
<InvoiceDataGrid
|
||||
data-testid="grid-select-to-pay"
|
||||
rows={selectRows}
|
||||
columns={readyToPayColumns}
|
||||
checkboxSelection
|
||||
rowSelectionModel={selection}
|
||||
onRowSelectionModelChange={(model) => setSelection(model)}
|
||||
processRowUpdate={processRowUpdate}
|
||||
onProcessRowUpdateError={(processError) => {
|
||||
console.error(processError);
|
||||
}}
|
||||
onRowClick={openInvoice}
|
||||
sx={rowClickSx}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "pending" && (
|
||||
<InvoiceDataGrid
|
||||
data-testid="grid-pay-pending"
|
||||
rows={pendingRows}
|
||||
columns={pendingApprovalColumns}
|
||||
onRowClick={openInvoice}
|
||||
sx={rowClickSx}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "scheduled" && (
|
||||
<InvoiceDataGrid
|
||||
data-testid="grid-scheduled"
|
||||
rows={scheduledRows}
|
||||
columns={readyToPayColumns}
|
||||
onRowClick={openInvoice}
|
||||
sx={rowClickSx}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(tab === "outside" || tab === "errors" || tab === "outbox") && (
|
||||
<Box
|
||||
sx={{
|
||||
py: 8,
|
||||
textAlign: "center",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
data-testid="payment-empty-stub"
|
||||
>
|
||||
<Typography color="text.secondary">No Result Found</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
parity stub
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={4} sx={{ mt: 2 }} data-testid="payment-footer-totals">
|
||||
<Typography variant="body2">
|
||||
<Box component="span" color="text.secondary">
|
||||
Total Due:{" "}
|
||||
</Box>
|
||||
<strong>
|
||||
{totalDue.toLocaleString("en-US", { style: "currency", currency: "USD" })}
|
||||
</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Box component="span" color="text.secondary">
|
||||
Total To Pay:{" "}
|
||||
</Box>
|
||||
<strong>$0.00</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Box component="span" color="text.secondary">
|
||||
Estimated USD:{" "}
|
||||
</Box>
|
||||
<strong>$0.00</strong>
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -85,17 +85,40 @@ export const pendingApprovalColumns: GridColDef<MockInvoice>[] = [
|
|||
moneyColumn("amount", "Amount"),
|
||||
];
|
||||
|
||||
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"),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
33
src/layout/nav.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { Path } from "@/router";
|
||||
|
||||
export type NavModule = {
|
||||
label: string;
|
||||
to: Path;
|
||||
testId: string;
|
||||
badge?: string;
|
||||
secondaryBadge?: string;
|
||||
};
|
||||
|
||||
export const navModules: NavModule[] = [
|
||||
{ label: "Procurement", to: "/procurement", testId: "nav-procurement" },
|
||||
{
|
||||
label: "Invoice Processing",
|
||||
to: "/",
|
||||
testId: "nav-invoice-processing",
|
||||
badge: "99+",
|
||||
secondaryBadge: "6",
|
||||
},
|
||||
{
|
||||
label: "Payment Processing",
|
||||
to: "/payments",
|
||||
testId: "nav-payment-processing",
|
||||
badge: "99+",
|
||||
},
|
||||
{ label: "Approvals", to: "/approvals", testId: "nav-approvals" },
|
||||
{ label: "Vendor Management", to: "/settings/vendors", testId: "nav-vendors" },
|
||||
{ label: "Recurring Invoices", to: "/recurring", testId: "nav-recurring" },
|
||||
{ label: "My Past Invoices", to: "/past-invoices", testId: "nav-past-invoices" },
|
||||
{ label: "Dashboards & Reports", to: "/reports", testId: "nav-reports" },
|
||||
{ label: "System Settings", to: "/settings", testId: "nav-settings" },
|
||||
{ label: "Billing", to: "/billing", testId: "nav-billing" },
|
||||
];
|
||||
29
src/lib/brand.ts
Normal file
29
src/lib/brand.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Visual-parity brand config (AP-22 / AP-38).
|
||||
* Default: Stampli-parity wordmark for AP-22 mocks.
|
||||
* Set `VITE_SEA_HAVEN_BRAND=true` for Sea Haven cutover (AP-38).
|
||||
*/
|
||||
export const useSeaHavenBrand = import.meta.env.VITE_SEA_HAVEN_BRAND === "true";
|
||||
|
||||
export const brand = useSeaHavenBrand
|
||||
? {
|
||||
productName: "Sea Haven AP",
|
||||
wordmark: "SEA HAVEN",
|
||||
orgLabel: "Sea Haven",
|
||||
headerGradient: "linear-gradient(90deg, #0b3d5c 0%, #1f6f8b 100%)",
|
||||
showBillyBanner: false,
|
||||
hideModuleTestIds: [
|
||||
"nav-procurement",
|
||||
"nav-billing",
|
||||
"nav-recurring",
|
||||
"nav-reports",
|
||||
] as const,
|
||||
}
|
||||
: {
|
||||
productName: "STAMPLI",
|
||||
wordmark: "STAMPLI",
|
||||
orgLabel: "Seahaven",
|
||||
headerGradient: "linear-gradient(90deg, #2167AE 0%, #7A2B8F 100%)",
|
||||
showBillyBanner: true,
|
||||
hideModuleTestIds: [] as const,
|
||||
};
|
||||
|
|
@ -1,27 +1,73 @@
|
|||
import { createTheme } from "@mui/material/styles";
|
||||
|
||||
/** 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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
12
src/pages/billing/index.tsx
Normal file
12
src/pages/billing/index.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { ModuleStubPage } from "@/components/ModuleStubPage";
|
||||
|
||||
export default function BillingPage() {
|
||||
return (
|
||||
<ModuleStubPage
|
||||
testId="billing-page"
|
||||
title="Billing"
|
||||
headline="Subscription and billing settings."
|
||||
body="Parity stub for Billing."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +1,15 @@
|
|||
import { Box, Container, Stack, Typography } from "@mui/material";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import { DashboardGrids } from "@/dashboard/DashboardGrids";
|
||||
|
||||
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
49
src/pages/login.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Box, Button, Link, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||
import { brand } from "@/lib/brand";
|
||||
import { useNavigate } from "@/router";
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-testid="login-page"
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: brand.headerGradient,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Paper sx={{ width: 400, p: 4 }}>
|
||||
<Stack spacing={2}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{ fontWeight: 800, letterSpacing: "0.06em", color: "primary.dark" }}
|
||||
>
|
||||
{brand.wordmark}
|
||||
</Typography>
|
||||
<Typography color="text.secondary">Sign in (mock auth — Cognito in AP-14)</Typography>
|
||||
<TextField label="Email" size="small" fullWidth defaultValue="adam@seahavenind.com" />
|
||||
<TextField label="Password" type="password" size="small" fullWidth defaultValue="mock" />
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={() => navigate("/")}
|
||||
data-testid="login-submit"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
<Button variant="outlined" fullWidth>
|
||||
SSO
|
||||
</Button>
|
||||
<Link href="#forgot" underline="hover" variant="body2">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
12
src/pages/past-invoices/index.tsx
Normal file
12
src/pages/past-invoices/index.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { ModuleStubPage } from "@/components/ModuleStubPage";
|
||||
|
||||
export default function PastInvoicesPage() {
|
||||
return (
|
||||
<ModuleStubPage
|
||||
testId="past-invoices-page"
|
||||
title="My Past Invoices"
|
||||
headline="Review invoices you have already handled."
|
||||
body="Parity stub for My Past Invoices."
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
src/pages/payments/index.tsx
Normal file
15
src/pages/payments/index.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Box, Typography } from "@mui/material";
|
||||
import { PaymentProcessingGrids } from "@/dashboard/PaymentProcessingGrids";
|
||||
|
||||
export default function PaymentsPage() {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ px: 2, pt: 1.5, pb: 0.5 }}>
|
||||
<Typography component="h1" variant="h1" data-testid="page-title">
|
||||
Payment Processing
|
||||
</Typography>
|
||||
</Box>
|
||||
<PaymentProcessingGrids />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
12
src/pages/procurement/index.tsx
Normal file
12
src/pages/procurement/index.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { ModuleStubPage } from "@/components/ModuleStubPage";
|
||||
|
||||
export default function ProcurementPage() {
|
||||
return (
|
||||
<ModuleStubPage
|
||||
testId="procurement-page"
|
||||
title="Procurement"
|
||||
headline="Anyone can ask. Everyone benefits."
|
||||
body="Parity stub for Stampli Procurement. Request intake and purchasing workflows will wire later."
|
||||
/>
|
||||
);
|
||||
}
|
||||
12
src/pages/recurring/index.tsx
Normal file
12
src/pages/recurring/index.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { ModuleStubPage } from "@/components/ModuleStubPage";
|
||||
|
||||
export default function RecurringPage() {
|
||||
return (
|
||||
<ModuleStubPage
|
||||
testId="recurring-page"
|
||||
title="Recurring Invoices"
|
||||
headline="Automate repeating vendor bills."
|
||||
body="Parity stub for Recurring Invoices module."
|
||||
/>
|
||||
);
|
||||
}
|
||||
12
src/pages/reports/index.tsx
Normal file
12
src/pages/reports/index.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { ModuleStubPage } from "@/components/ModuleStubPage";
|
||||
|
||||
export default function ReportsPage() {
|
||||
return (
|
||||
<ModuleStubPage
|
||||
testId="reports-page"
|
||||
title="Dashboards & Reports"
|
||||
headline="Operational AP reporting."
|
||||
body="Parity stub for Dashboards & Reports."
|
||||
/>
|
||||
);
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_USE_MOCKS: string;
|
||||
readonly VITE_SEA_HAVEN_BRAND?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue