"use client"; import { useState, useMemo, useEffect, useRef } from "react"; import dynamic from "next/dynamic"; import { DndContext, DragOverlay, useDroppable, useSensor, useSensors, PointerSensor, type DragEndEvent } from "@dnd-kit/core"; import { AppShell } from "@/components/app-shell"; import { MapPageHeader } from "@/components/maps/map-page-header"; import { PovertyStatsCards } from "@/components/poverty/poverty-stats-cards"; import { PovertyTrendChart } from "@/components/poverty/poverty-trend-chart"; import { PovertyDirectory } from "@/components/poverty/poverty-directory"; import { PovertyInspectorTop, PovertyInspectorBottom } from "@/components/poverty/poverty-inspector"; import { PovertyHud } from "@/components/poverty/poverty-hud"; import { TrendingDownIcon } from "@/components/ui/phosphor-icons"; import { PovertyDisclosureCard } from "@/components/ui/collection-grid-disclosure"; import type { UserMarker } from "@/components/leaflet-map"; import type { HouseholdRow, PovertyOverview } from "@/lib/poverty-types"; import { MarkerPlacementDialog } from "@/components/poverty/marker-placement-dialog"; import { ReligionDialog } from "@/components/poverty/religion-dialog"; import { MarkerEditDialog } from "@/components/poverty/marker-edit-dialog"; import { PovertyHouseholdsTable } from "@/components/poverty/poverty-households-table"; import { FuzzyPriorityStats } from "@/components/poverty/fuzzy-priority-stats"; import { toast } from "sonner"; const LeafletMap = dynamic(() => import("@/components/leaflet-map"), { ssr: false }); const PLACING_LABELS: Record = { marker: "Data Warga", religion: "Rumah Ibadah", clinic: "Klinik", "food-bank": "Lumbung Pangan", school: "Sekolah", }; function MapDropZone() { const { setNodeRef } = useDroppable({ id: "map-canvas" }); return
; } export default function PovertyMapPage() { const [searchQuery, setSearchQuery] = useState(""); // Initialize deterministically (matches SSR) and hydrate from sessionStorage // after mount to avoid a hydration mismatch that regenerates the whole tree. const [useLeaflet, setUseLeaflet] = useState(false); const [mapStyle, setMapStyle] = useState<"street" | "satellite">("street"); const [hydrated, setHydrated] = useState(false); const [userMarkers, setUserMarkers] = useState([]); const [placingMarkerType, setPlacingMarkerType] = useState(null); const [dragDropEnabled, setDragDropEnabled] = useState(false); const [pendingClick, setPendingClick] = useState<{ lat: number; lng: number } | null>(null); const [householdsRefreshKey, setHouseholdsRefreshKey] = useState(0); const [editingMarker, setEditingMarker] = useState(null); const [draggedItemId, setDraggedItemId] = useState(null); const [households, setHouseholds] = useState([]); const [householdsLoading, setHouseholdsLoading] = useState(true); const [overview, setOverview] = useState(null); const [selectedHouseholdId, setSelectedHouseholdId] = useState(null); const mapInstanceRef = useRef(null); const mapContainerRef = useRef(null); // Require a small drag distance before activating dnd, so a plain click on a // disclosure item is treated as a tool toggle (not a drag). Without this the // click is swallowed and the marker tool never activates — the dialog would // then only appear via drag-drop instead of the click-tool → click-map flow. const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }) ); const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000"; // Dashboard data — households + aggregated overview, straight from the DB. useEffect(() => { const token = localStorage.getItem("auth_token"); const headers: Record = token ? { Authorization: `Bearer ${token}` } : {}; Promise.allSettled([ fetch(`${BACKEND_URL}/households`, { headers }).then((r) => r.json()), fetch(`${BACKEND_URL}/households/stats-overview`, { headers }).then((r) => r.json()), ]).then(([hhResult, overviewResult]) => { if (hhResult.status === "fulfilled" && Array.isArray(hhResult.value)) { setHouseholds(hhResult.value as HouseholdRow[]); } if (overviewResult.status === "fulfilled" && overviewResult.value?.totals) { setOverview(overviewResult.value as PovertyOverview); } setHouseholdsLoading(false); }); }, [householdsRefreshKey]); // Load existing markers when map view opens. Households are reused from the // dashboard fetch above; only POIs need their own request here. useEffect(() => { if (!useLeaflet) return; const token = localStorage.getItem("auth_token"); const headers: Record = token ? { Authorization: `Bearer ${token}` } : {}; fetch(`${BACKEND_URL}/poi`, { headers }) .then((r) => r.json()) .catch(() => []) .then((poiRows: any[]) => { const hhMarkers: UserMarker[] = households.map((r) => ({ id: `hh-${r.id}`, lat: parseFloat(r.latitude), lng: parseFloat(r.longitude), type: "marker", name: r.head_name, meta: { poverty_level: r.poverty_level, family_count: r.family_count, penghasilan: r.penghasilan, fuzzy_label: r.fuzzy_label ?? undefined, fuzzy_score: r.fuzzy_score != null ? Number(r.fuzzy_score) : undefined, notes: r.notes ?? undefined, }, })); const poiMarkers: UserMarker[] = (Array.isArray(poiRows) ? poiRows : []).map((r) => ({ id: `poi-${r.id}`, lat: parseFloat(r.latitude), lng: parseFloat(r.longitude), type: r.religion_subtype ?? r.poi_type, name: r.name, meta: { poi_type: r.poi_type, notes: r.notes ?? undefined, radius: r.radius_meters ?? 0, }, })); setUserMarkers([...hhMarkers, ...poiMarkers]); }); }, [useLeaflet, households]); useEffect(() => { setUseLeaflet(sessionStorage.getItem("poverty_useLeaflet") === "true"); setMapStyle((sessionStorage.getItem("poverty_mapStyle") as "street" | "satellite") ?? "street"); setHydrated(true); }, []); useEffect(() => { if (hydrated) sessionStorage.setItem("poverty_useLeaflet", String(useLeaflet)); }, [useLeaflet, hydrated]); useEffect(() => { if (hydrated) sessionStorage.setItem("poverty_mapStyle", mapStyle); }, [mapStyle, hydrated]); function handleDragEnd(event: DragEndEvent) { const { active, over } = event; setDraggedItemId(null); if (over?.id !== "map-canvas") return; const itemId = (active.data.current as any)?.itemId as string | undefined; if (!itemId || !mapInstanceRef.current) return; // Get drop coordinates in the map container const nativeEvent = event.activatorEvent as PointerEvent; const rect = mapContainerRef.current?.getBoundingClientRect(); if (!rect) return; const L = (window as any).L; if (!L) return; const x = nativeEvent.clientX + (event.delta?.x ?? 0) - rect.left; const y = nativeEvent.clientY + (event.delta?.y ?? 0) - rect.top; const latLng = mapInstanceRef.current.containerPointToLatLng(L.point(x, y)); setPendingClick({ lat: latLng.lat, lng: latLng.lng }); setPlacingMarkerType(itemId); } const demographics = useMemo(() => { const householdMarkers = userMarkers.filter((m) => m.type === "marker"); return { population: householdMarkers.reduce((sum, h) => sum + (h.meta?.family_count ?? 0), 0), beneficiaries: householdMarkers.filter((h) => h.meta?.poverty_level === "Extreme" || h.meta?.poverty_level === "Miskin").length, socialSecurity: householdMarkers.filter((h) => h.meta?.poverty_level === "Rentan").length, }; }, [userMarkers]); // Default the inspector to the highest-priority household. const selectedHousehold = useMemo(() => { if (households.length === 0) return null; const found = households.find((h) => h.id === selectedHouseholdId); if (found) return found; return [...households].sort( (a, b) => Number(b.fuzzy_score ?? -1) - Number(a.fuzzy_score ?? -1) )[0]; }, [households, selectedHouseholdId]); async function handleMarkerDragEnd(marker: UserMarker, newLat: number, newLng: number) { const isHousehold = marker.id.startsWith("hh-"); const dbId = marker.id.split("-").slice(1).join("-"); const token = localStorage.getItem("auth_token"); const headers = { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }; try { const url = isHousehold ? `${BACKEND_URL}/households/${dbId}` : `${BACKEND_URL}/poi/${dbId}`; const res = await fetch(url, { method: "PUT", headers, body: JSON.stringify({ latitude: newLat, longitude: newLng }), }); if (!res.ok) throw new Error("Gagal memindahkan penanda"); setUserMarkers((prev) => prev.map((m) => m.id === marker.id ? { ...m, lat: newLat, lng: newLng } : m ) ); toast.success(`Posisi "${marker.name}" berhasil diperbarui`); if (isHousehold) { setHouseholdsRefreshKey((k) => k + 1); } } catch (err) { toast.error(err instanceof Error ? err.message : "Gagal memindahkan penanda"); } } // FULL BLEED MAP LAYOUT if (useLeaflet) { return ( setDraggedItemId((e.active.data.current as any)?.itemId ?? null)} onDragEnd={handleDragEnd} onDragCancel={() => setDraggedItemId(null)} > setUseLeaflet(false)}>
{/* Leaflet Map filling the screen */} { if (placingMarkerType && !editingMarker) { setPendingClick({ lat, lng }); } }} onMarkerClick={(marker) => { if (!placingMarkerType) setEditingMarker(marker); }} onMapReady={(map) => { mapInstanceRef.current = map; }} dragDropEnabled={dragDropEnabled} onMarkerDragEnd={handleMarkerDragEnd} /> {/* Placing Marker Banner — hidden while the dialog is open */} {placingMarkerType && !pendingClick && (
Menempatkan {PLACING_LABELS[placingMarkerType] ?? placingMarkerType.replace("-", " ")}... Klik pada peta untuk menentukan posisi
)} {/* Floating Legend HUD overlay */} {/* Floating Collection Grid Disclosure */}
{/* Marker Placement Dialog (households + non-religion POIs) */} { if (!v) { setPendingClick(null); setPlacingMarkerType(null); } }} markerType={placingMarkerType ?? "marker"} pendingClick={pendingClick} onConfirm={(marker) => { setUserMarkers((prev) => [...prev, marker]); setPendingClick(null); setPlacingMarkerType(null); if (marker.type === "marker") setHouseholdsRefreshKey((k) => k + 1); }} /> {/* Religion Place Dialog */} { if (!v) { setPendingClick(null); setPlacingMarkerType(null); } }} pendingClick={pendingClick} onConfirm={(marker) => { setUserMarkers((prev) => [...prev, marker]); setPendingClick(null); setPlacingMarkerType(null); }} /> {/* Marker Edit/Delete Dialog */} { if (!v) setEditingMarker(null); }} marker={editingMarker} onUpdated={(updated) => { setUserMarkers((prev) => prev.map((m) => m.id === updated.id ? updated : m)); setEditingMarker(null); if (updated.type === "marker") setHouseholdsRefreshKey((k) => k + 1); }} onDeleted={(deleted) => { setUserMarkers((prev) => prev.filter((m) => m.id !== deleted.id)); setEditingMarker(null); if (deleted.type === "marker") setHouseholdsRefreshKey((k) => k + 1); }} />
{/* Drag overlay badge */} {draggedItemId && (
{PLACING_LABELS[draggedItemId] ?? draggedItemId.replace("-", " ")}
)}
); } // STANDARD DASHBOARD LAYOUT (DEFAULT) return (
setUseLeaflet(true)} buttonText="Peta Kemiskinan" icon={} /> {/* Unified Grid Layout */}
{/* Row 1: 4 stats cards (rendered inside PovertyStatsCards as direct children) */} {/* Row 2: Trend Chart */}
{/* Row 2: Inspector Top */}
{/* Row 3: Inspector Bottom */}
{/* Row 3: Directory */}
setSelectedHouseholdId(h.id)} />
{/* Row 4: Fuzzy Priority Distribution */} setHouseholdsRefreshKey((k) => k + 1)} /> {/* Row 5: Households Table (full width) */}
); }