seahaven-ap/src/layout/AppShell.tsx
2026-08-14 12:28:35 -04:00

304 lines
10 KiB
TypeScript

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 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,
Box,
FormControl,
IconButton,
InputAdornment,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Toolbar,
Tooltip,
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 { formatNavBadgeCount, navModules } from "@/layout/nav";
import { getMockInvoices, getMockUsers } from "@/mocks";
import { useMockSession } from "@/providers/use-mock-session";
import { useNavigate, type Path } from "@/router";
const RAIL_WIDTH = 96;
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,
};
type AppShellProps = {
children: ReactNode;
};
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();
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
position="fixed"
elevation={0}
sx={{
zIndex: (theme) => theme.zIndex.drawer + 1,
background: brand.headerGradient,
color: "#fff",
}}
>
<Toolbar sx={{ gap: 2, minHeight: 56, px: 2 }}>
<Typography
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}
</Typography>
<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={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>
<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>
<Box
component="nav"
data-testid="module-rail"
sx={{
width: RAIL_WIDTH,
flexShrink: 0,
position: "fixed",
top: 56,
bottom: 0,
left: 0,
borderRight: 1,
borderColor: "divider",
bgcolor: "#F7F9FC",
pt: 1,
overflowY: "auto",
zIndex: (theme) => theme.zIndex.appBar - 1,
}}
>
<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;
const badgeContent = badgeByTestId[item.testId];
return (
<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={badgeContent}
color="primary"
overlap="circular"
sx={{
"& .MuiBadge-badge": { fontSize: "0.65rem", height: 16, minWidth: 16 },
}}
>
<Icon fontSize="small" />
</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>
);
})}
</Stack>
</Box>
<Box
component="main"
sx={{
flexGrow: 1,
width: `calc(100% - ${RAIL_WIDTH}px)`,
ml: `${RAIL_WIDTH}px`,
bgcolor: "background.default",
minHeight: "100vh",
}}
>
<Toolbar />
{children}
</Box>
</Box>
);
}