Files
khata-ui/src/Header.tsx
Vishesh 'ironeagle' Bangotra f08bc72037 reports page: spec-driven generate panel, list, and sliceable viewer
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
2026-08-19 22:00:14 +05:30

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>
);
}