feat(web): fix shell chrome, logo home, and live nav badges (#14)

Refs: AP-42
This commit is contained in:
Adam Moussa 2026-08-14 12:28:35 -04:00 • committed by GitHub
parent ba20f365e3
commit e507a6d48b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 96 additions and 34 deletions

View file

@ -8,3 +8,21 @@ test("home page shows Stampli-parity chrome and mocks", async ({ page }) => {
await expect(page.getByTestId("page-title")).toHaveText("Invoice Processing");
await expect(page.getByTestId("mocks-status")).toContainText("Mocks enabled");
});
test("AP-42 shell chrome: wordmark home, no org label, single nav badge", async ({ page }) => {
await page.goto("/payments");
await expect(page.getByTestId("payment-processing")).toBeVisible();
await page.getByTestId("app-wordmark").click();
await expect(page.getByTestId("page-title")).toHaveText("Invoice Processing");
await expect(page.getByTestId("header-user-name")).toBeVisible();
await expect(page.getByText("Seahaven", { exact: true })).toHaveCount(0);
const invoiceNav = page.getByTestId("nav-invoice-processing");
await expect(invoiceNav.locator(".MuiBadge-badge")).toHaveCount(1);
await expect(invoiceNav.locator(".MuiBadge-badge")).toHaveText(/^\d+$|^99\+$/);
const paymentNav = page.getByTestId("nav-payment-processing");
await expect(paymentNav.locator(".MuiBadge-badge")).toHaveCount(1);
});

View file

@ -30,7 +30,7 @@ export default defineConfig({
],
webServer: [
{
command: "npm run dev -- --port 4173 --strictPort",
command: "VITE_SEA_HAVEN_BRAND=false npm run dev -- --port 4173 --strictPort",
url: "http://localhost:4173",
reuseExistingServer: !process.env.CI,
timeout: 120_000,

View file

@ -9,6 +9,7 @@ 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 { isReadyToPay } from "@seahaven-ap/shared";
import {
AppBar,
Badge,
@ -26,10 +27,11 @@ import {
Typography,
} from "@mui/material";
import type { ReactElement, ReactNode } from "react";
import { useMemo } from "react";
import { useLocation } from "react-router";
import { brand } from "@/lib/brand";
import { navModules } from "@/layout/nav";
import { getMockUsers } from "@/mocks";
import { formatNavBadgeCount, navModules } from "@/layout/nav";
import { getMockInvoices, getMockUsers } from "@/mocks";
import { useMockSession } from "@/providers/use-mock-session";
import { useNavigate, type Path } from "@/router";
@ -64,6 +66,18 @@ export function AppShell({ children }: AppShellProps): ReactElement {
const { user, setCurrentUserId } = useMockSession();
const users = getMockUsers();
const badgeByTestId = useMemo(() => {
const invoices = getMockInvoices();
const attentionCount = invoices.filter(
(invoice) => invoice.status === "pending_approval",
).length;
const readyToPayCount = invoices.filter((invoice) => isReadyToPay(invoice.status)).length;
return {
"nav-invoice-processing": formatNavBadgeCount(attentionCount),
"nav-payment-processing": formatNavBadgeCount(readyToPayCount),
} as Record<string, string | undefined>;
}, []);
return (
<Box sx={{ display: "flex", minHeight: "100vh", bgcolor: "background.default" }}>
<AppBar
@ -77,14 +91,22 @@ export function AppShell({ children }: AppShellProps): ReactElement {
>
<Toolbar sx={{ gap: 2, minHeight: 56, px: 2 }}>
<Typography
component="div"
component="button"
type="button"
data-testid="app-wordmark"
onClick={() => navigate("/")}
sx={{
fontWeight: 800,
letterSpacing: "0.08em",
fontSize: "1.05rem",
mr: 1,
flexShrink: 0,
border: 0,
background: "transparent",
color: "inherit",
cursor: "pointer",
p: 0,
fontFamily: "inherit",
}}
>
{brand.wordmark}
@ -136,19 +158,25 @@ export function AppShell({ children }: AppShellProps): ReactElement {
</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 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ ml: "auto", flexShrink: 0 }}>
<IconButton
color="inherit"
aria-label="Notifications"
size="small"
sx={{ flexShrink: 0 }}
>
<Badge badgeContent={12} color="error" data-testid="notif-badge">
<NotificationsNoneOutlinedIcon />
</Badge>
</IconButton>
<Typography
variant="body2"
sx={{ fontWeight: 700, whiteSpace: "nowrap", flexShrink: 0 }}
data-testid="header-user-name"
>
{user.name}
</Typography>
<FormControl size="small" sx={{ minWidth: 180, flexShrink: 0 }}>
<InputLabel id="mock-user-label" sx={{ color: "rgba(255,255,255,0.8)" }}>
Mock user
</InputLabel>
@ -204,6 +232,7 @@ export function AppShell({ children }: AppShellProps): ReactElement {
.map((item) => {
const selected = isSelected(location.pathname, item.to);
const Icon = iconByTestId[item.testId] ?? ReceiptLongOutlinedIcon;
const badgeContent = badgeByTestId[item.testId];
return (
<Tooltip key={item.testId} title={item.label} placement="right">
<Box
@ -229,24 +258,14 @@ export function AppShell({ children }: AppShellProps): ReactElement {
}}
>
<Badge
badgeContent={item.badge}
badgeContent={badgeContent}
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>
<Icon fontSize="small" />
</Badge>
<Typography
variant="caption"

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { formatNavBadgeCount } from "@/layout/nav";
describe("formatNavBadgeCount", () => {
it("hides zero", () => {
expect(formatNavBadgeCount(0)).toBeUndefined();
});
it("shows exact counts under 100", () => {
expect(formatNavBadgeCount(1)).toBe("1");
expect(formatNavBadgeCount(6)).toBe("6");
expect(formatNavBadgeCount(99)).toBe("99");
});
it("caps at 99+", () => {
expect(formatNavBadgeCount(100)).toBe("99+");
expect(formatNavBadgeCount(250)).toBe("99+");
});
});

View file

@ -4,8 +4,6 @@ export type NavModule = {
label: string;
to: Path;
testId: string;
badge?: string;
secondaryBadge?: string;
};
export const navModules: NavModule[] = [
@ -14,14 +12,11 @@ export const navModules: NavModule[] = [
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" },
@ -30,3 +25,14 @@ export const navModules: NavModule[] = [
{ label: "Dashboards & Reports", to: "/reports", testId: "nav-reports" },
{ label: "System Settings", to: "/settings", testId: "nav-settings" },
];
/** Format a nav count badge: hide at 0, cap at 99+. */
export function formatNavBadgeCount(count: number): string | undefined {
if (count <= 0) {
return undefined;
}
if (count >= 100) {
return "99+";
}
return String(count);
}