From d4c72057bf259d95430744713aa3ab3e3b850c3f Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Sat, 11 Jul 2026 23:45:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(collection-map):=20two-pin=20model=20per?= =?UTF-8?q?=20supplier=20(letter=20+=20=E2=9C=93/=E2=9C=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(app)/collection-map/page.tsx | 146 +++++----------------- src/app/(app)/collection-map/pin-model.ts | 47 +++++++ 2 files changed, 81 insertions(+), 112 deletions(-) create mode 100644 src/app/(app)/collection-map/pin-model.ts diff --git a/src/app/(app)/collection-map/page.tsx b/src/app/(app)/collection-map/page.tsx index dd3160e..4903bf7 100644 --- a/src/app/(app)/collection-map/page.tsx +++ b/src/app/(app)/collection-map/page.tsx @@ -5,15 +5,16 @@ * * Reads `order_items` where `item_status` is in * `("assigned", "collection_pool")` for the operator-selected day, groups - * by `purchase_order.expand.supplier.coordinates`, and renders one - * supplier-pin per supplier-with-stops (with a count badge for how many - * items route to that supplier today). A driver pin sits on top of each - * supplier coordinate for the drivers with `drv_id` set on those items, - * so the operator can see at a glance "who's heading where". + * by `purchase_order.expand.supplier.coordinates`, and renders two pin + * shapes per supplier: + * - one blue circle per distinct driver at that supplier (with the + * driver letter from `driverLabelFor` inside); + * - one amber circle for the supplier's unassigned pool bucket (with + * ✓ / ✕ inside from `unassignedGlyphFor`). * * Reuses: - * - @/lib/geo — parseCoordinates, distanceKm, buildClusters - * - @/components/leaflet-markers — pinMarker, driverMarker, clusterBubble + * - @/lib/geo — parseCoordinates, distanceKm, buildClusters, driverLabelFor, unassignedGlyphFor + * - @/components/leaflet-markers — pinMarker, clusterBubble, driverLetterPin, unassignedPoolPin * - @/components/ui/assign-driver-dialog — click-to-assign + click-to-reschedule * * Followups (not in this page yet): @@ -49,16 +50,20 @@ import { buildClusters, CLUSTER_PALETTE, clusterDistanceForZoom, - colorForStop, - DRIVER_PALETTE, - driverIndexFor, + driverLabelFor, parseCoordinates, paletteIndexForId, - STOP_STATUS_COLOR, + unassignedGlyphFor, type Cluster, type LatLng, } from "@/lib/geo"; -import { clusterBubble, driverMarker, pinMarker, pinMarkerKind } from "@/components/leaflet-markers"; +import { + clusterBubble, + driverLetterPin, + pinMarker, + unassignedPoolPin, +} from "@/components/leaflet-markers"; +import { splitSupplierBucket } from "./pin-model"; import type { OrderItem, User as UserType } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -77,9 +82,6 @@ const LIGHT_TILE_ATTRIBUTION = '© OpenStreetMap © CARTO'; // Amber fill for the unassigned / "in pool" pin — mirrored from // STOP_STATUS_COLOR.unassigned in src/lib/geo.ts. Kept as a local alias so -// the marker factory call reads naturally without dereferencing the import. -const UNASSIGNED_PIN_COLOR = STOP_STATUS_COLOR.unassigned; - /* ============================================================================ * Types * ========================================================================= */ @@ -166,25 +168,6 @@ function formatDateLabel(ymd: string): string { return date.toLocaleDateString(undefined, { weekday: "short", day: "numeric", month: "short" }); } -function initialsOf(name: string | undefined): string { - if (!name) return "?"; - const parts = name.trim().split(/\s+/).slice(0, 2); - return parts.map((p) => p.charAt(0).toUpperCase()).join("") || "?"; -} - -/** - * Picks the dominant status of a supplier's bucket: - * - any unassigned items → "unassigned" (diamond) - * - otherwise → "assigned" (filled circle) - * - * When a supplier has both kinds, the diamond wins so the operator can - * see "there's something waiting here" without having to click in. - */ -function dominantStatusForStop(stop: SupplierStop): "assigned" | "unassigned" { - const hasUnassigned = stop.items.some((i) => i.item_status !== "assigned"); - return hasUnassigned ? "unassigned" : "assigned"; -} - /* ============================================================================ * Map watcher — emits zoom + viewport on Leaflet events. * ========================================================================= */ @@ -511,36 +494,6 @@ export default function CollectionMapPage() { const showIndividualStops = zoom >= CLUSTER_ZOOM_BREAKPOINT; const showLabels = zoom >= LABEL_ZOOM_BREAKPOINT; - /** - * Per-bucket grouped pins. We bucket items in each supplier's bucket by - * (driver × item_status × failed-flag) so the operator sees one pin per - * "what's happening" in the bucket: - * • 1 David assigned pin if 5 items are all routed to David - * • 1 David collected pin if David completed 4 items successfully - * • 1 David failed-ring pin if David flagged any item as failed - * • 2 pins (David red + Nathan blue) if both drivers are routed - * The unassigned pool items get a separate amber diamond. - */ - type RoutedPinKey = string; // `${drvId}::${status}::${isFailed ? "failed" : "ok"}` - type RoutedPinBucket = { - drvId: string | undefined; - isFailed: boolean; - items: typeof stopsWithCoords[number]["items"]; - }; - function buildRoutedBuckets(items: typeof stopsWithCoords[number]["items"]) { - const buckets = new Map(); - for (const it of items) { - const routed = it.item_status === "assigned" || it.item_status === "collected"; - if (!routed) continue; - const isFailed = it.item_status === "collected" && it.drv_status === "failed"; - const key: RoutedPinKey = `${it.drv_id ?? "_unknown"}::${it.item_status}::${isFailed ? "failed" : "ok"}`; - const existing = buckets.get(key); - if (existing) existing.items.push(it); - else buckets.set(key, { drvId: it.drv_id, isFailed, items: [it] }); - } - return Array.from(buckets.entries()); - } - const mapCenter: [number, number] = useMemo(() => { if (stopsWithCoords.length === 0) return DEFAULT_CENTER; return [ @@ -776,32 +729,25 @@ export default function CollectionMapPage() { ))} {showIndividualStops && stopsWithCoords.map((stop) => { - // Role-driven colour per-pin (not per-supplier): - // assigned → driver A red / driver B blue - // collected → emerald, uncollected → red ring on emerald - // unassigned → amber (diamond, separate pin shape) - // We render ONE routed pin per unique (driver × status × - // failed-flag) combination in the bucket, so a supplier - // with 3 David assigned + 2 Nathan collected shows two - // circles: David's red and Nathan's blue. Adds a third - // red-ring pin if there's a failed collect separately. + const { assignedByDriver, poolItems } = splitSupplierBucket(stop.items); return ( - {buildRoutedBuckets(stop.items).map(([key, bucket]) => { - const driverIdx = driverIndexFor(driversWithItems, bucket.drvId); - const color = bucket.isFailed - ? STOP_STATUS_COLOR.collected - : colorForStop({ role: "assigned", driverIndex: driverIdx }); - const pinKind = bucket.isFailed ? "failed" : "assigned"; + {/* One blue pin per distinct driver at this supplier. + Letter comes from driverLabelFor against the page- + wide sorted drivers list — stable across re-renders + because the input list is sorted alphabetically by + name. */} + {Array.from(assignedByDriver.entries()).map(([drvId, bucket]) => { + const letter = driverLabelFor(driversWithItems, drvId); return ( {showLabels && labeledSupplierIds.has(stop.supplierId) && ( - {stop.supplierName} + {stop.supplierName} · {letter} )} @@ -814,13 +760,15 @@ export default function CollectionMapPage() { ); })} - {stop.items.some((it) => it.item_status === "collection_pool") && ( + {/* One amber pin for the supplier's unassigned bucket. + Glyph collapses "all ready" vs "any unready" into + ✓ / ✕ — the bottom list + popup still surface + per-item ready state. */} + {poolItems.length > 0 && ( it.item_status === "collection_pool").length - ))} + icon={unassignedPoolPin(unassignedGlyphFor(poolItems), poolItems.length)} > {showLabels && labeledSupplierIds.has(stop.supplierId) && ( @@ -839,32 +787,6 @@ export default function CollectionMapPage() { ); })} - {/* Driver pins sit on top of their supplier coordinates. - One pin per (driver, supplier) pair — a driver with two - suppliers today shows as two pins, each labelled with - that driver's initials. Driver's own colour comes from - the same DRIVER_PALETTE the supplier-pin + sidebar use. */} - {showIndividualStops && - stopsWithCoords.flatMap((stop) => - stop.drivers.map((driverId) => { - const u = driverMap.get(driverId); - const driverIdx = driverIndexFor(driversWithItems, driverId); - return ( - - - Driver · {u?.name || driverId} → {stop.supplierName} - - - ); - }) - )} diff --git a/src/app/(app)/collection-map/pin-model.ts b/src/app/(app)/collection-map/pin-model.ts new file mode 100644 index 0000000..314d572 --- /dev/null +++ b/src/app/(app)/collection-map/pin-model.ts @@ -0,0 +1,47 @@ +/** + * Collection map pin-model helpers. + * + * Pure functions that drive the two-pin model on `/collection-map`. + * Kept in a sibling module (not co-located in `page.tsx`) because Next.js' + * typed-route generation forbids non-component exports from `"use client"` + * pages. + */ + +/** + * Splits a supplier's items into the two groups the two-pin model renders: + * - one bucket per assigned driver (keyed by `drv_id`), with `readyAll` + * true iff every item in that bucket is `ready_for_collection=true`; + * - one shared unassigned pool bucket for items with + * `item_status === "collection_pool"`. + * + * Items with `item_status` in `("assigned" | "collected")` but a missing + * `drv_id` are treated as orphan pool items — they go into the unassigned + * bucket so they don't silently vanish from the map. + */ +export function splitSupplierBucket< + T extends { + item_status: string; + drv_id?: string; + ready_for_collection?: boolean; + } +>(items: T[]) { + const assignedByDriver = new Map< + string, + { items: T[]; readyAll: boolean } + >(); + const poolItems: T[] = []; + for (const it of items) { + const isRouted = + it.item_status === "assigned" || it.item_status === "collected"; + if (isRouted && it.drv_id) { + const entry = + assignedByDriver.get(it.drv_id) ?? { items: [], readyAll: true }; + entry.items.push(it); + if (!it.ready_for_collection) entry.readyAll = false; + assignedByDriver.set(it.drv_id, entry); + } else if (it.item_status === "collection_pool") { + poolItems.push(it); + } + } + return { assignedByDriver, poolItems }; +}