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
502 lines
18 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|