Files
WEB-GIS/index.html
T
2026-05-12 10:16:16 +07:00

4791 lines
243 KiB
HTML

<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sistem GIS</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" />
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
html, body { height: 100%; }
body { overflow: hidden; }
.app-shell {
position: relative;
width: 100%;
height: 100vh;
padding: calc(.5rem + env(safe-area-inset-top)) .5rem .5rem .5rem;
box-sizing: border-box;
}
#map {
height: calc(100vh - 1rem);
width: 100%;
border-radius: .75rem;
z-index: 1;
}
.custom-msg { display: none; position: fixed; top: 20px; right: 20px; z-index: 9999; min-width: 250px; }
.leaflet-popup-content-wrapper { border-radius: 12px; padding: 0; overflow: hidden; }
.leaflet-popup-content { margin: 0; width: 280px !important; }
.leaflet-draw-toolbar a { background-color: white; }
.leaflet-control-layers {
max-height: calc(100vh - 8rem);
overflow-y: auto;
width: 190px;
font-size: 12px;
}
.leaflet-control-layers-toggle {
width: 30px !important;
height: 30px !important;
background-size: 18px 18px !important;
}
.leaflet-control-layers-overlays label,
.leaflet-control-layers-list label {
margin-bottom: 0.15rem;
line-height: 1.2;
}
.leaflet-control-layers input {
margin-top: 0.15rem;
}
.leaflet-draw-toolbar a {
width: 32px !important;
height: 32px !important;
line-height: 32px !important;
background-size: 18px 18px !important;
background-repeat: no-repeat !important;
background-position: center center !important;
}
.leaflet-draw-toolbar .leaflet-draw-draw-marker {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230f172a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='9' r='2.5'/%3E%3Cpath d='M12 2C8.13 2 5 5.13 5 9c0 5 7 13 7 13s7-8 7-13c0-3.87-3.13-7-7-7Z'/%3E%3C/svg>") !important;
}
.leaflet-draw-toolbar .leaflet-draw-draw-polyline {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230f172a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 18l6-6 4 4 6-8'/%3E%3C/svg>") !important;
}
.leaflet-draw-toolbar .leaflet-draw-draw-polygon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230f172a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2l8 5v10l-8 5-8-5V7z'/%3E%3C/svg>") !important;
}
.leaflet-draw-toolbar .leaflet-draw-draw-rectangle {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230f172a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='5' y='5' width='14' height='14' rx='1'/%3E%3C/svg>") !important;
}
.leaflet-draw-toolbar .leaflet-draw-draw-circle {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230f172a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='7'/%3E%3C/svg>") !important;
}
.leaflet-draw-toolbar .leaflet-draw-edit-edit {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230f172a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 20h9'/%3E%3Cpath d='M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z'/%3E%3C/svg>") !important;
}
.leaflet-draw-toolbar .leaflet-draw-edit-remove {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230f172a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 6h18'/%3E%3Cpath d='M8 6V4h8v2'/%3E%3Cpath d='M6 6l1 14h10l1-14'/%3E%3Cpath d='M10 11v6'/%3E%3Cpath d='M14 11v6'/%3E%3C/svg>") !important;
}
.leaflet-draw-toolbar .leaflet-draw-draw-polygon,
.leaflet-draw-toolbar .leaflet-draw-draw-polyline,
.leaflet-draw-toolbar .leaflet-draw-edit-edit,
.leaflet-draw-toolbar .leaflet-draw-edit-remove {
margin-bottom: 0;
}
.leaflet-draw-section {
margin-bottom: 2px;
}
.leaflet-top.leaflet-left .leaflet-control-layers,
.leaflet-top.leaflet-left .leaflet-bar,
.leaflet-top.leaflet-left .leaflet-draw {
margin-top: 0.35rem;
}
/* CSS Tambahan agar ikon SVG tidak terpotong atau memiliki background */
.custom-marker { background: transparent !important; border: none !important; }
.feature-card { box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08); }
.icon-btn { width: 2.45rem; height: 2.45rem; display: inline-flex; align-items: center; justify-content: center; border-radius: 0.8rem; border: 1px solid rgba(203,213,225,.9); background: rgba(255,255,255,.92); backdrop-filter: blur(14px); transition: .18s ease; }
.icon-btn:hover { transform: translateY(-1px); box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12); }
.icon-btn.active { background: #4f46e5; color: white; border-color: #4f46e5; }
.registry-card { box-shadow: 0 18px 45px rgba(15, 23, 42, 0.14); }
.registry-list { max-height: min(56vh, 520px); overflow-y: auto; }
.registry-item { transition: .18s ease; }
.registry-item:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(15, 23, 42, 0.1); }
.registry-chip { display: inline-flex; align-items: center; gap: .35rem; border-radius: 9999px; padding: .25rem .6rem; font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
#registryDrawer { transition: transform .24s ease, opacity .24s ease; }
#registryDrawer.hidden-drawer { transform: translateX(18px); opacity: 0; pointer-events: none; }
#bantuanDrawer { transition: transform .24s ease, opacity .24s ease; }
#bantuanDrawer.hidden-drawer { transform: translateX(18px); opacity: 0; pointer-events: none; }
#layerDrawer { transition: transform .24s ease, opacity .24s ease; }
#layerDrawer.hidden-drawer { transform: translateX(-18px); opacity: 0; pointer-events: none; }
</style>
</head>
<body class="bg-slate-50 text-slate-800 font-sans">
<div class="app-shell">
<div id="map"></div>
<div id="mainPanel" class="hidden absolute left-6 top-[calc(1rem+env(safe-area-inset-top))] z-[2000] w-[260px] max-w-[calc(100vw-3rem)] rounded-2xl border border-slate-200 bg-white/90 p-2.5 shadow-2xl backdrop-blur-md md:w-[290px]">
<div>
<h1 class="text-base font-black tracking-tight text-indigo-700">GIS</h1>
<p id="modeHint" class="hidden"></p>
</div>
<div id="mainPanelBody">
<div id="connectionStatus" class="mt-1 text-[9px] font-bold uppercase tracking-wide text-amber-500">Mengecek koneksi...</div>
<div class="mt-2 flex items-center justify-between rounded-xl bg-slate-50 px-2.5 py-1.5 text-xs text-slate-600">
<span>SPBU</span>
<span id="totalCount" class="text-sm font-black text-indigo-600">0</span>
</div>
<div class="mt-2 flex flex-wrap items-center gap-1.5">
<button class="icon-btn" id="toggleEditModeBtn" title="Aktifkan mode edit">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
</button>
<div id="editModeButtons" class="hidden flex flex-wrap gap-1.5">
<button class="icon-btn" id="modeViewBtn" title="Lihat data">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="icon-btn" id="modePointBtn" title="Tambah point SPBU">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2C8.13 2 5 5.13 5 9c0 5 7 13 7 13s7-8 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>
</button>
<button class="icon-btn" id="modePolylineBtn" title="Tambah polyline jalan">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 18l6-6 4 4 6-8"/></svg>
</button>
<button class="icon-btn" id="modePolygonBtn" title="Tambah polygon parsil">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l8 5v10l-8 5-8-5V7z"/></svg>
</button>
<button class="icon-btn" id="modeRumahIbadahBtn" title="Input rumah ibadah">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3 4 8v13h16V8z"/><path d="M9 21v-7h6v7"/><path d="M12 7v6"/><path d="M9 10h6"/></svg>
</button>
<button class="icon-btn" id="modePendudukMiskinBtn" title="Input penduduk miskin">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 12a4 4 0 1 0-4-4 4 4 0 0 0 4 4z"/><path d="M4 21a8 8 0 0 1 16 0"/><path d="M18 8h4"/><path d="M20 6v4"/></svg>
</button>
<button class="icon-btn" id="modeRoadDamageBtn" title="Lapor jalan rusak">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 12h12M12 6v12M2 12h20M12 2v20"/><circle cx="12" cy="12" r="10"/></svg>
</button>
<button class="icon-btn" id="modeVerifikasiBtn" title="Verifikasi lapangan">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6 9 17l-5-5"/><path d="M4 4h16v16H4z"/></svg>
</button>
<button class="icon-btn" id="modeAduanWargaBtn" title="Aduan warga">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 8h10"/><path d="M7 12h6"/><path d="M20 16.5A2.5 2.5 0 0 1 17.5 19H8l-4 3V6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5z"/></svg>
</button>
<button class="icon-btn" id="modeChoroplethBtn" title="Lihat peta choropleth kecamatan">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6l6-2 6 2 6-2v14l-6 2-6-2-6 2z"/><path d="M9 4v14"/><path d="M15 6v14"/></svg>
</button>
</div>
</div>
<div id="spatialControls" class="mt-3 hidden rounded-2xl border border-slate-200 bg-white p-3 shadow-sm">
<div id="statusControlWrap">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Status</label>
<select id="drawStatus" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"></select>
</div>
<div id="measureControlWrap" class="mt-2">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Panjang / Luas</label>
<input id="modeMeasure" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
</div>
<div class="mt-3 flex gap-2">
<button id="runActionBtn" class="flex-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white transition hover:bg-indigo-700">Mulai</button>
<button id="clearDraftBtn" class="rounded-lg bg-slate-100 px-4 py-2 text-sm font-bold text-slate-700 transition hover:bg-slate-200">Bersih</button>
</div>
</div>
<div id="inputRegistryPanel" class="mt-3 hidden rounded-2xl border border-slate-200 bg-white/95 p-3 shadow-sm registry-card">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-sm font-black tracking-tight text-slate-800">Registry Input</h2>
<p class="mt-1 text-[11px] text-slate-500">Daftar input yang bisa difilter.</p>
</div>
<button id="closeInputRegistryBtn" class="icon-btn h-9 w-9 text-slate-600" title="Tutup registry">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<div class="mt-3 flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2">
<span class="text-[11px] font-bold uppercase tracking-widest text-slate-500">Tercatat</span>
<span id="inputRegistryCount" class="text-sm font-black text-indigo-600">0</span>
</div>
<div class="mt-3 grid gap-2">
<div class="sm:col-span-2">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Cari Nama / Atribut</label>
<input id="inputSearchQuery" type="text" placeholder="Cari nama, kategori, nomor..." class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div class="sm:col-span-1">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Jenis</label>
<select id="inputTypeFilter" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
<option value="all">Semua</option>
<option value="point">Poin</option>
<option value="polyline">Polyline</option>
<option value="polygon">Polygon</option>
<option value="rumahIbadah">Rumah Ibadah</option>
<option value="pendudukMiskin">Penduduk Miskin</option>
<option value="roadDamage">Jalan Rusak</option>
</select>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Dari</label>
<input id="inputDateFrom" type="date" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Sampai</label>
<input id="inputDateTo" type="date" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
</div>
</div>
</div>
<div class="mt-3 flex gap-2">
<button id="applyInputFilterBtn" class="flex-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white transition hover:bg-indigo-700">Terapkan</button>
<button id="clearInputFilterBtn" class="rounded-lg bg-slate-100 px-4 py-2 text-sm font-bold text-slate-700 transition hover:bg-slate-200">Reset</button>
</div>
<div id="inputRegistryList" class="registry-list mt-3 space-y-2 pr-1">
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
Memuat data input...
</div>
</div>
</div>
</div>
</div>
<div id="minimalToolbar" class="hidden absolute left-6 top-[calc(1rem+env(safe-area-inset-top))] z-[2100] flex items-center gap-2 rounded-2xl border border-slate-200 bg-white/90 p-2 shadow-xl backdrop-blur-md">
<button class="icon-btn" id="miniModePointBtn" title="Tambah point SPBU">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2C8.13 2 5 5.13 5 9c0 5 7 13 7 13s7-8 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>
</button>
<button class="icon-btn" id="miniModePolylineBtn" title="Tambah polyline jalan">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 18l6-6 4 4 6-8"/></svg>
</button>
<button class="icon-btn" id="miniModePolygonBtn" title="Tambah polygon parsil">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l8 5v10l-8 5-8-5V7z"/></svg>
</button>
<button class="icon-btn" id="miniModeRumahIbadahBtn" title="Input rumah ibadah">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3 4 8v13h16V8z"/><path d="M9 21v-7h6v7"/><path d="M12 7v6"/><path d="M9 10h6"/></svg>
</button>
<button class="icon-btn" id="miniModePendudukMiskinBtn" title="Input penduduk miskin">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 12a4 4 0 1 0-4-4 4 4 0 0 0 4 4z"/><path d="M4 21a8 8 0 0 1 16 0"/><path d="M18 8h4"/><path d="M20 6v4"/></svg>
</button>
<button class="icon-btn" id="miniModeVerifikasiBtn" title="Verifikasi lapangan">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6 9 17l-5-5"/><path d="M4 4h16v16H4z"/></svg>
</button>
<button class="icon-btn" id="miniModeAduanWargaBtn" title="Aduan warga">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 8h10"/><path d="M7 12h6"/><path d="M20 16.5A2.5 2.5 0 0 1 17.5 19H8l-4 3V6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5z"/></svg>
</button>
<button class="icon-btn" id="toggleRegistryDrawerBtn" title="Buka registry & filter">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="7" y1="12" x2="17" y2="12"/><line x1="10" y1="18" x2="14" y2="18"/></svg>
</button>
<button class="icon-btn" id="toggleBantuanDrawerBtn" title="Manajemen bantuan penduduk miskin">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21c4.97 0 9-2.24 9-5V8l-9-5-9 5v8c0 2.76 4.03 5 9 5Z"/><path d="M12 12v9"/><path d="M7 10.5 12 13l5-2.5"/></svg>
</button>
</div>
<div id="registryDrawer" class="hidden-drawer fixed right-4 top-[calc(1rem+env(safe-area-inset-top))] z-[2100] h-[calc(100vh-2rem-env(safe-area-inset-top))] w-[460px] max-w-[calc(100vw-1rem)] rounded-l-2xl border border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur-md overflow-y-auto">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-sm font-black tracking-tight text-slate-800">Registry</h2>
<p class="mt-1 text-[11px] text-slate-500">Filter dan pencarian data input.</p>
</div>
<button id="registryDrawerCloseBtn" class="icon-btn h-9 w-9 text-slate-600" title="Tutup registry">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<div class="mt-3 flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2">
<span class="text-[11px] font-bold uppercase tracking-widest text-slate-500">Tercatat</span>
<span id="registryCount" class="text-sm font-black text-indigo-600">0</span>
</div>
<div class="mt-3 grid gap-2">
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Cari Nama / Atribut</label>
<input id="registrySearchQuery" type="text" placeholder="Cari nama, kategori, nomor..." class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Jenis</label>
<select id="registryTypeFilter" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
<option value="all">Semua</option>
<option value="point">Poin</option>
<option value="polyline">Polyline</option>
<option value="polygon">Polygon</option>
<option value="rumahIbadah">Rumah Ibadah</option>
<option value="pendudukMiskin">Penduduk Miskin</option>
<option value="roadDamage">Jalan Rusak</option>
</select>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Dari</label>
<input id="registryDateFrom" type="date" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Sampai</label>
<input id="registryDateTo" type="date" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
</div>
</div>
</div>
<div class="mt-3 flex gap-2">
<button id="registryApplyFilterBtn" class="flex-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white transition hover:bg-indigo-700">Terapkan</button>
<button id="registryResetFilterBtn" class="rounded-lg bg-slate-100 px-4 py-2 text-sm font-bold text-slate-700 transition hover:bg-slate-200">Reset</button>
</div>
<div id="registryList" class="registry-list mt-3 space-y-2 pr-1">
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
Memuat data input...
</div>
</div>
</div>
<div id="bantuanDrawer" class="hidden-drawer fixed right-4 top-[calc(1rem+env(safe-area-inset-top))] z-[2101] h-[calc(100vh-2rem-env(safe-area-inset-top))] w-[460px] max-w-[calc(100vw-1rem)] rounded-l-2xl border border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur-md overflow-y-auto">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-sm font-black tracking-tight text-slate-800">Manajemen Bantuan</h2>
<p class="mt-1 text-[11px] text-slate-500">Kelola bantuan untuk penduduk miskin per wilayah rumah ibadah.</p>
</div>
<button id="bantuanDrawerCloseBtn" class="icon-btn h-9 w-9 text-slate-600" title="Tutup manajemen bantuan">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<div class="mt-3 grid gap-2 sm:grid-cols-2">
<div class="sm:col-span-2">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Rumah Ibadah Wilayah</label>
<select id="bantuanRumahFilter" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
<option value="">Pilih rumah ibadah...</option>
</select>
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Status Bantuan</label>
<select id="bantuanStatusFilter" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
<option value="all">Semua</option>
<option value="belum">Belum Dibantu</option>
<option value="proses">Sedang Diproses</option>
<option value="sudah">Sudah Dibantu</option>
</select>
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Cari Ketua KK</label>
<input id="bantuanSearchQuery" type="text" placeholder="Nama ketua KK..." class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
</div>
</div>
<div class="mt-3 flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2">
<span class="text-[11px] font-bold uppercase tracking-widest text-slate-500">Prioritas</span>
<span id="bantuanCount" class="text-sm font-black text-indigo-600">0</span>
</div>
<div id="bantuanList" class="registry-list mt-3 space-y-2 pr-1">
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
Pilih rumah ibadah untuk melihat daftar penerima bantuan.
</div>
</div>
</div>
<div id="layerDrawer" class="hidden-drawer absolute left-6 top-[calc(1rem+env(safe-area-inset-top)+4.4rem)] z-[2099] w-[220px] max-w-[calc(100vw-3rem)] rounded-2xl border border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur-md">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-sm font-black tracking-tight text-slate-800">Layer</h2>
<p class="mt-1 text-[11px] text-slate-500">Dropdown semua layer.</p>
</div>
<button id="layerDrawerCloseBtn" class="icon-btn h-9 w-9 text-slate-600" title="Tutup layer">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<div class="mt-3 space-y-2 text-sm text-slate-700">
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<input id="layerToggle-spbuBuka24JamLayer" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>SPBU Buka 24 Jam</span>
</label>
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<input id="layerToggle-spbuTidak24JamLayer" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>SPBU Tidak Buka 24 Jam</span>
</label>
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<input id="layerToggle-jalanLayerGroup" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>Jalan</span>
</label>
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<input id="layerToggle-polygonLayerGroup" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>Polygon</span>
</label>
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<input id="layerToggle-roadDamageLayer" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>Jalan Rusak</span>
</label>
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<input id="layerToggle-rumahIbadahLayer" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>Rumah Ibadah</span>
</label>
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<input id="layerToggle-pendudukMiskinLayer" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>Penduduk Miskin</span>
</label>
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<input id="layerToggle-verifikasiLapanganLayer" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>Verifikasi Lapangan</span>
</label>
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<input id="layerToggle-aduanWargaLayer" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>Aduan Warga</span>
</label>
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<input id="layerToggle-roadNetworkLayer" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>Jalan Eksternal</span>
</label>
</div>
</div>
<div id="choroplethPanel" class="absolute right-6 bottom-6 z-[2000] hidden w-[320px] max-w-[calc(100vw-3rem)] rounded-2xl border border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur-md">
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="text-sm font-black tracking-tight text-slate-800">Choropleth Kecamatan Pontianak</h3>
<p class="mt-1 text-[11px] text-slate-500">Klik kecamatan untuk melihat jumlah penduduk.</p>
</div>
<span class="rounded-full bg-indigo-100 px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-indigo-700">Interaktif</span>
</div>
<div class="mt-3 rounded-xl border border-slate-200 bg-slate-50 p-3">
<div id="choroplethName" class="text-xs font-bold uppercase tracking-wider text-slate-500">Belum dipilih</div>
<div id="choroplethPopulation" class="mt-1 text-xl font-black text-slate-800">-</div>
<div id="choroplethDensity" class="mt-1 text-xs text-slate-500">Klik salah satu kecamatan di peta.</div>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-[11px] text-slate-600">
<div class="rounded-lg bg-[#fee2e2] px-2 py-1">&lt; 80.000 jiwa</div>
<div class="rounded-lg bg-[#fecaca] px-2 py-1">80.000 - 100.000</div>
<div class="rounded-lg bg-[#fca5a5] px-2 py-1">100.000 - 120.000</div>
<div class="rounded-lg bg-[#f87171] px-2 py-1">&gt; 120.000 jiwa</div>
</div>
</div>
<!-- Notifikasi -->
<div id="statusMsg" class="custom-msg p-4 rounded-lg shadow-xl font-medium transition-all transform"></div>
<!-- Modal Edit Fitur -->
<div id="featureModal" class="fixed inset-0 z-[10000] hidden items-center justify-center bg-slate-900/50 px-4">
<div class="w-full max-w-lg rounded-2xl bg-white p-5 shadow-2xl">
<div class="flex items-start justify-between gap-4 border-b pb-3">
<div>
<h3 class="text-lg font-bold text-slate-800">Edit Fitur Spasial</h3>
<p class="text-sm text-slate-500">Ubah atribut atau simpan geometri yang sudah diedit di peta.</p>
</div>
<button id="closeFeatureModalBtn" class="rounded-lg bg-slate-100 px-3 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Tutup</button>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<input id="editFeatureId" type="hidden">
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Nama Objek</label>
<input id="editFeatureName" type="text" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Kategori</label>
<input id="editFeatureKategori" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Tipe</label>
<input id="editFeatureType" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Status</label>
<select id="editFeatureStatus" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"></select>
</div>
<div class="sm:col-span-2">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Panjang / Luas</label>
<input id="editFeatureMeasure" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2 border-t pt-4">
<button id="saveFeatureAttrBtn" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white hover:bg-indigo-700">Simpan Atribut</button>
<button id="toggleFeatureGeometryBtn" class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700">Edit Geometri</button>
<button id="saveFeatureGeometryBtn" class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-bold text-white hover:bg-amber-600">Simpan Geometri</button>
<button id="deleteFeatureBtn" class="rounded-lg bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700">Hapus</button>
</div>
</div>
</div>
<!-- Modal Fitur Baru -->
<div id="newFeatureModal" class="fixed inset-0 z-[10001] hidden items-center justify-center bg-slate-900/50 px-4">
<div class="w-full max-w-lg rounded-2xl bg-white p-5 shadow-2xl">
<div class="flex items-start justify-between gap-4 border-b pb-3">
<div>
<h3 class="text-lg font-bold text-slate-800">Input Fitur Baru</h3>
<p class="text-sm text-slate-500">Isi nama setelah garis atau polygon selesai digambar.</p>
</div>
<button id="closeNewFeatureModalBtn" class="rounded-lg bg-slate-100 px-3 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Tutup</button>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div class="sm:col-span-2">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Nama Objek</label>
<input id="newFeatureName" type="text" placeholder="Tambahkan nama atribut" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Kategori</label>
<input id="newFeatureKategori" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Tipe</label>
<input id="newFeatureTypeLabel" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Status</label>
<select id="newFeatureStatus" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"></select>
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Panjang / Luas</label>
<input id="newFeatureMeasure" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2 border-t pt-4">
<button id="saveNewFeatureBtn" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white hover:bg-indigo-700">Simpan</button>
<button id="cancelNewFeatureBtn" class="rounded-lg bg-slate-100 px-4 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Batal</button>
</div>
</div>
</div>
<!-- Modal Lapor Jalan Rusak -->
<div id="roadDamageModal" class="fixed inset-0 z-[10002] hidden items-center justify-center bg-slate-900/50 px-4">
<div class="w-full max-w-lg rounded-2xl bg-white p-5 shadow-2xl">
<div class="flex items-start justify-between gap-4 border-b pb-3">
<div>
<h3 class="text-lg font-bold text-slate-800">Lapor Jalan Rusak</h3>
<p class="text-sm text-slate-500">Dokumentasikan kondisi jalan dengan foto untuk membantu perbaikan.</p>
</div>
<button id="closeRoadDamageModalBtn" class="rounded-lg bg-slate-100 px-3 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Tutup</button>
</div>
<div class="mt-4 grid gap-3">
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Jenis Kerusakan</label>
<select id="roadDamageType" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-red-500">
<option value="">Pilih jenis kerusakan</option>
<option value="lubang">Lubang</option>
<option value="retak">Retak</option>
<option value="ambles">Ambles</option>
<option value="bergelombang">Bergelombang</option>
<option value="lainnya">Lainnya</option>
</select>
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Tingkat Kerusakan</label>
<select id="roadDamageSeverity" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-red-500">
<option value="ringan">Ringan</option>
<option value="sedang" selected>Sedang</option>
<option value="berat">Berat</option>
</select>
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Deskripsi</label>
<textarea id="roadDamageDesc" placeholder="Jelaskan kondisi jalan, lokasi spesifik, dll..." class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-red-500" rows="3"></textarea>
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Foto Kondisi (Opsional)</label>
<input id="roadDamageImage" type="file" accept="image/*" class="w-full rounded-lg border border-slate-300 p-2 text-sm">
<div id="imagePreview" class="mt-2 hidden">
<img id="previewImg" src="" alt="Preview" class="max-h-40 rounded-lg">
</div>
</div>
<div class="text-[10px] text-slate-500 bg-slate-50 p-2 rounded">
📍 Koordinat: <span id="roadDamageCoords">-</span>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2 border-t pt-4">
<button id="saveRoadDamageBtn" class="rounded-lg bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700">Simpan Laporan</button>
<button id="cancelRoadDamageBtn" class="rounded-lg bg-slate-100 px-4 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Batal</button>
</div>
</div>
</div>
<div id="rumahIbadahModal" class="fixed inset-0 z-[10003] hidden items-center justify-center bg-slate-900/50 px-4">
<div class="w-full max-w-xl rounded-2xl bg-white p-5 shadow-2xl">
<div class="flex items-start justify-between gap-4 border-b pb-3">
<div>
<h3 class="text-lg font-bold text-slate-800">Input Rumah Ibadah</h3>
<p class="text-sm text-slate-500">Alamat diambil otomatis dari reverse geocode koordinat peta.</p>
</div>
<button id="closeRumahIbadahModalBtn" class="rounded-lg bg-slate-100 px-3 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Tutup</button>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<input id="rumahIbadahId" type="hidden">
<div class="sm:col-span-2">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Nama Rumah Ibadah</label>
<input id="rumahIbadahNama" type="text" placeholder="Masukkan nama rumah ibadah" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div class="sm:col-span-2">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Alamat Otomatis</label>
<textarea id="rumahIbadahAlamat" rows="3" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600"></textarea>
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">PIC</label>
<input id="rumahIbadahPic" type="text" placeholder="Nama PIC" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Radius Pencarian</label>
<input id="rumahIbadahRadius" type="range" min="50" max="5000" step="50" value="1000" class="w-full">
<div class="mt-1 text-xs text-slate-600">Radius: <span id="rumahIbadahRadiusValue">1000</span> meter</div>
</div>
<div class="sm:col-span-2 text-[10px] text-slate-500 bg-slate-50 p-2 rounded">
📍 Koordinat: <span id="rumahIbadahCoords">-</span>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2 border-t pt-4">
<button id="saveRumahIbadahBtn" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white hover:bg-indigo-700">Simpan</button>
<button id="cancelRumahIbadahBtn" class="rounded-lg bg-slate-100 px-4 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Batal</button>
</div>
</div>
</div>
<div id="pendudukMiskinModal" class="fixed inset-0 z-[10004] hidden items-center justify-center bg-slate-900/50 px-4">
<div class="w-full max-w-xl rounded-2xl bg-white p-5 shadow-2xl">
<div class="flex items-start justify-between gap-4 border-b pb-3">
<div>
<h3 class="text-lg font-bold text-slate-800">Input Penduduk Miskin</h3>
<p class="text-sm text-slate-500">Data akan ditautkan otomatis ke rumah ibadah terdekat.</p>
</div>
<button id="closePendudukMiskinModalBtn" class="rounded-lg bg-slate-100 px-3 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Tutup</button>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<input id="pendudukMiskinId" type="hidden">
<div class="sm:col-span-2">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Nama Ketua KK</label>
<input id="pendudukMiskinNama" type="text" placeholder="Masukkan nama ketua KK" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Jumlah Anggota Keluarga</label>
<input id="pendudukMiskinAnggota" type="number" min="1" step="1" value="1" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Rumah Ibadah Terdekat</label>
<input id="pendudukMiskinRumahIbadah" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
</div>
<div class="sm:col-span-2">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Jarak ke Rumah Ibadah</label>
<input id="pendudukMiskinJarak" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
</div>
<div class="sm:col-span-2 text-[10px] text-slate-500 bg-slate-50 p-2 rounded">
📍 Koordinat: <span id="pendudukMiskinCoords">-</span>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2 border-t pt-4">
<button id="savePendudukMiskinBtn" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white hover:bg-indigo-700">Simpan</button>
<button id="cancelPendudukMiskinBtn" class="rounded-lg bg-slate-100 px-4 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Batal</button>
</div>
</div>
</div>
<div id="verifikasiLapanganModal" class="fixed inset-0 z-[10005] hidden items-center justify-center bg-slate-900/50 px-4">
<div class="w-full max-w-xl rounded-2xl bg-white p-5 shadow-2xl">
<div class="flex items-start justify-between gap-4 border-b pb-3">
<div>
<h3 class="text-lg font-bold text-slate-800">Verifikasi Lapangan</h3>
<p class="text-sm text-slate-500">Catat hasil cek lapangan untuk data kemiskinan atau infrastruktur.</p>
</div>
<button id="closeVerifikasiLapanganModalBtn" class="rounded-lg bg-slate-100 px-3 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Tutup</button>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<input id="verifikasiLapanganId" type="hidden">
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Target Data</label>
<select id="verifikasiJenisTarget" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-emerald-500">
<option value="penduduk_miskin">Penduduk Miskin</option>
<option value="jalan_rusak">Jalan Rusak</option>
<option value="rumah_ibadah">Rumah Ibadah</option>
<option value="lainnya">Lainnya</option>
</select>
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Nama Target</label>
<input id="verifikasiTargetNama" type="text" placeholder="Contoh: KK A / Jalan RW 03" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Nama Petugas</label>
<input id="verifikasiPetugasNama" type="text" placeholder="Nama surveyor" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Status</label>
<select id="verifikasiStatus" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-emerald-500">
<option value="menunggu">Menunggu</option>
<option value="valid">Valid</option>
<option value="perlu_tindak_lanjut">Perlu Tindak Lanjut</option>
</select>
</div>
<div class="sm:col-span-2">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Hasil Temuan</label>
<textarea id="verifikasiHasilTemuan" rows="3" placeholder="Ringkasan hasil cek lapangan" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-emerald-500"></textarea>
</div>
<div class="sm:col-span-2">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Tindak Lanjut</label>
<textarea id="verifikasiTindakLanjut" rows="2" placeholder="Catatan tindak lanjut" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-emerald-500"></textarea>
</div>
<div class="sm:col-span-2">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Foto (Opsional)</label>
<input id="verifikasiFoto" type="file" accept="image/*" class="w-full rounded-lg border border-slate-300 p-2 text-sm">
</div>
<div class="sm:col-span-2 text-[10px] text-slate-500 bg-slate-50 p-2 rounded">
📍 Koordinat: <span id="verifikasiCoords">-</span>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2 border-t pt-4">
<button id="saveVerifikasiLapanganBtn" class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700">Simpan Verifikasi</button>
<button id="cancelVerifikasiLapanganBtn" class="rounded-lg bg-slate-100 px-4 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Batal</button>
</div>
</div>
</div>
<div id="aduanWargaModal" class="fixed inset-0 z-[10006] hidden items-center justify-center bg-slate-900/50 px-4">
<div class="w-full max-w-xl rounded-2xl bg-white p-5 shadow-2xl">
<div class="flex items-start justify-between gap-4 border-b pb-3">
<div>
<h3 class="text-lg font-bold text-slate-800">Aduan Warga</h3>
<p class="text-sm text-slate-500">Terima aduan berbasis lokasi untuk memprioritaskan respon lapangan.</p>
</div>
<button id="closeAduanWargaModalBtn" class="rounded-lg bg-slate-100 px-3 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Tutup</button>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<input id="aduanWargaId" type="hidden">
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Kategori</label>
<select id="aduanKategori" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-rose-500">
<option value="">Pilih kategori</option>
<option value="jalan rusak">Jalan Rusak</option>
<option value="banjir">Banjir</option>
<option value="rumah tidak layak">Rumah Tidak Layak</option>
<option value="air bersih">Air Bersih</option>
<option value="sanitasi">Sanitasi</option>
<option value="akses bantuan">Akses Bantuan</option>
<option value="lainnya">Lainnya</option>
</select>
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Nama Pelapor</label>
<input id="aduanPelaporNama" type="text" placeholder="Nama warga" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-rose-500">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Kontak</label>
<input id="aduanKontakPelapor" type="text" placeholder="Nomor HP / WA" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-rose-500">
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Status</label>
<select id="aduanStatus" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-rose-500">
<option value="baru">Baru</option>
<option value="diproses">Diproses</option>
<option value="selesai">Selesai</option>
</select>
</div>
<div class="sm:col-span-2">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Deskripsi Aduan</label>
<textarea id="aduanDeskripsi" rows="3" placeholder="Jelaskan kondisi yang diadukan" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-rose-500"></textarea>
</div>
<div class="sm:col-span-2">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Tindak Lanjut</label>
<textarea id="aduanTindakLanjut" rows="2" placeholder="Catatan respon / tindak lanjut" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-rose-500"></textarea>
</div>
<div class="sm:col-span-2">
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Foto (Opsional)</label>
<input id="aduanFoto" type="file" accept="image/*" class="w-full rounded-lg border border-slate-300 p-2 text-sm">
</div>
<div class="sm:col-span-2 text-[10px] text-slate-500 bg-slate-50 p-2 rounded">
📍 Koordinat: <span id="aduanCoords">-</span>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2 border-t pt-4">
<button id="saveAduanWargaBtn" class="rounded-lg bg-rose-600 px-4 py-2 text-sm font-bold text-white hover:bg-rose-700">Simpan Aduan</button>
<button id="cancelAduanWargaBtn" class="rounded-lg bg-slate-100 px-4 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Batal</button>
</div>
</div>
</div>
<select id="featureType" class="hidden">
<option value="polyline">polyline</option>
<option value="polygon">polygon</option>
</select>
<select id="featureStatus" class="hidden"></select>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
<script src="https://cdn.jsdelivr.net/npm/proj4@2.12.1/dist/proj4.js"></script>
<script>
// Inisialisasi Peta (Default Pontianak)
const map = L.map('map').setView([-0.0263, 109.3425], 13);
const REFRESH_INTERVAL_MS = 5000;
let autoRefreshTimer = null;
let isSyncing = false;
let suppressAutoReload = false;
let isFeatureDrawing = false;
let isFeatureSaving = false;
let latestSnapshotHash = '';
let featureLayer = L.featureGroup();
let drawControl = null;
let activeDrawHandler = null;
let currentDrawMode = 'polyline';
let appMode = 'view';
let isEditModeEnabled = true;
let pendingShape = null;
let pendingNewFeatureLayer = null;
let featureLayerMap = {};
let locationLayerMap = {};
let locationRecords = [];
let featureRecords = [];
let editingFeatureId = null;
let choroplethLayer = null;
let spbuBuka24JamLayer = L.layerGroup().addTo(map);
let spbuTidak24JamLayer = L.layerGroup().addTo(map);
let spbuLayerControl = null;
let spatialLayerControl = null;
let utilityControl = null;
let rumahIbadahLayer = L.featureGroup().addTo(map);
let pendudukMiskinLayer = L.featureGroup().addTo(map);
let verifikasiLapanganLayer = L.featureGroup().addTo(map);
let aduanWargaLayer = L.featureGroup().addTo(map);
let rumahIbadahLayerMap = {};
let pendudukMiskinLayerMap = {};
let verifikasiLapanganLayerMap = {};
let aduanWargaLayerMap = {};
let rumahIbadahRecords = [];
let pendudukMiskinRecords = [];
let verifikasiLapanganRecords = [];
let aduanWargaRecords = [];
let pendingRumahIbadahCoords = null;
let pendingPendudukMiskinCoords = null;
let pendingVerifikasiCoords = null;
let pendingAduanCoords = null;
let editingRumahIbadahId = null;
let selectedRumahIbadahId = null;
let selectedBantuanRumahIbadahId = null;
let jalanLayerGroup = L.featureGroup().addTo(map);
let polygonLayerGroup = L.featureGroup().addTo(map);
let roadDamageLayer = L.featureGroup().addTo(map);
let roadNetworkLayer = L.featureGroup().addTo(map);
let roadDamageRecords = [];
let pendingRoadDamageCoords = null;
let isMainPanelCollapsed = false;
let hiddenLayersDuringChoropleth = [];
let layerDrawerControls = [];
const ROAD_NETWORK_GEOJSON_PATH = 'data/Export_Output_2.json';
let roadNetworkLoaded = false;
const FEATURE_STATUS_OPTIONS = {
polyline: [
{ value: 'Jalan Nasional', label: 'Jalan Nasional', color: '#16a34a' },
{ value: 'Jalan Provinsi', label: 'Jalan Provinsi', color: '#f59e0b' },
{ value: 'Jalan Kabupaten', label: 'Jalan Kabupaten', color: '#dc2626' }
],
polygon: [
{ value: 'SHM', label: 'SHM', color: '#10b981' },
{ value: 'HGB', label: 'HGB', color: '#3b82f6' },
{ value: 'HGU', label: 'HGU', color: '#8b5cf6' },
{ value: 'HP', label: 'HP', color: '#64748b' }
]
};
const CHOROPLETH_GEOJSON_PATH = 'data/pontianak-kecamatan.geojson';
let choroplethGeoJsonData = null;
const DISTRICT_POPULATION_MAP = {
'pontianak barat': 148800,
'pontianak kota': 98500,
'pontianak selatan': 133600,
'pontianak tenggara': 52900,
'pontianak timur': 121500,
'pontianak utara': 129200
};
function normalizeDistrictName(name) {
return String(name || '')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim();
}
function enrichFeaturePopulation(feature) {
const properties = feature.properties || {};
const districtName = properties.name || properties.NAME || properties.Ket || '';
const districtKey = normalizeDistrictName(districtName);
const mappedPopulation = DISTRICT_POPULATION_MAP[districtKey];
if (mappedPopulation == null) {
return feature;
}
return {
...feature,
properties: {
...properties,
population: Number(mappedPopulation)
}
};
}
function getPopulationFill(population) {
if (population > 120000) return '#ef4444';
if (population > 100000) return '#f87171';
if (population > 80000) return '#fca5a5';
return '#fecaca';
}
function getChoroplethValue(properties) {
if (properties.population != null) {
return { key: 'population', label: 'Populasi', value: Number(properties.population || 0), unit: 'jiwa' };
}
if (properties.density != null) {
return { key: 'density', label: 'Kepadatan', value: Number(properties.density || 0), unit: 'jiwa/km²' };
}
return { key: 'none', label: 'Nilai', value: 0, unit: '' };
}
function resetChoroplethPanel() {
document.getElementById('choroplethName').innerText = 'Belum dipilih';
document.getElementById('choroplethPopulation').innerText = '-';
document.getElementById('choroplethDensity').innerText = 'Klik salah satu kecamatan di peta.';
}
function updateChoroplethPanel(properties) {
const mainValue = getChoroplethValue(properties);
const population = Number(properties.population || 0);
const area = Number(properties.area_km2 || 0);
const density = area > 0 ? population / area : 0;
document.getElementById('choroplethName').innerText = properties.name || properties.NAME || 'Tanpa nama';
document.getElementById('choroplethPopulation').innerText = `${mainValue.value.toLocaleString('id-ID')} ${mainValue.unit}`;
if (area > 0 && population > 0) {
document.getElementById('choroplethDensity').innerText = `Kepadatan: ${density.toLocaleString('id-ID', { maximumFractionDigits: 0 })} jiwa/km²`;
} else {
document.getElementById('choroplethDensity').innerText = `${mainValue.label} ditampilkan dari properti GeoJSON.`;
}
}
function clearChoroplethLayer() {
if (choroplethLayer) {
map.removeLayer(choroplethLayer);
choroplethLayer = null;
}
}
function getRingArea(ring) {
if (!Array.isArray(ring) || ring.length < 3) {
return 0;
}
let area = 0;
for (let index = 0; index < ring.length; index += 1) {
const [x1, y1] = ring[index];
const [x2, y2] = ring[(index + 1) % ring.length];
area += (x1 * y2) - (x2 * y1);
}
return Math.abs(area) / 2;
}
function getPolygonPartArea(part) {
const outerRing = Array.isArray(part) ? part[0] : null;
return getRingArea(outerRing);
}
function sanitizeChoroplethFeature(feature) {
const geometry = feature.geometry || {};
if (geometry.type === 'Polygon' && Array.isArray(geometry.coordinates)) {
const outerRing = geometry.coordinates[0];
if (!Array.isArray(outerRing)) {
return feature;
}
return {
...feature,
geometry: {
...geometry,
coordinates: [outerRing]
}
};
}
if (geometry.type !== 'MultiPolygon' || !Array.isArray(geometry.coordinates)) {
return feature;
}
const normalizedParts = geometry.coordinates
.map(part => (Array.isArray(part) && Array.isArray(part[0]) ? [part[0]] : part));
const partsWithArea = normalizedParts
.map(part => ({ part, area: getPolygonPartArea(part) }))
.filter(entry => entry.area > 0)
.sort((left, right) => right.area - left.area);
if (partsWithArea.length <= 1) {
return feature;
}
const largestArea = partsWithArea[0].area;
const filteredParts = partsWithArea
.filter(entry => entry.area >= largestArea * 0.1)
.map(entry => entry.part);
return {
...feature,
geometry: {
...geometry,
coordinates: filteredParts.length > 0 ? filteredParts : [partsWithArea[0].part]
}
};
}
function sanitizeChoroplethGeoJson(data) {
return {
...data,
features: data.features
.map(sanitizeChoroplethFeature)
.map(enrichFeaturePopulation)
};
}
async function loadChoroplethGeoJson() {
if (choroplethGeoJsonData) {
return choroplethGeoJsonData;
}
const response = await fetch(CHOROPLETH_GEOJSON_PATH, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`Gagal memuat ${CHOROPLETH_GEOJSON_PATH} (HTTP ${response.status})`);
}
const data = await response.json();
if (!data || data.type !== 'FeatureCollection' || !Array.isArray(data.features)) {
throw new Error('Format GeoJSON tidak valid, harus FeatureCollection.');
}
choroplethGeoJsonData = sanitizeChoroplethGeoJson(data);
return choroplethGeoJsonData;
}
async function buildChoroplethLayer() {
clearChoroplethLayer();
const geoJsonData = await loadChoroplethGeoJson();
choroplethLayer = L.geoJSON(geoJsonData, {
style: feature => ({
color: 'transparent',
weight: 0,
fillColor: getPopulationFill(Number(getChoroplethValue(feature.properties || {}).value || 0)),
fillOpacity: 0.72
}),
onEachFeature: (feature, layer) => {
const props = feature.properties || {};
const districtName = props.name || props.NAME || 'Tanpa nama';
// Diagnostic: Disabled tooltip
// layer.bindTooltip(districtName, { sticky: true });
layer.on('mouseover', () => {
layer.setStyle({ weight: 0, fillOpacity: 0.86, color: 'transparent' });
});
layer.on('mouseout', () => {
choroplethLayer.resetStyle(layer);
});
layer.on('click', () => {
const mainValue = getChoroplethValue(props);
updateChoroplethPanel(props);
// Diagnostic: Disabled popup to check if black rectangle comes from popup element
// layer.bindPopup(`<div class="p-1"><strong>${districtName}</strong><br>${mainValue.label}: ${mainValue.value.toLocaleString('id-ID')} ${mainValue.unit}</div>`).openPopup();
});
}
}).addTo(map);
map.fitBounds(choroplethLayer.getBounds(), { padding: [20, 20] });
resetChoroplethPanel();
}
async function toggleChoroplethMode(enabled) {
const panel = document.getElementById('choroplethPanel');
panel.classList.toggle('hidden', !enabled);
if (enabled) {
setSpatialOverlayVisibility(false);
try {
await buildChoroplethLayer();
} catch (error) {
showMessage(error.message || 'Gagal memuat peta choropleth', 'bg-red-600 text-white');
panel.classList.add('hidden');
setSpatialOverlayVisibility(true);
}
return;
}
clearChoroplethLayer();
setSpatialOverlayVisibility(true);
}
function getFeatureStatusColor(type, status) {
const options = FEATURE_STATUS_OPTIONS[type] || [];
const found = options.find(item => item.value === status);
return found ? found.color : '#0f172a';
}
function formatMeters(value) {
return `${value.toFixed(2)} m`;
}
function formatSquareMeters(value) {
return `${value.toFixed(2)} m²`;
}
function parseDateOnly(value) {
if (!value) return null;
const normalized = String(value).replace(' ', 'T');
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return null;
return date;
}
function formatInputDate(value) {
const date = parseDateOnly(value);
if (!date) return 'Tanggal tidak tersedia';
return new Intl.DateTimeFormat('id-ID', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date);
}
function getRecordDateKey(record) {
const date = parseDateOnly(record && record.created_at);
if (!date) return null;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function getRegistryUi() {
const drawerPanel = document.getElementById('registryDrawer');
const drawerSearch = document.getElementById('registrySearchQuery');
if (drawerPanel && drawerSearch) {
return {
panel: drawerPanel,
count: document.getElementById('registryCount'),
list: document.getElementById('registryList'),
search: drawerSearch,
type: document.getElementById('registryTypeFilter'),
from: document.getElementById('registryDateFrom'),
to: document.getElementById('registryDateTo')
};
}
return {
panel: document.getElementById('inputRegistryPanel'),
count: document.getElementById('inputRegistryCount'),
list: document.getElementById('inputRegistryList'),
search: document.getElementById('inputSearchQuery'),
type: document.getElementById('inputTypeFilter'),
from: document.getElementById('inputDateFrom'),
to: document.getElementById('inputDateTo')
};
}
function enforceRegistryDateRange(showFeedback = false) {
const ui = getRegistryUi();
const fromEl = ui.from;
const toEl = ui.to;
if (!fromEl || !toEl) return;
const from = fromEl.value;
const to = toEl.value;
// Batasi tanggal akhir agar tidak boleh sebelum tanggal awal.
toEl.min = from || '';
if (from && to && to < from) {
toEl.value = from;
if (showFeedback) {
showMessage('Tanggal akhir disesuaikan ke tanggal awal', 'bg-slate-700 text-white');
}
}
}
function getRegistryFilters() {
const ui = getRegistryUi();
return {
search: String(ui.search && ui.search.value ? ui.search.value : '').toLowerCase().trim(),
type: ui.type && ui.type.value ? ui.type.value : 'all',
from: ui.from && ui.from.value ? ui.from.value : '',
to: ui.to && ui.to.value ? ui.to.value : ''
};
}
function recordMatchesFilters(record, filters) {
// Filter berdasarkan pencarian
if (filters.search) {
const searchQuery = filters.search;
const matchesSearch =
(record.title || '').toLowerCase().includes(searchQuery) ||
(record.subtitle || '').toLowerCase().includes(searchQuery) ||
(record.status || '').toLowerCase().includes(searchQuery) ||
String(record.id).includes(searchQuery);
if (!matchesSearch) {
return false;
}
}
if (filters.type !== 'all' && record.type !== filters.type) {
return false;
}
const dateKey = getRecordDateKey(record);
if (!filters.from && !filters.to) {
return true;
}
if (!dateKey) {
return false;
}
if (filters.from && dateKey < filters.from) {
return false;
}
if (filters.to && dateKey > filters.to) {
return false;
}
return true;
}
function buildRegistryRecords() {
const pointRecords = locationRecords.map(loc => ({
id: Number(loc.id),
type: 'point',
title: loc.nama || 'Tanpa nama',
subtitle: loc.nomor || '-',
status: loc.buka_24jam || '-',
created_at: loc.created_at || null
}));
const spatialRecords = featureRecords.map(feature => ({
id: Number(feature.id),
type: feature.type,
title: feature.nama_objek || 'Tanpa nama',
subtitle: feature.kategori || '-',
status: feature.status_objek || '-',
created_at: feature.created_at || null
}));
const roadDamageRegistryRecords = roadDamageRecords.map(record => ({
id: Number(record.id),
type: 'roadDamage',
title: `Jalan Rusak - ${record.jenis_kerusakan || 'Tanpa jenis'}`,
subtitle: record.deskripsi || '-',
status: record.severity || '-',
created_at: record.created_at || null,
latitude: Number(record.latitude),
longitude: Number(record.longitude)
}));
const rumahIbadahRegistryRecords = rumahIbadahRecords.map(record => ({
id: Number(record.id),
type: 'rumahIbadah',
title: record.nama || 'Tanpa nama',
subtitle: record.pic || '-',
status: `${Number(record.radius_m || 0).toLocaleString('id-ID')} m`,
created_at: record.created_at || null,
latitude: Number(record.latitude),
longitude: Number(record.longitude)
}));
const pendudukMiskinRegistryRecords = pendudukMiskinRecords.map(record => ({
id: Number(record.id),
type: 'pendudukMiskin',
title: record.nama_ketua_kk || 'Tanpa nama',
subtitle: `${Number(record.jumlah_anggota || 0)} anggota`,
status: record.rumah_ibadah_nama || '-',
created_at: record.created_at || null,
latitude: Number(record.latitude),
longitude: Number(record.longitude)
}));
const verifikasiRegistryRecords = verifikasiLapanganRecords.map(record => ({
id: Number(record.id),
type: 'verifikasiLapangan',
title: record.target_nama || 'Verifikasi lapangan',
subtitle: record.petugas_nama || '-',
status: record.status_verifikasi || 'menunggu',
created_at: record.created_at || null,
latitude: Number(record.latitude),
longitude: Number(record.longitude)
}));
const aduanRegistryRecords = aduanWargaRecords.map(record => ({
id: Number(record.id),
type: 'aduanWarga',
title: record.kategori || 'Aduan warga',
subtitle: record.pelapor_nama || '-',
status: record.status_tindak_lanjut || 'baru',
created_at: record.created_at || null,
latitude: Number(record.latitude),
longitude: Number(record.longitude)
}));
return [...pointRecords, ...spatialRecords, ...roadDamageRegistryRecords, ...rumahIbadahRegistryRecords, ...pendudukMiskinRegistryRecords, ...verifikasiRegistryRecords, ...aduanRegistryRecords].sort((left, right) => {
const leftDate = parseDateOnly(left.created_at);
const rightDate = parseDateOnly(right.created_at);
if (!leftDate && !rightDate) return right.id - left.id;
if (!leftDate) return 1;
if (!rightDate) return -1;
return rightDate - leftDate;
});
}
function getRegistryTypeLabel(type) {
if (type === 'point') return 'Poin';
if (type === 'polyline') return 'Polyline';
if (type === 'polygon') return 'Polygon';
if (type === 'roadDamage') return 'Jalan Rusak';
if (type === 'rumahIbadah') return 'Rumah Ibadah';
if (type === 'pendudukMiskin') return 'Penduduk Miskin';
if (type === 'verifikasiLapangan') return 'Verifikasi Lapangan';
if (type === 'aduanWarga') return 'Aduan Warga';
return 'Data';
}
function getRegistryTypeStyle(type) {
if (type === 'point') return 'bg-emerald-100 text-emerald-700';
if (type === 'polyline') return 'bg-indigo-100 text-indigo-700';
if (type === 'polygon') return 'bg-amber-100 text-amber-700';
if (type === 'roadDamage') return 'bg-red-100 text-red-700';
if (type === 'rumahIbadah') return 'bg-teal-100 text-teal-700';
if (type === 'pendudukMiskin') return 'bg-rose-100 text-rose-700';
if (type === 'verifikasiLapangan') return 'bg-emerald-100 text-emerald-700';
if (type === 'aduanWarga') return 'bg-fuchsia-100 text-fuchsia-700';
return 'bg-slate-100 text-slate-700';
}
function focusRegistryItem(type, id) {
if (type === 'point') {
const marker = locationLayerMap[id];
if (!marker) return;
map.setView(marker.getLatLng(), Math.max(map.getZoom(), 16));
marker.openPopup();
return;
}
if (type === 'rumahIbadah') {
const item = rumahIbadahLayerMap[id];
if (!item) return;
map.setView(item.marker.getLatLng(), Math.max(map.getZoom(), 16));
item.marker.openPopup();
return;
}
if (type === 'pendudukMiskin') {
const marker = pendudukMiskinLayerMap[id];
if (!marker) return;
map.setView(marker.getLatLng(), Math.max(map.getZoom(), 16));
marker.openPopup();
return;
}
if (type === 'roadDamage') {
let selectedMarker = null;
roadDamageLayer.eachLayer(layer => {
if (layer && layer.roadDamageId === id) {
selectedMarker = layer;
}
});
if (selectedMarker) {
map.setView(selectedMarker.getLatLng(), Math.max(map.getZoom(), 16));
selectedMarker.openPopup();
return;
}
const record = roadDamageRecords.find(item => Number(item.id) === Number(id));
if (record && Number.isFinite(Number(record.latitude)) && Number.isFinite(Number(record.longitude))) {
map.setView([Number(record.latitude), Number(record.longitude)], Math.max(map.getZoom(), 16));
}
return;
}
if (type === 'verifikasiLapangan') {
const marker = verifikasiLapanganLayerMap[id];
if (!marker) return;
map.setView(marker.getLatLng(), Math.max(map.getZoom(), 16));
marker.openPopup();
return;
}
if (type === 'aduanWarga') {
const marker = aduanWargaLayerMap[id];
if (!marker) return;
map.setView(marker.getLatLng(), Math.max(map.getZoom(), 16));
marker.openPopup();
return;
}
const layer = featureLayerMap[id];
if (!layer) return;
if (layer.getBounds) {
map.fitBounds(layer.getBounds(), { padding: [40, 40] });
}
layer.openPopup();
}
function renderInputRegistry() {
const ui = getRegistryUi();
const listEl = ui.list;
const countEl = ui.count;
if (!listEl || !countEl) return;
const filters = getRegistryFilters();
const records = buildRegistryRecords().filter(record => recordMatchesFilters(record, filters));
countEl.innerText = records.length;
if (!records.length) {
listEl.innerHTML = `
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
Tidak ada input yang sesuai dengan filter.
</div>
`;
return;
}
listEl.innerHTML = records.map(record => {
const typeLabel = getRegistryTypeLabel(record.type);
const typeClass = getRegistryTypeStyle(record.type);
const dateLabel = formatInputDate(record.created_at);
const measure = record.type === 'point'
? `Telp: ${record.subtitle}`
: record.type === 'roadDamage'
? `Deskripsi: ${record.subtitle}`
: record.type === 'rumahIbadah'
? `PIC: ${record.subtitle}`
: record.type === 'pendudukMiskin'
? `Anggota: ${record.subtitle}`
: record.type === 'verifikasiLapangan'
? `Petugas: ${record.subtitle}`
: record.type === 'aduanWarga'
? `Pelapor: ${record.subtitle}`
: `Kategori: ${record.subtitle}`;
const statusLabel = record.type === 'point'
? `24 Jam: ${record.status}`
: record.type === 'roadDamage'
? `Tingkat: ${record.status}`
: record.type === 'rumahIbadah'
? `Radius: ${record.status}`
: record.type === 'pendudukMiskin'
? `Rumah Ibadah: ${record.status}`
: record.type === 'verifikasiLapangan'
? `Status Verifikasi: ${record.status}`
: record.type === 'aduanWarga'
? `Status Tindak Lanjut: ${record.status}`
: `Status: ${record.status}`;
return `
<button type="button" onclick="focusRegistryItem('${record.type}', ${record.id})" class="registry-item w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-left shadow-sm">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="registry-chip ${typeClass}">${typeLabel}</span>
<span class="text-[10px] font-bold uppercase tracking-widest text-slate-400">ID ${record.id}</span>
</div>
<h3 class="mt-1 truncate text-sm font-semibold text-slate-800">${record.title}</h3>
<p class="mt-1 text-[11px] text-slate-500">${measure}</p>
<p class="mt-0.5 text-[11px] text-slate-500">${statusLabel}</p>
</div>
<div class="shrink-0 text-right text-[10px] font-bold uppercase tracking-widest text-slate-400">
<div>Tanggal</div>
<div class="mt-1 max-w-[110px] normal-case tracking-normal text-slate-600">${dateLabel}</div>
</div>
</div>
</button>
`;
}).join('');
}
function toggleInputRegistry(forceState = null) {
const ui = getRegistryUi();
const panel = ui.panel;
if (!panel) return;
const useDrawer = panel.id === 'registryDrawer';
const isHidden = useDrawer ? panel.classList.contains('hidden-drawer') : panel.classList.contains('hidden');
const shouldOpen = forceState === null ? isHidden : !!forceState;
if (useDrawer) {
panel.classList.toggle('hidden-drawer', !shouldOpen);
const triggerBtn = document.getElementById('toggleRegistryDrawerBtn');
if (triggerBtn) {
triggerBtn.classList.toggle('active', shouldOpen);
}
const leafletTriggerBtn = document.getElementById('leafletRegistryBtn');
if (leafletTriggerBtn) {
leafletTriggerBtn.classList.toggle('active', shouldOpen);
}
} else {
panel.classList.toggle('hidden', !shouldOpen);
}
if (shouldOpen) {
toggleBantuanDrawer(false);
toggleLayerDrawer(false);
renderInputRegistry();
}
}
function getLayerDrawerUi() {
return {
panel: document.getElementById('layerDrawer'),
closeBtn: document.getElementById('layerDrawerCloseBtn'),
toggles: {
spbuBuka24JamLayer: document.getElementById('layerToggle-spbuBuka24JamLayer'),
spbuTidak24JamLayer: document.getElementById('layerToggle-spbuTidak24JamLayer'),
jalanLayerGroup: document.getElementById('layerToggle-jalanLayerGroup'),
polygonLayerGroup: document.getElementById('layerToggle-polygonLayerGroup'),
roadDamageLayer: document.getElementById('layerToggle-roadDamageLayer'),
rumahIbadahLayer: document.getElementById('layerToggle-rumahIbadahLayer'),
pendudukMiskinLayer: document.getElementById('layerToggle-pendudukMiskinLayer'),
verifikasiLapanganLayer: document.getElementById('layerToggle-verifikasiLapanganLayer'),
aduanWargaLayer: document.getElementById('layerToggle-aduanWargaLayer'),
roadNetworkLayer: document.getElementById('layerToggle-roadNetworkLayer')
}
};
}
function getLayerEntries() {
return [
{ key: 'spbuBuka24JamLayer', layer: spbuBuka24JamLayer, label: 'SPBU Buka 24 Jam' },
{ key: 'spbuTidak24JamLayer', layer: spbuTidak24JamLayer, label: 'SPBU Tidak Buka 24 Jam' },
{ key: 'jalanLayerGroup', layer: jalanLayerGroup, label: 'Jalan' },
{ key: 'polygonLayerGroup', layer: polygonLayerGroup, label: 'Polygon' },
{ key: 'roadDamageLayer', layer: roadDamageLayer, label: 'Jalan Rusak' },
{ key: 'rumahIbadahLayer', layer: rumahIbadahLayer, label: 'Rumah Ibadah' },
{ key: 'pendudukMiskinLayer', layer: pendudukMiskinLayer, label: 'Penduduk Miskin' },
{ key: 'verifikasiLapanganLayer', layer: verifikasiLapanganLayer, label: 'Verifikasi Lapangan' },
{ key: 'aduanWargaLayer', layer: aduanWargaLayer, label: 'Aduan Warga' },
{ key: 'roadNetworkLayer', layer: roadNetworkLayer, label: 'Jalan Eksternal' }
];
}
function setLayerVisible(layer, visible) {
if (!layer) return;
if (visible) {
if (!map.hasLayer(layer)) {
map.addLayer(layer);
}
return;
}
if (map.hasLayer(layer)) {
map.removeLayer(layer);
}
}
function syncLayerDrawerState() {
const ui = getLayerDrawerUi();
const entries = getLayerEntries();
entries.forEach(entry => {
const toggle = ui.toggles[entry.key];
if (toggle) {
toggle.checked = map.hasLayer(entry.layer);
}
});
}
function renderLayerDrawer() {
syncLayerDrawerState();
}
function toggleLayerDrawer(forceState = null) {
const ui = getLayerDrawerUi();
const panel = ui.panel;
if (!panel) return;
const isHidden = panel.classList.contains('hidden-drawer');
const shouldOpen = forceState === null ? isHidden : !!forceState;
panel.classList.toggle('hidden-drawer', !shouldOpen);
const triggerBtn = document.getElementById('toggleLayerDrawerBtn');
if (triggerBtn) {
triggerBtn.classList.toggle('active', shouldOpen);
}
if (shouldOpen) {
toggleInputRegistry(false);
toggleBantuanDrawer(false);
renderLayerDrawer();
}
}
function bindLayerDrawerEvents() {
const ui = getLayerDrawerUi();
const entries = getLayerEntries();
entries.forEach(entry => {
const toggle = ui.toggles[entry.key];
if (!toggle || layerDrawerControls.includes(entry.key)) return;
toggle.addEventListener('change', () => setLayerVisible(entry.layer, toggle.checked));
layerDrawerControls.push(entry.key);
});
if (ui.closeBtn && !ui.closeBtn.dataset.bound) {
ui.closeBtn.addEventListener('click', () => toggleLayerDrawer(false));
ui.closeBtn.dataset.bound = '1';
}
}
function normalizeAidStatus(status) {
const value = String(status || '').toLowerCase().trim();
if (value === 'proses' || value === 'sudah') return value;
return 'belum';
}
function getAidStatusBadge(status) {
const normalized = normalizeAidStatus(status);
if (normalized === 'sudah') {
return { label: 'Sudah Dibantu', className: 'bg-emerald-100 text-emerald-700' };
}
if (normalized === 'proses') {
return { label: 'Sedang Diproses', className: 'bg-amber-100 text-amber-700' };
}
return { label: 'Belum Dibantu', className: 'bg-rose-100 text-rose-700' };
}
function getBantuanUi() {
return {
panel: document.getElementById('bantuanDrawer'),
count: document.getElementById('bantuanCount'),
list: document.getElementById('bantuanList'),
rumahFilter: document.getElementById('bantuanRumahFilter'),
statusFilter: document.getElementById('bantuanStatusFilter'),
search: document.getElementById('bantuanSearchQuery')
};
}
function getBantuanFilters() {
const ui = getBantuanUi();
return {
rumahId: ui.rumahFilter && ui.rumahFilter.value ? Number(ui.rumahFilter.value) : 0,
status: ui.statusFilter && ui.statusFilter.value ? ui.statusFilter.value : 'all',
search: String(ui.search && ui.search.value ? ui.search.value : '').toLowerCase().trim()
};
}
function openBantuanForRumah(rumahIbadahId) {
selectedBantuanRumahIbadahId = Number(rumahIbadahId) || null;
toggleBantuanDrawer(true);
}
function toggleBantuanDrawer(forceState = null) {
const ui = getBantuanUi();
const panel = ui.panel;
if (!panel) return;
const isHidden = panel.classList.contains('hidden-drawer');
const shouldOpen = forceState === null ? isHidden : !!forceState;
panel.classList.toggle('hidden-drawer', !shouldOpen);
const triggerBtn = document.getElementById('toggleBantuanDrawerBtn');
if (triggerBtn) {
triggerBtn.classList.toggle('active', shouldOpen);
}
const leafletTriggerBtn = document.getElementById('leafletBantuanBtn');
if (leafletTriggerBtn) {
leafletTriggerBtn.classList.toggle('active', shouldOpen);
}
if (shouldOpen) {
toggleInputRegistry(false);
toggleLayerDrawer(false);
renderBantuanDrawer();
}
}
function renderBantuanDrawer() {
const ui = getBantuanUi();
if (!ui.panel || !ui.list || !ui.count || !ui.rumahFilter || !ui.statusFilter || !ui.search) return;
const existingSummary = ui.panel.querySelector('[data-bantuan-summary="1"]');
if (existingSummary) {
existingSummary.remove();
}
const previousValue = ui.rumahFilter.value;
const options = ['<option value="">Pilih rumah ibadah...</option>'].concat(
rumahIbadahRecords.map(record => `<option value="${Number(record.id)}">${record.nama} (radius ${Number(record.radius_m || 0).toLocaleString('id-ID')} m)</option>`)
);
ui.rumahFilter.innerHTML = options.join('');
const defaultValue = selectedBantuanRumahIbadahId != null ? String(selectedBantuanRumahIbadahId) : previousValue;
if (defaultValue && rumahIbadahRecords.some(item => String(Number(item.id)) === String(defaultValue))) {
ui.rumahFilter.value = String(defaultValue);
}
const filters = getBantuanFilters();
selectedBantuanRumahIbadahId = filters.rumahId || null;
if (!filters.rumahId) {
ui.count.innerText = '0';
ui.list.innerHTML = `
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
Pilih rumah ibadah untuk menampilkan daftar penerima bantuan pada wilayahnya.
</div>
`;
return;
}
const rumahRecord = rumahIbadahRecords.find(item => Number(item.id) === Number(filters.rumahId));
if (!rumahRecord) {
ui.count.innerText = '0';
ui.list.innerHTML = `
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
Data rumah ibadah tidak ditemukan.
</div>
`;
return;
}
const residents = getPoorResidentsInRumahRadius(rumahRecord).filter(item => {
const status = normalizeAidStatus(item.bantuan_status);
if (filters.status !== 'all' && status !== filters.status) return false;
if (filters.search && !(item.nama_ketua_kk || '').toLowerCase().includes(filters.search)) return false;
return true;
});
const statusCounts = residents.reduce((accumulator, item) => {
const status = normalizeAidStatus(item.bantuan_status);
accumulator[status] = (accumulator[status] || 0) + 1;
return accumulator;
}, { belum: 0, proses: 0, sudah: 0 });
ui.count.innerText = String(residents.length);
const summaryWrapper = document.createElement('div');
summaryWrapper.setAttribute('data-bantuan-summary', '1');
summaryWrapper.className = 'mt-3 grid grid-cols-3 gap-2 text-[11px] text-slate-600';
summaryWrapper.innerHTML = `
<div class="rounded-lg bg-rose-50 px-2 py-2 text-center">
<div class="font-black text-rose-700">${statusCounts.belum || 0}</div>
<div>Belum</div>
</div>
<div class="rounded-lg bg-amber-50 px-2 py-2 text-center">
<div class="font-black text-amber-700">${statusCounts.proses || 0}</div>
<div>Diproses</div>
</div>
<div class="rounded-lg bg-emerald-50 px-2 py-2 text-center">
<div class="font-black text-emerald-700">${statusCounts.sudah || 0}</div>
<div>Sudah</div>
</div>
`;
ui.list.parentNode.insertBefore(summaryWrapper, ui.list);
if (!residents.length) {
ui.list.innerHTML = `
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
Tidak ada data penduduk miskin yang sesuai filter untuk wilayah ${rumahRecord.nama}.
</div>
`;
return;
}
ui.list.innerHTML = residents.map(item => {
const badge = getAidStatusBadge(item.bantuan_status);
const jarak = formatDistanceMeters(Number(item._distanceToRumah));
const created = formatInputDate(item.created_at);
const updated = item.bantuan_updated_at ? formatInputDate(item.bantuan_updated_at) : '-';
const noteValue = String(item.bantuan_catatan || '').replace(/"/g, '&quot;');
return `
<div class="rounded-xl border border-slate-200 bg-white p-3 shadow-sm">
<div class="flex items-start justify-between gap-2">
<div>
<h3 class="text-sm font-bold text-slate-800">${item.nama_ketua_kk}</h3>
<p class="mt-0.5 text-[11px] text-slate-500">${item.jumlah_anggota} anggota • ${jarak} dari ${rumahRecord.nama}</p>
</div>
<span class="registry-chip ${badge.className}">${badge.label}</span>
</div>
<div class="mt-2 grid grid-cols-2 gap-x-2 gap-y-1 text-[11px] text-slate-600">
<div>ID: ${item.id}</div>
<div>Koordinat: ${Number(item.latitude).toFixed(5)}, ${Number(item.longitude).toFixed(5)}</div>
<div>Dibuat: ${created}</div>
<div>Update Bantuan: ${updated}</div>
</div>
<div class="mt-3 grid gap-2">
<label class="text-[10px] font-bold uppercase tracking-widest text-slate-400">Status Bantuan</label>
<select id="aidStatus-${item.id}" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
<option value="belum" ${normalizeAidStatus(item.bantuan_status) === 'belum' ? 'selected' : ''}>Belum Dibantu</option>
<option value="proses" ${normalizeAidStatus(item.bantuan_status) === 'proses' ? 'selected' : ''}>Sedang Diproses</option>
<option value="sudah" ${normalizeAidStatus(item.bantuan_status) === 'sudah' ? 'selected' : ''}>Sudah Dibantu</option>
</select>
<label class="text-[10px] font-bold uppercase tracking-widest text-slate-400">Catatan Bantuan</label>
<textarea id="aidNote-${item.id}" rows="2" placeholder="Contoh: paket sembako tahap 1" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">${noteValue}</textarea>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 border-t pt-3">
<button onclick="saveAidStatus(${item.id})" class="rounded-lg bg-indigo-600 px-3 py-2 text-xs font-bold text-white hover:bg-indigo-700">Simpan Bantuan</button>
<button onclick="focusRegistryItem('pendudukMiskin', ${item.id})" class="rounded-lg bg-slate-100 px-3 py-2 text-xs font-bold text-slate-700 hover:bg-slate-200">Lihat di Peta</button>
</div>
</div>
`;
}).join('');
}
function saveAidStatus(id) {
const statusEl = document.getElementById(`aidStatus-${id}`);
const noteEl = document.getElementById(`aidNote-${id}`);
if (!statusEl || !noteEl) {
showMessage('Komponen update bantuan tidak ditemukan', 'bg-red-600 text-white');
return;
}
const formData = new FormData();
formData.append('id', String(id));
formData.append('bantuan_status', normalizeAidStatus(statusEl.value));
formData.append('bantuan_catatan', String(noteEl.value || '').trim());
fetch('update_bantuan_penduduk.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'update_bantuan_penduduk.php'))
.then(data => {
if (data.status !== 'success') {
throw new Error(data.message || 'Gagal memperbarui bantuan');
}
const record = pendudukMiskinRecords.find(item => Number(item.id) === Number(id));
if (record) {
record.bantuan_status = normalizeAidStatus(statusEl.value);
record.bantuan_catatan = String(noteEl.value || '').trim();
record.bantuan_updated_at = data.bantuan_updated_at || new Date().toISOString();
}
refreshRumahIbadahPopups();
renderBantuanDrawer();
loadPendudukMiskin(true);
showMessage('✅ Status bantuan berhasil diperbarui', 'bg-green-600 text-white');
})
.catch(err => showMessage(err.message || 'Gagal memperbarui bantuan', 'bg-red-600 text-white'));
}
function getFeatureOptions(type) {
return FEATURE_STATUS_OPTIONS[type] || [];
}
function setSpatialType(type) {
document.getElementById('featureType').value = type;
const statusSelect = document.getElementById('featureStatus');
const drawStatus = document.getElementById('drawStatus');
drawStatus.innerHTML = '';
getFeatureOptions(type).forEach(option => {
const el = document.createElement('option');
el.value = option.value;
el.textContent = option.label;
drawStatus.appendChild(el);
});
if (!drawStatus.value) drawStatus.selectedIndex = 0;
statusSelect.innerHTML = '';
Array.from(drawStatus.options).forEach(option => {
statusSelect.appendChild(option.cloneNode(true));
});
statusSelect.value = drawStatus.value;
currentDrawMode = type;
updateDrawControl(type);
updateFeatureMeasurePreview();
}
function setDataMode(mode) {
appMode = mode;
const statusWrap = document.getElementById('statusControlWrap');
const measureWrap = document.getElementById('measureControlWrap');
const runActionBtn = document.getElementById('runActionBtn');
const clearDraftBtn = document.getElementById('clearDraftBtn');
const spatialControls = document.getElementById('spatialControls');
const modeButtons = {
view: document.getElementById('modeViewBtn'),
point: document.getElementById('modePointBtn'),
polyline: document.getElementById('modePolylineBtn'),
polygon: document.getElementById('modePolygonBtn'),
rumahIbadah: document.getElementById('modeRumahIbadahBtn'),
pendudukMiskin: document.getElementById('modePendudukMiskinBtn'),
choropleth: document.getElementById('modeChoroplethBtn'),
roadDamage: document.getElementById('leafletRoadDamageBtn') || document.getElementById('modeRoadDamageBtn'),
verifikasiLapangan: document.getElementById('leafletVerifikasiBtn') || document.getElementById('modeVerifikasiBtn'),
aduanWarga: document.getElementById('leafletAduanWargaBtn') || document.getElementById('modeAduanWargaBtn'),
miniPoint: document.getElementById('miniModePointBtn'),
miniPolyline: document.getElementById('miniModePolylineBtn'),
miniPolygon: document.getElementById('miniModePolygonBtn'),
miniRumahIbadah: document.getElementById('miniModeRumahIbadahBtn'),
miniPendudukMiskin: document.getElementById('miniModePendudukMiskinBtn'),
miniVerifikasiLapangan: document.getElementById('miniModeVerifikasiBtn'),
miniAduanWarga: document.getElementById('miniModeAduanWargaBtn')
};
Object.values(modeButtons).forEach(button => {
if (button) button.classList.remove('active');
});
if (mode === 'point') {
if (modeButtons.point) modeButtons.point.classList.add('active');
if (modeButtons.miniPoint) modeButtons.miniPoint.classList.add('active');
}
if (mode === 'polyline') {
if (modeButtons.polyline) modeButtons.polyline.classList.add('active');
if (modeButtons.miniPolyline) modeButtons.miniPolyline.classList.add('active');
}
if (mode === 'polygon') {
if (modeButtons.polygon) modeButtons.polygon.classList.add('active');
if (modeButtons.miniPolygon) modeButtons.miniPolygon.classList.add('active');
}
if (mode === 'rumahIbadah') {
if (modeButtons.rumahIbadah) modeButtons.rumahIbadah.classList.add('active');
if (modeButtons.miniRumahIbadah) modeButtons.miniRumahIbadah.classList.add('active');
}
if (mode === 'pendudukMiskin') {
if (modeButtons.pendudukMiskin) modeButtons.pendudukMiskin.classList.add('active');
if (modeButtons.miniPendudukMiskin) modeButtons.miniPendudukMiskin.classList.add('active');
}
if (mode === 'verifikasiLapangan') {
if (modeButtons.verifikasiLapangan) modeButtons.verifikasiLapangan.classList.add('active');
if (modeButtons.miniVerifikasiLapangan) modeButtons.miniVerifikasiLapangan.classList.add('active');
}
if (mode === 'aduanWarga') {
if (modeButtons.aduanWarga) modeButtons.aduanWarga.classList.add('active');
if (modeButtons.miniAduanWarga) modeButtons.miniAduanWarga.classList.add('active');
}
if (modeButtons[mode]) {
modeButtons[mode].classList.add('active');
}
toggleChoroplethMode(mode === 'choropleth');
if (mode === 'view') {
spatialControls.classList.add('hidden');
runActionBtn.innerText = 'Mode Lihat';
runActionBtn.disabled = true;
clearDraftBtn.disabled = true;
updateDrawControl(null);
document.getElementById('modeMeasure').value = '';
return;
}
if (mode === 'choropleth') {
spatialControls.classList.add('hidden');
runActionBtn.innerText = 'Choropleth Aktif';
runActionBtn.disabled = true;
clearDraftBtn.disabled = true;
updateDrawControl(null);
document.getElementById('modeMeasure').value = '';
return;
}
if (mode === 'roadDamage' || mode === 'verifikasiLapangan' || mode === 'aduanWarga') {
spatialControls.classList.add('hidden');
runActionBtn.innerText = 'Klik Peta';
runActionBtn.disabled = true;
clearDraftBtn.disabled = true;
updateDrawControl(null);
document.getElementById('modeMeasure').value = '';
if (mode === 'roadDamage') loadRoadDamage(true);
if (mode === 'verifikasiLapangan') loadVerifikasiLapangan(true);
if (mode === 'aduanWarga') loadAduanWarga(true);
return;
}
if (mode === 'rumahIbadah' || mode === 'pendudukMiskin') {
spatialControls.classList.add('hidden');
runActionBtn.innerText = 'Klik Peta';
runActionBtn.disabled = true;
clearDraftBtn.disabled = true;
updateDrawControl(null);
document.getElementById('modeMeasure').value = '';
return;
}
runActionBtn.disabled = false;
clearDraftBtn.disabled = false;
if (mode === 'point') {
spatialControls.classList.add('hidden');
runActionBtn.innerText = 'Klik Peta';
clearDraftBtn.disabled = true;
updateDrawControl(null);
document.getElementById('modeMeasure').value = '';
return;
}
spatialControls.classList.remove('hidden');
runActionBtn.innerText = mode === 'polyline' ? 'Gambar Jalan' : 'Gambar Polygon';
setSpatialType(mode);
}
function setEditModeEnabled(enabled) {
isEditModeEnabled = !!enabled;
const toggleBtn = document.getElementById('toggleEditModeBtn');
const editButtonsWrap = document.getElementById('editModeButtons');
if (!toggleBtn || !editButtonsWrap) return;
editButtonsWrap.classList.toggle('hidden', !isEditModeEnabled);
toggleBtn.classList.toggle('active', isEditModeEnabled);
toggleBtn.title = isEditModeEnabled ? 'Nonaktifkan mode edit' : 'Aktifkan mode edit';
if (!isEditModeEnabled) {
setDataMode('view');
return;
}
}
function openRoadDamageModal(lat, lng) {
pendingRoadDamageCoords = { lat, lng };
document.getElementById('roadDamageCoords').innerText = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
document.getElementById('roadDamageType').value = '';
document.getElementById('roadDamageSeverity').value = 'sedang';
document.getElementById('roadDamageDesc').value = '';
document.getElementById('roadDamageImage').value = '';
document.getElementById('imagePreview').classList.add('hidden');
const modal = document.getElementById('roadDamageModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
suppressAutoReload = true;
}
function closeRoadDamageModal() {
const modal = document.getElementById('roadDamageModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
pendingRoadDamageCoords = null;
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)') || !!editingFeatureId;
}
function saveRoadDamage() {
if (!pendingRoadDamageCoords) {
showMessage('Koordinat tidak ditemukan', 'bg-red-600 text-white');
return;
}
const jenis = document.getElementById('roadDamageType').value;
if (!jenis) {
showMessage('Pilih jenis kerusakan terlebih dahulu', 'bg-red-600 text-white');
return;
}
const severity = document.getElementById('roadDamageSeverity').value;
const deskripsi = document.getElementById('roadDamageDesc').value;
const fileInput = document.getElementById('roadDamageImage');
const formData = new FormData();
formData.append('latitude', pendingRoadDamageCoords.lat);
formData.append('longitude', pendingRoadDamageCoords.lng);
formData.append('jenis_kerusakan', jenis);
formData.append('severity', severity);
formData.append('deskripsi', deskripsi);
if (fileInput.files && fileInput.files[0]) {
formData.append('gambar', fileInput.files[0]);
}
fetch('save_jalan_rusak.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'save_jalan_rusak.php'))
.then(data => {
if (data.status === 'success') {
showMessage('✅ Laporan jalan rusak berhasil disimpan', 'bg-green-600 text-white');
closeRoadDamageModal();
loadRoadDamage(true);
} else {
throw new Error(data.message || 'Gagal menyimpan laporan');
}
})
.catch(err => showMessage(err.message || 'Gagal menyimpan laporan', 'bg-red-600 text-white'));
}
function createRoadDamageIcon(severity) {
const colorMap = {
'ringan': '#fbbf24',
'sedang': '#f97316',
'berat': '#dc2626'
};
const color = colorMap[severity] || '#f97316';
return L.divIcon({
className: "custom-marker",
html: `<svg width="32" height="40" viewBox="0 0 32 40" fill="${color}" xmlns="http://www.w3.org/2000/svg">
<path d="M16 0C8.27 0 2 6.27 2 14c0 8 14 26 14 26s14-18 14-26c0-7.73-6.27-14-14-14z"/>
<circle cx="16" cy="14" r="5" fill="white"/>
<text x="16" y="16" text-anchor="middle" dy=".3em" font-size="10" font-weight="bold" fill="${color}">!</text>
</svg>`,
iconSize: [32, 40],
iconAnchor: [16, 40],
popupAnchor: [0, -40]
});
}
function getRoadDamagePopup(record) {
const imageHtml = record.gambar ? `<img src="${record.gambar}" alt="Foto jalan rusak" class="mt-2 w-full rounded-lg max-h-48 object-cover">` : '';
return `
<div class="w-full p-3">
<h4 class="font-bold text-slate-800">Laporan Jalan Rusak</h4>
<p class="mt-1 text-sm text-slate-600"><strong>Jenis:</strong> ${record.jenis_kerusakan}</p>
<p class="text-sm text-slate-600"><strong>Tingkat:</strong>
<span class="px-2 py-1 rounded text-xs font-bold ${
record.severity === 'ringan' ? 'bg-yellow-100 text-yellow-700' :
record.severity === 'sedang' ? 'bg-orange-100 text-orange-700' :
'bg-red-100 text-red-700'
}">${record.severity}</span>
</p>
${record.deskripsi ? `<p class="mt-1 text-sm text-slate-600"><strong>Deskripsi:</strong> ${record.deskripsi}</p>` : ''}
<p class="mt-1 text-xs text-slate-500">📍 ${record.latitude.toFixed(5)}, ${record.longitude.toFixed(5)}</p>
${imageHtml}
<div class="mt-3 flex gap-2 border-t pt-2">
<button onclick="deleteRoadDamage(${record.id})" class="flex-1 rounded bg-red-50 px-2 py-1 text-xs font-bold text-red-700 hover:bg-red-100">Hapus</button>
</div>
</div>
`;
}
function loadRoadDamage(force = false) {
if (!force && (document.hidden || suppressAutoReload)) return;
fetch('get_jalan_rusak.php')
.then(res => {
if (!res.ok) throw new Error('HTTP Error: ' + res.status);
return res.text();
})
.then(text => {
const data = JSON.parse(text);
if (!Array.isArray(data)) {
throw new Error('Format data tidak valid');
}
roadDamageRecords = data;
roadDamageLayer.clearLayers();
data.forEach(record => {
const marker = L.marker([record.latitude, record.longitude], {
icon: createRoadDamageIcon(record.severity)
}).addTo(roadDamageLayer);
marker.roadDamageId = Number(record.id);
marker.bindPopup(getRoadDamagePopup(record));
});
renderInputRegistry();
})
.catch(err => {
console.error('Gagal memuat data jalan rusak:', err);
});
}
function getRoadNetworkColor(status) {
const key = String(status || '').toLowerCase().trim();
if (key.includes('nasional')) return '#0f766e';
if (key.includes('propinsi') || key.includes('provinsi')) return '#1d4ed8';
if (key.includes('kota')) return '#dc2626';
if (key.includes('kabupaten')) return '#b45309';
return '#475569';
}
function sanitizeRoadCoordinates(coords) {
if (!Array.isArray(coords)) {
return [];
}
const result = [];
for (const coord of coords) {
const x = Number(coord && coord[0]);
const y = Number(coord && coord[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
continue;
}
// Data jalan berada pada UTM 49S (easting normal sekitar 100k-900k).
if (x < 100000 || x > 900000 || Math.abs(y) > 11000000) {
continue;
}
result.push([x, y]);
}
return result;
}
function sanitizeRoadFeature(feature) {
if (!feature || !feature.geometry) {
return null;
}
const geometry = feature.geometry;
const type = geometry.type;
if (type === 'LineString') {
const cleaned = sanitizeRoadCoordinates(geometry.coordinates);
if (cleaned.length < 2) {
return null;
}
return {
...feature,
geometry: {
...geometry,
coordinates: cleaned
}
};
}
if (type === 'MultiLineString') {
const cleanedLines = (Array.isArray(geometry.coordinates) ? geometry.coordinates : [])
.map(line => sanitizeRoadCoordinates(line))
.filter(line => line.length >= 2);
if (!cleanedLines.length) {
return null;
}
return {
...feature,
geometry: {
...geometry,
coordinates: cleanedLines
}
};
}
return null;
}
function sanitizeRoadGeoJson(data) {
return {
...data,
features: (Array.isArray(data.features) ? data.features : [])
.map(sanitizeRoadFeature)
.filter(Boolean)
};
}
function convertExportRoadCoord(coords) {
const x = Number(coords && coords[0]);
const y = Number(coords && coords[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return L.latLng(0, 0);
}
// If coordinates are already lon/lat, use them directly.
if (Math.abs(x) <= 180 && Math.abs(y) <= 90) {
return L.latLng(y, x);
}
if (typeof proj4 !== 'function') {
return L.latLng(0, 0);
}
// Export data uses UTM 49S with false northing removed (small +/- values).
const adjustedNorthing = Math.abs(y) < 1000000 ? y + 10000000 : y;
const projected = proj4('EPSG:32749', 'EPSG:4326', [x, adjustedNorthing]);
return L.latLng(projected[1], projected[0]);
}
async function loadExternalRoadNetwork(force = false) {
if (roadNetworkLoaded && !force) {
return;
}
if (typeof proj4 === 'function' && !proj4.defs('EPSG:32749')) {
proj4.defs('EPSG:32749', '+proj=utm +zone=49 +south +datum=WGS84 +units=m +no_defs +type=crs');
}
if (force) {
roadNetworkLayer.clearLayers();
roadNetworkLoaded = false;
}
try {
const response = await fetch(ROAD_NETWORK_GEOJSON_PATH, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`Gagal memuat ${ROAD_NETWORK_GEOJSON_PATH} (HTTP ${response.status})`);
}
const data = await response.json();
if (!data || data.type !== 'FeatureCollection' || !Array.isArray(data.features)) {
throw new Error('Format data jalan tidak valid.');
}
const sanitizedRoadData = sanitizeRoadGeoJson(data);
const roadGeoJsonLayer = L.geoJSON(sanitizedRoadData, {
coordsToLatLng: convertExportRoadCoord,
filter: feature => {
const type = feature && feature.geometry && feature.geometry.type;
return type === 'LineString' || type === 'MultiLineString';
},
style: feature => {
const props = feature.properties || {};
return {
color: getRoadNetworkColor(props.status_jal),
weight: 2.8,
opacity: 0.92
};
},
onEachFeature: (feature, layer) => {
const props = feature.properties || {};
const namaJalan = String(props.nama_jalan || '').trim() || 'Tanpa nama';
const status = String(props.status_jal || '').trim() || '-';
const fungsi = String(props.Fungsi_Jal || '').trim() || '-';
const panjang = Number(props.Plan_Len);
const panjangText = Number.isFinite(panjang) ? `${panjang.toFixed(1)} m` : '-';
layer.bindPopup(`
<div class="p-2">
<strong>${namaJalan}</strong><br>
Status: ${status}<br>
Fungsi: ${fungsi}<br>
Panjang: ${panjangText}
</div>
`);
}
});
roadNetworkLayer.addLayer(roadGeoJsonLayer);
roadNetworkLoaded = true;
} catch (error) {
console.error('Gagal memuat layer jalan eksternal:', error);
showMessage(error.message || 'Gagal memuat data jalan eksternal', 'bg-red-600 text-white');
}
}
function deleteRoadDamage(id) {
if (!confirm('Hapus laporan jalan rusak ini?')) return;
const formData = new FormData();
formData.append('id', id);
fetch('delete_jalan_rusak.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'delete_jalan_rusak.php'))
.then(data => {
if (data.status === 'success') {
showMessage('🗑️ Laporan berhasil dihapus', 'bg-orange-600 text-white');
loadRoadDamage(true);
} else {
throw new Error(data.message || 'Gagal menghapus laporan');
}
})
.catch(err => showMessage(err.message || 'Gagal menghapus laporan', 'bg-red-600 text-white'));
}
function updateFeatureMeasurePreview() {
const type = document.getElementById('featureType').value;
const layer = pendingShape;
const measureField = document.getElementById('modeMeasure');
if (!layer) {
measureField.value = type === 'polyline' ? 'Panjang otomatis setelah gambar' : 'Luas otomatis setelah gambar';
return;
}
if (type === 'polyline') {
measureField.value = formatMeters(calculatePolylineLength(layer));
} else {
measureField.value = formatSquareMeters(calculatePolygonArea(layer));
}
}
function updateDrawControl(type) {
if (drawControl) {
map.removeControl(drawControl);
drawControl = null;
}
drawControl = new L.Control.Draw({
position: 'topleft',
edit: {
featureGroup: featureLayer,
edit: true,
remove: true
},
draw: {
polygon: {
allowIntersection: false,
showArea: true,
shapeOptions: {
color: '#3b82f6',
weight: 3
}
},
polyline: {
shapeOptions: {
color: '#16a34a',
weight: 5
}
},
rectangle: true,
circle: true,
circlemarker: false,
marker: true
}
});
map.addControl(drawControl);
}
function openNewLocationPopup(lat, lng) {
const content = `
<div class="p-4 w-full">
<h3 class="font-bold text-indigo-700 border-b pb-2 mb-3 uppercase text-[10px] tracking-widest">Input SPBU Baru</h3>
<div class="space-y-3">
<input type="text" id="new_nama" placeholder="Nama SPBU" class="w-full border rounded p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm">
<input type="text" id="new_nomor" placeholder="Nomor SPBU" class="w-full border rounded p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm">
<select id="new_status" class="w-full border rounded p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm">
<option value="Ya">Buka 24 Jam</option>
<option value="Tidak">Tidak</option>
</select>
<div class="text-[10px] text-slate-400 bg-slate-50 p-2 rounded border border-dashed text-center">
Lat: ${lat.toFixed(6)}, Lng: ${lng.toFixed(6)}
</div>
<button onclick="saveNew(${lat}, ${lng})" class="w-full bg-indigo-600 text-white py-2 rounded font-bold text-sm shadow-lg hover:bg-indigo-700 transition">Simpan</button>
</div>
</div>
`;
L.popup().setLatLng([lat, lng]).setContent(content).openOn(map);
}
async function reverseGeocodeLocation(lat, lng) {
const url = new URL('https://nominatim.openstreetmap.org/reverse');
url.searchParams.set('format', 'jsonv2');
url.searchParams.set('lat', String(lat));
url.searchParams.set('lon', String(lng));
url.searchParams.set('accept-language', 'id');
const response = await fetch(url.toString(), {
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Reverse geocode gagal (HTTP ${response.status})`);
}
const data = await response.json();
return data.display_name || 'Alamat tidak ditemukan';
}
function distanceMeters(lat1, lng1, lat2, lng2) {
const earthRadius = 6371000;
const toRadians = value => (value * Math.PI) / 180;
const deltaLat = toRadians(lat2 - lat1);
const deltaLng = toRadians(lng2 - lng1);
const a = Math.sin(deltaLat / 2) ** 2 + Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(deltaLng / 2) ** 2;
return 2 * earthRadius * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function formatDistanceMeters(value) {
if (!Number.isFinite(value)) return '-';
if (value >= 1000) {
return `${(value / 1000).toFixed(2)} km`;
}
return `${Math.round(value)} m`;
}
function findNearestRumahIbadah(lat, lng) {
if (!Array.isArray(rumahIbadahRecords) || !rumahIbadahRecords.length) {
return null;
}
return rumahIbadahRecords.reduce((nearest, record) => {
const distance = distanceMeters(lat, lng, Number(record.latitude), Number(record.longitude));
if (!nearest || distance < nearest.distance) {
return { record, distance };
}
return nearest;
}, null);
}
function syncRumahIbadahRadius(value) {
const slider = document.getElementById('rumahIbadahRadius');
const field = document.getElementById('rumahIbadahRadiusValue');
const numericValue = Math.max(50, Number(value) || 1000);
if (slider) slider.value = numericValue;
if (field) field.innerText = String(numericValue);
}
function openRumahIbadahModal(lat, lng, record = null) {
pendingRumahIbadahCoords = { lat, lng };
editingRumahIbadahId = record ? Number(record.id) : null;
document.getElementById('rumahIbadahId').value = record ? String(record.id) : '';
document.getElementById('rumahIbadahNama').value = record ? record.nama || '' : '';
document.getElementById('rumahIbadahPic').value = record ? record.pic || '' : '';
document.getElementById('rumahIbadahAlamat').value = record ? record.alamat || '' : 'Mencari alamat...';
syncRumahIbadahRadius(record ? record.radius_m : 1000);
document.getElementById('rumahIbadahCoords').innerText = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
const modal = document.getElementById('rumahIbadahModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
suppressAutoReload = true;
if (!record) {
reverseGeocodeLocation(lat, lng)
.then(address => {
if (pendingRumahIbadahCoords && pendingRumahIbadahCoords.lat === lat && pendingRumahIbadahCoords.lng === lng) {
document.getElementById('rumahIbadahAlamat').value = address;
}
})
.catch(() => {
if (pendingRumahIbadahCoords && pendingRumahIbadahCoords.lat === lat && pendingRumahIbadahCoords.lng === lng) {
document.getElementById('rumahIbadahAlamat').value = 'Alamat tidak ditemukan';
}
});
}
}
function closeRumahIbadahModal() {
const modal = document.getElementById('rumahIbadahModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
pendingRumahIbadahCoords = null;
editingRumahIbadahId = null;
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)') || !!editingFeatureId;
}
function openPendudukMiskinModal(lat, lng, record = null) {
pendingPendudukMiskinCoords = { lat, lng };
document.getElementById('pendudukMiskinId').value = record ? String(record.id) : '';
document.getElementById('pendudukMiskinNama').value = record ? (record.nama_ketua_kk || '') : '';
document.getElementById('pendudukMiskinAnggota').value = record ? String(Number(record.jumlah_anggota) || 1) : '1';
document.getElementById('pendudukMiskinCoords').innerText = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
const nearest = findNearestRumahIbadah(lat, lng);
if (nearest) {
document.getElementById('pendudukMiskinRumahIbadah').value = nearest.record.nama;
document.getElementById('pendudukMiskinJarak').value = `${formatDistanceMeters(nearest.distance)} dari rumah ibadah terdekat`;
} else {
document.getElementById('pendudukMiskinRumahIbadah').value = 'Belum ada data rumah ibadah';
document.getElementById('pendudukMiskinJarak').value = '-';
}
const modal = document.getElementById('pendudukMiskinModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
suppressAutoReload = true;
}
function closePendudukMiskinModal() {
const modal = document.getElementById('pendudukMiskinModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
pendingPendudukMiskinCoords = null;
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)') || !!editingFeatureId;
}
function buildRumahIbadahPopup(record) {
const residentsInArea = getPoorResidentsInRumahRadius(record);
return `
<div class="w-full p-4">
<h3 class="text-lg font-bold text-slate-800">${record.nama}</h3>
<p class="mt-1 text-sm text-slate-600">PIC: ${record.pic}</p>
<p class="mt-1 text-sm text-slate-600">Radius: ${Number(record.radius_m).toLocaleString('id-ID')} m</p>
<p class="mt-1 text-sm text-slate-600">Alamat: ${record.alamat}</p>
<p class="mt-1 text-sm text-slate-600">Penduduk miskin dalam wilayah: <span class="font-semibold">${residentsInArea.length}</span></p>
<div class="mt-4 grid grid-cols-2 gap-2 border-t pt-3">
<button onclick="openRumahIbadahEditor(${record.id})" class="rounded-lg bg-indigo-50 px-3 py-2 text-xs font-bold text-indigo-700 hover:bg-indigo-100">Edit</button>
<button onclick="showNearbyPoorLocations(${record.id})" class="rounded-lg bg-emerald-50 px-3 py-2 text-xs font-bold text-emerald-700 hover:bg-emerald-100">Cari Miskin</button>
<button onclick="openBantuanForRumah(${record.id})" class="rounded-lg bg-violet-50 px-3 py-2 text-xs font-bold text-violet-700 hover:bg-violet-100">Kelola Bantuan</button>
<button onclick="deleteRumahIbadah(${record.id})" class="rounded-lg bg-red-50 px-3 py-2 text-xs font-bold text-red-700 hover:bg-red-100">Hapus</button>
</div>
</div>
`;
}
function buildPendudukMiskinPopup(record) {
const nearestName = record.rumah_ibadah_nama || '-';
const radiusInfo = record.jarak_ke_rumah_ibadah_m != null ? formatDistanceMeters(Number(record.jarak_ke_rumah_ibadah_m)) : '-';
const aidStatus = getAidStatusBadge(record.bantuan_status);
const aidNote = record.bantuan_catatan ? record.bantuan_catatan : '-';
return `
<div class="w-full p-4">
<h3 class="text-lg font-bold text-slate-800">${record.nama_ketua_kk}</h3>
<p class="mt-1 text-sm text-slate-600">Anggota keluarga: ${record.jumlah_anggota}</p>
<p class="mt-1 text-sm text-slate-600">Rumah ibadah terdekat: ${nearestName}</p>
<p class="mt-1 text-sm text-slate-600">Jarak: ${radiusInfo}</p>
<p class="mt-1 text-sm text-slate-600">Status bantuan: <span class="font-semibold">${aidStatus.label}</span></p>
<p class="mt-1 text-xs text-slate-500">Catatan: ${aidNote}</p>
<div class="mt-4 grid grid-cols-3 gap-2 border-t pt-3">
<button onclick="openPendudukMiskinEditor(${record.id})" class="rounded-lg bg-indigo-50 px-3 py-2 text-xs font-bold text-indigo-700 hover:bg-indigo-100">Edit</button>
<button onclick="deletePendudukMiskin(${record.id})" class="rounded-lg bg-red-50 px-3 py-2 text-xs font-bold text-red-700 hover:bg-red-100">Hapus</button>
<button onclick="focusRegistryItem('rumahIbadah', ${record.rumah_ibadah_id || 0})" class="rounded-lg bg-emerald-50 px-3 py-2 text-xs font-bold text-emerald-700 hover:bg-emerald-100">Lihat Rumah Ibadah</button>
</div>
<div class="mt-2 grid grid-cols-2 gap-2">
<button onclick="openVerifikasiFromPenduduk(${record.id})" class="rounded-lg bg-emerald-50 px-3 py-2 text-xs font-bold text-emerald-700 hover:bg-emerald-100">Verifikasi</button>
<button onclick="setDataMode('aduanWarga'); openAduanWargaModal(${Number(record.latitude)}, ${Number(record.longitude)})" class="rounded-lg bg-fuchsia-50 px-3 py-2 text-xs font-bold text-fuchsia-700 hover:bg-fuchsia-100">Buat Aduan</button>
</div>
</div>
`;
}
function getPoorResidentsInRumahRadius(rumahRecord) {
if (!rumahRecord) return [];
const radius = Number(rumahRecord.radius_m || 0);
const rumahLat = Number(rumahRecord.latitude);
const rumahLng = Number(rumahRecord.longitude);
if (!Number.isFinite(radius) || !Number.isFinite(rumahLat) || !Number.isFinite(rumahLng)) {
return [];
}
return pendudukMiskinRecords
.map(item => {
const distance = distanceMeters(rumahLat, rumahLng, Number(item.latitude), Number(item.longitude));
return { ...item, _distanceToRumah: distance };
})
.filter(item => {
if (!Number.isFinite(item._distanceToRumah) || item._distanceToRumah > radius) {
return false;
}
if (item.rumah_ibadah_id != null) {
return Number(item.rumah_ibadah_id) === Number(rumahRecord.id);
}
const nearest = findNearestRumahIbadah(Number(item.latitude), Number(item.longitude));
return nearest && Number(nearest.record.id) === Number(rumahRecord.id);
})
.sort((a, b) => a._distanceToRumah - b._distanceToRumah);
}
function openRumahIbadahEditor(id) {
const record = rumahIbadahRecords.find(item => Number(item.id) === Number(id));
if (!record) {
showMessage('Rumah ibadah tidak ditemukan', 'bg-red-600 text-white');
return;
}
openRumahIbadahModal(Number(record.latitude), Number(record.longitude), record);
}
function openPendudukMiskinEditor(id) {
const record = pendudukMiskinRecords.find(item => Number(item.id) === Number(id));
if (!record) {
showMessage('Data penduduk miskin tidak ditemukan', 'bg-red-600 text-white');
return;
}
openPendudukMiskinModal(Number(record.latitude), Number(record.longitude), record);
}
function showNearbyPoorLocations(rumahIbadahId) {
const record = rumahIbadahRecords.find(item => Number(item.id) === Number(rumahIbadahId));
if (!record) {
showMessage('Rumah ibadah tidak ditemukan', 'bg-red-600 text-white');
return;
}
selectedRumahIbadahId = Number(rumahIbadahId);
const withinRadius = getPoorResidentsInRumahRadius(record);
if (!withinRadius.length) {
showMessage('Belum ada penduduk miskin di dalam radius ini', 'bg-amber-600 text-white');
return;
}
const bounds = L.latLngBounds(withinRadius.map(item => [Number(item.latitude), Number(item.longitude)]));
map.fitBounds(bounds.pad(0.2));
withinRadius.forEach(item => {
const layer = pendudukMiskinLayerMap[item.id];
if (layer) {
layer.openPopup();
}
});
showMessage(`Menampilkan ${withinRadius.length} lokasi miskin terdekat`, 'bg-emerald-600 text-white');
}
function getAidStatusColor(status) {
const normalized = normalizeAidStatus(status);
if (normalized === 'sudah') return '#10b981';
if (normalized === 'proses') return '#f59e0b';
return '#dc2626';
}
function createPendudukMiskinIcon(status) {
const color = getAidStatusColor(status);
return L.divIcon({
className: 'custom-marker',
html: `<svg width="32" height="40" viewBox="0 0 24 24" fill="${color}" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="8" r="4"/><path d="M4 22a8 8 0 0 1 16 0"/></svg>`,
iconSize: [32, 40],
iconAnchor: [16, 40],
popupAnchor: [0, -40]
});
}
function getVerifikasiStatusBadge(status) {
const value = String(status || 'menunggu').toLowerCase().trim();
if (value === 'valid') return { label: 'Valid', className: 'bg-emerald-100 text-emerald-700' };
if (value === 'perlu_tindak_lanjut') return { label: 'Perlu Tindak Lanjut', className: 'bg-amber-100 text-amber-700' };
return { label: 'Menunggu', className: 'bg-slate-100 text-slate-700' };
}
function getVerifikasiIcon(status) {
const value = String(status || 'menunggu').toLowerCase().trim();
const color = value === 'valid' ? '#10b981' : value === 'perlu_tindak_lanjut' ? '#f59e0b' : '#64748b';
return L.divIcon({
className: 'custom-marker',
html: `<svg width="32" height="40" viewBox="0 0 24 24" fill="${color}" xmlns="http://www.w3.org/2000/svg"><path d="M12 0 3 4v6c0 6.2 4.2 11.4 9 14 4.8-2.6 9-7.8 9-14V4z"/><path d="M9.5 11.5 11 13l3.5-4" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
iconSize: [32, 40],
iconAnchor: [16, 40],
popupAnchor: [0, -40]
});
}
function getAduanStatusBadge(status) {
const value = String(status || 'baru').toLowerCase().trim();
if (value === 'selesai') return { label: 'Selesai', className: 'bg-emerald-100 text-emerald-700' };
if (value === 'diproses') return { label: 'Diproses', className: 'bg-amber-100 text-amber-700' };
return { label: 'Baru', className: 'bg-rose-100 text-rose-700' };
}
function getAduanIcon(status) {
const value = String(status || 'baru').toLowerCase().trim();
const color = value === 'selesai' ? '#10b981' : value === 'diproses' ? '#f59e0b' : '#e11d48';
return L.divIcon({
className: 'custom-marker',
html: `<svg width="32" height="40" viewBox="0 0 24 24" fill="${color}" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c-5 0-9 3.6-9 8 0 3.1 2.1 5.8 5.2 7l-.7 4 4.5-2.4c.3 0 .6.1 1 .1 5 0 9-3.6 9-8s-4-8.7-9-8.7z"/><circle cx="9" cy="10" r="1" fill="white"/><circle cx="12" cy="10" r="1" fill="white"/><circle cx="15" cy="10" r="1" fill="white"/></svg>`,
iconSize: [32, 40],
iconAnchor: [16, 40],
popupAnchor: [0, -40]
});
}
function buildVerifikasiLapanganPopup(record) {
const badge = getVerifikasiStatusBadge(record.status_verifikasi);
const fotoHtml = record.foto ? `<img src="${record.foto}" alt="Foto verifikasi" class="mt-2 w-full rounded-lg max-h-48 object-cover">` : '';
return `
<div class="w-full p-4">
<h3 class="text-lg font-bold text-slate-800">Verifikasi Lapangan</h3>
<p class="mt-1 text-sm text-slate-600">Target: ${record.target_nama || '-'} (${record.jenis_target || '-'})</p>
<p class="mt-1 text-sm text-slate-600">Petugas: ${record.petugas_nama || '-'}</p>
<div class="mt-2">
<span class="registry-chip ${badge.className}">${badge.label}</span>
</div>
${record.hasil_temuan ? `<p class="mt-2 text-sm text-slate-600"><strong>Temuan:</strong> ${record.hasil_temuan}</p>` : ''}
${record.tindak_lanjut ? `<p class="mt-1 text-sm text-slate-600"><strong>Tindak lanjut:</strong> ${record.tindak_lanjut}</p>` : ''}
<p class="mt-1 text-xs text-slate-500">📍 ${Number(record.latitude).toFixed(5)}, ${Number(record.longitude).toFixed(5)}</p>
${fotoHtml}
<div class="mt-3 grid grid-cols-2 gap-2 border-t pt-3">
<button onclick="openVerifikasiLapanganEditor(${record.id})" class="rounded-lg bg-indigo-50 px-3 py-2 text-xs font-bold text-indigo-700 hover:bg-indigo-100">Edit</button>
<button onclick="deleteVerifikasiLapangan(${record.id})" class="rounded-lg bg-red-50 px-3 py-2 text-xs font-bold text-red-700 hover:bg-red-100">Hapus</button>
</div>
</div>
`;
}
function openVerifikasiLapanganModal(lat, lng, record = null) {
pendingVerifikasiCoords = { lat, lng };
document.getElementById('verifikasiLapanganId').value = record ? String(record.id) : '';
document.getElementById('verifikasiJenisTarget').value = record ? (record.jenis_target || 'penduduk_miskin') : 'penduduk_miskin';
document.getElementById('verifikasiTargetNama').value = record ? (record.target_nama || '') : '';
document.getElementById('verifikasiPetugasNama').value = record ? (record.petugas_nama || '') : '';
document.getElementById('verifikasiStatus').value = record ? (record.status_verifikasi || 'menunggu') : 'menunggu';
document.getElementById('verifikasiHasilTemuan').value = record ? (record.hasil_temuan || '') : '';
document.getElementById('verifikasiTindakLanjut').value = record ? (record.tindak_lanjut || '') : '';
document.getElementById('verifikasiCoords').innerText = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
document.getElementById('verifikasiFoto').value = '';
const modal = document.getElementById('verifikasiLapanganModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
suppressAutoReload = true;
}
function closeVerifikasiLapanganModal() {
const modal = document.getElementById('verifikasiLapanganModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
pendingVerifikasiCoords = null;
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)') || !!editingFeatureId;
}
function saveVerifikasiLapangan() {
if (!pendingVerifikasiCoords) {
showMessage('Koordinat verifikasi tidak ditemukan', 'bg-red-600 text-white');
return;
}
const id = document.getElementById('verifikasiLapanganId').value;
const formData = new FormData();
formData.append('jenis_target', document.getElementById('verifikasiJenisTarget').value);
formData.append('target_nama', document.getElementById('verifikasiTargetNama').value.trim());
formData.append('petugas_nama', document.getElementById('verifikasiPetugasNama').value.trim());
formData.append('status_verifikasi', document.getElementById('verifikasiStatus').value);
formData.append('hasil_temuan', document.getElementById('verifikasiHasilTemuan').value.trim());
formData.append('tindak_lanjut', document.getElementById('verifikasiTindakLanjut').value.trim());
formData.append('latitude', pendingVerifikasiCoords.lat);
formData.append('longitude', pendingVerifikasiCoords.lng);
const fileInput = document.getElementById('verifikasiFoto');
if (fileInput.files && fileInput.files[0]) {
formData.append('foto', fileInput.files[0]);
}
const endpoint = id ? 'update_verifikasi_lapangan.php' : 'save_verifikasi_lapangan.php';
if (id) {
formData.append('id', id);
}
fetch(endpoint, { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, endpoint))
.then(data => {
if (data.status === 'success') {
showMessage('✅ Verifikasi lapangan berhasil disimpan', 'bg-green-600 text-white');
closeVerifikasiLapanganModal();
loadVerifikasiLapangan(true);
} else {
throw new Error(data.message || 'Gagal menyimpan verifikasi');
}
})
.catch(err => showMessage(err.message || 'Gagal menyimpan verifikasi', 'bg-red-600 text-white'));
}
function deleteVerifikasiLapangan(id) {
if (!confirm('Hapus verifikasi lapangan ini?')) return;
const formData = new FormData();
formData.append('id', id);
fetch('delete_verifikasi_lapangan.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'delete_verifikasi_lapangan.php'))
.then(data => {
if (data.status === 'success') {
showMessage('🗑️ Verifikasi lapangan berhasil dihapus', 'bg-orange-600 text-white');
loadVerifikasiLapangan(true);
} else {
throw new Error(data.message || 'Gagal menghapus verifikasi');
}
})
.catch(err => showMessage(err.message || 'Gagal menghapus verifikasi', 'bg-red-600 text-white'));
}
function openVerifikasiLapanganEditor(id) {
const record = verifikasiLapanganRecords.find(item => Number(item.id) === Number(id));
if (!record) {
showMessage('Data verifikasi tidak ditemukan', 'bg-red-600 text-white');
return;
}
openVerifikasiLapanganModal(Number(record.latitude), Number(record.longitude), record);
}
function openVerifikasiFromPenduduk(id) {
const record = pendudukMiskinRecords.find(item => Number(item.id) === Number(id));
if (!record) {
showMessage('Data penduduk miskin tidak ditemukan', 'bg-red-600 text-white');
return;
}
openVerifikasiLapanganModal(Number(record.latitude), Number(record.longitude), {
jenis_target: 'penduduk_miskin',
target_nama: record.nama_ketua_kk || '',
petugas_nama: '',
status_verifikasi: 'menunggu',
hasil_temuan: '',
tindak_lanjut: ''
});
}
function buildAduanWargaPopup(record) {
const badge = getAduanStatusBadge(record.status_tindak_lanjut);
const fotoHtml = record.foto ? `<img src="${record.foto}" alt="Foto aduan" class="mt-2 w-full rounded-lg max-h-48 object-cover">` : '';
return `
<div class="w-full p-4">
<h3 class="text-lg font-bold text-slate-800">Aduan Warga</h3>
<p class="mt-1 text-sm text-slate-600">Kategori: ${record.kategori || '-'}</p>
<p class="mt-1 text-sm text-slate-600">Pelapor: ${record.pelapor_nama || '-'}</p>
<p class="mt-1 text-sm text-slate-600">Kontak: ${record.kontak_pelapor || '-'}</p>
<div class="mt-2">
<span class="registry-chip ${badge.className}">${badge.label}</span>
</div>
<p class="mt-2 text-sm text-slate-600"><strong>Deskripsi:</strong> ${record.deskripsi || '-'}</p>
${record.tindak_lanjut ? `<p class="mt-1 text-sm text-slate-600"><strong>Tindak lanjut:</strong> ${record.tindak_lanjut}</p>` : ''}
<p class="mt-1 text-xs text-slate-500">📍 ${Number(record.latitude).toFixed(5)}, ${Number(record.longitude).toFixed(5)}</p>
${fotoHtml}
<div class="mt-3 grid grid-cols-2 gap-2 border-t pt-3">
<button onclick="openAduanWargaEditor(${record.id})" class="rounded-lg bg-indigo-50 px-3 py-2 text-xs font-bold text-indigo-700 hover:bg-indigo-100">Edit</button>
<button onclick="deleteAduanWarga(${record.id})" class="rounded-lg bg-red-50 px-3 py-2 text-xs font-bold text-red-700 hover:bg-red-100">Hapus</button>
</div>
</div>
`;
}
function openAduanWargaModal(lat, lng, record = null) {
pendingAduanCoords = { lat, lng };
document.getElementById('aduanWargaId').value = record ? String(record.id) : '';
document.getElementById('aduanKategori').value = record ? (record.kategori || '') : '';
document.getElementById('aduanPelaporNama').value = record ? (record.pelapor_nama || '') : '';
document.getElementById('aduanKontakPelapor').value = record ? (record.kontak_pelapor || '') : '';
document.getElementById('aduanStatus').value = record ? (record.status_tindak_lanjut || 'baru') : 'baru';
document.getElementById('aduanDeskripsi').value = record ? (record.deskripsi || '') : '';
document.getElementById('aduanTindakLanjut').value = record ? (record.tindak_lanjut || '') : '';
document.getElementById('aduanCoords').innerText = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
document.getElementById('aduanFoto').value = '';
const modal = document.getElementById('aduanWargaModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
suppressAutoReload = true;
}
function closeAduanWargaModal() {
const modal = document.getElementById('aduanWargaModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
pendingAduanCoords = null;
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)') || !!editingFeatureId;
}
function saveAduanWarga() {
if (!pendingAduanCoords) {
showMessage('Koordinat aduan tidak ditemukan', 'bg-red-600 text-white');
return;
}
const id = document.getElementById('aduanWargaId').value;
const kategori = document.getElementById('aduanKategori').value.trim();
const pelaporNama = document.getElementById('aduanPelaporNama').value.trim();
const deskripsi = document.getElementById('aduanDeskripsi').value.trim();
if (!kategori || !pelaporNama || !deskripsi) {
showMessage('Kategori, nama pelapor, dan deskripsi wajib diisi', 'bg-red-600 text-white');
return;
}
const formData = new FormData();
formData.append('kategori', kategori);
formData.append('pelapor_nama', pelaporNama);
formData.append('kontak_pelapor', document.getElementById('aduanKontakPelapor').value.trim());
formData.append('deskripsi', deskripsi);
formData.append('status_tindak_lanjut', document.getElementById('aduanStatus').value);
formData.append('tindak_lanjut', document.getElementById('aduanTindakLanjut').value.trim());
formData.append('latitude', pendingAduanCoords.lat);
formData.append('longitude', pendingAduanCoords.lng);
const fileInput = document.getElementById('aduanFoto');
if (fileInput.files && fileInput.files[0]) {
formData.append('foto', fileInput.files[0]);
}
const endpoint = id ? 'update_aduan_warga.php' : 'save_aduan_warga.php';
if (id) {
formData.append('id', id);
}
fetch(endpoint, { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, endpoint))
.then(data => {
if (data.status === 'success') {
showMessage('✅ Aduan warga berhasil disimpan', 'bg-green-600 text-white');
closeAduanWargaModal();
loadAduanWarga(true);
} else {
throw new Error(data.message || 'Gagal menyimpan aduan');
}
})
.catch(err => showMessage(err.message || 'Gagal menyimpan aduan', 'bg-red-600 text-white'));
}
function deleteAduanWarga(id) {
if (!confirm('Hapus aduan warga ini?')) return;
const formData = new FormData();
formData.append('id', id);
fetch('delete_aduan_warga.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'delete_aduan_warga.php'))
.then(data => {
if (data.status === 'success') {
showMessage('🗑️ Aduan warga berhasil dihapus', 'bg-orange-600 text-white');
loadAduanWarga(true);
} else {
throw new Error(data.message || 'Gagal menghapus aduan');
}
})
.catch(err => showMessage(err.message || 'Gagal menghapus aduan', 'bg-red-600 text-white'));
}
function openAduanWargaEditor(id) {
const record = aduanWargaRecords.find(item => Number(item.id) === Number(id));
if (!record) {
showMessage('Data aduan tidak ditemukan', 'bg-red-600 text-white');
return;
}
openAduanWargaModal(Number(record.latitude), Number(record.longitude), record);
}
function saveRumahIbadah() {
if (!pendingRumahIbadahCoords) {
showMessage('Koordinat rumah ibadah tidak ditemukan', 'bg-red-600 text-white');
return;
}
const id = document.getElementById('rumahIbadahId').value;
const nama = document.getElementById('rumahIbadahNama').value.trim();
const alamat = document.getElementById('rumahIbadahAlamat').value.trim();
const pic = document.getElementById('rumahIbadahPic').value.trim();
const radius = Number(document.getElementById('rumahIbadahRadius').value || 1000);
if (!nama || !alamat || !pic) {
showMessage('Nama, alamat, dan PIC wajib diisi', 'bg-red-600 text-white');
return;
}
if (alamat.toLowerCase().includes('mencari alamat')) {
showMessage('Alamat masih diproses, tunggu reverse geocode selesai', 'bg-amber-600 text-white');
return;
}
const formData = new FormData();
formData.append('nama', nama);
formData.append('alamat', alamat);
formData.append('pic', pic);
formData.append('latitude', pendingRumahIbadahCoords.lat);
formData.append('longitude', pendingRumahIbadahCoords.lng);
formData.append('radius_m', radius);
const endpoint = id ? 'update_rumah_ibadah.php' : 'save_rumah_ibadah.php';
if (id) {
formData.append('id', id);
}
fetch(endpoint, { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, endpoint))
.then(data => {
if (data.status === 'success') {
showMessage('✅ Rumah ibadah berhasil disimpan', 'bg-green-600 text-white');
closeRumahIbadahModal();
loadRumahIbadah(true);
} else {
throw new Error(data.message || 'Gagal menyimpan rumah ibadah');
}
})
.catch(err => showMessage(err.message || 'Gagal menyimpan rumah ibadah', 'bg-red-600 text-white'));
}
function deleteRumahIbadah(id) {
if (!confirm('Hapus rumah ibadah ini?')) return;
const formData = new FormData();
formData.append('id', id);
fetch('delete_rumah_ibadah.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'delete_rumah_ibadah.php'))
.then(data => {
if (data.status === 'success') {
showMessage('🗑️ Rumah ibadah berhasil dihapus', 'bg-orange-600 text-white');
loadRumahIbadah(true);
loadPendudukMiskin(true);
} else {
throw new Error(data.message || 'Gagal menghapus rumah ibadah');
}
})
.catch(err => showMessage(err.message || 'Gagal menghapus rumah ibadah', 'bg-red-600 text-white'));
}
function savePendudukMiskin() {
if (!pendingPendudukMiskinCoords) {
showMessage('Koordinat penduduk miskin tidak ditemukan', 'bg-red-600 text-white');
return;
}
const id = document.getElementById('pendudukMiskinId').value;
const namaKetuaKk = document.getElementById('pendudukMiskinNama').value.trim();
const jumlahAnggota = Number(document.getElementById('pendudukMiskinAnggota').value || 0);
const nearest = findNearestRumahIbadah(pendingPendudukMiskinCoords.lat, pendingPendudukMiskinCoords.lng);
if (!namaKetuaKk || jumlahAnggota <= 0) {
showMessage('Nama ketua KK dan jumlah anggota wajib diisi', 'bg-red-600 text-white');
return;
}
if (!nearest) {
showMessage('Tambahkan rumah ibadah terlebih dahulu', 'bg-red-600 text-white');
return;
}
const formData = new FormData();
formData.append('nama_ketua_kk', namaKetuaKk);
formData.append('jumlah_anggota', jumlahAnggota);
formData.append('latitude', pendingPendudukMiskinCoords.lat);
formData.append('longitude', pendingPendudukMiskinCoords.lng);
formData.append('rumah_ibadah_id', nearest.record.id);
formData.append('rumah_ibadah_nama', nearest.record.nama);
formData.append('jarak_ke_rumah_ibadah_m', nearest.distance.toFixed(2));
const endpoint = id ? 'update_penduduk_miskin.php' : 'save_penduduk_miskin.php';
if (id) {
formData.append('id', id);
}
fetch(endpoint, { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, endpoint))
.then(data => {
if (data.status === 'success') {
showMessage('✅ Penduduk miskin berhasil disimpan', 'bg-green-600 text-white');
closePendudukMiskinModal();
loadPendudukMiskin(true);
loadRumahIbadah(true);
} else {
throw new Error(data.message || 'Gagal menyimpan penduduk miskin');
}
})
.catch(err => showMessage(err.message || 'Gagal menyimpan penduduk miskin', 'bg-red-600 text-white'));
}
function deletePendudukMiskin(id) {
if (!confirm('Hapus data penduduk miskin ini?')) return;
const formData = new FormData();
formData.append('id', id);
fetch('delete_penduduk_miskin.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'delete_penduduk_miskin.php'))
.then(data => {
if (data.status === 'success') {
showMessage('🗑️ Data penduduk miskin berhasil dihapus', 'bg-orange-600 text-white');
loadPendudukMiskin(true);
loadRumahIbadah(true);
} else {
throw new Error(data.message || 'Gagal menghapus data');
}
})
.catch(err => showMessage(err.message || 'Gagal menghapus data', 'bg-red-600 text-white'));
}
function createRumahIbadahIcon() {
return L.divIcon({
className: 'custom-marker',
html: `<svg width="32" height="40" viewBox="0 0 24 24" fill="#0f766e" xmlns="http://www.w3.org/2000/svg"><path d="M12 2 4 7v15h16V7z"/><path d="M9 22v-8h6v8" fill="#fff" opacity="0.85"/></svg>`,
iconSize: [32, 40],
iconAnchor: [16, 40],
popupAnchor: [0, -40]
});
}
function clearRumahIbadahLayers() {
rumahIbadahLayer.clearLayers();
rumahIbadahLayerMap = {};
}
function clearPendudukMiskinLayers() {
pendudukMiskinLayer.clearLayers();
pendudukMiskinLayerMap = {};
}
function refreshRumahIbadahPopups() {
Object.values(rumahIbadahLayerMap).forEach(entry => {
if (!entry || !entry.record) return;
const popupContent = buildRumahIbadahPopup(entry.record);
if (entry.marker) entry.marker.bindPopup(popupContent);
if (entry.circle) entry.circle.bindPopup(popupContent);
});
}
function loadRumahIbadah(force = false) {
if (!force && (document.hidden || suppressAutoReload)) return;
fetch('get_rumah_ibadah.php')
.then(res => {
if (!res.ok) throw new Error('HTTP Error: ' + res.status);
return res.text();
})
.then(text => {
const data = JSON.parse(text);
if (!Array.isArray(data)) {
throw new Error('Format data rumah ibadah tidak valid');
}
rumahIbadahRecords = data;
clearRumahIbadahLayers();
data.forEach(record => {
const marker = L.marker([Number(record.latitude), Number(record.longitude)], {
icon: createRumahIbadahIcon()
}).addTo(rumahIbadahLayer);
const circle = L.circle([Number(record.latitude), Number(record.longitude)], {
radius: Number(record.radius_m || 1000),
color: '#0f766e',
weight: 2,
fillColor: '#14b8a6',
fillOpacity: 0.12
}).addTo(rumahIbadahLayer);
marker.bindPopup(buildRumahIbadahPopup(record));
circle.bindPopup(buildRumahIbadahPopup(record));
rumahIbadahLayerMap[record.id] = { marker, circle, record };
});
renderInputRegistry();
const bantuanPanel = document.getElementById('bantuanDrawer');
if (bantuanPanel && !bantuanPanel.classList.contains('hidden-drawer')) {
renderBantuanDrawer();
}
})
.catch(err => console.error('Gagal memuat rumah ibadah:', err));
}
function loadPendudukMiskin(force = false) {
if (!force && (document.hidden || suppressAutoReload)) return;
fetch('get_penduduk_miskin.php')
.then(res => {
if (!res.ok) throw new Error('HTTP Error: ' + res.status);
return res.text();
})
.then(text => {
const data = JSON.parse(text);
if (!Array.isArray(data)) {
throw new Error('Format data penduduk miskin tidak valid');
}
pendudukMiskinRecords = data;
clearPendudukMiskinLayers();
data.forEach(record => {
const marker = L.marker([Number(record.latitude), Number(record.longitude)], {
icon: createPendudukMiskinIcon(record.bantuan_status)
}).addTo(pendudukMiskinLayer);
marker.bindPopup(buildPendudukMiskinPopup(record));
pendudukMiskinLayerMap[record.id] = marker;
});
refreshRumahIbadahPopups();
const bantuanPanel = document.getElementById('bantuanDrawer');
if (bantuanPanel && !bantuanPanel.classList.contains('hidden-drawer')) {
renderBantuanDrawer();
}
renderInputRegistry();
})
.catch(err => console.error('Gagal memuat penduduk miskin:', err));
}
function getVerifikasiLayerPopup(record) {
return buildVerifikasiLapanganPopup(record);
}
function getAduanLayerPopup(record) {
return buildAduanWargaPopup(record);
}
function clearVerifikasiLapanganLayers() {
verifikasiLapanganLayer.clearLayers();
verifikasiLapanganLayerMap = {};
}
function clearAduanWargaLayers() {
aduanWargaLayer.clearLayers();
aduanWargaLayerMap = {};
}
function loadVerifikasiLapangan(force = false) {
if (!force && (document.hidden || suppressAutoReload)) return;
fetch('get_verifikasi_lapangan.php')
.then(res => {
if (!res.ok) throw new Error('HTTP Error: ' + res.status);
return res.text();
})
.then(text => {
const data = JSON.parse(text);
if (!Array.isArray(data)) {
throw new Error('Format data verifikasi tidak valid');
}
verifikasiLapanganRecords = data;
clearVerifikasiLapanganLayers();
data.forEach(record => {
const marker = L.marker([Number(record.latitude), Number(record.longitude)], {
icon: getVerifikasiIcon(record.status_verifikasi)
}).addTo(verifikasiLapanganLayer);
marker.bindPopup(getVerifikasiLayerPopup(record));
verifikasiLapanganLayerMap[record.id] = marker;
});
renderInputRegistry();
})
.catch(err => console.error('Gagal memuat verifikasi lapangan:', err));
}
function loadAduanWarga(force = false) {
if (!force && (document.hidden || suppressAutoReload)) return;
fetch('get_aduan_warga.php')
.then(res => {
if (!res.ok) throw new Error('HTTP Error: ' + res.status);
return res.text();
})
.then(text => {
const data = JSON.parse(text);
if (!Array.isArray(data)) {
throw new Error('Format data aduan tidak valid');
}
aduanWargaRecords = data;
clearAduanWargaLayers();
data.forEach(record => {
const marker = L.marker([Number(record.latitude), Number(record.longitude)], {
icon: getAduanIcon(record.status_tindak_lanjut)
}).addTo(aduanWargaLayer);
marker.bindPopup(getAduanLayerPopup(record));
aduanWargaLayerMap[record.id] = marker;
});
renderInputRegistry();
})
.catch(err => console.error('Gagal memuat aduan warga:', err));
}
function convertCircleToPolygon(circleLayer, vertices = 48) {
const center = circleLayer.getLatLng();
const radius = Number(circleLayer.getRadius() || 0);
const points = [];
const latFactor = 111320;
const lngFactor = latFactor * Math.cos((center.lat * Math.PI) / 180);
for (let index = 0; index < vertices; index += 1) {
const angle = (index / vertices) * Math.PI * 2;
const dx = radius * Math.cos(angle);
const dy = radius * Math.sin(angle);
const lat = center.lat + (dy / latFactor);
const lng = center.lng + (dx / (lngFactor || 1));
points.push([lat, lng]);
}
return L.polygon(points);
}
function calculatePolylineLength(layer) {
const latlngs = layer.getLatLngs();
const points = Array.isArray(latlngs[0]) ? latlngs.flat() : latlngs;
let total = 0;
for (let i = 1; i < points.length; i += 1) {
total += map.distance(points[i - 1], points[i]);
}
return total;
}
function calculatePolygonArea(layer) {
const latlngs = layer.getLatLngs();
const rings = Array.isArray(latlngs[0]) ? latlngs[0] : latlngs;
if (L.GeometryUtil && typeof L.GeometryUtil.geodesicArea === 'function') {
return L.GeometryUtil.geodesicArea(rings);
}
return 0;
}
function geometryToCoordinates(layer) {
const latlngs = layer.getLatLngs();
const type = document.getElementById('featureType').value;
if (type === 'polyline') {
return latlngs.map(point => [point.lat, point.lng]);
}
const ring = Array.isArray(latlngs[0]) ? latlngs[0] : latlngs;
return [ring.map(point => [point.lat, point.lng])];
}
function getFeaturePopup(feature) {
const color = getFeatureStatusColor(feature.type, feature.status_objek);
const measureText = feature.type === 'polyline'
? formatMeters(Number(feature.nilai_ukur))
: formatSquareMeters(Number(feature.nilai_ukur));
return `
<div class="w-full p-4">
<h3 class="text-lg font-bold text-slate-800">${feature.nama_objek}</h3>
<p class="mt-1 text-sm text-slate-600">Kategori: ${feature.kategori}</p>
<p class="mt-1 text-sm text-slate-600">Status: <span class="font-semibold" style="color: ${color}">${feature.status_objek}</span></p>
<p class="mt-1 text-sm text-slate-600">${feature.type === 'polyline' ? 'Panjang' : 'Luas'}: <span class="font-semibold">${measureText}</span></p>
<div class="mt-4 grid grid-cols-2 gap-2 border-t pt-3">
<button onclick="openFeatureEditor(${feature.id})" class="rounded-lg bg-indigo-50 px-3 py-2 text-xs font-bold text-indigo-700 hover:bg-indigo-100">Edit Atribut</button>
<button onclick="toggleFeatureGeometryEditing(${feature.id})" class="rounded-lg bg-emerald-50 px-3 py-2 text-xs font-bold text-emerald-700 hover:bg-emerald-100">Edit Geometri</button>
<button onclick="saveFeatureGeometry(${feature.id})" class="rounded-lg bg-amber-50 px-3 py-2 text-xs font-bold text-amber-700 hover:bg-amber-100">Simpan Geometri</button>
<button onclick="deleteFeature(${feature.id})" class="rounded-lg bg-red-50 px-3 py-2 text-xs font-bold text-red-700 hover:bg-red-100">Hapus</button>
</div>
</div>
`;
}
function buildFeatureStatusOptions(type) {
return getFeatureOptions(type).map(option => `<option value="${option.value}">${option.label}</option>`).join('');
}
function geometryToLayer(type, geometry) {
if (type === 'polyline') {
return L.polyline(geometry);
}
return L.polygon(geometry);
}
function getFeatureGeometry(layer) {
const type = layer instanceof L.Polygon ? 'polygon' : 'polyline';
const latlngs = layer.getLatLngs();
if (type === 'polyline') {
return latlngs.map(point => [point.lat, point.lng]);
}
const ring = Array.isArray(latlngs[0]) ? latlngs[0] : latlngs;
return [ring.map(point => [point.lat, point.lng])];
}
function updateFeatureLayerStyle(layer, type, status) {
const color = getFeatureStatusColor(type, status);
layer.setStyle(type === 'polyline'
? { color, weight: 5 }
: { color, fillColor: color, fillOpacity: 0.25, weight: 3 }
);
}
function getFeatureLayerById(id) {
return featureLayerMap[id] || null;
}
function addSpatialFeatureLayer(layer, type) {
featureLayer.addLayer(layer);
if (type === 'polyline') {
jalanLayerGroup.addLayer(layer);
return;
}
if (type === 'polygon') {
polygonLayerGroup.addLayer(layer);
}
}
function removeSpatialFeatureLayer(layer, type) {
featureLayer.removeLayer(layer);
if (type === 'polyline') {
jalanLayerGroup.removeLayer(layer);
return;
}
if (type === 'polygon') {
polygonLayerGroup.removeLayer(layer);
}
}
function setSpatialOverlayVisibility(visible) {
const overlayLayers = [jalanLayerGroup, polygonLayerGroup, roadDamageLayer, roadNetworkLayer, rumahIbadahLayer, pendudukMiskinLayer];
if (visible) {
hiddenLayersDuringChoropleth.forEach(layer => {
if (!map.hasLayer(layer)) {
map.addLayer(layer);
}
});
hiddenLayersDuringChoropleth = [];
return;
}
hiddenLayersDuringChoropleth = overlayLayers.filter(layer => map.hasLayer(layer));
hiddenLayersDuringChoropleth.forEach(layer => map.removeLayer(layer));
}
function clearFeatureInput() {
document.getElementById('modeMeasure').value = '';
document.getElementById('newFeatureName').value = '';
document.getElementById('newFeatureMeasure').value = '';
document.getElementById('newFeatureKategori').value = '';
document.getElementById('newFeatureTypeLabel').value = '';
document.getElementById('newFeatureStatus').innerHTML = '';
pendingShape = null;
pendingNewFeatureLayer = null;
if (appMode === 'polyline' || appMode === 'polygon') {
setSpatialType(appMode);
}
}
function openNewFeatureModal(layer) {
const type = document.getElementById('featureType').value;
const status = document.getElementById('featureStatus').value;
const modal = document.getElementById('newFeatureModal');
pendingNewFeatureLayer = layer;
document.getElementById('newFeatureKategori').value = type === 'polyline' ? 'Jalan' : 'Tanah';
document.getElementById('newFeatureTypeLabel').value = type === 'polyline' ? 'Jalan (Polyline)' : 'Parsil Tanah (Polygon)';
document.getElementById('newFeatureStatus').innerHTML = buildFeatureStatusOptions(type);
document.getElementById('newFeatureStatus').value = status;
document.getElementById('newFeatureMeasure').value = type === 'polyline'
? formatMeters(calculatePolylineLength(layer))
: formatSquareMeters(calculatePolygonArea(layer));
document.getElementById('newFeatureName').value = '';
modal.classList.remove('hidden');
modal.classList.add('flex');
suppressAutoReload = true;
}
function closeNewFeatureModal(removeLayer = true) {
const modal = document.getElementById('newFeatureModal');
if (removeLayer && pendingNewFeatureLayer) {
const pendingType = pendingNewFeatureLayer.featureType || document.getElementById('featureType').value;
removeSpatialFeatureLayer(pendingNewFeatureLayer, pendingType);
}
modal.classList.add('hidden');
modal.classList.remove('flex');
clearFeatureInput();
}
function loadFeatures(force = false) {
if (!force && (document.hidden || suppressAutoReload || isFeatureDrawing || isFeatureSaving)) return;
fetch('get_features.php')
.then(res => {
if (!res.ok) throw new Error('HTTP Error: ' + res.status);
return res.text();
})
.then(text => {
const data = JSON.parse(text);
if (!Array.isArray(data)) {
if (data && data.status === 'error') {
throw new Error(data.message || 'Terjadi error pada server');
}
throw new Error('Format data fitur tidak valid');
}
featureRecords = data;
featureLayer.clearLayers();
jalanLayerGroup.clearLayers();
polygonLayerGroup.clearLayers();
featureLayerMap = {};
data.forEach(feature => {
const color = getFeatureStatusColor(feature.type, feature.status_objek);
const geometry = feature.geometry_data;
if (feature.type === 'polyline') {
const layer = L.polyline(geometry, { color, weight: 5 });
layer.featureData = feature;
layer.featureType = 'polyline';
featureLayerMap[feature.id] = layer;
addSpatialFeatureLayer(layer, 'polyline');
layer.bindPopup(getFeaturePopup(feature));
} else if (feature.type === 'polygon') {
const layer = L.polygon(geometry, { color, fillColor: color, fillOpacity: 0.25, weight: 3 });
layer.featureData = feature;
layer.featureType = 'polygon';
featureLayerMap[feature.id] = layer;
addSpatialFeatureLayer(layer, 'polygon');
layer.bindPopup(getFeaturePopup(feature));
}
});
renderInputRegistry();
})
.catch(err => {
console.error('Gagal memuat fitur spasial:', err);
if (force) {
showMessage('Gagal memuat data jalan/parsil', 'bg-red-600 text-white');
}
});
}
function saveFeature(layer) {
const type = document.getElementById('featureType').value;
const nama = document.getElementById('newFeatureName').value.trim();
const status = document.getElementById('featureStatus').value;
const kategori = type === 'polyline' ? 'Jalan' : 'Tanah';
const nilai = type === 'polyline' ? calculatePolylineLength(layer) : calculatePolygonArea(layer);
const geometry = geometryToCoordinates(layer);
if (!nama) {
showMessage('Nama objek wajib diisi', 'bg-red-600 text-white');
return;
}
const formData = new FormData();
formData.append('nama', nama);
formData.append('kategori', kategori);
formData.append('status', status);
formData.append('type', type);
formData.append('geometry', JSON.stringify(geometry));
formData.append('nilai', nilai);
isFeatureSaving = true;
fetch('save_feature.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'save_feature.php'))
.then(data => {
if (data.status === 'success') {
showMessage('✅ Fitur berhasil disimpan', 'bg-green-600 text-white');
loadFeatures(true);
clearFeatureInput();
closeNewFeatureModal(false);
} else {
removeSpatialFeatureLayer(layer, type);
throw new Error(data.message || 'Gagal menyimpan fitur');
}
})
.catch(err => {
removeSpatialFeatureLayer(layer, type);
showMessage(err.message || 'Gagal menyimpan fitur', 'bg-red-600 text-white');
})
.finally(() => {
isFeatureSaving = false;
});
}
function saveNewFeatureFromModal() {
const layer = pendingNewFeatureLayer;
if (!layer) {
showMessage('Tidak ada fitur baru untuk disimpan', 'bg-red-600 text-white');
return;
}
if (!document.getElementById('newFeatureName').value.trim()) {
showMessage('Nama objek wajib diisi', 'bg-red-600 text-white');
return;
}
document.getElementById('featureStatus').value = document.getElementById('newFeatureStatus').value;
saveFeature(layer);
}
function resetSpatialDraft(type) {
const measureField = document.getElementById('modeMeasure');
measureField.value = '';
if (pendingNewFeatureLayer && document.getElementById('featureType').value === type) {
closeNewFeatureModal(true);
return;
}
pendingShape = null;
pendingNewFeatureLayer = null;
updateFeatureMeasurePreview();
showMessage(`Draft ${type === 'polyline' ? 'jalan' : 'polygon'} dibersihkan`, 'bg-slate-700 text-white');
}
function openFeatureEditor(id) {
const layer = getFeatureLayerById(id);
if (!layer || !layer.featureData) {
showMessage('Fitur tidak ditemukan', 'bg-red-600 text-white');
return;
}
if (editingFeatureId && featureLayerMap[editingFeatureId] && featureLayerMap[editingFeatureId].editing) {
featureLayerMap[editingFeatureId].editing.disable();
editingFeatureId = null;
}
const feature = layer.featureData;
const modal = document.getElementById('featureModal');
document.getElementById('editFeatureId').value = feature.id;
document.getElementById('editFeatureName').value = feature.nama_objek;
document.getElementById('editFeatureKategori').value = feature.kategori;
document.getElementById('editFeatureType').value = feature.type;
document.getElementById('editFeatureStatus').innerHTML = buildFeatureStatusOptions(feature.type);
document.getElementById('editFeatureStatus').value = feature.status_objek;
document.getElementById('editFeatureMeasure').value = feature.type === 'polyline'
? formatMeters(Number(feature.nilai_ukur))
: formatSquareMeters(Number(feature.nilai_ukur));
modal.classList.remove('hidden');
modal.classList.add('flex');
suppressAutoReload = true;
}
function closeFeatureEditor() {
if (editingFeatureId && featureLayerMap[editingFeatureId] && featureLayerMap[editingFeatureId].editing) {
featureLayerMap[editingFeatureId].editing.disable();
editingFeatureId = null;
}
const modal = document.getElementById('featureModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)') || !!editingFeatureId;
}
function collectFeatureFormData(id, layer) {
const feature = layer.featureData;
const type = feature.type;
const nama = document.getElementById('editFeatureName').value.trim();
const kategori = document.getElementById('editFeatureKategori').value.trim();
const status = document.getElementById('editFeatureStatus').value;
const geometry = JSON.stringify(getFeatureGeometry(layer));
const nilai = type === 'polyline' ? calculatePolylineLength(layer) : calculatePolygonArea(layer);
return { id, nama, kategori, status, type, geometry, nilai };
}
function saveFeatureAttributes() {
const id = Number(document.getElementById('editFeatureId').value);
const layer = getFeatureLayerById(id);
if (!layer) {
showMessage('Fitur tidak ditemukan', 'bg-red-600 text-white');
return;
}
const payload = collectFeatureFormData(id, layer);
if (!payload.nama) {
showMessage('Nama objek wajib diisi', 'bg-red-600 text-white');
return;
}
const formData = new FormData();
Object.entries(payload).forEach(([key, value]) => formData.append(key, value));
fetch('update_feature.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'update_feature.php'))
.then(data => {
if (data.status === 'success') {
layer.featureData.nama_objek = payload.nama;
layer.featureData.kategori = payload.kategori;
layer.featureData.status_objek = payload.status;
layer.featureData.nilai_ukur = payload.nilai;
updateFeatureLayerStyle(layer, payload.type, payload.status);
layer.bindPopup(getFeaturePopup(layer.featureData));
document.getElementById('editFeatureMeasure').value = payload.type === 'polyline'
? formatMeters(payload.nilai)
: formatSquareMeters(payload.nilai);
showMessage('✅ Atribut fitur diperbarui', 'bg-green-600 text-white');
loadFeatures(true);
} else {
throw new Error(data.message || 'Gagal menyimpan perubahan');
}
})
.catch(err => showMessage(err.message || 'Gagal menyimpan perubahan', 'bg-red-600 text-white'));
}
function toggleFeatureGeometryEditing(id) {
const layer = getFeatureLayerById(id);
if (!layer) {
showMessage('Fitur tidak ditemukan', 'bg-red-600 text-white');
return;
}
if (!layer.editing || typeof layer.editing.enable !== 'function') {
showMessage('Edit geometri tidak didukung untuk layer ini', 'bg-red-600 text-white');
return;
}
if (editingFeatureId === id) {
layer.editing.disable();
editingFeatureId = null;
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)');
showMessage('Mode edit geometri dimatikan', 'bg-slate-700 text-white');
return;
}
if (editingFeatureId && featureLayerMap[editingFeatureId] && featureLayerMap[editingFeatureId].editing) {
featureLayerMap[editingFeatureId].editing.disable();
}
layer.editing.enable();
editingFeatureId = id;
suppressAutoReload = true;
showMessage('Geser titik vertex lalu klik Simpan Geometri', 'bg-emerald-600 text-white');
}
function saveFeatureGeometry(id) {
const layer = getFeatureLayerById(id);
if (!layer || !layer.featureData) {
showMessage('Fitur tidak ditemukan', 'bg-red-600 text-white');
return;
}
if (layer.editing && typeof layer.editing.disable === 'function') {
layer.editing.disable();
}
editingFeatureId = null;
const payload = collectFeatureFormData(id, layer);
const formData = new FormData();
Object.entries(payload).forEach(([key, value]) => formData.append(key, value));
fetch('update_feature.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'update_feature.php'))
.then(data => {
if (data.status === 'success') {
layer.featureData.geometry_data = JSON.parse(payload.geometry);
layer.featureData.nilai_ukur = payload.nilai;
updateFeatureLayerStyle(layer, payload.type, payload.status);
layer.setLatLngs(layer.featureData.geometry_data);
layer.bindPopup(getFeaturePopup(layer.featureData));
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)');
document.getElementById('editFeatureMeasure').value = payload.type === 'polyline'
? formatMeters(payload.nilai)
: formatSquareMeters(payload.nilai);
showMessage('✅ Geometri fitur diperbarui', 'bg-green-600 text-white');
loadFeatures(true);
} else {
throw new Error(data.message || 'Gagal menyimpan geometri');
}
})
.catch(err => showMessage(err.message || 'Gagal menyimpan geometri', 'bg-red-600 text-white'));
}
function deleteFeature(id) {
if (!confirm('Hapus fitur ini dari database?')) return;
const layer = getFeatureLayerById(id);
const formData = new FormData();
formData.append('id', id);
fetch('delete_feature.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'delete_feature.php'))
.then(data => {
if (data.status === 'success') {
if (layer) removeSpatialFeatureLayer(layer, layer.featureType || 'polyline');
delete featureLayerMap[id];
featureRecords = featureRecords.filter(feature => Number(feature.id) !== Number(id));
renderInputRegistry();
if (editingFeatureId === id) editingFeatureId = null;
closeFeatureEditor();
showMessage('🗑️ Fitur berhasil dihapus', 'bg-orange-600 text-white');
} else {
throw new Error(data.message || 'Gagal menghapus fitur');
}
})
.catch(err => showMessage(err.message || 'Gagal menghapus fitur', 'bg-red-600 text-white'));
}
function startDraw() {
const type = document.getElementById('featureType').value;
const status = document.getElementById('featureStatus').value;
if (appMode !== 'polyline' && appMode !== 'polygon') {
showMessage('Pilih mode Polyline atau Polygon dulu', 'bg-slate-700 text-white');
return;
}
if (!status) {
showMessage('Pilih status terlebih dahulu', 'bg-red-600 text-white');
return;
}
const shapeOptions = type === 'polyline'
? {
color: getFeatureStatusColor('polyline', status),
weight: 5
}
: {
color: getFeatureStatusColor('polygon', status),
fillColor: getFeatureStatusColor('polygon', status),
fillOpacity: 0.25,
weight: 3
};
const toolbar = document.querySelector(type === 'polyline' ? '.leaflet-draw-draw-polyline' : '.leaflet-draw-draw-polygon');
if (toolbar) {
isFeatureDrawing = true;
suppressAutoReload = true;
map.closePopup();
toolbar.click();
return;
}
// Fallback: aktifkan handler draw langsung jika tombol toolbar tidak ditemukan.
if (activeDrawHandler && typeof activeDrawHandler.disable === 'function') {
activeDrawHandler.disable();
}
activeDrawHandler = type === 'polyline'
? new L.Draw.Polyline(map, { shapeOptions })
: new L.Draw.Polygon(map, { allowIntersection: false, showArea: true, shapeOptions });
isFeatureDrawing = true;
suppressAutoReload = true;
map.closePopup();
activeDrawHandler.enable();
showMessage('Mode gambar aktif, klik pada peta untuk mulai menggambar', 'bg-indigo-600 text-white');
}
map.on(L.Draw.Event.DRAWSTOP, function() {
if (activeDrawHandler && typeof activeDrawHandler.disable === 'function') {
activeDrawHandler.disable();
activeDrawHandler = null;
}
isFeatureDrawing = false;
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)');
});
map.on(L.Draw.Event.CREATED, function(event) {
let { layerType, layer } = event;
if (layerType === 'marker') {
const latlng = layer.getLatLng();
openNewLocationPopup(latlng.lat, latlng.lng);
return;
}
if (layerType === 'circlemarker') {
showMessage('Circle marker dinonaktifkan', 'bg-amber-600 text-white');
return;
}
if (layerType === 'circle') {
layer = convertCircleToPolygon(layer);
layerType = 'polygon';
}
const activeType = (layerType === 'polyline') ? 'polyline' : 'polygon';
document.getElementById('featureType').value = activeType;
const statusSelect = document.getElementById('featureStatus');
statusSelect.innerHTML = buildFeatureStatusOptions(activeType);
if (!statusSelect.value && statusSelect.options.length > 0) {
statusSelect.selectedIndex = 0;
}
const color = getFeatureStatusColor(activeType, document.getElementById('featureStatus').value);
if (activeType === 'polyline') {
layer.setStyle({ color, weight: 5 });
} else {
layer.setStyle({ color, fillColor: color, fillOpacity: 0.25, weight: 3 });
}
pendingShape = layer;
addSpatialFeatureLayer(layer, activeType);
updateFeatureMeasurePreview();
isFeatureDrawing = false;
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)');
openNewFeatureModal(layer);
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);
// Fungsi membuat ikon SVG berwarna
function createColoredIcon(color) {
return L.divIcon({
className: "custom-marker",
html: `<svg width="30" height="42" viewBox="0 0 24 24" fill="${color}" stroke="white" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0C7.58 0 4 3.58 4 8c0 5.25 7 13 8 13s8-7.75 8-13c0-4.42-3.58-8-8-8zm0 11c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/>
</svg>`,
iconSize: [30, 42],
iconAnchor: [15, 42],
popupAnchor: [0, -40]
});
}
function setConnectionStatus(message, colorClass) {
const statusEl = document.getElementById('connectionStatus');
statusEl.innerText = message;
statusEl.className = `mt-1 text-[9px] font-bold uppercase tracking-wide ${colorClass}`;
}
function toggleMainPanel(forceState = null) {
const panelBody = document.getElementById('mainPanelBody');
const toggleIcon = document.getElementById('toggleMainPanelIcon');
const nextState = typeof forceState === 'boolean' ? forceState : !isMainPanelCollapsed;
isMainPanelCollapsed = nextState;
panelBody.classList.toggle('hidden', isMainPanelCollapsed);
if (isMainPanelCollapsed) {
toggleIcon.innerHTML = '<path d="m9 18 6-6-6-6"/>';
} else {
toggleIcon.innerHTML = '<path d="m15 18-6-6 6-6"/>';
}
}
function isSpbuBuka24Jam(status) {
return String(status || '').trim().toLowerCase() === 'ya';
}
function ensureSpbuLayerControl() {
if (spbuLayerControl) return;
spbuLayerControl = L.control.layers(null, {
'SPBU Buka 24 Jam': spbuBuka24JamLayer,
'SPBU Tidak Buka 24 Jam': spbuTidak24JamLayer
}, {
collapsed: true,
position: 'topleft'
}).addTo(map);
}
function ensureSpatialLayerControl() {
if (spatialLayerControl) return;
spatialLayerControl = L.control.layers(null, {
'Jalan': jalanLayerGroup,
'Polygon': polygonLayerGroup,
'Jalan Rusak': roadDamageLayer,
'Rumah Ibadah': rumahIbadahLayer,
'Penduduk Miskin': pendudukMiskinLayer,
'Verifikasi Lapangan': verifikasiLapanganLayer,
'Aduan Warga': aduanWargaLayer
}, {
collapsed: true,
position: 'topleft'
}).addTo(map);
}
function ensureLeafletUtilityControl() {
if (utilityControl) return;
utilityControl = L.control({ position: 'topleft' });
utilityControl.onAdd = function() {
const container = L.DomUtil.create('div', 'leaflet-bar');
function setUtilityModeActive(activeMode) {
rumahIbadahBtn.classList.toggle('active', activeMode === 'rumahIbadah');
pendudukMiskinBtn.classList.toggle('active', activeMode === 'pendudukMiskin');
choroplethBtn.classList.toggle('active', activeMode === 'choropleth');
roadDamageBtn.classList.toggle('active', activeMode === 'roadDamage');
verifikasiBtn.classList.toggle('active', activeMode === 'verifikasiLapangan');
aduanWargaBtn.classList.toggle('active', activeMode === 'aduanWarga');
}
const registryBtn = L.DomUtil.create('a', '', container);
registryBtn.href = '#';
registryBtn.id = 'leafletRegistryBtn';
registryBtn.title = 'Buka Registry';
registryBtn.innerHTML = 'Rg';
const layerBtn = L.DomUtil.create('a', '', container);
layerBtn.href = '#';
layerBtn.id = 'leafletLayerBtn';
layerBtn.title = 'Layer';
layerBtn.innerHTML = 'Ly';
const bantuanBtn = L.DomUtil.create('a', '', container);
bantuanBtn.href = '#';
bantuanBtn.id = 'leafletBantuanBtn';
bantuanBtn.title = 'Manajemen Bantuan';
bantuanBtn.innerHTML = 'Bn';
const rumahIbadahBtn = L.DomUtil.create('a', '', container);
rumahIbadahBtn.href = '#';
rumahIbadahBtn.id = 'leafletRumahIbadahBtn';
rumahIbadahBtn.title = 'Input Rumah Ibadah';
rumahIbadahBtn.innerHTML = 'Ri';
const pendudukMiskinBtn = L.DomUtil.create('a', '', container);
pendudukMiskinBtn.href = '#';
pendudukMiskinBtn.id = 'leafletPendudukMiskinBtn';
pendudukMiskinBtn.title = 'Input Penduduk Miskin';
pendudukMiskinBtn.innerHTML = 'Pm';
const choroplethBtn = L.DomUtil.create('a', '', container);
choroplethBtn.href = '#';
choroplethBtn.id = 'leafletChoroplethBtn';
choroplethBtn.title = 'Tampilkan Choropleth Kecamatan';
choroplethBtn.innerHTML = 'Ch';
const roadDamageBtn = L.DomUtil.create('a', '', container);
roadDamageBtn.href = '#';
roadDamageBtn.id = 'leafletRoadDamageBtn';
roadDamageBtn.title = 'Lapor Jalan Rusak';
roadDamageBtn.innerHTML = 'Jr';
const verifikasiBtn = L.DomUtil.create('a', '', container);
verifikasiBtn.href = '#';
verifikasiBtn.id = 'leafletVerifikasiBtn';
verifikasiBtn.title = 'Verifikasi Lapangan';
verifikasiBtn.innerHTML = 'Vf';
const aduanWargaBtn = L.DomUtil.create('a', '', container);
aduanWargaBtn.href = '#';
aduanWargaBtn.id = 'leafletAduanWargaBtn';
aduanWargaBtn.title = 'Aduan Warga';
aduanWargaBtn.innerHTML = 'Ad';
L.DomEvent.disableClickPropagation(container);
L.DomEvent.on(registryBtn, 'click', function(event) {
L.DomEvent.stop(event);
toggleInputRegistry();
});
L.DomEvent.on(layerBtn, 'click', function(event) {
L.DomEvent.stop(event);
toggleLayerDrawer();
});
L.DomEvent.on(bantuanBtn, 'click', function(event) {
L.DomEvent.stop(event);
toggleBantuanDrawer();
});
L.DomEvent.on(rumahIbadahBtn, 'click', function(event) {
L.DomEvent.stop(event);
setDataMode('rumahIbadah');
toggleInputRegistry(false);
setUtilityModeActive('rumahIbadah');
showMessage('Mode rumah ibadah aktif. Klik peta untuk menambah lokasi.', 'bg-indigo-600 text-white');
});
L.DomEvent.on(pendudukMiskinBtn, 'click', function(event) {
L.DomEvent.stop(event);
setDataMode('pendudukMiskin');
toggleInputRegistry(false);
setUtilityModeActive('pendudukMiskin');
showMessage('Mode penduduk miskin aktif. Klik peta untuk menambah lokasi.', 'bg-rose-600 text-white');
});
L.DomEvent.on(choroplethBtn, 'click', function(event) {
L.DomEvent.stop(event);
setDataMode('choropleth');
toggleInputRegistry(false);
setUtilityModeActive('choropleth');
showMessage('Mode choropleth aktif. Klik kecamatan untuk melihat populasi.', 'bg-slate-700 text-white');
});
L.DomEvent.on(roadDamageBtn, 'click', function(event) {
L.DomEvent.stop(event);
setDataMode('roadDamage');
toggleInputRegistry(false);
setUtilityModeActive('roadDamage');
showMessage('Mode laporan jalan rusak aktif. Klik peta untuk menandai lokasi.', 'bg-red-600 text-white');
});
L.DomEvent.on(verifikasiBtn, 'click', function(event) {
L.DomEvent.stop(event);
setDataMode('verifikasiLapangan');
toggleInputRegistry(false);
setUtilityModeActive('verifikasiLapangan');
showMessage('Mode verifikasi lapangan aktif. Klik peta untuk mencatat hasil cek.', 'bg-emerald-600 text-white');
});
L.DomEvent.on(aduanWargaBtn, 'click', function(event) {
L.DomEvent.stop(event);
setDataMode('aduanWarga');
toggleInputRegistry(false);
setUtilityModeActive('aduanWarga');
showMessage('Mode aduan warga aktif. Klik peta untuk mencatat laporan.', 'bg-fuchsia-600 text-white');
});
return container;
};
utilityControl.addTo(map);
}
function clearAllMarkers() {
spbuBuka24JamLayer.clearLayers();
spbuTidak24JamLayer.clearLayers();
locationLayerMap = {};
}
function createSnapshotHash(locations) {
const normalized = locations
.map(loc => ({
id: Number(loc.id),
nama: loc.nama,
nomor: loc.nomor,
buka_24jam: loc.buka_24jam,
latitude: Number(loc.latitude),
longitude: Number(loc.longitude)
}))
.sort((a, b) => a.id - b.id);
return JSON.stringify(normalized);
}
// Memuat semua data dari database
function loadMarkers(options = {}) {
const { silent = false, force = false } = options;
if (isSyncing) return;
if (!force && (document.hidden || suppressAutoReload)) return;
isSyncing = true;
if (!silent) {
setConnectionStatus("🔄 Memuat data...", "text-blue-500");
}
fetch('get_location.php')
.then(res => {
if (!res.ok) throw new Error('HTTP Error: ' + res.status);
return res.text(); // Ambil teks dulu untuk debug jika ada error PHP
})
.then(text => {
try {
const data = JSON.parse(text);
if (!Array.isArray(data)) {
if (data && data.status === 'error') {
throw new Error(data.message || 'Terjadi error pada server');
}
throw new Error('Format data bukan array');
}
locationRecords = data;
const currentHash = createSnapshotHash(data);
if (!force && currentHash === latestSnapshotHash) {
if (!silent) {
setConnectionStatus("✅ Terhubung ke database", "text-green-600");
}
return;
}
latestSnapshotHash = currentHash;
clearAllMarkers();
document.getElementById('totalCount').innerText = data.length;
data.forEach(loc => {
// Konversi lat/lng ke float (MySQL Decimal sering dikirim sebagai string)
const lat = parseFloat(loc.latitude);
const lng = parseFloat(loc.longitude);
if (isNaN(lat) || isNaN(lng)) return;
const isOpen24 = isSpbuBuka24Jam(loc.buka_24jam);
const color = isOpen24 ? '#16a34a' : '#dc2626';
const targetLayer = isOpen24 ? spbuBuka24JamLayer : spbuTidak24JamLayer;
const marker = L.marker([lat, lng], {
icon: createColoredIcon(color),
draggable: true
}).addTo(targetLayer);
locationLayerMap[loc.id] = marker;
marker.locationData = loc;
// Event Drag & Drop untuk Update Lokasi
marker.on('dragend', function(event) {
const newPos = event.target.getLatLng();
updateMarkerLocation(loc.id, newPos.lat, newPos.lng);
});
marker.bindPopup(getPopupContent(loc));
});
renderInputRegistry();
setConnectionStatus("✅ Terhubung ke database", "text-green-600");
} catch (e) {
console.error("JSON Parse Error:", e, "Raw text:", text);
throw new Error('Gagal membaca data dari server (JSON Error)');
}
})
.catch(err => {
console.error("Fetch Error:", err);
setConnectionStatus(`❌ ${err.message || 'Gagal terhubung ke database'}`, "text-red-600");
// Jika di Canvas Preview, berikan info spesifik
if (window.location.hostname === "") {
console.warn("Info: Jalankan proyek ini di Laragon (http://localhost/...) agar PHP dapat berjalan.");
}
})
.finally(() => {
isSyncing = false;
});
}
// HTML untuk isi Popup (View & Edit Mode)
function getPopupContent(loc) {
const statusClass = isSpbuBuka24Jam(loc.buka_24jam) ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700';
return `
<div class="w-full">
<!-- Mode Tampilan -->
<div id="view-mode-${loc.id}" class="p-4">
<h3 class="font-bold text-lg text-slate-800 leading-tight">${loc.nama}</h3>
<p class="text-sm text-slate-600 mt-1 italic">Telp: ${loc.nomor}</p>
<div class="mt-2">
<span class="inline-block px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wide ${statusClass}">
24 Jam: ${loc.buka_24jam}
</span>
</div>
<div class="mt-4 flex gap-2 border-t pt-3">
<button onclick="toggleEdit(${loc.id})" class="flex-1 bg-indigo-50 text-indigo-600 py-2 rounded text-xs font-bold hover:bg-indigo-100 transition">Edit</button>
<button onclick="deleteData(${loc.id})" class="flex-1 bg-red-50 text-red-600 py-2 rounded text-xs font-bold hover:bg-red-100 transition">Hapus</button>
</div>
</div>
<!-- Mode Edit -->
<div id="edit-mode-${loc.id}" class="hidden p-4 bg-slate-50">
<h4 class="font-bold text-[10px] text-slate-400 mb-3 uppercase tracking-widest border-b pb-1">Update Informasi</h4>
<div class="space-y-3">
<input type="text" id="edit_nama_${loc.id}" value="${loc.nama}" class="w-full border p-2 text-sm rounded outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm">
<input type="text" id="edit_nomor_${loc.id}" value="${loc.nomor}" class="w-full border p-2 text-sm rounded outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm">
<select id="edit_status_${loc.id}" class="w-full border p-2 text-sm rounded outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm">
<option value="Ya" ${loc.buka_24jam === 'Ya' ? 'selected' : ''}>Buka 24 Jam</option>
<option value="Tidak" ${loc.buka_24jam === 'Tidak' ? 'selected' : ''}>Tidak</option>
</select>
<div class="flex gap-2 pt-1">
<button onclick="saveEdit(${loc.id})" class="flex-1 bg-green-600 text-white py-2 rounded text-xs font-bold shadow-md hover:bg-green-700 transition">Simpan</button>
<button onclick="toggleEdit(${loc.id})" class="flex-1 bg-slate-300 text-slate-700 py-2 rounded text-xs font-bold hover:bg-slate-400 transition">Batal</button>
</div>
</div>
</div>
</div>
`;
}
// Fungsi Update Koordinat (Drag)
function updateMarkerLocation(id, lat, lng) {
const formData = new FormData();
formData.append('id', id);
formData.append('lat', lat);
formData.append('lng', lng);
fetch('update_coords.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'update_coords.php'))
.then(data => {
if(data.status === 'success') showMessage("📍 Lokasi diperbarui!", "bg-green-600 text-white");
})
.catch(err => showMessage("Gagal simpan lokasi", "bg-red-600 text-white"));
}
// Fungsi Hapus Data
function deleteData(id) {
if(!confirm("Hapus data ini dari database?")) return;
const formData = new FormData();
formData.append('id', id);
fetch('delete_location.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'delete_location.php'))
.then(data => {
if(data.status === 'success') {
showMessage("🗑️ Data berhasil dihapus", "bg-orange-600 text-white");
loadMarkers({ force: true });
}
})
.catch(err => showMessage("Gagal hapus data", "bg-red-600 text-white"));
}
function toggleEdit(id) {
document.getElementById(`view-mode-${id}`).classList.toggle('hidden');
document.getElementById(`edit-mode-${id}`).classList.toggle('hidden');
// Tahan auto refresh saat ada popup edit terbuka agar input tidak tertimpa.
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)');
}
// Fungsi Update Atribut
function saveEdit(id) {
const nama = document.getElementById(`edit_nama_${id}`).value;
const nomor = document.getElementById(`edit_nomor_${id}`).value;
const status = document.getElementById(`edit_status_${id}`).value;
if(!nama || !nomor) return alert("Field tidak boleh kosong!");
const formData = new FormData();
formData.append('id', id);
formData.append('nama', nama);
formData.append('nomor', nomor);
formData.append('status', status);
fetch('update_details.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'update_details.php'))
.then(data => {
if(data.status === 'success') {
suppressAutoReload = false;
showMessage("✅ Data berhasil diperbarui", "bg-indigo-600 text-white");
loadMarkers({ force: true });
}
})
.catch(err => showMessage("Gagal update data", "bg-red-600 text-white"));
}
// Klik Peta untuk Input Baru
map.on('click', function(e) {
if (isFeatureDrawing) return;
if (appMode === 'roadDamage') {
openRoadDamageModal(e.latlng.lat, e.latlng.lng);
return;
}
if (appMode === 'verifikasiLapangan') {
openVerifikasiLapanganModal(e.latlng.lat, e.latlng.lng);
return;
}
if (appMode === 'aduanWarga') {
openAduanWargaModal(e.latlng.lat, e.latlng.lng);
return;
}
if (appMode === 'rumahIbadah') {
openRumahIbadahModal(e.latlng.lat, e.latlng.lng);
return;
}
if (appMode === 'pendudukMiskin') {
openPendudukMiskinModal(e.latlng.lat, e.latlng.lng);
return;
}
if (appMode !== 'point') return;
openNewLocationPopup(e.latlng.lat, e.latlng.lng);
});
function saveNew(lat, lng) {
const nama = document.getElementById('new_nama').value;
const nomor = document.getElementById('new_nomor').value;
const status = document.getElementById('new_status').value;
if(!nama || !nomor) return alert("Mohon lengkapi semua field.");
const formData = new FormData();
formData.append('nama', nama);
formData.append('nomor', nomor);
formData.append('status', status);
formData.append('lat', lat);
formData.append('lng', lng);
fetch('save_location.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'save_location.php'))
.then(data => {
if(data.status === 'success') {
showMessage("✨ Berhasil disimpan ke database!", "bg-green-600 text-white");
map.closePopup();
loadMarkers({ force: true });
}
})
.catch(err => showMessage("Gagal menyimpan ke server", "bg-red-600 text-white"));
}
function startAutoRefresh() {
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
autoRefreshTimer = setInterval(() => {
loadMarkers({ silent: true });
loadFeatures();
loadRumahIbadah();
loadPendudukMiskin();
loadVerifikasiLapangan();
loadAduanWarga();
}, REFRESH_INTERVAL_MS);
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
loadMarkers({ silent: true, force: true });
loadFeatures(true);
loadRumahIbadah(true);
loadPendudukMiskin(true);
loadVerifikasiLapangan(true);
loadAduanWarga(true);
}
});
// Fungsi Notifikasi Toast
function showMessage(text, classes) {
const el = document.getElementById('statusMsg');
el.innerText = text;
el.className = `custom-msg p-4 rounded-lg shadow-2xl font-medium ${classes}`;
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 3000);
}
async function parseJsonResponse(res, endpointName) {
const text = await res.text();
if (!text || !text.trim()) {
throw new Error(`Respons kosong dari ${endpointName}`);
}
let data;
try {
data = JSON.parse(text);
} catch (error) {
throw new Error(`Respons ${endpointName} bukan JSON valid: ${text.slice(0, 160)}`);
}
if (!res.ok) {
throw new Error(data.message || `HTTP ${res.status} dari ${endpointName}`);
}
return data;
}
window.onload = function () {
ensureLeafletUtilityControl();
bindLayerDrawerEvents();
updateDrawControl();
setDataMode('view');
enforceRegistryDateRange(false);
loadMarkers({ force: true });
loadFeatures(true);
loadRumahIbadah(true);
loadPendudukMiskin(true);
loadRoadDamage(true);
loadVerifikasiLapangan(true);
loadAduanWarga(true);
loadExternalRoadNetwork(true);
startAutoRefresh();
};
const toggleEditModeBtn = document.getElementById('toggleEditModeBtn');
if (toggleEditModeBtn) {
toggleEditModeBtn.addEventListener('click', () => {
setEditModeEnabled(!isEditModeEnabled);
});
}
document.getElementById('modeViewBtn').addEventListener('click', () => setDataMode('view'));
document.getElementById('modePointBtn').addEventListener('click', () => setDataMode('point'));
document.getElementById('modePolylineBtn').addEventListener('click', () => setDataMode('polyline'));
document.getElementById('modePolygonBtn').addEventListener('click', () => setDataMode('polygon'));
document.getElementById('modeRumahIbadahBtn').addEventListener('click', () => setDataMode('rumahIbadah'));
document.getElementById('modePendudukMiskinBtn').addEventListener('click', () => setDataMode('pendudukMiskin'));
document.getElementById('modeChoroplethBtn').addEventListener('click', () => setDataMode('choropleth'));
document.getElementById('modeRoadDamageBtn').addEventListener('click', () => setDataMode('roadDamage'));
const modeVerifikasiBtn = document.getElementById('modeVerifikasiBtn');
if (modeVerifikasiBtn) {
modeVerifikasiBtn.addEventListener('click', () => setDataMode('verifikasiLapangan'));
}
const modeAduanWargaBtn = document.getElementById('modeAduanWargaBtn');
if (modeAduanWargaBtn) {
modeAduanWargaBtn.addEventListener('click', () => setDataMode('aduanWarga'));
}
const miniModePointBtn = document.getElementById('miniModePointBtn');
if (miniModePointBtn) {
miniModePointBtn.addEventListener('click', () => {
setDataMode('point');
showMessage('Klik peta untuk menambah point SPBU', 'bg-indigo-600 text-white');
});
}
const miniModePolylineBtn = document.getElementById('miniModePolylineBtn');
if (miniModePolylineBtn) {
miniModePolylineBtn.addEventListener('click', () => {
setDataMode('polyline');
startDraw();
});
}
const miniModePolygonBtn = document.getElementById('miniModePolygonBtn');
if (miniModePolygonBtn) {
miniModePolygonBtn.addEventListener('click', () => {
setDataMode('polygon');
startDraw();
});
}
const miniModeRumahIbadahBtn = document.getElementById('miniModeRumahIbadahBtn');
if (miniModeRumahIbadahBtn) {
miniModeRumahIbadahBtn.addEventListener('click', () => {
setDataMode('rumahIbadah');
showMessage('Klik peta untuk menambah rumah ibadah', 'bg-indigo-600 text-white');
});
}
const miniModePendudukMiskinBtn = document.getElementById('miniModePendudukMiskinBtn');
if (miniModePendudukMiskinBtn) {
miniModePendudukMiskinBtn.addEventListener('click', () => {
setDataMode('pendudukMiskin');
showMessage('Klik peta untuk menambah penduduk miskin', 'bg-indigo-600 text-white');
});
}
const miniModeVerifikasiBtn = document.getElementById('miniModeVerifikasiBtn');
if (miniModeVerifikasiBtn) {
miniModeVerifikasiBtn.addEventListener('click', () => {
setDataMode('verifikasiLapangan');
showMessage('Klik peta untuk verifikasi lapangan', 'bg-emerald-600 text-white');
});
}
const miniModeAduanWargaBtn = document.getElementById('miniModeAduanWargaBtn');
if (miniModeAduanWargaBtn) {
miniModeAduanWargaBtn.addEventListener('click', () => {
setDataMode('aduanWarga');
showMessage('Klik peta untuk membuat aduan warga', 'bg-fuchsia-600 text-white');
});
}
const toggleRegistryDrawerBtn = document.getElementById('toggleRegistryDrawerBtn');
if (toggleRegistryDrawerBtn) {
toggleRegistryDrawerBtn.addEventListener('click', () => toggleInputRegistry());
}
const toggleBantuanDrawerBtn = document.getElementById('toggleBantuanDrawerBtn');
if (toggleBantuanDrawerBtn) {
toggleBantuanDrawerBtn.addEventListener('click', () => toggleBantuanDrawer());
}
const registryDrawerCloseBtn = document.getElementById('registryDrawerCloseBtn');
if (registryDrawerCloseBtn) {
registryDrawerCloseBtn.addEventListener('click', () => toggleInputRegistry(false));
}
const bantuanDrawerCloseBtn = document.getElementById('bantuanDrawerCloseBtn');
if (bantuanDrawerCloseBtn) {
bantuanDrawerCloseBtn.addEventListener('click', () => toggleBantuanDrawer(false));
}
const bantuanRumahFilter = document.getElementById('bantuanRumahFilter');
if (bantuanRumahFilter) {
bantuanRumahFilter.addEventListener('change', renderBantuanDrawer);
}
const bantuanStatusFilter = document.getElementById('bantuanStatusFilter');
if (bantuanStatusFilter) {
bantuanStatusFilter.addEventListener('change', renderBantuanDrawer);
}
const bantuanSearchQuery = document.getElementById('bantuanSearchQuery');
if (bantuanSearchQuery) {
bantuanSearchQuery.addEventListener('input', renderBantuanDrawer);
}
const toggleMainPanelBtn = document.getElementById('toggleMainPanelBtn');
if (toggleMainPanelBtn) {
toggleMainPanelBtn.addEventListener('click', () => toggleMainPanel());
}
document.getElementById('drawStatus').addEventListener('change', () => {
const type = document.getElementById('featureType').value;
document.getElementById('featureStatus').value = document.getElementById('drawStatus').value;
if (type === 'polyline' || type === 'polygon') {
updateDrawControl(type);
}
});
document.getElementById('runActionBtn').addEventListener('click', () => {
if (appMode === 'polyline' || appMode === 'polygon') {
startDraw();
return;
}
if (appMode === 'point') {
showMessage('Klik peta untuk menambah point SPBU', 'bg-indigo-600 text-white');
}
});
document.getElementById('clearDraftBtn').addEventListener('click', () => {
if (appMode === 'polyline' || appMode === 'polygon') {
resetSpatialDraft(appMode);
}
});
document.getElementById('closeFeatureModalBtn').addEventListener('click', closeFeatureEditor);
document.getElementById('saveFeatureAttrBtn').addEventListener('click', saveFeatureAttributes);
document.getElementById('toggleFeatureGeometryBtn').addEventListener('click', () => {
const id = Number(document.getElementById('editFeatureId').value);
toggleFeatureGeometryEditing(id);
});
document.getElementById('saveFeatureGeometryBtn').addEventListener('click', () => {
const id = Number(document.getElementById('editFeatureId').value);
saveFeatureGeometry(id);
});
document.getElementById('deleteFeatureBtn').addEventListener('click', () => {
const id = Number(document.getElementById('editFeatureId').value);
deleteFeature(id);
});
const registryUi = getRegistryUi();
if (registryUi.type) {
registryUi.type.addEventListener('change', renderInputRegistry);
}
if (registryUi.from) {
registryUi.from.addEventListener('change', () => {
enforceRegistryDateRange(true);
renderInputRegistry();
});
}
if (registryUi.to) {
registryUi.to.addEventListener('change', () => {
enforceRegistryDateRange(true);
renderInputRegistry();
});
}
const registryApplyFilterBtn = document.getElementById('registryApplyFilterBtn');
if (registryApplyFilterBtn) {
registryApplyFilterBtn.addEventListener('click', () => {
enforceRegistryDateRange(true);
renderInputRegistry();
});
}
const registryResetFilterBtn = document.getElementById('registryResetFilterBtn');
if (registryResetFilterBtn) {
registryResetFilterBtn.addEventListener('click', () => {
const ui = getRegistryUi();
if (ui.search) ui.search.value = '';
if (ui.type) ui.type.value = 'all';
if (ui.from) ui.from.value = '';
if (ui.to) ui.to.value = '';
enforceRegistryDateRange(false);
renderInputRegistry();
});
}
if (registryUi.search) {
registryUi.search.addEventListener('input', () => {
renderInputRegistry();
});
registryUi.search.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
renderInputRegistry();
}
});
}
document.getElementById('featureModal').addEventListener('click', (event) => {
if (event.target.id === 'featureModal') closeFeatureEditor();
});
document.getElementById('closeNewFeatureModalBtn').addEventListener('click', () => closeNewFeatureModal(true));
document.getElementById('cancelNewFeatureBtn').addEventListener('click', () => closeNewFeatureModal(true));
document.getElementById('saveNewFeatureBtn').addEventListener('click', saveNewFeatureFromModal);
document.getElementById('newFeatureModal').addEventListener('click', (event) => {
if (event.target.id === 'newFeatureModal') closeNewFeatureModal(true);
});
document.getElementById('saveRumahIbadahBtn').addEventListener('click', saveRumahIbadah);
document.getElementById('cancelRumahIbadahBtn').addEventListener('click', closeRumahIbadahModal);
document.getElementById('closeRumahIbadahModalBtn').addEventListener('click', closeRumahIbadahModal);
document.getElementById('rumahIbadahRadius').addEventListener('input', (event) => syncRumahIbadahRadius(event.target.value));
document.getElementById('rumahIbadahModal').addEventListener('click', (event) => {
if (event.target.id === 'rumahIbadahModal') closeRumahIbadahModal();
});
document.getElementById('savePendudukMiskinBtn').addEventListener('click', savePendudukMiskin);
document.getElementById('cancelPendudukMiskinBtn').addEventListener('click', closePendudukMiskinModal);
document.getElementById('closePendudukMiskinModalBtn').addEventListener('click', closePendudukMiskinModal);
document.getElementById('pendudukMiskinModal').addEventListener('click', (event) => {
if (event.target.id === 'pendudukMiskinModal') closePendudukMiskinModal();
});
document.getElementById('saveVerifikasiLapanganBtn').addEventListener('click', saveVerifikasiLapangan);
document.getElementById('cancelVerifikasiLapanganBtn').addEventListener('click', closeVerifikasiLapanganModal);
document.getElementById('closeVerifikasiLapanganModalBtn').addEventListener('click', closeVerifikasiLapanganModal);
document.getElementById('verifikasiLapanganModal').addEventListener('click', (event) => {
if (event.target.id === 'verifikasiLapanganModal') closeVerifikasiLapanganModal();
});
document.getElementById('saveAduanWargaBtn').addEventListener('click', saveAduanWarga);
document.getElementById('cancelAduanWargaBtn').addEventListener('click', closeAduanWargaModal);
document.getElementById('closeAduanWargaModalBtn').addEventListener('click', closeAduanWargaModal);
document.getElementById('aduanWargaModal').addEventListener('click', (event) => {
if (event.target.id === 'aduanWargaModal') closeAduanWargaModal();
});
// Road Damage Modal Event Listeners
document.getElementById('saveRoadDamageBtn').addEventListener('click', saveRoadDamage);
document.getElementById('cancelRoadDamageBtn').addEventListener('click', closeRoadDamageModal);
document.getElementById('closeRoadDamageModalBtn').addEventListener('click', closeRoadDamageModal);
document.getElementById('roadDamageImage').addEventListener('change', (event) => {
const file = event.target.files[0];
const preview = document.getElementById('imagePreview');
const previewImg = document.getElementById('previewImg');
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
previewImg.src = e.target.result;
preview.classList.remove('hidden');
};
reader.readAsDataURL(file);
} else {
preview.classList.add('hidden');
}
});
document.getElementById('roadDamageModal').addEventListener('click', (event) => {
if (event.target.id === 'roadDamageModal') closeRoadDamageModal();
});
</script>
</body>
</html>