feat(sidebar): Delivery Pool under Logistics, own Repairs section with dashboard, fix nav scrolling

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-11-09 08:13:19 +02:00
parent 569e0121a8
commit 9232063ca5
2 changed files with 224 additions and 6 deletions

View file

@ -0,0 +1,210 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { ChevronRight, Wrench } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
REPAIR_STATUS,
REPAIR_STATUS_LABEL,
REPAIR_STATUS_VARIANT,
type RepairStatus,
} from "@/lib/repair-status";
import getPocketBase from "@/lib/pocketbase";
import type { Customer, Repair } from "@/lib/types";
type Counts = { total: number; byStatus: Record<RepairStatus, number> };
type Recent = Repair & { expand?: { customer?: Customer } };
// The working pipeline, in order. Done / Cancelled are shown apart.
const PIPELINE = REPAIR_STATUS.filter(
(s) => s !== "completed" && s !== "cancelled",
);
// One tone per stage, drawn from the theme so it follows light/dark.
const STAGE_TONE: Record<string, string> = {
new: "bg-primary/30",
assessing: "bg-primary/45",
quoted: "bg-primary/60",
approved: "bg-primary/75",
in_progress: "bg-primary",
};
function authHeaders(): Record<string, string> {
const token = getPocketBase().authStore.token;
return token ? { Authorization: `Bearer ${token}` } : {};
}
export default function RepairsDashboardPage() {
const [counts, setCounts] = useState<Counts | null>(null);
const [recent, setRecent] = useState<Recent[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const headers = authHeaders();
fetch("/api/v1/repairs/status-counts", { headers })
.then(async (res) => {
if (!res.ok) throw new Error(`Could not load repairs (HTTP ${res.status}).`);
setCounts((await res.json()) as Counts);
})
.catch((e: unknown) =>
setError(e instanceof Error ? e.message : "Could not load repairs."),
);
fetch("/api/v1/repairs?page=1&perPage=6", { headers })
.then(async (res) => {
if (res.ok) setRecent(((await res.json()) as { items: Recent[] }).items ?? []);
})
.catch(() => {});
}, []);
const by = (s: RepairStatus) => counts?.byStatus[s] ?? 0;
const active = PIPELINE.reduce((n, s) => n + by(s), 0);
return (
<div className="mx-auto max-w-5xl space-y-8 p-6">
<header className="flex items-end justify-between gap-4">
<div>
<h1 className="font-heading-theme text-2xl font-semibold tracking-tight">
Repairs
</h1>
<p className="text-sm text-muted-foreground">
Where every machine is right now.
</p>
</div>
<Link
href="/repairs"
className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
Open the list <ChevronRight className="h-4 w-4" />
</Link>
</header>
{error && <p className="text-sm text-destructive">{error}</p>}
{!counts && !error && (
<p className="text-sm text-muted-foreground">Loading…</p>
)}
{counts && (
<>
{/* Hero: the number that matters, plus the pipeline as one bar */}
<section className="rounded-2xl border bg-gradient-to-br from-primary/10 via-transparent to-transparent p-6">
<div className="flex flex-wrap items-end gap-x-10 gap-y-4">
<div>
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">
In progress
</div>
<div className="text-6xl font-semibold leading-none tabular-nums">
{active}
</div>
</div>
<div className="flex gap-8 pb-1 text-sm">
<div>
<div className="text-2xl font-semibold tabular-nums">
{by("completed")}
</div>
<div className="text-muted-foreground">Done</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums">
{by("cancelled")}
</div>
<div className="text-muted-foreground">Cancelled</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums">
{counts.total}
</div>
<div className="text-muted-foreground">All time</div>
</div>
</div>
</div>
<div
className="mt-6 flex h-3 w-full gap-1 overflow-hidden rounded-full bg-muted"
role="img"
aria-label="Repairs by stage"
>
{PIPELINE.map(
(s) =>
by(s) > 0 && (
<div
key={s}
className={`${STAGE_TONE[s]} rounded-full`}
style={{ flexGrow: by(s), flexBasis: 0 }}
title={`${REPAIR_STATUS_LABEL[s]}: ${by(s)}`}
/>
),
)}
</div>
</section>
{/* The pipeline, stage by stage */}
<section>
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-muted-foreground">
The journey
</h2>
<ol className="grid grid-cols-2 gap-px overflow-hidden rounded-2xl border bg-border sm:grid-cols-3 lg:grid-cols-5">
{PIPELINE.map((s, i) => (
<li key={s} className="bg-card p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span
className={`grid h-5 w-5 place-items-center rounded-full text-[10px] font-semibold text-primary-foreground ${STAGE_TONE[s]}`}
>
{i + 1}
</span>
{REPAIR_STATUS_LABEL[s]}
</div>
<div
className={`mt-3 text-4xl font-semibold tabular-nums ${
by(s) === 0 ? "text-muted-foreground/40" : ""
}`}
>
{by(s)}
</div>
</li>
))}
</ol>
</section>
</>
)}
{/* Latest */}
{recent.length > 0 && (
<section>
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-muted-foreground">
Latest repairs
</h2>
<ul className="divide-y overflow-hidden rounded-2xl border bg-card">
{recent.map((r) => (
<li key={r.id}>
<Link
href={`/repairs/${r.id}`}
className="flex items-center gap-4 px-4 py-3 transition-colors hover:bg-muted/50"
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
<Wrench className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{r.item_description || "Machine"}{" "}
<span className="font-normal text-muted-foreground">
· {r.repair_number}
</span>
</div>
<div className="truncate text-xs text-muted-foreground">
{r.expand?.customer?.customer ?? "No customer"}
</div>
</div>
<Badge variant={REPAIR_STATUS_VARIANT[r.status] || "secondary"}>
{REPAIR_STATUS_LABEL[r.status] || r.status}
</Badge>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</Link>
</li>
))}
</ul>
</section>
)}
</div>
);
}

View file

@ -137,12 +137,14 @@ const stockNavItems: NavEntry[] = [
{ 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.
// Repairs is its own section: more repair-related pages are coming, so
// the list sits under a section heading next to a small overview.
const repairsNavItems: NavEntry[] = [
{ href: "/repairs/dashboard", label: "Repairs Dashboard", icon: LayoutDashboard },
{ href: "/repairs", label: "Repairs List", icon: Wrench },
];
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
@ -402,7 +404,7 @@ function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
<BrandStripe className="h-[3px] shrink-0" />
{/* Nav */}
<ScrollArea className="flex-1 px-3 py-3">
<ScrollArea className="min-h-0 flex-1 overflow-hidden px-3 py-3">
<nav className="space-y-3">
{/* Dashboard + universal search — top-level, above the grouped nav.
Rendered as a map rather than `dashboardItems[0]`: that
@ -457,6 +459,12 @@ function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
pathname={pathname}
onLinkClick={onLinkClick}
/>
<NavSection
label="Repairs"
items={repairsNavItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
{otherItems.length > 0 && (
<NavSection
label="Other"