Add a /reports page built on react-openapi's runtime configs: - inline generate panel (GroupSpec enum, prefetched payee picker, strict DD-MM-YYYY range, 400 surfacing) - metadata report list with view/regenerate/delete - viewer that slices the cached per-entity payload via metadata.group_options, aggregates metrics + txns, pivots period x payee, and renders month accordions - route + nav entry; no hand-rolled schema duplication
292 lines
8.1 KiB
TypeScript
292 lines
8.1 KiB
TypeScript
import * as React from "react";
|
|
import { useLocation, matchPath, useNavigate } from "react-router-dom";
|
|
import {
|
|
AppBar,
|
|
Toolbar,
|
|
Typography,
|
|
IconButton,
|
|
Tooltip,
|
|
Button,
|
|
Box,
|
|
Drawer,
|
|
List,
|
|
ListItemButton,
|
|
ListItemText,
|
|
Divider,
|
|
useMediaQuery,
|
|
useTheme,
|
|
} from "@mui/material";
|
|
import MenuIcon from "@mui/icons-material/Menu";
|
|
import LogoutIcon from "@mui/icons-material/Logout";
|
|
import DarkModeIcon from "@mui/icons-material/DarkMode";
|
|
import LightModeIcon from "@mui/icons-material/LightMode";
|
|
import { useAuth } from "../react-auth";
|
|
import { ColorModeContext } from "./shared-theme/AppTheme";
|
|
|
|
interface HeaderProps {
|
|
routerMapping: {
|
|
path: string;
|
|
headerTitle: string;
|
|
}[];
|
|
onDrawerToggle?: () => void;
|
|
}
|
|
|
|
const NAV_LINKS = [
|
|
{ label: "Home", path: "/" },
|
|
{ label: "Expenses", path: "/expenses" },
|
|
{ label: "Reports", path: "/reports" },
|
|
{ label: "Fetch Requests", path: "/fetch-requests" },
|
|
];
|
|
|
|
function isActive(path: string, locationPath: string): boolean {
|
|
if (path === "/") return locationPath === "/" || locationPath === "/home";
|
|
return matchPath({ path, end: false }, locationPath) !== null;
|
|
}
|
|
|
|
export default function Header({ routerMapping, onDrawerToggle }: HeaderProps) {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const { currentUser, logout } = useAuth();
|
|
const { mode, toggleColorMode } = React.useContext(ColorModeContext);
|
|
|
|
const theme = useTheme();
|
|
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
|
|
const [mobileOpen, setMobileOpen] = React.useState(false);
|
|
|
|
const isAuthenticated = !!currentUser;
|
|
|
|
const navContent = (
|
|
<Box
|
|
sx={{
|
|
display: { xs: "none", md: "flex" },
|
|
alignItems: "center",
|
|
gap: 0.5,
|
|
ml: 3,
|
|
}}
|
|
>
|
|
{NAV_LINKS.map((link) => {
|
|
const active = isActive(link.path, location.pathname);
|
|
return (
|
|
<Button
|
|
key={link.path}
|
|
color="inherit"
|
|
onClick={() => navigate(link.path)}
|
|
sx={{
|
|
textTransform: "none",
|
|
fontWeight: active ? 600 : 500,
|
|
color: active ? "text.primary" : "text.secondary",
|
|
px: 1.5,
|
|
borderRadius: "6px",
|
|
position: "relative",
|
|
"&::after":
|
|
active
|
|
? {
|
|
content: '""',
|
|
position: "absolute",
|
|
left: 12,
|
|
right: 12,
|
|
bottom: 4,
|
|
height: 2,
|
|
borderRadius: 1,
|
|
backgroundColor: "primary.main",
|
|
}
|
|
: {},
|
|
"&:hover": { backgroundColor: "action.hover", color: "text.primary" },
|
|
}}
|
|
>
|
|
{link.label}
|
|
</Button>
|
|
);
|
|
})}
|
|
</Box>
|
|
);
|
|
|
|
const authSection = (
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
{isAuthenticated ? (
|
|
<>
|
|
<Button
|
|
color="inherit"
|
|
onClick={() => navigate("/admin")}
|
|
sx={{
|
|
textTransform: "none",
|
|
fontWeight: isActive("/admin", location.pathname) ? 600 : 500,
|
|
color: isActive("/admin", location.pathname) ? "text.primary" : "text.secondary",
|
|
display: { xs: "none", sm: "inline-flex" },
|
|
}}
|
|
>
|
|
Admin
|
|
</Button>
|
|
<Button
|
|
color="inherit"
|
|
onClick={() => navigate("/profile/me")}
|
|
sx={{ textTransform: "none", fontWeight: 500 }}
|
|
>
|
|
{currentUser.username}
|
|
</Button>
|
|
<Tooltip title="Logout">
|
|
<IconButton color="inherit" onClick={logout} aria-label="Logout">
|
|
<LogoutIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</>
|
|
) : (
|
|
<Button
|
|
color="inherit"
|
|
variant="outlined"
|
|
onClick={() => navigate("/login")}
|
|
sx={{ textTransform: "none" }}
|
|
>
|
|
Login
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
);
|
|
|
|
const drawerContent = (
|
|
<Box sx={{ width: 260 }} role="presentation">
|
|
<Box sx={{ p: 2 }}>
|
|
<Brand />
|
|
</Box>
|
|
<Divider />
|
|
<List>
|
|
{routerMapping
|
|
.filter((r) => !r.path.startsWith("/login") && !r.path.startsWith("/register") && !r.path.startsWith("/profile"))
|
|
.map((route) => {
|
|
const active = isActive(route.path, location.pathname);
|
|
return (
|
|
<ListItemButton
|
|
key={route.path}
|
|
selected={active}
|
|
onClick={() => {
|
|
navigate(route.path);
|
|
setMobileOpen(false);
|
|
}}
|
|
sx={{
|
|
borderRadius: "6px",
|
|
mx: 1,
|
|
mb: 0.5,
|
|
"&.Mui-selected": {
|
|
backgroundColor: "action.selected",
|
|
},
|
|
}}
|
|
>
|
|
<ListItemText
|
|
primary={route.headerTitle}
|
|
primaryTypographyProps={{ fontWeight: active ? 600 : 500, fontSize: 14 }}
|
|
/>
|
|
</ListItemButton>
|
|
);
|
|
})}
|
|
<Divider sx={{ my: 1 }} />
|
|
{isAuthenticated ? (
|
|
<>
|
|
<ListItemButton onClick={() => navigate("/profile/me")} sx={{ borderRadius: "6px", mx: 1 }}>
|
|
<ListItemText primary="Profile" primaryTypographyProps={{ fontSize: 14 }} />
|
|
</ListItemButton>
|
|
<ListItemButton onClick={logout} sx={{ borderRadius: "6px", mx: 1 }}>
|
|
<ListItemText primary="Logout" primaryTypographyProps={{ fontSize: 14 }} />
|
|
</ListItemButton>
|
|
</>
|
|
) : (
|
|
<ListItemButton onClick={() => navigate("/login")} sx={{ borderRadius: "6px", mx: 1 }}>
|
|
<ListItemText primary="Login" primaryTypographyProps={{ fontSize: 14 }} />
|
|
</ListItemButton>
|
|
)}
|
|
</List>
|
|
</Box>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<AppBar
|
|
position="sticky"
|
|
color="default"
|
|
sx={{
|
|
zIndex: (theme) => theme.zIndex.drawer + 1,
|
|
backdropFilter: "blur(8px)",
|
|
boxShadow: "none",
|
|
borderBottom: "1px solid",
|
|
borderColor: "divider",
|
|
backgroundColor: "background.default",
|
|
}}
|
|
>
|
|
<Toolbar>
|
|
{/* MOBILE MENU */}
|
|
{isMobile && (
|
|
<IconButton
|
|
color="inherit"
|
|
edge="start"
|
|
onClick={() => setMobileOpen(true)}
|
|
aria-label="Open navigation"
|
|
sx={{ mr: 1 }}
|
|
>
|
|
<MenuIcon />
|
|
</IconButton>
|
|
)}
|
|
|
|
{/* BRAND — always leftmost */}
|
|
<Brand onClick={() => navigate("/")} />
|
|
|
|
{navContent}
|
|
|
|
<Box sx={{ flexGrow: 1 }} />
|
|
|
|
{/* THEME TOGGLE — right side */}
|
|
<IconButton onClick={toggleColorMode} color="inherit" aria-label="Toggle color mode" sx={{ mr: 1 }}>
|
|
{mode === "dark" ? <LightModeIcon /> : <DarkModeIcon />}
|
|
</IconButton>
|
|
|
|
{authSection}
|
|
</Toolbar>
|
|
</AppBar>
|
|
|
|
<Drawer
|
|
open={mobileOpen}
|
|
onClose={() => setMobileOpen(false)}
|
|
ModalProps={{ keepMounted: true }}
|
|
>
|
|
{drawerContent}
|
|
</Drawer>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Brand({ onClick }: { onClick?: () => void }) {
|
|
return (
|
|
<Box
|
|
onClick={onClick}
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 1,
|
|
cursor: onClick ? "pointer" : "default",
|
|
userSelect: "none",
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: "8px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
background: "linear-gradient(135deg, #635BFF 0%, #8B5CF6 100%)",
|
|
color: "#fff",
|
|
fontWeight: 700,
|
|
fontSize: 15,
|
|
}}
|
|
>
|
|
K
|
|
</Box>
|
|
<Typography
|
|
variant="h6"
|
|
sx={{ fontWeight: 700, letterSpacing: "-0.02em", color: "text.primary" }}
|
|
>
|
|
Khata
|
|
</Typography>
|
|
</Box>
|
|
);
|
|
}
|