diff --git a/add_users.sql b/add_users.sql new file mode 100644 index 0000000..d41fec8 --- /dev/null +++ b/add_users.sql @@ -0,0 +1,21 @@ +-- ============================================================ +-- add_users.sql — Tabel Users untuk Login & Role +-- ============================================================ + +USE webgis; + +-- Tabel Users +DROP TABLE IF EXISTS users; +CREATE TABLE 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; + +-- Insert default accounts (password: 'password') +INSERT INTO users (username, password, role) VALUES +('admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'admin'), +('petugas', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'petugas'), +('guest', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'guest'); diff --git a/api/get_all_bantuan.php b/api/get_all_bantuan.php new file mode 100644 index 0000000..a288634 --- /dev/null +++ b/api/get_all_bantuan.php @@ -0,0 +1,30 @@ +query("SELECT * FROM fasilitas_publik ORDER BY id DESC"); + $fasilitasPublik = $stmtFp->fetchAll(); + + // 2. Fetch all penduduk_miskin (dengan nama FP jika ada join) + $stmtPm = $pdo->query(" + SELECT p.*, f.nama as fp_nama + FROM penduduk_miskin p + LEFT JOIN fasilitas_publik f ON p.fasilitas_publik_id = f.id + ORDER BY p.id DESC + "); + $pendudukMiskin = $stmtPm->fetchAll(); + + echo json_encode([ + 'status' => 'success', + 'fasilitasPublik' => $fasilitasPublik, + 'pendudukMiskin' => $pendudukMiskin + ]); +} catch (PDOException $e) { + echo json_encode([ + 'status' => 'error', + 'message' => 'Database error: ' . $e->getMessage() + ]); +} +?> diff --git a/api/get_point.php b/api/get_point.php new file mode 100644 index 0000000..da201cc --- /dev/null +++ b/api/get_point.php @@ -0,0 +1,24 @@ + 'error', 'message' => 'ID tidak diberikan']); + exit; +} + +try { + $stmt = $pdo->prepare("SELECT * FROM points WHERE id = ?"); + $stmt->execute([$id]); + $data = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($data) { + echo json_encode(['status' => 'success', 'data' => $data]); + } else { + echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan']); + } +} catch (PDOException $e) { + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); +} diff --git a/api/update_status_bantuan.php b/api/update_status_bantuan.php new file mode 100644 index 0000000..acd7f8e --- /dev/null +++ b/api/update_status_bantuan.php @@ -0,0 +1,28 @@ +'error','message'=>'Akses ditolak.']); + exit; +} + +include '../koneksi.php'; +header('Content-Type: application/json'); + +$id = (int)($_POST['id'] ?? 0); +$status_bantuan = trim($_POST['status_bantuan'] ?? ''); + +if (!$id || !$status_bantuan) { + echo json_encode(['status'=>'error','message'=>'Data tidak valid.']); + exit; +} + +try { + $stmt = $pdo->prepare("UPDATE penduduk_miskin SET status_bantuan=? WHERE id=?"); + $stmt->execute([$status_bantuan, $id]); + echo json_encode(['status'=>'success']); +} catch (PDOException $e) { + echo json_encode(['status'=>'error','message'=>$e->getMessage()]); +} +?> diff --git a/api_admin.php b/api_admin.php new file mode 100644 index 0000000..543c600 --- /dev/null +++ b/api_admin.php @@ -0,0 +1,98 @@ + 'error', 'message' => 'Unauthorized']); + exit; +} + +include 'koneksi.php'; + +$action = $_POST['action'] ?? ''; + +try { + if ($action === 'approve_petugas') { + $id = (int)$_POST['id']; + $stmt = $pdo->prepare("UPDATE users SET status = 'active' WHERE id = ? AND role = 'petugas'"); + $stmt->execute([$id]); + echo json_encode(['status' => 'success']); + } + elseif ($action === 'reject_petugas') { + $id = (int)$_POST['id']; + $stmt = $pdo->prepare("DELETE FROM users WHERE id = ? AND role = 'petugas' AND status = 'pending'"); + $stmt->execute([$id]); + echo json_encode(['status' => 'success']); + } + elseif ($action === 'toggle_setting') { + $key = $_POST['key']; + $val = $_POST['value']; + $stmt = $pdo->prepare("UPDATE settings SET setting_value = ? WHERE setting_key = ?"); + $stmt->execute([$val, $key]); + echo json_encode(['status' => 'success']); + } + elseif ($action === 'update_user') { + $id = (int)$_POST['id']; + $role = $_POST['role']; + $status = $_POST['status']; + + // Cek jika ini adalah admin utama + $stmt = $pdo->prepare("SELECT username FROM users WHERE id = ?"); + $stmt->execute([$id]); + $userTarget = $stmt->fetch(); + if ($userTarget && $userTarget['username'] === 'admin') { + echo json_encode(['status' => 'error', 'message' => 'Akun Admin Utama tidak dapat diubah hak akses atau statusnya.']); + exit; + } + + // Cek admin terakhir + if ($role !== 'admin' || $status !== 'active') { + $stmt = $pdo->prepare("SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active' AND id != ?"); + $stmt->execute([$id]); + if ($stmt->fetchColumn() == 0) { + echo json_encode(['status' => 'error', 'message' => 'Harus ada minimal satu admin aktif']); + exit; + } + } + + $stmt = $pdo->prepare("UPDATE users SET role = ?, status = ? WHERE id = ?"); + $stmt->execute([$role, $status, $id]); + echo json_encode(['status' => 'success']); + } + elseif ($action === 'delete_user') { + $id = (int)$_POST['id']; + + $stmt = $pdo->prepare("SELECT username, role, status FROM users WHERE id = ?"); + $stmt->execute([$id]); + $user = $stmt->fetch(); + + if (!$user) { + echo json_encode(['status' => 'error', 'message' => 'User tidak ditemukan']); + exit; + } + + if ($user['username'] === 'admin') { + echo json_encode(['status' => 'error', 'message' => 'Akun Admin Utama tidak dapat dihapus.']); + exit; + } + + // Cek admin terakhir + if ($user['role'] === 'admin' && $user['status'] === 'active') { + $stmt = $pdo->prepare("SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active' AND id != ?"); + $stmt->execute([$id]); + if ($stmt->fetchColumn() == 0) { + echo json_encode(['status' => 'error', 'message' => 'Tidak dapat menghapus admin aktif terakhir']); + exit; + } + } + + $stmt = $pdo->prepare("DELETE FROM users WHERE id = ?"); + $stmt->execute([$id]); + echo json_encode(['status' => 'success']); + } + else { + echo json_encode(['status' => 'error', 'message' => 'Invalid action']); + } +} catch (Exception $e) { + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); +} diff --git a/assets/animasi-lokasi.json b/assets/animasi-lokasi.json new file mode 100644 index 0000000..f9ef02c --- /dev/null +++ b/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/assets/css/style.css b/assets/css/style.css index 9d8458a..55a3bf4 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -1,4 +1,4 @@ -/* ============================================================ +/* ============================================================ WebGIS - style.css Design: Red & White Theme (Merah Putih) Font: Plus Jakarta Sans + JetBrains Mono @@ -52,6 +52,8 @@ body { 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); @@ -59,8 +61,7 @@ body { display: flex; flex-direction: column; z-index: 900; - overflow-y: auto; - overflow-x: hidden; + 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); } @@ -116,7 +117,7 @@ body { /* Sidebar Section */ .sidebar-section { - padding: 16px; + padding: 10px 16px; border-bottom: 1px solid var(--sidebar-border); } @@ -124,21 +125,21 @@ body { font-size: 10px; font-weight: 700; color: var(--sidebar-text-muted); letter-spacing: 1.2px; text-transform: uppercase; - margin-bottom: 12px; + margin-bottom: 8px; } /* Stats Grid */ .stats-grid { display: grid; grid-template-columns: 1fr 1fr; - gap: 8px; - margin-bottom: 14px; + gap: 6px; + margin-bottom: 8px; } .stat-card { background: var(--surface-2); border-radius: var(--radius-md); - padding: 12px 10px; + padding: 8px 10px; display: flex; align-items: center; gap: 8px; border: 1px solid var(--sidebar-border); transition: transform var(--transition); @@ -184,13 +185,13 @@ body { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; - margin-bottom: 10px; + margin-bottom: 6px; } .tool-btn { display: flex; flex-direction: column; align-items: center; justify-content: center; - gap: 4px; padding: 10px 6px; + gap: 2px; padding: 6px 4px; background: var(--surface); border: 1px solid var(--sidebar-border); border-radius: var(--radius-sm); @@ -210,8 +211,8 @@ body { .tool-icon { font-size: 18px; } .help-text { - font-size: 11px; color: var(--sidebar-text-muted); - line-height: 1.5; padding: 8px 10px; + 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); @@ -222,7 +223,7 @@ body { .layer-item { display: flex; align-items: center; gap: 8px; - padding: 7px 8px; + padding: 4px 6px; border-radius: var(--radius-sm); cursor: pointer; transition: background var(--transition); font-size: 12.5px; color: #0f172a; font-weight: 500; @@ -237,8 +238,8 @@ body { } /* Legend */ -.legend-list { display: flex; flex-direction: column; gap: 6px; } -.legend-item { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--sidebar-text); font-weight: 500; } +.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%; @@ -262,6 +263,78 @@ body { 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) */ @@ -289,7 +362,11 @@ body { position: fixed; left: var(--sidebar-w); top: 0; right: 0; bottom: 0; - transition: left var(--transition); + transition: left var(--transition), right var(--transition); +} + +#mapContainer.with-right-sidebar { + right: var(--right-sidebar-w); } #mapContainer.full-width { left: 0; } @@ -740,14 +817,17 @@ body { @media (max-width: 680px) { .sidebar { - transform: translateX(calc(-1 * var(--sidebar-w))); - width: 85%; - max-width: 300px; + 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); - box-shadow: 10px 0 25px rgba(0,0,0,0.2); } #mapContainer { left: 0 !important; @@ -755,7 +835,13 @@ body { .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); @@ -778,3 +864,1217 @@ body { .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/assets/img/map-bg.png b/assets/img/map-bg.png new file mode 100644 index 0000000..2c3c088 Binary files /dev/null and b/assets/img/map-bg.png differ diff --git a/assets/js/app.js b/assets/js/app.js index aea8add..b6eb1a5 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,15 +1,15 @@ /* ============================================================ - WebGIS — app.js + WebGIS - app.js Sistem Informasi Geografis Pengentasan Kemiskinan ============================================================ */ (function () { 'use strict'; -/* ── Konfigurasi ─────────────────────────────────────── */ +/* -- Konfigurasi ----------------------------------------- */ const DEFAULT_CENTER = [-0.05754868797441759, 109.32739998281652]; const DEFAULT_ZOOM = 14; -/* ── State Aplikasi ──────────────────────────────────── */ +/* -- 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 @@ -29,18 +29,20 @@ let polygonLatLngs = []; let fasilitasPublikList = []; // [{id, nama, lat, lng, radius}, ...] let pendudukList = []; // [{data, marker}, ...] -/* ── Layer Groups ────────────────────────────────────── */ +/* -- 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 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 ───────────────────────────────── */ +/* -- Inisialisasi Peta ----------------------------------- */ const savedCenter = (() => { try { return JSON.parse(localStorage.getItem('webgis_center')); } catch { return null; } })(); @@ -51,10 +53,10 @@ const map = L.map('map', { zoomControl: true }).setView(savedCenter || DEFAULT_CENTER, savedZoom); -// Base tile -L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { +// 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' + attribution: '© OpenStreetMap contributors © CARTO' }).addTo(map); // Scale control @@ -67,29 +69,53 @@ map.on('moveend zoomend', () => { 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(() => el('mapLoading').classList.add('hidden'), 600); + 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, lgPenduduk].forEach(lg => lg.addTo(map)); -if (document.getElementById("layerTitik") && document.getElementById("layerTitik").checked) lgPoints.addTo(map); +/* -- 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: '✓', error: '✕', info: 'ⓘ', warning: 'âš ' }; + const icons = { success: '\u2713', error: '\u2715', info: '\u2139', warning: '\u26A0' }; toast.className = `toast ${type}`; - toast.innerHTML = `${icons[type] || '•'}${message}`; + toast.innerHTML = `${icons[type] || '\u2022'}${message}`; container.appendChild(toast); setTimeout(() => { toast.classList.add('fade-out'); @@ -126,7 +152,7 @@ function setHelpText(text) { el('helpText').textContent = text; } -/* ── Kalkulasi Geospasial ─────────────────────────────── */ +/* -- Kalkulasi Geospasial -------------------------------- */ function hitungPanjang(latlngs) { let total = 0; @@ -152,16 +178,19 @@ function getDistanceMeter(lat1, lng1, 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)) { + if (d <= parseFloat(ri.radius) && d < minDistance) { + minDistance = d; hasil = { status: 'Dalam Jangkauan', id: ri.id, nama: ri.nama }; } }); return hasil; } -/* ── GeoJSON helpers ─────────────────────────────────── */ +/* -- GeoJSON helpers ------------------------------------- */ function toLineFeature(latlngs, props = {}) { return { @@ -198,7 +227,7 @@ function polygonFeatureToLatLngs(feat) { return ring.map(c => [c[1], c[0]]); } -/* ── Warna Helper ─────────────────────────────────────── */ +/* -- Warna Helper ---------------------------------------- */ function warnaJalan(status) { return { 'Jalan Nasional': '#d32f2f', 'Jalan Provinsi': '#f57c00', 'Jalan Kabupaten': '#388e3c' }[status] || '#1565c0'; @@ -217,45 +246,46 @@ function warnaKecamatan(nama) { return colors[nama] || '#888888'; } -/* ── Custom Marker Icons ─────────────────────────────── */ +/* -- Custom Marker Icons --------------------------------- */ function createFPIcon() { + const svg = ``; return L.divIcon({ className: '', - html: `
`, + 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] }); } -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; } @@ -315,9 +345,9 @@ function updatePolygonPreview() { el('koordinat_polygon').value = JSON.stringify(toPolygonFeature(polygonLatLngs), null, 2); } -/* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• +/* ------------------------------------------------------- MODE MANAGEMENT -••••••••••••••••••••••••••••••••••••••••••••••••••••••• */ +------------------------------------------------------- */ const MODES = { point: { btn: 'btnToolPoint', help: 'Klik pada peta untuk menambah titik.' }, @@ -346,39 +376,96 @@ function setMode(newMode) { // 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'); + 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 (SPBU) ─────────────────────────────── */ +/* -- 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) }).addTo(lgPoints); + 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; } -/* ── Render Jalan ─────────────────────────────────────── */ +/* -- 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); @@ -389,12 +476,12 @@ function renderJalan(item) { const popup = ` `; poly.bindPopup(popup, { maxWidth: 280 }); @@ -402,7 +489,7 @@ function renderJalan(item) { } catch (e) { console.error('renderJalan error:', e); } } -/* ── Render Polygon ───────────────────────────────────── */ +/* -- Render Polygon -------------------------------------- */ function renderPolygon(item) { try { const feat = JSON.parse(item.koordinat); @@ -414,12 +501,12 @@ function renderPolygon(item) { const popup = ` `; poly.bindPopup(popup, { maxWidth: 280 }); @@ -427,7 +514,7 @@ function renderPolygon(item) { } catch (e) { console.error('renderPolygon error:', e); } } -/* ── Render Fasilitas Publik ─────────────────────────────── */ +/* -- Render Fasilitas Publik ----------------------------- */ function renderFasilitasPublik(item) { const lat = parseFloat(item.latitude); const lng = parseFloat(item.longitude); @@ -438,13 +525,14 @@ function renderFasilitasPublik(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' + fillOpacity: 0.12, weight: 2, dashArray: '6 4', + interactive: false }).addTo(lgFasilitasPublik); const popup = ` `; marker.bindPopup(popup, { maxWidth: 300 }); - circle.bindPopup(popup, { maxWidth: 300 }); return { marker, circle }; } -/* ── Render Penduduk Miskin ───────────────────────────── */ +/* -- 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 warna = cek.status === 'Dalam Jangkauan' ? '#e53935' : '#2e7d32'; - const fillW = cek.status === 'Dalam Jangkauan' ? '#ef5350' : '#43a047'; + 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); - const marker = L.circleMarker([lat, lng], { - radius: 8, color: warna, fillColor: fillW, - fillOpacity: 0.85, weight: 2 - }).addTo(lgPenduduk); + 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 }); @@ -498,16 +597,17 @@ function renderPenduduk(item) { return marker; } -/* ── Refresh Status Penduduk ──────────────────────────── */ +/* -- Refresh Status Penduduk ----------------------------- */ function refreshStatusPenduduk() { - lgPenduduk.clearLayers(); + 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 ──────── */ +/* -- Update Statistik Dashboard Secara Real-Time --------- */ function updateDashboardStats() { const totalFP = fasilitasPublikList.length; const totalPM = pendudukList.length; @@ -521,6 +621,17 @@ function updateDashboardStats() { 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; @@ -529,18 +640,51 @@ function updateDashboardStats() { 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) @@ -594,45 +738,50 @@ function loadGeoJSON(url, options = {}) { Promise.all([ loadGeoJSON('data/Sungai_Besar.geojson', { title: 'Sungai Besar', color: '#0d47a1', fillColor: '#90caf9', - weight: 2, fitBounds: false, addToMap: el('layerSungai') && el('layerSungai').checked + weight: 2, fitBounds: false, addToMap: false }), loadGeoJSON('data/admin_kecamatan.geojson', { title: 'Admin Kecamatan', dynamicColor: true, - color: '#ef6c00', weight: 1.5, fitBounds: false, addToMap: el('layerKecamatan') && el('layerKecamatan').checked + 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: el('layerGeoJalan') && el('layerGeoJalan').checked + 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) map.removeLayer(geoSungaiLayer); if (el('layerKecamatan') && !el('layerKecamatan').checked) map.removeLayer(geoKecamatanLayer); if (el('layerGeoJalan') && !el('layerGeoJalan').checked) map.removeLayer(geoJalanLayer); }); + 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 = '⏳ Mencari alamat...'; + 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 = '✓'; + el('geocodeStatus').textContent = '\u2713'; setTimeout(() => { el('geocodeStatus').textContent = ''; }, 2000); }) .catch(() => { el('fp_alamat').value = 'Gagal mengambil alamat'; - el('geocodeStatus').textContent = '✕'; + el('geocodeStatus').textContent = '\u2715'; }); } -/* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• +/* ------------------------------------------------------- MAP EVENT HANDLERS -••••••••••••••••••••••••••••••••••••••••••••••••••••••• */ +------------------------------------------------------- */ map.on('click', e => { const { lat, lng } = e.latlng; @@ -687,14 +836,10 @@ map.on('click', e => { 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 + updateStatusJangkauan(cek); if (previewMarker) map.removeLayer(previewMarker); + previewMarker = L.marker(e.latlng, { + icon: createPendudukIcon(cek.status === 'Dalam Jangkauan') }).addTo(map); - showModal('pendudukModal'); return; } @@ -751,7 +896,7 @@ map.on('locationfound', e => { showToast('Lokasi ditemukan!', 'success'); L.marker(e.latlng) .addTo(map) - .bindPopup('') + .bindPopup('') .openPopup(); }); @@ -763,9 +908,9 @@ 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'); @@ -774,9 +919,9 @@ function updateStatusJangkauan(cek) { 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; @@ -784,35 +929,40 @@ document.querySelectorAll('.tool-btn').forEach(btn => { }); }); -/* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• +/* ------------------------------------------------------- SIDEBAR TOGGLE -••••••••••••••••••••••••••••••••••••••••••••••••••••••• */ -const sidebar = el('sidebar'); -const mapContainer = el('mapContainer'); -const sidebarOpenBtn= el('sidebarOpenBtn'); -const isMobile = () => window.innerWidth <= 680; +------------------------------------------------------- */ + /* -- 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(); + } + }); -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; @@ -826,35 +976,42 @@ function bindLayerToggle(checkboxId, layerGroup) { } bindLayerToggle('layerFasilitasPublik', lgFasilitasPublik); -bindLayerToggle('layerPendudukDalam', lgPenduduk); -bindLayerToggle('layerPendudukLuar', lgPenduduk); +bindLayerToggle('layerPendudukDalam', lgPendudukDalam); +bindLayerToggle('layerPendudukLuar', lgPendudukLuar); -bindLayerToggle("layerTitik", lgPoints); +bindLayerToggle("layerSpbu24", lgSpbu24); +bindLayerToggle("layerSpbuTidak", lgSpbuTidak); 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); } -}); +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 -••••••••••••••••••••••••••••••••••••••••••••••••••••••• */ +/* ------------------------------------------------------- + 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'], @@ -890,9 +1047,9 @@ el('cancelDelete').addEventListener('click', () => { deleteCallback = null; }); -/* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• +/* ------------------------------------------------------- SAVE: POINT -••••••••••••••••••••••••••••••••••••••••••••••••••••••• */ +------------------------------------------------------- */ el('btnSavePoint').addEventListener('click', () => { const nama = el('point_nama').value.trim(); const no_wa = el('point_no_wa').value.trim(); @@ -901,7 +1058,8 @@ el('btnSavePoint').addEventListener('click', () => { 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; + 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; @@ -922,12 +1080,16 @@ el('btnSavePoint').addEventListener('click', () => { 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(); + 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.'); @@ -937,17 +1099,19 @@ el('btnSavePoint').addEventListener('click', () => { }); }); -/* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• +/* ------------------------------------------------------- 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; } + 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); @@ -958,11 +1122,12 @@ el('btnSaveJalan').addEventListener('click', () => { postData('jalan/simpan.php', fd).then(res => { if (res.status === 'success') { clearAllPreview(); - APP_DATA.jalan.push(res.data); // Sinkronisasi array lokal + 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'); @@ -970,17 +1135,19 @@ el('btnSaveJalan').addEventListener('click', () => { }); }); -/* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• +/* ------------------------------------------------------- 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; } + 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); @@ -991,11 +1158,12 @@ el('btnSavePolygon').addEventListener('click', () => { postData('polygon/simpan.php', fd).then(res => { if (res.status === 'success') { clearAllPreview(); - APP_DATA.polygons.push(res.data); // Sinkronisasi array lokal + 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'); @@ -1003,16 +1171,18 @@ el('btnSavePolygon').addEventListener('click', () => { }); }); -/* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• +/* ------------------------------------------------------- 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; } + 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'; @@ -1033,19 +1203,9 @@ el('btnSaveFasilitasPublik').addEventListener('click', () => { 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); - } + // Panggil refreshDataModul secara real-time tanpa reload + refreshDataModul(); - refreshStatusPenduduk(); hideModal('fasilitasPublikModal'); showToast(id ? 'Fasilitas Publik diperbarui.' : 'Fasilitas Publik berhasil disimpan.'); } else { @@ -1054,16 +1214,18 @@ el('btnSaveFasilitasPublik').addEventListener('click', () => { }); }); -/* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• +/* ------------------------------------------------------- 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; } + 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; @@ -1077,21 +1239,20 @@ el('btnSavePenduduk').addEventListener('click', () => { 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; } - // 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 - } + // Panggil refreshDataModul secara real-time tanpa reload + refreshDataModul(); hideModal('pendudukModal'); showToast(id ? 'Data penduduk diperbarui.' : 'Penduduk miskin berhasil disimpan.'); @@ -1101,9 +1262,9 @@ el('btnSavePenduduk').addEventListener('click', () => { }); }); -/* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• +/* ------------------------------------------------------- GLOBAL: HAPUS DATA -••••••••••••••••••••••••••••••••••••••••••••••••••••••• */ +------------------------------------------------------- */ window.hapusData = function (tipe, id, pesan) { showDeleteConfirm(pesan || 'Hapus data ini?', () => { const urlMap = { @@ -1121,13 +1282,16 @@ window.hapusData = function (tipe, id, pesan) { 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); - lgPoints.clearLayers(); + 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); @@ -1138,16 +1302,9 @@ window.hapusData = function (tipe, id, pesan) { 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 if (tipe === 'fasilitas_publik' || tipe === 'penduduk_miskin') { + // Khusus modul bantuan, panggil refresh penuh + refreshDataModul(); } } else { @@ -1157,9 +1314,48 @@ window.hapusData = function (tipe, id, pesan) { }); }; -/* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• +/* ------------------------------------------------------- + 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()) @@ -1185,7 +1381,7 @@ window.editFasilitasPublik = function (id) { previewCircle = L.circle(ll, { radius: parseInt(d.radius), color: '#e53935', fillColor: '#ef9a9a', fillOpacity: 0.12, - weight: 2, dashArray: '6 4' + weight: 2, dashArray: '6 4', interactive: false }).addTo(map); map.setView(ll, 16); @@ -1195,9 +1391,9 @@ window.editFasilitasPublik = function (id) { .catch(() => showToast('Gagal memuat data.', 'error')); }; -/* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• +/* ------------------------------------------------------- GLOBAL: EDIT PENDUDUK -••••••••••••••••••••••••••••••••••••••••••••••••••••••• */ +------------------------------------------------------- */ window.editPenduduk = function (id) { fetch(`api/get_penduduk.php?id=${id}`) .then(r => r.json()) @@ -1208,30 +1404,28 @@ window.editPenduduk = function (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)); - const warna = cek.status === 'Dalam Jangkauan' ? '#e53935' : '#2e7d32'; - previewMarker = L.circleMarker(ll, { - radius: 8, color: warna, fillColor: warna, fillOpacity: 0.85, weight: 2 + 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()) @@ -1242,9 +1436,9 @@ function postData(url, formData) { }); } -/* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• +/* ------------------------------------------------------- FITUR PENCARIAN (SEARCHING) -••••••••••••••••••••••••••••••••••••••••••••••••••••••• */ +------------------------------------------------------- */ const searchInput = el('searchInput'); const searchResults = el('searchResults'); @@ -1264,21 +1458,21 @@ if (searchInput && searchResults) { // 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 }); + 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 }); + 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 }); + results.push({ title: p.data.nama, type: '👤 Penduduk Miskin', lat: p.data.latitude, lng: p.data.longitude }); } }); @@ -1289,7 +1483,7 @@ if (searchInput && searchResults) { 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] }); + results.push({ title: item.nama_jalan, type: `🛣️ Jalan (${item.status_jalan})`, lat: coords[1], lng: coords[0] }); } catch(e) {} } }); @@ -1301,7 +1495,7 @@ if (searchInput && searchResults) { 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] }); + results.push({ title: item.nama_parsil, type: `📐 Polygon (${item.status_kepemilikan})`, lat: coords[1], lng: coords[0] }); } catch(e) {} } }); @@ -1350,9 +1544,1168 @@ if (searchInput && searchResults) { }); } -/* ── Init ─────────────────────────────────────────────── */ -setMode('point'); +/* -- 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/dashboard.php b/dashboard.php new file mode 100644 index 0000000..ab40e09 --- /dev/null +++ b/dashboard.php @@ -0,0 +1,609 @@ + + + + + + + Dashboard – WebGIS Pengentasan Kemiskinan + + + + + + + + + + + +
+
+
+
+ +
+
+

WebGIS Geospasial

+

Kementerian Analisis Spasial

+
+
+ +
+ +
+ + +
+
+ + + + +
+
+
+ + +
+ + +
+
+ + Sistem Aktif +
+

+ Halo, 👋 +

+

+ Pilih modul analisis spasial yang ingin Anda kerjakan hari ini. Sistem menyediakan berbagai perangkat untuk pemetaan, digitasi, dan analisis data kemiskinan terpadu. +

+
+ + +
+ + + +
+
+ +
+
+ +
+
+
+

Pemetaan SPBU

+

+ Kelola titik lokasi SPBU, pantau status operasional 24 jam, dan update informasi layanan bahan bakar. +

+
+
+ + + +
+
+ +
+
+ +
+
+
+

Analisis Bantuan

+

+ Data spasial penerima bantuan kemiskinan, upload dokumentasi foto rumah dan KK secara terpadu. +

+
+
+ + + +
+
+ +
+
+ +
+
+
+

Peta Tematik

+

+ Visualisasi sebaran wilayah rawan bencana (Banjir, Longsor, Kebakaran) melalui layer GeoJSON yang interaktif. +

+
+
+ + + +
+
+ +
+
+ +
+
+
+

Digitasi Lahan

+

+ Fitur editing geometri spasial. Buat dan edit batas area (polygon) langsung di atas peta untuk pemetaan aset. +

+
+
+ + + +
+
+ +
+
+ +
+
+
+

WebGIS Terpadu

+

+ Tampilan peta keseluruhan yang menggabungkan semua layer dan modul secara bersamaan. +

+
+
+ +
+ + + query("SELECT id, username, email, nama_lengkap, created_at FROM users WHERE role = 'petugas' AND status = 'pending'"); + $pendingUsers = $stmtPending->fetchAll(); + + // Ambil semua data pengguna untuk manajemen + $stmtAll = $pdo->query("SELECT id, username, email, nama_lengkap, role, status, created_at FROM users ORDER BY created_at DESC"); + $allUsers = $stmtAll->fetchAll(); + + // Ambil pengaturan show_demo_accounts + $stmtSet = $pdo->query("SELECT setting_value FROM settings WHERE setting_key = 'show_demo_accounts'"); + $showDemo = '0'; + if ($r = $stmtSet->fetch()) $showDemo = $r['setting_value']; + ?> + +
+

Panel Administrator

+ +
+ +
+
+

Approval Petugas Lapangan

+ + Menunggu + +
+ + 0): ?> +
+ + + + + + + + + + + + + + + + + +
Nama LengkapEmailAksi
+ + +
+
+ +
+ 🎉 +

Tidak ada pendaftaran baru

+

Semua petugas telah diverifikasi.

+
+ +
+ + +
+

Pengaturan Sistem

+ +
+
+

Tampilkan Tombol Akun Demo

+

Memunculkan helper login untuk demonstrasi aplikasi.

+
+ +
+
+
+ + +
+
+
+

+ + + + Manajemen Akun Terdaftar +

+

Atur hak akses (role) dan status pengguna sistem.

+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
Nama LengkapEmail / UsernameRoleStatusTerdaftarAksi
+
+
+ +
+ +
+
+
+ + @ +
+
+ + + Super Admin + + +
+ + +
+ +
+ + + Active + + +
+ + +
+ +
+ + + + + + Anda + +
+
+
+ +
+ + + + + + +
+ + + + + + diff --git a/data.zip b/data.zip new file mode 100644 index 0000000..764c503 Binary files /dev/null and b/data.zip differ diff --git a/fasilitas_publik/hapus.php b/fasilitas_publik/hapus.php index 911c319..15d5b72 100644 --- a/fasilitas_publik/hapus.php +++ b/fasilitas_publik/hapus.php @@ -1,4 +1,12 @@ 'error','message'=>'Akses ditolak. Hanya admin yang dapat menghapus data.']); + exit; +} + include '../koneksi.php'; header('Content-Type: application/json'); diff --git a/fasilitas_publik/simpan.php b/fasilitas_publik/simpan.php index d8e30d8..ca50112 100644 --- a/fasilitas_publik/simpan.php +++ b/fasilitas_publik/simpan.php @@ -1,4 +1,12 @@ 'error','message'=>'Akses ditolak.']); + exit; +} + // fasilitas_publik/simpan.php include '../koneksi.php'; header('Content-Type: application/json'); diff --git a/fasilitas_publik/update.php b/fasilitas_publik/update.php index 39d4208..7cb577b 100644 --- a/fasilitas_publik/update.php +++ b/fasilitas_publik/update.php @@ -1,4 +1,12 @@ 'error','message'=>'Akses ditolak.']); + exit; +} + include '../koneksi.php'; header('Content-Type: application/json'); diff --git a/fix_emoji.php b/fix_emoji.php new file mode 100644 index 0000000..f15c7ff --- /dev/null +++ b/fix_emoji.php @@ -0,0 +1,99 @@ + correct HTML entity replacement +$replacements = [ + // Broken trash can emoji (ðŸ—') -> HTML entity for 🗑 + "\xC3\xB0\xC5\xB8\xE2\x80\x97\xE2\x80\x99" => "🗑", + + // Broken mosque emoji (🕌) -> HTML entity for 🏢 + "\xC3\xB0\xC5\xB8\xE2\x80\xA2\xC5\x92" => "🏢", + + // Broken person emoji (ðŸ'¤) -> HTML entity for 👤 + "\xC3\xB0\xC5\xB8\xE2\x80\x98\xC2\xA4" => "👤", + + // Broken satellite emoji (ðŸ"¡) -> HTML entity for 📡 + "\xC3\xB0\xC5\xB8\xE2\x80\x9C\xC2\xA1" => "📡", + + // Broken pencil (âœ) -> HTML entity for ✏️ + "\xC3\xA2\xC5\x93" => "✏️", + + // Broken square emoji (â¬) -> HTML entity for 📐 + "\xC3\xA2\xC2\xAC" => "📐", + + // Broken m² (m²) -> correct m² + "m\xC3\x82\xC2\xB2" => "m²", + + // Broken info icon (ⓘ) + "\xE2\x93\x98" => "ℹ", + + // Broken warning icon (âš ) + "\xC3\xA2\xC5\xA1\xC2\xA0" => "⚠", + + // Broken bullet (•) + "\xC3\xA2\xE2\x80\x9A\xC2\xAC" => "•", +]; + +$count = 0; +foreach ($replacements as $broken => $fixed) { + $occurrences = substr_count($content, $broken); + if ($occurrences > 0) { + $content = str_replace($broken, $fixed, $content); + echo "Fixed: '$fixed' ($occurrences occurrences)\n"; + $count += $occurrences; + } +} + +if ($count === 0) { + echo "No broken emoji patterns found. Trying alternative byte patterns...\n"; + + // Try reading the file as raw bytes and searching for patterns + $hex = bin2hex($content); + + // Let's search for common patterns around "Hapus" text + $hapus_pos = strpos($content, 'Hapus'); + if ($hapus_pos !== false) { + // Look at the bytes right before "Hapus" to see what the broken emoji looks like + $before = substr($content, max(0, $hapus_pos - 20), 20); + echo "Bytes before first 'Hapus': " . bin2hex($before) . "\n"; + echo "Chars before first 'Hapus': " . $before . "\n"; + } + + // Search for "Edit" button text + $edit_pos = strpos($content, 'Edit'); + if ($edit_pos !== false) { + $before = substr($content, max(0, $edit_pos - 10), 10); + echo "Bytes before 'Edit': " . bin2hex($before) . "\n"; + } + + // Search for mosque pattern near "item.nama" + $fp_title = strpos($content, '${item.nama}'); + if ($fp_title !== false) { + $before = substr($content, max(0, $fp_title - 15), 15); + echo "Bytes before '\${item.nama}': " . bin2hex($before) . "\n"; + } +} + +if ($count > 0) { + // Backup + copy($file, $file . '.bak'); + + // Write fixed content + file_put_contents($file, $content); + echo "\nTotal: $count emoji fixes applied.\n"; + echo "Backup saved as: app.js.bak\n"; +} else { + echo "\nNo fixes applied.\n"; +} diff --git a/index.php b/index.php index 9074d5a..75de4f4 100644 --- a/index.php +++ b/index.php @@ -1,9 +1,28 @@ -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(); @@ -26,6 +45,10 @@ $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; ?> @@ -38,200 +61,437 @@ $persen = $totalPM > 0 ? round(($ditangani / $totalPM) * 100, 1) : 0; + - - + + + + + + + + + - +
+ +
+ +
+ Menyiapkan Engine Geospasial... +
+ +
+ + +============================================================ --> @@ -242,9 +502,81 @@ $persen = $totalPM > 0 ? round(($ditangani / $totalPM) * 100, 1) : 0; - + + + + + + +============================================================ -->
@@ -258,9 +590,9 @@ $persen = $totalPM > 0 ? round(($ditangani / $totalPM) * 100, 1) : 0;
- +============================================================ --> @@ -281,12 +613,12 @@ $persen = $totalPM > 0 ? round(($ditangani / $totalPM) * 100, 1) : 0;
- - + +
- - + +
@@ -430,8 +762,8 @@ $persen = $totalPM > 0 ? round(($ditangani / $totalPM) * 100, 1) : 0;
- - + +
@@ -499,22 +831,42 @@ $persen = $totalPM > 0 ? round(($ditangani / $totalPM) * 100, 1) : 0;
- - + +
- - + +
-
- - +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
Status Jangkauan
-
—
+
@@ -553,11 +905,93 @@ $persen = $totalPM > 0 ? round(($ditangani / $totalPM) * 100, 1) : 0;
- + + + + + + + + + + - + + - - diff --git a/jalan/hapus.php b/jalan/hapus.php index 40ad518..959562e 100644 --- a/jalan/hapus.php +++ b/jalan/hapus.php @@ -1,4 +1,12 @@ 'error','message'=>'Akses ditolak. Hanya admin yang dapat menghapus data.']); + exit; +} + include '../koneksi.php'; header('Content-Type: application/json'); diff --git a/jalan/simpan.php b/jalan/simpan.php index 7c82f9a..00e301f 100644 --- a/jalan/simpan.php +++ b/jalan/simpan.php @@ -1,4 +1,12 @@ 'error','message'=>'Akses ditolak.']); + exit; +} + include '../koneksi.php'; header('Content-Type: application/json'); diff --git a/koneksi.php b/koneksi.php index e09e433..7b97319 100644 --- a/koneksi.php +++ b/koneksi.php @@ -19,6 +19,46 @@ $options = [ 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([ diff --git a/login.php b/login.php new file mode 100644 index 0000000..618ee2b --- /dev/null +++ b/login.php @@ -0,0 +1,306 @@ +query("SELECT setting_value FROM settings WHERE setting_key = 'show_demo_accounts'"); + if ($row = $stmt->fetch()) { + $show_demo = (int)$row['setting_value']; + } +} catch (Exception $e) {} + +$error = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $username = trim($_POST['username'] ?? ''); // Bisa email atau username + $password = trim($_POST['password'] ?? ''); + + if ($username && $password) { + $stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? OR email = ?"); + $stmt->execute([$username, $username]); + $user = $stmt->fetch(); + + if ($user && password_verify($password, $user['password'])) { + // Cek status pending + if ($user['status'] === 'pending') { + $error = 'Akun Anda sedang menunggu persetujuan Admin.'; + } else { + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['role'] = $user['role']; + header('Location: dashboard.php'); + exit; + } + } else { + $error = 'Email/Username atau password salah.'; + } + } else { + $error = 'Silakan isi Email/Username dan password.'; + } +} +?> + + + + + + Login – WebGIS Pengentasan Kemiskinan + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+ +
+
+

WebGIS Geospasial

+

Kementerian Analisis Spasial

+
+
+ + +
+

Selamat Datang Kembali

+

Silakan masuk menggunakan kredensial Anda untuk melanjutkan ke dashboard.

+
+ + +
+
+ +
+ +
+
+ +
+ +
+
+ + +
+ +
+
+ +
+ + +
+
+ + +
+ + + Lupa sandi? + +
+ + + +
+ +
+ Belum punya akun? Daftar sekarang +
+ + + +
+
+ +

Info Akun Demo

+ · klik untuk mengisi +
+
+ + + +
+
+ + +
+ +

+ © WebGIS Geospasial · Kementerian Analisis Spasial +

+
+
+
+ + + + + diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..e8ef96c --- /dev/null +++ b/logout.php @@ -0,0 +1,14 @@ + diff --git a/penduduk_miskin/hapus.php b/penduduk_miskin/hapus.php index 8a533a3..a9e42b1 100644 --- a/penduduk_miskin/hapus.php +++ b/penduduk_miskin/hapus.php @@ -1,4 +1,12 @@ 'error','message'=>'Akses ditolak. Hanya admin yang dapat menghapus data.']); + exit; +} + include '../koneksi.php'; header('Content-Type: application/json'); diff --git a/penduduk_miskin/simpan.php b/penduduk_miskin/simpan.php index e3a1f0e..252f649 100644 --- a/penduduk_miskin/simpan.php +++ b/penduduk_miskin/simpan.php @@ -1,4 +1,12 @@ 'error','message'=>'Akses ditolak.']); + exit; +} + include '../koneksi.php'; header('Content-Type: application/json'); @@ -8,6 +16,7 @@ $keterangan = trim($_POST['keterangan'] ?? ''); $latitude = trim($_POST['latitude'] ?? ''); $longitude = trim($_POST['longitude'] ?? ''); $status_jangkauan= trim($_POST['status_jangkauan']?? 'Luar Jangkauan'); +$status_bantuan = trim($_POST['status_bantuan'] ?? 'Belum Disalurkan'); $fasilitas_publik_id = trim($_POST['fasilitas_publik_id'] ?? ''); if (!$nama || !$latitude || !$longitude) { @@ -20,12 +29,40 @@ if (!in_array($status_jangkauan, $valid_status)) $status_jangkauan = 'Luar Jangk $fp_id = ($fasilitas_publik_id !== '') ? (int)$fasilitas_publik_id : null; +// Fungsi upload foto +function uploadFoto($file_input_name) { + if (isset($_FILES[$file_input_name]) && $_FILES[$file_input_name]['error'] === UPLOAD_ERR_OK) { + $file_tmp_path = $_FILES[$file_input_name]['tmp_name']; + $file_name = $_FILES[$file_input_name]['name']; + $file_size = $_FILES[$file_input_name]['size']; + $file_type = mime_content_type($file_tmp_path); + + $allowed_mime_types = ['image/jpeg', 'image/png', 'image/jpg']; + $max_size = 2 * 1024 * 1024; // 2MB + + if (in_array($file_type, $allowed_mime_types) && $file_size <= $max_size) { + $file_extension = pathinfo($file_name, PATHINFO_EXTENSION); + $new_file_name = uniqid($file_input_name . '_', true) . '.' . $file_extension; + $upload_file_dir = '../uploads/'; + $dest_path = $upload_file_dir . $new_file_name; + + if (move_uploaded_file($file_tmp_path, $dest_path)) { + return $new_file_name; + } + } + } + return null; +} + +$foto_rumah = uploadFoto('foto_rumah'); +$foto_kk = uploadFoto('foto_kk'); + try { $stmt = $pdo->prepare( - "INSERT INTO penduduk_miskin (nama, nik, keterangan, latitude, longitude, status_jangkauan, fasilitas_publik_id) - VALUES (?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO penduduk_miskin (nama, nik, keterangan, latitude, longitude, status_jangkauan, fasilitas_publik_id, foto_rumah, foto_kk, status_bantuan) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ); - $stmt->execute([$nama, $nik, $keterangan, $latitude, $longitude, $status_jangkauan, $fp_id]); + $stmt->execute([$nama, $nik, $keterangan, $latitude, $longitude, $status_jangkauan, $fp_id, $foto_rumah, $foto_kk, $status_bantuan]); $id = $pdo->lastInsertId(); $stmtSelect = $pdo->prepare("SELECT pm.*, fp.nama AS fp_nama FROM penduduk_miskin pm LEFT JOIN fasilitas_publik fp ON pm.fasilitas_publik_id = fp.id WHERE pm.id = ?"); diff --git a/penduduk_miskin/update.php b/penduduk_miskin/update.php index b986597..574bc24 100644 --- a/penduduk_miskin/update.php +++ b/penduduk_miskin/update.php @@ -1,4 +1,12 @@ 'error','message'=>'Akses ditolak.']); + exit; +} + include '../koneksi.php'; header('Content-Type: application/json'); @@ -9,6 +17,7 @@ $keterangan = trim($_POST['keterangan'] ?? ''); $latitude = trim($_POST['latitude'] ?? ''); $longitude = trim($_POST['longitude'] ?? ''); $status_jangkauan= trim($_POST['status_jangkauan']?? 'Luar Jangkauan'); +$status_bantuan = trim($_POST['status_bantuan'] ?? 'Belum Disalurkan'); $fasilitas_publik_id = trim($_POST['fasilitas_publik_id'] ?? ''); if (!$id || !$nama || !$latitude || !$longitude) { @@ -18,12 +27,52 @@ if (!$id || !$nama || !$latitude || !$longitude) { $fp_id = ($fasilitas_publik_id !== '') ? (int)$fasilitas_publik_id : null; +// Fungsi upload foto +function uploadFoto($file_input_name) { + if (isset($_FILES[$file_input_name]) && $_FILES[$file_input_name]['error'] === UPLOAD_ERR_OK) { + $file_tmp_path = $_FILES[$file_input_name]['tmp_name']; + $file_name = $_FILES[$file_input_name]['name']; + $file_size = $_FILES[$file_input_name]['size']; + $file_type = mime_content_type($file_tmp_path); + + $allowed_mime_types = ['image/jpeg', 'image/png', 'image/jpg']; + $max_size = 2 * 1024 * 1024; // 2MB + + if (in_array($file_type, $allowed_mime_types) && $file_size <= $max_size) { + $file_extension = pathinfo($file_name, PATHINFO_EXTENSION); + $new_file_name = uniqid($file_input_name . '_', true) . '.' . $file_extension; + $upload_file_dir = '../uploads/'; + $dest_path = $upload_file_dir . $new_file_name; + + if (move_uploaded_file($file_tmp_path, $dest_path)) { + return $new_file_name; + } + } + } + return null; +} + +$foto_rumah = uploadFoto('foto_rumah'); +$foto_kk = uploadFoto('foto_kk'); + try { - $stmt = $pdo->prepare( - "UPDATE penduduk_miskin SET nama=?, nik=?, keterangan=?, latitude=?, longitude=?, - status_jangkauan=?, fasilitas_publik_id=? WHERE id=?" - ); - $stmt->execute([$nama, $nik, $keterangan, $latitude, $longitude, $status_jangkauan, $fp_id, $id]); + $query = "UPDATE penduduk_miskin SET nama=?, nik=?, keterangan=?, latitude=?, longitude=?, status_jangkauan=?, fasilitas_publik_id=?, status_bantuan=?"; + $params = [$nama, $nik, $keterangan, $latitude, $longitude, $status_jangkauan, $fp_id, $status_bantuan]; + + if ($foto_rumah) { + $query .= ", foto_rumah=?"; + $params[] = $foto_rumah; + } + if ($foto_kk) { + $query .= ", foto_kk=?"; + $params[] = $foto_kk; + } + + $query .= " WHERE id=?"; + $params[] = $id; + + $stmt = $pdo->prepare($query); + $stmt->execute($params); $stmtSelect = $pdo->prepare("SELECT pm.*, fp.nama AS fp_nama FROM penduduk_miskin pm LEFT JOIN fasilitas_publik fp ON pm.fasilitas_publik_id = fp.id WHERE pm.id = ?"); $stmtSelect->execute([$id]); diff --git a/penduduk_miskin/update_lokasi.php b/penduduk_miskin/update_lokasi.php new file mode 100644 index 0000000..47d5fcb --- /dev/null +++ b/penduduk_miskin/update_lokasi.php @@ -0,0 +1,37 @@ + 'error', 'message' => 'Unauthorized']); + exit; +} + +$id = $_POST['id'] ?? ''; +$latitude = $_POST['latitude'] ?? ''; +$longitude = $_POST['longitude'] ?? ''; +$status_jangkauan = $_POST['status_jangkauan'] ?? ''; +$fasilitas_publik_id = $_POST['fasilitas_publik_id'] ?? null; + +if (!$id || !$latitude || !$longitude) { + echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap']); + exit; +} + +if ($fasilitas_publik_id === '') { + $fasilitas_publik_id = null; +} + +try { + if ($status_jangkauan !== '') { + $stmt = $pdo->prepare("UPDATE penduduk_miskin SET latitude = ?, longitude = ?, status_jangkauan = ?, fasilitas_publik_id = ? WHERE id = ?"); + $stmt->execute([$latitude, $longitude, $status_jangkauan, $fasilitas_publik_id, $id]); + } else { + $stmt = $pdo->prepare("UPDATE penduduk_miskin SET latitude = ?, longitude = ? WHERE id = ?"); + $stmt->execute([$latitude, $longitude, $id]); + } + + echo json_encode(['status' => 'success']); +} catch (PDOException $e) { + echo json_encode(['status' => 'error', 'message' => 'Database error: ' . $e->getMessage()]); +} diff --git a/point/hapus.php b/point/hapus.php index 174739d..f8840b6 100644 --- a/point/hapus.php +++ b/point/hapus.php @@ -1,4 +1,13 @@ 'error','message'=>'Akses ditolak. Hanya admin yang dapat menghapus data.']); + exit; +} + include '../koneksi.php'; header('Content-Type: application/json'); diff --git a/point/simpan.php b/point/simpan.php index 8a90fbb..28fd006 100644 --- a/point/simpan.php +++ b/point/simpan.php @@ -1,4 +1,13 @@ 'error','message'=>'Akses ditolak. Hanya admin/petugas yang dapat menambah data.']); + exit; +} + // point/simpan.php include '../koneksi.php'; header('Content-Type: application/json'); diff --git a/point/update.php b/point/update.php index 7266dd5..a27ad64 100644 --- a/point/update.php +++ b/point/update.php @@ -1,4 +1,12 @@ 'error','message'=>'Akses ditolak.']); + exit; +} + include '../koneksi.php'; header('Content-Type: application/json'); diff --git a/point/update_lokasi.php b/point/update_lokasi.php new file mode 100644 index 0000000..be3eb5a --- /dev/null +++ b/point/update_lokasi.php @@ -0,0 +1,26 @@ + 'error', 'message' => 'Unauthorized']); + exit; +} + +$id = $_POST['id'] ?? ''; +$latitude = $_POST['latitude'] ?? ''; +$longitude = $_POST['longitude'] ?? ''; + +if (!$id || !$latitude || !$longitude) { + echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap']); + exit; +} + +try { + $stmt = $pdo->prepare("UPDATE points SET latitude = ?, longitude = ? WHERE id = ?"); + $stmt->execute([$latitude, $longitude, $id]); + + echo json_encode(['status' => 'success']); +} catch (PDOException $e) { + echo json_encode(['status' => 'error', 'message' => 'Database error']); +} diff --git a/polygon/hapus.php b/polygon/hapus.php index b8c0218..d2b3006 100644 --- a/polygon/hapus.php +++ b/polygon/hapus.php @@ -1,4 +1,12 @@ 'error','message'=>'Akses ditolak. Hanya admin yang dapat menghapus data.']); + exit; +} + include '../koneksi.php'; header('Content-Type: application/json'); diff --git a/polygon/simpan.php b/polygon/simpan.php index 20111fb..03f4915 100644 --- a/polygon/simpan.php +++ b/polygon/simpan.php @@ -1,4 +1,12 @@ 'error','message'=>'Akses ditolak.']); + exit; +} + include '../koneksi.php'; header('Content-Type: application/json'); diff --git a/register.php b/register.php new file mode 100644 index 0000000..437f051 --- /dev/null +++ b/register.php @@ -0,0 +1,308 @@ + GOOGLE_CLIENT_ID, + 'client_secret' => GOOGLE_CLIENT_SECRET, + 'redirect_uri' => GOOGLE_REDIRECT_URI, + 'grant_type' => 'authorization_code', + 'code' => $code + ])); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + $response = curl_exec($ch); + curl_close($ch); + + $token_data = json_decode($response, true); + if (isset($token_data['access_token'])) { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, "https://www.googleapis.com/oauth2/v3/userinfo"); + curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer " . $token_data['access_token']]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + $user_data = json_decode(curl_exec($ch), true); + curl_close($ch); + + if (isset($user_data['email'])) { + $email = $user_data['email']; + $name = $user_data['name']; + $state_role = $_GET['state'] ?? 'guest'; + $role = in_array($state_role, ['guest', 'petugas']) ? $state_role : 'guest'; + + $stmt = $pdo->prepare("SELECT * FROM users WHERE email = ? OR username = ?"); + $stmt->execute([$email, $email]); + $existing_user = $stmt->fetch(); + + if ($existing_user) { + if ($existing_user['status'] === 'pending') { + $sweetAlertScript = "Swal.fire({icon: 'warning', title: 'Menunggu Persetujuan', text: 'Akun Anda sedang menunggu persetujuan Admin.', confirmButtonColor: '#e11d48'});"; + } else { + $_SESSION['user_id'] = $existing_user['id']; + $_SESSION['username'] = $existing_user['username']; + $_SESSION['role'] = $existing_user['role']; + header('Location: dashboard.php'); + exit; + } + } else { + $status = ($role === 'petugas') ? 'pending' : 'active'; + $username = explode('@', $email)[0] . rand(100,999); + $stmt = $pdo->prepare("INSERT INTO users (username, email, nama_lengkap, password, role, status, auth_provider) VALUES (?, ?, ?, ?, ?, ?, 'google')"); + $stmt->execute([$username, $email, $name, password_hash(bin2hex(random_bytes(8)), PASSWORD_DEFAULT), $role, $status]); + + if ($role === 'petugas') { + $sweetAlertScript = "Swal.fire({icon: 'success', title: 'Pendaftaran Berhasil', text: 'Pendaftaran via Google berhasil. Akun Anda menunggu persetujuan Admin.', confirmButtonColor: '#e11d48'}).then(() => { window.location.href = 'login.php'; });"; + } else { + $_SESSION['user_id'] = $pdo->lastInsertId(); + $_SESSION['username'] = $username; + $_SESSION['role'] = 'guest'; + header('Location: dashboard.php'); + exit; + } + } + } + } else { + $sweetAlertScript = "Swal.fire({icon: 'error', title: 'Google Login Gagal', text: 'Terjadi kesalahan kredensial OAuth.', confirmButtonColor: '#e11d48'});"; + } +} + +// Proses Registrasi Lokal +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['register'])) { + $nama_lengkap = trim($_POST['nama_lengkap'] ?? ''); + $email = trim($_POST['email'] ?? ''); + $password = trim($_POST['password'] ?? ''); + $role = trim($_POST['role'] ?? 'guest'); + + if ($nama_lengkap && $email && $password && in_array($role, ['guest', 'petugas'])) { + $stmt = $pdo->prepare("SELECT id FROM users WHERE email = ? OR username = ?"); + $stmt->execute([$email, $email]); + if ($stmt->fetch()) { + $sweetAlertScript = "Swal.fire({icon: 'error', title: 'Registrasi Gagal', text: 'Email tersebut sudah terdaftar.', confirmButtonColor: '#e11d48'});"; + } else { + $status = ($role === 'petugas') ? 'pending' : 'active'; + $username = explode('@', $email)[0] . rand(100,999); + + $stmt = $pdo->prepare("INSERT INTO users (username, email, nama_lengkap, password, role, status, auth_provider) VALUES (?, ?, ?, ?, ?, ?, 'local')"); + if ($stmt->execute([$username, $email, $nama_lengkap, password_hash($password, PASSWORD_DEFAULT), $role, $status])) { + if ($role === 'petugas') { + $sweetAlertScript = "Swal.fire({icon: 'success', title: 'Pendaftaran Berhasil', text: 'Pendaftaran berhasil. Menunggu persetujuan Admin.', confirmButtonColor: '#e11d48'}).then(() => { window.location.href = 'login.php'; });"; + } else { + $sweetAlertScript = "Swal.fire({icon: 'success', title: 'Pendaftaran Berhasil', text: 'Akun Anda aktif, silakan masuk ke sistem.', confirmButtonColor: '#e11d48'}).then(() => { window.location.href = 'login.php'; });"; + } + } else { + $sweetAlertScript = "Swal.fire({icon: 'error', title: 'Gagal', text: 'Terjadi kesalahan sistem internal.', confirmButtonColor: '#e11d48'});"; + } + } + } else { + $sweetAlertScript = "Swal.fire({icon: 'warning', title: 'Data Belum Lengkap', text: 'Harap lengkapi semua bidang yang tersedia.', confirmButtonColor: '#e11d48'});"; + } +} + +// URL OAuth Google +$google_auth_url = "https://accounts.google.com/o/oauth2/v2/auth?client_id=" . GOOGLE_CLIENT_ID . "&redirect_uri=" . urlencode(GOOGLE_REDIRECT_URI) . "&response_type=code&scope=email%20profile"; +?> + + + + + + Register – WebGIS Pengentasan Kemiskinan + + + + + + + + + + + + +
+ + + + +
+
+ + +
+

Buat Akun Baru

+

Isi data di bawah ini untuk membuat akun WebGIS Anda.

+
+ + +
+ + + + +
+
+ atau dengan email +
+
+ +
+ + + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+ +
+
+
+ + + +
+ +
+ Sudah punya akun? Masuk di sini +
+
+ +

+ © WebGIS Geospasial · Kementerian Analisis Spasial +

+
+
+
+ + + + + diff --git a/update_db.sql b/update_db.sql new file mode 100644 index 0000000..bac971f --- /dev/null +++ b/update_db.sql @@ -0,0 +1,4 @@ +USE webgis; +ALTER TABLE penduduk_miskin +ADD COLUMN foto_rumah VARCHAR(255) NULL, +ADD COLUMN foto_kk VARCHAR(255) NULL; diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..c73c999 --- /dev/null +++ b/uploads/.gitkeep @@ -0,0 +1 @@ +# Gitkeep diff --git a/uploads/foto_kk_6a2a216972b273.24659129.png b/uploads/foto_kk_6a2a216972b273.24659129.png new file mode 100644 index 0000000..e2795fa Binary files /dev/null and b/uploads/foto_kk_6a2a216972b273.24659129.png differ diff --git a/uploads/foto_rumah_6a2a2169722485.32558108.png b/uploads/foto_rumah_6a2a2169722485.32558108.png new file mode 100644 index 0000000..e2795fa Binary files /dev/null and b/uploads/foto_rumah_6a2a2169722485.32558108.png differ diff --git a/uploads/foto_rumah_6a2a30a32dd215.01943630.png b/uploads/foto_rumah_6a2a30a32dd215.01943630.png new file mode 100644 index 0000000..c4137bb Binary files /dev/null and b/uploads/foto_rumah_6a2a30a32dd215.01943630.png differ diff --git a/web-gis-login-page/.gitignore b/web-gis-login-page/.gitignore new file mode 100644 index 0000000..97eb8c0 --- /dev/null +++ b/web-gis-login-page/.gitignore @@ -0,0 +1,15 @@ +# v0 sandbox internal files +__v0_runtime_loader.js +__v0_devtools.tsx +__v0_jsx-dev-runtime.ts +.snowflake/ +.v0-trash/ +.vercel/ + +# Environment variables +.env*.local + +# Common ignores +node_modules +.next/ +.DS_Store \ No newline at end of file diff --git a/web-gis-login-page/app/globals.css b/web-gis-login-page/app/globals.css new file mode 100644 index 0000000..b8ec58c --- /dev/null +++ b/web-gis-login-page/app/globals.css @@ -0,0 +1,130 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; +@import 'shadcn/tailwind.css'; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --font-heading: var(--font-jakarta), 'Plus Jakarta Sans Fallback'; + --font-sans: var(--font-jakarta), 'Plus Jakarta Sans Fallback'; + --font-mono: var(--font-geist-mono), 'Geist Mono Fallback'; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(0.985 0.003 17); + --foreground: oklch(0.22 0.02 25); + --card: oklch(1 0 0); + --card-foreground: oklch(0.22 0.02 25); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.22 0.02 25); + --primary: oklch(0.52 0.21 27); + --primary-foreground: oklch(0.99 0.005 17); + --secondary: oklch(0.96 0.008 20); + --secondary-foreground: oklch(0.3 0.02 25); + --muted: oklch(0.96 0.006 20); + --muted-foreground: oklch(0.52 0.02 25); + --accent: oklch(0.95 0.03 22); + --accent-foreground: oklch(0.45 0.2 27); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.91 0.008 25); + --input: oklch(0.91 0.008 25); + --ring: oklch(0.52 0.21 27); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} diff --git a/web-gis-login-page/app/layout.tsx b/web-gis-login-page/app/layout.tsx new file mode 100644 index 0000000..684cfe4 --- /dev/null +++ b/web-gis-login-page/app/layout.tsx @@ -0,0 +1,50 @@ +import { Analytics } from '@vercel/analytics/next' +import type { Metadata } from 'next' +import { Plus_Jakarta_Sans, Geist_Mono } from 'next/font/google' +import './globals.css' + +const jakarta = Plus_Jakarta_Sans({ + variable: '--font-jakarta', + subsets: ['latin'], + display: 'swap', +}) +const geistMono = Geist_Mono({ + variable: '--font-geist-mono', + subsets: ['latin'], +}) + +export const metadata: Metadata = { + title: 'WebGIS Geospasial — Masuk', + description: + 'Platform analisis spasial untuk pengentasan kemiskinan berbasis data geospasial.', + generator: 'v0.app', + icons: { + icon: [ + { + url: '/icon-light-32x32.png', + media: '(prefers-color-scheme: light)', + }, + { + url: '/icon-dark-32x32.png', + media: '(prefers-color-scheme: dark)', + }, + { + url: '/icon.svg', + type: 'image/svg+xml', + }, + ], + apple: '/apple-icon.png', + }, +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + {children}{process.env.NODE_ENV === 'production' && } + + ) +} diff --git a/web-gis-login-page/app/page.tsx b/web-gis-login-page/app/page.tsx new file mode 100644 index 0000000..cb64baf --- /dev/null +++ b/web-gis-login-page/app/page.tsx @@ -0,0 +1,116 @@ +import Image from "next/image" +import { LoginForm } from "@/components/login-form" + +export default function Page() { + return ( +
+ {/* Left: hero / map */} +
+ Visualisasi peta analisis spasial + {/* Layered red overlay for elegant depth */} +
+ {/* Fine grid texture */} +
+ + {/* Content */} +
+ {/* Top badge */} +
+
+ + + + +
+ + WebGIS Geospasial + +
+ + {/* Headline */} +
+ {/* glowing radial gradient behind headline */} +
+
+ + Platform Analisis Data Spasial +
+

+ Analisis Spasial Pengentasan Kemiskinan +

+

+ Petakan, pantau, dan analisis indikator kemiskinan secara geospasial untuk + pengambilan keputusan kebijakan yang tepat sasaran dan berbasis data. +

+ + {/* Mini stats */} +
+ {[ + { v: "514", l: "Kabupaten/Kota" }, + { v: "34", l: "Provinsi" }, + { v: "99.9%", l: "Akurasi Data" }, + ].map((s) => ( +
+

{s.v}

+

{s.l}

+
+ ))} +
+
+ +

+ Data terintegrasi · Pembaruan berkala · Aman & terenkripsi +

+
+
+ + {/* Right: form */} +
+ {/* soft ambient accents */} +
+
+ +
+
+ ) +} diff --git a/web-gis-login-page/components.json b/web-gis-login-page/components.json new file mode 100644 index 0000000..45921ec --- /dev/null +++ b/web-gis-login-page/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/web-gis-login-page/components/login-form.tsx b/web-gis-login-page/components/login-form.tsx new file mode 100644 index 0000000..8999634 --- /dev/null +++ b/web-gis-login-page/components/login-form.tsx @@ -0,0 +1,206 @@ +"use client" + +import type React from "react" +import { useState } from "react" +import { Eye, EyeOff, Lock, User, Loader2, ArrowRight } from "lucide-react" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { cn } from "@/lib/utils" + +export function LoginForm() { + const [showPassword, setShowPassword] = useState(false) + const [loading, setLoading] = useState(false) + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setTimeout(() => setLoading(false), 1600) + } + + const demoAccounts = [ + { icon: "👑", role: "Admin", user: "admin", pass: "password" }, + { icon: "📝", role: "Petugas", user: "petugas", pass: "password" }, + { icon: "👁️", role: "Guest", user: "guest", pass: "password" }, + ] + + function fillDemo(user: string, pass: string) { + setUsername(user) + setPassword(pass) + } + + return ( +
+ {/* Glassmorphism card */} +
+ {/* subtle red glow accent */} +
+ + {/* Brand */} +
+
+ + + + +
+
+

+ WebGIS Geospasial +

+

Sistem Analisis Spasial

+
+
+ +
+

+ Selamat Datang Kembali +

+

+ Masuk ke akun Anda untuk mengakses peta dan data analisis spasial. +

+
+ +
+ {/* Username */} +
+ +
+ + setUsername(e.target.value)} + placeholder="Masukkan nama pengguna" + className="h-12 rounded-xl border-border/80 bg-white/80 pl-11 text-sm shadow-sm transition-all hover:border-primary/60 focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary/20" + /> +
+
+ + {/* Password */} +
+ +
+ + setPassword(e.target.value)} + placeholder="Masukkan kata sandi" + className="h-12 rounded-xl border-border/80 bg-white/80 pl-11 pr-11 text-sm shadow-sm transition-all hover:border-primary/60 focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary/20" + /> + +
+
+ + {/* Remember + forgot */} +
+ + + Lupa sandi? + +
+ + {/* Submit */} + +
+ + {/* Demo accounts helper */} +
+
+ +

Info Akun Demo

+ · klik untuk mengisi +
+
+ {demoAccounts.map((acc) => ( + + ))} +
+
+
+ +

+ © {new Date().getFullYear()} WebGIS Geospasial · Kementerian Analisis Spasial +

+
+ ) +} diff --git a/web-gis-login-page/components/ui/button.tsx b/web-gis-login-page/components/ui/button.tsx new file mode 100644 index 0000000..b520e87 --- /dev/null +++ b/web-gis-login-page/components/ui/button.tsx @@ -0,0 +1,58 @@ +import { Button as ButtonPrimitive } from '@base-ui/react/button' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + outline: + 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', + ghost: + 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50', + destructive: + 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: + 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + icon: 'size-8', + 'icon-xs': + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + 'icon-sm': + 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg', + 'icon-lg': 'size-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +function Button({ + className, + variant = 'default', + size = 'default', + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/web-gis-login-page/components/ui/checkbox.tsx b/web-gis-login-page/components/ui/checkbox.tsx new file mode 100644 index 0000000..4fcd847 --- /dev/null +++ b/web-gis-login-page/components/ui/checkbox.tsx @@ -0,0 +1,29 @@ +"use client" + +import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox" + +import { cn } from "@/lib/utils" +import { CheckIcon } from "lucide-react" + +function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/web-gis-login-page/components/ui/input.tsx b/web-gis-login-page/components/ui/input.tsx new file mode 100644 index 0000000..7d21bab --- /dev/null +++ b/web-gis-login-page/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { Input as InputPrimitive } from "@base-ui/react/input" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/web-gis-login-page/components/ui/label.tsx b/web-gis-login-page/components/ui/label.tsx new file mode 100644 index 0000000..74da65c --- /dev/null +++ b/web-gis-login-page/components/ui/label.tsx @@ -0,0 +1,20 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +