diff --git a/package-lock.json b/package-lock.json index 542dd0e..4765898 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "@tanstack/react-query": "^5.83.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", + "@types/leaflet": "^1.9.21", "@vitest/coverage-v8": "^4.0.5", "@vitest/ui": "^4.0.5", "axios": "^1.12.2", @@ -62,6 +63,7 @@ "input-otp": "^1.4.2", "jsdom": "^27.0.1", "jspdf": "^4.2.1", + "leaflet": "^1.9.4", "lucide-react": "^0.525.0", "qrcode.react": "^4.2.0", "react": "^19.1.0", @@ -71,6 +73,7 @@ "react-hook-form": "^7.61.1", "react-i18next": "^17.0.8", "react-is": "^19.1.0", + "react-leaflet": "^5.0.0", "react-resizable-panels": "^3.0.3", "react-router-dom": "^7.7.1", "recharts": "^2.15.4", @@ -5593,6 +5596,17 @@ "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", "license": "MIT" }, + "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/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -7037,6 +7051,12 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "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", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -7044,6 +7064,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": "24.13.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.1.tgz", @@ -13844,6 +13873,12 @@ "integrity": "sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g==", "license": "MIT" }, + "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/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -16166,6 +16201,20 @@ "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "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-native-get-random-values": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-2.0.0.tgz", diff --git a/package.json b/package.json index ab3bb24..a3e94a9 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@tanstack/react-query": "^5.83.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", + "@types/leaflet": "^1.9.21", "@vitest/coverage-v8": "^4.0.5", "@vitest/ui": "^4.0.5", "axios": "^1.12.2", @@ -67,6 +68,7 @@ "input-otp": "^1.4.2", "jsdom": "^27.0.1", "jspdf": "^4.2.1", + "leaflet": "^1.9.4", "lucide-react": "^0.525.0", "qrcode.react": "^4.2.0", "react": "^19.1.0", @@ -76,6 +78,7 @@ "react-hook-form": "^7.61.1", "react-i18next": "^17.0.8", "react-is": "^19.1.0", + "react-leaflet": "^5.0.0", "react-resizable-panels": "^3.0.3", "react-router-dom": "^7.7.1", "recharts": "^2.15.4", diff --git a/src/App.tsx b/src/App.tsx index cf0d3b8..fe5eb44 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ const TestHedera = lazy(() => import('./pages/TestHedera')); const Dashboard = lazy(() => import('./pages/Dashboard')); const RiskIntelligence = lazy(() => import('./pages/RiskIntelligence')); const BatchJourney = lazy(() => import('./pages/BatchJourney')); +const MapExplore = lazy(() => import('./pages/MapExplore')); const queryClient = new QueryClient(); @@ -61,6 +62,7 @@ const App = () => ( } /> } /> } /> + } /> } /> diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 4148e65..54476af 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -14,6 +14,8 @@ import { FileText, Coins, ShieldCheck, + ShieldAlert, + MapPin, User, Settings, LogOut, @@ -54,6 +56,8 @@ export default function Navbar() { { to: "/register", label: t('nav.register'), icon: FileText }, { to: "/tokenize", label: t('nav.tokenize'), icon: Coins }, { to: "/verify", label: t('nav.verify'), icon: ShieldCheck }, + { to: "/risk-intelligence", label: "Risk Intelligence", icon: ShieldAlert }, + { to: "/map", label: "Explore Map", icon: MapPin }, { to: "/about", label: t('nav.about'), icon: Info }, ]; diff --git a/src/components/SupplyChainMap.tsx b/src/components/SupplyChainMap.tsx new file mode 100644 index 0000000..e86d0a7 --- /dev/null +++ b/src/components/SupplyChainMap.tsx @@ -0,0 +1,130 @@ +import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'; +import 'leaflet/dist/leaflet.css'; +import L from 'leaflet'; +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { format } from 'date-fns'; + +// Fix for default marker icon in react-leaflet +// eslint-disable-next-line @typescript-eslint/no-explicit-any +delete (L.Icon.Default.prototype as any)._getIconUrl; +L.Icon.Default.mergeOptions({ + iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png', + iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png', + shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', +}); + +// A simple dictionary to map common string locations to approx coordinates (Lat, Lng) +const REGION_COORDINATES: Record = { + 'Lampung': [-4.5586, 105.4068], + 'Sumatra': [-0.5897, 101.3431], + 'Java': [-7.6145, 110.7122], + 'Bali': [-8.4095, 115.1889], + 'Kalimantan': [1.4326, 114.1511], + 'Sulawesi': [-1.4300, 121.4456], + 'Papua': [-4.2699, 138.0803], + 'Jakarta': [-6.2088, 106.8456], + 'Bandung': [-6.9175, 107.6191], + 'Surabaya': [-7.2504, 112.7688], + 'Default': [-0.7893, 113.9213] // Center of Indonesia +}; + +export interface MapBatch { + id: string; + batch_name: string; + location: string; + quantity: number; + harvest_date: string; + status: string; + farmer_id: string; + hcs_tx_id?: string; + ai_analysis?: { + riskLevel?: string; + }; +} + +interface SupplyChainMapProps { + batches: MapBatch[]; +} + +function getCoordinates(locationStr: string): [number, number] { + if (!locationStr) return REGION_COORDINATES['Default']; + + const loc = locationStr.toLowerCase(); + for (const [region, coords] of Object.entries(REGION_COORDINATES)) { + if (loc.includes(region.toLowerCase())) { + // Add a tiny random jitter so markers in the same region don't completely overlap + const jitterLat = (Math.random() - 0.5) * 0.5; + const jitterLng = (Math.random() - 0.5) * 0.5; + return [coords[0] + jitterLat, coords[1] + jitterLng]; + } + } + + // If not found, place in center of Indonesia with wider jitter + const jitterLat = (Math.random() - 0.5) * 5; + const jitterLng = (Math.random() - 0.5) * 10; + return [REGION_COORDINATES['Default'][0] + jitterLat, REGION_COORDINATES['Default'][1] + jitterLng]; +} + +export function SupplyChainMap({ batches }: SupplyChainMapProps) { + // Center of Indonesia + const centerPosition: [number, number] = [-0.7893, 113.9213]; + + return ( + + + + + {batches.map((batch) => { + const position = getCoordinates(batch.location); + const riskLevel = batch.ai_analysis?.riskLevel || 'Unknown'; + + let riskColor = 'bg-gray-500'; + if (riskLevel.toLowerCase() === 'low') riskColor = 'bg-emerald-500'; + if (riskLevel.toLowerCase() === 'medium') riskColor = 'bg-yellow-500'; + if (riskLevel.toLowerCase() === 'high') riskColor = 'bg-red-500'; + + return ( + + +
+

{batch.batch_name}

+
+ + {riskLevel} Risk + + {batch.status} +
+
+

Location: {batch.location || 'Unknown'}

+

Quantity: {batch.quantity} kg

+

Harvest: {batch.harvest_date ? format(new Date(batch.harvest_date), 'MMM d, yyyy') : 'N/A'}

+

Farmer ID: {batch.farmer_id.substring(0, 8)}...

+
+ {batch.hcs_tx_id && ( + + View on HashScan + + )} +
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 6531f3e..2535b38 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -748,6 +748,23 @@ export const getFraudOverview = async (): Promise<{ ok: boolean; data: FraudOver return payload; }; +/** + * Fetch all registered batches from Supabase for the Supply Chain Map. + * Returns an array of batch records with location, quantity, and risk data. + */ +export const getBatches = async () => { + const { data, error } = await supabase + .from('batches') + .select('id, batch_name, location, quantity, harvest_date, status, farmer_id, hcs_tx_id, ai_analysis') + .order('created_at', { ascending: false }); + + if (error) { + throw new Error(error.message || 'Failed to fetch batches'); + } + + return data || []; +}; + export interface AuditLogEntry { token_id: string; serial_number: string; diff --git a/src/pages/MapExplore.tsx b/src/pages/MapExplore.tsx new file mode 100644 index 0000000..e1a5d40 --- /dev/null +++ b/src/pages/MapExplore.tsx @@ -0,0 +1,49 @@ +import { Helmet } from 'react-helmet-async'; +import { useQuery } from '@tanstack/react-query'; +import { getBatches } from '@/lib/api'; +import Navbar from '@/components/Navbar'; +import Footer from '@/components/Footer'; +import { SupplyChainMap, MapBatch } from '@/components/SupplyChainMap'; +import { Loader2 } from 'lucide-react'; + +export default function MapExplore() { + const { data: batches, isLoading, error } = useQuery({ + queryKey: ['explore-batches'], + queryFn: getBatches, + }); + + return ( +
+ + Supply Chain Map | AgroDex + + + +
+
+

+ Supply Chain Map +

+

+ Explore registered agricultural batches across Indonesia. Click on any pin to view provenance, fraud risk, and Hedera verification status. +

+
+ + {isLoading ? ( +
+ + Loading supply chain data... +
+ ) : error ? ( +
+ Failed to load map data. Please try again later. +
+ ) : ( + + )} +
+ +
+ ); +}