mega-plant/components/Sidebar.tsx

172 lines
6.2 KiB
TypeScript
Raw Normal View History

"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type NavLeaf = { href: string; label: string };
export type NavItem =
| { kind: "link"; href: string; label: string; icon: React.ReactNode }
| { kind: "group"; label: string; icon: React.ReactNode; children: NavLeaf[] };
Sites: a real Sites list, with the unbooked lines bookable to a site The container lines (MPLANT04/MPLANT05) have no matching plant item, so they sat unbooked forever with no way to account for them. They can now be booked to a SITE instead. `sites` is a new PocketBase collection derived from the invoices — each distinct invoice site becomes a site, created as invoices are imported. They are never hand-typed, and never deleted: a site can carry notes and hold line bookings, so removing one would orphan that history. /sites lists every site with its revenue ex VAT, its VAT, the total billed, how many invoices and machines it carries, and how many lines are still outstanding. Each row links to a detail page listing the site's invoices and every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a group with Overview and Sites. Booking a line to a site posts NO income — there is no machine to carry it — it only marks the line accounted for, so it stops showing as unbooked. Exactly one of booked_asset / booked_site is ever set, and a line with neither is what surfaces as unbooked everywhere. One container per site, as specified: once a machine-less line has taken a site, that site is withheld from the next one's picker and shown disabled ("already has one") rather than hidden. Free sites sort to the top. The rule is enforced server-side too, so it does not depend on the UI being the only way in — re-posting a taken site is refused with 409. Verified: booking The Hills, then trying again, then taking West Park, and the remaining line sees 8 free and 2 taken. Clearing a booking frees its site. The site picker only appears on lines that need it — no machine, or one already booked to a site — so fully-booked rows keep a single control. Also fixed the Sidebar's group-active check, which derived its prefix from the first child. For a group whose first child is "/" that yields "/" and matched every route, so Dashboard would have been highlighted on every page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:24:06 +00:00
// A group is active when the current path is one of its children, or sits under one
// of them. A child of "/" only matches exactly — otherwise every route would count as
// "under" the dashboard.
function isGroupActive(item: Extract<NavItem, { kind: "group" }>, pathname: string): boolean {
return item.children.some((c) =>
c.href === "/" ? pathname === "/" : pathname === c.href || pathname.startsWith(`${c.href}/`),
);
}
export function Sidebar({ items }: { items: NavItem[] }) {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
// Close drawer on navigation
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
// Lock body scroll when drawer is open
useEffect(() => {
document.body.style.overflow = mobileOpen ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [mobileOpen]);
const renderNav = (onNavigate?: () => void) => (
<nav className="p-3 space-y-0.5 text-sm">
{items.map((item, i) =>
item.kind === "link" ? (
<LeafLink
key={i}
href={item.href}
label={item.label}
icon={item.icon}
active={pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))}
onClick={onNavigate}
/>
) : (
Sites: a real Sites list, with the unbooked lines bookable to a site The container lines (MPLANT04/MPLANT05) have no matching plant item, so they sat unbooked forever with no way to account for them. They can now be booked to a SITE instead. `sites` is a new PocketBase collection derived from the invoices — each distinct invoice site becomes a site, created as invoices are imported. They are never hand-typed, and never deleted: a site can carry notes and hold line bookings, so removing one would orphan that history. /sites lists every site with its revenue ex VAT, its VAT, the total billed, how many invoices and machines it carries, and how many lines are still outstanding. Each row links to a detail page listing the site's invoices and every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a group with Overview and Sites. Booking a line to a site posts NO income — there is no machine to carry it — it only marks the line accounted for, so it stops showing as unbooked. Exactly one of booked_asset / booked_site is ever set, and a line with neither is what surfaces as unbooked everywhere. One container per site, as specified: once a machine-less line has taken a site, that site is withheld from the next one's picker and shown disabled ("already has one") rather than hidden. Free sites sort to the top. The rule is enforced server-side too, so it does not depend on the UI being the only way in — re-posting a taken site is refused with 409. Verified: booking The Hills, then trying again, then taking West Park, and the remaining line sees 8 free and 2 taken. Clearing a booking frees its site. The site picker only appears on lines that need it — no machine, or one already booked to a site — so fully-booked rows keep a single control. Also fixed the Sidebar's group-active check, which derived its prefix from the first child. For a group whose first child is "/" that yields "/" and matched every route, so Dashboard would have been highlighted on every page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:24:06 +00:00
<Group key={i} item={item} active={isGroupActive(item, pathname)} />
)
)}
</nav>
);
return (
<>
{/* Desktop: persistent sidebar (hidden on mobile) */}
<aside className="hidden lg:block w-64 shrink-0 bg-white dark:bg-surface-muted border-r border-gray-200 dark:border-border min-h-[calc(100vh-4rem)]">
{renderNav()}
</aside>
{/* Mobile: hamburger in the top bar triggers a drawer */}
<button
type="button"
onClick={() => setMobileOpen(true)}
aria-label="Open navigation"
className="lg:hidden w-9 h-9 grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-500 dark:text-gray-400"
>
<Menu className="w-5 h-5" />
</button>
{mobileOpen && (
<div
className="lg:hidden fixed inset-0 z-40 bg-black/40"
onClick={() => setMobileOpen(false)}
/>
)}
<aside
className={cn(
"lg:hidden fixed top-0 left-0 bottom-0 z-50 w-72 bg-white dark:bg-surface-muted shadow-2xl transition-transform",
mobileOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex items-center justify-between px-4 h-16 border-b border-gray-200 dark:border-border">
<div className="flex items-center gap-2">
<img src="/brand/logo.png" alt="Mega Plant" className="w-8 h-8 rounded-xl object-contain bg-white dark:bg-bg shadow-sm" />
<span className="text-sm font-semibold text-[#003D5B] dark:text-white">Mega Plant</span>
</div>
<button
type="button"
onClick={() => setMobileOpen(false)}
aria-label="Close navigation"
className="w-9 h-9 grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-500 dark:text-gray-400"
>
<X className="w-5 h-5" />
</button>
</div>
{renderNav(() => setMobileOpen(false))}
</aside>
</>
);
}
function LeafLink({
href, label, icon, active, onClick,
}: {
href: string; label: string; icon: React.ReactNode; active: boolean; onClick?: () => void;
}) {
return (
<Link
href={href}
onClick={onClick}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg font-medium transition",
active
? "bg-gradient-to-r from-[#30638E]/10 to-[#00798C]/5 text-[#003D5B] dark:from-[#30638E]/20 dark:to-[#00798C]/10 dark:text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white"
)}
>
<span className={cn(active ? "text-accent-ink" : "text-gray-400")}>{icon}</span>
<span>{label}</span>
</Link>
);
}
function Group({ item, active }: { item: Extract<NavItem, { kind: "group" }>; active: boolean }) {
const [open, setOpen] = useState(active);
const pathname = usePathname();
return (
<div>
<button
onClick={() => setOpen((o) => !o)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-lg font-medium transition",
active
? "text-[#003D5B] dark:text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white"
)}
>
<span className={cn(active ? "text-accent-ink" : "text-gray-400")}>{item.icon}</span>
<span className="flex-1 text-left">{item.label}</span>
<ChevronDown className={cn("w-4 h-4 text-gray-400 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div className="ml-7 mt-0.5 space-y-0.5 border-l-2 border-gray-100 dark:border-border pl-2">
{item.children.map((child) => {
const childActive = pathname === child.href;
return (
<Link
key={child.href}
href={child.href}
className={cn(
"block px-3 py-1.5 rounded-lg text-xs font-medium transition",
childActive
? "bg-[#30638E]/10 text-[#30638E] dark:bg-[#00798C]/15 dark:text-[#00798C]"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white"
)}
>
{child.label}
</Link>
);
})}
</div>
)}
</div>
);
}