WFDASH-WEBAPP/src/components/sidebar.tsx
SJCoetz 7f924ccc45 refactor: switch hardcoded UI colors to semantic theme tokens
Overhaul the app's color system by replacing all hardcoded Tailwind color classes with semantic theme variables to ensure consistent theming across all components, pages, and tests.

Additional changes:
- Add assigned date support for order item status updates: add `assignedDate` field to the order item status PATCH API, implement server-side normalization/validation, and add corresponding tests
- Refactor purchase order PDF workflows to use the consolidated `purchase_orders.documents` field instead of the legacy separate `purchase_order_pdfs` collection
- Fix quote number parsing and formatting to support both dashed (Q-YYYY-MM-NNN) and dashless (QYYYYMMNNN) formats
- Rename `driver_pool` item status to `collection_pool` across the codebase
- Add null handling for order item quantities and pricing to display "—" for missing values
- Improve filter tab styling: make mobile tabs scrollable, prevent text wrapping, hide scrollbar
- Add new utility scripts, API routes, migrations, and polish small UI/UX fixes including tsconfig updates and theme hook improvements
2026-07-07 17:22:03 +02:00

365 lines
12 KiB
TypeScript

"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "./auth-provider";
import { useTheme } from "next-themes";
import type { LucideIcon } from "lucide-react";
import {
LayoutDashboard,
Users,
Building2,
Truck,
FileText,
ShoppingCart,
LogOut,
Sun,
Moon,
Menu,
CheckCircle,
Archive,
Package,
ClipboardList,
Wrench,
MapPinned,
MapPin,
Plus,
Settings as SettingsIcon,
Tag,
Zap,
ChevronDown,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
/* ============================================================================
* Sidebar nav structure
*
* Six flat sections. The active state is the theme's single accent.
* No per-category brand colours — every section reads the same so the eye
* scans label + icon, not colour.
* ========================================================================== */
type NavEntry = {
href: string;
label: string;
icon: LucideIcon;
};
const dashboardItems: NavEntry[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
];
const managementItems: NavEntry[] = [
{ href: "/companies", label: "Companies", icon: Building2 },
{ href: "/customers", label: "Customers", icon: Users },
{ href: "/suppliers", label: "Suppliers", icon: Truck },
{ href: "/supplier-map", label: "Supplier Map", icon: MapPinned },
{ href: "/pricelist", label: "Pricelist", icon: Tag },
];
const quoteItems: NavEntry[] = [
{ href: "/quotes", label: "Quote List", icon: FileText },
{ href: "/sprint", label: "Sprint", icon: Zap },
{ href: "/quotes/approvals", label: "Pending Approvals", icon: CheckCircle },
{ href: "/quotes/archived", label: "Archived", icon: Archive },
];
const orderItems: NavEntry[] = [
{ href: "/orders/new", label: "New Order", icon: Plus },
{ href: "/orders", label: "Order List", icon: ShoppingCart },
{ href: "/orders/assign", label: "Assign Drivers", icon: Truck },
];
const operationsItems: NavEntry[] = [
{ href: "/collection-pool", label: "Collection Pool", icon: Package },
{ href: "/collection-map", label: "Collection Map", icon: MapPin },
{ href: "/delivery-notes", label: "Delivery Notes", icon: ClipboardList },
{ href: "/repairs", label: "Repairs", icon: Wrench },
];
/* ============================================================================
* Active-route resolution
* The naïve "exact OR starts-with-prefix" rule double-highlights
* `/orders/new` and `/orders`. We fix it by demanding the item is the
* most-specific match within its group.
* ========================================================================== */
function isActive(itemHref: string, siblings: NavEntry[], pathname: string): boolean {
if (pathname === itemHref) return true;
if (!pathname.startsWith(itemHref + "/")) return false;
return !siblings.some(
(other) =>
other.href !== itemHref &&
other.href.length > itemHref.length &&
(pathname === other.href || pathname.startsWith(other.href + "/"))
);
}
/* ============================================================================
* NavSection — collapsible group with a small uppercase label
* ========================================================================== */
function NavSection({
label,
items,
pathname,
defaultOpen = false,
onLinkClick,
}: {
label: string;
items: NavEntry[];
pathname: string;
defaultOpen?: boolean;
onLinkClick?: () => void;
}) {
const sectionActive = items.some(
(i) => pathname === i.href || pathname.startsWith(i.href + "/")
);
const [open, setOpen] = useState(defaultOpen || sectionActive);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (sectionActive) setOpen(true);
}, [sectionActive]);
return (
<div className="space-y-0.5">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-1.5 px-2.5 py-1 text-[10.5px] font-semibold uppercase tracking-widest text-muted-foreground hover:text-foreground transition-colors"
>
{label}
<ChevronDown
className={cn(
"ml-auto h-3 w-3 transition-transform",
open ? "" : "-rotate-90"
)}
/>
</button>
{open && (
<div className="space-y-0.5">
{items.map((item) => (
<NavLink
key={item.href}
item={item}
active={isActive(item.href, items, pathname)}
onClick={onLinkClick}
/>
))}
</div>
)}
</div>
);
}
/* ============================================================================
* NavLink — single nav row, theme-driven active state
* ========================================================================== */
function NavLink({
item,
active,
onClick,
}: {
item: NavEntry;
active: boolean;
onClick?: () => void;
}) {
return (
<Link
href={item.href}
onClick={onClick}
className={cn(
"group flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors",
active
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
)}
>
<item.icon
className={cn(
"h-[17px] w-[17px] shrink-0",
active ? "text-primary" : "text-sidebar-foreground/60 group-hover:text-sidebar-foreground/80"
)}
aria-hidden
/>
<span className="truncate">{item.label}</span>
{active && (
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-primary" aria-hidden />
)}
</Link>
);
}
/* ============================================================================
* NavContent — the actual sidebar body
* ========================================================================== */
function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
const pathname = usePathname();
const { user, logout } = useAuth();
const { theme, setTheme } = useTheme();
return (
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
{/* Brand block */}
<Link
href="/dashboard"
onClick={onLinkClick}
className="flex items-center gap-3 px-5 pt-6 pb-5"
>
<div className="grid size-9 place-items-center rounded-lg bg-primary text-primary-foreground">
<Package className="h-4.5 w-4.5" />
</div>
<div className="leading-tight">
<div className="text-[15px] font-semibold tracking-tight font-heading-theme">
WFDASH
</div>
<div className="text-[11px] text-muted-foreground">
Westcott Fasteners
</div>
</div>
</Link>
<Separator className="mx-3" />
{/* Nav */}
<ScrollArea className="flex-1 px-3 py-3">
<nav className="space-y-3">
{/* Dashboard — single, top-level */}
<NavLink
item={dashboardItems[0]}
active={isActive(dashboardItems[0].href, dashboardItems, pathname)}
onClick={onLinkClick}
/>
<Separator className="mx-1" />
<NavSection
label="Management"
items={managementItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
<NavSection
label="Quotes"
items={quoteItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
<NavSection
label="Orders"
items={orderItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
<NavSection
label="Operations"
items={operationsItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
</nav>
</ScrollArea>
<Separator className="mx-3" />
{/* Bottom block */}
<div className="space-y-1 p-3">
<Link
href="/settings"
onClick={onLinkClick}
aria-label="Settings"
title="Settings"
className="flex h-9 items-center gap-3 rounded-lg px-2.5 text-sm text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<SettingsIcon className="h-[17px] w-[17px]" />
Settings
</Link>
<button
type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="flex h-9 w-full items-center gap-3 rounded-lg px-2.5 text-sm text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
{theme === "dark" ? (
<>
<Sun className="h-[17px] w-[17px]" />
Light mode
</>
) : (
<>
<Moon className="h-[17px] w-[17px]" />
Dark mode
</>
)}
</button>
<Separator className="my-1" />
<div className="flex items-center gap-3 px-2.5 py-2">
<Avatar className="h-8 w-8 shrink-0">
<AvatarFallback className="text-xs font-medium bg-muted text-foreground">
{user?.name?.charAt(0)?.toUpperCase() ||
user?.email?.charAt(0)?.toUpperCase() ||
"U"}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{user?.name || "User"}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{user?.email}
</p>
</div>
<button
type="button"
onClick={logout}
aria-label="Logout"
title="Logout"
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<LogOut className="h-[15px] w-[15px]" />
</button>
</div>
</div>
</div>
);
}
/* ============================================================================
* Sidebar — desktop + mobile (Sheet) wrappers
* ========================================================================== */
export function Sidebar() {
const [open, setOpen] = useState(false);
return (
<>
<aside className="hidden lg:flex lg:w-60 lg:flex-col lg:h-dvh lg:sticky lg:top-0 lg:border-r lg:border-sidebar-border">
<NavContent />
</aside>
<div className="fixed inset-x-0 top-0 z-40 flex h-14 items-center gap-3 border-b bg-background/95 px-4 backdrop-blur-sm lg:hidden">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
render={<Button variant="ghost" size="icon" className="h-9 w-9" />}
>
<Menu className="h-5 w-5" />
</SheetTrigger>
<SheetContent
side="left"
className="w-64 gap-0 border-r border-sidebar-border p-0"
>
<SheetTitle className="sr-only">Navigation</SheetTitle>
<NavContent onLinkClick={() => setOpen(false)} />
</SheetContent>
</Sheet>
<div className="text-base font-semibold font-heading-theme">WFDASH</div>
</div>
</>
);
}