WFDASH-WEBAPP/src/components/sidebar.tsx
SJCoetz 05abe499b0 feat(sprint): add new sprint pricing workflow and supporting code
Add full sprint pricing workflow and supporting infrastructure:
- New sidebar navigation entry for the sprint dashboard
- PocketBase category collection schema and migration script
- Server API routes for sprint item claims, attachments, counts, and file proxying
- Dev identity utilities and UI switcher for multi-user testing
- Reusable FilterTabs component replacing ItemStatusTabs
- Updated TypeScript types for sprint-related entities

Also include:
- Fix PocketBase file URL method casing (getUrl → getURL) across codebase
- Add development utility, smoke test, and helper scripts
- Update package.json with new sprint migration command
2026-07-03 07:29:43 +02:00

502 lines
18 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,
ChevronDown,
MapPinned,
Plus,
Settings as SettingsIcon,
Tag,
Zap,
} 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";
/* ============================================================================
* CATEGORY SYSTEM
*
* Sidebar nav is grouped into 5 categories, each with a fixed brand color
* so users can scan by color. Colors are intentionally hard-coded — they
* stay the same regardless of which theme is active, so the sidebar reads
* consistently.
*
* dashboard → green (brand) — home / landing
* management → blue (brand)
* quotes → gold (brand)
* orders → red (brand) — supplier ordering / PO flow
* operations → neutral — collection pool, delivery, repairs (no color)
* ========================================================================== */
type CategoryId = "dashboard" | "management" | "quotes" | "orders" | "operations";
const CATEGORY_COLORS: Record<CategoryId, string> = {
dashboard: "#22c55e",
management: "#4C96D7",
quotes: "#F2AF58",
orders: "#E94E49",
// Operations intentionally has no brand colour — it sits as the
// neutral "everything else" group. Sub-items still need a colour
// string for NavItem's tint math, so we hand it a low-saturation
// slate that disappears on the sidebar background. The Operations
// header itself uses an explicit muted-foreground override (see
// OperationsList below) so it doesn't pick this up.
operations: "#94a3b8",
};
// Helper — produce 8-digit hex with alpha (browser-portable, no color-mix needed)
function withAlpha(hex: string, alphaPercent: number): string {
const a = Math.round((alphaPercent / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${hex}${a}`;
}
type NavEntry = {
href: string;
label: string;
icon: LucideIcon;
category: CategoryId;
};
const dashboardItems: NavEntry[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, category: "dashboard" },
];
const managementItems: NavEntry[] = [
{ href: "/companies", label: "Companies", icon: Building2, category: "management" },
{ href: "/customers", label: "Customers", icon: Users, category: "management" },
{ href: "/suppliers", label: "Suppliers", icon: Truck, category: "management" },
{ href: "/supplier-map", label: "Supplier Map", icon: MapPinned, category: "management" },
{ href: "/pricelist", label: "Pricelist", icon: Tag, category: "management" },
];
const quoteItems: NavEntry[] = [
{ href: "/quotes", label: "Quote List", icon: FileText, category: "quotes" },
{ href: "/sprint", label: "Sprint", icon: Zap, category: "quotes" },
{ href: "/quotes/approvals", label: "Pending Approvals", icon: CheckCircle, category: "quotes" },
{ href: "/quotes/archived", label: "Archived", icon: Archive, category: "quotes" },
];
const orderItems: NavEntry[] = [
{ href: "/orders/new", label: "New Order", icon: Plus, category: "orders" },
{ href: "/orders", label: "Order List", icon: ShoppingCart, category: "orders" },
];
const operationsItems: NavEntry[] = [
{ href: "/collection-pool", label: "Collection Pool", icon: Package, category: "operations" },
{ href: "/delivery-notes", label: "Delivery Notes", icon: ClipboardList, category: "operations" },
{ href: "/repairs", label: "Repairs", icon: Wrench, category: "operations" },
];
const CATEGORY_LABELS: Record<CategoryId, string> = {
dashboard: "Dashboard",
management: "Management",
quotes: "Quotes",
orders: "Orders",
operations: "Operations",
};
/* ============================================================================
* Active-route resolution for a group of sibling nav items.
*
* The naïve rule "exact OR starts-with-prefix" breaks down when one item
* is a child of another (e.g. `/orders/new` is a child of `/orders`).
* On `/orders/new` both rules are true for `/orders`, so two nav rows
* highlight at once. We fix it by demanding that the item is the
* MOST SPECIFIC match within the group — no sibling may have a longer
* matching prefix.
* ========================================================================== */
function isNavItemActive(itemHref: string, siblings: NavEntry[], pathname: string): boolean {
if (pathname === itemHref) return true;
if (!pathname.startsWith(itemHref + "/")) return false;
// Some sibling has a more specific (longer) matching href → we're
// actually on that child, not on this parent.
return !siblings.some(
(other) =>
other.href !== itemHref &&
other.href.length > itemHref.length &&
(pathname === other.href || pathname.startsWith(other.href + "/"))
);
}
/* ============================================================================
* NavItem — single nav link with category-colored icon
*
* Each sub-item takes its colour from the parent category, matching the
* accordion header. Strength per category:
* - dashboard → full green (home entry, stands alone)
* - orders → full red ("orders red" — visible operational signal)
* - management → 78% alpha blue
* - quotes → 78% alpha gold
* - operations → 78% alpha slate (the operations colour, not white)
*
* Active state: sidebar-accent bg + full-strength category colour text.
* Computed via isNavItemActive above so that nested routes like
* /orders/new don't also light up /orders.
* ========================================================================== */
function NavItem({
item,
active,
onClick,
indent = false,
}: {
item: NavEntry;
active: boolean;
onClick?: () => void;
indent?: boolean;
}) {
const color = CATEGORY_COLORS[item.category];
const isFullColor =
item.category === "dashboard" || item.category === "orders";
const inactiveText = isFullColor ? color : withAlpha(color, 78);
const activeColor = color;
return (
<Link
href={item.href}
onClick={onClick}
style={
active
? { background: "var(--sidebar-accent)", color: activeColor }
: { color: inactiveText }
}
className={`group flex items-center gap-3 rounded-xl ${
indent ? "px-2.5 py-2 text-sm" : "px-3 py-2.5 text-sm"
} font-medium transition-all duration-150 ${
active ? "" : "hover:opacity-80"
}`}
>
<item.icon
className={`${indent ? "h-4 w-4" : "h-[18px] w-[18px]"} shrink-0`}
style={{ color: active ? activeColor : inactiveText }}
/>
{item.label}
</Link>
);
}
/* ============================================================================
* NavGroup — accordion wrapper tinted with category color
* ========================================================================== */
function NavGroup({
category,
open,
onToggle,
headerActive,
headerIcon,
items,
pathname,
onLinkClick,
}: {
category: CategoryId;
open: boolean;
onToggle: () => void;
headerActive: boolean;
headerIcon: LucideIcon;
items: NavEntry[];
pathname: string;
onLinkClick?: () => void;
}) {
const color = CATEGORY_COLORS[category];
const HeaderIcon = headerIcon;
return (
<div
className="rounded-xl border p-1.5"
style={{
background: withAlpha(color, open ? 10 : 6),
borderColor: withAlpha(color, open ? 28 : 18),
}}
>
<button
type="button"
onClick={onToggle}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors ${
headerActive ? "" : "hover:opacity-80"
}`}
style={{
color: headerActive ? color : withAlpha(color, 78),
}}
>
<HeaderIcon
className="h-3.5 w-3.5"
style={{ color: headerActive ? color : color }}
/>
{CATEGORY_LABELS[category]}
<ChevronDown
className={`ml-auto h-3.5 w-3.5 transition-transform ${open ? "rotate-180" : ""}`}
style={{ color: withAlpha(color, 60) }}
/>
</button>
{open && (
<div className="mt-1 space-y-0.5">
{items.map((item) => {
// Quotes has a dedicated /quotes/approvals and /quotes/archived
// section; the All Quotes parent shouldn't highlight when on
// a child route, since the child items live in the same nav
// group. isNavItemActive already handles that via the
// longest-prefix rule, but Quotes has an extra wrinkle:
// `/quotes` is also the list root, so an exact match there
// is fine, while `/quotes/approvals` is a child. The
// longest-prefix rule handles both without a special case,
// so we just call it uniformly.
const isActive = isNavItemActive(item.href, items, pathname);
return (
<NavItem
key={item.href}
item={item}
active={isActive}
onClick={onLinkClick}
indent
/>
);
})}
</div>
)}
</div>
);
}
/* ============================================================================
* NavContent — the actual sidebar body
* ========================================================================== */
function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
const pathname = usePathname();
const { user, logout } = useAuth();
const { theme, setTheme } = useTheme();
const isManagementActive = managementItems.some(
(item) => pathname === item.href || pathname.startsWith(item.href + "/")
);
const isQuotesActive =
pathname === "/quotes" || pathname.startsWith("/quotes/");
const isOrdersActive = orderItems.some(
(item) => pathname === item.href || pathname.startsWith(item.href + "/")
);
const isOperationsActive = operationsItems.some(
(item) => pathname === item.href || pathname.startsWith(item.href + "/")
);
const [managementOpen, setManagementOpen] = useState(isManagementActive);
const [quotesOpen, setQuotesOpen] = useState(isQuotesActive);
const [ordersOpen, setOrdersOpen] = useState(isOrdersActive);
const [operationsOpen, setOperationsOpen] = useState(isOperationsActive);
// Auto-open accordion when route inside it becomes active
useEffect(() => {
if (isManagementActive) setManagementOpen(true);
if (isQuotesActive) setQuotesOpen(true);
if (isOrdersActive) setOrdersOpen(true);
if (isOperationsActive) setOperationsOpen(true);
}, [isManagementActive, isQuotesActive, isOrdersActive, isOperationsActive]);
const isDashboardActive =
pathname === "/dashboard" || pathname.startsWith("/dashboard/");
return (
<div className="flex flex-col h-full">
{/* Brand block */}
<div className="px-5 pt-6 pb-4">
<div className="flex items-center gap-3">
<div className="grid size-10 place-items-center rounded-xl bg-primary text-primary-foreground shadow-sm">
<Package className="h-5 w-5" />
</div>
<div>
<h1 className="text-lg font-semibold tracking-tight text-sidebar-foreground font-heading-theme">
WFDASH
</h1>
<p className="text-xs text-sidebar-foreground/70 mt-0.5">
Westcott Fasteners
</p>
</div>
</div>
{/* Brand color stripe */}
<div className="mt-4 grid grid-cols-4 gap-1">
<span className="h-1.5 rounded-full" style={{ background: "#22c55e" }} />
<span className="h-1.5 rounded-full" style={{ background: "#4C96D7" }} />
<span className="h-1.5 rounded-full" style={{ background: "#F2AF58" }} />
<span className="h-1.5 rounded-full" style={{ background: "#E94E49" }} />
</div>
</div>
<Separator className="mx-3" />
{/* Main nav */}
<ScrollArea className="flex-1 px-3 py-3">
<nav className="space-y-1.5">
{/* Dashboard — flat top item, no accordion */}
<NavItem
item={dashboardItems[0]}
active={isDashboardActive}
onClick={onLinkClick}
/>
{/* Management — accordion */}
<NavGroup
category="management"
open={managementOpen}
onToggle={() => setManagementOpen((o) => !o)}
headerActive={isManagementActive}
headerIcon={Users}
items={managementItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
{/* Quotes — accordion */}
<NavGroup
category="quotes"
open={quotesOpen}
onToggle={() => setQuotesOpen((o) => !o)}
headerActive={isQuotesActive}
headerIcon={FileText}
items={quoteItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
{/* Orders — accordion */}
<NavGroup
category="orders"
open={ordersOpen}
onToggle={() => setOrdersOpen((o) => !o)}
headerActive={isOrdersActive}
headerIcon={ShoppingCart}
items={orderItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
{/* Operations — accordion (neutral category, hides on click) */}
<NavGroup
category="operations"
open={operationsOpen}
onToggle={() => setOperationsOpen((o) => !o)}
headerActive={isOperationsActive}
headerIcon={Package}
items={operationsItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
</nav>
</ScrollArea>
<Separator className="mx-3" />
{/* Bottom block: settings (icon-only) + theme + avatar + logout */}
<div className="p-3 space-y-2">
{/* Settings — small icon-only button bottom-left */}
<Link
href="/settings"
onClick={onLinkClick}
aria-label="Settings"
title="Settings"
className="grid size-9 place-items-center rounded-xl text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<SettingsIcon className="h-[18px] w-[18px]" />
</Link>
{/* Theme toggle */}
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-3 text-muted-foreground hover:text-foreground h-9 px-2.5 rounded-xl"
onClick={() => setTheme(theme === "dark" ? "warm" : "dark")}
>
{theme === "dark" ? (
<>
<Sun className="h-[18px] w-[18px]" />
<span className="text-sm">Light Mode</span>
</>
) : (
<>
<Moon className="h-[18px] w-[18px]" />
<span className="text-sm">Dark Mode</span>
</>
)}
</Button>
{/* User */}
<div className="flex items-center gap-3 px-2.5 py-2">
<Avatar className="h-9 w-9 shrink-0 ring-2 ring-border/50">
<AvatarFallback className="text-xs font-medium bg-muted">
{user?.name?.charAt(0)?.toUpperCase() ||
user?.email?.charAt(0)?.toUpperCase() ||
"U"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{user?.name || "User"}
</p>
<p className="text-xs text-muted-foreground truncate">
{user?.email}
</p>
</div>
</div>
{/* Logout */}
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-3 text-destructive hover:text-destructive hover:bg-destructive/8 h-9 px-2.5 rounded-xl"
onClick={logout}
>
<LogOut className="h-[18px] w-[18px]" />
<span className="text-sm">Logout</span>
</Button>
</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 bg-sidebar border-r border-sidebar-border lg:h-dvh lg:sticky lg:top-0">
<NavContent />
</aside>
<div className="lg:hidden fixed top-0 left-0 right-0 z-40 flex items-center gap-3 border-b bg-background/95 backdrop-blur-sm px-4 h-14">
<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-60 p-0 bg-sidebar text-sidebar-foreground border-sidebar-border">
<SheetTitle className="sr-only">Navigation</SheetTitle>
<NavContent onLinkClick={() => setOpen(false)} />
</SheetContent>
</Sheet>
<h1 className="text-lg font-semibold font-heading-theme">WFDASH</h1>
</div>
</>
);
}