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
365 lines
12 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|