feat(collection-map): sidebar with legend, driver cards, unassigned compact (page split)
This commit is contained in:
parent
0d63061ea0
commit
9769dbb5ae
2 changed files with 301 additions and 28 deletions
|
|
@ -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 = '© <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 */}
|
||||
|
|
|
|||
276
src/app/(app)/collection-map/sidebar.tsx
Normal file
276
src/app/(app)/collection-map/sidebar.tsx
Normal 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'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,
|
||||
};
|
||||
Loading…
Reference in a new issue