From 565b1a6e2b4b1dbba7747c2006df28c39f963e29 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Wed, 27 May 2026 14:39:00 +0200 Subject: [PATCH] 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). --- Dockerfile | 8 +- package-lock.json | 51 ++++- package.json | 4 +- src/app/(app)/supplier-map/page.tsx | 38 ++++ src/app/layout.tsx | 1 + src/components/sidebar.tsx | 2 + src/components/supplier-map.tsx | 316 ++++++++++++++++++++++++++++ 7 files changed, 414 insertions(+), 6 deletions(-) create mode 100644 src/app/(app)/supplier-map/page.tsx create mode 100644 src/components/supplier-map.tsx diff --git a/Dockerfile b/Dockerfile index d39090a..afb9025 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file +CMD ["node", "server.js"] diff --git a/package-lock.json b/package-lock.json index e87cebe..4b33f0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b5bafc3..a24d07e 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/src/app/(app)/supplier-map/page.tsx b/src/app/(app)/supplier-map/page.tsx new file mode 100644 index 0000000..ff9fe90 --- /dev/null +++ b/src/app/(app)/supplier-map/page.tsx @@ -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: () => ( +
+
+
+

Loading supplier map...

+
+
+ ), + } +); + +export default function SupplierMapPage() { + return ( +
+
+
+ + Supplier network +
+

Supplier Map

+

+ View suppliers from PocketBase on a Leaflet map, grouped by proximity with visible name tags. +

+
+ + +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f8b85f6..808c7c0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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"; diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 7c249b0..e9520f5 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -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 = [ diff --git a/src/components/supplier-map.tsx b/src/components/supplier-map.tsx new file mode 100644 index 0000000..b84b917 --- /dev/null +++ b/src/components/supplier-map.tsx @@ -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: `
${label}
`, + 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([]); + 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(); + clusters.forEach((cluster) => cluster.suppliers.forEach((supplier) => clusterBySupplierId.set(supplier.id, cluster))); + + return ( +
+
+ + + Mapped Suppliers + + +

{mapped.length}

+

With usable coordinates

+
+
+ + + Proximity Groups + + +

{clusters.length}

+

Grouped within {CLUSTER_DISTANCE_KM}km

+
+
+ + + Needs Coordinates + + +

{missing.length}

+

Supplier records to update

+
+
+
+ +
+
+ + setSearch(event.target.value)} + placeholder="Search suppliers, tags, address..." + className="pl-9" + /> +
+ +
+ +
+ +
+ + + {clusters.map((cluster) => ( + + ))} + {mapped.map((supplier) => { + const cluster = clusterBySupplierId.get(supplier.id); + const label = cluster ? String(cluster.suppliers.findIndex((item) => item.id === supplier.id) + 1) : ""; + return ( + + + {supplier.suppliername || "Unnamed supplier"} + + +
+

{supplier.suppliername || "Unnamed supplier"}

+ {supplier.supplieraddress &&

{supplier.supplieraddress}

} + {supplier.suppliernumber &&

Phone: {supplier.suppliernumber}

} + {supplier.email &&

Email: {supplier.email}

} + {supplier.supply_tags &&

Tags: {supplier.supply_tags}

} +
+
+
+ ); + })} +
+
+
+ +
+ + + + + Proximity Groups + + + + {clusters.length === 0 ? ( +

No mapped suppliers yet.

+ ) : ( + clusters.map((cluster, index) => ( +
+
+
+ +

Group {index + 1}

+
+ {cluster.suppliers.length} supplier{cluster.suppliers.length === 1 ? "" : "s"} +
+
+ {cluster.suppliers.map((supplier, supplierIndex) => ( +
+

{supplierIndex + 1}. {supplier.suppliername || "Unnamed supplier"}

+ {supplier.supplieraddress && ( +

{supplier.supplieraddress}

+ )} +
+ ))} +
+
+ )) + )} +
+
+ + + + Missing Coordinates + + + {missing.length === 0 ? ( +

All visible suppliers have coordinates.

+ ) : ( + missing.map((supplier) => ( +
+

{supplier.suppliername || "Unnamed supplier"}

+ {supplier.supplieraddress &&

{supplier.supplieraddress}

} +
+ {supplier.suppliernumber && {supplier.suppliernumber}} + {supplier.email && {supplier.email}} + {supplier.supply_tags && {supplier.supply_tags}} +
+
+ )) + )} +
+
+
+
+
+ ); +}