feat(collection-map): two-pin model per supplier (letter + ✓/✕)

This commit is contained in:
SJCoetz 2026-07-11 23:45:07 +02:00
parent dcfe662c9f
commit d4c72057bf
2 changed files with 81 additions and 112 deletions

View file

@ -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 = '&copy; <a href="https://www.openstreetmap.org/co
const DARK_TILE_ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>';
// 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<RoutedPinKey, RoutedPinBucket>();
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 (
<React.Fragment key={`${stop.supplierId}-pin-group`}>
{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 (
<Marker
key={`${stop.supplierId}-routed-${key}`}
key={`${stop.supplierId}-drv-${drvId}`}
position={[stop.lat, stop.lng]}
icon={pinMarkerKind(pinKind, color, String(bucket.items.length))}
icon={driverLetterPin(letter, bucket.items.length)}
>
{showLabels && labeledSupplierIds.has(stop.supplierId) && (
<Tooltip direction="top" offset={[0, -12]} permanent className="supplier-map-label">
{stop.supplierName}
{stop.supplierName} · {letter}
</Tooltip>
)}
<Popup>
@ -814,13 +760,15 @@ export default function CollectionMapPage() {
</Marker>
);
})}
{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 && (
<Marker
key={`${stop.supplierId}-pool`}
position={[stop.lat, stop.lng]}
icon={pinMarkerKind("unassigned", UNASSIGNED_PIN_COLOR, String(
stop.items.filter((it) => it.item_status === "collection_pool").length
))}
icon={unassignedPoolPin(unassignedGlyphFor(poolItems), poolItems.length)}
>
{showLabels && labeledSupplierIds.has(stop.supplierId) && (
<Tooltip direction="top" offset={[0, -22]} permanent className="supplier-map-label">
@ -839,32 +787,6 @@ export default function CollectionMapPage() {
</React.Fragment>
);
})}
{/* 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 (
<Marker
key={`${stop.supplierId}-${driverId}`}
position={[stop.lat, stop.lng]}
icon={driverMarker(
DRIVER_PALETTE[driverIdx],
initialsOf(u?.name)
)}
>
<Tooltip direction="bottom" offset={[0, 14]}>
Driver · {u?.name || driverId} → {stop.supplierName}
</Tooltip>
</Marker>
);
})
)}
</MapContainer>
</div>
</Card>

View file

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