"use client"; import { useState, useMemo, useEffect } from "react"; import dynamic from "next/dynamic"; import { AppShell } from "@/components/app-shell"; import { MapPageHeader } from "@/components/maps/map-page-header"; import { LandStatsCards } from "@/components/land/land-stats-cards"; import { LandAllocationChart } from "@/components/land/land-allocation-chart"; import { LandDirectory } from "@/components/land/land-directory"; import { LandInspectorTop, LandInspectorBottom } from "@/components/land/land-inspector"; import { LAND_MARKER_LABELS } from "@/lib/map-data"; import { LayersIcon } from "@/components/ui/phosphor-icons"; import { LandDisclosureCard } from "@/components/ui/collection-grid-disclosure"; import { ShapePlacementDialog, LAND_STATUSES, calcLineLength, calcPolygonArea, } from "@/components/land/shape-placement-dialog"; import { ShapeEditDialog } from "@/components/land/shape-edit-dialog"; import type { UserMarker } from "@/components/leaflet-map"; import { toast } from "sonner"; const LeafletMap = dynamic(() => import("@/components/leaflet-map"), { ssr: false }); const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000"; function authHeaders(json = false) { const token = localStorage.getItem("auth_token"); return { ...(json ? { "Content-Type": "application/json" } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) }; } export default function LandMapPage() { const [searchQuery, setSearchQuery] = useState(""); const [visibilityFilters, setVisibilityFilters] = useState>({ marker: true, flag: true, protected: true, registry: true, line: true, polygon: true, circle: true, }); const [useLeaflet, setUseLeaflet] = useState(false); const [mapStyle, setMapStyle] = useState<"street" | "satellite">("street"); const [hydrated, setHydrated] = useState(false); const [userMarkers, setUserMarkers] = useState([]); const [refreshKey, setRefreshKey] = useState(0); const [placingMarkerType, setPlacingMarkerType] = useState(null); const [dragDropEnabled, setDragDropEnabled] = useState(false); const [selectedItemId, setSelectedItemId] = useState(null); // Shape/marker dialog state const [pendingShape, setPendingShape] = useState(null); const [editingShape, setEditingShape] = useState(null); // Hydrate persisted view state from sessionStorage on mount useEffect(() => { setUseLeaflet(sessionStorage.getItem("land_useLeaflet") === "true"); setMapStyle((sessionStorage.getItem("land_mapStyle") as "street" | "satellite") ?? "street"); const storedFilters = sessionStorage.getItem("land_visibilityFilters"); if (storedFilters) { try { setVisibilityFilters(JSON.parse(storedFilters)); } catch { /* keep defaults */ } } setSelectedItemId(sessionStorage.getItem("land_selectedItemId")); setHydrated(true); }, []); useEffect(() => { if (hydrated) sessionStorage.setItem("land_useLeaflet", String(useLeaflet)); }, [useLeaflet, hydrated]); useEffect(() => { if (hydrated) sessionStorage.setItem("land_mapStyle", mapStyle); }, [mapStyle, hydrated]); useEffect(() => { if (hydrated) sessionStorage.setItem("land_visibilityFilters", JSON.stringify(visibilityFilters)); }, [visibilityFilters, hydrated]); useEffect(() => { if (!hydrated) return; if (selectedItemId) sessionStorage.setItem("land_selectedItemId", selectedItemId); else sessionStorage.removeItem("land_selectedItemId"); }, [selectedItemId, hydrated]); // Always-on data fetch — feeds both the dashboard widgets and the map. useEffect(() => { const token = localStorage.getItem("auth_token"); const headers: Record = token ? { Authorization: `Bearer ${token}` } : {}; Promise.allSettled([ fetch(`${BACKEND_URL}/land/markers`, { headers }).then((r) => r.json()), fetch(`${BACKEND_URL}/land/shapes`, { headers }).then((r) => r.json()), ]).then(([markersResult, shapesResult]) => { const markerRows = markersResult.status === "fulfilled" && Array.isArray(markersResult.value) ? markersResult.value : []; const shapeRows = shapesResult.status === "fulfilled" && Array.isArray(shapesResult.value) ? shapesResult.value : []; const pointMarkers: UserMarker[] = markerRows.map((r: any) => ({ id: `lm-${r.id}`, lat: parseFloat(r.latitude), lng: parseFloat(r.longitude), type: r.marker_type, name: r.name, meta: { notes: r.notes ?? undefined, created_by_username: r.created_by_username ?? undefined, created_at: r.created_at ?? undefined, updated_at: r.updated_at ?? undefined, }, })); const shapeMarkers: UserMarker[] = shapeRows.map((r: any) => ({ id: `ls-${r.id}`, lat: parseFloat(r.latitude), lng: parseFloat(r.longitude), type: r.shape_type, name: r.name, meta: { poi_type: r.status ?? undefined, coordinates: r.coordinates ?? undefined, radius: r.radius_meters != null ? parseFloat(r.radius_meters) : undefined, notes: r.notes ?? undefined, created_by_username: r.created_by_username ?? undefined, created_at: r.created_at ?? undefined, updated_at: r.updated_at ?? undefined, }, })); setUserMarkers([...pointMarkers, ...shapeMarkers]); }); }, [refreshKey]); const landMarkerItems = useMemo(() => userMarkers.filter((m) => m.id.startsWith("lm-")), [userMarkers]); const landShapeItems = useMemo(() => userMarkers.filter((m) => m.id.startsWith("ls-")), [userMarkers]); const filteredItems = useMemo(() => { const q = searchQuery.toLowerCase(); return userMarkers.filter((m) => { const label = LAND_MARKER_LABELS[m.type] ?? m.type; return m.name.toLowerCase().includes(q) || label.toLowerCase().includes(q); }); }, [searchQuery, userMarkers]); const aggregateStats = useMemo(() => { let totalAreaHa = 0; let totalRoadKm = 0; landShapeItems.forEach((m) => { if (m.type === "polygon" && m.meta?.coordinates) totalAreaHa += calcPolygonArea(m.meta.coordinates) / 10000; else if (m.type === "line" && m.meta?.coordinates) totalRoadKm += calcLineLength(m.meta.coordinates) / 1000; }); return { totalParcelArea: totalAreaHa.toLocaleString(undefined, { maximumFractionDigits: 1 }), totalRoadLength: totalRoadKm.toLocaleString(undefined, { maximumFractionDigits: 1 }), activeMarkers: landMarkerItems.length, }; }, [landShapeItems, landMarkerItems]); const pieChartData = useMemo(() => { const totals: Record = {}; landShapeItems.forEach((m) => { if (m.type === "polygon" && m.meta?.coordinates) { const status = m.meta.poi_type ?? "Unknown"; totals[status] = (totals[status] ?? 0) + calcPolygonArea(m.meta.coordinates) / 10000; } }); return Object.entries(totals).map(([status, value]) => { const found = LAND_STATUSES.find((s) => s.value === status); return { name: found?.label ?? status, value: parseFloat(value.toFixed(1)), color: found?.color ?? "#6b7280" }; }); }, [landShapeItems]); const selectedItem = useMemo(() => userMarkers.find((m) => m.id === selectedItemId) ?? null, [selectedItemId, userMarkers]); const recentFlags = useMemo( () => landMarkerItems .filter((m) => m.type === "flag") .sort((a, b) => (b.meta?.created_at ?? "").localeCompare(a.meta?.created_at ?? "")) .slice(0, 2), [landMarkerItems] ); const toggleFilter = (type: string) => { setVisibilityFilters((prev) => ({ ...prev, [type]: !prev[type], })); }; async function handleConfirmPlacement(item: UserMarker) { const isPointType = !["line", "polygon", "circle"].includes(item.type); try { const res = await fetch(`${BACKEND_URL}/land/${isPointType ? "markers" : "shapes"}`, { method: "POST", headers: authHeaders(true), body: JSON.stringify( isPointType ? { name: item.name, marker_type: item.type, latitude: item.lat, longitude: item.lng, notes: item.meta?.notes ?? null } : { name: item.name, shape_type: item.type, status: item.meta?.poi_type ?? null, latitude: item.lat, longitude: item.lng, coordinates: item.meta?.coordinates ?? null, radius_meters: item.meta?.radius ?? null, notes: item.meta?.notes ?? null, } ), }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal menyimpan"); setPendingShape(null); setRefreshKey((k) => k + 1); toast.success(`"${item.name}" berhasil disimpan`); } catch (err) { toast.error(err instanceof Error ? err.message : "Gagal menyimpan"); } } async function handleUpdateItem(updated: UserMarker) { const isMarker = updated.id.startsWith("lm-"); const dbId = updated.id.split("-").slice(1).join("-"); const url = `${BACKEND_URL}/land/${isMarker ? "markers" : "shapes"}/${dbId}`; const body = isMarker ? { name: updated.name, marker_type: updated.type, notes: updated.meta?.notes ?? null } : { name: updated.name, shape_type: updated.type, status: updated.meta?.poi_type ?? null, notes: updated.meta?.notes ?? null }; try { const res = await fetch(url, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(body) }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal memperbarui"); setEditingShape(null); setRefreshKey((k) => k + 1); toast.success(`"${updated.name}" berhasil diperbarui`); } catch (err) { toast.error(err instanceof Error ? err.message : "Gagal memperbarui"); } } async function handleDeleteItem(deleted: UserMarker) { const isMarker = deleted.id.startsWith("lm-"); const dbId = deleted.id.split("-").slice(1).join("-"); try { const res = await fetch(`${BACKEND_URL}/land/${isMarker ? "markers" : "shapes"}/${dbId}`, { method: "DELETE", headers: authHeaders() }); if (!res.ok) throw new Error("Gagal menghapus"); setEditingShape(null); if (selectedItemId === deleted.id) setSelectedItemId(null); setRefreshKey((k) => k + 1); toast.success(`"${deleted.name}" berhasil dihapus`); } catch (err) { toast.error(err instanceof Error ? err.message : "Gagal menghapus"); } } async function handleMarkerDragEnd(marker: UserMarker, newLat: number, newLng: number) { const isMarker = marker.id.startsWith("lm-"); const dbId = marker.id.split("-").slice(1).join("-"); try { const res = await fetch(`${BACKEND_URL}/land/${isMarker ? "markers" : "shapes"}/${dbId}`, { method: "PUT", headers: authHeaders(true), 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`); } catch (err) { toast.error(err instanceof Error ? err.message : "Gagal memindahkan penanda"); } } // FULL BLEED MAP LAYOUT if (useLeaflet) { return ( setUseLeaflet(false)}>
{/* Leaflet Map */} { // Open placement dialog instead of adding directly setPendingShape(shape); setPlacingMarkerType(null); }} onMarkerClick={(marker) => { setEditingShape(marker); setSelectedItemId(marker.id); }} onMapClick={(lat, lng) => { if (placingMarkerType && !["line", "polygon", "circle"].includes(placingMarkerType)) { setPendingShape({ id: `pending-${Date.now()}`, lat, lng, type: placingMarkerType, name: "" }); setPlacingMarkerType(null); } }} dragDropEnabled={dragDropEnabled} onMarkerDragEnd={handleMarkerDragEnd} /> {/* Placing Marker Banner */} {placingMarkerType && (
Menempatkan {LAND_MARKER_LABELS[placingMarkerType] ?? placingMarkerType.replace("-", " ")}... Klik pada peta untuk menentukan posisi
)} {/* Floating Collection Grid Disclosure */}
{/* Shape Placement Dialog */} { if (!v) setPendingShape(null); }} pendingShape={pendingShape} onConfirm={handleConfirmPlacement} /> {/* Shape Edit Dialog */} { if (!v) setEditingShape(null); }} shape={editingShape} onUpdated={handleUpdateItem} onDeleted={handleDeleteItem} />
); } // STANDARD DASHBOARD LAYOUT return (
{/* Top Header Card */} setUseLeaflet(true)} buttonText="Peta Lahan" icon={} /> {/* Unified Grid Layout */}
{/* Row 1: 3 stats cards (rendered inside LandStatsCards as direct children) */} {/* Row 2: Allocation Chart */}
{/* Row 2: Inspector Top */}
{/* Row 3: Inspector Bottom */}
{/* Row 3: Directory */}
setSelectedItemId(item.id)} />
); }