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) => (
+
+
+
+
{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}}
+
+
+ ))
+ )}
+
+
+
+
+
+ );
+}