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