feat(collection-map): two-pin model per supplier (letter + ✓/✕)
This commit is contained in:
parent
dcfe662c9f
commit
d4c72057bf
2 changed files with 81 additions and 112 deletions
|
|
@ -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 = '© <a href="https://www.openstreetmap.org/co
|
|||
const DARK_TILE_ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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>
|
||||
|
|
|
|||
47
src/app/(app)/collection-map/pin-model.ts
Normal file
47
src/app/(app)/collection-map/pin-model.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue