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: ``,
+ 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: ``,
+ 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: ``,
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 ``;
+}
+
+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')}
+
+
+
+
+
👥 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 ` : '- '}
+
+
+
+
+ ${statusBantuan} ▼
+
+
+
+
+ `;
+ 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
+
+
+
+ Pilih modul analisis spasial yang ingin Anda kerjakan hari ini. Sistem menyediakan berbagai perangkat untuk pemetaan, digitasi, dan analisis data kemiskinan terpadu.
+
+
+
+
+
+
+
+ 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
+
+ = count($pendingUsers) ?> Menunggu
+
+
+
+ 0): ?>
+
+
+
+
+ Nama Lengkap
+ Email
+ Aksi
+
+
+
+
+
+ = htmlspecialchars($u['nama_lengkap'] ?: $u['username']) ?>
+ = htmlspecialchars($u['email'] ?? '-') ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🎉
+
Tidak ada pendaftaran baru
+
Semua petugas telah diverifikasi.
+
+
+
+
+
+
+
Pengaturan Sistem
+
+
+
+
Tampilkan Tombol Akun Demo
+
Memunculkan helper login untuk demonstrasi aplikasi.
+
+
+ onchange="toggleSetting('show_demo_accounts', this.checked ? '1' : '0')">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Manajemen Akun Terdaftar
+
+
Atur hak akses (role) dan status pengguna sistem.
+
+
+
+
+
+
+
+ Nama Lengkap
+ Email / Username
+ Role
+ Status
+ Terdaftar
+ Aksi
+
+
+
+
+
+
+
+
+ = strtoupper(substr($u['nama_lengkap'] ?: $u['username'], 0, 1)) ?>
+
+ = htmlspecialchars($u['nama_lengkap'] ?: '-') ?>
+
+
+
+
+ = htmlspecialchars($u['email'] ?: '-') ?>
+ @= htmlspecialchars($u['username']) ?>
+
+
+
+
+
+ Super Admin
+
+
+
+
+ = $u['role'] ?>
+
+
+
+
Admin
+
Petugas
+
Guest
+
+
+
+
+
+
+
+ Active
+
+
+
+
+ = $u['status'] ?>
+
+
+
+
+
+
+
+ = date('d M Y, H:i', strtotime($u['created_at'])) ?>
+
+
+
+
+
+
+
+ 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...
+
+
+
+
+
+============================================================ -->