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:
parent
569e0121a8
commit
9232063ca5
2 changed files with 224 additions and 6 deletions
210
src/app/(app)/repairs/dashboard/page.tsx
Normal file
210
src/app/(app)/repairs/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue