diff --git a/api/get_all_bantuan.php b/kemiskinan/api/get_all_bantuan.php similarity index 100% rename from api/get_all_bantuan.php rename to kemiskinan/api/get_all_bantuan.php diff --git a/api/get_fasilitas_publik.php b/kemiskinan/api/get_fasilitas_publik.php similarity index 100% rename from api/get_fasilitas_publik.php rename to kemiskinan/api/get_fasilitas_publik.php diff --git a/api/get_penduduk.php b/kemiskinan/api/get_penduduk.php similarity index 100% rename from api/get_penduduk.php rename to kemiskinan/api/get_penduduk.php diff --git a/api/get_point.php b/kemiskinan/api/get_point.php similarity index 100% rename from api/get_point.php rename to kemiskinan/api/get_point.php diff --git a/api/statistik.php b/kemiskinan/api/statistik.php similarity index 100% rename from api/statistik.php rename to kemiskinan/api/statistik.php diff --git a/api/update_status_bantuan.php b/kemiskinan/api/update_status_bantuan.php similarity index 100% rename from api/update_status_bantuan.php rename to kemiskinan/api/update_status_bantuan.php diff --git a/api_admin.php b/kemiskinan/api_admin.php similarity index 100% rename from api_admin.php rename to kemiskinan/api_admin.php diff --git a/assets/animasi-lokasi.json b/kemiskinan/assets/animasi-lokasi.json similarity index 100% rename from assets/animasi-lokasi.json rename to kemiskinan/assets/animasi-lokasi.json diff --git a/assets/css/style.css b/kemiskinan/assets/css/style.css similarity index 100% rename from assets/css/style.css rename to kemiskinan/assets/css/style.css diff --git a/assets/img/map-bg.png b/kemiskinan/assets/img/map-bg.png similarity index 100% rename from assets/img/map-bg.png rename to kemiskinan/assets/img/map-bg.png diff --git a/assets/js/app.js b/kemiskinan/assets/js/app.js similarity index 100% rename from assets/js/app.js rename to kemiskinan/assets/js/app.js diff --git a/assets/js/app.js.backup b/kemiskinan/assets/js/app.js.backup similarity index 100% rename from assets/js/app.js.backup rename to kemiskinan/assets/js/app.js.backup diff --git a/dashboard.php b/kemiskinan/dashboard.php similarity index 100% rename from dashboard.php rename to kemiskinan/dashboard.php diff --git a/fasilitas_publik/hapus.php b/kemiskinan/fasilitas_publik/hapus.php similarity index 100% rename from fasilitas_publik/hapus.php rename to kemiskinan/fasilitas_publik/hapus.php diff --git a/fasilitas_publik/simpan.php b/kemiskinan/fasilitas_publik/simpan.php similarity index 100% rename from fasilitas_publik/simpan.php rename to kemiskinan/fasilitas_publik/simpan.php diff --git a/fasilitas_publik/update.php b/kemiskinan/fasilitas_publik/update.php similarity index 100% rename from fasilitas_publik/update.php rename to kemiskinan/fasilitas_publik/update.php diff --git a/index.php b/kemiskinan/index.php similarity index 98% rename from index.php rename to kemiskinan/index.php index 379c3d4..fac3540 100644 --- a/index.php +++ b/kemiskinan/index.php @@ -4,26 +4,17 @@ // ============================================================ session_start(); -$modul = $_GET['modul'] ?? 'utuh'; +$modul = $_GET['modul'] ?? 'bantuan'; -// TAHAP 1: Modul publik diizinkan tanpa login -$public_modules = ['spbu', 'tematik', 'digitasi']; -$is_public_access = in_array($modul, $public_modules); - -if ($is_public_access) { - // Jika akses publik, berikan role petugas agar pengunjung dapat mengedit data - $currentUser = $_SESSION['username'] ?? 'Pengunjung Kelas'; - $currentRole = $_SESSION['role'] ?? 'petugas'; -} else { - // Modul privat wajib login - if (!isset($_SESSION['user_id'])) { - header('Location: login.php'); - exit; - } - $currentUser = $_SESSION['username']; - $currentRole = $_SESSION['role']; +// Aplikasi Kemiskinan Wajib Login Sepenuhnya +if (!isset($_SESSION['user_id'])) { + header('Location: login.php'); + exit; } +$currentUser = $_SESSION['username']; +$currentRole = $_SESSION['role']; + include 'koneksi.php'; // Eksekusi ALTER TABLE secara diam-diam jika kolom belum ada diff --git a/koneksi.php b/kemiskinan/koneksi.php similarity index 100% rename from koneksi.php rename to kemiskinan/koneksi.php diff --git a/login.php b/kemiskinan/login.php similarity index 87% rename from login.php rename to kemiskinan/login.php index 5d61c11..2bd24d0 100644 --- a/login.php +++ b/kemiskinan/login.php @@ -134,27 +134,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {

- -
-

- - + +
+

+ + - Project Kelas (Akses Publik): + Sistem Pengamanan Tertutup

-
- - - Peta Lokasi SPBU - - - 🗺️ - Peta Tematik - - - 📐 - Digitasi Lahan Kota - +
+
+
+

Database Terpusat: Melacak data penduduk miskin berdasarkan NIK secara real-time.

+
+
+
+

Tracking Bantuan: Verifikasi silang penyaluran BLT dan PKH di berbagai wilayah.

+
+
+
+

Analisis Spasial: Pemetaan kantong kemiskinan untuk kebijakan tepat sasaran.

+
diff --git a/logout.php b/kemiskinan/logout.php similarity index 100% rename from logout.php rename to kemiskinan/logout.php diff --git a/penduduk_miskin/hapus.php b/kemiskinan/penduduk_miskin/hapus.php similarity index 100% rename from penduduk_miskin/hapus.php rename to kemiskinan/penduduk_miskin/hapus.php diff --git a/penduduk_miskin/simpan.php b/kemiskinan/penduduk_miskin/simpan.php similarity index 100% rename from penduduk_miskin/simpan.php rename to kemiskinan/penduduk_miskin/simpan.php diff --git a/penduduk_miskin/update.php b/kemiskinan/penduduk_miskin/update.php similarity index 100% rename from penduduk_miskin/update.php rename to kemiskinan/penduduk_miskin/update.php diff --git a/penduduk_miskin/update_lokasi.php b/kemiskinan/penduduk_miskin/update_lokasi.php similarity index 100% rename from penduduk_miskin/update_lokasi.php rename to kemiskinan/penduduk_miskin/update_lokasi.php diff --git a/register.php b/kemiskinan/register.php similarity index 100% rename from register.php rename to kemiskinan/register.php diff --git a/uploads/.gitkeep b/kemiskinan/uploads/.gitkeep similarity index 100% rename from uploads/.gitkeep rename to kemiskinan/uploads/.gitkeep diff --git a/uploads/foto_kk_6a2a216972b273.24659129.png b/kemiskinan/uploads/foto_kk_6a2a216972b273.24659129.png similarity index 100% rename from uploads/foto_kk_6a2a216972b273.24659129.png rename to kemiskinan/uploads/foto_kk_6a2a216972b273.24659129.png diff --git a/uploads/foto_rumah_6a2a2169722485.32558108.png b/kemiskinan/uploads/foto_rumah_6a2a2169722485.32558108.png similarity index 100% rename from uploads/foto_rumah_6a2a2169722485.32558108.png rename to kemiskinan/uploads/foto_rumah_6a2a2169722485.32558108.png diff --git a/uploads/foto_rumah_6a2a30a32dd215.01943630.png b/kemiskinan/uploads/foto_rumah_6a2a30a32dd215.01943630.png similarity index 100% rename from uploads/foto_rumah_6a2a30a32dd215.01943630.png rename to kemiskinan/uploads/foto_rumah_6a2a30a32dd215.01943630.png diff --git a/publik/assets/animasi-lokasi.json b/publik/assets/animasi-lokasi.json new file mode 100644 index 0000000..f9ef02c --- /dev/null +++ b/publik/assets/animasi-lokasi.json @@ -0,0 +1 @@ +{"v":"4.8.0","meta":{"g":"LottieFiles AE ","a":"","k":"","d":"","tc":""},"fr":30,"ip":0,"op":60,"w":60,"h":60,"nm":"position","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"logo","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-4.211,2.251,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[123.867,123.867,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[12.575,12.575],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"椭圆路径 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1.288,-5.462],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"椭圆 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Logo","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.434,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[29.633,28.223,0],"to":[0,-1.667,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.566,"y":0},"t":5,"s":[29.633,18.223,0],"to":[0,0,0],"ti":[0,-1.167,0]},{"i":{"x":0.35,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[29.633,28.223,0],"to":[0,1.167,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.642,"y":0},"t":13,"s":[29.633,25.223,0],"to":[0,0,0],"ti":[0,-0.5,0]},{"t":16,"s":[29.633,28.223,0]}],"ix":2},"a":{"a":0,"k":[-3.071,2.062,0],"ix":1},"s":{"a":0,"k":[80.732,80.732,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.005,0.07],[0.199,-17.979],[0,0],[0.131,18.108]],"o":[[16.319,-0.067],[-0.187,16.928],[0,0],[-0.133,-18.386]],"v":[[0,-32.17],[30.464,0.635],[0,63.943],[-30.782,0.724]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.171487890505,0.035294113907,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-2.539,-3.488],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[48.793,48.793],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"椭圆 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":21,"s":[80]},{"t":31,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30,49,0],"ix":2},"a":{"a":0,"k":[141.269,22.211,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.554,0.554],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":0,"s":[1.481,0.535]},{"t":31,"s":[50.481,18.246]}],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"椭圆路径 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.020023071065,0.850980392157,0,1],"ix":3},"o":{"a":0,"k":91.757,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[141.269,22.211],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"椭圆 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":30,"s":[80]},{"t":40,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30,49,0],"ix":2},"a":{"a":0,"k":[141.269,22.211,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":9,"s":[1.481,0.535]},{"t":40,"s":[50.481,18.246]}],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"椭圆路径 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.020023071065,0.850980392157,0,1],"ix":3},"o":{"a":0,"k":91.757,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[141.269,22.211],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"椭圆 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":19,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":40,"s":[80]},{"t":50,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30,49,0],"ix":2},"a":{"a":0,"k":[141.269,22.211,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":19,"s":[1.481,0.535]},{"t":50,"s":[50.481,18.246]}],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"椭圆路径 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.020023071065,0.850980392157,0,1],"ix":3},"o":{"a":0,"k":91.757,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[141.269,22.211],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"椭圆 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":600,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/publik/assets/css/style.css b/publik/assets/css/style.css new file mode 100644 index 0000000..55a3bf4 --- /dev/null +++ b/publik/assets/css/style.css @@ -0,0 +1,2080 @@ +/* ============================================================ + WebGIS - style.css + Design: Red & White Theme (Merah Putih) + Font: Plus Jakarta Sans + JetBrains Mono +============================================================ */ + +/* --- Reset & Variables ---------------------------------- */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --sidebar-w: 300px; + --sidebar-bg: #ffffff; + --sidebar-border: rgba(0,0,0,0.08); + --sidebar-text: #334155; + --sidebar-text-muted: #64748b; + --sidebar-hover: #f1f5f9; + --accent: #e11d48; /* Merah cantik */ + --accent-glow: rgba(225, 29, 72, 0.25); + --green: #16a34a; + --red: #e11d48; + --amber: #d97706; + --surface: #ffffff; + --surface-2: #f8fafc; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --shadow-md: 0 4px 24px rgba(0,0,0,0.08); + --shadow-lg: 0 8px 40px rgba(0,0,0,0.12); + --font: 'Plus Jakarta Sans', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', monospace; + --transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1); +} + +html, body { height: 100%; overflow: hidden; } + +body { + font-family: var(--font); + font-size: 14px; + background: #f1f5f9; + color: var(--sidebar-text); +} + +/* --- Scrollbar ------------------------------------------ */ +::-webkit-scrollbar { width: 4px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 4px; } + +/* ============================================================ + SIDEBAR +============================================================ */ +.sidebar { + position: fixed; + left: 0; top: 0; bottom: 0; + width: var(--sidebar-w); + height: 100vh; + max-height: 100vh; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-right: 1px solid var(--sidebar-border); + display: flex; + flex-direction: column; + z-index: 900; + overflow: hidden; /* Changed: overflow handled by sidebar-scrollable child */ + transition: transform var(--transition), width var(--transition); + box-shadow: 2px 0 20px rgba(0,0,0,0.03); +} + +.sidebar.collapsed { + transform: translateX(calc(-1 * var(--sidebar-w))); +} + +/* Sidebar Header */ +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 16px 16px; + border-bottom: 1px solid var(--sidebar-border); + flex-shrink: 0; +} + +.sidebar-logo { display: flex; align-items: center; gap: 10px; } + +.logo-icon { + width: 38px; height: 38px; + background: linear-gradient(135deg, var(--accent), #be123c); + border-radius: 10px; + display: flex; align-items: center; justify-content: center; + color: white; + box-shadow: 0 4px 12px var(--accent-glow); + flex-shrink: 0; +} + +.logo-title { + display: block; + font-size: 16px; font-weight: 800; + color: #0f172a; letter-spacing: -0.3px; +} + +.logo-sub { + display: block; + font-size: 10.5px; font-weight: 500; + color: var(--sidebar-text-muted); + letter-spacing: 0.5px; text-transform: uppercase; +} + +.sidebar-toggle { + width: 30px; height: 30px; + border: 1px solid var(--sidebar-border); + border-radius: 8px; background: transparent; + color: var(--sidebar-text-muted); cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: all var(--transition); +} +.sidebar-toggle:hover { background: var(--sidebar-hover); color: #0f172a; border-color: rgba(0,0,0,0.15); } + +/* Sidebar Section */ +.sidebar-section { + padding: 10px 16px; + border-bottom: 1px solid var(--sidebar-border); +} + +.section-label { + font-size: 10px; font-weight: 700; + color: var(--sidebar-text-muted); + letter-spacing: 1.2px; text-transform: uppercase; + margin-bottom: 8px; +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + margin-bottom: 8px; +} + +.stat-card { + background: var(--surface-2); + border-radius: var(--radius-md); + padding: 8px 10px; + display: flex; align-items: center; gap: 8px; + border: 1px solid var(--sidebar-border); + transition: transform var(--transition); +} +.stat-card:hover { transform: translateY(-1px); border-color: rgba(0,0,0,0.15); } + +.stat-icon { font-size: 18px; line-height: 1; } + +.stat-value { + font-size: 22px; font-weight: 800; + color: #0f172a; line-height: 1; + font-variant-numeric: tabular-nums; +} +.stat-label { font-size: 10px; color: var(--sidebar-text-muted); margin-top: 2px; font-weight: 600; } + +.stat-blue { border-left: 3px solid #3b82f6; } +.stat-amber { border-left: 3px solid var(--amber); } +.stat-green { border-left: 3px solid var(--green); } +.stat-red { border-left: 3px solid var(--red); } + +/* Coverage Bar */ +.coverage-bar-wrap { } +.coverage-bar-header { + display: flex; justify-content: space-between; + font-size: 11.5px; color: var(--sidebar-text); + margin-bottom: 6px; font-weight: 600; +} +.coverage-bar-header strong { color: var(--accent); font-size: 13px; } + +.coverage-bar-track { + height: 6px; background: rgba(0,0,0,0.06); + border-radius: 99px; overflow: hidden; +} +.coverage-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--red), #fb7185); + border-radius: 99px; + transition: width 0.8s cubic-bezier(0.4,0,0.2,1); +} + +/* Tool Grid */ +.tool-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + margin-bottom: 6px; +} + +.tool-btn { + display: flex; flex-direction: column; + align-items: center; justify-content: center; + gap: 2px; padding: 6px 4px; + background: var(--surface); + border: 1px solid var(--sidebar-border); + border-radius: var(--radius-sm); + color: var(--sidebar-text); + cursor: pointer; font-family: var(--font); + font-size: 10px; font-weight: 600; + transition: all var(--transition); + letter-spacing: 0.3px; +} +.tool-btn:hover { background: var(--surface-2); border-color: var(--accent); color: var(--accent); } +.tool-btn.active { + background: var(--accent); + border-color: var(--accent); + color: white; + box-shadow: 0 2px 12px var(--accent-glow); +} +.tool-icon { font-size: 18px; } + +.help-text { + font-size: 10px; color: var(--sidebar-text-muted); + line-height: 1.4; padding: 6px 8px; + background: var(--surface-2); + border-radius: var(--radius-sm); + border: 1px solid var(--sidebar-border); +} + +/* Layer List */ +.layer-list { display: flex; flex-direction: column; gap: 4px; } + +.layer-item { + display: flex; align-items: center; gap: 8px; + padding: 4px 6px; + border-radius: var(--radius-sm); + cursor: pointer; transition: background var(--transition); + font-size: 12.5px; color: #0f172a; font-weight: 500; + user-select: none; +} +.layer-item:hover { background: var(--sidebar-hover); } +.layer-item input[type="checkbox"] { width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent); } + +.layer-dot { + width: 10px; height: 10px; + border-radius: 50%; flex-shrink: 0; +} + +/* Legend */ +.legend-list { display: flex; flex-direction: column; gap: 4px; } +.legend-item { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--sidebar-text); font-weight: 500; padding: 2px 0; } +.legend-marker { + width: 12px; height: 12px; + background: #e11d48; border-radius: 50%; + border: 2px solid white; + box-shadow: 0 0 0 2px rgba(225,29,72,0.3); + flex-shrink: 0; +} +.legend-circle { + width: 14px; height: 14px; + border-radius: 50%; flex-shrink: 0; +} +.legend-line { + width: 22px; height: 3px; + border-radius: 2px; flex-shrink: 0; +} + +/* Sidebar Footer */ +.sidebar-footer { + margin-top: auto; + padding: 12px 16px; + border-top: 1px solid var(--sidebar-border); + font-size: 10.5px; color: var(--sidebar-text-muted); + font-weight: 500; + display: flex; align-items: center; justify-content: space-between; +} + +.btn-logout-footer { + font-size: 10.5px; + color: var(--accent); + font-weight: 700; + text-decoration: none; + transition: opacity var(--transition); +} +.btn-logout-footer:hover { opacity: 0.7; } + +/* Sidebar User Info */ +.sidebar-user { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--sidebar-border); + background: linear-gradient(135deg, rgba(225,29,72,0.04), transparent); +} + +.user-info { + display: flex; + align-items: center; + gap: 10px; +} + +.user-avatar { + width: 32px; height: 32px; + background: linear-gradient(135deg, var(--accent), #be123c); + border-radius: 8px; + display: flex; align-items: center; justify-content: center; + color: white; + font-size: 14px; font-weight: 800; + flex-shrink: 0; + box-shadow: 0 2px 8px var(--accent-glow); +} + +.user-detail { + display: flex; flex-direction: column; +} + +.user-name { + font-size: 12.5px; + font-weight: 700; + color: #0f172a; +} + +.user-role { + font-size: 10px; + font-weight: 700; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.btn-logout { + width: 30px; height: 30px; + border: 1px solid rgba(0,0,0,0.08); + border-radius: 8px; + background: #ffffff; + color: var(--sidebar-text-muted); + cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: all var(--transition); + text-decoration: none; +} +.btn-logout:hover { + background: #ffe4e6; + border-color: var(--accent); + color: var(--accent); +} + +/* Open button (collapsed state) */ +.sidebar-open-btn { + position: fixed; + left: 12px; top: 50%; + transform: translateY(-50%); + z-index: 901; + width: 36px; height: 36px; + background: var(--sidebar-bg); + border: 1px solid var(--sidebar-border); + border-radius: 10px; + color: var(--sidebar-text); + cursor: pointer; + display: flex; align-items: center; justify-content: center; + box-shadow: var(--shadow-md); + transition: all var(--transition); +} +.sidebar-open-btn:hover { background: var(--surface-2); color: #0f172a; border-color: rgba(0,0,0,0.15); } + +/* ============================================================ + MAP CONTAINER +============================================================ */ +#mapContainer { + position: fixed; + left: var(--sidebar-w); + top: 0; right: 0; bottom: 0; + transition: left var(--transition), right var(--transition); +} + +#mapContainer.with-right-sidebar { + right: var(--right-sidebar-w); +} + +#mapContainer.full-width { left: 0; } + +#map { width: 100%; height: 100%; background: #e5e5e5; } + +/* Loading overlay */ +.map-loading { + position: absolute; inset: 0; + background: rgba(255,255,255,0.85); + display: flex; flex-direction: column; + align-items: center; justify-content: center; + gap: 12px; z-index: 500; + color: #0f172a; font-size: 14px; font-weight: 600; + backdrop-filter: blur(4px); + transition: opacity 0.4s; +} +.map-loading.hidden { opacity: 0; pointer-events: none; } + +.loading-spinner { + width: 38px; height: 38px; + border: 3px solid rgba(0,0,0,0.1); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ============================================================ + TOAST NOTIFICATIONS +============================================================ */ +.toast-container { + position: absolute; + bottom: 24px; right: 16px; + display: flex; flex-direction: column; gap: 8px; + z-index: 800; pointer-events: none; +} + +.toast { + display: flex; align-items: center; gap: 10px; + padding: 12px 16px; + border-radius: var(--radius-md); + font-size: 13px; font-weight: 600; + color: white; + box-shadow: var(--shadow-md); + animation: toastIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + pointer-events: all; max-width: 320px; + backdrop-filter: blur(8px); +} +.toast.success { background: rgba(22,163,74,0.95); } +.toast.error { background: rgba(225,29,72,0.95); } +.toast.info { background: rgba(59,130,246,0.95); } +.toast.warning { background: rgba(217,119,6,0.95); } +.toast.fade-out { animation: toastOut 0.3s forwards; } + +@keyframes toastIn { + from { transform: translateX(60px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} +@keyframes toastOut { + to { transform: translateX(60px); opacity: 0; } +} + +/* ============================================================ + MODAL +============================================================ */ +.modal-backdrop { + position: fixed; inset: 0; + background: rgba(0,0,0,0.4); + z-index: 1000; + backdrop-filter: blur(3px); + transition: opacity var(--transition); +} +.modal-backdrop.hidden { display: none; } + +.modal { + position: fixed; + top: 50%; left: 50%; + transform: translate(-50%, -50%) scale(0.96); + width: 480px; max-width: calc(100vw - 32px); + max-height: calc(100vh - 60px); + background: #ffffff; + border: 1px solid rgba(0,0,0,0.05); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + z-index: 1100; + display: flex; flex-direction: column; + overflow: hidden; + animation: modalIn 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} +.modal.hidden { display: none; } +.modal.modal-sm { width: 380px; } + +@keyframes modalIn { + from { opacity: 0; transform: translate(-50%, -52%) scale(0.93); } + to { opacity: 1; transform: translate(-50%, -50%) scale(1); } +} + +.modal-header { + padding: 18px 20px 14px; + border-bottom: 1px solid rgba(0,0,0,0.06); + display: flex; align-items: center; + justify-content: space-between; gap: 12px; + flex-shrink: 0; + background: #f8fafc; +} + +.modal-title-wrap { display: flex; align-items: center; gap: 12px; } + +.modal-icon { + width: 40px; height: 40px; + border-radius: var(--radius-sm); + display: flex; align-items: center; + justify-content: center; font-size: 20px; + flex-shrink: 0; + background: #ffe4e6 !important; + color: var(--accent) !important; +} + +.modal-title { font-size: 16px; font-weight: 800; color: #0f172a; } +.modal-subtitle { font-size: 11px; color: var(--sidebar-text-muted); margin-top: 2px; font-weight: 500; } + +.modal-close { + width: 30px; height: 30px; + border: 1px solid rgba(0,0,0,0.1); + border-radius: 8px; background: #ffffff; + color: var(--sidebar-text-muted); cursor: pointer; + font-size: 14px; transition: all var(--transition); + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; +} +.modal-close:hover { background: #ffe4e6; border-color: var(--accent); color: var(--accent); } + +.modal-body { + padding: 18px 20px; + overflow-y: auto; flex: 1; +} + +.modal-footer { + padding: 14px 20px; + border-top: 1px solid rgba(0,0,0,0.06); + display: flex; gap: 8px; justify-content: flex-end; + flex-shrink: 0; + background: #f8fafc; +} + +/* --- Form Elements ------------------------------------ */ +.form-row { + display: grid; grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.form-group { margin-bottom: 14px; } +.form-group:last-child { margin-bottom: 0; } + +.form-label { + display: block; + font-size: 11.5px; font-weight: 700; + color: var(--sidebar-text-muted); + letter-spacing: 0.5px; text-transform: uppercase; + margin-bottom: 6px; +} +.form-label small { font-size: 10px; text-transform: none; font-weight: 500; color: #94a3b8; } +.req { color: var(--accent); } + +.form-input { + width: 100%; + background: #ffffff; + border: 1px solid rgba(0,0,0,0.15); + border-radius: var(--radius-sm); + padding: 9px 12px; + font-size: 13px; + color: #0f172a; + font-family: var(--font); + font-weight: 500; + outline: none; + transition: border-color var(--transition), box-shadow var(--transition); + resize: none; +} +.form-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} +.form-input::placeholder { color: #94a3b8; font-weight: 400; } +.form-input[readonly] { background: #f1f5f9; cursor: default; color: #64748b; } +.form-input.mono { font-family: var(--font-mono); font-size: 12px; } +.form-input option { background: #ffffff; } + +/* Radio Tabs */ +.radio-tabs { display: flex; gap: 6px; } +.radio-tab { + flex: 1; position: relative; +} +.radio-tab input { position: absolute; opacity: 0; width: 0; height: 0; } +.radio-tab span { + display: block; text-align: center; + padding: 8px; + background: #ffffff; + border: 1px solid rgba(0,0,0,0.15); + border-radius: var(--radius-sm); + font-size: 12.5px; font-weight: 600; + cursor: pointer; color: var(--sidebar-text); + transition: all var(--transition); +} +.radio-tab input:checked + span { + background: var(--accent); + border-color: var(--accent); + color: white; + box-shadow: 0 2px 8px var(--accent-glow); +} + +/* Range */ +.range-wrap { + display: flex; align-items: center; gap: 8px; +} +.range-min, .range-max { + font-size: 10px; color: var(--sidebar-text-muted); + font-family: var(--font-mono); flex-shrink: 0; font-weight: 600; +} +.range-input { + flex: 1; height: 4px; cursor: pointer; + accent-color: var(--accent); + -webkit-appearance: none; + background: rgba(0,0,0,0.1); + border-radius: 99px; + outline: none; +} +.range-input::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; height: 16px; + border-radius: 50%; background: var(--accent); + box-shadow: 0 2px 6px var(--accent-glow); + cursor: pointer; border: 2px solid white; +} + +/* Geocode wrap */ +.geocode-wrap { position: relative; } +.geocode-status { + position: absolute; top: 8px; right: 10px; + font-size: 11px; color: var(--sidebar-text-muted); font-weight: 500; +} + +/* Status Jangkauan Card */ +.status-jangkauan-card { + background: #f8fafc; + border: 1px solid rgba(0,0,0,0.06); + border-radius: var(--radius-md); + padding: 12px 14px; + margin-bottom: 14px; +} +.sj-label { font-size: 10px; font-weight: 800; letter-spacing: 1px; text-transform: uppercase; color: var(--sidebar-text-muted); margin-bottom: 4px; } +.sj-value { font-size: 15px; font-weight: 800; color: #0f172a; } +.sj-value.dalam { color: var(--green); } +.sj-value.luar { color: var(--accent); } +.sj-ditangani { font-size: 11.5px; color: var(--sidebar-text-muted); margin-top: 3px; font-weight: 500; } + +/* --- Buttons ------------------------------------------ */ +.btn { + padding: 9px 20px; + border-radius: var(--radius-sm); + border: none; cursor: pointer; + font-family: var(--font); + font-size: 13px; font-weight: 700; + transition: all var(--transition); + display: inline-flex; align-items: center; gap: 6px; + justify-content: center; +} + +.btn-primary { + background: var(--accent); + color: white; + box-shadow: 0 2px 12px var(--accent-glow); +} +.btn-primary:hover { background: #be123c; transform: translateY(-1px); box-shadow: 0 4px 16px var(--accent-glow); } +.btn-primary:active { transform: none; } + +.btn-ghost { + background: #ffffff; + border: 1px solid rgba(0,0,0,0.15); + color: var(--sidebar-text); +} +.btn-ghost:hover { background: #f1f5f9; color: #0f172a; border-color: rgba(0,0,0,0.25); } + +.btn-danger { background: var(--accent); color: white; } +.btn-danger:hover { background: #be123c; } + +.btn-sm { padding: 5px 12px; font-size: 11.5px; } + +/* --- Hidden ------------------------------------------- */ +.hidden { display: none !important; } + +/* ============================================================ + LEAFLET CUSTOMIZATIONS +============================================================ */ + +/* Popup */ +.leaflet-popup-content-wrapper { + background: #ffffff !important; + border: 1px solid rgba(0,0,0,0.08) !important; + border-radius: var(--radius-md) !important; + box-shadow: var(--shadow-md) !important; + color: #334155 !important; + padding: 0 !important; + overflow: hidden; +} +.leaflet-popup-tip { background: #ffffff !important; border-top: 1px solid rgba(0,0,0,0.08); border-left: 1px solid rgba(0,0,0,0.08); } +.leaflet-popup-content { margin: 0 !important; } + +.popup-card { + padding: 14px 16px; + min-width: 200px; +} +.popup-title { + font-size: 14px; font-weight: 800; + color: #0f172a; margin-bottom: 8px; + display: flex; align-items: center; gap: 6px; +} +.popup-badge { + font-size: 10px; font-weight: 700; + padding: 2px 7px; border-radius: 99px; + text-transform: uppercase; letter-spacing: 0.5px; +} +.badge-dalam { background: #dcfce7; color: #166534; } +.badge-luar { background: #ffe4e6; color: #9f1239; } +.badge-fp { background: #f1f5f9; color: #475569; } + +.popup-row { + display: flex; gap: 6px; + font-size: 12px; margin-bottom: 4px; + color: #64748b; font-weight: 500; +} +.popup-row strong { color: #0f172a; min-width: 80px; font-weight: 700; } + +.popup-actions { + display: flex; gap: 6px; + padding: 10px 16px; + border-top: 1px solid rgba(0,0,0,0.06); + background: #f8fafc; +} + +.popup-btn { + flex: 1; padding: 6px; + border: 1px solid rgba(0,0,0,0.12); + border-radius: 6px; + background: #ffffff; cursor: pointer; + font-size: 11px; font-weight: 700; + color: var(--sidebar-text); font-family: var(--font); + transition: all 0.15s; + text-align: center; +} +.popup-btn:hover { background: #f1f5f9; color: #0f172a; } +.popup-btn.del:hover { background: #ffe4e6; border-color: var(--accent); color: var(--accent); } + +/* Zoom / Attribution */ +.leaflet-control-zoom a { + background: #ffffff !important; + border-color: rgba(0,0,0,0.1) !important; + color: #0f172a !important; + font-weight: 700 !important; +} +.leaflet-control-zoom a:hover { background: #f8fafc !important; color: var(--accent) !important; } +.leaflet-control-attribution { background: rgba(255,255,255,0.9) !important; color: #64748b !important; font-size: 10px !important; font-weight: 500 !important; } +.leaflet-control-attribution a { color: var(--accent) !important; font-weight: 600 !important; } + +/* Scale */ +.leaflet-control-scale-line { + background: rgba(255,255,255,0.9) !important; + border-color: rgba(0,0,0,0.3) !important; + color: #0f172a !important; + font-size: 10px !important; + font-weight: 600 !important; +} + +/* ============================================================ + SEARCH BOX +============================================================ */ +.sidebar-search { + padding: 16px 16px 0; + position: relative; + z-index: 50; +} +.search-input-wrap { + position: relative; + display: flex; + align-items: center; +} +.search-icon { + position: absolute; + left: 12px; + color: #94a3b8; +} +#searchInput { + width: 100%; + background: #ffffff; + border: 1px solid rgba(0,0,0,0.15); + border-radius: var(--radius-sm); + padding: 10px 12px 10px 36px; + color: #0f172a; + font-family: var(--font); + font-size: 12.5px; + font-weight: 500; + transition: all var(--transition); +} +#searchInput:focus { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 3px var(--accent-glow); +} +#searchInput::placeholder { + color: #94a3b8; font-weight: 400; +} + +.search-results { + position: absolute; + top: calc(100% + 4px); + left: 16px; right: 16px; + background: #ffffff; + border: 1px solid rgba(0,0,0,0.1); + border-radius: var(--radius-md); + max-height: 280px; + overflow-y: auto; + box-shadow: var(--shadow-lg); + display: flex; flex-direction: column; +} +.search-results.hidden { + display: none !important; +} +.search-item { + padding: 10px 14px; + border-bottom: 1px solid rgba(0,0,0,0.05); + cursor: pointer; + display: flex; flex-direction: column; gap: 3px; + transition: background var(--transition); +} +.search-item:last-child { border-bottom: none; } +.search-item:hover { background: #f8fafc; } + +.search-item-title { font-size: 13px; font-weight: 800; color: #0f172a; } +.search-item-type { font-size: 10.5px; color: var(--accent); font-weight: 700; letter-spacing: 0.3px; text-transform: uppercase; } +.search-item-empty { padding: 14px; text-align: center; font-size: 12px; color: var(--sidebar-text-muted); font-weight: 500; } + +/* ============================================================ + RESPONSIVE +============================================================ */ +@media (max-width: 900px) { + :root { --sidebar-w: 260px; } +} + +@media (max-width: 680px) { + .sidebar { + transform: translateX(-100%); + width: 280px; + max-width: 85vw; + z-index: 1050; + border-right: none; + border-top-right-radius: 16px; + border-bottom-right-radius: 16px; + box-shadow: 0 0 40px rgba(0,0,0,0.15); + } + .sidebar.mobile-open { + transform: translateX(0); + } + #mapContainer { + left: 0 !important; + } + .sidebar-open-btn { + display: flex !important; + top: 20px; + left: 20px; + z-index: 1000; + width: 44px; height: 44px; + border-radius: 12px; + background: white; + color: var(--accent); + box-shadow: 0 4px 15px rgba(225,29,72,0.2); + } + .modal { + width: calc(100vw - 32px); + max-height: calc(100vh - 40px); + } + .form-row { grid-template-columns: 1fr; } + + .toast-container { + bottom: 24px; left: 16px; right: 16px; + align-items: center; + } + .toast { + width: 100%; max-width: 100%; + justify-content: center; + } +} + +/* --- Utility ------------------------------------------ */ +.text-center { text-align: center; } +.mt-8 { margin-top: 8px; } +.mb-8 { margin-bottom: 8px; } + +/* ============================================================ + CUSTOM PREMIUM MAP MARKERS & LEGEND + ============================================================ */ +.custom-marker { + position: relative; + border-radius: 50% 50% 50% 0; + transform: rotate(-45deg); + display: flex; + align-items: center; + justify-content: center; + border: 2px solid #ffffff; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; +} + +/* Base Shadow/Indicator */ +.custom-marker::after { + content: ''; + position: absolute; + background: rgba(0, 0, 0, 0.25); + border-radius: 50%; + filter: blur(2px); + transform: rotate(45deg); + z-index: -1; + transition: all 0.25s ease; +} + +.custom-marker .marker-icon { + transform: rotate(45deg); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; +} + +/* Hover effects */ +.custom-marker:hover { + transform: rotate(-45deg) scale(1.15) translateY(-3px); + z-index: 1000 !important; +} + +/* FP (Fasilitas Publik) Pin */ +.fp-marker { + width: 32px; + height: 32px; + background: linear-gradient(135deg, #f43f5e, #be123c); + box-shadow: 0 4px 10px rgba(225, 29, 72, 0.45); +} +.fp-marker::after { + width: 10px; + height: 10px; + bottom: -11px; + left: 11px; +} +.fp-marker:hover { + box-shadow: 0 6px 14px rgba(225, 29, 72, 0.6); +} + +/* PM (Penduduk Miskin) Pins */ +.pm-marker-dalam { + width: 28px; + height: 28px; + background: linear-gradient(135deg, #10b981, #047857); + box-shadow: 0 4px 8px rgba(16, 185, 129, 0.4); +} +.pm-marker-dalam::after { + width: 8px; + height: 8px; + bottom: -10px; + left: 10px; +} +.pm-marker-dalam:hover { + box-shadow: 0 6px 12px rgba(16, 185, 129, 0.55); +} + +.pm-marker-luar { + width: 28px; + height: 28px; + background: linear-gradient(135deg, #f43f5e, #be123c); + box-shadow: 0 4px 8px rgba(225, 29, 72, 0.4); +} +.pm-marker-luar::after { + width: 8px; + height: 8px; + bottom: -10px; + left: 10px; +} +.pm-marker-luar:hover { + box-shadow: 0 6px 12px rgba(225, 29, 72, 0.55); +} + +/* Point Pins (SPBU) */ +.point-marker-24 { + width: 24px; + height: 24px; + background: linear-gradient(135deg, #10b981, #047857); /* Pertamina Green */ + box-shadow: 0 4px 8px rgba(16, 185, 129, 0.4); +} +.point-marker-24::after { + width: 6px; + height: 6px; + bottom: -9px; + left: 9px; +} +.point-marker-24:hover { + box-shadow: 0 6px 12px rgba(16, 185, 129, 0.55); +} + +.point-marker-normal { + width: 24px; + height: 24px; + background: linear-gradient(135deg, #f43f5e, #be123c); /* Pertamina Red */ + box-shadow: 0 3px 6px rgba(225, 29, 72, 0.35); +} +.point-marker-normal::after { + width: 6px; + height: 6px; + bottom: -9px; + left: 9px; +} +.point-marker-normal:hover { + box-shadow: 0 5px 10px rgba(225, 29, 72, 0.5); +} + +/* Legend items style matching new markers */ +.legend-marker-wrapper { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.legend-pin { + width: 11px; + height: 11px; + border-radius: 50% 50% 50% 0; + transform: rotate(-45deg) translateY(-1px) translateX(1px); + border: 1.2px solid #ffffff; + box-shadow: 0 1px 3px rgba(0,0,0,0.25); + flex-shrink: 0; +} +.legend-pin.fp { + background: linear-gradient(135deg, #f43f5e, #be123c); +} +.legend-pin.pm-dalam { + background: linear-gradient(135deg, #10b981, #047857); +} +.legend-pin.pm-luar { + background: linear-gradient(135deg, #f43f5e, #be123c); +} + +/* ============================================================ + RIGHT SIDEBAR (PANEL KANAN) +============================================================ */ +:root { + --right-sidebar-w: 320px; +} + +#rightSidebar { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: var(--right-sidebar-w); + background: rgba(255, 255, 255, 0.96); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-left: 1px solid rgba(225, 29, 72, 0.1); + display: flex; + flex-direction: column; + z-index: 850; + transition: transform var(--transition); + box-shadow: -6px 0 40px rgba(0, 0, 0, 0.08); + overflow: hidden; +} + +#rightSidebar.right-collapsed { + transform: translateX(100%); +} + +/* Right Sidebar Header */ +.right-sidebar-header { + padding: 18px 16px 14px; + border-bottom: 1px solid rgba(225, 29, 72, 0.08); + flex-shrink: 0; + background: linear-gradient(135deg, rgba(225,29,72,0.04) 0%, rgba(255,255,255,0) 100%); + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.right-sidebar-title { + font-size: 13px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.3px; + display: flex; + align-items: center; + gap: 8px; +} + +.right-sidebar-title-icon { + width: 28px; + height: 28px; + background: linear-gradient(135deg, var(--accent), #be123c); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 13px; + box-shadow: 0 2px 8px var(--accent-glow); + flex-shrink: 0; +} + +.right-sidebar-close { + width: 28px; + height: 28px; + border: 1px solid rgba(0,0,0,0.1); + border-radius: 7px; + background: #fff; + color: var(--sidebar-text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition); + font-size: 13px; + flex-shrink: 0; +} +.right-sidebar-close:hover { + background: #ffe4e6; + border-color: var(--accent); + color: var(--accent); +} + +/* Search inside Right Sidebar */ +.right-search-wrap { + padding: 14px 16px 8px; + flex-shrink: 0; + border-bottom: 1px solid rgba(0,0,0,0.05); + position: relative; + z-index: 10; +} + +.right-search-inner { + position: relative; + display: flex; + align-items: center; +} + +.right-search-inner svg { + position: absolute; + left: 11px; + color: #94a3b8; + pointer-events: none; +} + +#rightSearchInput { + width: 100%; + background: #f8fafc; + border: 1.5px solid rgba(0,0,0,0.10); + border-radius: 10px; + padding: 9px 12px 9px 34px; + color: #0f172a; + font-family: var(--font); + font-size: 12.5px; + font-weight: 500; + transition: all var(--transition); + outline: none; +} + +#rightSearchInput:focus { + border-color: var(--accent); + background: #fff; + box-shadow: 0 0 0 3px var(--accent-glow); +} + +#rightSearchInput::placeholder { + color: #94a3b8; + font-weight: 400; +} + +/* Smooth UX for Toggle Button */ +#rightSidebarToggleBtn { + transition: all var(--transition); +} +#rightSidebarToggleBtn.hidden { + display: flex !important; /* Override global hidden */ + opacity: 0; + pointer-events: none; + transform: translateY(-50%) translateX(20px); +} + +/* Right Sidebar Count Badge */ +.right-count-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 10.5px; + font-weight: 700; + color: var(--accent); + background: rgba(225,29,72,0.08); + border-radius: 99px; + padding: 2px 10px; + letter-spacing: 0.3px; +} + +.right-sidebar-meta { + padding: 8px 16px 6px; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.right-sidebar-meta-label { + font-size: 10px; + font-weight: 700; + color: var(--sidebar-text-muted); + letter-spacing: 1px; + text-transform: uppercase; +} + +/* Scrollable Card List */ +.right-card-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 8px 12px 20px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.right-card-list::-webkit-scrollbar { width: 4px; } +.right-card-list::-webkit-scrollbar-track { background: transparent; } +.right-card-list::-webkit-scrollbar-thumb { + background: rgba(225,29,72,0.18); + border-radius: 4px; +} +.right-card-list::-webkit-scrollbar-thumb:hover { + background: rgba(225,29,72,0.45); +} + +/* ─── DATA CARD (Ultra Compact) ─── */ +.data-card { + background: #ffffff; + border: 1px solid rgba(0,0,0,0.05); + border-radius: 8px; + padding: 6px 8px; + cursor: pointer; + transition: + transform 0.3s cubic-bezier(0.25, 1, 0.5, 1), + border-color 0.2s ease, + box-shadow 0.3s cubic-bezier(0.25, 1, 0.5, 1), + background-color 0.2s ease; + display: flex; + flex-direction: column; + gap: 0px; + position: relative; + overflow: hidden; + flex-shrink: 0; +} + +.data-card::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: linear-gradient(180deg, var(--accent), #fb7185); + border-radius: 3px 0 0 3px; + opacity: 0; + transition: opacity 0.2s ease; +} + +.data-card:hover { + transform: translateY(-2px) scale(1.005); + border-color: rgba(225, 29, 72, 0.25); + box-shadow: 0 8px 24px rgba(225, 29, 72, 0.08), 0 2px 6px rgba(0,0,0,0.03); + background: #fafaf9; +} + +.data-card:hover::before { + opacity: 1; +} + +.data-card:active { + transform: translateY(0px) scale(0.98); + box-shadow: 0 2px 8px rgba(225, 29, 72, 0.05); +} + +.data-card-header { + display: flex; + align-items: flex-start; + gap: 6px; +} + +.data-card-icon-wrap { + width: 24px; + height: 24px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + flex-shrink: 0; + box-shadow: 0 2px 4px rgba(0,0,0,0.04); +} + +.dc-icon-spbu { background: linear-gradient(135deg, #dcfce7, #bbf7d0); } +.dc-icon-pm { background: linear-gradient(135deg, #ffe4e6, #fecdd3); } +.dc-icon-fp { background: linear-gradient(135deg, #dbeafe, #bfdbfe); } +.dc-icon-jalan { background: linear-gradient(135deg, #fef9c3, #fef08a); } +.dc-icon-poly { background: linear-gradient(135deg, #f3e8ff, #e9d5ff); } +.dc-icon-geo { background: linear-gradient(135deg, #e0f2fe, #bae6fd); } + +.data-card-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; +} + +.data-card-name { + font-size: 12.5px; + font-weight: 700; + color: #1e293b; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 2px; +} + +.data-card-sub { + font-size: 10.5px; + color: var(--sidebar-text-muted); + font-weight: 500; + margin-top: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.data-card-badge { + font-size: 9px; + font-weight: 800; + padding: 2px 6px; + border-radius: 99px; + letter-spacing: 0.3px; + text-transform: uppercase; + flex-shrink: 0; + align-self: flex-start; + margin-top: 2px; +} + +.badge-24jam { background: #dcfce7; color: #166534; } +.badge-tidak24 { background: #fee2e2; color: #991b1b; } +.badge-dalam-jk { background: #dcfce7; color: #166534; } +.badge-luar-jk { background: #fee2e2; color: #991b1b; } +.badge-shm { background: #dcfce7; color: #166534; } +.badge-hgb { background: #dbeafe; color: #1e40af; } +.badge-hgu { background: #fef9c3; color: #854d0e; } +.badge-hp { background: #f3e8ff; color: #6b21a8; } +.badge-geo { background: #f1f5f9; color: #475569; } + +.data-card-coords { + font-size: 9.5px; + color: #94a3b8; + font-family: var(--font-mono); + font-weight: 500; + display: flex; + align-items: center; + gap: 4px; + opacity: 0.8; + padding-left: 0; + margin-top: 2px; +} + +.data-card-footer { + margin-top: 4px; + padding-top: 4px; + border-top: 1px dashed rgba(0,0,0,0.06); + width: 100%; +} +.data-card-footer span, .data-card-footer span[class^="badge-"] { + font-size: 9.5px !important; +} + +/* Tab System for Modul Utuh */ +.right-tabs { + display: flex; + gap: 0; + padding: 10px 12px 0; + border-bottom: 1.5px solid rgba(0,0,0,0.07); + flex-shrink: 0; + overflow-x: auto; +} + +.right-tabs::-webkit-scrollbar { height: 0; } + +.right-tab-btn { + padding: 7px 14px; + background: transparent; + border: none; + border-bottom: 2.5px solid transparent; + font-size: 11px; + font-weight: 700; + color: var(--sidebar-text-muted); + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; + font-family: var(--font); + letter-spacing: 0.2px; + margin-bottom: -1.5px; +} + +.right-tab-btn:hover { color: var(--accent); } +.right-tab-btn.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* Skeleton Loading */ +.skeleton-card { + background: #f8fafc; + border: 1.5px solid rgba(0,0,0,0.05); + border-radius: 14px; + padding: 12px 14px; + display: flex; + gap: 10px; + align-items: flex-start; + flex-shrink: 0; +} + +.skeleton-icon { + width: 34px; + height: 34px; + border-radius: 9px; + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + flex-shrink: 0; +} + +.skeleton-lines { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 4px; +} + +.skeleton-line { + height: 10px; + border-radius: 99px; + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; +} + +.skeleton-line.w-80 { width: 80%; } +.skeleton-line.w-50 { width: 50%; } +.skeleton-line.w-30 { width: 30%; } + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 40px 20px; + text-align: center; +} + +.empty-state-icon { + width: 64px; + height: 64px; + border-radius: 18px; + background: linear-gradient(135deg, rgba(225,29,72,0.08), rgba(225,29,72,0.04)); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + border: 1.5px solid rgba(225,29,72,0.1); +} + +.empty-state-title { + font-size: 14px; + font-weight: 800; + color: #0f172a; +} + +.empty-state-sub { + font-size: 12px; + color: var(--sidebar-text-muted); + font-weight: 500; + line-height: 1.5; + max-width: 220px; +} + +/* Right Sidebar Toggle Button (on map) */ +#rightSidebarToggleBtn { + position: fixed; + right: 12px; + top: 50%; + transform: translateY(-50%); + z-index: 851; + width: 36px; + height: 70px; + background: rgba(255,255,255,0.95); + backdrop-filter: blur(10px); + border: 1px solid rgba(225,29,72,0.15); + border-radius: 12px; + color: var(--accent); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + box-shadow: -4px 0 20px rgba(225,29,72,0.1); + transition: all var(--transition); +} + +#rightSidebarToggleBtn:hover { + background: var(--accent); + color: white; + border-color: transparent; + box-shadow: -4px 0 20px rgba(225,29,72,0.3); + transform: translateY(-50%) scale(1.05); +} + +#rightSidebarToggleBtn.hidden { + display: none !important; +} + +.rs-toggle-label { + font-size: 8px; + font-weight: 800; + letter-spacing: 0.8px; + text-transform: uppercase; + writing-mode: vertical-rl; + text-orientation: mixed; + transform: rotate(180deg); +} + +/* Map container adjustment for right sidebar */ +#mapContainer { + transition: left var(--transition), right var(--transition); +} + +#mapContainer.with-right-sidebar { + right: var(--right-sidebar-w); +} + +/* ============================================================ + STATS BUTTON (Left Sidebar) +============================================================ */ + +/* Sidebar Footer - always visible at bottom of sidebar */ +.sidebar-footer { + flex-shrink: 0; + padding: 12px 14px 16px; + border-top: 1px solid var(--sidebar-border); + background: rgba(255,255,255,0.96); + display: flex; + flex-direction: column; + gap: 8px; +} + +.sidebar-footer-copy { + font-size: 9.5px; + text-align: center; + color: var(--sidebar-text-muted); + opacity: 0.6; + line-height: 1.5; +} + +.btn-statistik { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 12px 14px; + background: linear-gradient(135deg, var(--accent) 0%, #be123c 100%); + border: none; + border-radius: 12px; + color: white; + cursor: pointer; + font-family: var(--font); + font-size: 12.5px; + font-weight: 700; + transition: all var(--transition); + box-shadow: 0 4px 16px var(--accent-glow); + letter-spacing: 0.2px; + text-decoration: none; + position: relative; + overflow: hidden; +} + +.btn-statistik::before { + content: ''; + position: absolute; + top: 0; left: -100%; bottom: 0; + width: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent); + transition: left 0.5s ease; +} + +.btn-statistik:hover::before { left: 100%; } + +.btn-statistik:hover { + transform: translateY(-1px); + box-shadow: 0 6px 24px rgba(225,29,72,0.4); +} + +.btn-statistik:active { transform: translateY(0); } + +.btn-statistik-icon { + font-size: 18px; + flex-shrink: 0; +} + +.btn-statistik-text { + flex: 1; + text-align: left; +} + +.btn-statistik-text strong { + display: block; + font-size: 12.5px; +} + +.btn-statistik-text small { + font-size: 9.5px; + font-weight: 500; + opacity: 0.8; + display: block; +} + +.btn-statistik-arrow { + font-size: 14px; + opacity: 0.7; + flex-shrink: 0; +} + +/* ============================================================ + CHART MODAL +============================================================ */ +#chartModal { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(0,0,0,0.5); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + animation: backdropIn 0.25s ease; +} + +#chartModal.hidden { display: none; } + +@keyframes backdropIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.chart-modal-box { + background: #ffffff; + border-radius: 20px; + width: 100%; + max-width: 700px; + max-height: calc(100vh - 60px); + overflow: hidden; + box-shadow: 0 25px 80px rgba(0,0,0,0.2); + display: flex; + flex-direction: column; + animation: chartModalIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes chartModalIn { + from { opacity: 0; transform: scale(0.92) translateY(20px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.chart-modal-header { + padding: 20px 22px 16px; + border-bottom: 1px solid rgba(0,0,0,0.06); + display: flex; + align-items: flex-start; + gap: 14px; + flex-shrink: 0; + background: linear-gradient(135deg, rgba(225,29,72,0.04) 0%, #fff 60%); +} + +.chart-modal-icon { + width: 46px; + height: 46px; + background: linear-gradient(135deg, var(--accent), #be123c); + border-radius: 13px; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + color: white; + box-shadow: 0 4px 14px var(--accent-glow); + flex-shrink: 0; +} + +.chart-modal-meta { + flex: 1; +} + +.chart-modal-title { + font-size: 17px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.4px; +} + +.chart-modal-subtitle { + font-size: 11.5px; + color: var(--sidebar-text-muted); + font-weight: 500; + margin-top: 3px; +} + +.chart-modal-close { + width: 34px; + height: 34px; + border: 1px solid rgba(0,0,0,0.1); + border-radius: 9px; + background: #fff; + color: var(--sidebar-text-muted); + cursor: pointer; + font-size: 15px; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition); + flex-shrink: 0; +} +.chart-modal-close:hover { + background: #ffe4e6; + border-color: var(--accent); + color: var(--accent); +} + +.chart-modal-body { + padding: 20px 22px 22px; + overflow-y: auto; + flex: 1; +} + +/* Stat Summary Row inside Chart Modal */ +.chart-stat-row { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 10px; + margin-bottom: 20px; +} + +.chart-stat-mini { + background: #f8fafc; + border: 1px solid rgba(0,0,0,0.06); + border-radius: 12px; + padding: 10px 12px; + text-align: center; +} + +.chart-stat-mini-val { + font-size: 24px; + font-weight: 800; + color: #0f172a; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.chart-stat-mini-label { + font-size: 10px; + font-weight: 600; + color: var(--sidebar-text-muted); + margin-top: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Chart canvas wrapper */ +.chart-canvas-wrap { + position: relative; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.chart-canvas-wrap canvas { + max-width: 100%; +} + +/* Dashboard Mini Charts (for modul utuh) */ +.chart-dashboard-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.chart-mini-card { + background: #f8fafc; + border: 1px solid rgba(0,0,0,0.06); + border-radius: 14px; + padding: 14px; +} + +.chart-mini-title { + font-size: 11px; + font-weight: 800; + color: var(--sidebar-text-muted); + text-transform: uppercase; + letter-spacing: 0.8px; + margin-bottom: 10px; +} + +/* ============================================================ + RESPONSIVE – Right Sidebar +============================================================ */ +@media (max-width: 900px) { + :root { --right-sidebar-w: 280px; } +} + +@media (max-width: 680px) { + #rightSidebar { + width: 90vw; + max-width: 320px; + border-radius: 16px 0 0 16px; + border-left: 1px solid rgba(225,29,72,0.15); + } + + #mapContainer.with-right-sidebar { + right: 0; + } + + .chart-modal-box { + max-width: calc(100vw - 32px); + max-height: calc(100svh - 40px); + } + + .chart-dashboard-grid { + grid-template-columns: 1fr; + } +} + +/* ============================================================ + mb-4 utility +============================================================ */ +.mb-4 { margin-bottom: 4px; } + +/* ============================================================ + MANAJEMEN DATA PENDUDUK (MODUL BANTUAN) +============================================================ */ + +/* Tombol Sidebar Kanan Premium */ +.btn-manajemen { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + background: linear-gradient(135deg, #e11d48 0%, #fb7185 100%); + color: #fff; + border: none; + border-radius: 12px; + padding: 12px 16px; + font-family: inherit; + font-weight: 700; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 15px rgba(225, 29, 72, 0.3); + position: relative; + overflow: hidden; +} + +.btn-manajemen::after { + content: ''; + position: absolute; + top: 0; left: -100%; bottom: 0; + width: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); + transition: left 0.6s ease; +} + +.btn-manajemen:hover { + transform: translateY(-2px) scale(1.02); + box-shadow: 0 8px 25px rgba(225, 29, 72, 0.5); +} + +.btn-manajemen:hover::after { + left: 100%; +} + +/* Tabel Manajemen */ +.mj-table th { + border-bottom: 2px solid #e2e8f0; +} +.mj-table td { + padding: 12px 16px; + border-bottom: 1px solid #f1f5f9; + font-size: 0.85rem; + vertical-align: middle; +} +.mj-table tbody tr:hover { + background: #f8fafc; +} + +/* Custom Dropdown Badge Premium */ +.mj-dropdown { + position: relative; + display: inline-block; + font-family: var(--font), sans-serif; +} + +.mj-dropdown-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 800; + cursor: pointer; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + border: 1px solid transparent; + user-select: none; +} +.mj-dropdown-toggle .caret { + font-size: 8px; + opacity: 0.7; + transition: transform 0.2s; +} +.mj-dropdown.open .mj-dropdown-toggle .caret { + transform: rotate(180deg); +} + +.mj-dropdown-toggle.belum { background: #ffe4e6; color: #be123c; border-color: #fda4af; } +.mj-dropdown-toggle.proses { background: #fef3c7; color: #b45309; border-color: #fcd34d; } +.mj-dropdown-toggle.sudah { background: #dcfce7; color: #15803d; border-color: #86efac; } + +.mj-dropdown-toggle:hover { + transform: translateY(-1px); + filter: brightness(0.95); + box-shadow: 0 4px 10px rgba(0,0,0,0.08); +} + +.mj-dropdown-menu { + position: absolute; + min-width: 140px; + background: #ffffff; + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0,0,0,0.15); + border: 1px solid rgba(0,0,0,0.05); + z-index: 999999; + display: flex; + flex-direction: column; + padding: 4px; + opacity: 0; + visibility: hidden; + transform: scaleY(0.9); + transition: opacity 0.15s, transform 0.15s, visibility 0.15s; +} +.mj-dropdown.open .mj-dropdown-menu { + opacity: 1; + visibility: visible; + transform: scaleY(1); +} + +.mj-dropdown-item { + padding: 8px 12px; + font-size: 0.75rem; + font-weight: 700; + cursor: pointer; + border-radius: 8px; + transition: background 0.15s; + color: #334155; + text-align: left; +} +.mj-dropdown-item:hover { + background: #f1f5f9; +} +.mj-dropdown-item.belum:hover { background: #ffe4e6; color: #be123c; } +.mj-dropdown-item.proses:hover { background: #fef3c7; color: #b45309; } +.mj-dropdown-item.sudah:hover { background: #dcfce7; color: #15803d; } + +/* SweetAlert2 Foto Popup Customization */ +.swal2-container.swal2-center { + z-index: 9999999 !important; +} +.swal-foto-popup { + border-radius: 16px !important; + overflow: hidden !important; + box-shadow: 0 20px 40px rgba(0,0,0,0.2) !important; + padding: 0 !important; + background: #ffffff !important; +} +.swal-foto-popup .swal2-image { + margin: 0 !important; + width: 100% !important; + border-radius: 16px 16px 0 0 !important; + object-fit: cover !important; +} +.swal-foto-popup .swal2-actions { + margin-top: 10px !important; + padding-bottom: 15px !important; +} + +/* Tab Filter */ +.mj-tab { + padding: 8px 16px; + font-size: 0.85rem; + font-weight: 600; + border-radius: 8px; + color: #64748b; + border: 1px solid transparent; +} +.mj-tab.active { + background: #f1f5f9; + color: #0f172a; + border-color: #e2e8f0; +} + +/* ============================================================ + PRINT CSS (Cetak Laporan) +============================================================ */ +@media print { + /* Pastikan elemen lain disembunyikan */ + body > *:not(#manajemenModal), + #sidebar, + #rightSidebar, + #mapContainer, + #rightSidebarToggleBtn, + .modal-backdrop, + .modal-close, + .manajemen-filter, + .btn-primary[onclick="window.print()"] { + display: none !important; + } + + /* Aktifkan warna background & print color exact agar badge tetap berwarna */ + * { + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; + } + + body { + background: white !important; + padding: 0 !important; + margin: 0 !important; + } + + #manajemenModal { + display: block !important; + position: static !important; + transform: none !important; + box-shadow: none !important; + width: 100% !important; + max-width: none !important; + animation: none !important; + background: white !important; + } + + #manajemenModal .modal-body { + background: white !important; + overflow: visible !important; + max-height: none !important; + padding: 0 !important; + } + + .table-responsive { + overflow: visible !important; + max-height: none !important; + padding: 0 !important; + } + + .mj-table { + width: 100% !important; + box-shadow: none !important; + border: 1px solid #e2e8f0 !important; + } + + .mj-table th, .mj-table td { + border: 1px solid #e2e8f0 !important; + } + + @page { + size: landscape; + margin: 1cm; + } +} + + diff --git a/publik/assets/img/map-bg.png b/publik/assets/img/map-bg.png new file mode 100644 index 0000000..2c3c088 Binary files /dev/null and b/publik/assets/img/map-bg.png differ diff --git a/publik/assets/js/app.js b/publik/assets/js/app.js new file mode 100644 index 0000000..b6eb1a5 --- /dev/null +++ b/publik/assets/js/app.js @@ -0,0 +1,2711 @@ +/* ============================================================ + WebGIS - app.js + Sistem Informasi Geografis Pengentasan Kemiskinan +============================================================ */ +(function () { +'use strict'; + +/* -- Konfigurasi ----------------------------------------- */ +const DEFAULT_CENTER = [-0.05754868797441759, 109.32739998281652]; +const DEFAULT_ZOOM = 14; + +/* -- State Aplikasi -------------------------------------- */ +let clickTimer = null; // Timer untuk membedakan single click & double click +let mode = 'point'; // mode aktif saat ini +let deleteCallback = null; // callback untuk konfirmasi hapus + +// Preview sementara di peta +let previewMarker = null; +let previewCircle = null; +let previewLine = null; +let previewPolygon = null; +let previewVertices = []; + +// Data untuk drawing +let jalanLatLngs = []; +let polygonLatLngs = []; + +// Data runtime +let fasilitasPublikList = []; // [{id, nama, lat, lng, radius}, ...] +let pendudukList = []; // [{data, marker}, ...] + +/* -- Layer Groups ---------------------------------------- */ +let geoJalanLayer = null; +let geoKecamatanLayer= null; +let geoSungaiLayer = null; + +const lgFasilitasPublik = L.layerGroup(); +const lgPendudukDalam = L.layerGroup(); +const lgPendudukLuar = L.layerGroup(); +const lgSpbu24 = L.layerGroup(); +const lgSpbuTidak = L.layerGroup(); +const lgJalan = L.layerGroup(); +const lgPolygon = L.layerGroup(); + +/* -- Inisialisasi Peta ----------------------------------- */ +const savedCenter = (() => { + try { return JSON.parse(localStorage.getItem('webgis_center')); } catch { return null; } +})(); +const savedZoom = parseInt(localStorage.getItem('webgis_zoom') || '0', 10) || DEFAULT_ZOOM; + +const map = L.map('map', { + doubleClickZoom: false, + zoomControl: true +}).setView(savedCenter || DEFAULT_CENTER, savedZoom); + +// Base tile (Premium CARTO Positron for SaaS look) +L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap contributors © CARTO' +}).addTo(map); + +// Scale control +L.control.scale({ imperial: false, metric: true, position: 'bottomleft' }).addTo(map); + +// Simpan posisi peta +map.on('moveend zoomend', () => { + const c = map.getCenter(); + localStorage.setItem('webgis_center', JSON.stringify([c.lat, c.lng])); + localStorage.setItem('webgis_zoom', String(map.getZoom())); +}); + +// Inisialisasi Lottie Splash Screen +const splashEl = document.getElementById('splashScreen'); +if (splashEl && window.lottie) { + lottie.loadAnimation({ + container: document.getElementById('lottieContainer'), + renderer: 'svg', + loop: true, + autoplay: true, + path: 'assets/animasi-lokasi.json' + }); +} + +// Sembunyikan loading setelah peta siap +map.whenReady(() => { + setTimeout(() => { + const ml = el('mapLoading'); + if(ml) ml.classList.add('hidden'); + }, 600); + + // Sembunyikan Splash Screen dengan efek fade-out yang mulus + if (splashEl) { + setTimeout(() => { + splashEl.classList.add('splash-hidden'); + setTimeout(() => splashEl.remove(), 500); // Hapus elemen setelah transisi opacity 0.5s selesai + }, 1500); // Delay 1.5 detik agar preloader premium sempat dinikmati user + } +}); + +/* -- Tambahkan layer groups ke peta ---------------------- */ +[lgFasilitasPublik, lgPendudukDalam, lgPendudukLuar].forEach(lg => lg.addTo(map)); +if (document.getElementById("layerSpbu24") && document.getElementById("layerSpbu24").checked) lgSpbu24.addTo(map); +if (document.getElementById("layerSpbuTidak") && document.getElementById("layerSpbuTidak").checked) lgSpbuTidak.addTo(map); +if (document.getElementById("layerJalan") && document.getElementById("layerJalan").checked) lgJalan.addTo(map); +if (document.getElementById("layerPolygon") && document.getElementById("layerPolygon").checked) lgPolygon.addTo(map); + +/* ------------------------------------------------------- + HELPER FUNCTIONS +------------------------------------------------------- */ + +function el(id) { return document.getElementById(id); } + +function showToast(message, type = 'success', duration = 3500) { + const container = el('toastContainer'); + const toast = document.createElement('div'); + const icons = { success: '\u2713', error: '\u2715', info: '\u2139', warning: '\u26A0' }; + toast.className = `toast ${type}`; + toast.innerHTML = `${icons[type] || '\u2022'}${message}`; + container.appendChild(toast); + setTimeout(() => { + toast.classList.add('fade-out'); + setTimeout(() => toast.remove(), 350); + }, duration); +} + +function showDeleteConfirm(message, callback) { + el('deleteMessage').textContent = message; + deleteCallback = callback; + showModal('deleteModal'); +} + +function showModal(id) { + el('modalBackdrop').classList.remove('hidden'); + el(id).classList.remove('hidden'); +} + +function hideModal(id) { + el(id).classList.add('hidden'); + // sembunyikan backdrop hanya jika tidak ada modal lain terbuka + const openModals = document.querySelectorAll('.modal:not(.hidden)'); + if (openModals.length === 0) { + el('modalBackdrop').classList.add('hidden'); + } +} + +function hideAllModals() { + document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden')); + el('modalBackdrop').classList.add('hidden'); +} + +function setHelpText(text) { + el('helpText').textContent = text; +} + +/* -- Kalkulasi Geospasial -------------------------------- */ + +function hitungPanjang(latlngs) { + let total = 0; + for (let i = 1; i < latlngs.length; i++) { + total += latlngs[i - 1].distanceTo(latlngs[i]); + } + return total; +} + +function hitungLuas(latlngs) { + if (latlngs.length < 3) return 0; + const pts = latlngs.map(ll => map.options.crs.project(ll)); + let area = 0; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + area += (pts[j].x * pts[i].y) - (pts[i].x * pts[j].y); + } + return Math.abs(area / 2); +} + +function getDistanceMeter(lat1, lng1, lat2, lng2) { + return L.latLng(lat1, lng1).distanceTo(L.latLng(lat2, lng2)); +} + +function cekJangkauan(lat, lng) { + let hasil = { status: 'Luar Jangkauan', id: '', nama: '' }; + let minDistance = Infinity; + + fasilitasPublikList.forEach(ri => { + const d = getDistanceMeter(lat, lng, parseFloat(ri.latitude), parseFloat(ri.longitude)); + if (d <= parseFloat(ri.radius) && d < minDistance) { + minDistance = d; + hasil = { status: 'Dalam Jangkauan', id: ri.id, nama: ri.nama }; + } + }); + return hasil; +} + +/* -- GeoJSON helpers ------------------------------------- */ + +function toLineFeature(latlngs, props = {}) { + return { + type: 'Feature', + geometry: { type: 'LineString', coordinates: latlngs.map(ll => [ll.lng, ll.lat]) }, + properties: props + }; +} + +function toPolygonFeature(latlngs, props = {}) { + const ring = latlngs.map(ll => [ll.lng, ll.lat]); + if (ring.length && (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1])) { + ring.push([ring[0][0], ring[0][1]]); + } + return { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: props + }; +} + +function lineFeatureToLatLngs(feat) { + if (!feat?.geometry?.coordinates) return []; + return feat.geometry.coordinates.map(c => [c[1], c[0]]); +} + +function polygonFeatureToLatLngs(feat) { + if (!feat?.geometry?.coordinates?.[0]) return []; + const ring = [...feat.geometry.coordinates[0]]; + if (ring.length > 1) { + const f = ring[0], l = ring[ring.length - 1]; + if (f[0] === l[0] && f[1] === l[1]) ring.pop(); + } + return ring.map(c => [c[1], c[0]]); +} + +/* -- Warna Helper ---------------------------------------- */ + +function warnaJalan(status) { + return { 'Jalan Nasional': '#d32f2f', 'Jalan Provinsi': '#f57c00', 'Jalan Kabupaten': '#388e3c' }[status] || '#1565c0'; +} + +function warnaPolygon(status) { + return { 'SHM': '#1b5e20', 'HGB': '#1565c0', 'HGU': '#ef6c00', 'HP': '#6a1b9a' }[status] || '#455a64'; +} + +function warnaKecamatan(nama) { + const colors = { + 'Pontianak Barat': '#e41a1c', 'Pontianak Timur': '#377eb8', + 'Pontianak Utara': '#4daf4a', 'Pontianak Selatan': '#984ea3', + 'Pontianak Kota': '#ff7f00', 'Pontianak Tenggara': '#a65628' + }; + return colors[nama] || '#888888'; +} + +/* -- Custom Marker Icons --------------------------------- */ + +function createFPIcon() { + const svg = ``; + return L.divIcon({ + className: '', + html: `
${svg}
`, + iconSize: [32, 32], + iconAnchor: [16, 32], + popupAnchor: [0, -34] + }); +} + +function createPointIcon(is24) { + const markerClass = is24 ? 'point-marker-24' : 'point-marker-normal'; + const svg = ``; + return L.divIcon({ + className: '', + html: `
${svg}
`, + iconSize: [26, 26], + iconAnchor: [13, 26], + popupAnchor: [0, -28] + }); +} + +function createPendudukIcon(isDalam) { + const colorClass = isDalam ? 'pm-marker-dalam' : 'pm-marker-luar'; + const svg = ``; + return L.divIcon({ + className: '', + html: `
${svg}
`, + iconSize: [28, 28], + iconAnchor: [14, 28], + popupAnchor: [0, -30] + }); +} + +/* ------------------------------------------------------- + PREVIEW MANAGEMENT +------------------------------------------------------- */ + +function clearAllPreview() { + if (previewMarker) { map.removeLayer(previewMarker); previewMarker = null; } + if (previewCircle) { map.removeLayer(previewCircle); previewCircle = null; } + if (previewLine) { map.removeLayer(previewLine); previewLine = null; } + if (previewPolygon) { map.removeLayer(previewPolygon); previewPolygon = null; } + previewVertices.forEach(v => map.removeLayer(v)); + previewVertices = []; + jalanLatLngs = []; + polygonLatLngs = []; +} + +function addVertexMarker(latlng, color = '#1565c0') { + const v = L.circleMarker(latlng, { + radius: 5, color, fillColor: color, + fillOpacity: 1, weight: 2 + }).addTo(map); + previewVertices.push(v); + return v; +} + +function updateJalanPreview() { + if (previewLine) { map.removeLayer(previewLine); previewLine = null; } + previewVertices.forEach(v => map.removeLayer(v)); + previewVertices = []; + + jalanLatLngs.forEach((ll, i) => { + const v = addVertexMarker(ll, '#d32f2f'); + v.bindTooltip(`Titik ${i + 1}`, { direction: 'top', permanent: false }); + }); + + if (jalanLatLngs.length >= 2) { + previewLine = L.polyline(jalanLatLngs, { color: '#d32f2f', weight: 4, opacity: 0.9 }).addTo(map); + el('panjang_meter').value = hitungPanjang(jalanLatLngs).toFixed(2); + } + el('jumlah_titik_jalan').value = jalanLatLngs.length; + el('koordinat_jalan').value = JSON.stringify(toLineFeature(jalanLatLngs), null, 2); +} + +function updatePolygonPreview() { + if (previewPolygon) { map.removeLayer(previewPolygon); previewPolygon = null; } + previewVertices.forEach(v => map.removeLayer(v)); + previewVertices = []; + + polygonLatLngs.forEach((ll, i) => { + const v = addVertexMarker(ll, '#6a1b9a'); + v.bindTooltip(`Titik ${i + 1}`, { direction: 'top', permanent: false }); + }); + + if (polygonLatLngs.length >= 3) { + previewPolygon = L.polygon(polygonLatLngs, { + color: '#6a1b9a', fillColor: '#ba68c8', fillOpacity: 0.3, weight: 3 + }).addTo(map); + el('luas_tanah').value = hitungLuas(polygonLatLngs).toFixed(4); + } + el('jumlah_titik_polygon').value = polygonLatLngs.length; + el('koordinat_polygon').value = JSON.stringify(toPolygonFeature(polygonLatLngs), null, 2); +} + +/* ------------------------------------------------------- + MODE MANAGEMENT +------------------------------------------------------- */ + +const MODES = { + point: { btn: 'btnToolPoint', help: 'Klik pada peta untuk menambah titik.' }, + jalan: { btn: 'btnToolJalan', help: 'Klik titik-titik jalan. Double-klik untuk selesai. Klik-kanan untuk hapus titik terakhir.' }, + polygon: { btn: 'btnToolPolygon', help: 'Klik titik-titik polygon. Double-klik untuk selesai. Klik-kanan untuk hapus titik terakhir.' }, + fasilitas_publik: { btn: 'btnToolFasilitasPublik',help: 'Klik pada peta untuk menentukan lokasi Fasilitas Publik.' }, + penduduk_miskin:{ btn: 'btnToolPenduduk', help: 'Klik pada peta untuk menentukan lokasi penduduk miskin.' }, + lokasi: { btn: 'btnToolLokasi', help: 'Mencari lokasi Anda...' }, +}; + +function setMode(newMode) { + clearAllPreview(); + hideAllModals(); + + mode = newMode; + + // Update active button + document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active')); + const cfg = MODES[newMode]; + if (cfg) { + const btn = el(cfg.btn); + if (btn) btn.classList.add('active'); + setHelpText(cfg.help); + } + + // Khusus lokasi: langsung trigger + if (newMode === 'lokasi') { + map.locate({ setView: true, maxZoom: 18 }); + if (el('btnToolPoint')) { + mode = 'point'; // kembali ke point setelah locate + el('btnToolPoint').classList.add('active'); + } else { + mode = 'view'; + } + if (el('btnToolLokasi')) el('btnToolLokasi').classList.remove('active'); + } +} + +/* ------------------------------------------------------- + RENDER FUNCTIONS +------------------------------------------------------- */ + +/* -- Render Point ---------------------------------------- */ +function renderPoint(item) { + const lat = parseFloat(item.latitude); + const lng = parseFloat(item.longitude); + const is24 = item.buka_24_jam === 'Ya'; + + const marker = L.marker([lat, lng], { + icon: createPointIcon(is24), + draggable: (typeof USER_ROLE !== 'undefined' && USER_ROLE !== 'guest') + }).addTo(is24 ? lgSpbu24 : lgSpbuTidak); + + marker.on('dragend', function(e) { + const pos = e.target.getLatLng(); + updateLocation('point', item.id, pos.lat, pos.lng, item.nama); + }); + + const popup = ` + + `; + + marker.bindPopup(popup, { maxWidth: 280 }); + return marker; +} + +/* -- GLOBAL: EDIT Point / SPBU --------------------------- */ +window.editPoint = function (id) { + fetch(`api/get_point.php?id=${id}`) + .then(r => r.json()) + .then(res => { + if (res.status === 'success') { + const d = res.data; + el('point_id').value = d.id; + el('point_nama').value = d.nama; + el('point_no_wa').value = d.no_wa; + + // Handle radio buttons + if (d.buka_24_jam === 'Ya') { + document.querySelector('input[name="point_buka_24_jam"][value="Ya"]').checked = true; + } else { + document.querySelector('input[name="point_buka_24_jam"][value="Tidak"]').checked = true; + } + + // Populate coordinates + el('point_latitude').value = d.latitude; + el('point_longitude').value = d.longitude; + + el('pointModalTitle').textContent = 'Edit Titik'; + + // Preview di peta + const tempGeom = { lat: parseFloat(d.latitude), lng: parseFloat(d.longitude) }; + if (previewMarker) map.removeLayer(previewMarker); + previewMarker = L.marker([tempGeom.lat, tempGeom.lng], { + icon: createPointIcon(d.buka_24_jam === 'Ya') + }).addTo(map); + map.setView([tempGeom.lat, tempGeom.lng], 16); + + el('pointModal').classList.remove('hidden'); + el('modalBackdrop').classList.remove('hidden'); + } else { + showAlert('Gagal', res.message || 'Gagal mengambil data'); + } + }) + .catch(e => { + console.error(e); + showAlert('Terjadi Kesalahan', 'Error: ' + e.toString() + '. Mohon segarkan (refresh) halaman.'); + }); + }; + +/* -- Render Jalan ---------------------------------------- */ +function renderJalan(item) { + try { + const feat = JSON.parse(item.koordinat); + const coords = lineFeatureToLatLngs(feat); + const poly = L.polyline(coords, { + color: warnaJalan(item.status_jalan), weight: 5, opacity: 0.85 + }).addTo(lgJalan); + + const popup = ` + + `; + + poly.bindPopup(popup, { maxWidth: 280 }); + return poly; + } catch (e) { console.error('renderJalan error:', e); } +} + +/* -- Render Polygon -------------------------------------- */ +function renderPolygon(item) { + try { + const feat = JSON.parse(item.koordinat); + const coords = polygonFeatureToLatLngs(feat); + const warna = warnaPolygon(item.status_kepemilikan); + const poly = L.polygon(coords, { + color: warna, fillColor: warna, fillOpacity: 0.3, weight: 3 + }).addTo(lgPolygon); + + const popup = ` + + `; + + poly.bindPopup(popup, { maxWidth: 280 }); + return poly; + } catch (e) { console.error('renderPolygon error:', e); } +} + +/* -- Render Fasilitas Publik ----------------------------- */ +function renderFasilitasPublik(item) { + const lat = parseFloat(item.latitude); + const lng = parseFloat(item.longitude); + const radius = parseFloat(item.radius); + + fasilitasPublikList.push(item); + + const marker = L.marker([lat, lng], { icon: createFPIcon() }).addTo(lgFasilitasPublik); + const circle = L.circle([lat, lng], { + radius, color: '#e53935', fillColor: '#ef9a9a', + fillOpacity: 0.12, weight: 2, dashArray: '6 4', + interactive: false + }).addTo(lgFasilitasPublik); + + const popup = ` + + `; + + marker.bindPopup(popup, { maxWidth: 300 }); + + return { marker, circle }; +} + +/* -- Render Penduduk Miskin ------------------------------ */ +function renderPenduduk(item) { + const lat = parseFloat(item.latitude); + const lng = parseFloat(item.longitude); + const cek = cekJangkauan(lat, lng); + + // FIX SPASIAL: Pastikan status jangkauan tersimpan di objek item untuk dirender di sidebar + item.status_jangkauan = cek.status; + + const marker = L.marker([lat, lng], { + icon: createPendudukIcon(cek.status === 'Dalam Jangkauan'), + draggable: (typeof USER_ROLE !== 'undefined' && USER_ROLE !== 'guest') + }).addTo(cek.status === 'Dalam Jangkauan' ? lgPendudukDalam : lgPendudukLuar); + + marker.on('dragend', function(e) { + const pos = e.target.getLatLng(); + updateLocation('penduduk_miskin', item.id, pos.lat, pos.lng, item.nama); + }); + + const badgeClass = cek.status === 'Dalam Jangkauan' ? 'badge-dalam' : 'badge-luar'; + + let fotoHtml = ''; + if (item.foto_rumah) { + fotoHtml += ``; + } + + const popup = ` + + `; + + marker.bindPopup(popup, { maxWidth: 300 }); + + pendudukList.push({ data: item, marker }); + return marker; +} + +/* -- Refresh Status Penduduk ----------------------------- */ +function refreshStatusPenduduk() { + lgPendudukDalam.clearLayers(); + lgPendudukLuar.clearLayers(); + const backup = pendudukList.map(p => p.data); + pendudukList = []; + backup.forEach(item => renderPenduduk(item)); + updateDashboardStats(); // Panggil fungsi update stat +} + +/* -- Update Statistik Dashboard Secara Real-Time --------- */ +function updateDashboardStats() { + const totalFP = fasilitasPublikList.length; + const totalPM = pendudukList.length; + + let ditangani = 0; + pendudukList.forEach(p => { + const cek = cekJangkauan(parseFloat(p.data.latitude), parseFloat(p.data.longitude)); + if (cek.status === 'Dalam Jangkauan') ditangani++; + }); + + const belum = totalPM - ditangani; + const persen = totalPM > 0 ? ((ditangani / totalPM) * 100).toFixed(1) : 0; + + // Update SPBU Stats + if (el('statTotalSPBU')) { + const totalSPBU = APP_DATA.points.length; + let spbu24 = 0; + APP_DATA.points.forEach(p => { if (p.buka_24_jam === 'Ya') spbu24++; }); + const spbuTidak = totalSPBU - spbu24; + el('statTotalSPBU').textContent = totalSPBU; + if (el('statSPBU24')) el('statSPBU24').textContent = spbu24; + if (el('statSPBUTidak')) el('statSPBUTidak').textContent = spbuTidak; + } + + // Update angka di HTML + if (el('statTotalRI')) el('statTotalRI').textContent = totalFP; + if (el('statTotalPM')) el('statTotalPM').textContent = totalPM; + if (el('statDitangani')) el('statDitangani').textContent = ditangani; + if (el('statBelum')) el('statBelum').textContent = belum; + if (el('statPersen')) el('statPersen').textContent = persen + '%'; + if (el('statProgressBar')) el('statProgressBar').style.width = persen + '%'; +} + +/* -- Real-Time Refresh Data (Modul Bantuan) -------------- */ +async function refreshDataModul() { + try { + const res = await fetch('api/get_all_bantuan.php'); + const json = await res.json(); + if (json.status === 'success') { + // 1. Update data global + APP_DATA.fasilitasPublik = json.fasilitasPublik; + APP_DATA.pendudukMiskin = json.pendudukMiskin; + + // 2. Clear layer peta + lgFasilitasPublik.clearLayers(); + lgPendudukDalam.clearLayers(); + lgPendudukLuar.clearLayers(); + + // 3. Reset array data lokal + fasilitasPublikList = []; + pendudukList = []; + + // 4. Render ulang marker (renderPenduduk akan otomatis hitung jarak spasial terbaru) + APP_DATA.fasilitasPublik.forEach(renderFasilitasPublik); + APP_DATA.pendudukMiskin.forEach(renderPenduduk); + + // 5. Update stat & panel data + updateDashboardStats(); + if(window.__rebuildCards) window.__rebuildCards(); + } + } catch (err) { + console.error('Gagal me-refresh data bantuan:', err); + } +} + +/* ------------------------------------------------------- + LOAD DATA AWAL +------------------------------------------------------- */ +(APP_DATA.points || []).forEach(renderPoint); +(APP_DATA.jalan || []).forEach(renderJalan); +(APP_DATA.polygons || []).forEach(renderPolygon); +(APP_DATA.fasilitasPublik || []).forEach(renderFasilitasPublik); +(APP_DATA.pendudukMiskin || []).forEach(renderPenduduk); + +/* ------------------------------------------------------- + LOAD GEOJSON EKSTERNAL +------------------------------------------------------- */ + +function loadGeoJSON(url, options = {}) { + return fetch(url) + .then(r => { if (!r.ok) throw new Error('Gagal load: ' + url); return r.json(); }) + .then(geojson => { + const layer = L.geoJSON(geojson, { + style: feat => { + const t = feat.geometry?.type || ''; + if (t === 'Polygon' || t === 'MultiPolygon') { + let fill = options.fillColor || '#42a5f5'; + if (options.dynamicColor && feat.properties) { + const nm = feat.properties.Ket || feat.properties.WADMKC || + feat.properties.KECAMATAN || feat.properties.nama || ''; + fill = warnaKecamatan(nm); + } + return { color: '#333', weight: 1.5, fillColor: fill, fillOpacity: 0.35 }; + } + if (t === 'LineString' || t === 'MultiLineString') { + return { color: options.color || '#1565c0', weight: options.weight || 2, opacity: 0.9 }; + } + return { color: options.color || '#1565c0' }; + }, + pointToLayer: (feat, latlng) => L.circleMarker(latlng, { + radius: 4, color: options.color || '#1565c0', + fillColor: options.fillColor || '#42a5f5', fillOpacity: 0.9, weight: 1 + }), + onEachFeature: (feat, layer) => { + if (!feat.properties) return; + let html = `'; + layer.bindPopup(html, { maxWidth: 280 }); + } + }); + + if (options.adjustWeight) { + map.on('zoomend', () => { + const z = map.getZoom(); + const w = z <= 12 ? 0.8 : z <= 14 ? 1.5 : z <= 16 ? 2 : 3; + layer.setStyle({ weight: w }); + }); + } + + if (options.addToMap !== false) { layer.addTo(map); if (options.fitBounds) map.fitBounds(layer.getBounds()); } return layer; + }) + .catch(err => { console.warn(err.message); return null; }); +} + +Promise.all([ + loadGeoJSON('data/Sungai_Besar.geojson', { + title: 'Sungai Besar', color: '#0d47a1', fillColor: '#90caf9', + weight: 2, fitBounds: false, addToMap: false + }), + loadGeoJSON('data/admin_kecamatan.geojson', { + title: 'Admin Kecamatan', dynamicColor: true, + color: '#ef6c00', weight: 1.5, fitBounds: false, addToMap: false + }), + loadGeoJSON('data/Jaringan_Jalan.geojson', { + title: 'Jaringan Jalan', color: '#d32f2f', + weight: 1.5, fitBounds: false, adjustWeight: true, addToMap: false + }) +]).then(([sungai, kecamatan, jalan]) => { + geoSungaiLayer = sungai; + geoKecamatanLayer = kecamatan; + geoJalanLayer = jalan; + + if (el('layerSungai') && el('layerSungai').checked && geoSungaiLayer) geoSungaiLayer.addTo(map); + if (el('layerKecamatan') && el('layerKecamatan').checked && geoKecamatanLayer) geoKecamatanLayer.addTo(map); + if (el('layerGeoJalan') && el('layerGeoJalan').checked && geoJalanLayer) geoJalanLayer.addTo(map); +}); + +/* ------------------------------------------------------- + REVERSE GEOCODING +------------------------------------------------------- */ + +function reverseGeocode(lat, lng) { + el('fp_alamat').value = ''; + el('geocodeStatus').textContent = '\u23F3 Mencari alamat...'; + + fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=id`) + .then(r => r.json()) + .then(data => { + el('fp_alamat').value = data.display_name || 'Alamat tidak ditemukan'; + el('geocodeStatus').textContent = '\u2713'; + setTimeout(() => { el('geocodeStatus').textContent = ''; }, 2000); + }) + .catch(() => { + el('fp_alamat').value = 'Gagal mengambil alamat'; + el('geocodeStatus').textContent = '\u2715'; + }); +} + +/* ------------------------------------------------------- + MAP EVENT HANDLERS +------------------------------------------------------- */ + +map.on('click', e => { + const { lat, lng } = e.latlng; + + // Mode yang tidak perlu timer (langsung eksekusi) + if (mode === 'point') { + clearAllPreview(); + el('point_id').value = ''; + el('point_nama').value = ''; + el('point_no_wa').value = ''; + el('point_latitude').value = lat.toFixed(10); + el('point_longitude').value = lng.toFixed(10); + document.querySelector('input[name="point_buka_24_jam"][value="Tidak"]').checked = true; + el('pointModalTitle').textContent = 'Tambah Titik'; + + previewMarker = L.marker(e.latlng, { icon: createPointIcon(false) }).addTo(map); + showModal('pointModal'); + return; + } + + if (mode === 'fasilitas_publik') { + el('fp_id').value = ''; + el('fp_nama').value = ''; + el('fp_jenis').value = 'Masjid'; + el('fp_kontak').value = ''; + el('fp_radius').value = 300; + el('fp_radius_text').textContent = '300'; + el('fp_latitude').value = lat.toFixed(10); + el('fp_longitude').value = lng.toFixed(10); + el('fpModalTitle').textContent = 'Tambah Fasilitas Publik'; + + if (previewMarker) map.removeLayer(previewMarker); + if (previewCircle) map.removeLayer(previewCircle); + previewMarker = L.marker(e.latlng, { icon: createFPIcon() }).addTo(map); + previewCircle = L.circle(e.latlng, { + radius: 300, color: '#e53935', fillColor: '#ef9a9a', + fillOpacity: 0.12, weight: 2, dashArray: '6 4' + }).addTo(map); + + reverseGeocode(lat, lng); + showModal('fasilitasPublikModal'); + return; + } + + if (mode === 'penduduk_miskin') { + el('pm_id').value = ''; + el('pm_nama').value = ''; + el('pm_nik').value = ''; + el('pm_keterangan').value = ''; + el('pm_latitude').value = lat.toFixed(10); + el('pm_longitude').value = lng.toFixed(10); + el('pmModalTitle').textContent = 'Tambah Penduduk Miskin'; + + const cek = cekJangkauan(lat, lng); + updateStatusJangkauan(cek); if (previewMarker) map.removeLayer(previewMarker); + previewMarker = L.marker(e.latlng, { + icon: createPendudukIcon(cek.status === 'Dalam Jangkauan') + }).addTo(map); + showModal('pendudukModal'); + return; + } + + // Khusus mode Menggambar (Jalan & Polygon), gunakan Timer + if (mode === 'jalan' || mode === 'polygon') { + if (clickTimer) clearTimeout(clickTimer); // Hapus timer lama jika ada klik berdekatan + + clickTimer = setTimeout(() => { + // Eksekusi jika benar-benar single-click + if (mode === 'jalan') { + jalanLatLngs.push(e.latlng); + updateJalanPreview(); + } else if (mode === 'polygon') { + polygonLatLngs.push(e.latlng); + updatePolygonPreview(); + } + }, 250); // Delay 250 milidetik + } +}); + +map.on('dblclick', () => { + if (clickTimer) { + clearTimeout(clickTimer); // Batalkan single-click jika terdeteksi double-click + clickTimer = null; + } + + if (mode === 'jalan') { + if (jalanLatLngs.length < 2) { showToast('Minimal 2 titik untuk jalan.', 'warning'); return; } + updateJalanPreview(); + showModal('jalanModal'); + } + if (mode === 'polygon') { + if (polygonLatLngs.length < 3) { showToast('Minimal 3 titik untuk polygon.', 'warning'); return; } + updatePolygonPreview(); + showModal('polygonModal'); + } +}); + +map.on('contextmenu', () => { + if (mode === 'jalan' && jalanLatLngs.length > 0) { + jalanLatLngs.pop(); updateJalanPreview(); return; + } + if (mode === 'polygon' && polygonLatLngs.length > 0) { + polygonLatLngs.pop(); updatePolygonPreview(); return; + } + if (mode === 'point') { + clearAllPreview(); + hideModal('pointModal'); + } +}); + +map.on('locationfound', e => { + showToast('Lokasi ditemukan!', 'success'); + L.marker(e.latlng) + .addTo(map) + .bindPopup('') + .openPopup(); +}); + +map.on('locationerror', () => { + showToast('Tidak dapat mengakses lokasi. Pastikan izin lokasi browser aktif.', 'error'); +}); + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { clearAllPreview(); hideAllModals(); } +}); + +/* ------------------------------------------------------- + STATUS JANGKAUAN UI +------------------------------------------------------- */ +function updateStatusJangkauan(cek) { + const elStatus = el('pm_status'); + const elDitangani = el('pm_ditangani'); + elStatus.textContent = cek.status; + elStatus.className = 'sj-value ' + (cek.status === 'Dalam Jangkauan' ? 'dalam' : 'luar'); + elDitangani.textContent = cek.nama ? `Ditangani oleh: ${cek.nama}` : 'Tidak dalam jangkauan Fasilitas Publik manapun.'; +} + +/* ------------------------------------------------------- + TOOL BUTTONS +------------------------------------------------------- */ +document.querySelectorAll('.tool-btn').forEach(btn => { + btn.addEventListener('click', () => { + const m = btn.dataset.mode; + if (m) setMode(m); + }); +}); + +/* ------------------------------------------------------- + SIDEBAR TOGGLE +------------------------------------------------------- */ + /* -- Toggle Sidebar -------------------------------------- */ + const sidebar = el('sidebar'); + const mapContainer = el('mapContainer'); + const sidebarOpenBtn= el('sidebarOpenBtn'); + const isMobile = () => window.innerWidth <= 680; + + el('sidebarToggle').addEventListener('click', () => { + if (isMobile()) { + sidebar.classList.remove('mobile-open'); + } else { + sidebar.classList.add('collapsed'); + mapContainer.classList.add('full-width'); + sidebarOpenBtn.classList.remove('hidden'); + map.invalidateSize(); + } + }); + + sidebarOpenBtn.addEventListener('click', () => { + if (isMobile()) { + sidebar.classList.add('mobile-open'); + } else { + sidebar.classList.remove('collapsed'); + mapContainer.classList.remove('full-width'); + sidebarOpenBtn.classList.add('hidden'); + map.invalidateSize(); + } + }); + +/* ------------------------------------------------------- + LAYER CHECKBOXES +------------------------------------------------------- */ +function bindLayerToggle(checkboxId, layerGroup) { + const cb = el(checkboxId); + if (!cb) return; + cb.addEventListener('change', () => { + if (cb.checked) { + if (!map.hasLayer(layerGroup)) layerGroup.addTo(map); + } else { + map.removeLayer(layerGroup); + } + }); +} + +bindLayerToggle('layerFasilitasPublik', lgFasilitasPublik); +bindLayerToggle('layerPendudukDalam', lgPendudukDalam); +bindLayerToggle('layerPendudukLuar', lgPendudukLuar); + +bindLayerToggle("layerSpbu24", lgSpbu24); +bindLayerToggle("layerSpbuTidak", lgSpbuTidak); +bindLayerToggle("layerJalan", lgJalan); +bindLayerToggle("layerPolygon", lgPolygon); + +if (el('layerGeoJalan')) { + el('layerGeoJalan').addEventListener('change', function () { + if (geoJalanLayer) { this.checked ? geoJalanLayer.addTo(map) : map.removeLayer(geoJalanLayer); } + }); +} +if (el('layerKecamatan')) { + el('layerKecamatan').addEventListener('change', function () { + if (geoKecamatanLayer) { this.checked ? geoKecamatanLayer.addTo(map) : map.removeLayer(geoKecamatanLayer); } + }); +} +if (el('layerSungai')) { + el('layerSungai').addEventListener('change', function () { + if (geoSungaiLayer) { this.checked ? geoSungaiLayer.addTo(map) : map.removeLayer(geoSungaiLayer); } + }); +} + +/* ------------------------------------------------------- + RADIUS SLIDER -> PREVIEW +------------------------------------------------------- */ +el('fp_radius').addEventListener('input', function () { + const r = parseInt(this.value, 10); + el('fp_radius_text').textContent = r; + if (previewCircle) previewCircle.setRadius(r); +}); + +/* ------------------------------------------------------- + MODAL CLOSE BUTTONS +------------------------------------------------------- */ +[ + ['closePointModal', 'pointModal'], + ['cancelPointModal', 'pointModal'], + ['closeJalanModal', 'jalanModal'], + ['cancelJalanModal', 'jalanModal'], + ['closePolygonModal', 'polygonModal'], + ['cancelPolygonModal', 'polygonModal'], + ['closefasilitasPublikModal', 'fasilitasPublikModal'], + ['cancelfasilitasPublikModal','fasilitasPublikModal'], + ['closePendudukModal', 'pendudukModal'], + ['cancelPendudukModal', 'pendudukModal'], +].forEach(([btnId, modalId]) => { + const btn = el(btnId); + if (btn) btn.addEventListener('click', () => { + hideModal(modalId); + clearAllPreview(); + }); +}); + +el('modalBackdrop').addEventListener('click', () => { + hideAllModals(); + clearAllPreview(); +}); + +/* Konfirmasi hapus */ +el('confirmDelete').addEventListener('click', () => { + if (typeof deleteCallback === 'function') deleteCallback(); + hideModal('deleteModal'); + deleteCallback = null; +}); +el('cancelDelete').addEventListener('click', () => { + hideModal('deleteModal'); + deleteCallback = null; +}); + +/* ------------------------------------------------------- + SAVE: POINT +------------------------------------------------------- */ +el('btnSavePoint').addEventListener('click', () => { + const nama = el('point_nama').value.trim(); + const no_wa = el('point_no_wa').value.trim(); + const radio = document.querySelector('input[name="point_buka_24_jam"]:checked'); + const lat = el('point_latitude').value.trim(); + const lng = el('point_longitude').value.trim(); + + if (!nama || !no_wa || !radio || !lat || !lng) { + showAlert('Data Tidak Lengkap', 'Peringatan: Semua kolom wajib diisi dan Anda harus mengklik peta untuk menentukan lokasi SPBU terlebih dahulu.'); + return; + } + + const id = el('point_id').value; + const url = id ? 'point/update.php' : 'point/simpan.php'; + + const fd = new FormData(); + if (id) fd.append('id', id); + fd.append('nama', nama); + fd.append('no_wa', no_wa); + fd.append('buka_24_jam', radio.value); + fd.append('latitude', lat); + fd.append('longitude', lng); + + postData(url, fd).then(res => { + if (res.status === 'success') { + if (previewMarker) map.removeLayer(previewMarker); + + if (id) { + const idx = APP_DATA.points.findIndex(x => String(x.id) === String(id)); + if (idx > -1) APP_DATA.points[idx] = res.data; + lgSpbu24.clearLayers(); + lgSpbuTidak.clearLayers(); + APP_DATA.points.forEach(renderPoint); + } else { + APP_DATA.points.push(res.data); + renderPoint(res.data); + } + + updateDashboardStats(); + if(window.__rebuildCards) window.__rebuildCards(); + + hideModal('pointModal'); + showToast(id ? 'Titik berhasil diperbarui.' : 'Titik berhasil disimpan.'); + } else { + showToast(res.message || 'Gagal menyimpan titik.', 'error'); + } + }); +}); + +/* ------------------------------------------------------- + SAVE: JALAN +------------------------------------------------------- */ +el('btnSaveJalan').addEventListener('click', () => { + const nama = el('nama_jalan').value.trim(); + const status = el('status_jalan').value; + const panjang = el('panjang_meter').value; + const koordinat = el('koordinat_jalan').value.trim(); + + if (!nama || !status || jalanLatLngs.length < 2) { + showAlert('Data Tidak Lengkap', 'Peringatan: Nama, status jalan wajib diisi dan Anda harus menggambar garis (minimal 2 titik) di peta.'); + return; + } + + const fd = new FormData(); + fd.append('nama_jalan', nama); + fd.append('status_jalan', status); + fd.append('panjang_meter', panjang || '0'); + fd.append('koordinat', koordinat); + + postData('jalan/simpan.php', fd).then(res => { + if (res.status === 'success') { + clearAllPreview(); + APP_DATA.jalan.push(res.data); + renderJalan(res.data); + hideModal('jalanModal'); + el('nama_jalan').value = ''; + el('status_jalan').value = ''; + if(window.__rebuildCards) window.__rebuildCards(); + showToast('Jalan berhasil disimpan.'); + } else { + showToast(res.message || 'Gagal menyimpan jalan.', 'error'); + } + }); +}); + +/* ------------------------------------------------------- + SAVE: POLYGON +------------------------------------------------------- */ +el('btnSavePolygon').addEventListener('click', () => { + const nama = el('nama_parsil').value.trim(); + const status = el('status_kepemilikan').value; + const luas = el('luas_tanah').value; + const koordinat = el('koordinat_polygon').value.trim(); + + if (!nama || !status || polygonLatLngs.length < 3) { + showAlert('Data Tidak Lengkap', 'Peringatan: Nama, status kepemilikan wajib diisi dan Anda harus menggambar area (minimal 3 titik) di peta.'); + return; + } + + const fd = new FormData(); + fd.append('nama_parsil', nama); + fd.append('status_kepemilikan', status); + fd.append('luas_tanah', luas || '0'); + fd.append('koordinat', koordinat); + + postData('polygon/simpan.php', fd).then(res => { + if (res.status === 'success') { + clearAllPreview(); + APP_DATA.polygons.push(res.data); + renderPolygon(res.data); + hideModal('polygonModal'); + el('nama_parsil').value = ''; + el('status_kepemilikan').value = ''; + if(window.__rebuildCards) window.__rebuildCards(); + showToast('Polygon berhasil disimpan.'); + } else { + showToast(res.message || 'Gagal menyimpan polygon.', 'error'); + } + }); +}); + +/* ------------------------------------------------------- + SAVE: Fasilitas Publik +------------------------------------------------------- */ +el('btnSaveFasilitasPublik').addEventListener('click', () => { + const nama = el('fp_nama').value.trim(); + const lat = el('fp_latitude').value.trim(); + const lng = el('fp_longitude').value.trim(); + + if (!nama || !lat || !lng) { + showAlert('Data Tidak Lengkap', 'Peringatan: Semua kolom wajib diisi dan Anda harus mengklik peta untuk menentukan lokasi Fasilitas Publik terlebih dahulu.'); + return; + } + + const id = el('fp_id').value; + const url = id ? 'fasilitas_publik/update.php' : 'fasilitas_publik/simpan.php'; + + const fd = new FormData(); + if (id) fd.append('id', id); + fd.append('nama', nama); + fd.append('jenis', el('fp_jenis').value); + fd.append('alamat', el('fp_alamat').value.trim()); + fd.append('latitude', lat); + fd.append('longitude', lng); + fd.append('radius', el('fp_radius').value); + fd.append('kontak', el('fp_kontak').value.trim()); + + postData(url, fd).then(res => { + if (res.status === 'success') { + if (previewMarker) map.removeLayer(previewMarker); + if (previewCircle) map.removeLayer(previewCircle); + previewMarker = null; previewCircle = null; + + // Panggil refreshDataModul secara real-time tanpa reload + refreshDataModul(); + + hideModal('fasilitasPublikModal'); + showToast(id ? 'Fasilitas Publik diperbarui.' : 'Fasilitas Publik berhasil disimpan.'); + } else { + showToast(res.message || 'Gagal menyimpan.', 'error'); + } + }); +}); + +/* ------------------------------------------------------- + SAVE: PENDUDUK MISKIN +------------------------------------------------------- */ +el('btnSavePenduduk').addEventListener('click', () => { + const nama = el('pm_nama').value.trim(); + const lat = el('pm_latitude').value.trim(); + const lng = el('pm_longitude').value.trim(); + + if (!nama || !lat || !lng) { + showAlert('Data Tidak Lengkap', 'Peringatan: Semua kolom wajib diisi dan Anda harus mengklik peta untuk menentukan lokasi Penduduk terlebih dahulu.'); + return; + } + + const cek = cekJangkauan(parseFloat(lat), parseFloat(lng)); + const id = el('pm_id').value; + const url = id ? 'penduduk_miskin/update.php' : 'penduduk_miskin/simpan.php'; + + const fd = new FormData(); + if (id) fd.append('id', id); + fd.append('nama', nama); + fd.append('nik', el('pm_nik').value.trim()); + fd.append('keterangan', el('pm_keterangan').value.trim()); + fd.append('latitude', lat); + fd.append('longitude', lng); + fd.append('status_jangkauan', cek.status); + fd.append('status_bantuan', el('pm_status_bantuan').value); + fd.append('fasilitas_publik_id', cek.id || ''); + + const fileRumah = el('pm_foto_rumah').files[0]; + if (fileRumah) fd.append('foto_rumah', fileRumah); + const fileKk = el('pm_foto_kk').files[0]; + if (fileKk) fd.append('foto_kk', fileKk); + + postData(url, fd).then(res => { + if (res.status === 'success') { + if (previewMarker) { map.removeLayer(previewMarker); previewMarker = null; } + + // Panggil refreshDataModul secara real-time tanpa reload + refreshDataModul(); + + hideModal('pendudukModal'); + showToast(id ? 'Data penduduk diperbarui.' : 'Penduduk miskin berhasil disimpan.'); + } else { + showToast(res.message || 'Gagal menyimpan.', 'error'); + } + }); +}); + +/* ------------------------------------------------------- + GLOBAL: HAPUS DATA +------------------------------------------------------- */ +window.hapusData = function (tipe, id, pesan) { + showDeleteConfirm(pesan || 'Hapus data ini?', () => { + const urlMap = { + point: 'point/hapus.php', + jalan: 'jalan/hapus.php', + polygon: 'polygon/hapus.php', + fasilitas_publik: 'fasilitas_publik/hapus.php', + penduduk_miskin: 'penduduk_miskin/hapus.php', + }; + + const fd = new FormData(); + fd.append('id', id); + + postData(urlMap[tipe], fd).then(res => { + if (res.status === 'success') { + map.closePopup(); // Tutup popup di map + showToast('Data berhasil dihapus.', 'success'); + if(window.__rebuildCards) setTimeout(() => window.__rebuildCards(), 150); + + // Hapus elemen dari Peta TANPA refresh web + if (tipe === 'point') { + const idx = APP_DATA.points.findIndex(x => String(x.id) === String(id)); + if (idx > -1) APP_DATA.points.splice(idx, 1); + lgSpbu24.clearLayers(); + lgSpbuTidak.clearLayers(); + APP_DATA.points.forEach(renderPoint); + updateDashboardStats(); + } else if (tipe === 'jalan') { + const idx = APP_DATA.jalan.findIndex(x => String(x.id) === String(id)); + if (idx > -1) APP_DATA.jalan.splice(idx, 1); + lgJalan.clearLayers(); + APP_DATA.jalan.forEach(renderJalan); + } else if (tipe === 'polygon') { + const idx = APP_DATA.polygons.findIndex(x => String(x.id) === String(id)); + if (idx > -1) APP_DATA.polygons.splice(idx, 1); + lgPolygon.clearLayers(); + APP_DATA.polygons.forEach(renderPolygon); + } else if (tipe === 'fasilitas_publik' || tipe === 'penduduk_miskin') { + // Khusus modul bantuan, panggil refresh penuh + refreshDataModul(); + } + + } else { + showToast(res.message || 'Gagal menghapus.', 'error'); + } + }); + }); +}; + +/* ------------------------------------------------------- + GLOBAL: UPDATE LOKASI DARI DRAG MARKER +------------------------------------------------------- */ +window.updateLocation = function(tipe, id, lat, lng, nama) { + const urlMap = { + point: 'point/update_lokasi.php', + penduduk_miskin: 'penduduk_miskin/update_lokasi.php' + }; + + const fd = new FormData(); + fd.append('id', id); + fd.append('latitude', lat.toFixed(10)); + fd.append('longitude', lng.toFixed(10)); + + if (tipe === 'penduduk_miskin') { + const cek = cekJangkauan(lat, lng); + fd.append('status_jangkauan', cek.status); + fd.append('fasilitas_publik_id', cek.id || ''); + } + + postData(urlMap[tipe], fd).then(res => { + if (res.status === 'success') { + showToast('Lokasi ' + nama + ' berhasil diperbarui.', 'success'); + if (tipe === 'penduduk_miskin') { + refreshDataModul(); + } else if (tipe === 'point') { + const idx = APP_DATA.points.findIndex(x => String(x.id) === String(id)); + if (idx > -1) { + APP_DATA.points[idx].latitude = lat; + APP_DATA.points[idx].longitude = lng; + } + } + } else { + showToast('Gagal mengubah lokasi ' + nama, 'error'); + if (tipe === 'penduduk_miskin') refreshDataModul(); + } + }); +}; + +/* ------------------------------------------------------- + GLOBAL: EDIT Fasilitas Publik +------------------------------------------------------- */ +window.editFasilitasPublik = function (id) { + fetch(`api/get_fasilitas_publik.php?id=${id}`) + .then(r => r.json()) + .then(res => { + if (res.status !== 'success') { showToast('Data tidak ditemukan.', 'error'); return; } + const d = res.data; + el('fp_id').value = d.id; + el('fp_nama').value = d.nama; + el('fp_jenis').value = d.jenis || 'Masjid'; + el('fp_alamat').value = d.alamat || ''; + el('fp_kontak').value = d.kontak || ''; + el('fp_latitude').value = d.latitude; + el('fp_longitude').value= d.longitude; + el('fp_radius').value = d.radius; + el('fp_radius_text').textContent = d.radius; + el('fpModalTitle').textContent = 'Edit Fasilitas Publik'; + + // Preview di peta + if (previewMarker) map.removeLayer(previewMarker); + if (previewCircle) map.removeLayer(previewCircle); + const ll = L.latLng(parseFloat(d.latitude), parseFloat(d.longitude)); + previewMarker = L.marker(ll, { icon: createFPIcon() }).addTo(map); + previewCircle = L.circle(ll, { + radius: parseInt(d.radius), + color: '#e53935', fillColor: '#ef9a9a', fillOpacity: 0.12, + weight: 2, dashArray: '6 4', interactive: false + }).addTo(map); + map.setView(ll, 16); + + map.closePopup(); + showModal('fasilitasPublikModal'); + }) + .catch(() => showToast('Gagal memuat data.', 'error')); +}; + +/* ------------------------------------------------------- + GLOBAL: EDIT PENDUDUK +------------------------------------------------------- */ +window.editPenduduk = function (id) { + fetch(`api/get_penduduk.php?id=${id}`) + .then(r => r.json()) + .then(res => { + if (res.status !== 'success') { showToast('Data tidak ditemukan.', 'error'); return; } + const d = res.data; + el('pm_id').value = d.id; + el('pm_nama').value = d.nama; + el('pm_nik').value = d.nik || ''; + el('pm_keterangan').value = d.keterangan || ''; + el('pm_status_bantuan').value = d.status_bantuan || 'Belum Disalurkan'; + el('pm_latitude').value = d.latitude; + el('pm_longitude').value = d.longitude; + el('pmModalTitle').textContent = 'Edit Penduduk Miskin'; + + const cek = cekJangkauan(parseFloat(d.latitude), parseFloat(d.longitude)); + updateStatusJangkauan(cek); + if (previewMarker) map.removeLayer(previewMarker); + const ll = L.latLng(parseFloat(d.latitude), parseFloat(d.longitude)); + previewMarker = L.marker(ll, { + icon: createPendudukIcon(cek.status === 'Dalam Jangkauan') + }).addTo(map); + map.setView(ll, 16); + map.closePopup(); + showModal('pendudukModal'); + }) + .catch(() => showToast('Gagal memuat data.', 'error')); +}; + +/* ------------------------------------------------------- + UTILITY: POST DATA +------------------------------------------------------- */ +function postData(url, formData) { + return fetch(url, { method: 'POST', body: formData }) + .then(r => r.json()) + .catch(err => { + console.error(err); + showToast('Terjadi kesalahan koneksi.', 'error'); + return { status: 'error', message: 'Koneksi gagal.' }; + }); +} + +/* ------------------------------------------------------- + FITUR PENCARIAN (SEARCHING) +------------------------------------------------------- */ +const searchInput = el('searchInput'); +const searchResults = el('searchResults'); + +if (searchInput && searchResults) { + searchInput.addEventListener('input', function() { + const query = this.value.toLowerCase().trim(); + searchResults.innerHTML = ''; + + // Minimal ketik 2 huruf baru mencari + if (query.length < 2) { + searchResults.classList.add('hidden'); + return; + } + + let results = []; + + // 1. Cari Titik Biasa + APP_DATA.points.forEach(item => { + if (item.nama.toLowerCase().includes(query)) { + results.push({ title: item.nama, type: '📍 Titik Lokasi', lat: item.latitude, lng: item.longitude }); + } + }); + + // 2. Cari Fasilitas Publik + fasilitasPublikList.forEach(item => { + if (item.nama.toLowerCase().includes(query)) { + results.push({ title: item.nama, type: `🏢 Fasilitas Publik (${item.jenis})`, lat: item.latitude, lng: item.longitude }); + } + }); + + // 3. Cari Penduduk Miskin (Berdasarkan Nama atau NIK) + pendudukList.forEach(p => { + if (p.data.nama.toLowerCase().includes(query) || (p.data.nik && p.data.nik.includes(query))) { + results.push({ title: p.data.nama, type: '👤 Penduduk Miskin', lat: p.data.latitude, lng: p.data.longitude }); + } + }); + + // 4. Cari Jalan + APP_DATA.jalan.forEach(item => { + if (item.nama_jalan.toLowerCase().includes(query)) { + try { + const feat = JSON.parse(item.koordinat); + // Ambil titik koordinat pertama dari garis + const coords = feat.geometry.coordinates[0]; + results.push({ title: item.nama_jalan, type: `🛣️ Jalan (${item.status_jalan})`, lat: coords[1], lng: coords[0] }); + } catch(e) {} + } + }); + + // 5. Cari Polygon + APP_DATA.polygons.forEach(item => { + if (item.nama_parsil.toLowerCase().includes(query)) { + try { + const feat = JSON.parse(item.koordinat); + // Ambil titik koordinat pertama dari polygon + const coords = feat.geometry.coordinates[0][0]; + results.push({ title: item.nama_parsil, type: `📐 Polygon (${item.status_kepemilikan})`, lat: coords[1], lng: coords[0] }); + } catch(e) {} + } + }); + + // Tampilkan Hasil + if (results.length > 0) { + results.forEach(res => { + const div = document.createElement('div'); + div.className = 'search-item'; + div.innerHTML = ` + ${res.title} + ${res.type} + `; + + // Aksi saat item hasil dicentang + div.addEventListener('click', () => { + // Animasi terbang ke lokasi + map.flyTo([parseFloat(res.lat), parseFloat(res.lng)], 18, { + animate: true, + duration: 1.5 + }); + + searchResults.classList.add('hidden'); + searchInput.value = res.title; // Isi kotak input dengan nama yang dipilih + + // Khusus tampilan HP: Sembunyikan sidebar agar peta terlihat langsung + if (window.innerWidth <= 680) { + el('sidebar').classList.remove('mobile-open'); + } + }); + + searchResults.appendChild(div); + }); + } else { + searchResults.innerHTML = '
Tidak ada data ditemukan
'; + } + + searchResults.classList.remove('hidden'); + }); + + // Menyembunyikan dropdown jika klik di tempat lain + document.addEventListener('click', (e) => { + if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) { + searchResults.classList.add('hidden'); + } + }); +} + +/* -- Init ------------------------------------------------ */ +/* -- Modul Filter & Init -------------------------------- */ +function setupModul(modul) { + const tools = ['btnToolPoint', 'btnToolJalan', 'btnToolPolygon', 'btnToolFasilitasPublik', 'btnToolPenduduk']; + const layers = ['layerFasilitasPublik', 'layerPendudukDalam', 'layerPendudukLuar', 'layerSpbu24', 'layerSpbuTidak', 'layerJalan', 'layerPolygon', 'layerGeoJalan', 'layerKecamatan', 'layerSungai']; + + // Sembunyikan semua kecuali utuh + if (modul !== 'utuh') { + tools.forEach(t => { if(el(t)) el(t).style.display = 'none'; }); + layers.forEach(l => { + let lgName = ''; + if (l === 'layerSpbu24') lgName = 'lgSpbu24'; + else if (l === 'layerSpbuTidak') lgName = 'lgSpbuTidak'; + else if (l === 'layerPendudukDalam') lgName = 'lgPendudukDalam'; + else if (l === 'layerPendudukLuar') lgName = 'lgPendudukLuar'; + else lgName = l.replace('layer','lg').replace('Geo','geo').replace('Kecamatan','geoKecamatan').replace('Sungai','geoSungai'); + + try { if(eval(lgName)) map.removeLayer(eval(lgName)); } catch(e){} + + if(el(l)) { + el(l).parentElement.style.display = 'none'; + el(l).checked = false; + } + }); + } + + if (modul === 'spbu') { + if (USER_ROLE !== 'guest') { + if(el('btnToolPoint')) el('btnToolPoint').style.display = 'inline-flex'; + } + if(el('layerSpbu24')) { el('layerSpbu24').parentElement.style.display = 'flex'; el('layerSpbu24').checked = true; lgSpbu24.addTo(map); } + if(el('layerSpbuTidak')) { el('layerSpbuTidak').parentElement.style.display = 'flex'; el('layerSpbuTidak').checked = true; lgSpbuTidak.addTo(map); } + setMode(USER_ROLE === 'guest' ? 'lokasi' : 'point'); + } else if (modul === 'bantuan') { + if (USER_ROLE !== 'guest') { + if(el('btnToolPenduduk')) el('btnToolPenduduk').style.display = 'inline-flex'; + if(el('btnToolFasilitasPublik')) el('btnToolFasilitasPublik').style.display = 'inline-flex'; + } + if(el('layerFasilitasPublik')) { el('layerFasilitasPublik').parentElement.style.display = 'flex'; el('layerFasilitasPublik').checked = true; lgFasilitasPublik.addTo(map); } + if(el('layerPendudukDalam')) { el('layerPendudukDalam').parentElement.style.display = 'flex'; el('layerPendudukDalam').checked = true; lgPendudukDalam.addTo(map); } + if(el('layerPendudukLuar')) { el('layerPendudukLuar').parentElement.style.display = 'flex'; el('layerPendudukLuar').checked = true; lgPendudukLuar.addTo(map); } + setMode(USER_ROLE === 'guest' ? 'lokasi' : 'penduduk_miskin'); + } else if (modul === 'tematik') { + if(el('layerGeoJalan')) { el('layerGeoJalan').parentElement.style.display = 'flex'; el('layerGeoJalan').checked = true; if(geoJalanLayer) geoJalanLayer.addTo(map); } + if(el('layerKecamatan')) { el('layerKecamatan').parentElement.style.display = 'flex'; el('layerKecamatan').checked = true; if(geoKecamatanLayer) geoKecamatanLayer.addTo(map); } + if(el('layerSungai')) { el('layerSungai').parentElement.style.display = 'flex'; el('layerSungai').checked = true; if(geoSungaiLayer) geoSungaiLayer.addTo(map); } + setMode('lokasi'); + } else if (modul === 'digitasi') { + if (USER_ROLE !== 'guest') { + if(el('btnToolJalan')) el('btnToolJalan').style.display = 'inline-flex'; + if(el('btnToolPolygon')) el('btnToolPolygon').style.display = 'inline-flex'; + if(el('btnToolPoint')) el('btnToolPoint').style.display = 'inline-flex'; + } + if(el('layerSpbu24')) { el('layerSpbu24').parentElement.style.display = 'flex'; el('layerSpbu24').checked = true; lgSpbu24.addTo(map); } + if(el('layerSpbuTidak')) { el('layerSpbuTidak').parentElement.style.display = 'flex'; el('layerSpbuTidak').checked = true; lgSpbuTidak.addTo(map); } + if(el('layerJalan')) { el('layerJalan').parentElement.style.display = 'flex'; el('layerJalan').checked = true; lgJalan.addTo(map); } + if(el('layerPolygon')) { el('layerPolygon').parentElement.style.display = 'flex'; el('layerPolygon').checked = true; lgPolygon.addTo(map); } + setMode(USER_ROLE === 'guest' ? 'lokasi' : 'polygon'); + } else { // utuh + if (typeof USER_ROLE !== 'undefined') { + if (USER_ROLE === 'guest') setMode('lokasi'); + else if (USER_ROLE === 'petugas') setMode('penduduk_miskin'); + else setMode('point'); + } else { + setMode('point'); + } + } + + // Force hide section tools if guest + if (typeof USER_ROLE !== 'undefined' && USER_ROLE === 'guest' && el('sectionTools')) { + el('sectionTools').classList.add('hidden'); + } +} + +/* -- Alert / Modal Helper -------------------------------- */ +window.showAlert = function(title, message) { + el('alertTitle').textContent = title; + el('alertMessage').innerHTML = message; + el('alertModal').classList.remove('hidden'); + el('modalBackdrop').classList.remove('hidden'); +}; + +if(el('btnAlertOk')) { + el('btnAlertOk').addEventListener('click', () => { + el('alertModal').classList.add('hidden'); + // Hanya sembunyikan backdrop jika tidak ada modal lain yang terbuka + if(document.querySelectorAll('.modal:not(.hidden)').length === 0) { + el('modalBackdrop').classList.add('hidden'); + } + }); +} + +// Set mode awal +try { + setupModul(APP_DATA.modul || 'utuh'); +} catch(e) { + console.error("Error in setupModul:", e); +} + +/* =============================================================== + RIGHT SIDEBAR – PANEL KANAN + Data List Cards per Modul, flyTo, Search Filter +=============================================================== */ + +const rightSidebar = document.getElementById('rightSidebar'); +const rightSidebarClose = document.getElementById('rightSidebarClose'); +const rightSidebarToggle = document.getElementById('rightSidebarToggleBtn'); +const rightCardList = document.getElementById('rightCardList'); +const rightCardCount = document.getElementById('rightCardCount'); +const rightSearchInput = document.getElementById('rightSearchInput'); + +/* ── Marker registry: map from data-id + type → Leaflet marker/layer ── */ +const markerRegistry = {}; + +/* ── Register markers after render ─────────────────────────────── */ +// Override renderPoint to register +const _origRenderPoint = renderPoint; +function renderPointAndRegister(item) { + const mk = _origRenderPoint(item); + if (mk) markerRegistry['point_' + item.id] = { marker: mk, lat: parseFloat(item.latitude), lng: parseFloat(item.longitude) }; + return mk; +} + +/* We need to patch the existing render calls */ +/* Re-register all existing data loaded at init */ +function registerAllMarkers() { + /* SPBU / Points */ + lgSpbu24.eachLayer(mk => { + // match by iterating APP_DATA.points + }); + /* We'll use a direct lookup approach by storing references during renderPoint */ +} + +/* ── Right Sidebar Toggle ─────────────────────────────────────── */ +let rightOpen = true; + +function openRightSidebar() { + rightSidebar.classList.remove('right-collapsed'); + el('mapContainer').classList.add('with-right-sidebar'); + rightSidebarToggle.classList.add('hidden'); + map.invalidateSize(); + rightOpen = true; +} + +function closeRightSidebar() { + rightSidebar.classList.add('right-collapsed'); + el('mapContainer').classList.remove('with-right-sidebar'); + rightSidebarToggle.classList.remove('hidden'); + map.invalidateSize(); + rightOpen = false; +} + +if (rightSidebarClose) rightSidebarClose.addEventListener('click', closeRightSidebar); +if (rightSidebarToggle) rightSidebarToggle.addEventListener('click', openRightSidebar); + +// On mobile or tematik: right sidebar starts closed +if (window.innerWidth <= 680 || APP_DATA.modul === 'tematik') { + closeRightSidebar(); +} else { + openRightSidebar(); +} + +/* ── Data Card Factory ─────────────────────────────────────────── */ + +function makeDataCard({ icon, iconClass, name, sub, badge, badgeClass, footerHtml, coords, onClick }) { + const card = document.createElement('div'); + card.className = 'data-card'; + + const hdr = document.createElement('div'); + hdr.className = 'data-card-header'; + + const iconWrap = document.createElement('div'); + iconWrap.className = `data-card-icon-wrap ${iconClass}`; + iconWrap.textContent = icon; + + const body = document.createElement('div'); + body.className = 'data-card-body'; + + const nameEl = document.createElement('div'); + nameEl.className = 'data-card-name'; + nameEl.title = name; + nameEl.textContent = name; + + const subEl = document.createElement('div'); + subEl.className = 'data-card-sub'; + subEl.textContent = sub; + + body.appendChild(nameEl); + body.appendChild(subEl); + + hdr.appendChild(iconWrap); + hdr.appendChild(body); + + if (badge) { + const badgeEl = document.createElement('span'); + badgeEl.className = `data-card-badge ${badgeClass}`; + badgeEl.textContent = badge; + hdr.appendChild(badgeEl); + } + + card.appendChild(hdr); + + if (coords) { + const coordEl = document.createElement('div'); + coordEl.className = 'data-card-coords'; + coordEl.innerHTML = ` ${coords}`; + card.appendChild(coordEl); + } + + if (footerHtml) { + const footEl = document.createElement('div'); + footEl.className = 'data-card-footer'; + footEl.innerHTML = footerHtml; + card.appendChild(footEl); + } + + card.addEventListener('click', onClick); + return card; +} + +function emptyState(icon, title, sub) { + const div = document.createElement('div'); + div.className = 'empty-state'; + div.innerHTML = ` +
${icon}
+
${title}
+
${sub}
+ `; + return div; +} + +function updateCount(count) { + if (rightCardCount) rightCardCount.textContent = `${count} entri`; +} + +/* ── Fly & Open Popup ─────────────────────────────────────────── */ +function flyToAndOpenPopup(lat, lng, markerOrLayer) { + map.flyTo([lat, lng], 17, { animate: true, duration: 1.2 }); + if (markerOrLayer) { + setTimeout(() => { + try { markerOrLayer.openPopup(); } catch(e) {} + }, 1400); + } + // On mobile: close right sidebar to see map + if (window.innerWidth <= 680) closeRightSidebar(); +} + +/* ── Build Cards by Modul ─────────────────────────────────────── */ + +/** Find Leaflet marker from a layer group by lat/lng */ +function findMarkerInGroup(lg, lat, lng) { + let found = null; + lg.eachLayer(mk => { + if (found) return; + const ll = mk.getLatLng ? mk.getLatLng() : null; + if (ll && Math.abs(ll.lat - lat) < 0.0001 && Math.abs(ll.lng - lng) < 0.0001) found = mk; + }); + return found; +} + +function buildCardsSpbu(data, container) { + if (!data.length) { + container.appendChild(emptyState('⛽', 'Belum Ada SPBU', 'Tambahkan data SPBU menggunakan alat peta.')); + return 0; + } + data.forEach(item => { + const lat = parseFloat(item.latitude), lng = parseFloat(item.longitude); + const is24 = item.buka_24_jam === 'Ya'; + const card = makeDataCard({ + icon: '⛽', + iconClass: 'dc-icon-spbu', + name: item.nama, + sub: `WA: ${item.no_wa || '-'}`, + badge: is24 ? 'Buka 24 Jam' : 'Tidak 24 Jam', + badgeClass: is24 ? 'badge-24jam' : 'badge-tidak24', + coords: `${lat.toFixed(5)}, ${lng.toFixed(5)}`, + onClick: () => { + const mk = findMarkerInGroup(is24 ? lgSpbu24 : lgSpbuTidak, lat, lng); + flyToAndOpenPopup(lat, lng, mk); + } + }); + container.appendChild(card); + }); + return data.length; +} + +function buildCardsBantuan(pmData, container) { + if (!pmData.length) { + container.appendChild(emptyState('👥', 'Belum Ada Data Penduduk', 'Tambahkan data penduduk miskin menggunakan alat peta.')); + return 0; + } + pmData.forEach(item => { + const lat = parseFloat(item.data.latitude), lng = parseFloat(item.data.longitude); + const isDalam = item.data.status_jangkauan === 'Dalam Jangkauan'; + const card = makeDataCard({ + icon: '👤', + iconClass: 'dc-icon-pm', + name: item.data.nama, + sub: `NIK: ${item.data.nik || '-'}`, + badge: isDalam ? 'Dalam Jangkauan' : 'Luar Jangkauan', + badgeClass: isDalam ? 'badge-dalam-jk' : 'badge-luar-jk', + footerHtml: `
+ Status Penyaluran + ${item.data.status_bantuan || 'Belum Disalurkan'} +
`, + coords: `${lat.toFixed(5)}, ${lng.toFixed(5)}`, + onClick: () => { + const mk = findMarkerInGroup(isDalam ? lgPendudukDalam : lgPendudukLuar, lat, lng); + flyToAndOpenPopup(lat, lng, mk); + } + }); + container.appendChild(card); + }); + return pmData.length; +} + +function buildCardsTematik(container) { + const items = []; + + // Fasilitas Publik + fasilitasPublikList.forEach(fp => { + const lat = parseFloat(fp.latitude), lng = parseFloat(fp.longitude); + items.push({ icon: '🏢', iconClass: 'dc-icon-fp', name: fp.nama, sub: fp.jenis || 'Fasilitas Publik', badge: null, lat, lng, group: lgFasilitasPublik }); + }); + + if (!items.length) { + container.appendChild(emptyState('🗺️', 'Belum Ada Layer', 'Layer tematik (Kecamatan, Sungai, Jalan) belum tersedia.')); + return 0; + } + + items.forEach(it => { + const card = makeDataCard({ + icon: it.icon, + iconClass: it.iconClass, + name: it.name, + sub: it.sub, + badge: null, + badgeClass: '', + coords: `${it.lat.toFixed(5)}, ${it.lng.toFixed(5)}`, + onClick: () => { + const mk = findMarkerInGroup(it.group, it.lat, it.lng); + flyToAndOpenPopup(it.lat, it.lng, mk); + } + }); + container.appendChild(card); + }); + return items.length; +} + +function buildCardsDigitasi(jalanData, polygonData, container) { + let count = 0; + + // Polygons first + if (polygonData.length) { + polygonData.forEach(item => { + const badgeMap = { SHM: 'badge-shm', HGB: 'badge-hgb', HGU: 'badge-hgu', HP: 'badge-hp' }; + let lat = 0, lng = 0; + try { + const feat = JSON.parse(item.koordinat); + const coords = feat.geometry.coordinates[0][0]; + lat = coords[1]; lng = coords[0]; + } catch(e) {} + + const card = makeDataCard({ + icon: '⬜', + iconClass: 'dc-icon-poly', + name: item.nama_parsil, + sub: `Luas: ${parseFloat(item.luas_tanah).toFixed(2)} m²`, + badge: item.status_kepemilikan, + badgeClass: badgeMap[item.status_kepemilikan] || 'badge-geo', + coords: lat ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : null, + onClick: () => { + if (!lat) return; + const warna = warnaPolygon(item.status_kepemilikan); + // find polygon in lgPolygon + let found = null; + lgPolygon.eachLayer(lyr => { + if (found) return; + const c = lyr.getCenter ? lyr.getCenter() : null; + if (c && Math.abs(c.lat - lat) < 0.002 && Math.abs(c.lng - lng) < 0.002) found = lyr; + }); + if (lat) map.flyTo([lat, lng], 17, { animate: true, duration: 1.2 }); + if (found) setTimeout(() => { try { found.openPopup(); } catch(e) {} }, 1400); + } + }); + container.appendChild(card); + count++; + }); + } + + // Jalan + if (jalanData.length) { + jalanData.forEach(item => { + let lat = 0, lng = 0; + try { + const feat = JSON.parse(item.koordinat); + const coords = feat.geometry.coordinates[0]; + lat = coords[1]; lng = coords[0]; + } catch(e) {} + + const statusColors = { 'Jalan Nasional': 'badge-luar-jk', 'Jalan Provinsi': 'badge-hgu', 'Jalan Kabupaten': 'badge-shm' }; + + const card = makeDataCard({ + icon: '🛣️', + iconClass: 'dc-icon-jalan', + name: item.nama_jalan, + sub: `${parseFloat(item.panjang_meter).toFixed(0)} m`, + badge: item.status_jalan, + badgeClass: statusColors[item.status_jalan] || 'badge-geo', + coords: lat ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : null, + onClick: () => { + if (!lat) return; + let found = null; + lgJalan.eachLayer(lyr => { + if (found) return; + const lls = lyr.getLatLngs ? lyr.getLatLngs() : []; + const first = Array.isArray(lls[0]) ? lls[0][0] : lls[0]; + if (first && Math.abs(first.lat - lat) < 0.001 && Math.abs(first.lng - lng) < 0.001) found = lyr; + }); + map.flyTo([lat, lng], 16, { animate: true, duration: 1.2 }); + if (found) setTimeout(() => { try { found.openPopup(); } catch(e) {} }, 1400); + if (window.innerWidth <= 680) closeRightSidebar(); + } + }); + container.appendChild(card); + count++; + }); + } + + if (!count) { + container.appendChild(emptyState('📐', 'Belum Ada Data Digitasi', 'Tambahkan polygon/jalan menggunakan alat peta.')); + } + return count; +} + +/* ── Main builder ─────────────────────────────────────────────── */ +let currentSearchQuery = ''; +let currentUtuhTab = 'spbu'; + +function buildRightSidebarCards(modul, searchQ = '') { + if (!rightCardList) return; + rightCardList.innerHTML = ''; + let count = 0; + + const filteredPoints = (APP_DATA.points || []).filter(it => + !searchQ || (it.nama && it.nama.toLowerCase().includes(searchQ)) + ); + const filteredPM = (pendudukList || []).filter(it => + !searchQ || (it.data.nama && it.data.nama.toLowerCase().includes(searchQ)) || (it.data.nik && it.data.nik.includes(searchQ)) + ); + const filteredJalan = (APP_DATA.jalan || []).filter(it => + !searchQ || (it.nama_jalan && it.nama_jalan.toLowerCase().includes(searchQ)) + ); + const filteredPolygon = (APP_DATA.polygons || []).filter(it => + !searchQ || (it.nama_parsil && it.nama_parsil.toLowerCase().includes(searchQ)) + ); + const filteredFP = (fasilitasPublikList || []).filter(it => + !searchQ || (it.nama && it.nama.toLowerCase().includes(searchQ)) + ); + + if (modul === 'spbu') { + count = buildCardsSpbu(filteredPoints, rightCardList); + } else if (modul === 'bantuan') { + count = buildCardsBantuan(filteredPM, rightCardList); + // Also add FP + if (filteredFP.length) { + filteredFP.forEach(fp => { + const lat = parseFloat(fp.latitude), lng = parseFloat(fp.longitude); + const card = makeDataCard({ + icon: '🏢', iconClass: 'dc-icon-fp', + name: fp.nama, sub: fp.jenis || 'Fasilitas Publik', + badge: null, badgeClass: '', + coords: `${lat.toFixed(5)}, ${lng.toFixed(5)}`, + onClick: () => { + const mk = findMarkerInGroup(lgFasilitasPublik, lat, lng); + flyToAndOpenPopup(lat, lng, mk); + } + }); + rightCardList.appendChild(card); + count++; + }); + } + } else if (modul === 'tematik') { + // Kosongkan daftar data untuk peta tematik + rightCardList.appendChild(emptyState('🗺️', 'Peta Tematik', 'Mode Peta Tematik hanya menampilkan layer poligon dan garis pada peta.')); + count = 0; + } else if (modul === 'digitasi') { + count = buildCardsDigitasi(filteredJalan, filteredPolygon, rightCardList); + } else { // utuh – use tab system + if (currentUtuhTab === 'spbu') count = buildCardsSpbu(filteredPoints, rightCardList); + else if (currentUtuhTab === 'bantuan') count = buildCardsBantuan(filteredPM, rightCardList); + else if (currentUtuhTab === 'jalan') { + if (!filteredJalan.length) { + rightCardList.appendChild(emptyState('🛣️', 'Belum Ada Jalan', 'Tambahkan data jalan menggunakan alat peta.')); + } else { + filteredJalan.forEach(item => { + let lat = 0, lng = 0; + try { const f = JSON.parse(item.koordinat); lat = f.geometry.coordinates[0][1]; lng = f.geometry.coordinates[0][0]; } catch(e) {} + const card = makeDataCard({ + icon: '🛣️', iconClass: 'dc-icon-jalan', + name: item.nama_jalan, sub: `${parseFloat(item.panjang_meter).toFixed(0)} m · ${item.status_jalan}`, + badge: null, badgeClass: '', + coords: lat ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : null, + onClick: () => { if (lat) map.flyTo([lat, lng], 16, { animate: true, duration: 1.2 }); if (window.innerWidth <= 680) closeRightSidebar(); } + }); + rightCardList.appendChild(card); + count++; + }); + } + } else if (currentUtuhTab === 'polygon') { + if (!filteredPolygon.length) { + rightCardList.appendChild(emptyState('⬜', 'Belum Ada Parsil', 'Tambahkan polygon menggunakan alat peta.')); + } else { + const badgeMap = { SHM: 'badge-shm', HGB: 'badge-hgb', HGU: 'badge-hgu', HP: 'badge-hp' }; + filteredPolygon.forEach(item => { + let lat = 0, lng = 0; + try { const f = JSON.parse(item.koordinat); lat = f.geometry.coordinates[0][0][1]; lng = f.geometry.coordinates[0][0][0]; } catch(e) {} + const card = makeDataCard({ + icon: '⬜', iconClass: 'dc-icon-poly', + name: item.nama_parsil, sub: `Luas: ${parseFloat(item.luas_tanah).toFixed(2)} m²`, + badge: item.status_kepemilikan, badgeClass: badgeMap[item.status_kepemilikan] || 'badge-geo', + coords: lat ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : null, + onClick: () => { + if (lat) { + map.flyTo([lat, lng], 17, { animate: true, duration: 1.2 }); + let found = null; + lgPolygon.eachLayer(lyr => { + if (found) return; + const c = lyr.getCenter ? lyr.getCenter() : null; + if (c && Math.abs(c.lat - lat) < 0.002 && Math.abs(c.lng - lng) < 0.002) found = lyr; + }); + if (found) setTimeout(() => { try { found.openPopup(); } catch(e) {} }, 1400); + } + if (window.innerWidth <= 680) closeRightSidebar(); + } + }); + rightCardList.appendChild(card); + count++; + }); + } + } + } + + updateCount(count); +} + +/* ── Tab Switching (Modul Utuh) ───────────────────────────────── */ +const tabBtns = document.querySelectorAll('.right-tab-btn'); +tabBtns.forEach(btn => { + btn.addEventListener('click', () => { + tabBtns.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + currentUtuhTab = btn.dataset.tab; + buildRightSidebarCards(APP_DATA.modul, currentSearchQuery); + }); +}); + +/* ── Right Search Input ───────────────────────────────────────── */ +if (rightSearchInput) { + let rsTimer = null; + rightSearchInput.addEventListener('input', function() { + clearTimeout(rsTimer); + const q = this.value.toLowerCase().trim(); + rsTimer = setTimeout(() => { + currentSearchQuery = q; + buildRightSidebarCards(APP_DATA.modul, q); + }, 200); + }); +} + +/* ── Trigger initial card build after markers loaded ────────────── */ +// We wait a tick to let renderPoint/renderPenduduk etc. complete +setTimeout(() => buildRightSidebarCards(APP_DATA.modul), 100); + +/* ── Re-build after CRUD ops ─────────────────────────────────── */ +// Patch postData success to rebuild cards +const _origPostData = window.postData; +// We override the save success moments via a custom event +document.addEventListener('webgis:dataChanged', () => { + buildRightSidebarCards(APP_DATA.modul, currentSearchQuery); +}); + +// Hook into showToast to trigger rebuild on success saves +const _origShowToast = showToast; +// We'll re-export showToast patch at module level +window.__rebuildCards = () => buildRightSidebarCards(APP_DATA.modul, currentSearchQuery); + +/* =============================================================== + CHART.JS MODAL +=============================================================== */ + +let activeChart = null; + +const chartModal = document.getElementById('chartModal'); +const chartModalClose = document.getElementById('chartModalClose'); +const chartModalBody = document.getElementById('chartModalBody'); +const chartModalTitle = document.getElementById('chartModalTitle'); +const chartModalSub = document.getElementById('chartModalSubtitle'); +const chartModalIcon = document.getElementById('chartModalIcon'); +const btnStatistik = document.getElementById('btnLihatStatistik'); + +// Chart color palette (Merah Putih premium) +const CHART_COLORS = { + red: 'rgba(225, 29, 72, 0.85)', + redLight: 'rgba(225, 29, 72, 0.18)', + redMid: 'rgba(251, 113, 133, 0.85)', + dark: 'rgba(15, 23, 42, 0.85)', + darkMid: 'rgba(71, 85, 105, 0.7)', + green: 'rgba(22, 163, 74, 0.85)', + greenLight:'rgba(22,163,74,0.18)', + blue: 'rgba(59, 130, 246, 0.85)', + amber: 'rgba(217, 119, 6, 0.85)', + purple: 'rgba(107, 33, 168, 0.85)', + teal: 'rgba(20, 184, 166, 0.85)', +}; + +const CHART_DEFAULTS = { + responsive: true, + maintainAspectRatio: true, + plugins: { + legend: { + position: 'bottom', + labels: { + font: { family: "'Plus Jakarta Sans', sans-serif", size: 12, weight: '600' }, + padding: 16, + usePointStyle: true, + pointStyleWidth: 10, + color: '#334155' + } + }, + tooltip: { + backgroundColor: 'rgba(15,23,42,0.9)', + titleColor: '#f8fafc', + bodyColor: '#cbd5e1', + borderColor: 'rgba(225,29,72,0.3)', + borderWidth: 1, + cornerRadius: 10, + padding: 12, + titleFont: { family: "'Plus Jakarta Sans', sans-serif", weight: '800', size: 13 }, + bodyFont: { family: "'Plus Jakarta Sans', sans-serif", weight: '500', size: 12 }, + } + } +}; + +function destroyChart() { + if (activeChart) { activeChart.destroy(); activeChart = null; } +} + +function openChartModal() { + if (!chartModal) return; + destroyChart(); + chartModalBody.innerHTML = ''; + + const modul = APP_DATA.modul; + + if (modul === 'spbu') { + chartModalIcon.textContent = '⛽'; + chartModalTitle.textContent = 'Statistik SPBU'; + chartModalSub.textContent = 'Distribusi SPBU berdasarkan jam operasional'; + buildChartSpbu(); + } else if (modul === 'bantuan') { + chartModalIcon.textContent = '👥'; + chartModalTitle.textContent = 'Statistik Bantuan'; + chartModalSub.textContent = 'Cakupan jangkauan penduduk miskin'; + buildChartBantuan(); + } else if (modul === 'tematik') { + chartModalIcon.textContent = '🗺️'; + chartModalTitle.textContent = 'Statistik Tematik'; + chartModalSub.textContent = 'Distribusi jaringan jalan berdasarkan status'; + buildChartTematik(); + } else if (modul === 'digitasi') { + chartModalIcon.textContent = '📐'; + chartModalTitle.textContent = 'Statistik Digitasi'; + chartModalSub.textContent = 'Proporsi kepemilikan tanah & panjang jalan'; + buildChartDigitasi(); + } else { + chartModalIcon.textContent = '📊'; + chartModalTitle.textContent = 'Dashboard Statistik'; + chartModalSub.textContent = 'Ringkasan semua modul data spasial'; + buildChartUtuh(); + } + + chartModal.classList.remove('hidden'); +} + +function closeChartModal() { + chartModal.classList.add('hidden'); + destroyChart(); +} + +if (btnStatistik) btnStatistik.addEventListener('click', openChartModal); +if (chartModalClose) chartModalClose.addEventListener('click', closeChartModal); +if (chartModal) chartModal.addEventListener('click', e => { if (e.target === chartModal) closeChartModal(); }); + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') closeChartModal(); +}); + +/* ── Chart Builders per Modul ─────────────────────────────────── */ + +function statMiniHTML(val, label, color) { + return `
+
${val}
+
${label}
+
`; +} + +function buildChartSpbu() { + const total = APP_DATA.points.length; + const ya = APP_DATA.points.filter(p => p.buka_24_jam === 'Ya').length; + const tidak = total - ya; + + chartModalBody.innerHTML = ` +
+ ${statMiniHTML(total, 'Total SPBU', '#0f172a')} + ${statMiniHTML(ya, 'Buka 24 Jam', CHART_COLORS.green.replace('0.85','1'))} + ${statMiniHTML(tidak, 'Tidak 24 Jam', CHART_COLORS.red.replace('0.85','1'))} +
+
+ +
`; + + const ctx = document.getElementById('chartCanvas').getContext('2d'); + activeChart = new Chart(ctx, { + type: 'doughnut', + data: { + labels: ['Buka 24 Jam', 'Tidak 24 Jam'], + datasets: [{ + data: [ya, tidak], + backgroundColor: [CHART_COLORS.green, CHART_COLORS.red], + borderColor: ['#fff', '#fff'], + borderWidth: 3, + hoverBorderWidth: 4, + hoverOffset: 8 + }] + }, + options: { + ...CHART_DEFAULTS, + cutout: '65%', + plugins: { + ...CHART_DEFAULTS.plugins, + legend: { ...CHART_DEFAULTS.plugins.legend, position: 'bottom' } + } + } + }); +} + +function buildChartBantuan() { + const totalPM = pendudukList.length; + const dalam = pendudukList.filter(p => { + const cek = cekJangkauan(parseFloat(p.data.latitude), parseFloat(p.data.longitude)); + return cek.status === 'Dalam Jangkauan'; + }).length; + const luar = totalPM - dalam; + const totalFP = fasilitasPublikList.length; + const persen = totalPM > 0 ? ((dalam / totalPM) * 100).toFixed(1) : 0; + + chartModalBody.innerHTML = ` +
+ ${statMiniHTML(totalFP, 'Fasilitas Publik', '#3b82f6')} + ${statMiniHTML(totalPM, 'Penduduk Miskin', '#0f172a')} + ${statMiniHTML(dalam, 'Terjangkau', '#16a34a')} + ${statMiniHTML(luar, 'Luar Jangkauan', '#e11d48')} + ${statMiniHTML(persen + '%', 'Cakupan', '#d97706')} +
+
+ +
`; + + const ctx = document.getElementById('chartCanvas').getContext('2d'); + activeChart = new Chart(ctx, { + type: 'bar', + data: { + labels: ['Dalam Jangkauan', 'Luar Jangkauan'], + datasets: [{ + label: 'Jumlah Penduduk', + data: [dalam, luar], + backgroundColor: [CHART_COLORS.green, CHART_COLORS.red], + borderRadius: 8, + borderSkipped: false, + maxBarThickness: 80 + }] + }, + options: { + ...CHART_DEFAULTS, + scales: { + y: { + beginAtZero: true, + grid: { color: 'rgba(0,0,0,0.06)' }, + ticks: { font: { family: "'Plus Jakarta Sans', sans-serif", weight: '600', size: 11 }, color: '#64748b' } + }, + x: { + grid: { display: false }, + ticks: { font: { family: "'Plus Jakarta Sans', sans-serif", weight: '700', size: 12 }, color: '#0f172a' } + } + }, + plugins: { ...CHART_DEFAULTS.plugins, legend: { display: false } } + } + }); +} + +function buildChartTematik() { + // Count jalan by status from APP_DATA.jalan + const counts = { 'Jalan Nasional': 0, 'Jalan Provinsi': 0, 'Jalan Kabupaten': 0 }; + const panjang = { 'Jalan Nasional': 0, 'Jalan Provinsi': 0, 'Jalan Kabupaten': 0 }; + (APP_DATA.jalan || []).forEach(j => { + if (counts[j.status_jalan] !== undefined) { + counts[j.status_jalan]++; + panjang[j.status_jalan] += parseFloat(j.panjang_meter) || 0; + } + }); + + const totalPanjang = Object.values(panjang).reduce((a,b) => a+b, 0); + + chartModalBody.innerHTML = ` +
+ ${statMiniHTML(counts['Jalan Nasional'], 'Nasional', '#d32f2f')} + ${statMiniHTML(counts['Jalan Provinsi'], 'Provinsi', '#f57c00')} + ${statMiniHTML(counts['Jalan Kabupaten'], 'Kabupaten', '#388e3c')} + ${statMiniHTML((totalPanjang/1000).toFixed(2)+' km', 'Total Panjang', '#0f172a')} +
+
+ +
`; + + const ctx = document.getElementById('chartCanvas').getContext('2d'); + activeChart = new Chart(ctx, { + type: 'bar', + data: { + labels: ['Jalan Nasional', 'Jalan Provinsi', 'Jalan Kabupaten'], + datasets: [{ + label: 'Panjang (m)', + data: [panjang['Jalan Nasional'], panjang['Jalan Provinsi'], panjang['Jalan Kabupaten']], + backgroundColor: ['rgba(211,47,47,0.85)', 'rgba(245,124,0,0.85)', 'rgba(56,142,60,0.85)'], + borderRadius: 8, + borderSkipped: false, + maxBarThickness: 80 + }] + }, + options: { + ...CHART_DEFAULTS, + scales: { + y: { + beginAtZero: true, + grid: { color: 'rgba(0,0,0,0.06)' }, + ticks: { + font: { family: "'Plus Jakarta Sans', sans-serif", weight: '600', size: 11 }, + color: '#64748b', + callback: val => val >= 1000 ? (val/1000).toFixed(1)+'km' : val+'m' + } + }, + x: { + grid: { display: false }, + ticks: { font: { family: "'Plus Jakarta Sans', sans-serif", weight: '700', size: 11 }, color: '#0f172a' } + } + }, + plugins: { ...CHART_DEFAULTS.plugins, legend: { display: false } } + } + }); +} + +function buildChartDigitasi() { + const kepMap = { SHM: 0, HGB: 0, HGU: 0, HP: 0 }; + (APP_DATA.polygons || []).forEach(p => { if (kepMap[p.status_kepemilikan] !== undefined) kepMap[p.status_kepemilikan]++; }); + + const totalPoly = APP_DATA.polygons.length; + const totalJalan = APP_DATA.jalan.length; + + chartModalBody.innerHTML = ` +
+ ${statMiniHTML(totalPoly, 'Total Parsil', '#0f172a')} + ${statMiniHTML(kepMap.SHM, 'SHM', '#166534')} + ${statMiniHTML(kepMap.HGB, 'HGB', '#1e40af')} + ${statMiniHTML(kepMap.HGU, 'HGU', '#854d0e')} + ${statMiniHTML(kepMap.HP, 'HP', '#6b21a8')} + ${statMiniHTML(totalJalan, 'Ruas Jalan', '#d32f2f')} +
+
+ +
`; + + const ctx = document.getElementById('chartCanvas').getContext('2d'); + activeChart = new Chart(ctx, { + type: 'polarArea', + data: { + labels: ['SHM', 'HGB', 'HGU', 'HP'], + datasets: [{ + data: [kepMap.SHM, kepMap.HGB, kepMap.HGU, kepMap.HP], + backgroundColor: [ + 'rgba(22,163,74,0.75)', + 'rgba(59,130,246,0.75)', + 'rgba(217,119,6,0.75)', + 'rgba(107,33,168,0.75)' + ], + borderColor: '#fff', + borderWidth: 2 + }] + }, + options: { + ...CHART_DEFAULTS, + scales: { + r: { + grid: { color: 'rgba(0,0,0,0.08)' }, + ticks: { + backdropColor: 'transparent', + font: { family: "'Plus Jakarta Sans', sans-serif", size: 10 }, + color: '#64748b' + } + } + } + } + }); +} + +function buildChartUtuh() { + const totalSPBU = APP_DATA.points.length; + const spbu24 = APP_DATA.points.filter(p => p.buka_24_jam === 'Ya').length; + const totalPM = pendudukList.length; + const dalam = pendudukList.filter(p => cekJangkauan(parseFloat(p.data.latitude), parseFloat(p.data.longitude)).status === 'Dalam Jangkauan').length; + const totalJalan = APP_DATA.jalan.length; + const totalPolygon = APP_DATA.polygons.length; + const totalFP = fasilitasPublikList.length; + + chartModalBody.innerHTML = ` +
+ ${statMiniHTML(totalSPBU, 'Total SPBU', '#0f172a')} + ${statMiniHTML(totalPM, 'Penduduk Miskin', '#e11d48')} + ${statMiniHTML(totalFP, 'Fasilitas Publik', '#3b82f6')} + ${statMiniHTML(totalJalan, 'Ruas Jalan', '#d32f2f')} + ${statMiniHTML(totalPolygon,'Parsil Tanah', '#6b21a8')} +
+
+
+
⛽ SPBU
+ +
+
+
👥 Jangkauan Bantuan
+ +
+
`; + + const ctxS = document.getElementById('chartSpbu').getContext('2d'); + new Chart(ctxS, { + type: 'doughnut', + data: { + labels: ['Buka 24 Jam', 'Tidak 24 Jam'], + datasets: [{ data: [spbu24, totalSPBU - spbu24], backgroundColor: [CHART_COLORS.green, CHART_COLORS.red], borderColor: '#fff', borderWidth: 2, hoverOffset: 6 }] + }, + options: { ...CHART_DEFAULTS, cutout: '60%', plugins: { ...CHART_DEFAULTS.plugins, legend: { ...CHART_DEFAULTS.plugins.legend, labels: { ...CHART_DEFAULTS.plugins.legend.labels, font: { family: "'Plus Jakarta Sans', sans-serif", size: 10, weight: '600' }, padding: 10 } } } } + }); + + const ctxB = document.getElementById('chartBantuan').getContext('2d'); + new Chart(ctxB, { + type: 'doughnut', + data: { + labels: ['Terjangkau', 'Luar Jangkauan'], + datasets: [{ data: [dalam, totalPM - dalam], backgroundColor: [CHART_COLORS.green, CHART_COLORS.red], borderColor: '#fff', borderWidth: 2, hoverOffset: 6 }] + }, + options: { ...CHART_DEFAULTS, cutout: '60%', plugins: { ...CHART_DEFAULTS.plugins, legend: { ...CHART_DEFAULTS.plugins.legend, labels: { ...CHART_DEFAULTS.plugins.legend.labels, font: { family: "'Plus Jakarta Sans', sans-serif", size: 10, weight: '600' }, padding: 10 } } } } + }); +} + +/* ============================================================ + MANAJEMEN DATA PENDUDUK (MODUL BANTUAN) +============================================================ */ +window.openManajemenModal = function() { + document.getElementById('manajemenModal').classList.remove('hidden'); + document.getElementById('modalBackdrop').classList.remove('hidden'); + renderTabelManajemen('Semua'); + + // Set tab aktif kembali ke 'Semua' + document.querySelectorAll('.mj-tab').forEach(t => t.classList.remove('active')); + const allTab = document.querySelector('.mj-tab[data-filter="Semua"]'); + if(allTab) allTab.classList.add('active'); +}; + +if (document.getElementById('closeManajemenModal')) { + document.getElementById('closeManajemenModal').addEventListener('click', () => { + document.getElementById('manajemenModal').classList.add('hidden'); + // Cek apakah modal lain tidak terbuka sebelum menutup backdrop + if(document.querySelectorAll('.modal:not(.hidden)').length <= 1) { + document.getElementById('modalBackdrop').classList.add('hidden'); + } + }); +} + +document.querySelectorAll('.mj-tab').forEach(tab => { + tab.addEventListener('click', (e) => { + document.querySelectorAll('.mj-tab').forEach(t => t.classList.remove('active')); + e.target.classList.add('active'); + renderTabelManajemen(e.target.getAttribute('data-filter')); + }); +}); + +window.renderTabelManajemen = function(filter) { + const tbody = document.getElementById('manajemenTableBody'); + if (!tbody) return; + tbody.innerHTML = ''; + + let no = 1; + pendudukList.forEach(item => { + const d = item.data; + const statusBantuan = d.status_bantuan || 'Belum Disalurkan'; + + if (filter !== 'Semua' && statusBantuan !== filter) return; + + let badgeClass = 'belum'; + if (statusBantuan === 'Proses') badgeClass = 'proses'; + if (statusBantuan === 'Sudah Disalurkan') badgeClass = 'sudah'; + + let jangkauanColor = d.status_jangkauan === 'Dalam Jangkauan' ? '#16a34a' : '#e11d48'; + + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${no++} + +
${d.nama}
+
NIK: ${d.nik || '-'}
+ + + + ${d.status_jangkauan} + + + + ${d.foto_rumah ? `Lihat Foto` : '-'} + + + + + `; + tbody.appendChild(tr); + }); + + if (no === 1) { + tbody.innerHTML = `Tidak ada data.`; + } +}; + +window.lihatFotoModal = function(url) { + if (typeof Swal !== 'undefined') { + Swal.fire({ + imageUrl: url, + imageAlt: 'Foto', + showConfirmButton: false, + showCloseButton: true, + customClass: { popup: 'swal-foto-popup' } + }); + } else { + window.open(url, '_blank'); + } +}; + +window.updateStatusLangsung = function(id, newValue, oldValue) { + // Tutup dropdown global + if (typeof closeGlobalDropdown === 'function') closeGlobalDropdown(); + + if (newValue === oldValue) return; + + const fd = new FormData(); + fd.append('id', id); + fd.append('status_bantuan', newValue); + + fetch('api/update_status_bantuan.php', { method: 'POST', body: fd }) + .then(r => r.json()) + .then(res => { + if (res.status === 'success') { + showToast('Status penyaluran diperbarui.', 'success'); + refreshDataModul().then(() => { + const activeFilter = document.querySelector('.mj-tab.active').getAttribute('data-filter'); + renderTabelManajemen(activeFilter); + }); + } else { + showToast('Gagal mengubah status', 'error'); + const activeFilter = document.querySelector('.mj-tab.active').getAttribute('data-filter'); + renderTabelManajemen(activeFilter); + } + }).catch(err => { + showToast('Koneksi terputus', 'error'); + const activeFilter = document.querySelector('.mj-tab.active').getAttribute('data-filter'); + renderTabelManajemen(activeFilter); + }); +}; + +let globalDropdown = null; + +window.closeGlobalDropdown = function() { + if (globalDropdown) { + globalDropdown.remove(); + globalDropdown = null; + } + document.querySelectorAll('.mj-dropdown.open').forEach(d => d.classList.remove('open')); +}; + +window.toggleDropdown = function(id) { + const el = document.getElementById(`dropdown-${id}`); + if (!el) return; + + // If clicking the same one that's open, close it + if (el.classList.contains('open')) { + closeGlobalDropdown(); + return; + } + + // Close any existing first + closeGlobalDropdown(); + + // Open it + el.classList.add('open'); + + // Create a new one on body + const menuTemplate = el.querySelector('.mj-dropdown-menu'); + + globalDropdown = document.createElement('div'); + globalDropdown.className = 'mj-dropdown-menu'; + globalDropdown.innerHTML = menuTemplate.innerHTML; + + const rect = el.querySelector('.mj-dropdown-toggle').getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + + globalDropdown.style.position = 'fixed'; + globalDropdown.style.left = rect.left + 'px'; + globalDropdown.style.zIndex = '9999999'; + + // Force visibility since it's not under .mj-dropdown.open anymore + globalDropdown.style.opacity = '1'; + globalDropdown.style.visibility = 'visible'; + globalDropdown.style.transform = 'scaleY(1)'; + + if (spaceBelow < 140) { + globalDropdown.style.top = 'auto'; + globalDropdown.style.bottom = (window.innerHeight - rect.top + 6) + 'px'; + globalDropdown.style.transformOrigin = 'bottom left'; + } else { + globalDropdown.style.top = (rect.bottom + 6) + 'px'; + globalDropdown.style.bottom = 'auto'; + globalDropdown.style.transformOrigin = 'top left'; + } + + document.body.appendChild(globalDropdown); +}; + +// Global click to close +document.addEventListener('click', function(e) { + if (!e.target.closest('.mj-dropdown') && !e.target.closest('.mj-dropdown-menu')) { + closeGlobalDropdown(); + } +}); + +// Close when scrolling table to prevent floating menu out of sync +document.querySelector('#manajemenModal .table-responsive')?.addEventListener('scroll', closeGlobalDropdown); +window.addEventListener('scroll', closeGlobalDropdown, true); + +})(); + diff --git a/publik/assets/js/app.js.backup b/publik/assets/js/app.js.backup new file mode 100644 index 0000000..6230cf2 --- /dev/null +++ b/publik/assets/js/app.js.backup @@ -0,0 +1,1358 @@ +/* ============================================================ + WebGIS — app.js + Sistem Informasi Geografis Pengentasan Kemiskinan +============================================================ */ +(function () { +'use strict'; + +/* ── Konfigurasi ─────────────────────────────────────── */ +const DEFAULT_CENTER = [-0.05754868797441759, 109.32739998281652]; +const DEFAULT_ZOOM = 14; + +/* ── State Aplikasi ──────────────────────────────────── */ +let clickTimer = null; // Timer untuk membedakan single click & double click +let mode = 'point'; // mode aktif saat ini +let deleteCallback = null; // callback untuk konfirmasi hapus + +// Preview sementara di peta +let previewMarker = null; +let previewCircle = null; +let previewLine = null; +let previewPolygon = null; +let previewVertices = []; + +// Data untuk drawing +let jalanLatLngs = []; +let polygonLatLngs = []; + +// Data runtime +let fasilitasPublikList = []; // [{id, nama, lat, lng, radius}, ...] +let pendudukList = []; // [{data, marker}, ...] + +/* ── Layer Groups ────────────────────────────────────── */ +let geoJalanLayer = null; +let geoKecamatanLayer= null; +let geoSungaiLayer = null; + +const lgFasilitasPublik = L.layerGroup(); +const lgPenduduk = L.layerGroup(); +const lgPoints = L.layerGroup(); +const lgJalan = L.layerGroup(); +const lgPolygon = L.layerGroup(); + +/* ── Inisialisasi Peta ───────────────────────────────── */ +const savedCenter = (() => { + try { return JSON.parse(localStorage.getItem('webgis_center')); } catch { return null; } +})(); +const savedZoom = parseInt(localStorage.getItem('webgis_zoom') || '0', 10) || DEFAULT_ZOOM; + +const map = L.map('map', { + doubleClickZoom: false, + zoomControl: true +}).setView(savedCenter || DEFAULT_CENTER, savedZoom); + +// Base tile +L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap contributors' +}).addTo(map); + +// Scale control +L.control.scale({ imperial: false, metric: true, position: 'bottomleft' }).addTo(map); + +// Simpan posisi peta +map.on('moveend zoomend', () => { + const c = map.getCenter(); + localStorage.setItem('webgis_center', JSON.stringify([c.lat, c.lng])); + localStorage.setItem('webgis_zoom', String(map.getZoom())); +}); + +// Sembunyikan loading setelah peta siap +map.whenReady(() => { + setTimeout(() => el('mapLoading').classList.add('hidden'), 600); +}); + +/* ── Tambahkan layer groups ke peta ─────────────────── */ +[lgFasilitasPublik, lgPenduduk].forEach(lg => lg.addTo(map)); +if (document.getElementById("layerTitik") && document.getElementById("layerTitik").checked) lgPoints.addTo(map); +if (document.getElementById("layerJalan") && document.getElementById("layerJalan").checked) lgJalan.addTo(map); +if (document.getElementById("layerPolygon") && document.getElementById("layerPolygon").checked) lgPolygon.addTo(map); + +/* ═══════════════════════════════════════════════════════ + HELPER FUNCTIONS +═══════════════════════════════════════════════════════ */ + +function el(id) { return document.getElementById(id); } + +function showToast(message, type = 'success', duration = 3500) { + const container = el('toastContainer'); + const toast = document.createElement('div'); + const icons = { success: '✓', error: '✕', info: 'ℹ', warning: '⚠' }; + toast.className = `toast ${type}`; + toast.innerHTML = `${icons[type] || '•'}${message}`; + container.appendChild(toast); + setTimeout(() => { + toast.classList.add('fade-out'); + setTimeout(() => toast.remove(), 350); + }, duration); +} + +function showDeleteConfirm(message, callback) { + el('deleteMessage').textContent = message; + deleteCallback = callback; + showModal('deleteModal'); +} + +function showModal(id) { + el('modalBackdrop').classList.remove('hidden'); + el(id).classList.remove('hidden'); +} + +function hideModal(id) { + el(id).classList.add('hidden'); + // sembunyikan backdrop hanya jika tidak ada modal lain terbuka + const openModals = document.querySelectorAll('.modal:not(.hidden)'); + if (openModals.length === 0) { + el('modalBackdrop').classList.add('hidden'); + } +} + +function hideAllModals() { + document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden')); + el('modalBackdrop').classList.add('hidden'); +} + +function setHelpText(text) { + el('helpText').textContent = text; +} + +/* ── Kalkulasi Geospasial ─────────────────────────────── */ + +function hitungPanjang(latlngs) { + let total = 0; + for (let i = 1; i < latlngs.length; i++) { + total += latlngs[i - 1].distanceTo(latlngs[i]); + } + return total; +} + +function hitungLuas(latlngs) { + if (latlngs.length < 3) return 0; + const pts = latlngs.map(ll => map.options.crs.project(ll)); + let area = 0; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + area += (pts[j].x * pts[i].y) - (pts[i].x * pts[j].y); + } + return Math.abs(area / 2); +} + +function getDistanceMeter(lat1, lng1, lat2, lng2) { + return L.latLng(lat1, lng1).distanceTo(L.latLng(lat2, lng2)); +} + +function cekJangkauan(lat, lng) { + let hasil = { status: 'Luar Jangkauan', id: '', nama: '' }; + fasilitasPublikList.forEach(ri => { + const d = getDistanceMeter(lat, lng, parseFloat(ri.latitude), parseFloat(ri.longitude)); + if (d <= parseFloat(ri.radius)) { + hasil = { status: 'Dalam Jangkauan', id: ri.id, nama: ri.nama }; + } + }); + return hasil; +} + +/* ── GeoJSON helpers ─────────────────────────────────── */ + +function toLineFeature(latlngs, props = {}) { + return { + type: 'Feature', + geometry: { type: 'LineString', coordinates: latlngs.map(ll => [ll.lng, ll.lat]) }, + properties: props + }; +} + +function toPolygonFeature(latlngs, props = {}) { + const ring = latlngs.map(ll => [ll.lng, ll.lat]); + if (ring.length && (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1])) { + ring.push([ring[0][0], ring[0][1]]); + } + return { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: props + }; +} + +function lineFeatureToLatLngs(feat) { + if (!feat?.geometry?.coordinates) return []; + return feat.geometry.coordinates.map(c => [c[1], c[0]]); +} + +function polygonFeatureToLatLngs(feat) { + if (!feat?.geometry?.coordinates?.[0]) return []; + const ring = [...feat.geometry.coordinates[0]]; + if (ring.length > 1) { + const f = ring[0], l = ring[ring.length - 1]; + if (f[0] === l[0] && f[1] === l[1]) ring.pop(); + } + return ring.map(c => [c[1], c[0]]); +} + +/* ── Warna Helper ─────────────────────────────────────── */ + +function warnaJalan(status) { + return { 'Jalan Nasional': '#d32f2f', 'Jalan Provinsi': '#f57c00', 'Jalan Kabupaten': '#388e3c' }[status] || '#1565c0'; +} + +function warnaPolygon(status) { + return { 'SHM': '#1b5e20', 'HGB': '#1565c0', 'HGU': '#ef6c00', 'HP': '#6a1b9a' }[status] || '#455a64'; +} + +function warnaKecamatan(nama) { + const colors = { + 'Pontianak Barat': '#e41a1c', 'Pontianak Timur': '#377eb8', + 'Pontianak Utara': '#4daf4a', 'Pontianak Selatan': '#984ea3', + 'Pontianak Kota': '#ff7f00', 'Pontianak Tenggara': '#a65628' + }; + return colors[nama] || '#888888'; +} + +/* ── Custom Marker Icons ─────────────────────────────── */ + +function createFPIcon() { + return L.divIcon({ + className: '', + html: `
`, + iconSize: [28, 28], + iconAnchor: [14, 28], + popupAnchor: [0, -30] + }); +} + +function createPointIcon(is24) { + const color = is24 ? '#4f8ef7' : '#78909c'; + return L.divIcon({ + className: '', + html: `
`, + iconSize: [24, 24], + iconAnchor: [12, 12], + popupAnchor: [0, -14] + }); +} + +/* ═══════════════════════════════════════════════════════ + PREVIEW MANAGEMENT +═══════════════════════════════════════════════════════ */ + +function clearAllPreview() { + if (previewMarker) { map.removeLayer(previewMarker); previewMarker = null; } + if (previewCircle) { map.removeLayer(previewCircle); previewCircle = null; } + if (previewLine) { map.removeLayer(previewLine); previewLine = null; } + if (previewPolygon) { map.removeLayer(previewPolygon); previewPolygon = null; } + previewVertices.forEach(v => map.removeLayer(v)); + previewVertices = []; + jalanLatLngs = []; + polygonLatLngs = []; +} + +function addVertexMarker(latlng, color = '#1565c0') { + const v = L.circleMarker(latlng, { + radius: 5, color, fillColor: color, + fillOpacity: 1, weight: 2 + }).addTo(map); + previewVertices.push(v); + return v; +} + +function updateJalanPreview() { + if (previewLine) { map.removeLayer(previewLine); previewLine = null; } + previewVertices.forEach(v => map.removeLayer(v)); + previewVertices = []; + + jalanLatLngs.forEach((ll, i) => { + const v = addVertexMarker(ll, '#d32f2f'); + v.bindTooltip(`Titik ${i + 1}`, { direction: 'top', permanent: false }); + }); + + if (jalanLatLngs.length >= 2) { + previewLine = L.polyline(jalanLatLngs, { color: '#d32f2f', weight: 4, opacity: 0.9 }).addTo(map); + el('panjang_meter').value = hitungPanjang(jalanLatLngs).toFixed(2); + } + el('jumlah_titik_jalan').value = jalanLatLngs.length; + el('koordinat_jalan').value = JSON.stringify(toLineFeature(jalanLatLngs), null, 2); +} + +function updatePolygonPreview() { + if (previewPolygon) { map.removeLayer(previewPolygon); previewPolygon = null; } + previewVertices.forEach(v => map.removeLayer(v)); + previewVertices = []; + + polygonLatLngs.forEach((ll, i) => { + const v = addVertexMarker(ll, '#6a1b9a'); + v.bindTooltip(`Titik ${i + 1}`, { direction: 'top', permanent: false }); + }); + + if (polygonLatLngs.length >= 3) { + previewPolygon = L.polygon(polygonLatLngs, { + color: '#6a1b9a', fillColor: '#ba68c8', fillOpacity: 0.3, weight: 3 + }).addTo(map); + el('luas_tanah').value = hitungLuas(polygonLatLngs).toFixed(4); + } + el('jumlah_titik_polygon').value = polygonLatLngs.length; + el('koordinat_polygon').value = JSON.stringify(toPolygonFeature(polygonLatLngs), null, 2); +} + +/* ═══════════════════════════════════════════════════════ + MODE MANAGEMENT +═══════════════════════════════════════════════════════ */ + +const MODES = { + point: { btn: 'btnToolPoint', help: 'Klik pada peta untuk menambah titik.' }, + jalan: { btn: 'btnToolJalan', help: 'Klik titik-titik jalan. Double-klik untuk selesai. Klik-kanan untuk hapus titik terakhir.' }, + polygon: { btn: 'btnToolPolygon', help: 'Klik titik-titik polygon. Double-klik untuk selesai. Klik-kanan untuk hapus titik terakhir.' }, + fasilitas_publik: { btn: 'btnToolFasilitasPublik',help: 'Klik pada peta untuk menentukan lokasi Fasilitas Publik.' }, + penduduk_miskin:{ btn: 'btnToolPenduduk', help: 'Klik pada peta untuk menentukan lokasi penduduk miskin.' }, + lokasi: { btn: 'btnToolLokasi', help: 'Mencari lokasi Anda...' }, +}; + +function setMode(newMode) { + clearAllPreview(); + hideAllModals(); + + mode = newMode; + + // Update active button + document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active')); + const cfg = MODES[newMode]; + if (cfg) { + const btn = el(cfg.btn); + if (btn) btn.classList.add('active'); + setHelpText(cfg.help); + } + + // Khusus lokasi: langsung trigger + if (newMode === 'lokasi') { + map.locate({ setView: true, maxZoom: 18 }); + mode = 'point'; // kembali ke point setelah locate + el('btnToolPoint').classList.add('active'); + el('btnToolLokasi').classList.remove('active'); + } +} + +/* ═══════════════════════════════════════════════════════ + RENDER FUNCTIONS +═══════════════════════════════════════════════════════ */ + +/* ── Render Point (SPBU) ─────────────────────────────── */ +function renderPoint(item) { + const lat = parseFloat(item.latitude); + const lng = parseFloat(item.longitude); + const is24 = item.buka_24_jam === 'Ya'; + + const marker = L.marker([lat, lng], { icon: createPointIcon(is24) }).addTo(lgPoints); + + const popup = ` + + `; + + marker.bindPopup(popup, { maxWidth: 280 }); + return marker; +} + +/* ── Render Jalan ─────────────────────────────────────── */ +function renderJalan(item) { + try { + const feat = JSON.parse(item.koordinat); + const coords = lineFeatureToLatLngs(feat); + const poly = L.polyline(coords, { + color: warnaJalan(item.status_jalan), weight: 5, opacity: 0.85 + }).addTo(lgJalan); + + const popup = ` + + `; + + poly.bindPopup(popup, { maxWidth: 280 }); + return poly; + } catch (e) { console.error('renderJalan error:', e); } +} + +/* ── Render Polygon ───────────────────────────────────── */ +function renderPolygon(item) { + try { + const feat = JSON.parse(item.koordinat); + const coords = polygonFeatureToLatLngs(feat); + const warna = warnaPolygon(item.status_kepemilikan); + const poly = L.polygon(coords, { + color: warna, fillColor: warna, fillOpacity: 0.3, weight: 3 + }).addTo(lgPolygon); + + const popup = ` + + `; + + poly.bindPopup(popup, { maxWidth: 280 }); + return poly; + } catch (e) { console.error('renderPolygon error:', e); } +} + +/* ── Render Fasilitas Publik ─────────────────────────────── */ +function renderFasilitasPublik(item) { + const lat = parseFloat(item.latitude); + const lng = parseFloat(item.longitude); + const radius = parseFloat(item.radius); + + fasilitasPublikList.push(item); + + const marker = L.marker([lat, lng], { icon: createFPIcon() }).addTo(lgFasilitasPublik); + const circle = L.circle([lat, lng], { + radius, color: '#e53935', fillColor: '#ef9a9a', + fillOpacity: 0.12, weight: 2, dashArray: '6 4' + }).addTo(lgFasilitasPublik); + + const popup = ` + + `; + + marker.bindPopup(popup, { maxWidth: 300 }); + circle.bindPopup(popup, { maxWidth: 300 }); + + return { marker, circle }; +} + +/* ── Render Penduduk Miskin ───────────────────────────── */ +function renderPenduduk(item) { + const lat = parseFloat(item.latitude); + const lng = parseFloat(item.longitude); + const cek = cekJangkauan(lat, lng); + + const warna = cek.status === 'Dalam Jangkauan' ? '#e53935' : '#2e7d32'; + const fillW = cek.status === 'Dalam Jangkauan' ? '#ef5350' : '#43a047'; + + const marker = L.circleMarker([lat, lng], { + radius: 8, color: warna, fillColor: fillW, + fillOpacity: 0.85, weight: 2 + }).addTo(lgPenduduk); + + const badgeClass = cek.status === 'Dalam Jangkauan' ? 'badge-dalam' : 'badge-luar'; + const popup = ` + + `; + + marker.bindPopup(popup, { maxWidth: 300 }); + + pendudukList.push({ data: item, marker }); + return marker; +} + +/* ── Refresh Status Penduduk ──────────────────────────── */ +function refreshStatusPenduduk() { + lgPenduduk.clearLayers(); + const backup = pendudukList.map(p => p.data); + pendudukList = []; + backup.forEach(item => renderPenduduk(item)); + updateDashboardStats(); // Panggil fungsi update stat +} + +/* ── Update Statistik Dashboard Secara Real-Time ──────── */ +function updateDashboardStats() { + const totalFP = fasilitasPublikList.length; + const totalPM = pendudukList.length; + + let ditangani = 0; + pendudukList.forEach(p => { + const cek = cekJangkauan(parseFloat(p.data.latitude), parseFloat(p.data.longitude)); + if (cek.status === 'Dalam Jangkauan') ditangani++; + }); + + const belum = totalPM - ditangani; + const persen = totalPM > 0 ? ((ditangani / totalPM) * 100).toFixed(1) : 0; + + // Update angka di HTML + if (el('statTotalRI')) el('statTotalRI').textContent = totalFP; + if (el('statTotalPM')) el('statTotalPM').textContent = totalPM; + if (el('statDitangani')) el('statDitangani').textContent = ditangani; + if (el('statBelum')) el('statBelum').textContent = belum; + if (el('statPersen')) el('statPersen').textContent = persen + '%'; + if (el('statProgressBar')) el('statProgressBar').style.width = persen + '%'; +} +/* ═══════════════════════════════════════════════════════ + LOAD DATA AWAL +═══════════════════════════════════════════════════════ */ +(APP_DATA.points || []).forEach(renderPoint); +(APP_DATA.jalan || []).forEach(renderJalan); +(APP_DATA.polygons || []).forEach(renderPolygon); +(APP_DATA.fasilitasPublik || []).forEach(renderFasilitasPublik); +(APP_DATA.pendudukMiskin || []).forEach(renderPenduduk); + +/* ═══════════════════════════════════════════════════════ + LOAD GEOJSON EKSTERNAL +═══════════════════════════════════════════════════════ */ + +function loadGeoJSON(url, options = {}) { + return fetch(url) + .then(r => { if (!r.ok) throw new Error('Gagal load: ' + url); return r.json(); }) + .then(geojson => { + const layer = L.geoJSON(geojson, { + style: feat => { + const t = feat.geometry?.type || ''; + if (t === 'Polygon' || t === 'MultiPolygon') { + let fill = options.fillColor || '#42a5f5'; + if (options.dynamicColor && feat.properties) { + const nm = feat.properties.Ket || feat.properties.WADMKC || + feat.properties.KECAMATAN || feat.properties.nama || ''; + fill = warnaKecamatan(nm); + } + return { color: '#333', weight: 1.5, fillColor: fill, fillOpacity: 0.35 }; + } + if (t === 'LineString' || t === 'MultiLineString') { + return { color: options.color || '#1565c0', weight: options.weight || 2, opacity: 0.9 }; + } + return { color: options.color || '#1565c0' }; + }, + pointToLayer: (feat, latlng) => L.circleMarker(latlng, { + radius: 4, color: options.color || '#1565c0', + fillColor: options.fillColor || '#42a5f5', fillOpacity: 0.9, weight: 1 + }), + onEachFeature: (feat, layer) => { + if (!feat.properties) return; + let html = `'; + layer.bindPopup(html, { maxWidth: 280 }); + } + }); + + if (options.adjustWeight) { + map.on('zoomend', () => { + const z = map.getZoom(); + const w = z <= 12 ? 0.8 : z <= 14 ? 1.5 : z <= 16 ? 2 : 3; + layer.setStyle({ weight: w }); + }); + } + + if (options.addToMap !== false) { layer.addTo(map); if (options.fitBounds) map.fitBounds(layer.getBounds()); } return layer; + }) + .catch(err => { console.warn(err.message); return null; }); +} + +Promise.all([ + loadGeoJSON('data/Sungai_Besar.geojson', { + title: 'Sungai Besar', color: '#0d47a1', fillColor: '#90caf9', + weight: 2, fitBounds: false, addToMap: el('layerSungai') && el('layerSungai').checked + }), + loadGeoJSON('data/admin_kecamatan.geojson', { + title: 'Admin Kecamatan', dynamicColor: true, + color: '#ef6c00', weight: 1.5, fitBounds: false, addToMap: el('layerKecamatan') && el('layerKecamatan').checked + }), + loadGeoJSON('data/Jaringan_Jalan.geojson', { + title: 'Jaringan Jalan', color: '#d32f2f', + weight: 1.5, fitBounds: false, adjustWeight: true, addToMap: el('layerGeoJalan') && el('layerGeoJalan').checked + }) +]).then(([sungai, kecamatan, jalan]) => { + geoSungaiLayer = sungai; + geoKecamatanLayer = kecamatan; + geoJalanLayer = jalan; if (el('layerSungai') && !el('layerSungai').checked) map.removeLayer(geoSungaiLayer); if (el('layerKecamatan') && !el('layerKecamatan').checked) map.removeLayer(geoKecamatanLayer); if (el('layerGeoJalan') && !el('layerGeoJalan').checked) map.removeLayer(geoJalanLayer); }); + +/* ═══════════════════════════════════════════════════════ + REVERSE GEOCODING +═══════════════════════════════════════════════════════ */ + +function reverseGeocode(lat, lng) { + el('fp_alamat').value = ''; + el('geocodeStatus').textContent = '⏳ Mencari alamat...'; + + fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=id`) + .then(r => r.json()) + .then(data => { + el('fp_alamat').value = data.display_name || 'Alamat tidak ditemukan'; + el('geocodeStatus').textContent = '✓'; + setTimeout(() => { el('geocodeStatus').textContent = ''; }, 2000); + }) + .catch(() => { + el('fp_alamat').value = 'Gagal mengambil alamat'; + el('geocodeStatus').textContent = '✕'; + }); +} + +/* ═══════════════════════════════════════════════════════ + MAP EVENT HANDLERS +═══════════════════════════════════════════════════════ */ + +map.on('click', e => { + const { lat, lng } = e.latlng; + + // Mode yang tidak perlu timer (langsung eksekusi) + if (mode === 'point') { + clearAllPreview(); + el('point_id').value = ''; + el('point_nama').value = ''; + el('point_no_wa').value = ''; + el('point_latitude').value = lat.toFixed(10); + el('point_longitude').value = lng.toFixed(10); + document.querySelector('input[name="point_buka_24_jam"][value="Tidak"]').checked = true; + el('pointModalTitle').textContent = 'Tambah Titik'; + + previewMarker = L.marker(e.latlng, { icon: createPointIcon(false) }).addTo(map); + showModal('pointModal'); + return; + } + + if (mode === 'fasilitas_publik') { + el('fp_id').value = ''; + el('fp_nama').value = ''; + el('fp_jenis').value = 'Masjid'; + el('fp_kontak').value = ''; + el('fp_radius').value = 300; + el('fp_radius_text').textContent = '300'; + el('fp_latitude').value = lat.toFixed(10); + el('fp_longitude').value = lng.toFixed(10); + el('fpModalTitle').textContent = 'Tambah Fasilitas Publik'; + + if (previewMarker) map.removeLayer(previewMarker); + if (previewCircle) map.removeLayer(previewCircle); + previewMarker = L.marker(e.latlng, { icon: createFPIcon() }).addTo(map); + previewCircle = L.circle(e.latlng, { + radius: 300, color: '#e53935', fillColor: '#ef9a9a', + fillOpacity: 0.12, weight: 2, dashArray: '6 4' + }).addTo(map); + + reverseGeocode(lat, lng); + showModal('fasilitasPublikModal'); + return; + } + + if (mode === 'penduduk_miskin') { + el('pm_id').value = ''; + el('pm_nama').value = ''; + el('pm_nik').value = ''; + el('pm_keterangan').value = ''; + el('pm_latitude').value = lat.toFixed(10); + el('pm_longitude').value = lng.toFixed(10); + el('pmModalTitle').textContent = 'Tambah Penduduk Miskin'; + + const cek = cekJangkauan(lat, lng); + updateStatusJangkauan(cek); + + if (previewMarker) map.removeLayer(previewMarker); + const warna = cek.status === 'Dalam Jangkauan' ? '#e53935' : '#2e7d32'; + previewMarker = L.circleMarker(e.latlng, { + radius: 8, color: warna, fillColor: warna, fillOpacity: 0.85, weight: 2 + }).addTo(map); + + showModal('pendudukModal'); + return; + } + + // Khusus mode Menggambar (Jalan & Polygon), gunakan Timer + if (mode === 'jalan' || mode === 'polygon') { + if (clickTimer) clearTimeout(clickTimer); // Hapus timer lama jika ada klik berdekatan + + clickTimer = setTimeout(() => { + // Eksekusi jika benar-benar single-click + if (mode === 'jalan') { + jalanLatLngs.push(e.latlng); + updateJalanPreview(); + } else if (mode === 'polygon') { + polygonLatLngs.push(e.latlng); + updatePolygonPreview(); + } + }, 250); // Delay 250 milidetik + } +}); + +map.on('dblclick', () => { + if (clickTimer) { + clearTimeout(clickTimer); // Batalkan single-click jika terdeteksi double-click + clickTimer = null; + } + + if (mode === 'jalan') { + if (jalanLatLngs.length < 2) { showToast('Minimal 2 titik untuk jalan.', 'warning'); return; } + updateJalanPreview(); + showModal('jalanModal'); + } + if (mode === 'polygon') { + if (polygonLatLngs.length < 3) { showToast('Minimal 3 titik untuk polygon.', 'warning'); return; } + updatePolygonPreview(); + showModal('polygonModal'); + } +}); + +map.on('contextmenu', () => { + if (mode === 'jalan' && jalanLatLngs.length > 0) { + jalanLatLngs.pop(); updateJalanPreview(); return; + } + if (mode === 'polygon' && polygonLatLngs.length > 0) { + polygonLatLngs.pop(); updatePolygonPreview(); return; + } + if (mode === 'point') { + clearAllPreview(); + hideModal('pointModal'); + } +}); + +map.on('locationfound', e => { + showToast('Lokasi ditemukan!', 'success'); + L.marker(e.latlng) + .addTo(map) + .bindPopup('') + .openPopup(); +}); + +map.on('locationerror', () => { + showToast('Tidak dapat mengakses lokasi. Pastikan izin lokasi browser aktif.', 'error'); +}); + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { clearAllPreview(); hideAllModals(); } +}); + +/* ═══════════════════════════════════════════════════════ + STATUS JANGKAUAN UI +═══════════════════════════════════════════════════════ */ +function updateStatusJangkauan(cek) { + const elStatus = el('pm_status'); + const elDitangani = el('pm_ditangani'); + elStatus.textContent = cek.status; + elStatus.className = 'sj-value ' + (cek.status === 'Dalam Jangkauan' ? 'dalam' : 'luar'); + elDitangani.textContent = cek.nama ? `Ditangani oleh: ${cek.nama}` : 'Tidak dalam jangkauan Fasilitas Publik manapun.'; +} + +/* ═══════════════════════════════════════════════════════ + TOOL BUTTONS +═══════════════════════════════════════════════════════ */ +document.querySelectorAll('.tool-btn').forEach(btn => { + btn.addEventListener('click', () => { + const m = btn.dataset.mode; + if (m) setMode(m); + }); +}); + +/* ═══════════════════════════════════════════════════════ + SIDEBAR TOGGLE +═══════════════════════════════════════════════════════ */ +const sidebar = el('sidebar'); +const mapContainer = el('mapContainer'); +const sidebarOpenBtn= el('sidebarOpenBtn'); +const isMobile = () => window.innerWidth <= 680; + +el('sidebarToggle').addEventListener('click', () => { + if (isMobile()) { + sidebar.classList.toggle('mobile-open'); + } else { + sidebar.classList.toggle('collapsed'); + mapContainer.classList.toggle('full-width'); + sidebarOpenBtn.classList.toggle('hidden'); + map.invalidateSize(); + } +}); + +sidebarOpenBtn.addEventListener('click', () => { + sidebar.classList.remove('collapsed'); + mapContainer.classList.remove('full-width'); + sidebarOpenBtn.classList.add('hidden'); + map.invalidateSize(); +}); + +/* ═══════════════════════════════════════════════════════ + LAYER CHECKBOXES +═══════════════════════════════════════════════════════ */ +function bindLayerToggle(checkboxId, layerGroup) { + const cb = el(checkboxId); + if (!cb) return; + cb.addEventListener('change', () => { + if (cb.checked) { + if (!map.hasLayer(layerGroup)) layerGroup.addTo(map); + } else { + map.removeLayer(layerGroup); + } + }); +} + +bindLayerToggle('layerFasilitasPublik', lgFasilitasPublik); +bindLayerToggle('layerPendudukDalam', lgPenduduk); +bindLayerToggle('layerPendudukLuar', lgPenduduk); + +bindLayerToggle("layerTitik", lgPoints); +bindLayerToggle("layerJalan", lgJalan); +bindLayerToggle("layerPolygon", lgPolygon); + +el('layerGeoJalan').addEventListener('change', function () { + if (geoJalanLayer) { this.checked ? geoJalanLayer.addTo(map) : map.removeLayer(geoJalanLayer); } +}); +el('layerKecamatan').addEventListener('change', function () { + if (geoKecamatanLayer) { this.checked ? geoKecamatanLayer.addTo(map) : map.removeLayer(geoKecamatanLayer); } +}); +el('layerSungai').addEventListener('change', function () { + if (geoSungaiLayer) { this.checked ? geoSungaiLayer.addTo(map) : map.removeLayer(geoSungaiLayer); } +}); + +/* ═══════════════════════════════════════════════════════ + RADIUS SLIDER → PREVIEW +═══════════════════════════════════════════════════════ */ +el('fp_radius').addEventListener('input', function () { + const r = parseInt(this.value, 10); + el('fp_radius_text').textContent = r; + if (previewCircle) previewCircle.setRadius(r); +}); + +/* ═══════════════════════════════════════════════════════ + MODAL CLOSE BUTTONS +═══════════════════════════════════════════════════════ */ +[ + ['closePointModal', 'pointModal'], + ['cancelPointModal', 'pointModal'], + ['closeJalanModal', 'jalanModal'], + ['cancelJalanModal', 'jalanModal'], + ['closePolygonModal', 'polygonModal'], + ['cancelPolygonModal', 'polygonModal'], + ['closefasilitasPublikModal', 'fasilitasPublikModal'], + ['cancelfasilitasPublikModal','fasilitasPublikModal'], + ['closePendudukModal', 'pendudukModal'], + ['cancelPendudukModal', 'pendudukModal'], +].forEach(([btnId, modalId]) => { + const btn = el(btnId); + if (btn) btn.addEventListener('click', () => { + hideModal(modalId); + clearAllPreview(); + }); +}); + +el('modalBackdrop').addEventListener('click', () => { + hideAllModals(); + clearAllPreview(); +}); + +/* Konfirmasi hapus */ +el('confirmDelete').addEventListener('click', () => { + if (typeof deleteCallback === 'function') deleteCallback(); + hideModal('deleteModal'); + deleteCallback = null; +}); +el('cancelDelete').addEventListener('click', () => { + hideModal('deleteModal'); + deleteCallback = null; +}); + +/* ═══════════════════════════════════════════════════════ + SAVE: POINT +═══════════════════════════════════════════════════════ */ +el('btnSavePoint').addEventListener('click', () => { + const nama = el('point_nama').value.trim(); + const no_wa = el('point_no_wa').value.trim(); + const radio = document.querySelector('input[name="point_buka_24_jam"]:checked'); + const lat = el('point_latitude').value.trim(); + const lng = el('point_longitude').value.trim(); + + if (!nama || !no_wa || !radio || !lat || !lng) { + showToast('Semua field wajib diisi dan lokasi harus dipilih.', 'error'); return; + } + + const id = el('point_id').value; + const url = id ? 'point/update.php' : 'point/simpan.php'; + + const fd = new FormData(); + if (id) fd.append('id', id); + fd.append('nama', nama); + fd.append('no_wa', no_wa); + fd.append('buka_24_jam', radio.value); + fd.append('latitude', lat); + fd.append('longitude', lng); + + postData(url, fd).then(res => { + if (res.status === 'success') { + if (previewMarker) map.removeLayer(previewMarker); + + if (id) { + const idx = APP_DATA.points.findIndex(x => String(x.id) === String(id)); + if (idx > -1) APP_DATA.points[idx] = res.data; + lgPoints.clearLayers(); + APP_DATA.points.forEach(renderPoint); + } else { + APP_DATA.points.push(res.data); + renderPoint(res.data); + } + + hideModal('pointModal'); + showToast(id ? 'Titik berhasil diperbarui.' : 'Titik berhasil disimpan.'); + } else { + showToast(res.message || 'Gagal menyimpan titik.', 'error'); + } + }); +}); + +/* ═══════════════════════════════════════════════════════ + SAVE: JALAN +═══════════════════════════════════════════════════════ */ +el('btnSaveJalan').addEventListener('click', () => { + const nama = el('nama_jalan').value.trim(); + const status = el('status_jalan').value; + const panjang = el('panjang_meter').value; + const koordinat = el('koordinat_jalan').value.trim(); + + if (!nama || !status) { showToast('Nama dan status jalan wajib diisi.', 'error'); return; } + if (jalanLatLngs.length < 2) { showToast('Minimal 2 titik untuk jalan.', 'error'); return; } + + const fd = new FormData(); + fd.append('nama_jalan', nama); + fd.append('status_jalan', status); + fd.append('panjang_meter', panjang || '0'); + fd.append('koordinat', koordinat); + + postData('jalan/simpan.php', fd).then(res => { + if (res.status === 'success') { + clearAllPreview(); + APP_DATA.jalan.push(res.data); // Sinkronisasi array lokal + renderJalan(res.data); + hideModal('jalanModal'); + el('nama_jalan').value = ''; + el('status_jalan').value = ''; + showToast('Jalan berhasil disimpan.'); + } else { + showToast(res.message || 'Gagal menyimpan jalan.', 'error'); + } + }); +}); + +/* ═══════════════════════════════════════════════════════ + SAVE: POLYGON +═══════════════════════════════════════════════════════ */ +el('btnSavePolygon').addEventListener('click', () => { + const nama = el('nama_parsil').value.trim(); + const status = el('status_kepemilikan').value; + const luas = el('luas_tanah').value; + const koordinat = el('koordinat_polygon').value.trim(); + + if (!nama || !status) { showToast('Nama dan status kepemilikan wajib diisi.', 'error'); return; } + if (polygonLatLngs.length < 3) { showToast('Minimal 3 titik untuk polygon.', 'error'); return; } + + const fd = new FormData(); + fd.append('nama_parsil', nama); + fd.append('status_kepemilikan', status); + fd.append('luas_tanah', luas || '0'); + fd.append('koordinat', koordinat); + + postData('polygon/simpan.php', fd).then(res => { + if (res.status === 'success') { + clearAllPreview(); + APP_DATA.polygons.push(res.data); // Sinkronisasi array lokal + renderPolygon(res.data); + hideModal('polygonModal'); + el('nama_parsil').value = ''; + el('status_kepemilikan').value = ''; + showToast('Polygon berhasil disimpan.'); + } else { + showToast(res.message || 'Gagal menyimpan polygon.', 'error'); + } + }); +}); + +/* ═══════════════════════════════════════════════════════ + SAVE: Fasilitas Publik +═══════════════════════════════════════════════════════ */ +el('btnSaveFasilitasPublik').addEventListener('click', () => { + const nama = el('fp_nama').value.trim(); + const lat = el('fp_latitude').value.trim(); + const lng = el('fp_longitude').value.trim(); + + if (!nama) { showToast('Nama Fasilitas Publik wajib diisi.', 'error'); return; } + if (!lat || !lng) { showToast('Klik pada peta untuk menentukan lokasi.', 'error'); return; } + + const id = el('fp_id').value; + const url = id ? 'fasilitas_publik/update.php' : 'fasilitas_publik/simpan.php'; + + const fd = new FormData(); + if (id) fd.append('id', id); + fd.append('nama', nama); + fd.append('jenis', el('fp_jenis').value); + fd.append('alamat', el('fp_alamat').value.trim()); + fd.append('latitude', lat); + fd.append('longitude', lng); + fd.append('radius', el('fp_radius').value); + fd.append('kontak', el('fp_kontak').value.trim()); + + postData(url, fd).then(res => { + if (res.status === 'success') { + if (previewMarker) map.removeLayer(previewMarker); + if (previewCircle) map.removeLayer(previewCircle); + previewMarker = null; previewCircle = null; + + // Update data lokal dan bersihkan radius lama agar tidak double + if (id) { + const idx = fasilitasPublikList.findIndex(r => String(r.id) === String(id)); + if (idx >= 0) fasilitasPublikList[idx] = res.data; + lgFasilitasPublik.clearLayers(); + const backup = [...fasilitasPublikList]; + fasilitasPublikList = []; // Reset state + backup.forEach(item => renderFasilitasPublik(item)); + } else { + renderFasilitasPublik(res.data); + } + + refreshStatusPenduduk(); + hideModal('fasilitasPublikModal'); + showToast(id ? 'Fasilitas Publik diperbarui.' : 'Fasilitas Publik berhasil disimpan.'); + } else { + showToast(res.message || 'Gagal menyimpan.', 'error'); + } + }); +}); + +/* ═══════════════════════════════════════════════════════ + SAVE: PENDUDUK MISKIN +═══════════════════════════════════════════════════════ */ +el('btnSavePenduduk').addEventListener('click', () => { + const nama = el('pm_nama').value.trim(); + const lat = el('pm_latitude').value.trim(); + const lng = el('pm_longitude').value.trim(); + + if (!nama) { showToast('Nama penduduk wajib diisi.', 'error'); return; } + if (!lat || !lng) { showToast('Klik pada peta untuk menentukan lokasi.', 'error'); return; } + + const cek = cekJangkauan(parseFloat(lat), parseFloat(lng)); + const id = el('pm_id').value; + const url = id ? 'penduduk_miskin/update.php' : 'penduduk_miskin/simpan.php'; + + const fd = new FormData(); + if (id) fd.append('id', id); + fd.append('nama', nama); + fd.append('nik', el('pm_nik').value.trim()); + fd.append('keterangan', el('pm_keterangan').value.trim()); + fd.append('latitude', lat); + fd.append('longitude', lng); + fd.append('status_jangkauan', cek.status); + fd.append('fasilitas_publik_id', cek.id || ''); + + postData(url, fd).then(res => { + if (res.status === 'success') { + if (previewMarker) { map.removeLayer(previewMarker); previewMarker = null; } + + // Update data lokal TANPA mereload halaman + if (id) { + const idx = pendudukList.findIndex(p => String(p.data.id) === String(id)); + if (idx >= 0) pendudukList[idx].data = res.data; + refreshStatusPenduduk(); + } else { + renderPenduduk(res.data); + updateDashboardStats(); // <--- Tambahkan Baris Ini + } + + hideModal('pendudukModal'); + showToast(id ? 'Data penduduk diperbarui.' : 'Penduduk miskin berhasil disimpan.'); + } else { + showToast(res.message || 'Gagal menyimpan.', 'error'); + } + }); +}); + +/* ═══════════════════════════════════════════════════════ + GLOBAL: HAPUS DATA +═══════════════════════════════════════════════════════ */ +window.hapusData = function (tipe, id, pesan) { + showDeleteConfirm(pesan || 'Hapus data ini?', () => { + const urlMap = { + point: 'point/hapus.php', + jalan: 'jalan/hapus.php', + polygon: 'polygon/hapus.php', + fasilitas_publik: 'fasilitas_publik/hapus.php', + penduduk_miskin: 'penduduk_miskin/hapus.php', + }; + + const fd = new FormData(); + fd.append('id', id); + + postData(urlMap[tipe], fd).then(res => { + if (res.status === 'success') { + map.closePopup(); // Tutup popup di map + showToast('Data berhasil dihapus.', 'success'); + + // Hapus elemen dari Peta TANPA refresh web + if (tipe === 'point') { + const idx = APP_DATA.points.findIndex(x => String(x.id) === String(id)); + if (idx > -1) APP_DATA.points.splice(idx, 1); + lgPoints.clearLayers(); + APP_DATA.points.forEach(renderPoint); + } else if (tipe === 'jalan') { + const idx = APP_DATA.jalan.findIndex(x => String(x.id) === String(id)); + if (idx > -1) APP_DATA.jalan.splice(idx, 1); + lgJalan.clearLayers(); + APP_DATA.jalan.forEach(renderJalan); + } else if (tipe === 'polygon') { + const idx = APP_DATA.polygons.findIndex(x => String(x.id) === String(id)); + if (idx > -1) APP_DATA.polygons.splice(idx, 1); + lgPolygon.clearLayers(); + APP_DATA.polygons.forEach(renderPolygon); + } else if (tipe === 'fasilitas_publik') { + fasilitasPublikList = fasilitasPublikList.filter(x => String(x.id) !== String(id)); + lgFasilitasPublik.clearLayers(); + const backup = [...fasilitasPublikList]; + fasilitasPublikList = []; + backup.forEach(item => renderFasilitasPublik(item)); + refreshStatusPenduduk(); + } else if (tipe === 'penduduk_miskin') { + pendudukList = pendudukList.filter(x => String(x.data.id) !== String(id)); + refreshStatusPenduduk(); + } + + } else { + showToast(res.message || 'Gagal menghapus.', 'error'); + } + }); + }); +}; + +/* ═══════════════════════════════════════════════════════ + GLOBAL: EDIT Fasilitas Publik +═══════════════════════════════════════════════════════ */ +window.editFasilitasPublik = function (id) { + fetch(`api/get_fasilitas_publik.php?id=${id}`) + .then(r => r.json()) + .then(res => { + if (res.status !== 'success') { showToast('Data tidak ditemukan.', 'error'); return; } + const d = res.data; + el('fp_id').value = d.id; + el('fp_nama').value = d.nama; + el('fp_jenis').value = d.jenis || 'Masjid'; + el('fp_alamat').value = d.alamat || ''; + el('fp_kontak').value = d.kontak || ''; + el('fp_latitude').value = d.latitude; + el('fp_longitude').value= d.longitude; + el('fp_radius').value = d.radius; + el('fp_radius_text').textContent = d.radius; + el('fpModalTitle').textContent = 'Edit Fasilitas Publik'; + + // Preview di peta + if (previewMarker) map.removeLayer(previewMarker); + if (previewCircle) map.removeLayer(previewCircle); + const ll = L.latLng(parseFloat(d.latitude), parseFloat(d.longitude)); + previewMarker = L.marker(ll, { icon: createFPIcon() }).addTo(map); + previewCircle = L.circle(ll, { + radius: parseInt(d.radius), + color: '#e53935', fillColor: '#ef9a9a', fillOpacity: 0.12, + weight: 2, dashArray: '6 4' + }).addTo(map); + map.setView(ll, 16); + + map.closePopup(); + showModal('fasilitasPublikModal'); + }) + .catch(() => showToast('Gagal memuat data.', 'error')); +}; + +/* ═══════════════════════════════════════════════════════ + GLOBAL: EDIT PENDUDUK +═══════════════════════════════════════════════════════ */ +window.editPenduduk = function (id) { + fetch(`api/get_penduduk.php?id=${id}`) + .then(r => r.json()) + .then(res => { + if (res.status !== 'success') { showToast('Data tidak ditemukan.', 'error'); return; } + const d = res.data; + el('pm_id').value = d.id; + el('pm_nama').value = d.nama; + el('pm_nik').value = d.nik || ''; + el('pm_keterangan').value = d.keterangan || ''; + el('pm_latitude').value = d.latitude; + el('pm_longitude').value = d.longitude; + el('pmModalTitle').textContent = 'Edit Penduduk Miskin'; + + const cek = cekJangkauan(parseFloat(d.latitude), parseFloat(d.longitude)); + updateStatusJangkauan(cek); + + if (previewMarker) map.removeLayer(previewMarker); + const ll = L.latLng(parseFloat(d.latitude), parseFloat(d.longitude)); + const warna = cek.status === 'Dalam Jangkauan' ? '#e53935' : '#2e7d32'; + previewMarker = L.circleMarker(ll, { + radius: 8, color: warna, fillColor: warna, fillOpacity: 0.85, weight: 2 + }).addTo(map); + map.setView(ll, 16); + + map.closePopup(); + showModal('pendudukModal'); + }) + .catch(() => showToast('Gagal memuat data.', 'error')); +}; + +/* ═══════════════════════════════════════════════════════ + UTILITY: POST DATA +═══════════════════════════════════════════════════════ */ +function postData(url, formData) { + return fetch(url, { method: 'POST', body: formData }) + .then(r => r.json()) + .catch(err => { + console.error(err); + showToast('Terjadi kesalahan koneksi.', 'error'); + return { status: 'error', message: 'Koneksi gagal.' }; + }); +} + +/* ═══════════════════════════════════════════════════════ + FITUR PENCARIAN (SEARCHING) +═══════════════════════════════════════════════════════ */ +const searchInput = el('searchInput'); +const searchResults = el('searchResults'); + +if (searchInput && searchResults) { + searchInput.addEventListener('input', function() { + const query = this.value.toLowerCase().trim(); + searchResults.innerHTML = ''; + + // Minimal ketik 2 huruf baru mencari + if (query.length < 2) { + searchResults.classList.add('hidden'); + return; + } + + let results = []; + + // 1. Cari Titik Biasa + APP_DATA.points.forEach(item => { + if (item.nama.toLowerCase().includes(query)) { + results.push({ title: item.nama, type: '📍 Titik Lokasi', lat: item.latitude, lng: item.longitude }); + } + }); + + // 2. Cari Fasilitas Publik + fasilitasPublikList.forEach(item => { + if (item.nama.toLowerCase().includes(query)) { + results.push({ title: item.nama, type: `🕌 Fasilitas Publik (${item.jenis})`, lat: item.latitude, lng: item.longitude }); + } + }); + + // 3. Cari Penduduk Miskin (Berdasarkan Nama atau NIK) + pendudukList.forEach(p => { + if (p.data.nama.toLowerCase().includes(query) || (p.data.nik && p.data.nik.includes(query))) { + results.push({ title: p.data.nama, type: '👤 Penduduk Miskin', lat: p.data.latitude, lng: p.data.longitude }); + } + }); + + // 4. Cari Jalan + APP_DATA.jalan.forEach(item => { + if (item.nama_jalan.toLowerCase().includes(query)) { + try { + const feat = JSON.parse(item.koordinat); + // Ambil titik koordinat pertama dari garis + const coords = feat.geometry.coordinates[0]; + results.push({ title: item.nama_jalan, type: `🛣️ Jalan (${item.status_jalan})`, lat: coords[1], lng: coords[0] }); + } catch(e) {} + } + }); + + // 5. Cari Polygon + APP_DATA.polygons.forEach(item => { + if (item.nama_parsil.toLowerCase().includes(query)) { + try { + const feat = JSON.parse(item.koordinat); + // Ambil titik koordinat pertama dari polygon + const coords = feat.geometry.coordinates[0][0]; + results.push({ title: item.nama_parsil, type: `⬠ Polygon (${item.status_kepemilikan})`, lat: coords[1], lng: coords[0] }); + } catch(e) {} + } + }); + + // Tampilkan Hasil + if (results.length > 0) { + results.forEach(res => { + const div = document.createElement('div'); + div.className = 'search-item'; + div.innerHTML = ` + ${res.title} + ${res.type} + `; + + // Aksi saat item hasil dicentang + div.addEventListener('click', () => { + // Animasi terbang ke lokasi + map.flyTo([parseFloat(res.lat), parseFloat(res.lng)], 18, { + animate: true, + duration: 1.5 + }); + + searchResults.classList.add('hidden'); + searchInput.value = res.title; // Isi kotak input dengan nama yang dipilih + + // Khusus tampilan HP: Sembunyikan sidebar agar peta terlihat langsung + if (window.innerWidth <= 680) { + el('sidebar').classList.remove('mobile-open'); + } + }); + + searchResults.appendChild(div); + }); + } else { + searchResults.innerHTML = '
Tidak ada data ditemukan
'; + } + + searchResults.classList.remove('hidden'); + }); + + // Menyembunyikan dropdown jika klik di tempat lain + document.addEventListener('click', (e) => { + if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) { + searchResults.classList.add('hidden'); + } + }); +} + +/* ── Init ─────────────────────────────────────────────── */ +setMode('point'); + +})(); + + diff --git a/publik/index.php b/publik/index.php new file mode 100644 index 0000000..ab41281 --- /dev/null +++ b/publik/index.php @@ -0,0 +1,1024 @@ +exec("ALTER TABLE penduduk_miskin ADD COLUMN status_bantuan ENUM('Belum Disalurkan', 'Proses', 'Sudah Disalurkan') DEFAULT 'Belum Disalurkan'"); +} catch(PDOException $e) { + // Abaikan error jika kolom sudah ada +} + +// Ambil semua data +$stmt = $pdo->query("SELECT * FROM points ORDER BY id DESC"); +$points = $stmt->fetchAll(); + +$stmt = $pdo->query("SELECT * FROM jalan ORDER BY id DESC"); +$jalan = $stmt->fetchAll(); + +$stmt = $pdo->query("SELECT * FROM polygon ORDER BY id DESC"); +$polygons = $stmt->fetchAll(); + +$stmt = $pdo->query("SELECT * FROM fasilitas_publik ORDER BY id DESC"); +$fasilitasPublik = $stmt->fetchAll(); + +$stmt = $pdo->query("SELECT pm.*, fp.nama AS fp_nama FROM penduduk_miskin pm LEFT JOIN fasilitas_publik fp ON pm.fasilitas_publik_id = fp.id ORDER BY pm.id DESC"); +$pendudukMiskin = $stmt->fetchAll(); + +// Statistik dashboard +$totalFP = count($fasilitasPublik); +$totalPM = count($pendudukMiskin); +$ditangani = count(array_filter($pendudukMiskin, fn($p) => $p['status_jangkauan'] === 'Dalam Jangkauan')); +$belumDitangani = $totalPM - $ditangani; +$persen = $totalPM > 0 ? round(($ditangani / $totalPM) * 100, 1) : 0; + +$totalSPBU = count($points); +$spbu24 = count(array_filter($points, fn($p) => $p['buka_24_jam'] === 'Ya')); +$spbuTidak = $totalSPBU - $spbu24; +?> + + + + + + WebGIS – Sistem Analisis Geospasial Pengentasan Kemiskinan + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ Menyiapkan Engine Geospasial... +
+ +
+ + + + + + + + + + + + + + +
+
+ + +
+ + +
+
+ Memuat peta... +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jalan/hapus.php b/publik/jalan/hapus.php similarity index 100% rename from jalan/hapus.php rename to publik/jalan/hapus.php diff --git a/jalan/simpan.php b/publik/jalan/simpan.php similarity index 100% rename from jalan/simpan.php rename to publik/jalan/simpan.php diff --git a/publik/koneksi.php b/publik/koneksi.php new file mode 100644 index 0000000..bdf6300 --- /dev/null +++ b/publik/koneksi.php @@ -0,0 +1,75 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]; + +try { + $pdo = new PDO($dsn, DB_USER, DB_PASS, $options); + + // ============================================================ + // AUTO-MIGRATION SCRIPT (Sesuai Permintaan) + // ============================================================ + + // 1. Buat tabel users jika belum ada (fallback) + $pdo->exec("CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role ENUM('admin','petugas','guest') NOT NULL DEFAULT 'guest', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB"); + + // 2. Cek dan tambahkan kolom baru pada tabel users + $columns = $pdo->query("SHOW COLUMNS FROM users")->fetchAll(PDO::FETCH_COLUMN); + + if (!in_array('email', $columns)) { + $pdo->exec("ALTER TABLE users ADD COLUMN email VARCHAR(150) UNIQUE NULL AFTER username"); + } + if (!in_array('nama_lengkap', $columns)) { + $pdo->exec("ALTER TABLE users ADD COLUMN nama_lengkap VARCHAR(150) NULL AFTER email"); + } + if (!in_array('status', $columns)) { + // Update user lama menjadi active terlebih dahulu jika diperlukan, tapi defaultnya active + $pdo->exec("ALTER TABLE users ADD COLUMN status ENUM('pending', 'active') NOT NULL DEFAULT 'active' AFTER role"); + } + if (!in_array('auth_provider', $columns)) { + $pdo->exec("ALTER TABLE users ADD COLUMN auth_provider ENUM('local', 'google') NOT NULL DEFAULT 'local' AFTER status"); + } + + // 3. Buat tabel settings + $pdo->exec("CREATE TABLE IF NOT EXISTS settings ( + setting_key VARCHAR(50) PRIMARY KEY, + setting_value VARCHAR(255) NOT NULL + ) ENGINE=InnoDB"); + + // 4. Masukkan data default settings jika belum ada + $pdo->exec("INSERT IGNORE INTO settings (setting_key, setting_value) VALUES ('show_demo_accounts', '1')"); + +} catch (PDOException $e) { + http_response_code(500); + die(json_encode([ + 'status' => 'error', + 'message' => 'Koneksi database gagal: ' . $e->getMessage() + ])); +} + +// Legacy mysqli compatibility (untuk file yang masih menggunakannya) +$conn = mysqli_connect(DB_HOST, DB_USER, DB_PASS, DB_NAME); +if ($conn) { + mysqli_set_charset($conn, DB_CHARSET); +} +?> diff --git a/point/hapus.php b/publik/point/hapus.php similarity index 100% rename from point/hapus.php rename to publik/point/hapus.php diff --git a/point/simpan.php b/publik/point/simpan.php similarity index 100% rename from point/simpan.php rename to publik/point/simpan.php diff --git a/point/update.php b/publik/point/update.php similarity index 100% rename from point/update.php rename to publik/point/update.php diff --git a/point/update_lokasi.php b/publik/point/update_lokasi.php similarity index 100% rename from point/update_lokasi.php rename to publik/point/update_lokasi.php diff --git a/polygon/hapus.php b/publik/polygon/hapus.php similarity index 100% rename from polygon/hapus.php rename to publik/polygon/hapus.php diff --git a/polygon/simpan.php b/publik/polygon/simpan.php similarity index 100% rename from polygon/simpan.php rename to publik/polygon/simpan.php diff --git a/publik/uploads/.gitkeep b/publik/uploads/.gitkeep new file mode 100644 index 0000000..c73c999 --- /dev/null +++ b/publik/uploads/.gitkeep @@ -0,0 +1 @@ +# Gitkeep diff --git a/publik/uploads/foto_kk_6a2a216972b273.24659129.png b/publik/uploads/foto_kk_6a2a216972b273.24659129.png new file mode 100644 index 0000000..e2795fa Binary files /dev/null and b/publik/uploads/foto_kk_6a2a216972b273.24659129.png differ diff --git a/publik/uploads/foto_rumah_6a2a2169722485.32558108.png b/publik/uploads/foto_rumah_6a2a2169722485.32558108.png new file mode 100644 index 0000000..e2795fa Binary files /dev/null and b/publik/uploads/foto_rumah_6a2a2169722485.32558108.png differ diff --git a/publik/uploads/foto_rumah_6a2a30a32dd215.01943630.png b/publik/uploads/foto_rumah_6a2a30a32dd215.01943630.png new file mode 100644 index 0000000..c4137bb Binary files /dev/null and b/publik/uploads/foto_rumah_6a2a30a32dd215.01943630.png differ