WFDASH-WEBAPP/src/components/sidebar.tsx
Dave d98c263f45 feat(scan): standalone delivery-note scanner PWA at /scan
Scan the QR printed on a delivery note, see what it is, and act on it:
reprint, photograph the signed copy, or walk it toward invoicing. It is its
own route group outside `(app)` so it gets a clean full-bleed layout and its
own manifest — "add to home screen" should give the operator a scanner, not
the CRM. Mirrors the pattern `(counter)` already uses.

Two detectors, not one. `BarcodeDetector` is fast and allocation-free but is
Chrome-only: Safari/iOS still has it "Under Consideration" (WebKit bug
#254573) and Firefox does not ship it. A scanner that only used it would
simply not work on an iPhone. So native where it exists, jsQR everywhere
else — pure JS, so no 2 MB WASM payload on a weak warehouse signal, and the
delivery-note QR is always a QR code. Both run against the same canvas frame
so the caller never branches. Also requires HTTPS, which the page says
plainly when it isn't.

The QR carries the human number, so the first call is a new resolve endpoint
mapping number -> PB id. Everything after that speaks ids, which is what every
existing route already takes. The number is shape-validated before any network
call, so a supplier's packing label can't fire a lookup.

STATUS LADDER. mark-invoiced hard-refuses anything that isn't po_received, and
customer-po refuses a note that isn't sent/loaded — so rather than offering a
button that 500s, each action only appears when the server would accept it, and
the card shows which step comes next. It routes draft -> loaded -> po_received
-> invoiced via the existing load-dn, NOT draft -> sent, because draft -> sent
means "create a quote and email it" and a warehouse scan must never trigger
that silently.

The signed-copy upload deliberately uses the upload-only endpoint, not
/api/v1/deliveries/mark-signed-copy — that one flips the note to `completed`
in the same write, which would jump a note straight past invoicing.

Manual entry is always available, so the page works on a device with no camera
or a browser that won't scan.

Tests decode the REAL delivery-note QR through the production fallback path at
print size, and assert the shape check rejects a supplier QR, a random string
and an over-long number.
2026-10-04 13:15:56 +02:00

555 lines
20 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 {
Archive,
BarChart3,
BookOpen,
Bot,
Boxes,
Building2,
CheckCircle,
ChevronDown,
ClipboardList,
FileText,
LayoutDashboard,
LogOut,
MapPin,
Menu,
Moon,
Package,
Plus,
QrCode,
Settings as SettingsIcon,
ShoppingCart,
Sun,
Tag,
Truck,
Users,
Wrench,
Zap,
} from "lucide-react";;
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { BrandStripe } from "@/components/brand-stripe";
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
*
* Flat sections — every nav group renders the same. The active state is
* the theme's single accent. No per-category brand colours 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 },
// Office pin tool + (future) visit log. The page itself is a
// launcher right now — the full visit-history view is blocked on
// PB schema + FleetAI credentials per the rep-visit-check-in
// design doc.
{ href: "/visits", label: "Visits", icon: MapPin },
{ href: "/suppliers", label: "Suppliers", icon: Truck },
];
const quoteItems: NavEntry[] = [
{ href: "/quotes", label: "Quote List", icon: FileText },
// ── TEMPORARILY HIDDEN ──────────────────────────────────────────────
// Quote sprint board — disabled pending a rebuild. Operators should
// process quotes through /quotes/[id]/process until this is
// re-enabled. Revert by uncommenting the line below.
// { href: "/sprint", label: "Sprint", icon: Zap },
{ href: "/quotes/approvals", label: "Pending Approvals", icon: CheckCircle },
];
// Assign Drivers used to live here as its own sidebar entry, but the
// driver-assignment workflow is now fully embedded in the Collection
// Pool page (per-row Assign + per-bucket bulk Assign). Keeping a
// redundant top-level link only invited confusion, so it's gone.
// The full /orders/assign route is still reachable by URL for the
// older admin flow but is no longer surfaced in the nav.
const orderItems: NavEntry[] = [
{ href: "/orders/new", label: "New Order", icon: Plus },
{ href: "/orders", label: "Order List", icon: ShoppingCart },
// ── TEMPORARILY HIDDEN ──────────────────────────────────────────────
// Order sprint board — disabled pending a rebuild. Operators should
// work orders through /orders/[id] until this is re-enabled.
// Revert by uncommenting the line below.
// { href: "/orders/sprint", label: "Order Sprint", icon: Truck },
{ href: "/orders/tasks", label: "Buyer Tasks", icon: ClipboardList },
];
// Office-side view of the driver / collection workflow. The driver app
// does the actual pickups — these pages are how the office sees and
// manages that activity. Split out of Operations so the menu reflects
// the actual lifecycle: orders originate in Orders, become physical
// work here in Logistics, then close out in Operations (invoicing).
const logisticsItems: NavEntry[] = [
{ href: "/collection-pool", label: "Collection Pool", icon: Package },
{ href: "/collection-map", label: "Collection Map", icon: MapPin },
{ href: "/collection-confirm", label: "Collection Confirm", icon: CheckCircle },
];
const operationsItems: NavEntry[] = [
{ href: "/invoicing", label: "Invoicing", icon: FileText },
{ href: "/delivery-notes", label: "Delivery Notes", icon: ClipboardList },
// Post-invoice physical-delivery queue. Tracks the
// invoiced → with_driver → checked_in lifecycle. Picked
// up from the Invoicing page once the office hits
// "Mark invoiced", lives here until the driver brings
// back the signed paper and the operator types the
// reference number. n8n will eventually auto-complete
// these (docs/deliveries-n8n-workflow.md).
{ href: "/deliveries", label: "Deliveries", icon: Truck },
// Counter-side scanner for the printed delivery-note QR. Separate
// standalone PWA at /scan (it declares its own manifest), linked
// here so the office can find it from the CRM too.
{ href: "/scan", label: "DN Scanner", icon: QrCode },
];
// Stock is its own top-level group (not nested under Operations) so the
// stock-orders / pricelist / stock-catalog trio lives together. Pricelist
// was previously the lone "Other" item; promoting it here keeps related
// inventory-adjacent surfaces together without inventing a new
// catch-all bucket just for one entry.
const stockNavItems: NavEntry[] = [
{ href: "/stock", label: "Stock", icon: Boxes },
{ href: "/stock-orders", label: "Stock Orders", icon: Archive },
{ href: "/pricelist", label: "Pricelist", icon: Tag },
];
// Repairs lives here for now — it doesn't fit Logistics (it's not a
// driver / collection workflow) and it doesn't fit Operations (which
// is reserved for the order-close-out loop). Move it once it has a
// clearer home.
const otherItems: NavEntry[] = [
{ href: "/repairs", label: "Repairs", icon: Wrench },
{ href: "/giellie", label: "Giellie Agent", icon: Bot },
// Standard Operating Procedures for the operator roles. Markdown
// source of truth lives in docs/sops/ — the /sops route renders
// it. See src/lib/sops.ts.
{ href: "/sops", label: "SOPs", icon: BookOpen },
// Monthly per-rep quotes/orders roll-up. Reuses the same
// getBossDashboardData aggregation as the /m phone dashboard's
// month tab — see /api/v1/reports/rep-performance.
{ href: "/reports/rep-performance", label: "Rep Performance", icon: BarChart3 },
];
/* ============================================================================
* 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 flex h-2 items-center gap-[2px] rounded-full p-[2px]"
aria-hidden
style={{
background:
"linear-gradient(90deg, var(--brand-blue, var(--primary)) 0% 33%, var(--brand-orange, var(--warn)) 33% 66%, var(--brand-red, var(--destructive)) 66% 100%)",
}}
>
<span className="h-1 w-1 rounded-full" style={{ background: "rgba(255,255,255,0.6)" }} />
</span>
)}
</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 — the WFDASH logo (transparent background) sits
naturally on the sidebar surface in both light and dark
modes. Sized 36px so it matches the visual weight of the
previous Package icon. The "Westcott Fasteners" subtitle
keeps the operator-facing context that the original block
carried. */}
<Link
href="/dashboard"
onClick={onLinkClick}
className="flex items-center gap-3 px-5 pt-6 pb-5"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/wfdash-logo-mark.png"
alt="WFDASH"
width={36}
height={36}
className="h-9 w-9 shrink-0"
/>
<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" />
{/* Tri-color brand accent — sits flush against the brand block so
the logo + WFDASH text + the three-peak stripe read as one
signature unit. 3px tall so it reads as a "rule", not a
hairline. */}
<BrandStripe className="h-[3px] shrink-0" />
{/* 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="Logistics"
items={logisticsItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
<NavSection
label="Operations"
items={operationsItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
<NavSection
label="Stock"
items={stockNavItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
{otherItems.length > 0 && (
<NavSection
label="Other"
items={otherItems}
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>
{/* Tri-color brand signature at the bottom of the sidebar —
mirrors the top stripe so the brand identity wraps the
whole sidebar. 2px tall so it reads as a footer rule. */}
<BrandStripe className="h-[2px] shrink-0" />
</div>
);
}
/* ============================================================================
* Mobile top-bar label — derived from the current pathname so the bar
* reads as "Hamburger · Orders" rather than the redundant "Hamburger ·
* WFDASH · WFDASH" it shipped with (the brand block is already in the
* drawer). Map is checked longest-prefix-first so /orders/sprint wins
* over /orders before the generic /orders entry.
* ========================================================================== */
const MOBILE_TITLE_MAP: { prefix: string; label: string }[] = [
{ prefix: "/collection-confirm", label: "Collection Confirm" },
{ prefix: "/collection-map", label: "Collection Map" },
{ prefix: "/collection-pool", label: "Collection Pool" },
{ prefix: "/delivery-notes", label: "Delivery Notes" },
{ prefix: "/stock-orders", label: "Stock Orders" },
{ prefix: "/orders/sprint", label: "Sprint" },
{ prefix: "/orders/assign", label: "Assign Driver" },
{ prefix: "/orders/new", label: "New Order" },
{ prefix: "/orders", label: "Orders" },
{ prefix: "/quotes/approvals", label: "Quote Approvals" },
{ prefix: "/quotes/archive", label: "Quote Archive" },
{ prefix: "/quotes", label: "Quotes" },
{ prefix: "/customers", label: "Customers" },
{ prefix: "/visits", label: "Visits" },
{ prefix: "/supplier-map", label: "Supplier Map" },
{ prefix: "/suppliers", label: "Suppliers" },
{ prefix: "/companies", label: "Companies" },
{ prefix: "/pricelist", label: "Pricelist" },
{ prefix: "/repairs", label: "Repairs" },
{ prefix: "/invoicing", label: "Invoicing" },
{ prefix: "/deliveries", label: "Deliveries" },
{ prefix: "/sprint", label: "Sprint" },
{ prefix: "/stock", label: "Stock" },
{ prefix: "/settings", label: "Settings" },
{ prefix: "/giellie", label: "Giellie Agent" },
{ prefix: "/webview", label: "Webview" },
{ prefix: "/dashboard", label: "Dashboard" },
];
function mobileTitleFor(pathname: string): string {
for (const { prefix, label } of MOBILE_TITLE_MAP) {
if (pathname === prefix || pathname.startsWith(prefix + "/")) return label;
}
return "WFDASH";
}
/* ============================================================================
* Sidebar — desktop + mobile (Sheet) wrappers
* ========================================================================== */
export function Sidebar() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const mobileTitle = mobileTitleFor(pathname);
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>
{/*
* Mobile top bar — hamburger on the left, page title centred-left
* in the bar. NO brand block here: the brand already lives in the
* drawer (NavContent at the top of NavContent renders the logo
* + "WFDASH / Westcott Fasteners" stack). Shipping the brand
* twice on a 390px viewport ate ~30px of vertical room and
* pushed the first row of every shared page below the fold —
* see docs/audit/frontend-ux.md §76.
*/}
<div className="fixed inset-x-0 top-0 z-40 flex h-14 items-center gap-3 border-b bg-background/95 px-3 backdrop-blur-sm lg:hidden">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
render={<Button variant="ghost" size="icon" className="h-9 w-9" />}
aria-label="Open navigation"
>
<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>
{/*
* Page title — presentational text, not an <h1>. Every shared
* page renders its own <h1> in the page body (e.g.
* `app/(app)/orders/page.tsx` "Orders"), and stacking two h1's
* on the same page breaks the document outline. Using a span
* with `aria-label` keeps it announced as a landmark for
* screen readers without duplicating the heading semantics.
*/}
<span
aria-label={`Current page: ${mobileTitle}`}
className="min-w-0 flex-1 truncate text-base font-semibold font-heading-theme"
>
{mobileTitle}
</span>
</div>
</>
);
}