WFDASH-WEBAPP/src/components/supplier-map.tsx
SJCoetz 7f924ccc45 refactor: switch hardcoded UI colors to semantic theme tokens
Overhaul the app's color system by replacing all hardcoded Tailwind color classes with semantic theme variables to ensure consistent theming across all components, pages, and tests.

Additional changes:
- Add assigned date support for order item status updates: add `assignedDate` field to the order item status PATCH API, implement server-side normalization/validation, and add corresponding tests
- Refactor purchase order PDF workflows to use the consolidated `purchase_orders.documents` field instead of the legacy separate `purchase_order_pdfs` collection
- Fix quote number parsing and formatting to support both dashed (Q-YYYY-MM-NNN) and dashless (QYYYYMMNNN) formats
- Rename `driver_pool` item status to `collection_pool` across the codebase
- Add null handling for order item quantities and pricing to display "—" for missing values
- Improve filter tab styling: make mobile tabs scrollable, prevent text wrapping, hide scrollbar
- Add new utility scripts, API routes, migrations, and polish small UI/UX fixes including tsconfig updates and theme hook improvements
2026-07-07 17:22:03 +02:00

442 lines
No EOL
18 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import L from "leaflet";
import { MapContainer, Marker, Popup, TileLayer, Tooltip, Circle, useMap, useMapEvents } from "react-leaflet";
import { useTheme } from "next-themes";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Maximize2, Mail, MapPin, Minimize2, Move, Phone, RefreshCw, Search, Tag } from "lucide-react";
import { toast } from "sonner";
import { getMappableSuppliers } from "@/lib/pb-helpers";
import getPocketBase from "@/lib/pocketbase";
import type { Supplier } from "@/lib/types";
import {
buildClusters,
CLUSTER_PALETTE,
clusterDistanceForZoom,
parseCoordinates,
type Cluster,
} from "@/lib/geo";
import { clusterBubble, pinMarker } from "@/components/leaflet-markers";
type SupplierPoint = Supplier & {
lat: number;
lng: number;
};
type SupplierCluster = Cluster<SupplierPoint>;
const DEFAULT_CENTER: [number, number] = [-26.2041, 28.0473];
const CLUSTER_ZOOM_BREAKPOINT = 12;
const LABEL_ZOOM_BREAKPOINT = 12;
/**
* Max number of permanent supplier-name labels visible on the map at
* once. Picked by distance to viewport centre — keeps the DOM light
* when zoomed in over a dense cluster of pins.
*/
const LABEL_LIMIT = 24;
/** Stable empty Set so consumers can `has()` without re-creating it. */
const EMPTY_LABELED_IDS = new Set<string>();
const LIGHT_TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
const DARK_TILE_URL = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
const LIGHT_TILE_ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
const DARK_TILE_ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>';
function MapWatcher({
isFullscreen,
onZoomChange,
onViewportChange,
}: {
isFullscreen: boolean;
onZoomChange: (zoom: number) => void;
onViewportChange: (viewport: { center: [number, number]; zoom: number }) => void;
}) {
const map = useMap();
useEffect(() => {
window.setTimeout(() => map.invalidateSize(), 180);
}, [isFullscreen, map]);
useMapEvents({
zoomend: (event) => {
const target = event.target;
const zoom = target.getZoom();
const center = target.getCenter();
onZoomChange(zoom);
onViewportChange({ center: [center.lat, center.lng], zoom });
},
moveend: (event) => {
const target = event.target;
const center = target.getCenter();
onViewportChange({ center: [center.lat, center.lng], zoom: target.getZoom() });
},
});
return null;
}
function ClusterMarker({ cluster }: { cluster: SupplierCluster }) {
const map = useMap();
const count = cluster.items.length;
if (count === 1) {
const supplier = cluster.items[0];
return (
<Marker position={[supplier.lat, supplier.lng]} icon={pinMarker(cluster.color, "")}>
<Popup>
<SupplierPopup supplier={supplier} editMode={false} />
</Popup>
</Marker>
);
}
return (
<Marker
position={[cluster.center[0], cluster.center[1]]}
icon={clusterBubble(cluster.color, count)}
eventHandlers={{
click: () => {
map.closePopup();
map.flyTo([cluster.center[0], cluster.center[1]], Math.min(map.getZoom() + 2, CLUSTER_ZOOM_BREAKPOINT), { duration: 0.4 });
},
}}
/>
);
}
function SupplierPopup({ supplier, editMode }: { supplier: SupplierPoint; editMode: boolean }) {
return (
<div className="space-y-1 text-sm">
<p className="font-semibold">{supplier.suppliername || "Unnamed supplier"}</p>
{supplier.supplieraddress && <p>{supplier.supplieraddress}</p>}
{supplier.suppliernumber && <p>Phone: {supplier.suppliernumber}</p>}
{supplier.email && <p>Email: {supplier.email}</p>}
{supplier.supply_tags && <p>Tags: {supplier.supply_tags}</p>}
{editMode && (
<p className="pt-1 text-xs font-medium text-primary">
Drag this marker to correct its saved location.
</p>
)}
</div>
);
}
export function SupplierMap() {
const { resolvedTheme } = useTheme();
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [zoom, setZoom] = useState(10);
const [viewport, setViewport] = useState<{ center: [number, number]; zoom: number } | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [editMode, setEditMode] = useState(false);
const isDarkMap = resolvedTheme === "dark";
const fetchSuppliers = async () => {
setLoading(true);
try {
setSuppliers(await getMappableSuppliers());
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to load suppliers");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSuppliers();
}, []);
const saveSupplierLocation = async (supplier: SupplierPoint, lat: number, lng: number) => {
const coordinates = `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
setSuppliers((current) =>
current.map((item) => (item.id === supplier.id ? { ...item, coordinates } : item))
);
try {
const pb = getPocketBase();
await pb.collection("suppliers").update(supplier.id, { coordinates });
toast.success(`Updated ${supplier.suppliername || "supplier"} location`);
} catch (err) {
setSuppliers((current) =>
current.map((item) => (item.id === supplier.id ? { ...item, coordinates: supplier.coordinates } : item))
);
toast.error(err instanceof Error ? err.message : "Failed to update supplier location");
}
};
const { mapped, missing } = useMemo(() => {
const filtered = suppliers.filter((supplier) => {
const query = search.trim().toLowerCase();
if (!query) return true;
return [supplier.suppliername, supplier.supplieraddress, supplier.supply_tags]
.some((value) => value?.toLowerCase().includes(query));
});
const supplierPoints: SupplierPoint[] = [];
const missingCoordinates: Supplier[] = [];
for (const supplier of filtered) {
const coordinates = parseCoordinates(supplier.coordinates);
if (!coordinates) {
missingCoordinates.push(supplier);
continue;
}
supplierPoints.push({
...supplier,
lat: coordinates[0],
lng: coordinates[1],
});
}
return { mapped: supplierPoints, missing: missingCoordinates };
}, [search, suppliers]);
const clusterDistanceKm = clusterDistanceForZoom(zoom);
// `clusterDistanceKm` only changes when zoom crosses one of
// {7, 9, 11} (see clusterDistanceForZoom). Dep'ing on the bucket
// value — not on zoom — keeps `buildClusters` from re-running on
// every wheel tick. If you change the cluster distance function,
// keep this bucketed.
const clusters = useMemo(() => buildClusters(mapped, clusterDistanceKm), [clusterDistanceKm, mapped]);
const showIndividualSuppliers = zoom >= CLUSTER_ZOOM_BREAKPOINT;
const showLabels = zoom >= LABEL_ZOOM_BREAKPOINT;
const mapCenter: [number, number] = mapped.length
? [
mapped.reduce((sum, supplier) => sum + supplier.lat, 0) / mapped.length,
mapped.reduce((sum, supplier) => sum + supplier.lng, 0) / mapped.length,
]
: DEFAULT_CENTER;
const clusterBySupplierId = useMemo(() => {
const m = new Map<string, SupplierCluster>();
for (const cluster of clusters) {
for (const supplier of cluster.items) {
m.set(supplier.id, cluster);
}
}
return m;
}, [clusters]);
/**
* IDs of the suppliers that should render a permanent name label
* at the current zoom. Top LABEL_LIMIT by squared distance to the
* viewport centre — cheap, no sqrt, and "centre of viewport" is a
* good enough proxy for "what the user is actually looking at".
*/
const labeledIds = useMemo(() => {
if (!showLabels || !viewport) return EMPTY_LABELED_IDS;
const [cLat, cLng] = viewport.center;
const ranked = mapped
.map((supplier) => ({
id: supplier.id,
d: (supplier.lat - cLat) ** 2 + (supplier.lng - cLng) ** 2,
}))
.sort((a, b) => a.d - b.d)
.slice(0, LABEL_LIMIT);
return new Set(ranked.map((entry) => entry.id));
}, [mapped, showLabels, viewport]);
return (
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-primary/10">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Mapped Suppliers</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">{mapped.length}</p>
<p className="text-xs text-muted-foreground">With usable coordinates</p>
</CardContent>
</Card>
<Card className="border-primary/10">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Proximity Groups</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">{clusters.length}</p>
<p className="text-xs text-muted-foreground">Grouped within {clusterDistanceKm}km at this zoom</p>
</CardContent>
</Card>
<Card className="border-primary/10">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Needs Coordinates</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">{missing.length}</p>
<p className="text-xs text-muted-foreground">Supplier records to update</p>
</CardContent>
</Card>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search suppliers, tags, address..."
className="pl-9"
/>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={editMode ? "default" : "outline"}
onClick={() => setEditMode((enabled) => !enabled)}
>
<Move className="mr-2 h-4 w-4" />
{editMode ? "Done Editing" : "Edit Locations"}
</Button>
<Button variant="outline" onClick={fetchSuppliers} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
</div>
<div className="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card className={`overflow-hidden border-primary/10 py-0 ${isFullscreen ? "fixed inset-0 z-[45] rounded-none border-0" : "relative z-0"}`}>
<div className={`${isFullscreen ? "h-dvh min-h-0" : "h-[68vh] min-h-[520px]"} relative`}>
<div className="absolute right-3 top-3 z-[30] flex flex-col gap-2 sm:flex-row">
{editMode && (
<div className="rounded-full border bg-background/95 px-3 py-2 text-xs font-medium shadow-sm backdrop-blur">
Drag pins to save locations
</div>
)}
<Button
type="button"
variant="secondary"
size="sm"
className="h-9 rounded-full bg-background/95 shadow-sm backdrop-blur"
onClick={() => setIsFullscreen((fullscreen) => !fullscreen)}
>
{isFullscreen ? <Minimize2 className="mr-2 h-4 w-4" /> : <Maximize2 className="mr-2 h-4 w-4" />}
{isFullscreen ? "Exit" : "Fullscreen"}
</Button>
</div>
<MapContainer center={mapCenter} zoom={mapped.length ? 10 : 6} scrollWheelZoom className="h-full w-full">
<MapWatcher
isFullscreen={isFullscreen}
onZoomChange={setZoom}
onViewportChange={setViewport}
/>
<TileLayer
key={isDarkMap ? "dark" : "light"}
attribution={isDarkMap ? DARK_TILE_ATTRIBUTION : LIGHT_TILE_ATTRIBUTION}
url={isDarkMap ? DARK_TILE_URL : LIGHT_TILE_URL}
/>
{!showIndividualSuppliers &&
clusters.map((cluster) => (
<ClusterMarker key={cluster.id} cluster={cluster} />
))}
{showIndividualSuppliers &&
clusters.map((cluster) => (
<Circle
key={cluster.id}
center={[cluster.center[0], cluster.center[1]]}
radius={clusterDistanceKm * 1000}
pathOptions={{ color: cluster.color, fillColor: cluster.color, fillOpacity: 0.05, weight: 1 }}
/>
))}
{showIndividualSuppliers &&
mapped.map((supplier) => {
const cluster = clusterBySupplierId.get(supplier.id);
const label = cluster ? String(cluster.items.findIndex((item) => item.id === supplier.id) + 1) : "";
return (
<Marker
key={supplier.id}
position={[supplier.lat, supplier.lng]}
icon={pinMarker(cluster?.color || CLUSTER_PALETTE[0], label)}
draggable={editMode}
eventHandlers={{
dragend: (event) => {
const marker = event.target as L.Marker;
const position = marker.getLatLng();
void saveSupplierLocation(supplier, position.lat, position.lng);
},
}}
>
{showLabels && labeledIds.has(supplier.id) && (
<Tooltip direction="top" offset={[0, -12]} permanent className="supplier-map-label">
{supplier.suppliername || "Unnamed supplier"}
</Tooltip>
)}
<Popup>
<SupplierPopup supplier={supplier} editMode={editMode} />
</Popup>
</Marker>
);
})}
</MapContainer>
</div>
</Card>
<div className="space-y-4">
<Card className="border-primary/10">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="h-4 w-4 text-primary" />
Proximity Groups
</CardTitle>
</CardHeader>
<CardContent className="max-h-[360px] space-y-3 overflow-auto">
{clusters.length === 0 ? (
<p className="text-sm text-muted-foreground">No mapped suppliers yet.</p>
) : (
clusters.map((cluster, index) => (
<div key={cluster.id} className="rounded-xl border p-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: cluster.color }} />
<p className="text-sm font-semibold">Group {index + 1}</p>
</div>
<Badge variant="outline">{cluster.items.length} supplier{cluster.items.length === 1 ? "" : "s"}</Badge>
</div>
<div className="space-y-2">
{cluster.items.map((supplier, supplierIndex) => (
<div key={supplier.id} className="text-sm">
<p className="font-medium">{supplierIndex + 1}. {supplier.suppliername || "Unnamed supplier"}</p>
{supplier.supplieraddress && (
<p className="text-xs text-muted-foreground">{supplier.supplieraddress}</p>
)}
</div>
))}
</div>
</div>
))
)}
</CardContent>
</Card>
<Card className="border-primary/10">
<CardHeader>
<CardTitle className="text-base">Missing Coordinates</CardTitle>
</CardHeader>
<CardContent className="max-h-[300px] space-y-3 overflow-auto">
{missing.length === 0 ? (
<p className="text-sm text-muted-foreground">All visible suppliers have coordinates.</p>
) : (
missing.map((supplier) => (
<div key={supplier.id} className="rounded-xl border p-3">
<p className="font-medium">{supplier.suppliername || "Unnamed supplier"}</p>
{supplier.supplieraddress && <p className="mt-1 text-xs text-muted-foreground">{supplier.supplieraddress}</p>}
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
{supplier.suppliernumber && <span className="inline-flex items-center gap-1"><Phone className="h-3 w-3" />{supplier.suppliernumber}</span>}
{supplier.email && <span className="inline-flex items-center gap-1"><Mail className="h-3 w-3" />{supplier.email}</span>}
{supplier.supply_tags && <span className="inline-flex items-center gap-1"><Tag className="h-3 w-3" />{supplier.supply_tags}</span>}
</div>
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}