Add supplier map feature with Leaflet UI

Introduce a new SupplierMap component and page to visualize suppliers on a Leaflet map (client-side, dynamic import). Wire the feature into the app by importing Leaflet CSS in the global layout and adding a Supplier Map entry to the sidebar. The map fetches suppliers, parses coordinates, groups nearby suppliers into proximity clusters, and shows markers, tooltips, and lists of missing coordinates. Update package.json to include leaflet, react-leaflet and types; remove a Windows-specific lightningcss package. Also normalize Dockerfile ENV formatting (no behavior change).
This commit is contained in:
SJCoetz 2026-05-27 14:39:00 +02:00
parent fd1571e294
commit 565b1a6e2b
7 changed files with 414 additions and 6 deletions

View file

@ -14,7 +14,7 @@ RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
@ -25,7 +25,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
CMD ["node", "server.js"]

51
package-lock.json generated
View file

@ -10,15 +10,18 @@
"dependencies": {
"@base-ui/react": "^1.5.0",
"@radix-ui/react-slot": "^1.2.4",
"@types/leaflet": "^1.9.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.16.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"pocketbase": "^0.26.9",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-leaflet": "^5.0.0",
"shadcn": "^4.8.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
@ -31,7 +34,6 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"lightningcss-win32-x64-msvc": "^1.32.0",
"tailwindcss": "^4",
"typescript": "^5"
}
@ -2209,6 +2211,17 @@
}
}
},
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"dev": true,
@ -2575,6 +2588,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"dev": true,
@ -2585,6 +2604,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.41",
"license": "MIT",
@ -6211,6 +6239,12 @@
"node": ">=0.10"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
"dev": true,
@ -6468,6 +6502,7 @@
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
@ -7513,6 +7548,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^3.0.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"license": "MIT",

View file

@ -13,15 +13,18 @@
"dependencies": {
"@base-ui/react": "^1.5.0",
"@radix-ui/react-slot": "^1.2.4",
"@types/leaflet": "^1.9.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.16.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"pocketbase": "^0.26.9",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-leaflet": "^5.0.0",
"shadcn": "^4.8.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
@ -34,7 +37,6 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"lightningcss-win32-x64-msvc": "^1.32.0",
"tailwindcss": "^4",
"typescript": "^5"
}

View file

@ -0,0 +1,38 @@
"use client";
import dynamic from "next/dynamic";
import { MapPinned } from "lucide-react";
const SupplierMap = dynamic(
() => import("@/components/supplier-map").then((module) => module.SupplierMap),
{
ssr: false,
loading: () => (
<div className="flex min-h-[520px] items-center justify-center rounded-xl border bg-card">
<div className="text-center">
<div className="mx-auto mb-3 h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />
<p className="text-sm text-muted-foreground">Loading supplier map...</p>
</div>
</div>
),
}
);
export default function SupplierMapPage() {
return (
<div className="space-y-6">
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/8 px-3 py-1 text-xs font-medium text-primary">
<MapPinned className="h-3.5 w-3.5" />
Supplier network
</div>
<h1 className="mt-3 text-2xl font-bold tracking-tight">Supplier Map</h1>
<p className="text-sm text-muted-foreground sm:text-base">
View suppliers from PocketBase on a Leaflet map, grouped by proximity with visible name tags.
</p>
</div>
<SupplierMap />
</div>
);
}

View file

@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "leaflet/dist/leaflet.css";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { AuthProvider } from "@/components/auth-provider";

View file

@ -21,6 +21,7 @@ import {
ClipboardList,
Wrench,
ChevronDown,
MapPinned,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@ -40,6 +41,7 @@ const managementItems = [
{ href: "/customers", label: "Customers", icon: Users },
{ href: "/companies", label: "Companies", icon: Building2 },
{ href: "/suppliers", label: "Suppliers", icon: Truck },
{ href: "/supplier-map", label: "Supplier Map", icon: MapPinned },
];
const quoteItems = [

View file

@ -0,0 +1,316 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import L from "leaflet";
import { MapContainer, Marker, Popup, TileLayer, Tooltip, Circle } from "react-leaflet";
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 { Mail, MapPin, Phone, RefreshCw, Search, Tag } from "lucide-react";
import { toast } from "sonner";
import { getAllSuppliers } from "@/lib/pb-helpers";
import type { Supplier } from "@/lib/types";
type SupplierPoint = Supplier & {
lat: number;
lng: number;
};
type SupplierCluster = {
id: string;
color: string;
center: [number, number];
suppliers: SupplierPoint[];
};
const DEFAULT_CENTER: [number, number] = [-26.2041, 28.0473];
const CLUSTER_DISTANCE_KM = 8;
const CLUSTER_COLORS = ["#1B76BB", "#E64B4B", "#F4B842", "#169B62", "#7C3AED", "#EA580C", "#0F766E"];
function parseCoordinates(value?: string): [number, number] | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
const jsonMatch = trimmed.match(/"?(?:lat|latitude)"?\s*:\s*(-?\d+(?:\.\d+)?).*"?(?:lng|lon|longitude)"?\s*:\s*(-?\d+(?:\.\d+)?)/i);
if (jsonMatch) return [Number(jsonMatch[1]), Number(jsonMatch[2])];
const atMatch = trimmed.match(/@(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)/);
if (atMatch) return [Number(atMatch[1]), Number(atMatch[2])];
const pairMatch = trimmed.match(/(-?\d+(?:\.\d+)?)\s*[,;\s]\s*(-?\d+(?:\.\d+)?)/);
if (!pairMatch) return null;
const first = Number(pairMatch[1]);
const second = Number(pairMatch[2]);
if (!Number.isFinite(first) || !Number.isFinite(second)) return null;
return [first, second];
}
function distanceKm(a: [number, number], b: [number, number]) {
const toRad = (value: number) => (value * Math.PI) / 180;
const earthRadiusKm = 6371;
const dLat = toRad(b[0] - a[0]);
const dLng = toRad(b[1] - a[1]);
const lat1 = toRad(a[0]);
const lat2 = toRad(b[0]);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
return 2 * earthRadiusKm * Math.asin(Math.sqrt(h));
}
function markerIcon(color: string, label: string) {
return L.divIcon({
className: "",
html: `<div style="display:grid;place-items:center;width:30px;height:30px;border-radius:999px;background:${color};color:white;border:3px solid white;box-shadow:0 8px 18px rgba(15,23,42,.28);font-size:11px;font-weight:800;">${label}</div>`,
iconSize: [30, 30],
iconAnchor: [15, 15],
});
}
function buildClusters(points: SupplierPoint[]) {
const clusters: SupplierCluster[] = [];
for (const supplier of points) {
const location: [number, number] = [supplier.lat, supplier.lng];
const nearby = clusters.find((cluster) => distanceKm(cluster.center, location) <= CLUSTER_DISTANCE_KM);
if (!nearby) {
clusters.push({
id: `cluster-${clusters.length + 1}`,
color: CLUSTER_COLORS[clusters.length % CLUSTER_COLORS.length],
center: location,
suppliers: [supplier],
});
continue;
}
nearby.suppliers.push(supplier);
nearby.center = [
nearby.suppliers.reduce((sum, item) => sum + item.lat, 0) / nearby.suppliers.length,
nearby.suppliers.reduce((sum, item) => sum + item.lng, 0) / nearby.suppliers.length,
];
}
return clusters;
}
export function SupplierMap() {
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const fetchSuppliers = async () => {
setLoading(true);
try {
setSuppliers(await getAllSuppliers());
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to load suppliers");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSuppliers();
}, []);
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 clusters = useMemo(() => buildClusters(mapped), [mapped]);
const mapCenter = mapped.length
? ([
mapped.reduce((sum, supplier) => sum + supplier.lat, 0) / mapped.length,
mapped.reduce((sum, supplier) => sum + supplier.lng, 0) / mapped.length,
] as [number, number])
: DEFAULT_CENTER;
const clusterBySupplierId = new Map<string, SupplierCluster>();
clusters.forEach((cluster) => cluster.suppliers.forEach((supplier) => clusterBySupplierId.set(supplier.id, cluster)));
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 {CLUSTER_DISTANCE_KM}km</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>
<Button variant="outline" onClick={fetchSuppliers} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card className="overflow-hidden border-primary/10">
<div className="h-[68vh] min-h-[520px]">
<MapContainer center={mapCenter} zoom={mapped.length ? 10 : 6} scrollWheelZoom className="h-full w-full">
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{clusters.map((cluster) => (
<Circle
key={cluster.id}
center={cluster.center}
radius={CLUSTER_DISTANCE_KM * 1000}
pathOptions={{ color: cluster.color, fillColor: cluster.color, fillOpacity: 0.08, weight: 2 }}
/>
))}
{mapped.map((supplier) => {
const cluster = clusterBySupplierId.get(supplier.id);
const label = cluster ? String(cluster.suppliers.findIndex((item) => item.id === supplier.id) + 1) : "";
return (
<Marker
key={supplier.id}
position={[supplier.lat, supplier.lng]}
icon={markerIcon(cluster?.color || CLUSTER_COLORS[0], label)}
>
<Tooltip direction="top" offset={[0, -16]} permanent>
{supplier.suppliername || "Unnamed supplier"}
</Tooltip>
<Popup>
<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>}
</div>
</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.suppliers.length} supplier{cluster.suppliers.length === 1 ? "" : "s"}</Badge>
</div>
<div className="space-y-2">
{cluster.suppliers.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>
);
}