mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-07 16:18:53 +00:00
304 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|