feat(collection-map): sidebar with legend, driver cards, unassigned compact (page split)

This commit is contained in:
SJCoetz 2026-07-12 12:23:13 +02:00
parent 0d63061ea0
commit 9769dbb5ae
2 changed files with 301 additions and 28 deletions

View file

@ -63,6 +63,12 @@ import {
unassignedPoolPin,
} from "@/components/leaflet-markers";
import { splitSupplierBucket } from "./pin-model";
import {
DriverCardSection,
LegendCard,
MissingCoordsCard,
UnassignedCompactCard,
} from "./sidebar";
import type { OrderItem, User as UserType } from "@/lib/types";
import { cn } from "@/lib/utils";
@ -85,7 +91,7 @@ const DARK_TILE_ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/cop
* Types
* ========================================================================= */
type StopItem = {
export type StopItem = {
id: string;
description: string;
item_status: OrderItem["item_status"];
@ -288,6 +294,8 @@ export default function CollectionMapPage() {
initialDate: string;
}>({ open: false, row: null, initialDriverId: "", initialDate: "" });
const [showAllDrivers, setShowAllDrivers] = useState(false);
/* -- data load ----------------------------------------------------------- */
const fetchRows = useCallback(async () => {
setLoading(true);
@ -725,33 +733,22 @@ export default function CollectionMapPage() {
</div>
</Card>
{/* Sidebar */}
<div className="space-y-4">
{/* Missing-coord suppliers */}
{suppliersWithoutCoords.length > 0 && (
<Card className="border-amber-200/60 bg-amber-50/30 dark:border-amber-500/20 dark:bg-amber-500/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="h-4 w-4 text-amber-600 dark:text-amber-400" />
Missing supplier coordinates
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5 text-sm">
<p className="text-xs text-muted-foreground">
{suppliersWithoutCoords.length} supplier{suppliersWithoutCoords.length === 1 ? "" : "s"}
{" "}have stops today but no coordinates. Update on /supplier-map.
</p>
<Link
href="/supplier-map"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
Open supplier map
<ExternalLink className="h-3 w-3" />
</Link>
</CardContent>
</Card>
)}
</div>
{/* Sidebar — Legend + Driver cards + Unassigned compact + Missing coords */}
<aside className="flex flex-col gap-4">
<LegendCard />
<DriverCardSection
driversWithItems={driversWithItems}
expanded={showAllDrivers}
onToggle={() => setShowAllDrivers((s) => !s)}
onOpenAssign={openAssignDialog}
/>
{items.filter((it) => !it.drv_id).length > 0 ? (
<UnassignedCompactCard count={items.filter((it) => !it.drv_id).length} />
) : null}
{suppliersWithoutCoords.length > 0 ? (
<MissingCoordsCard items={suppliersWithoutCoords} />
) : null}
</aside>
</div>
{/* TODO(driver-cards Task 5): bottom list removal is finalised below */}

View file

@ -0,0 +1,276 @@
"use client";
/**
* Collection-map sidebar inner components.
*
* Extracted from page.tsx when the page grows past the size budget. The
* page composes these in a fixed order: Legend → Driver cards →
* Unassigned → Missing coords.
*
* Types: `StopItem` is owned by page.tsx and re-exported here via type
* import (no runtime dependency).
*/
import React, { useState } from "react";
import Link from "next/link";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Circle as CircleIcon,
ExternalLink,
MapPin,
} from "lucide-react";
import { driverLabelFor } from "@/lib/geo";
import {
aggregateDriverStatus,
type DriverStatus,
} from "./pin-model";
import type { StopItem } from "./page";
function MissingCoordsCard({ items }: { items: string[] }) {
return (
<Card className="border-amber-200/60 bg-amber-50/30 dark:border-amber-500/20 dark:bg-amber-500/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="h-4 w-4 text-amber-600 dark:text-amber-400" />
Missing supplier coordinates
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5 text-sm">
<p className="text-xs text-muted-foreground">
{items.length} supplier{items.length === 1 ? "" : "s"} have stops
today but no coordinates. Update on /supplier-map.
</p>
<Link
href="/supplier-map"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
Open supplier map
<ExternalLink className="h-3 w-3" />
</Link>
</CardContent>
</Card>
);
}
function LegendCard() {
return (
<Card className="border-primary/10">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Legend</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<span
aria-hidden
className="grid h-5 w-5 place-items-center rounded-full bg-blue-600 text-[10px] font-semibold text-white"
>
A
</span>
<span>Assigned to driver (letter = A/B/C…)</span>
</div>
<div className="flex items-center gap-2">
<span
aria-hidden
className="grid h-5 w-5 place-items-center rounded-full bg-amber-500 text-[10px] font-semibold text-white"
>
✓
</span>
<span>Pool, supplier confirmed ready</span>
</div>
<div className="flex items-center gap-2">
<span
aria-hidden
className="grid h-5 w-5 place-items-center rounded-full bg-amber-500 text-[10px] font-semibold text-white"
>
✕
</span>
<span>Pool, supplier hasn&apos;t confirmed yet</span>
</div>
<div className="flex items-center gap-2">
<CircleIcon className="h-3 w-3 fill-emerald-500 text-emerald-500" aria-hidden />
<span>Stop has been collected (green anchor)</span>
</div>
</CardContent>
</Card>
);
}
type DriverCardProps = {
driver: { id: string; name: string; items: StopItem[] };
driversWithItems: { id: string; name: string; items: StopItem[] }[];
onOpenAssign: (row: StopItem) => void;
};
function DriverCard({ driver, driversWithItems, onOpenAssign }: DriverCardProps) {
const status = aggregateDriverStatus(driver.items);
const [expanded, setExpanded] = useState(false);
const visibleItems = expanded ? driver.items : driver.items.slice(0, 4);
const letter = driverLabelFor(driversWithItems, driver.id);
return (
<div className="rounded-lg border bg-card p-3">
<div className="flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-full bg-blue-600 text-xs font-semibold text-white">
{letter}
</span>
<span className="truncate font-medium">{driver.name}</span>
</div>
<CountsStrip status={status} />
<ul className="mt-2 space-y-1.5">
{visibleItems.map((item) => (
<StopRow key={item.id} item={item} onOpen={() => onOpenAssign(item)} />
))}
{driver.items.length > 4 && (
<li>
<button
type="button"
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
onClick={() => setExpanded((s) => !s)}
>
{expanded ? "Show fewer" : `+${driver.items.length - 4} more`}
</button>
</li>
)}
</ul>
</div>
);
}
function CountsStrip({ status }: { status: DriverStatus }) {
const parts = [`${status.total} stops`];
if (status.collected > 0) parts.push(`${status.collected} collected`);
if (status.waiting > 0) parts.push(`${status.waiting} waiting`);
return (
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<span>{parts[0]}</span>
{status.collected > 0 && (
<span className="inline-flex items-center gap-1 text-emerald-700">
<CircleIcon className="h-2.5 w-2.5 fill-emerald-500 text-emerald-500" aria-hidden />
{status.collected} collected
</span>
)}
{status.waiting > 0 && (
<span className="inline-flex items-center gap-1 text-amber-700">
<CircleIcon className="h-2.5 w-2.5 fill-amber-500 text-amber-500" aria-hidden />
{status.waiting} waiting
</span>
)}
</p>
);
}
function StopRow({ item, onOpen }: { item: StopItem; onOpen: () => void }) {
const pill = (() => {
if (item.item_status === "collected") {
return {
label: "Collected",
cls: "bg-emerald-100 text-emerald-900 border-emerald-300",
};
}
if (item.ready_for_collection) {
return {
label: "Ready",
cls: "bg-blue-100 text-blue-900 border-blue-300",
};
}
return {
label: "Waiting",
cls: "bg-amber-100 text-amber-900 border-amber-300",
};
})();
return (
<li>
<button
type="button"
onClick={onOpen}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-xs hover:bg-muted/50"
>
<span className="truncate text-left">{item.supplier_name}</span>
<span
className={`inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 text-[10px] font-medium ${pill.cls}`}
>
{pill.label}
</span>
</button>
</li>
);
}
function DriverCardSection({
driversWithItems,
expanded,
onToggle,
onOpenAssign,
}: {
driversWithItems: DriverCardProps["driver"][];
expanded: boolean;
onToggle: () => void;
onOpenAssign: (row: StopItem) => void;
}) {
const visible = expanded ? driversWithItems : driversWithItems.slice(0, 3);
return (
<Card className="border-primary/10">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">
Drivers with stops
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{visible.length === 0 ? (
<p className="text-xs text-muted-foreground">No drivers have stops yet.</p>
) : (
visible.map((d) => (
<DriverCard
key={d.id}
driver={d}
driversWithItems={driversWithItems}
onOpenAssign={onOpenAssign}
/>
))
)}
{driversWithItems.length > 3 && (
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={onToggle}
>
{expanded ? "Show top 3 only" : `Show all (${driversWithItems.length})`}
</Button>
)}
</CardContent>
</Card>
);
}
function UnassignedCompactCard({ count }: { count: number }) {
return (
<Card className="border-primary/10">
<CardContent className="flex items-center justify-between gap-2 p-3 text-sm">
<span className="text-muted-foreground">
{count} unassigned stop{count === 1 ? "" : "s"}
</span>
<Link
href="/collection-pool"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
Review <ExternalLink className="h-3 w-3" />
</Link>
</CardContent>
</Card>
);
}
export {
MissingCoordsCard,
LegendCard,
DriverCard,
CountsStrip,
StopRow,
DriverCardSection,
UnassignedCompactCard,
};