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 };
+}