diff --git a/.gitignore b/.gitignore
index 4e60579..6d1501b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
.env
.env.*
api/config/database.local.php
+api/config/database.php
*.sql
!database.sql
*.sqlite
@@ -12,4 +13,11 @@ node_modules/
.vscode/
.idea/
Thumbs.db
-desktop.ini
\ No newline at end of file
+desktop.ini
+
+# Temporary and migration files
+backup_html/
+migrate.php
+fix_scripts.php
+split_css.js
+split_js.js
\ No newline at end of file
diff --git a/README.md b/README.md
index fad0816..0a112ec 100644
--- a/README.md
+++ b/README.md
@@ -1,39 +1,45 @@
-# WebGIS Poverty
+# WebGIS Poverty Mapping System
-Proyek WebGIS sederhana untuk visualisasi data kemiskinan dan fasilitas umum di wilayah tertentu.
+Sistem Informasi Geografis berbasis web untuk visualisasi data kemiskinan dan penyaluran bantuan sosial.
## ๐ Fitur Utama
-* **Analisis Proximity**: Perhitungan jangkauan radius/buffer antara lokasi penduduk miskin dan rumah ibadah terdekat.
-* **Log Bantuan**: Pencatatan riwayat penyaluran bantuan sosial.
-* **Pemetaan Spasial**: Visualisasi dan input data titik (SPBU/Rumah Ibadah), garis (Jalan), dan area (Parsil tanah).
-* **Kalkulasi Spasial**: Perhitungan otomatis panjang jalan dan luas area parsil tanah secara real-time menggunakan **Turf.js**.
+* **Arsitektur Berbasis Peran (RBAC)**: Login terpisah antara Admin (akses penuh) dan Rumah Ibadah (hanya akses data penduduk dalam radiusnya).
+* **Dashboard Statistik**: Ringkasan data (KPI), grafik analisis kedekatan penduduk, grafik jenis penyaluran bantuan, dan log terbaru penyaluran bantuan.
+* **Peta Interaktif (WebGIS)**:
+ * Visualisasi radius layanan rumah ibadah (buffer).
+ * Pemetaan rumah ibadah dan penduduk miskin.
+ * Pencarian spasial (search).
+* **Manajemen Bantuan (Penyaluran)**: Setiap akun rumah ibadah dapat mencatat secara real-time bantuan yang diberikan kepada penduduk miskin.
+* **Manajemen Akun Rumah Ibadah**: Fitur lengkap bagi Admin untuk membuat dan mereset password akun petugas rumah ibadah.
## ๐ ๏ธ Teknologi yang Digunakan
-* **Backend**: PHP Native (REST API) & MySQL.
-* **Frontend**: Vanilla HTML/CSS/JS, Leaflet.js (peta interaktif), Leaflet Draw (alat menggambar di peta), dan Turf.js (kalkulasi spasial).
+* **Backend**: PHP Native (Multi-page views & REST API terstruktur) & MySQL.
+* **Frontend**:
+ * Vanilla HTML/CSS/JS (Arsitektur komponen modular dan routing `index.php`).
+ * Leaflet.js (Pemetaan Interaktif).
+ * Chart.js (Visualisasi Grafik Dashboard).
-## ๐ Persyaratan
-* PHP + Apache (mis. XAMPP atau Laragon)
-* MySQL/MariaDB
+## ๐ Persyaratan Sistem
+* PHP (versi 7.4 atau lebih baru direkomendasikan)
+* Apache Web Server (XAMPP / Laragon)
+* MySQL / MariaDB
-## โ๏ธ Cara Setup
-1. Tempatkan folder proyek di dalam direktori `htdocs` server lokal.
-2. Mulai modul Apache dan MySQL (di XAMPP Control Panel).
-3. Buat database bernama `webgis_poverty` dan import file `database.sql` ke dalamnya.
-4. Salin file `api/config/database.local.php.example` menjadi `api/config/database.local.php` lalu sesuaikan konfigurasi database.
-5. Buka webgis melalui browser (misal: `http://localhost/webgis_poverty_fixed/` atau langsung melalui `index.html`).
+## โ๏ธ Cara Setup (Instalasi Lokal)
+1. Tempatkan folder proyek `webgis_poverty` di dalam direktori `htdocs` (XAMPP) atau direktori publik server lokal Anda.
+2. Mulai modul **Apache** dan **MySQL** (di XAMPP Control Panel).
+3. Buat database baru bernama `webgis_poverty` di phpMyAdmin, kemudian import struktur database menggunakan file SQL yang disediakan (`database.sql`).
+4. Salin atau buat file konfigurasi database di `api/config/database.php` (Gunakan `database.php.example` sebagai referensi jika ada) lalu sesuaikan username dan password database.
+5. Akses sistem melalui browser: `http://localhost/webgis/webgis_poverty/`
-## ๐ Struktur File Penting
+## ๐ Akun Akses Default
+Jika setup awal berhasil, Anda dapat login menggunakan akun berikut:
+* **Admin**: username `admin` / password `admin123`
+* **Akun Rumah Ibadah**: Didaftarkan dan dibuat langsung oleh Admin melalui halaman Manajemen Akun RI.
-### Backend (REST API)
-* `api/config/database.local.php` โ Konfigurasi koneksi database lokal.
-* `api/penduduk_miskin.php` โ API data penduduk miskin (termasuk aksi hitung ulang kedekatan).
-* `api/rumah_ibadah.php` โ API data rumah ibadah.
-* `api/log_bantuan.php` โ API log bantuan sosial.
-* `api/data_jalan.php`, `api/data_parsil.php`, `api/spbu.php` โ API data spasial lainnya.
-
-### Frontend
-* `index.html` โ Halaman dashboard utama.
-* `css/app.css` โ File styling antarmuka.
-* `js/app-core.js` โ Inisialisasi peta Leaflet dan fungsi umum.
-* `js/feature-*.js` โ File logika interaktif per modul fitur.
+## ๐ Struktur Proyek Utama
+* `index.php` โ Router utama yang mengatur layout dan halaman aplikasi.
+* `pages/` โ Kumpulan file View PHP (Dashboard, Peta, Daftar PM, Manajemen Akun).
+* `components/` โ File PHP re-usable (Sidebar, Head, Global Scripts).
+* `api/` โ Endpoint REST API (Login, Fetch Data, CRUD, dll).
+* `js/pages/` dan `js/features/` โ Modular JavaScript untuk fungsionalitas tiap halaman dan fitur (Peta, Log Bantuan, dll).
+* `css/` โ Stylesheet yang dipecah per-halaman dan komponen untuk performa lebih baik.
diff --git a/api/config/database.php b/api/config/database.php
index 615e13c..e5664d9 100644
--- a/api/config/database.php
+++ b/api/config/database.php
@@ -6,10 +6,11 @@ mysqli_report(MYSQLI_REPORT_OFF);
if (!function_exists('loadDatabaseConfig')) {
function loadDatabaseConfig(): array {
$config = [
- 'host' => getenv('DB_HOST') ?: 'localhost',
+ 'host' => getenv('DB_HOST') ?: '127.0.0.1',
'user' => getenv('DB_USER') ?: 'root',
'pass' => getenv('DB_PASS') ?: '',
'name' => getenv('DB_NAME') ?: 'webgis_poverty',
+ 'port' => (int)(getenv('DB_PORT') ?: 3306),
'charset' => getenv('DB_CHARSET') ?: 'utf8mb4',
];
@@ -31,12 +32,13 @@ define('DB_HOST', $dbConfig['host']);
define('DB_USER', $dbConfig['user']);
define('DB_PASS', $dbConfig['pass']);
define('DB_NAME', $dbConfig['name']);
+define('DB_PORT', (int)$dbConfig['port']);
define('DB_CHARSET', $dbConfig['charset']);
function getDB(): mysqli {
static $conn = null;
if ($conn === null) {
- $conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
+ $conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT);
if ($conn->connect_error) {
error_log('DB connection failed: ' . $conn->connect_error);
http_response_code(500);
diff --git a/api/config/database.local.php.example b/api/config/database.php.example
similarity index 100%
rename from api/config/database.local.php.example
rename to api/config/database.php.example
diff --git a/api/config/setup_users.php b/api/config/setup_users.php
new file mode 100644
index 0000000..987928b
--- /dev/null
+++ b/api/config/setup_users.php
@@ -0,0 +1,51 @@
+query($sql)) {
+ echo "Tabel 'users' berhasil dibuat atau sudah ada.\n";
+} else {
+ echo "Gagal membuat tabel 'users': " . $db->error . "\n";
+ exit(1);
+}
+
+// Insert default users if they don't exist
+$defaultUsers = [
+ [
+ 'username' => 'admin',
+ 'password' => password_hash('admin123', PASSWORD_BCRYPT),
+ 'role' => 'admin'
+ ]
+];
+
+foreach ($defaultUsers as $user) {
+ $stmt = $db->prepare("SELECT id FROM users WHERE username = ?");
+ $stmt->bind_param('s', $user['username']);
+ $stmt->execute();
+ $result = $stmt->get_result();
+ if ($result->num_rows === 0) {
+ $stmtInsert = $db->prepare("INSERT INTO users (username, password, role) VALUES (?, ?, ?)");
+ $stmtInsert->bind_param('sss', $user['username'], $user['password'], $user['role']);
+ if ($stmtInsert->execute()) {
+ echo "User '" . $user['username'] . "' berhasil ditambahkan.\n";
+ } else {
+ echo "Gagal menambahkan user '" . $user['username'] . "': " . $stmtInsert->error . "\n";
+ }
+ } else {
+ echo "User '" . $user['username'] . "' sudah ada.\n";
+ }
+}
+echo "Setup database selesai.\n";
diff --git a/api/dashboard_stats.php b/api/dashboard_stats.php
new file mode 100644
index 0000000..c501734
--- /dev/null
+++ b/api/dashboard_stats.php
@@ -0,0 +1,73 @@
+query("SELECT COUNT(*) AS total_kk, IFNULL(SUM(jumlah_anggota), 0) AS total_jiwa FROM penduduk_miskin");
+$stats['penduduk_miskin'] = $resPm->fetch_assoc();
+
+// 2. Rumah Ibadah (Total)
+$resRi = $db->query("SELECT COUNT(*) AS total FROM rumah_ibadah");
+$stats['rumah_ibadah'] = $resRi->fetch_assoc();
+
+
+// 6. Log Bantuan (Total log & Nilai total bantuan)
+$resLb = $db->query("SELECT COUNT(*) AS total_count, IFNULL(SUM(nilai_bantuan), 0) AS total_nilai FROM log_bantuan");
+$stats['log_bantuan'] = $resLb->fetch_assoc();
+
+// 7. Proximity (Dalam vs Luar Radius)
+$resProx = $db->query("SELECT status_proximity, COUNT(*) AS count FROM penduduk_miskin GROUP BY status_proximity");
+$proximity = ['dalam_radius' => 0, 'luar_radius' => 0];
+while ($row = $resProx->fetch_assoc()) {
+ $key = $row['status_proximity'] === 'dalam_radius' ? 'dalam_radius' : 'luar_radius';
+ $proximity[$key] += (int)$row['count'];
+}
+$stats['proximity'] = $proximity;
+
+// 8. Tipe Bantuan (Pemberdayaan vs Konsumtif)
+$resTipe = $db->query("SELECT tipe_bantuan, COUNT(*) AS count FROM log_bantuan GROUP BY tipe_bantuan");
+$tipeBantuan = ['Pemberdayaan' => 0, 'Konsumtif' => 0];
+while ($row = $resTipe->fetch_assoc()) {
+ $key = $row['tipe_bantuan'] === 'Pemberdayaan' ? 'Pemberdayaan' : 'Konsumtif';
+ $tipeBantuan[$key] = (int)$row['count'];
+}
+$stats['tipe_bantuan'] = $tipeBantuan;
+
+// 9. Rumah Ibadah Teraktif (Top 5)
+$resRiAktif = $db->query("
+ SELECT ri.nama, COUNT(lb.id) AS count, IFNULL(SUM(lb.nilai_bantuan), 0) AS nilai
+ FROM log_bantuan lb
+ JOIN rumah_ibadah ri ON ri.id = lb.rumah_ibadah_id
+ GROUP BY lb.rumah_ibadah_id
+ ORDER BY count DESC LIMIT 5
+");
+$riAktif = [];
+while ($row = $resRiAktif->fetch_assoc()) {
+ $row['count'] = (int)$row['count'];
+ $row['nilai'] = (float)$row['nilai'];
+ $riAktif[] = $row;
+}
+$stats['ri_teraktif'] = $riAktif;
+
+// 10. Bantuan Terbaru (Top 5)
+$resBantuanBaru = $db->query("
+ SELECT lb.id, lb.tipe_bantuan, lb.sub_kategori, lb.keterangan, lb.tanggal_bantuan, lb.nilai_bantuan,
+ pm.nama AS nama_penduduk, ri.nama AS nama_rumah_ibadah
+ FROM log_bantuan lb
+ JOIN penduduk_miskin pm ON pm.id = lb.penduduk_miskin_id
+ JOIN rumah_ibadah ri ON ri.id = lb.rumah_ibadah_id
+ ORDER BY lb.tanggal_bantuan DESC, lb.id DESC LIMIT 5
+");
+$bantuanBaru = [];
+while ($row = $resBantuanBaru->fetch_assoc()) {
+ $row['nilai_bantuan'] = $row['nilai_bantuan'] ? (float)$row['nilai_bantuan'] : 0;
+ $bantuanBaru[] = $row;
+}
+$stats['bantuan_terbaru'] = $bantuanBaru;
+
+sendSuccess($stats);
diff --git a/api/data_jalan.php b/api/data_jalan.php
deleted file mode 100644
index c84bbca..0000000
--- a/api/data_jalan.php
+++ /dev/null
@@ -1,112 +0,0 @@
- 0) {
- $stmt = $db->prepare(
- "SELECT id, nama_jalan, status_jalan,
- panjang_meter, keterangan,
- CAST(geojson AS CHAR) AS geojson,
- created_at
- FROM data_jalan WHERE id = ?"
- );
- $stmt->bind_param('i', $id);
- $stmt->execute();
- $row = $stmt->get_result()->fetch_assoc();
- if (!$row) sendError('Data tidak ditemukan', 404);
- // Parse geojson string menjadi object
- $row['geojson'] = json_decode($row['geojson']);
- $row['panjang_meter'] = (float)$row['panjang_meter'];
- sendSuccess($row);
- } else {
- $rows = [];
- $res = $db->query(
- "SELECT id, nama_jalan, status_jalan,
- panjang_meter, keterangan,
- CAST(geojson AS CHAR) AS geojson,
- created_at
- FROM data_jalan ORDER BY created_at DESC"
- );
- while ($r = $res->fetch_assoc()) {
- $r['geojson'] = json_decode($r['geojson']);
- $r['panjang_meter'] = (float)$r['panjang_meter'];
- $rows[] = $r;
- }
- sendSuccess($rows);
- }
- break;
-
- case 'POST':
- $body = getBody();
- foreach (['nama_jalan','status_jalan','geojson','panjang_meter'] as $f) {
- if (!isset($body[$f])) sendError("Field '$f' wajib diisi");
- }
- // Validasi GeoJSON LineString
- $geo = is_array($body['geojson'])
- ? $body['geojson']
- : json_decode($body['geojson'], true);
- if (!$geo || $geo['type'] !== 'LineString') {
- sendError('GeoJSON harus bertipe LineString');
- }
- $geoStr = json_encode($geo);
- $panjang = (float)$body['panjang_meter'];
-
- $stmt = $db->prepare(
- "INSERT INTO data_jalan
- (nama_jalan, status_jalan, panjang_meter, keterangan, geojson)
- VALUES (?, ?, ?, ?, ?)"
- );
- $stmt->bind_param('ssdss',
- $body['nama_jalan'], $body['status_jalan'],
- $panjang, $body['keterangan'], $geoStr
- );
- executeOrFail($stmt, 'Gagal menyimpan data jalan');
- sendSuccess(['id' => $db->insert_id], 'Data jalan berhasil disimpan', 201);
- break;
-
- case 'PUT':
- if ($id <= 0) sendError('ID wajib disertakan');
- $body = getBody();
- $geo = is_array($body['geojson'])
- ? $body['geojson']
- : json_decode($body['geojson'], true);
- $geoStr = json_encode($geo);
- $panjang = (float)$body['panjang_meter'];
-
- $stmt = $db->prepare(
- "UPDATE data_jalan
- SET nama_jalan=?, status_jalan=?,
- panjang_meter=?, keterangan=?, geojson=?
- WHERE id=?"
- );
- $stmt->bind_param('ssdssi',
- $body['nama_jalan'], $body['status_jalan'],
- $panjang, $body['keterangan'], $geoStr, $id
- );
- executeOrFail($stmt, 'Gagal memperbarui data jalan');
- sendSuccess(null, 'Data jalan berhasil diperbarui');
- break;
-
- case 'DELETE':
- if ($id <= 0) sendError('ID wajib disertakan');
- $stmt = $db->prepare("DELETE FROM data_jalan WHERE id=?");
- $stmt->bind_param('i', $id);
- executeOrFail($stmt, 'Gagal menghapus data jalan');
- $stmt->affected_rows > 0
- ? sendSuccess(null, 'Data jalan berhasil dihapus')
- : sendError('Data tidak ditemukan', 404);
- break;
-
- default:
- sendError('Method tidak diizinkan', 405);
-}
\ No newline at end of file
diff --git a/api/data_parsil.php b/api/data_parsil.php
deleted file mode 100644
index dff1eb8..0000000
--- a/api/data_parsil.php
+++ /dev/null
@@ -1,112 +0,0 @@
- 0) {
- $stmt = $db->prepare(
- "SELECT id, nama_pemilik, no_sertifikat, jenis_hak,
- luas_m2, keterangan,
- CAST(geojson AS CHAR) AS geojson,
- created_at
- FROM data_parsil WHERE id = ?"
- );
- $stmt->bind_param('i', $id);
- $stmt->execute();
- $row = $stmt->get_result()->fetch_assoc();
- if (!$row) sendError('Data tidak ditemukan', 404);
- $row['geojson'] = json_decode($row['geojson']);
- $row['luas_m2'] = (float)$row['luas_m2'];
- sendSuccess($row);
- } else {
- $rows = [];
- $res = $db->query(
- "SELECT id, nama_pemilik, no_sertifikat, jenis_hak,
- luas_m2, keterangan,
- CAST(geojson AS CHAR) AS geojson,
- created_at
- FROM data_parsil ORDER BY created_at DESC"
- );
- while ($r = $res->fetch_assoc()) {
- $r['geojson'] = json_decode($r['geojson']);
- $r['luas_m2'] = (float)$r['luas_m2'];
- $rows[] = $r;
- }
- sendSuccess($rows);
- }
- break;
-
- case 'POST':
- $body = getBody();
- foreach (['nama_pemilik','jenis_hak','geojson','luas_m2'] as $f) {
- if (!isset($body[$f])) sendError("Field '$f' wajib diisi");
- }
- $geo = is_array($body['geojson'])
- ? $body['geojson']
- : json_decode($body['geojson'], true);
- if (!$geo || $geo['type'] !== 'Polygon') {
- sendError('GeoJSON harus bertipe Polygon');
- }
- $geoStr = json_encode($geo);
- $luas = (float)$body['luas_m2'];
-
- $stmt = $db->prepare(
- "INSERT INTO data_parsil
- (nama_pemilik, no_sertifikat, jenis_hak, luas_m2, keterangan, geojson)
- VALUES (?, ?, ?, ?, ?, ?)"
- );
- $stmt->bind_param('sssdss',
- $body['nama_pemilik'], $body['no_sertifikat'],
- $body['jenis_hak'], $luas,
- $body['keterangan'], $geoStr
- );
- executeOrFail($stmt, 'Gagal menyimpan data parsil');
- sendSuccess(['id' => $db->insert_id], 'Data parsil berhasil disimpan', 201);
- break;
-
- case 'PUT':
- if ($id <= 0) sendError('ID wajib disertakan');
- $body = getBody();
- $geo = is_array($body['geojson'])
- ? $body['geojson']
- : json_decode($body['geojson'], true);
- $geoStr = json_encode($geo);
- $luas = (float)$body['luas_m2'];
-
- $stmt = $db->prepare(
- "UPDATE data_parsil
- SET nama_pemilik=?, no_sertifikat=?, jenis_hak=?,
- luas_m2=?, keterangan=?, geojson=?
- WHERE id=?"
- );
- $stmt->bind_param('sssdssi',
- $body['nama_pemilik'], $body['no_sertifikat'],
- $body['jenis_hak'], $luas,
- $body['keterangan'], $geoStr, $id
- );
- executeOrFail($stmt, 'Gagal memperbarui data parsil');
- sendSuccess(null, 'Data parsil berhasil diperbarui');
- break;
-
- case 'DELETE':
- if ($id <= 0) sendError('ID wajib disertakan');
- $stmt = $db->prepare("DELETE FROM data_parsil WHERE id=?");
- $stmt->bind_param('i', $id);
- executeOrFail($stmt, 'Gagal menghapus data parsil');
- $stmt->affected_rows > 0
- ? sendSuccess(null, 'Data parsil berhasil dihapus')
- : sendError('Data tidak ditemukan', 404);
- break;
-
- default:
- sendError('Method tidak diizinkan', 405);
-}
\ No newline at end of file
diff --git a/api/helpers/response.php b/api/helpers/response.php
index 17ad923..a353332 100644
--- a/api/helpers/response.php
+++ b/api/helpers/response.php
@@ -49,28 +49,178 @@ function sendError(string $message, int $code = 400): void {
}
function requireWriteAuth(string $method): void {
- if (!in_array($method, ['POST', 'PUT', 'DELETE'], true)) {
- return;
- }
-
- $expected = (string)(getenv('API_WRITE_TOKEN') ?: '');
- if ($expected === '') {
- return;
- }
-
- $provided = (string)($_SERVER['HTTP_X_API_TOKEN'] ?? '');
- if ($provided === '') {
- $auth = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? '');
- if (preg_match('/Bearer\s+(.+)/i', $auth, $m)) {
- $provided = trim($m[1]);
- }
- }
-
- if ($provided === '' || !hash_equals($expected, $provided)) {
- sendError('Unauthorized', 401);
- }
+ // Deprecated: Menggunakan checkRBAC() secara global untuk semua request.
}
+function checkRBAC(): void {
+ if (PHP_SAPI === 'cli') {
+ return;
+ }
+
+ $script = basename($_SERVER['SCRIPT_FILENAME']);
+ if (in_array($script, ['login.php', 'logout.php', 'me.php'], true)) {
+ return;
+ }
+
+ if (session_status() === PHP_SESSION_NONE) {
+ ini_set('session.cookie_httponly', 1);
+ ini_set('session.use_only_cookies', 1);
+ session_start();
+ }
+
+ if (!isset($_SESSION['user_id'])) {
+ sendError('Unauthorized - Silakan login terlebih dahulu', 401);
+ }
+
+ $role = $_SESSION['role'] ?? 'guest';
+ $method = getMethod();
+
+ if ($method === 'GET') {
+ // Semua role diizinkan membaca data (GET)
+ return;
+ }
+
+ // Operasi Tulis (POST, PUT, DELETE)
+ if ($role === 'admin') {
+ // Admin diizinkan melakukan semua aksi tulis
+ return;
+ }
+
+ if ($role === 'petugas_lapangan') {
+ $user_ri_id = isset($_SESSION['rumah_ibadah_id']) ? (int)$_SESSION['rumah_ibadah_id'] : 0;
+ if ($user_ri_id <= 0) {
+ sendError('Forbidden - Akun petugas lapangan tidak dikaitkan dengan rumah ibadah manapun', 403);
+ }
+
+ if ($method === 'DELETE') {
+ sendError('Forbidden - Petugas Lapangan tidak diizinkan menghapus data', 403);
+ }
+
+ if ($script === 'rumah_ibadah.php') {
+ if ($method === 'PUT') {
+ $req_id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
+ if ($req_id !== $user_ri_id) {
+ sendError('Forbidden - Petugas Lapangan hanya dapat mengedit rumah ibadah mereka sendiri', 403);
+ }
+ return; // Allowed
+ }
+ sendError('Forbidden - Aksi tidak diizinkan untuk petugas lapangan pada rumah ibadah', 403);
+ }
+
+ if ($script === 'penduduk_miskin.php') {
+ if ($method === 'POST') {
+ if (isset($_GET['action']) && $_GET['action'] === 'recalc_all') {
+ sendError('Forbidden - Petugas Lapangan tidak dapat memperbarui proximity masal', 403);
+ }
+
+ $body = getBody();
+ $lat = isset($body['latitude']) ? (float)$body['latitude'] : null;
+ $lng = isset($body['longitude']) ? (float)$body['longitude'] : null;
+ if ($lat === null || $lng === null) {
+ sendError('Field latitude dan longitude wajib diisi');
+ }
+
+ $db = getDB();
+ $stmt = $db->prepare("SELECT latitude, longitude, radius_meter FROM rumah_ibadah WHERE id = ?");
+ $stmt->bind_param('i', $user_ri_id);
+ $stmt->execute();
+ $ri = $stmt->get_result()->fetch_assoc();
+ if (!$ri) {
+ sendError('Rumah ibadah petugas tidak ditemukan', 404);
+ }
+
+ $dist = haversine_distance((float)$ri['latitude'], (float)$ri['longitude'], $lat, $lng);
+ if ($dist > (int)$ri['radius_meter']) {
+ sendError('Forbidden - Titik koordinat penduduk miskin di luar radius jangkauan rumah ibadah Anda', 403);
+ }
+ return; // Allowed
+ }
+
+ if ($method === 'PUT') {
+ $req_id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
+ if ($req_id <= 0) {
+ sendError('ID penduduk miskin wajib disertakan');
+ }
+
+ $db = getDB();
+ $stmtCheck = $db->prepare("SELECT rumah_ibadah_id FROM penduduk_miskin WHERE id = ?");
+ $stmtCheck->bind_param('i', $req_id);
+ $stmtCheck->execute();
+ $pm = $stmtCheck->get_result()->fetch_assoc();
+ if (!$pm) {
+ sendError('Penduduk miskin tidak ditemukan', 404);
+ }
+
+ if ($pm['rumah_ibadah_id'] === null || (int)$pm['rumah_ibadah_id'] !== $user_ri_id) {
+ sendError('Forbidden - Penduduk miskin ini tidak berada di wilayah rumah ibadah Anda', 403);
+ }
+
+ $body = getBody();
+ $lat = isset($body['latitude']) ? (float)$body['latitude'] : null;
+ $lng = isset($body['longitude']) ? (float)$body['longitude'] : null;
+ if ($lat === null || $lng === null) {
+ sendError('Field latitude dan longitude wajib diisi');
+ }
+
+ $stmt = $db->prepare("SELECT latitude, longitude, radius_meter FROM rumah_ibadah WHERE id = ?");
+ $stmt->bind_param('i', $user_ri_id);
+ $stmt->execute();
+ $ri = $stmt->get_result()->fetch_assoc();
+ if (!$ri) {
+ sendError('Rumah ibadah petugas tidak ditemukan', 404);
+ }
+
+ $dist = haversine_distance((float)$ri['latitude'], (float)$ri['longitude'], $lat, $lng);
+ if ($dist > (int)$ri['radius_meter']) {
+ sendError('Forbidden - Titik koordinat baru di luar radius jangkauan rumah ibadah Anda', 403);
+ }
+ return; // Allowed
+ }
+
+ sendError('Forbidden - Aksi tidak diizinkan untuk petugas lapangan pada penduduk miskin', 403);
+ }
+
+ if ($script === 'log_bantuan.php') {
+ if ($method === 'POST') {
+ $body = getBody();
+ $req_pm_id = isset($body['penduduk_miskin_id']) ? (int)$body['penduduk_miskin_id'] : 0;
+ $req_ri_id = isset($body['rumah_ibadah_id']) ? (int)$body['rumah_ibadah_id'] : 0;
+
+ if ($req_ri_id !== $user_ri_id) {
+ sendError('Forbidden - Anda hanya dapat mencatat log bantuan dari rumah ibadah Anda sendiri', 403);
+ }
+
+ $db = getDB();
+ $stmtPm = $db->prepare("SELECT rumah_ibadah_id, status_proximity FROM penduduk_miskin WHERE id = ?");
+ $stmtPm->bind_param('i', $req_pm_id);
+ $stmtPm->execute();
+ $pm = $stmtPm->get_result()->fetch_assoc();
+ if (!$pm) {
+ sendError('Penduduk miskin tidak ditemukan', 404);
+ }
+
+ if ($pm['rumah_ibadah_id'] === null || (int)$pm['rumah_ibadah_id'] !== $user_ri_id || $pm['status_proximity'] !== 'dalam_radius') {
+ sendError('Forbidden - Penduduk miskin tidak berada di dalam radius rumah ibadah Anda', 403);
+ }
+ return; // Allowed
+ }
+ sendError('Forbidden - Aksi tidak diizinkan untuk petugas lapangan pada log bantuan', 403);
+ }
+
+ sendError('Forbidden', 403);
+ }
+
+ if ($role === 'guest') {
+ sendError('Forbidden - Guest hanya memiliki akses lihat (read-only)', 403);
+ }
+
+ sendError('Forbidden', 403);
+}
+
+// Jalankan pemeriksaan CORS dan RBAC secara global saat file di-load
+setCORSHeaders();
+checkRBAC();
+
function executeOrFail(mysqli_stmt $stmt, string $defaultMessage = 'Operasi database gagal'): void {
if ($stmt->execute()) {
return;
@@ -97,4 +247,13 @@ function getBody(): array {
function getMethod(): string {
return strtoupper($_SERVER['REQUEST_METHOD']);
+}
+
+function haversine_distance(float $lat1, float $lng1, float $lat2, float $lng2): float {
+ $R = 6371000; // radius bumi dalam meter
+ $dLat = deg2rad($lat2 - $lat1);
+ $dLng = deg2rad($lng2 - $lng1);
+ $a = sin($dLat/2)**2
+ + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng/2)**2;
+ return $R * 2 * atan2(sqrt($a), sqrt(1-$a));
}
\ No newline at end of file
diff --git a/api/login.php b/api/login.php
new file mode 100644
index 0000000..ea3b8f9
--- /dev/null
+++ b/api/login.php
@@ -0,0 +1,50 @@
+prepare("SELECT id, username, password, role, rumah_ibadah_id FROM users WHERE username = ?");
+if (!$stmt) {
+ sendError('Gagal menyiapkan query database', 500);
+}
+$stmt->bind_param('s', $username);
+$stmt->execute();
+$user = $stmt->get_result()->fetch_assoc();
+
+if (!$user || !password_verify($password, $user['password'])) {
+ sendError('Username atau Password salah', 401);
+}
+
+if (session_status() === PHP_SESSION_NONE) {
+ // Session settings for security
+ ini_set('session.cookie_httponly', 1);
+ ini_set('session.use_only_cookies', 1);
+ session_start();
+}
+
+$_SESSION['user_id'] = $user['id'];
+$_SESSION['username'] = $user['username'];
+$_SESSION['role'] = $user['role'];
+$_SESSION['rumah_ibadah_id'] = $user['rumah_ibadah_id'] ? (int)$user['rumah_ibadah_id'] : null;
+
+sendSuccess([
+ 'id' => $user['id'],
+ 'username' => $user['username'],
+ 'role' => $user['role'],
+ 'rumah_ibadah_id' => $user['rumah_ibadah_id'] ? (int)$user['rumah_ibadah_id'] : null
+], 'Login berhasil');
diff --git a/api/logout.php b/api/logout.php
new file mode 100644
index 0000000..f614cf6
--- /dev/null
+++ b/api/logout.php
@@ -0,0 +1,22 @@
+ 0) {
+ // Detail satu rumah ibadah
+ $stmt = $db->prepare("
+ SELECT ri.id, ri.nama, ri.jenis, ri.alamat, ri.no_wa,
+ ri.latitude, ri.longitude, ri.radius_meter, ri.created_at,
+ u.id AS user_id, u.username, u.password_plain, u.created_at AS akun_dibuat,
+ (SELECT COUNT(*) FROM penduduk_miskin pm WHERE pm.rumah_ibadah_id = ri.id AND pm.status_proximity = 'dalam_radius') AS pm_dalam_radius,
+ (SELECT COUNT(*) FROM log_bantuan lb WHERE lb.rumah_ibadah_id = ri.id) AS total_penyaluran,
+ (SELECT COUNT(*) FROM log_bantuan lb WHERE lb.rumah_ibadah_id = ri.id AND lb.tipe_bantuan = 'Pemberdayaan') AS bantuan_pemberdayaan,
+ (SELECT COUNT(*) FROM log_bantuan lb WHERE lb.rumah_ibadah_id = ri.id AND lb.tipe_bantuan = 'Konsumtif') AS bantuan_konsumtif,
+ (SELECT IFNULL(SUM(lb.nilai_bantuan), 0) FROM log_bantuan lb WHERE lb.rumah_ibadah_id = ri.id) AS total_nilai_bantuan,
+ (SELECT COUNT(DISTINCT lb.penduduk_miskin_id) FROM log_bantuan lb WHERE lb.rumah_ibadah_id = ri.id) AS jumlah_penerima_unik,
+ (SELECT lb.tanggal_bantuan FROM log_bantuan lb WHERE lb.rumah_ibadah_id = ri.id ORDER BY lb.tanggal_bantuan DESC LIMIT 1) AS tanggal_bantuan_terakhir
+ FROM rumah_ibadah ri
+ LEFT JOIN users u ON u.rumah_ibadah_id = ri.id
+ WHERE ri.id = ?
+ ");
+ $stmt->bind_param('i', $id);
+ $stmt->execute();
+ $row = $stmt->get_result()->fetch_assoc();
+
+ if (!$row) sendError('Data tidak ditemukan', 404);
+
+ // Cast types
+ $row['latitude'] = (float)$row['latitude'];
+ $row['longitude'] = (float)$row['longitude'];
+ $row['radius_meter'] = (int)$row['radius_meter'];
+ $row['pm_dalam_radius'] = (int)$row['pm_dalam_radius'];
+ $row['total_penyaluran'] = (int)$row['total_penyaluran'];
+ $row['bantuan_pemberdayaan'] = (int)$row['bantuan_pemberdayaan'];
+ $row['bantuan_konsumtif'] = (int)$row['bantuan_konsumtif'];
+ $row['total_nilai_bantuan'] = (float)$row['total_nilai_bantuan'];
+ $row['jumlah_penerima_unik'] = (int)$row['jumlah_penerima_unik'];
+ // password_plain: null if no account
+ if ($row['password_plain'] === null) $row['password_plain'] = null;
+
+ // Ambil 5 riwayat bantuan terbaru
+ $stmtLog = $db->prepare("
+ SELECT lb.tipe_bantuan, lb.sub_kategori, lb.tanggal_bantuan, lb.nilai_bantuan,
+ pm.nama AS nama_penerima
+ FROM log_bantuan lb
+ JOIN penduduk_miskin pm ON pm.id = lb.penduduk_miskin_id
+ WHERE lb.rumah_ibadah_id = ?
+ ORDER BY lb.tanggal_bantuan DESC, lb.id DESC
+ LIMIT 10
+ ");
+ $stmtLog->bind_param('i', $id);
+ $stmtLog->execute();
+ $logs = [];
+ $resLog = $stmtLog->get_result();
+ while ($l = $resLog->fetch_assoc()) {
+ $l['nilai_bantuan'] = $l['nilai_bantuan'] ? (float)$l['nilai_bantuan'] : null;
+ $logs[] = $l;
+ }
+ $row['riwayat_bantuan'] = $logs;
+
+ sendSuccess($row);
+ } else {
+ // Semua rumah ibadah dengan ringkasan statistik
+ $sql = "
+ SELECT ri.id, ri.nama, ri.jenis, ri.alamat, ri.no_wa,
+ ri.latitude, ri.longitude, ri.radius_meter, ri.created_at,
+ u.id AS user_id, u.username,
+ (SELECT COUNT(*) FROM penduduk_miskin pm WHERE pm.rumah_ibadah_id = ri.id AND pm.status_proximity = 'dalam_radius') AS pm_dalam_radius,
+ (SELECT COUNT(*) FROM log_bantuan lb WHERE lb.rumah_ibadah_id = ri.id) AS total_penyaluran,
+ (SELECT IFNULL(SUM(lb.nilai_bantuan), 0) FROM log_bantuan lb WHERE lb.rumah_ibadah_id = ri.id) AS total_nilai_bantuan,
+ (SELECT COUNT(DISTINCT lb.penduduk_miskin_id) FROM log_bantuan lb WHERE lb.rumah_ibadah_id = ri.id) AS jumlah_penerima_unik
+ FROM rumah_ibadah ri
+ LEFT JOIN users u ON u.rumah_ibadah_id = ri.id
+ ORDER BY ri.created_at DESC
+ ";
+ $result = $db->query($sql);
+ $rows = [];
+ while ($r = $result->fetch_assoc()) {
+ $r['latitude'] = (float)$r['latitude'];
+ $r['longitude'] = (float)$r['longitude'];
+ $r['radius_meter'] = (int)$r['radius_meter'];
+ $r['pm_dalam_radius'] = (int)$r['pm_dalam_radius'];
+ $r['total_penyaluran'] = (int)$r['total_penyaluran'];
+ $r['total_nilai_bantuan'] = (float)$r['total_nilai_bantuan'];
+ $r['jumlah_penerima_unik'] = (int)$r['jumlah_penerima_unik'];
+ $rows[] = $r;
+ }
+ sendSuccess($rows);
+ }
+ break;
+
+ // DELETE: Hapus akun user rumah ibadah (tidak hapus rumah ibadahnya)
+ case 'DELETE':
+ if ($id <= 0) sendError('ID wajib disertakan');
+ $stmt = $db->prepare("DELETE FROM users WHERE rumah_ibadah_id = ?");
+ $stmt->bind_param('i', $id);
+ if ($stmt->execute()) {
+ sendSuccess(null, $stmt->affected_rows > 0 ? 'Akun berhasil dihapus' : 'Tidak ada akun untuk dihapus');
+ } else {
+ sendError('Gagal menghapus akun');
+ }
+ break;
+
+ default:
+ sendError('Method tidak diizinkan', 405);
+}
diff --git a/api/me.php b/api/me.php
new file mode 100644
index 0000000..832f048
--- /dev/null
+++ b/api/me.php
@@ -0,0 +1,21 @@
+ $_SESSION['user_id'],
+ 'username' => $_SESSION['username'],
+ 'role' => $_SESSION['role'],
+ 'rumah_ibadah_id' => isset($_SESSION['rumah_ibadah_id']) ? (int)$_SESSION['rumah_ibadah_id'] : null
+]);
diff --git a/api/rumah_ibadah.php b/api/rumah_ibadah.php
index f4a2895..608af19 100644
--- a/api/rumah_ibadah.php
+++ b/api/rumah_ibadah.php
@@ -15,10 +15,12 @@ switch ($method) {
case 'GET':
if ($id > 0) {
$stmt = $db->prepare(
- "SELECT id, nama, jenis, alamat, no_wa,
- latitude, longitude, radius_meter,
- created_at
- FROM rumah_ibadah WHERE id = ?"
+ "SELECT ri.id, ri.nama, ri.jenis, ri.alamat, ri.no_wa,
+ ri.latitude, ri.longitude, ri.radius_meter,
+ ri.created_at, u.username
+ FROM rumah_ibadah ri
+ LEFT JOIN users u ON u.rumah_ibadah_id = ri.id
+ WHERE ri.id = ?"
);
$stmt->bind_param('i', $id);
$stmt->execute();
@@ -30,10 +32,12 @@ switch ($method) {
}
$row ? sendSuccess($row) : sendError('Data tidak ditemukan', 404);
} else {
- $sql = "SELECT id, nama, jenis, alamat, no_wa,
- latitude, longitude, radius_meter,
- created_at
- FROM rumah_ibadah ORDER BY created_at DESC";
+ $sql = "SELECT ri.id, ri.nama, ri.jenis, ri.alamat, ri.no_wa,
+ ri.latitude, ri.longitude, ri.radius_meter,
+ ri.created_at, u.username
+ FROM rumah_ibadah ri
+ LEFT JOIN users u ON u.rumah_ibadah_id = ri.id
+ ORDER BY ri.created_at DESC";
$result = $db->query($sql);
$rows = [];
while ($r = $result->fetch_assoc()) {
@@ -55,45 +59,139 @@ switch ($method) {
sendError("Field '$field' wajib diisi");
}
}
- $stmt = $db->prepare(
- "INSERT INTO rumah_ibadah
- (nama, jenis, alamat, no_wa, latitude, longitude, radius_meter)
- VALUES (?, ?, ?, ?, ?, ?, ?)"
- );
- $stmt->bind_param(
- 'ssssddi',
- $body['nama'],
- $body['jenis'],
- $body['alamat'],
- $body['no_wa'],
- $body['latitude'],
- $body['longitude'],
- $body['radius_meter']
- );
- executeOrFail($stmt, 'Gagal menyimpan rumah ibadah');
- $newId = $db->insert_id;
- sendSuccess(['id' => $newId], 'Rumah Ibadah berhasil disimpan', 201);
+
+ $db->begin_transaction();
+ try {
+ $stmt = $db->prepare(
+ "INSERT INTO rumah_ibadah
+ (nama, jenis, alamat, no_wa, latitude, longitude, radius_meter)
+ VALUES (?, ?, ?, ?, ?, ?, ?)"
+ );
+ $stmt->bind_param(
+ 'ssssddi',
+ $body['nama'],
+ $body['jenis'],
+ $body['alamat'],
+ $body['no_wa'],
+ $body['latitude'],
+ $body['longitude'],
+ $body['radius_meter']
+ );
+ executeOrFail($stmt, 'Gagal menyimpan rumah ibadah');
+ $newId = $db->insert_id;
+
+ // Handle user account creation
+ $username = isset($body['username']) ? trim($body['username']) : '';
+ $password = isset($body['password']) ? trim($body['password']) : '';
+ if ($username !== '') {
+ // Check if username already exists
+ $stmtUserCheck = $db->prepare("SELECT id FROM users WHERE username = ?");
+ $stmtUserCheck->bind_param('s', $username);
+ $stmtUserCheck->execute();
+ if ($stmtUserCheck->get_result()->num_rows > 0) {
+ throw new Exception('Username sudah digunakan');
+ }
+
+ if ($password === '') {
+ throw new Exception('Password wajib diisi jika username ditentukan');
+ }
+
+ $hashedPassword = password_hash($password, PASSWORD_BCRYPT);
+ $stmtUser = $db->prepare(
+ "INSERT INTO users (username, password, password_plain, role, rumah_ibadah_id) VALUES (?, ?, ?, 'petugas_lapangan', ?)"
+ );
+ $stmtUser->bind_param('sssi', $username, $hashedPassword, $password, $newId);
+ executeOrFail($stmtUser, 'Gagal membuat akun petugas lapangan');
+ }
+
+ $db->commit();
+ sendSuccess(['id' => $newId], 'Rumah Ibadah dan Akun Petugas berhasil disimpan', 201);
+ } catch (Exception $e) {
+ $db->rollback();
+ sendError($e->getMessage());
+ }
break;
// โโ PUT: update data โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
case 'PUT':
if ($id <= 0) sendError('ID wajib disertakan');
$body = getBody();
- $stmt = $db->prepare(
- "UPDATE rumah_ibadah
- SET nama=?, jenis=?, alamat=?, no_wa=?,
- latitude=?, longitude=?, radius_meter=?
- WHERE id=?"
- );
- $stmt->bind_param(
- 'ssssddii',
- $body['nama'], $body['jenis'],
- $body['alamat'], $body['no_wa'],
- $body['latitude'], $body['longitude'],
- $body['radius_meter'], $id
- );
- executeOrFail($stmt, 'Gagal memperbarui rumah ibadah');
- sendSuccess(null, 'Rumah Ibadah berhasil diperbarui');
+
+ $db->begin_transaction();
+ try {
+ $stmt = $db->prepare(
+ "UPDATE rumah_ibadah
+ SET nama=?, jenis=?, alamat=?, no_wa=?,
+ latitude=?, longitude=?, radius_meter=?
+ WHERE id=?"
+ );
+ $stmt->bind_param(
+ 'ssssddii',
+ $body['nama'], $body['jenis'],
+ $body['alamat'], $body['no_wa'],
+ $body['latitude'], $body['longitude'],
+ $body['radius_meter'], $id
+ );
+ executeOrFail($stmt, 'Gagal memperbarui rumah ibadah');
+
+ // Handle user account creation/update (only if user is admin)
+ if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin') {
+ $username = isset($body['username']) ? trim($body['username']) : '';
+ $password = isset($body['password']) ? trim($body['password']) : '';
+
+ if ($username !== '') {
+ // Check if username belongs to another user
+ $stmtUserCheck = $db->prepare("SELECT id, rumah_ibadah_id FROM users WHERE username = ?");
+ $stmtUserCheck->bind_param('s', $username);
+ $stmtUserCheck->execute();
+ $resUserCheck = $stmtUserCheck->get_result()->fetch_assoc();
+ if ($resUserCheck && (int)$resUserCheck['rumah_ibadah_id'] !== $id) {
+ throw new Exception('Username sudah digunakan oleh akun lain');
+ }
+
+ // Check if account already exists for this rumah_ibadah
+ $stmtExisting = $db->prepare("SELECT id FROM users WHERE rumah_ibadah_id = ?");
+ $stmtExisting->bind_param('i', $id);
+ $stmtExisting->execute();
+ $existing = $stmtExisting->get_result()->fetch_assoc();
+
+ if ($existing) {
+ // Update existing user
+ if ($password !== '') {
+ $hashedPassword = password_hash($password, PASSWORD_BCRYPT);
+ $stmtUpdate = $db->prepare("UPDATE users SET username = ?, password = ?, password_plain = ? WHERE id = ?");
+ $stmtUpdate->bind_param('sssi', $username, $hashedPassword, $password, $existing['id']);
+ } else {
+ $stmtUpdate = $db->prepare("UPDATE users SET username = ? WHERE id = ?");
+ $stmtUpdate->bind_param('si', $username, $existing['id']);
+ }
+ executeOrFail($stmtUpdate, 'Gagal memperbarui akun petugas lapangan');
+ } else {
+ // Create new user for this rumah ibadah
+ if ($password === '') {
+ throw new Exception('Password wajib diisi untuk pembuatan akun baru');
+ }
+ $hashedPassword = password_hash($password, PASSWORD_BCRYPT);
+ $stmtUser = $db->prepare(
+ "INSERT INTO users (username, password, password_plain, role, rumah_ibadah_id) VALUES (?, ?, ?, 'petugas_lapangan', ?)"
+ );
+ $stmtUser->bind_param('sssi', $username, $hashedPassword, $password, $id);
+ executeOrFail($stmtUser, 'Gagal membuat akun petugas lapangan');
+ }
+ } else {
+ // Delete the associated user account if username is cleared
+ $stmtDeleteUser = $db->prepare("DELETE FROM users WHERE rumah_ibadah_id = ?");
+ $stmtDeleteUser->bind_param('i', $id);
+ $stmtDeleteUser->execute();
+ }
+ }
+
+ $db->commit();
+ sendSuccess(null, 'Rumah Ibadah berhasil diperbarui');
+ } catch (Exception $e) {
+ $db->rollback();
+ sendError($e->getMessage());
+ }
break;
// โโ DELETE: hapus data โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
diff --git a/api/spbu.php b/api/spbu.php
deleted file mode 100644
index 422838d..0000000
--- a/api/spbu.php
+++ /dev/null
@@ -1,105 +0,0 @@
- 0) {
- $stmt = $db->prepare(
- "SELECT id, nama, no_wa, alamat, buka_24jam,
- latitude, longitude, created_at
- FROM spbu WHERE id = ?"
- );
- $stmt->bind_param('i', $id);
- $stmt->execute();
- $row = $stmt->get_result()->fetch_assoc();
- if (!$row) sendError('Data tidak ditemukan', 404);
- $row['latitude'] = (float)$row['latitude'];
- $row['longitude'] = (float)$row['longitude'];
- $row['buka_24jam']= (bool)$row['buka_24jam'];
- sendSuccess($row);
- } else {
- $rows = [];
- if (isset($_GET['buka_24jam'])) {
- $val = (int)$_GET['buka_24jam'];
- $stmt = $db->prepare(
- "SELECT id, nama, no_wa, alamat, buka_24jam,
- latitude, longitude, created_at
- FROM spbu WHERE buka_24jam = ? ORDER BY nama"
- );
- $stmt->bind_param('i', $val);
- $stmt->execute();
- $res = $stmt->get_result();
- } else {
- $res = $db->query(
- "SELECT id, nama, no_wa, alamat, buka_24jam,
- latitude, longitude, created_at
- FROM spbu ORDER BY nama"
- );
- }
- while ($r = $res->fetch_assoc()) {
- $r['latitude'] = (float)$r['latitude'];
- $r['longitude'] = (float)$r['longitude'];
- $r['buka_24jam']= (bool)$r['buka_24jam'];
- $rows[] = $r;
- }
- sendSuccess($rows);
- }
- break;
-
- case 'POST':
- $body = getBody();
- foreach (['nama','latitude','longitude'] as $f) {
- if (empty($body[$f]) && $body[$f] !== 0) sendError("Field '$f' wajib diisi");
- }
- $buka = isset($body['buka_24jam']) ? (int)(bool)$body['buka_24jam'] : 0;
- $stmt = $db->prepare(
- "INSERT INTO spbu (nama, no_wa, alamat, buka_24jam, latitude, longitude)
- VALUES (?, ?, ?, ?, ?, ?)"
- );
- $stmt->bind_param('sssidd',
- $body['nama'], $body['no_wa'], $body['alamat'],
- $buka, $body['latitude'], $body['longitude']
- );
- executeOrFail($stmt, 'Gagal menyimpan data SPBU');
- sendSuccess(['id' => $db->insert_id], 'SPBU berhasil disimpan', 201);
- break;
-
- case 'PUT':
- if ($id <= 0) sendError('ID wajib disertakan');
- $body = getBody();
- $buka = isset($body['buka_24jam']) ? (int)(bool)$body['buka_24jam'] : 0;
- $stmt = $db->prepare(
- "UPDATE spbu
- SET nama=?, no_wa=?, alamat=?, buka_24jam=?, latitude=?, longitude=?
- WHERE id=?"
- );
- $stmt->bind_param('sssiddi',
- $body['nama'], $body['no_wa'], $body['alamat'],
- $buka, $body['latitude'], $body['longitude'], $id
- );
- executeOrFail($stmt, 'Gagal memperbarui data SPBU');
- sendSuccess(null, 'SPBU berhasil diperbarui');
- break;
-
- case 'DELETE':
- if ($id <= 0) sendError('ID wajib disertakan');
- $stmt = $db->prepare("DELETE FROM spbu WHERE id=?");
- $stmt->bind_param('i', $id);
- executeOrFail($stmt, 'Gagal menghapus data SPBU');
- $stmt->affected_rows > 0
- ? sendSuccess(null, 'SPBU berhasil dihapus')
- : sendError('Data tidak ditemukan', 404);
- break;
-
- default:
- sendError('Method tidak diizinkan', 405);
-}
\ No newline at end of file
diff --git a/components/head.php b/components/head.php
new file mode 100644
index 0000000..9036dfb
--- /dev/null
+++ b/components/head.php
@@ -0,0 +1,43 @@
+
+
+
+
= htmlspecialchars($pageTitle) ?>
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/scripts.php b/components/scripts.php
new file mode 100644
index 0000000..c289242
--- /dev/null
+++ b/components/scripts.php
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
diff --git a/components/sidebar.php b/components/sidebar.php
new file mode 100644
index 0000000..cb3e8d6
--- /dev/null
+++ b/components/sidebar.php
@@ -0,0 +1,76 @@
+
+
+
+
+
diff --git a/cookie.txt b/cookie.txt
new file mode 100644
index 0000000..c31d989
--- /dev/null
+++ b/cookie.txt
@@ -0,0 +1,4 @@
+# Netscape HTTP Cookie File
+# https://curl.se/docs/http-cookies.html
+# This file was generated by libcurl! Edit at your own risk.
+
diff --git a/css/app.css b/css/base.css
similarity index 55%
rename from css/app.css
rename to css/base.css
index 4b073c6..a3dcee4 100644
--- a/css/app.css
+++ b/css/base.css
@@ -1,895 +1,794 @@
-:root {
- --bg: #eef3f8;
- --panel: rgba(255, 255, 255, .92);
- --panel-strong: #ffffff;
- --line: #d7e0ea;
- --line-strong: #bcc9d7;
- --text: #0f172a;
- --muted: #5b6b82;
- --muted-2: #7d8ca2;
- --accent: #165dff;
- --accent-2: #0ea5e9;
- --accent-soft: #eaf1ff;
- --shadow: 0 12px 36px rgba(15, 23, 42, .12);
- --shadow-soft: 0 8px 18px rgba(15, 23, 42, .08);
- --radius: 18px;
- --radius-sm: 12px;
- --font: 'Inter', sans-serif;
- --font-head: 'Space Grotesk', sans-serif;
-}
-
-* {
- box-sizing: border-box;
-}
-
-html,
-body {
- height: 100%;
-}
-
-body {
- margin: 0;
- font-family: var(--font);
- color: var(--text);
- background:
- radial-gradient(circle at top left, rgba(22, 93, 255, .12), transparent 32%),
- radial-gradient(circle at right center, rgba(14, 165, 233, .10), transparent 28%),
- linear-gradient(180deg, #f6f9fc 0%, var(--bg) 100%);
- overflow: hidden;
-}
-
-button,
-input,
-select,
-textarea {
- font: inherit;
-}
-
-button {
- cursor: pointer;
-}
-
-#app {
- height: 100%;
- display: grid;
- grid-template-rows: 64px 1fr;
-}
-
-#header {
- display: flex;
- align-items: center;
- gap: 16px;
- padding: 0 18px;
- background: rgba(255, 255, 255, .8);
- border-bottom: 1px solid rgba(215, 224, 234, .9);
- backdrop-filter: blur(14px);
- box-shadow: 0 1px 0 rgba(255, 255, 255, .7) inset;
- position: relative;
- z-index: 4000;
- overflow: visible;
-}
-
-.brand {
- display: flex;
- flex-direction: column;
- gap: 2px;
- white-space: nowrap;
-}
-
-.brand-title {
- font-family: var(--font-head);
- font-weight: 700;
- letter-spacing: -.04em;
- font-size: 15px;
-}
-
-.brand-sub {
- font-size: 11px;
- color: var(--muted);
-}
-
-.nav-tabs {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-wrap: wrap;
-}
-
-.header-search {
- position: relative;
- width: min(500px, 100%);
- flex: 1 1 360px;
- max-width: 500px;
- z-index: 4001;
-}
-
-.search-box {
- display: flex;
- align-items: center;
- gap: 10px;
- width: 100%;
- border: 1px solid var(--line);
- background: rgba(255, 255, 255, .96);
- border-radius: 999px;
- padding: 9px 14px;
- box-shadow: var(--shadow-soft);
-}
-
-.search-icon {
- flex: 0 0 auto;
- color: var(--muted-2);
- font-size: 14px;
- line-height: 1;
-}
-
-#search-input {
- width: 100%;
- border: none;
- outline: none;
- background: transparent;
- color: var(--text);
- font-size: 12px;
-}
-
-#search-input::placeholder {
- color: var(--muted-2);
-}
-
-.search-results {
- position: absolute;
- top: calc(100% + 8px);
- left: 0;
- right: 0;
- z-index: 5000;
- display: none;
- padding: 8px;
- border: 1px solid var(--line);
- border-radius: 18px;
- background: rgba(255, 255, 255, .98);
- box-shadow: 0 18px 40px rgba(15, 23, 42, .16);
- backdrop-filter: blur(12px);
- max-height: 320px;
- overflow-y: auto;
- overflow-x: hidden;
-}
-
-.search-results.show {
- display: grid;
- gap: 10px;
-}
-
-.search-results-head {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: 10px;
- padding: 2px 4px 0;
-}
-
-.search-results-hint {
- padding: 0 4px;
- font-size: 11px;
- color: var(--muted);
-}
-
-.search-results-title {
- font-size: 10px;
- font-weight: 800;
- letter-spacing: .08em;
- text-transform: uppercase;
- color: var(--muted-2);
-}
-
-.search-results-count {
- font-size: 10px;
- color: var(--muted);
- white-space: nowrap;
-}
-
-.search-results-list {
- display: grid;
- gap: 6px;
-}
-
-.search-result-item {
- display: flex;
- align-items: center;
- gap: 10px;
- width: 100%;
- border: 1px solid transparent;
- background: #f8fbff;
- border-radius: 14px;
- padding: 10px 12px;
- text-align: left;
- transition: all .14s ease;
-}
-
-.search-result-item:hover,
-.search-result-item.active {
- border-color: #cfe0ff;
- background: #eef5ff;
- transform: translateY(-1px);
-}
-
-.search-result-icon {
- width: 30px;
- height: 30px;
- border-radius: 10px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- background: #fff;
- border: 1px solid var(--line);
- flex: 0 0 auto;
-}
-
-.search-result-info {
- flex: 1;
- min-width: 0;
-}
-
-.search-result-name {
- font-size: 12px;
- font-weight: 700;
- color: var(--text);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.search-result-sub {
- margin-top: 2px;
- font-size: 10px;
- color: var(--muted);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.search-result-type {
- flex: 0 0 auto;
- font-size: 9px;
- font-weight: 800;
- letter-spacing: .04em;
- text-transform: uppercase;
- color: var(--accent);
- background: var(--accent-soft);
- border: 1px solid #cfe0ff;
- border-radius: 999px;
- padding: 4px 8px;
-}
-
-.search-empty {
- padding: 14px 12px;
- border: 1px dashed #cbd6e3;
- border-radius: 14px;
- color: var(--muted);
- font-size: 12px;
- text-align: center;
- background: #fbfcfe;
-}
-
-.search-empty strong {
- color: var(--text);
-}
-
-.nav-tab {
- border: 1px solid transparent;
- background: transparent;
- color: var(--muted);
- border-radius: 999px;
- padding: 9px 14px;
- font-size: 12px;
- font-weight: 600;
- transition: all .16s ease;
-}
-
-.nav-tab:hover {
- background: #f3f7fb;
- color: var(--text);
-}
-
-.nav-tab.active {
- background: var(--accent-soft);
- color: var(--accent);
- border-color: #cfe0ff;
- box-shadow: 0 0 0 1px rgba(22, 93, 255, .04) inset;
-}
-
-.header-right {
- margin-left: auto;
- display: flex;
- align-items: center;
- gap: 10px;
-}
-
-.coord-display,
-.status-pill {
- border: 1px solid var(--line);
- background: rgba(255, 255, 255, .86);
- box-shadow: var(--shadow-soft);
- border-radius: 999px;
- padding: 8px 12px;
- font-size: 11px;
- color: var(--muted);
- white-space: nowrap;
-}
-
-.coord-display {
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-}
-
-.status-pill strong {
- color: var(--text);
-}
-
-#main {
- display: grid;
- grid-template-columns: minmax(0, 1fr) 362px;
- min-height: 0;
- position: relative;
- z-index: 1;
-}
-
-#map-wrap {
- position: relative;
- min-width: 0;
-}
-
-#map {
- height: 100%;
- width: 100%;
-}
-
-#instruction-banner {
- position: absolute;
- left: 18px;
- top: 18px;
- z-index: 500;
- background: rgba(15, 23, 42, .92);
- color: #fff;
- padding: 10px 14px;
- border-radius: 999px;
- font-size: 12px;
- box-shadow: var(--shadow);
- opacity: 0;
- transform: translateY(-8px);
- pointer-events: none;
- transition: all .18s ease;
-}
-
-#instruction-banner.show {
- opacity: 1;
- transform: translateY(0);
-}
-
-#panel {
- min-width: 0;
- background: var(--panel);
- border-left: 1px solid rgba(215, 224, 234, .95);
- box-shadow: -1px 0 0 rgba(255, 255, 255, .75) inset;
- display: grid;
- grid-template-rows: auto 1fr;
- min-height: 0;
-}
-
-#panel-head {
- padding: 16px 16px 12px;
- border-bottom: 1px solid var(--line);
- background: linear-gradient(180deg, rgba(255, 255, 255, .95), rgba(255, 255, 255, .82));
-}
-
-#panel-head h2 {
- margin: 0 0 8px;
- font-family: var(--font-head);
- letter-spacing: -.04em;
- font-size: 17px;
-}
-
-.panel-meta {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- font-size: 11px;
- color: var(--muted);
-}
-
-#panel-body {
- min-height: 0;
- overflow: auto;
- padding: 14px;
- background: linear-gradient(180deg, rgba(247, 250, 253, .8), rgba(242, 246, 250, .88));
-}
-
-#panel-body::-webkit-scrollbar {
- width: 10px;
-}
-
-#panel-body::-webkit-scrollbar-thumb {
- background: #c7d3e0;
- border-radius: 999px;
- border: 2px solid transparent;
- background-clip: padding-box;
-}
-
-.layer-section {
- margin-bottom: 16px;
-}
-
-.layer-section-title {
- display: flex;
- align-items: center;
- justify-content: space-between;
- font-size: 10px;
- font-weight: 800;
- letter-spacing: .08em;
- text-transform: uppercase;
- color: var(--muted-2);
- margin: 0 0 8px;
-}
-
-.layer-item,
-.data-item {
- display: flex;
- align-items: center;
- gap: 10px;
- border: 1px solid var(--line);
- background: var(--panel-strong);
- border-radius: var(--radius-sm);
- box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
-}
-
-.layer-item {
- padding: 9px 11px;
- margin-bottom: 6px;
- transition: all .16s ease;
-}
-
-.layer-item:hover,
-.data-item:hover {
- border-color: #bcd0ff;
- box-shadow: 0 10px 20px rgba(22, 93, 255, .08);
-}
-
-.layer-item.checked {
- background: linear-gradient(180deg, #f8fbff, #eef5ff);
- border-color: #cfe0ff;
-}
-
-.layer-item.checked .layer-check {
- color: #fff;
- background: var(--accent);
- border-color: var(--accent);
-}
-
-.layer-check {
- width: 18px;
- height: 18px;
- border-radius: 5px;
- border: 1.5px solid #b8c5d4;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- flex: 0 0 auto;
- font-size: 11px;
- color: transparent;
- background: #fff;
-}
-
-.layer-dot {
- width: 12px;
- height: 12px;
- border-radius: 999px;
- flex: 0 0 auto;
-}
-
-.layer-label {
- font-size: 12px;
- font-weight: 600;
- color: var(--text);
- flex: 1;
- min-width: 0;
-}
-
-.layer-count {
- font-size: 10px;
- font-weight: 700;
- color: var(--muted);
- background: #f4f7fb;
- border: 1px solid var(--line);
- border-radius: 999px;
- padding: 3px 8px;
-}
-
-.btn-add {
- width: 100%;
- text-align: left;
- border: 1.5px dashed #aebfda;
- background: rgba(255, 255, 255, .92);
- color: var(--muted);
- border-radius: var(--radius-sm);
- padding: 11px 12px;
- margin-top: 6px;
- transition: all .16s ease;
-}
-
-.btn-add:hover,
-.btn-add.active {
- border-color: var(--accent);
- color: var(--accent);
- background: var(--accent-soft);
-}
-
-.data-list {
- display: grid;
- gap: 8px;
-}
-
-.data-item {
- padding: 10px 11px;
- transition: all .16s ease;
-}
-
-.item-icon {
- width: 28px;
- height: 28px;
- border-radius: 10px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- background: #f3f7fb;
- flex: 0 0 auto;
-}
-
-.item-info {
- flex: 1;
- min-width: 0;
-}
-
-.item-name {
- font-size: 12px;
- font-weight: 700;
- color: var(--text);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.item-sub {
- font-size: 10px;
- color: var(--muted);
- margin-top: 2px;
-}
-
-.item-badge {
- font-size: 9px;
- font-weight: 800;
- text-transform: uppercase;
- letter-spacing: .04em;
- border-radius: 999px;
- padding: 4px 8px;
- border: 1px solid transparent;
- flex: 0 0 auto;
-}
-
-.badge-green {
- background: #e6f8ec;
- color: #15803d;
- border-color: #b9ebc9;
-}
-
-.badge-red {
- background: #feecec;
- color: #dc2626;
- border-color: #fecaca;
-}
-
-.badge-blue {
- background: #eaf1ff;
- color: #165dff;
- border-color: #cfe0ff;
-}
-
-.badge-amber {
- background: #fff4df;
- color: #d97706;
- border-color: #f9deb0;
-}
-
-.badge-purple {
- background: #f1eaff;
- color: #7c3aed;
- border-color: #ddd2ff;
-}
-
-.loading {
- padding: 14px;
- border: 1px dashed #cbd6e3;
- border-radius: var(--radius-sm);
- background: rgba(255, 255, 255, .7);
- color: var(--muted);
- font-size: 12px;
- text-align: center;
-}
-
-.spinner {
- display: inline-block;
- width: 12px;
- height: 12px;
- border: 2px solid rgba(22, 93, 255, .18);
- border-top-color: var(--accent);
- border-radius: 50%;
- animation: spin .8s linear infinite;
- margin-right: 8px;
- vertical-align: -2px;
-}
-
-@keyframes spin {
- to {
- transform: rotate(360deg);
- }
-}
-
-#modal-overlay {
- position: fixed;
- inset: 0;
- display: none;
- align-items: center;
- justify-content: center;
- background: rgba(15, 23, 42, .42);
- z-index: 2000;
- padding: 18px;
-}
-
-#modal-overlay.open {
- display: flex;
-}
-
-#modal {
- width: min(720px, 100%);
- max-height: min(90vh, 860px);
- background: var(--panel-strong);
- border-radius: 24px;
- box-shadow: 0 24px 70px rgba(15, 23, 42, .28);
- overflow: hidden;
- display: grid;
- grid-template-rows: auto 1fr auto;
-}
-
-.modal-head {
- padding: 18px 20px 14px;
- border-bottom: 1px solid var(--line);
- background: linear-gradient(180deg, #fff, #fbfdff);
-}
-
-.modal-head h3 {
- margin: 0;
- font-family: var(--font-head);
- font-size: 18px;
- letter-spacing: -.03em;
-}
-
-#modal-body {
- padding: 18px 20px;
- overflow: auto;
- background: #fbfcfe;
-}
-
-#modal-footer {
- padding: 14px 20px 18px;
- display: flex;
- justify-content: flex-end;
- gap: 10px;
- border-top: 1px solid var(--line);
- background: #fff;
-}
-
-.form-group {
- margin-bottom: 14px;
-}
-
-.form-row {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 12px;
-}
-
-.form-label {
- display: block;
- margin-bottom: 6px;
- font-size: 12px;
- font-weight: 700;
- color: var(--text);
-}
-
-.form-input,
-.form-select {
- width: 100%;
- border: 1px solid var(--line-strong);
- background: #fff;
- border-radius: 12px;
- padding: 11px 12px;
- outline: none;
- transition: all .14s ease;
-}
-
-.form-input:focus,
-.form-select:focus {
- border-color: #8eb3ff;
- box-shadow: 0 0 0 4px rgba(22, 93, 255, .10);
-}
-
-.form-coord,
-.form-calc {
- margin-bottom: 14px;
- padding: 11px 12px;
- background: #eef5ff;
- color: #1140b8;
- border: 1px solid #d7e5ff;
- border-radius: 12px;
- font-size: 12px;
- font-weight: 600;
-}
-
-.popup-actions {
- display: flex;
- gap: 8px;
- margin-top: 10px;
-}
-
-.popup-drag-hint,
-.proximity-info {
- margin-top: 10px;
- padding: 10px 12px;
- border-radius: 12px;
- font-size: 12px;
-}
-
-.popup-drag-hint {
- background: #f3f7fb;
- color: var(--muted);
-}
-
-.proximity-info.dalam {
- background: #e6f8ec;
- color: #15803d;
-}
-
-.proximity-info.luar {
- background: #feecec;
- color: #dc2626;
-}
-
-.btn {
- border: 1px solid transparent;
- border-radius: 12px;
- padding: 10px 14px;
- font-weight: 700;
- font-size: 12px;
- transition: all .16s ease;
-}
-
-.btn:hover {
- transform: translateY(-1px);
-}
-
-.btn-primary {
- background: var(--accent);
- color: #fff;
- box-shadow: 0 12px 22px rgba(22, 93, 255, .18);
-}
-
-.btn-primary:hover {
- background: #0d4ae0;
-}
-
-.btn-ghost {
- background: #fff;
- border-color: var(--line);
- color: var(--text);
-}
-
-.btn-danger {
- background: #ef4444;
- color: #fff;
-}
-
-.btn-sm {
- padding: 7px 10px;
- border-radius: 10px;
-}
-
-#toast-container {
- position: fixed;
- right: 18px;
- bottom: 18px;
- display: grid;
- gap: 10px;
- z-index: 3000;
-}
-
-.toast {
- min-width: 240px;
- max-width: 360px;
- border-radius: 14px;
- padding: 12px 14px;
- color: #fff;
- box-shadow: var(--shadow);
- animation: toast-in .2s ease;
-}
-
-.toast.info {
- background: #0f172a;
-}
-
-.toast.success {
- background: #16a34a;
-}
-
-.toast.error {
- background: #dc2626;
-}
-
-@keyframes toast-in {
- from {
- opacity: 0;
- transform: translateY(8px);
- }
-
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-@media (max-width: 1100px) {
- #main {
- grid-template-columns: 1fr;
- }
-
- #panel {
- grid-template-rows: auto 360px;
- border-left: none;
- border-top: 1px solid var(--line);
- }
-}
-
-@media (max-width: 720px) {
- #header {
- flex-wrap: wrap;
- height: auto;
- padding: 12px 14px;
- gap: 10px;
- }
-
- .header-search {
- order: 3;
- width: 100%;
- max-width: none;
- flex-basis: 100%;
- }
-
- .header-right {
- margin-left: 0;
- width: 100%;
- justify-content: space-between;
- }
-
- .nav-tabs {
- width: 100%;
- overflow: auto;
- }
-
- .form-row {
- grid-template-columns: 1fr;
- }
-
- #panel {
- grid-template-rows: auto minmax(320px, 48vh);
- }
-
- #modal {
- width: 100%;
- border-radius: 20px;
- }
-}
\ No newline at end of file
+:root {
+ --bg: #eef3f8;
+ --panel: rgba(255, 255, 255, .92);
+ --panel-strong: #ffffff;
+ --line: #d7e0ea;
+ --line-strong: #bcc9d7;
+ --text: #0f172a;
+ --muted: #5b6b82;
+ --muted-2: #7d8ca2;
+ --accent: #165dff;
+ --accent-2: #0ea5e9;
+ --accent-soft: #eaf1ff;
+ --shadow: 0 12px 36px rgba(15, 23, 42, .12);
+ --shadow-soft: 0 8px 18px rgba(15, 23, 42, .08);
+ --radius: 18px;
+ --radius-sm: 12px;
+ --font: 'Inter', sans-serif;
+ --font-head: 'Space Grotesk', sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ font-family: var(--font);
+ color: var(--text);
+ background:
+ radial-gradient(circle at top left, rgba(22, 93, 255, .12), transparent 32%),
+ radial-gradient(circle at right center, rgba(14, 165, 233, .10), transparent 28%),
+ linear-gradient(180deg, #f6f9fc 0%, var(--bg) 100%);
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+#app {
+ height: 100%;
+ display: grid;
+ grid-template-rows: 64px 1fr;
+}
+
+.nav-tabs {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.header-search {
+ position: relative;
+ width: min(500px, 100%);
+ flex: 1 1 360px;
+ max-width: 500px;
+ z-index: 4001;
+}
+
+#search-input {
+ width: 100%;
+ border: none;
+ outline: none;
+ background: transparent;
+ color: var(--text);
+ font-size: 12px;
+}
+
+#search-input::placeholder {
+ color: var(--muted-2);
+}
+
+.nav-tab {
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--muted);
+ border-radius: 999px;
+ padding: 9px 14px;
+ font-size: 12px;
+ font-weight: 600;
+ transition: all .16s ease;
+}
+
+.nav-tab:hover {
+ background: #f3f7fb;
+ color: var(--text);
+}
+
+.nav-tab.active {
+ background: var(--accent-soft);
+ color: var(--accent);
+ border-color: #cfe0ff;
+ box-shadow: 0 0 0 1px rgba(22, 93, 255, .04) inset;
+}
+
+.header-right {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.coord-display,
+.status-pill {
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, .86);
+ box-shadow: var(--shadow-soft);
+ border-radius: 999px;
+ padding: 8px 12px;
+ font-size: 11px;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.coord-display {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+
+.status-pill strong {
+ color: var(--text);
+}
+
+#main {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 362px;
+ min-height: 0;
+ position: relative;
+ z-index: 1;
+}
+
+#instruction-banner {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%) translateY(-8px);
+ top: 18px;
+ z-index: 5000;
+ background: rgba(15, 23, 42, .92);
+ color: #fff;
+ padding: 10px 14px;
+ border-radius: 999px;
+ font-size: 12px;
+ box-shadow: var(--shadow);
+ opacity: 0;
+ pointer-events: none;
+ transition: all .18s ease;
+}
+
+#instruction-banner.show {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+}
+
+#panel {
+ min-width: 0;
+ background: var(--panel);
+ border-left: 1px solid rgba(215, 224, 234, .95);
+ box-shadow: -1px 0 0 rgba(255, 255, 255, .75) inset;
+ display: grid;
+ grid-template-rows: auto 1fr;
+ min-height: 0;
+}
+
+#panel-head {
+ padding: 16px 16px 12px;
+ border-bottom: 1px solid var(--line);
+ background: linear-gradient(180deg, rgba(255, 255, 255, .95), rgba(255, 255, 255, .82));
+}
+
+#panel-head h2 {
+ margin: 0 0 8px;
+ font-family: var(--font-head);
+ letter-spacing: -.04em;
+ font-size: 17px;
+}
+
+.panel-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ font-size: 11px;
+ color: var(--muted);
+}
+
+#panel-body {
+ min-height: 0;
+ overflow: auto;
+ padding: 14px;
+ background: linear-gradient(180deg, rgba(247, 250, 253, .8), rgba(242, 246, 250, .88));
+}
+
+#panel-body::-webkit-scrollbar {
+ width: 10px;
+}
+
+#panel-body::-webkit-scrollbar-thumb {
+ background: #c7d3e0;
+ border-radius: 999px;
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+
+.data-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ border: 1px solid var(--line);
+ background: var(--panel-strong);
+ border-radius: var(--radius-sm);
+ box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
+}
+
+.data-item:hover {
+ border-color: #bcd0ff;
+ box-shadow: 0 10px 20px rgba(22, 93, 255, .08);
+}
+
+.data-list {
+ display: grid;
+ gap: 8px;
+}
+
+.data-item {
+ padding: 10px 11px;
+ transition: all .16s ease;
+}
+
+.item-icon {
+ width: 28px;
+ height: 28px;
+ border-radius: 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: #f3f7fb;
+ flex: 0 0 auto;
+}
+
+.item-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.item-name {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.item-sub {
+ font-size: 10px;
+ color: var(--muted);
+ margin-top: 2px;
+}
+
+.item-badge {
+ font-size: 9px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: .04em;
+ border-radius: 999px;
+ padding: 4px 8px;
+ border: 1px solid transparent;
+ flex: 0 0 auto;
+}
+
+.loading {
+ padding: 14px;
+ border: 1px dashed #cbd6e3;
+ border-radius: var(--radius-sm);
+ background: rgba(255, 255, 255, .7);
+ color: var(--muted);
+ font-size: 12px;
+ text-align: center;
+}
+
+.spinner {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border: 2px solid rgba(22, 93, 255, .18);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin .8s linear infinite;
+ margin-right: 8px;
+ vertical-align: -2px;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+#modal-overlay {
+ position: fixed;
+ inset: 0;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ background: rgba(15, 23, 42, .42);
+ z-index: 6000;
+ padding: 18px;
+}
+
+#modal-overlay.open {
+ display: flex;
+}
+
+#modal {
+ width: min(720px, 100%);
+ max-height: min(90vh, 860px);
+ background: var(--panel-strong);
+ border-radius: 24px;
+ box-shadow: 0 24px 70px rgba(15, 23, 42, .28);
+ overflow: hidden;
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+}
+
+#modal-body {
+ padding: 18px 20px;
+ overflow: auto;
+ background: #fbfcfe;
+}
+
+#modal-footer {
+ padding: 14px 20px 18px;
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ border-top: 1px solid var(--line);
+ background: #fff;
+}
+
+.popup-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.popup-drag-hint,
+.proximity-info {
+ margin-top: 10px;
+ padding: 10px 12px;
+ border-radius: 12px;
+ font-size: 12px;
+}
+
+.popup-drag-hint {
+ background: #f3f7fb;
+ color: var(--muted);
+}
+
+.proximity-info.dalam {
+ background: #e6f8ec;
+ color: #15803d;
+}
+
+.proximity-info.luar {
+ background: #feecec;
+ color: #dc2626;
+}
+
+#toast-container {
+ position: fixed;
+ right: 18px;
+ bottom: 18px;
+ display: grid;
+ gap: 10px;
+ z-index: 3000;
+}
+
+@keyframes toast-in {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (max-width: 1100px) {
+ #main {
+ grid-template-columns: 1fr;
+ }
+
+ #panel {
+ grid-template-rows: auto 360px;
+ border-left: none;
+ border-top: 1px solid var(--line);
+ }
+}
+
+.admin-only, .petugas-only, .write-only {
+ display: none !important;
+}
+
+body.role-admin .admin-only,
+body.role-admin .write-only {
+ display: inline-flex !important;
+}
+
+body.role-admin button.admin-only,
+body.role-admin button.write-only {
+ display: block !important;
+}
+
+body.role-petugas_lapangan .petugas-only,
+body.role-petugas_lapangan .write-only {
+ display: inline-flex !important;
+}
+
+body.role-petugas_lapangan button.petugas-only,
+body.role-petugas_lapangan button.write-only {
+ display: block !important;
+}
+
+/* Auth hide/show โ body hidden until auth confirmed, prevents flash */
+body:not(.login-body) {
+ visibility: hidden;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+}
+body.auth-loaded {
+ visibility: visible;
+ opacity: 1;
+}
+/* Login page is always visible */
+body.login-body {
+ visibility: visible !important;
+ opacity: 1 !important;
+}
+
+/* ==========================================================================
+ SIDEBAR LAYOUT STYLING
+ ========================================================================== */
+.user-profile {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.user-avatar {
+ width: 36px;
+ height: 36px;
+ background: #1e293b;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+}
+
+.user-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.user-name {
+ font-size: 13px;
+ font-weight: 700;
+ color: #fff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.user-role-badge {
+ font-size: 9px;
+ font-weight: 800;
+ letter-spacing: .04em;
+ text-transform: uppercase;
+ color: #38bdf8;
+ background: rgba(56, 189, 248, 0.1);
+ padding: 2px 6px;
+ border-radius: 999px;
+ align-self: flex-start;
+}
+
+/* Main content area โ always offset by sidebar width */
+.main-content-area {
+ margin-left: 260px;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Map page: full-screen layout (header + map filling remaining height) */
+body.page-map .main-content-area {
+ height: 100vh;
+ overflow: hidden;
+}
+
+body.page-map .main-content-area #app {
+ height: 100%;
+ display: grid;
+ grid-template-rows: 64px 1fr;
+ overflow: hidden;
+}
+
+/* Dashboard / other pages: scrollable */
+body.page-dashboard .main-content-area {
+ overflow-y: auto;
+ background: none;
+}
+
+/* Content padding for all dashboard-style pages */
+.dashboard-content {
+ padding: 32px 36px;
+ max-width: 1400px;
+}
+
+/* ==========================================================================
+ LOGIN PAGE STYLING
+ ========================================================================== */
+.login-body {
+ margin: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ background:
+ radial-gradient(circle at top left, rgba(22, 93, 255, .15), transparent 35%),
+ radial-gradient(circle at bottom right, rgba(14, 165, 233, .12), transparent 35%),
+ linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
+ color: #f8fafc;
+ overflow-y: auto;
+ font-family: 'Inter', sans-serif;
+ display: flex !important; /* Force override body display */
+}
+
+.login-container {
+ width: min(440px, 90%);
+ padding: 40px;
+ background: rgba(15, 23, 42, 0.6);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 24px;
+ backdrop-filter: blur(20px);
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
+ animation: login-fade-in 0.5s cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+@keyframes login-fade-in {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.login-header {
+ text-align: center;
+ margin-bottom: 32px;
+}
+
+.login-logo {
+ font-size: 40px;
+ margin-bottom: 12px;
+ display: inline-block;
+ animation: float 3s ease-in-out infinite;
+}
+
+@keyframes float {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-6px); }
+}
+
+.login-header h1 {
+ font-family: var(--font-head);
+ font-size: 24px;
+ font-weight: 700;
+ letter-spacing: -.03em;
+ margin: 0 0 6px;
+ color: #fff;
+}
+
+.login-header p {
+ font-size: 13px;
+ color: #94a3b8;
+ margin: 0;
+}
+
+.login-error {
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.2);
+ color: #fca5a5;
+ border-radius: 12px;
+ padding: 12px;
+ font-size: 12px;
+ font-weight: 600;
+ margin-bottom: 20px;
+ display: none;
+}
+
+.login-hint-card {
+ margin-top: 30px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ border-radius: 14px;
+ padding: 14px;
+ font-size: 11px;
+ color: #94a3b8;
+ line-height: 1.6;
+}
+
+.login-hint-title {
+ color: #38bdf8;
+ font-weight: 700;
+ margin-bottom: 6px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.login-hint-card ul {
+ margin: 0;
+ padding-left: 16px;
+}
+
+.login-hint-card li {
+ margin-bottom: 4px;
+}
+
+.login-hint-card strong {
+ color: #f1f5f9;
+}
+
+/* ==========================================================================
+ DASHBOARD PAGE STYLING
+ ========================================================================== */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 20px;
+ margin-bottom: 36px;
+}
+
+.stat-icon {
+ width: 52px;
+ height: 52px;
+ border-radius: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+ flex-shrink: 0;
+}
+
+.stat-icon.purple { background: #faf5ff; color: #7c3aed; }
+.stat-icon.green { background: #f0fdf4; color: #16a34a; }
+.stat-icon.orange { background: #fffbeb; color: #d97706; }
+.stat-icon.blue { background: #eff6ff; color: #1d4ed8; }
+.stat-icon.rose { background: #fff1f2; color: #e11d48; }
+
+.stat-details {
+ min-width: 0;
+}
+
+.stat-label {
+ font-size: 12px;
+ font-weight: 700;
+ color: #64748b;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 4px;
+}
+
+.stat-val {
+ font-family: var(--font-head);
+ font-size: 24px;
+ font-weight: 700;
+ color: #0f172a;
+ line-height: 1.2;
+}
+
+.stat-sub {
+ font-size: 11px;
+ color: #64748b;
+ margin-top: 4px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Visualization (Charts) Grid */
+.chart-card {
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 20px;
+ padding: 24px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
+}
+
+.chart-title {
+ font-family: var(--font-head);
+ font-size: 16px;
+ font-weight: 700;
+ color: #0f172a;
+ margin: 0 0 20px;
+}
+
+.chart-container {
+ position: relative;
+ height: 280px;
+ width: 100%;
+}
+
+/* Data Tables Section Grid */
+.tables-grid {
+ display: grid;
+ grid-template-columns: 1.4fr 1fr;
+ gap: 24px;
+}
+
+@media (max-width: 1024px) {
+ .tables-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.db-table-wrapper {
+ overflow-x: auto;
+ border-radius: 12px;
+ border: 1px solid #f1f5f9;
+}
+
+.db-table {
+ width: 100%;
+ border-collapse: collapse;
+ text-align: left;
+ font-size: 13px;
+}
+
+.db-table th {
+ background: #f8fafc;
+ color: #475569;
+ font-weight: 700;
+ padding: 12px 16px;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.db-table td {
+ padding: 12px 16px;
+ border-bottom: 1px solid #f1f5f9;
+ color: #334155;
+}
+
+.db-table tr:last-child td {
+ border-bottom: none;
+}
+
+.db-table tr:hover td {
+ background: #f8fafc;
+}
+
+.db-badge {
+ display: inline-flex;
+ align-items: center;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ padding: 3px 8px;
+ border-radius: 999px;
+}
+
+.db-badge.pemberdayaan { background: #eff6ff; color: #1e40af; }
+.db-badge.konsumtif { background: #fffbeb; color: #92400e; }
+.db-badge.dalam { background: #f0fdf4; color: #166534; }
+.db-badge.luar { background: #fff1f2; color: #9f1239; }
+
+/* Responsive adjustments for sidebar */
\ No newline at end of file
diff --git a/css/components.css b/css/components.css
new file mode 100644
index 0000000..86bc7fd
--- /dev/null
+++ b/css/components.css
@@ -0,0 +1,389 @@
+
+.search-box {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, .96);
+ border-radius: 999px;
+ padding: 9px 14px;
+ box-shadow: var(--shadow-soft);
+}
+
+.search-icon {
+ flex: 0 0 auto;
+ color: var(--muted-2);
+ font-size: 14px;
+ line-height: 1;
+}
+
+.search-results {
+ position: absolute;
+ top: calc(100% + 8px);
+ left: 0;
+ right: 0;
+ z-index: 5000;
+ display: none;
+ padding: 8px;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: rgba(255, 255, 255, .98);
+ box-shadow: 0 18px 40px rgba(15, 23, 42, .16);
+ backdrop-filter: blur(12px);
+ max-height: 320px;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.search-results.show {
+ display: grid;
+ gap: 10px;
+}
+
+.search-results-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 2px 4px 0;
+}
+
+.search-results-hint {
+ padding: 0 4px;
+ font-size: 11px;
+ color: var(--muted);
+}
+
+.search-results-title {
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: .08em;
+ text-transform: uppercase;
+ color: var(--muted-2);
+}
+
+.search-results-count {
+ font-size: 10px;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.search-results-list {
+ display: grid;
+ gap: 6px;
+}
+
+.search-result-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ border: 1px solid transparent;
+ background: #f8fbff;
+ border-radius: 14px;
+ padding: 10px 12px;
+ text-align: left;
+ transition: all .14s ease;
+}
+
+.search-result-item:hover,
+.search-result-item.active {
+ border-color: #cfe0ff;
+ background: #eef5ff;
+ transform: translateY(-1px);
+}
+
+.search-result-icon {
+ width: 30px;
+ height: 30px;
+ border-radius: 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: #fff;
+ border: 1px solid var(--line);
+ flex: 0 0 auto;
+}
+
+.search-result-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.search-result-name {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.search-result-sub {
+ margin-top: 2px;
+ font-size: 10px;
+ color: var(--muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.search-result-type {
+ flex: 0 0 auto;
+ font-size: 9px;
+ font-weight: 800;
+ letter-spacing: .04em;
+ text-transform: uppercase;
+ color: var(--accent);
+ background: var(--accent-soft);
+ border: 1px solid #cfe0ff;
+ border-radius: 999px;
+ padding: 4px 8px;
+}
+
+.search-empty {
+ padding: 14px 12px;
+ border: 1px dashed #cbd6e3;
+ border-radius: 14px;
+ color: var(--muted);
+ font-size: 12px;
+ text-align: center;
+ background: #fbfcfe;
+}
+
+.search-empty strong {
+ color: var(--text);
+}
+
+.btn-add {
+ width: 100%;
+ text-align: left;
+ border: 1.5px dashed #aebfda;
+ background: rgba(255, 255, 255, .92);
+ color: var(--muted);
+ border-radius: var(--radius-sm);
+ padding: 11px 12px;
+ margin-top: 6px;
+ transition: all .16s ease;
+}
+
+.btn-add:hover,
+.btn-add.active {
+ border-color: var(--accent);
+ color: var(--accent);
+ background: var(--accent-soft);
+}
+
+.badge-green {
+ background: #e6f8ec;
+ color: #15803d;
+ border-color: #b9ebc9;
+}
+
+.badge-red {
+ background: #feecec;
+ color: #dc2626;
+ border-color: #fecaca;
+}
+
+.badge-blue {
+ background: #eaf1ff;
+ color: #165dff;
+ border-color: #cfe0ff;
+}
+
+.badge-amber {
+ background: #fff4df;
+ color: #d97706;
+ border-color: #f9deb0;
+}
+
+.badge-purple {
+ background: #f1eaff;
+ color: #7c3aed;
+ border-color: #ddd2ff;
+}
+
+.modal-head {
+ padding: 18px 20px 14px;
+ border-bottom: 1px solid var(--line);
+ background: linear-gradient(180deg, #fff, #fbfdff);
+}
+
+.modal-head h3 {
+ margin: 0;
+ font-family: var(--font-head);
+ font-size: 18px;
+ letter-spacing: -.03em;
+}
+
+.form-group {
+ margin-bottom: 14px;
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.form-label {
+ display: block;
+ margin-bottom: 6px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.form-input,
+.form-select {
+ width: 100%;
+ border: 1px solid var(--line-strong);
+ background: #fff;
+ border-radius: 12px;
+ padding: 11px 12px;
+ outline: none;
+ transition: all .14s ease;
+}
+
+.form-input:focus,
+.form-select:focus {
+ border-color: #8eb3ff;
+ box-shadow: 0 0 0 4px rgba(22, 93, 255, .10);
+}
+
+.form-coord,
+.form-calc {
+ margin-bottom: 14px;
+ padding: 11px 12px;
+ background: #eef5ff;
+ color: #1140b8;
+ border: 1px solid #d7e5ff;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.btn {
+ border: 1px solid transparent;
+ border-radius: 12px;
+ padding: 10px 14px;
+ font-weight: 700;
+ font-size: 12px;
+ transition: all .16s ease;
+}
+
+.btn:hover {
+ transform: translateY(-1px);
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: #fff;
+ box-shadow: 0 12px 22px rgba(22, 93, 255, .18);
+}
+
+.btn-primary:hover {
+ background: #0d4ae0;
+}
+
+.btn-ghost {
+ background: #fff;
+ border-color: var(--line);
+ color: var(--text);
+}
+
+.btn-danger {
+ background: #ef4444;
+ color: #fff;
+}
+
+.btn-sm {
+ padding: 7px 10px;
+ border-radius: 10px;
+}
+
+.toast {
+ min-width: 240px;
+ max-width: 360px;
+ border-radius: 14px;
+ padding: 12px 14px;
+ color: #fff;
+ box-shadow: var(--shadow);
+ animation: toast-in .2s ease;
+}
+
+.toast.info {
+ background: #0f172a;
+}
+
+.toast.success {
+ background: #16a34a;
+}
+
+.toast.error {
+ background: #dc2626;
+}
+
+.btn-logout {
+ width: 100%;
+ padding: 10px 14px;
+ background: transparent;
+ border: 1px solid #334155;
+ color: #cbd5e1;
+ border-radius: 10px;
+ font-size: 12px;
+ font-weight: 600;
+ transition: all .2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ cursor: pointer;
+}
+
+.btn-logout:hover {
+ background: rgba(239, 68, 68, 0.1);
+ border-color: #ef4444;
+ color: #fca5a5;
+}
+
+/* Adjust grid for sidebar mapping page */
+.login-form .form-group {
+ margin-bottom: 20px;
+}
+
+.login-form .form-label {
+ color: #cbd5e1;
+ font-weight: 500;
+ font-size: 12px;
+ margin-bottom: 8px;
+}
+
+.login-form .form-input {
+ background: rgba(30, 41, 59, 0.7);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ color: #fff;
+ border-radius: 12px;
+ padding: 12px 14px;
+ font-size: 13px;
+ width: 100%;
+ outline: none;
+ transition: all 0.2s ease;
+}
+
+.login-form .form-input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 4px rgba(22, 93, 255, 0.25);
+ background: rgba(30, 41, 59, 0.9);
+}
+
+.btn-login {
+ width: 100%;
+ padding: 12px;
+ font-size: 14px;
+ border-radius: 12px;
+ margin-top: 10px;
+}
+
\ No newline at end of file
diff --git a/css/layout.css b/css/layout.css
new file mode 100644
index 0000000..e822233
--- /dev/null
+++ b/css/layout.css
@@ -0,0 +1,158 @@
+
+#header {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 0 18px;
+ background: rgba(255, 255, 255, .8);
+ border-bottom: 1px solid rgba(215, 224, 234, .9);
+ backdrop-filter: blur(14px);
+ box-shadow: 0 1px 0 rgba(255, 255, 255, .7) inset;
+ position: relative;
+ z-index: 4000;
+ overflow: visible;
+}
+
+.brand {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ white-space: nowrap;
+}
+
+.brand-title {
+ font-family: var(--font-head);
+ font-weight: 700;
+ letter-spacing: -.04em;
+ font-size: 15px;
+}
+
+.brand-sub {
+ font-size: 11px;
+ color: var(--muted);
+}
+
+@media (max-width: 720px) {
+ #header {
+ flex-wrap: wrap;
+ height: auto;
+ padding: 12px 14px;
+ gap: 10px;
+ }
+
+ .header-search {
+ order: 3;
+ width: 100%;
+ max-width: none;
+ flex-basis: 100%;
+ }
+
+ .header-right {
+ margin-left: 0;
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .nav-tabs {
+ width: 100%;
+ overflow: auto;
+ }
+
+ .form-row {
+ grid-template-columns: 1fr;
+ }
+
+ #panel {
+ grid-template-rows: auto minmax(320px, 48vh);
+ }
+
+ #modal {
+ width: 100%;
+ border-radius: 20px;
+ }
+}
+
+/* ==========================================================================
+ ROLE-BASED ACCESS CONTROL (RBAC) VISIBILITY
+ ========================================================================== */
+.sidebar {
+ width: 260px;
+ background: #0f172a;
+ color: #cbd5e1;
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ position: fixed;
+ left: 0;
+ top: 0;
+ z-index: 5000;
+ border-right: 1px solid #1e293b;
+ font-family: var(--font);
+}
+
+.sidebar-brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 24px 20px;
+ border-bottom: 1px solid #1e293b;
+}
+
+.brand-icon {
+ font-size: 26px;
+}
+
+.sidebar-brand .brand-title {
+ color: #fff;
+ font-family: var(--font-head);
+ font-weight: 700;
+ font-size: 16px;
+ letter-spacing: -.03em;
+ margin: 0;
+}
+
+.sidebar-brand .brand-sub {
+ font-size: 11px;
+ color: #64748b;
+}
+
+.sidebar-nav {
+ flex: 1;
+ padding: 24px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.sidebar-nav .nav-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ color: #94a3b8;
+ text-decoration: none;
+ font-size: 13px;
+ font-weight: 600;
+ border-radius: 12px;
+ transition: all .2s ease;
+}
+
+.sidebar-nav .nav-item:hover {
+ background: #1e293b;
+ color: #fff;
+}
+
+.sidebar-nav .nav-item.active {
+ background: var(--accent);
+ color: #fff;
+ box-shadow: 0 4px 12px rgba(22, 93, 255, 0.25);
+}
+
+.sidebar-footer {
+ padding: 20px;
+ border-top: 1px solid #1e293b;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
\ No newline at end of file
diff --git a/css/map.css b/css/map.css
new file mode 100644
index 0000000..3c83448
--- /dev/null
+++ b/css/map.css
@@ -0,0 +1,97 @@
+/* Map page: prevent body scroll, all space goes to map */
+body.page-map {
+ overflow: hidden;
+ height: 100vh;
+}
+
+#map-wrap {
+ position: relative;
+ min-width: 0;
+ overflow: hidden;
+}
+
+#map {
+ height: 100%;
+ width: 100%;
+}
+
+.layer-section {
+ margin-bottom: 16px;
+}
+
+.layer-section-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: .08em;
+ text-transform: uppercase;
+ color: var(--muted-2);
+ margin: 0 0 8px;
+}
+
+.layer-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 11px;
+ margin-bottom: 6px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: var(--panel-strong);
+ cursor: pointer;
+ transition: all .16s ease;
+}
+
+.layer-item:hover,
+.layer-item.checked {
+ background: linear-gradient(180deg, #f8fbff, #eef5ff);
+ border-color: #cfe0ff;
+}
+
+.layer-item.checked .layer-check {
+ color: #fff;
+ background: var(--accent);
+ border-color: var(--accent);
+}
+
+.layer-check {
+ width: 18px;
+ height: 18px;
+ border-radius: 5px;
+ border: 1.5px solid #b8c5d4;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ font-size: 11px;
+ color: transparent;
+ background: #fff;
+}
+
+.layer-dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 999px;
+ flex: 0 0 auto;
+}
+
+.layer-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text);
+ flex: 1;
+ min-width: 0;
+}
+
+.layer-count {
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--muted);
+ background: #f4f7fb;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ padding: 3px 8px;
+}
+
\ No newline at end of file
diff --git a/css/pages/daftar_pm.css b/css/pages/daftar_pm.css
new file mode 100644
index 0000000..63e14d7
--- /dev/null
+++ b/css/pages/daftar_pm.css
@@ -0,0 +1,566 @@
+
+ /* ==================== PAGE-SPECIFIC STYLES ==================== */
+ .page-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 28px;
+ gap: 16px;
+ flex-wrap: wrap;
+ }
+
+ .page-title-group h1 {
+ font-family: var(--font-head);
+ font-size: 26px;
+ font-weight: 800;
+ letter-spacing: -.04em;
+ margin: 0 0 4px;
+ color: #0f172a;
+ }
+
+ .page-title-group p {
+ margin: 0;
+ font-size: 13px;
+ color: #64748b;
+ }
+
+ .page-actions {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ }
+
+ /* Summary stats at top */
+ .summary-bar {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 14px;
+ margin-bottom: 28px;
+ }
+
+ .summary-item {
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 14px;
+ padding: 16px 18px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .summary-icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ flex-shrink: 0;
+ }
+
+ .summary-icon.purple {
+ background: #faf5ff;
+ }
+
+ .summary-icon.green {
+ background: #f0fdf4;
+ }
+
+ .summary-icon.red {
+ background: #fff1f2;
+ }
+
+ .summary-icon.blue {
+ background: #eff6ff;
+ }
+
+ .summary-val {
+ font-family: var(--font-head);
+ font-size: 20px;
+ font-weight: 700;
+ color: #0f172a;
+ line-height: 1;
+ }
+
+ .summary-label {
+ font-size: 11px;
+ color: #64748b;
+ font-weight: 600;
+ margin-top: 3px;
+ }
+
+ /* Filter bar */
+ .filter-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 14px;
+ padding: 12px 16px;
+ }
+
+ .filter-search {
+ flex: 1;
+ min-width: 200px;
+ position: relative;
+ }
+
+ .filter-search input {
+ width: 100%;
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ padding: 9px 12px 9px 36px;
+ font-size: 13px;
+ outline: none;
+ font-family: var(--font);
+ color: #0f172a;
+ transition: all .15s;
+ }
+
+ .filter-search input:focus {
+ border-color: #8eb3ff;
+ box-shadow: 0 0 0 3px rgba(22, 93, 255, .08);
+ }
+
+ .filter-search-icon {
+ position: absolute;
+ left: 11px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: #94a3b8;
+ font-size: 14px;
+ pointer-events: none;
+ }
+
+ .filter-select {
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ padding: 9px 12px;
+ font-size: 12px;
+ outline: none;
+ background: #fff;
+ cursor: pointer;
+ font-family: var(--font);
+ color: #475569;
+ transition: all .15s;
+ }
+
+ .filter-select:focus {
+ border-color: #8eb3ff;
+ }
+
+ .filter-count {
+ font-size: 12px;
+ font-weight: 600;
+ color: #64748b;
+ white-space: nowrap;
+ margin-left: auto;
+ }
+
+ /* Table container */
+ .table-container {
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 18px;
+ overflow: hidden;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, .03);
+ }
+
+ .table-scrollwrap {
+ overflow-x: auto;
+ }
+
+ .pm-table {
+ width: 100%;
+ border-collapse: collapse;
+ text-align: left;
+ font-size: 13px;
+ min-width: 780px;
+ }
+
+ .pm-table thead th {
+ background: #f8fafc;
+ color: #475569;
+ font-weight: 700;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: .05em;
+ padding: 12px 16px;
+ border-bottom: 1px solid #e2e8f0;
+ white-space: nowrap;
+ cursor: pointer;
+ user-select: none;
+ transition: background .1s;
+ }
+
+ .pm-table thead th:hover {
+ background: #f1f5f9;
+ }
+
+ .pm-table thead th .sort-icon {
+ margin-left: 4px;
+ opacity: 0.4;
+ }
+
+ .pm-table thead th.sort-asc .sort-icon::after {
+ content: ' โ';
+ opacity: 1;
+ }
+
+ .pm-table thead th.sort-desc .sort-icon::after {
+ content: ' โ';
+ opacity: 1;
+ }
+
+ .pm-table tbody tr {
+ transition: background .1s;
+ cursor: pointer;
+ }
+
+ .pm-table tbody tr:hover td {
+ background: #f8fafc;
+ }
+
+ .pm-table tbody td {
+ padding: 12px 16px;
+ border-bottom: 1px solid #f8fafc;
+ color: #334155;
+ vertical-align: middle;
+ }
+
+ .pm-table tbody tr:last-child td {
+ border-bottom: none;
+ }
+
+ /* Name cell */
+ .cell-name {
+ font-weight: 700;
+ color: #0f172a;
+ }
+
+ .cell-sub {
+ font-size: 11px;
+ color: #94a3b8;
+ margin-top: 2px;
+ }
+
+ /* Badges */
+ .badge {
+ display: inline-flex;
+ align-items: center;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .04em;
+ padding: 3px 8px;
+ border-radius: 999px;
+ white-space: nowrap;
+ }
+
+ .badge-dalam {
+ background: #f0fdf4;
+ color: #166534;
+ border: 1px solid #bbf7d0;
+ }
+
+ .badge-luar {
+ background: #fff1f2;
+ color: #9f1239;
+ border: 1px solid #fecdd3;
+ }
+
+ .badge-pemberdayaan {
+ background: #eff6ff;
+ color: #1e40af;
+ border: 1px solid #bfdbfe;
+ }
+
+ .badge-konsumtif {
+ background: #fffbeb;
+ color: #92400e;
+ border: 1px solid #fde68a;
+ }
+
+ /* Aid count display */
+ .aid-pills {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+ }
+
+ /* Pagination */
+ .pagination {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 20px;
+ border-top: 1px solid #f1f5f9;
+ font-size: 12px;
+ color: #64748b;
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+
+ .pagination-info {
+ font-weight: 500;
+ }
+
+ .pagination-btns {
+ display: flex;
+ gap: 4px;
+ }
+
+ .pg-btn {
+ width: 32px;
+ height: 32px;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ background: #fff;
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 600;
+ color: #475569;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all .15s;
+ }
+
+ .pg-btn:hover:not(:disabled) {
+ border-color: var(--accent);
+ color: var(--accent);
+ }
+
+ .pg-btn.active {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+ }
+
+ .pg-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+
+ /* Empty state */
+ .empty-state {
+ text-align: center;
+ padding: 60px 24px;
+ color: #94a3b8;
+ }
+
+ .empty-state-icon {
+ font-size: 48px;
+ margin-bottom: 16px;
+ }
+
+ .empty-state h3 {
+ font-family: var(--font-head);
+ color: #475569;
+ margin: 0 0 8px;
+ }
+
+ .empty-state p {
+ font-size: 13px;
+ margin: 0;
+ }
+
+ /* Detail drawer */
+ .detail-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 23, 42, .45);
+ z-index: 7000;
+ display: none;
+ backdrop-filter: blur(4px);
+ }
+
+ .detail-overlay.open {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ }
+
+ .detail-drawer {
+ width: min(520px, 100%);
+ height: 100vh;
+ background: #fff;
+ overflow-y: auto;
+ box-shadow: -24px 0 60px rgba(15, 23, 42, .18);
+ animation: drawer-in .25s cubic-bezier(0.4, 0, 0.2, 1);
+ }
+
+ @keyframes drawer-in {
+ from {
+ transform: translateX(40px);
+ opacity: 0;
+ }
+
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+ }
+
+ .drawer-head {
+ padding: 24px;
+ border-bottom: 1px solid #f1f5f9;
+ position: sticky;
+ top: 0;
+ background: #fff;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ }
+
+ .drawer-head h2 {
+ font-family: var(--font-head);
+ font-size: 18px;
+ font-weight: 700;
+ margin: 0;
+ color: #0f172a;
+ }
+
+ .drawer-close {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ border: 1px solid #e2e8f0;
+ background: #fff;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ transition: all .15s;
+ }
+
+ .drawer-close:hover {
+ background: #f1f5f9;
+ }
+
+ .drawer-body {
+ padding: 24px;
+ }
+
+ .section-heading {
+ font-size: 11px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: .08em;
+ color: #94a3b8;
+ margin: 24px 0 12px;
+ }
+
+ .info-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 0;
+ border-bottom: 1px solid #f8fafc;
+ font-size: 13px;
+ }
+
+ .info-row:last-child {
+ border-bottom: none;
+ }
+
+ .info-label {
+ color: #64748b;
+ }
+
+ .info-val {
+ font-weight: 600;
+ color: #0f172a;
+ text-align: right;
+ max-width: 60%;
+ }
+
+ /* Proximity indicator */
+ .proximity-card {
+ padding: 14px 16px;
+ border-radius: 14px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 16px;
+ font-size: 13px;
+ font-weight: 600;
+ }
+
+ .proximity-card.dalam {
+ background: #f0fdf4;
+ color: #166534;
+ }
+
+ .proximity-card.luar {
+ background: #fff1f2;
+ color: #9f1239;
+ }
+
+ /* Log list in drawer */
+ .log-list {
+ display: grid;
+ gap: 8px;
+ }
+
+ .log-entry {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ background: #f8fafc;
+ border: 1px solid #f1f5f9;
+ border-radius: 12px;
+ }
+
+ .log-type-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+
+ .log-type-dot.pem {
+ background: #2563eb;
+ }
+
+ .log-type-dot.kon {
+ background: #f59e0b;
+ }
+
+ .log-entry-info {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .log-entry-name {
+ font-size: 12px;
+ font-weight: 700;
+ color: #0f172a;
+ }
+
+ .log-entry-detail {
+ font-size: 11px;
+ color: #64748b;
+ margin-top: 2px;
+ }
+
+ .log-entry-date {
+ font-size: 10px;
+ color: #94a3b8;
+ white-space: nowrap;
+ }
+
+ .loading-spinner-wrap {
+ text-align: center;
+ padding: 60px;
+ color: #94a3b8;
+ }
+
\ No newline at end of file
diff --git a/css/pages/dashboard.css b/css/pages/dashboard.css
new file mode 100644
index 0000000..7953ea0
--- /dev/null
+++ b/css/pages/dashboard.css
@@ -0,0 +1,149 @@
+/* Dashboard wrapper โ fills the main-content-area, scrollable */
+.dashboard-wrapper {
+ flex: 1;
+ overflow-y: auto;
+ background: #f8fafc;
+ min-height: 100vh;
+}
+
+.dashboard-content {
+ padding: 40px;
+ max-width: 1400px;
+ margin: 0 auto;
+}
+
+.dashboard-header {
+ margin-bottom: 36px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.dashboard-title h1 {
+ font-family: var(--font-head);
+ font-size: 28px;
+ font-weight: 800;
+ letter-spacing: -.04em;
+ margin: 0 0 6px;
+ color: #0f172a;
+}
+
+.dashboard-title p {
+ color: #64748b;
+ font-size: 14px;
+ margin: 0;
+}
+
+.dashboard-date {
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ padding: 10px 16px;
+ font-size: 12px;
+ font-weight: 600;
+ color: #475569;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
+}
+
+/* KPI Stat Cards Grid */
+.stat-card {
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 20px;
+ padding: 24px;
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
+}
+
+.stat-card:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 12px 24px rgba(15, 23, 42, 0.06);
+ border-color: #cbd5e1;
+}
+
+.charts-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 24px;
+ margin-bottom: 36px;
+}
+
+@media (max-width: 1024px) {
+ .charts-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.tables-grid {
+ display: grid;
+ grid-template-columns: 1.4fr 1fr;
+ gap: 24px;
+}
+
+@media (max-width: 1024px) {
+ .tables-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.table-card {
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 20px;
+ padding: 24px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
+}
+
+.table-title {
+ font-family: var(--font-head);
+ font-size: 16px;
+ font-weight: 700;
+ color: #0f172a;
+ margin: 0 0 20px;
+}
+
+/* Page header for list pages (manajemen_akun, daftar_pm) */
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 28px;
+}
+
+.page-title-group h1 {
+ font-family: var(--font-head);
+ font-size: 24px;
+ font-weight: 800;
+ letter-spacing: -.04em;
+ margin: 0 0 6px;
+ color: #0f172a;
+}
+
+.page-title-group p {
+ color: #64748b;
+ font-size: 14px;
+ margin: 0;
+}
+
+.page-actions {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+@media (max-width: 768px) {
+ .main-content-area {
+ margin-left: 0;
+ }
+ .sidebar {
+ transform: translateX(-100%);
+ transition: transform 0.3s ease;
+ }
+ .dashboard-content {
+ padding: 20px;
+ }
+}
\ No newline at end of file
diff --git a/css/pages/manajemen_akun.css b/css/pages/manajemen_akun.css
new file mode 100644
index 0000000..d1a771e
--- /dev/null
+++ b/css/pages/manajemen_akun.css
@@ -0,0 +1,434 @@
+
+ /* ==================== PAGE-SPECIFIC STYLES ==================== */
+ .page-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 32px;
+ gap: 16px;
+ flex-wrap: wrap;
+ }
+ .page-title-group h1 {
+ font-family: var(--font-head);
+ font-size: 26px;
+ font-weight: 800;
+ letter-spacing: -.04em;
+ margin: 0 0 4px;
+ color: #0f172a;
+ }
+ .page-title-group p {
+ margin: 0;
+ font-size: 13px;
+ color: #64748b;
+ }
+ .page-actions { display: flex; gap: 10px; flex-wrap: wrap; }
+
+ /* RI Card Grid */
+ .ri-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
+ gap: 20px;
+ margin-bottom: 40px;
+ }
+ .ri-card {
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 20px;
+ padding: 0;
+ overflow: hidden;
+ box-shadow: 0 1px 4px rgba(0,0,0,.04);
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+ cursor: pointer;
+ position: relative;
+ }
+ .ri-card:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 16px 36px rgba(15,23,42,.10);
+ border-color: #cbd5e1;
+ }
+ .ri-card-header {
+ padding: 20px 20px 0;
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ }
+ .ri-avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: 14px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 22px;
+ flex-shrink: 0;
+ background: linear-gradient(135deg, #fef3c7, #fde68a);
+ }
+ .ri-card-meta { flex: 1; min-width: 0; }
+ .ri-card-name {
+ font-family: var(--font-head);
+ font-size: 16px;
+ font-weight: 700;
+ color: #0f172a;
+ margin: 0 0 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .ri-card-type {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .05em;
+ color: #f59e0b;
+ background: #fffbeb;
+ border: 1px solid #fef3c7;
+ border-radius: 999px;
+ padding: 2px 8px;
+ display: inline-block;
+ }
+
+ /* Account status badge */
+ .ri-account-status {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .04em;
+ padding: 3px 8px;
+ border-radius: 999px;
+ }
+ .ri-account-status.active {
+ background: #f0fdf4;
+ color: #15803d;
+ border: 1px solid #bbf7d0;
+ }
+ .ri-account-status.inactive {
+ background: #f8fafc;
+ color: #94a3b8;
+ border: 1px solid #e2e8f0;
+ }
+
+ /* Stats row in card */
+ .ri-card-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0;
+ margin: 16px 20px 0;
+ background: #f8fafc;
+ border-radius: 14px;
+ overflow: hidden;
+ border: 1px solid #f1f5f9;
+ }
+ .stat-mini {
+ padding: 12px 8px;
+ text-align: center;
+ border-right: 1px solid #f1f5f9;
+ }
+ .stat-mini:last-child { border-right: none; }
+ .stat-mini-val {
+ font-family: var(--font-head);
+ font-size: 18px;
+ font-weight: 700;
+ color: #0f172a;
+ line-height: 1;
+ }
+ .stat-mini-label {
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .04em;
+ color: #94a3b8;
+ margin-top: 4px;
+ }
+
+ /* Account info row */
+ .ri-card-account {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 12px 20px 0;
+ padding: 10px 12px;
+ background: #f8fafc;
+ border-radius: 10px;
+ font-size: 12px;
+ color: #475569;
+ }
+ .ri-card-account .username {
+ font-weight: 700;
+ color: #1e293b;
+ font-family: 'ui-monospace', monospace;
+ font-size: 11px;
+ }
+ .ri-card-account .no-account {
+ color: #94a3b8;
+ font-style: italic;
+ }
+
+ /* Card footer */
+ .ri-card-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 12px 20px 20px;
+ }
+ .ri-card-alamat {
+ font-size: 11px;
+ color: #94a3b8;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .ri-card-actions { display: flex; gap: 6px; flex-shrink: 0; }
+ .btn-icon {
+ width: 32px;
+ height: 32px;
+ border-radius: 9px;
+ border: 1px solid #e2e8f0;
+ background: #fff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ cursor: pointer;
+ transition: all .15s ease;
+ }
+ .btn-icon:hover { border-color: var(--accent); background: var(--accent-soft); }
+ .btn-icon.danger:hover { border-color: #ef4444; background: #fef2f2; }
+
+ /* Empty state */
+ .empty-state {
+ text-align: center;
+ padding: 60px 24px;
+ color: #94a3b8;
+ grid-column: 1 / -1;
+ }
+ .empty-state-icon { font-size: 48px; margin-bottom: 16px; }
+ .empty-state h3 { font-family: var(--font-head); color: #475569; margin: 0 0 8px; }
+ .empty-state p { font-size: 13px; margin: 0; }
+
+ /* Search & filter bar */
+ .filter-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 24px;
+ flex-wrap: wrap;
+ }
+ .filter-search {
+ flex: 1;
+ min-width: 220px;
+ position: relative;
+ }
+ .filter-search input {
+ width: 100%;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ padding: 10px 14px 10px 38px;
+ font-size: 13px;
+ outline: none;
+ background: #fff;
+ transition: all .15s ease;
+ font-family: var(--font);
+ color: #0f172a;
+ }
+ .filter-search input:focus { border-color: #8eb3ff; box-shadow: 0 0 0 4px rgba(22,93,255,.08); }
+ .filter-search-icon {
+ position: absolute;
+ left: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: #94a3b8;
+ font-size: 15px;
+ pointer-events: none;
+ }
+ .filter-select {
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ padding: 10px 14px;
+ font-size: 13px;
+ outline: none;
+ background: #fff;
+ cursor: pointer;
+ font-family: var(--font);
+ color: #0f172a;
+ }
+ .filter-select:focus { border-color: #8eb3ff; }
+ .filter-count {
+ font-size: 12px;
+ font-weight: 600;
+ color: #64748b;
+ white-space: nowrap;
+ }
+
+ /* Detail panel (drawer) */
+ .detail-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(15,23,42,.45);
+ z-index: 7000;
+ display: none;
+ backdrop-filter: blur(4px);
+ }
+ .detail-overlay.open { display: flex; align-items: center; justify-content: flex-end; }
+ .detail-drawer {
+ width: min(520px, 100%);
+ height: 100vh;
+ background: #fff;
+ overflow-y: auto;
+ box-shadow: -24px 0 60px rgba(15,23,42,.18);
+ animation: drawer-in .25s cubic-bezier(0.4, 0, 0.2, 1);
+ }
+ @keyframes drawer-in {
+ from { transform: translateX(40px); opacity: 0; }
+ to { transform: translateX(0); opacity: 1; }
+ }
+ .drawer-head {
+ padding: 24px;
+ border-bottom: 1px solid #f1f5f9;
+ position: sticky;
+ top: 0;
+ background: #fff;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ }
+ .drawer-head h2 {
+ font-family: var(--font-head);
+ font-size: 18px;
+ font-weight: 700;
+ margin: 0;
+ color: #0f172a;
+ }
+ .drawer-close {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ border: 1px solid #e2e8f0;
+ background: #fff;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ transition: all .15s;
+ }
+ .drawer-close:hover { background: #f1f5f9; }
+ .drawer-body { padding: 24px; }
+
+ /* KPI row in drawer */
+ .kpi-row {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+ margin-bottom: 24px;
+ }
+ .kpi-box {
+ background: #f8fafc;
+ border: 1px solid #f1f5f9;
+ border-radius: 14px;
+ padding: 16px;
+ text-align: center;
+ }
+ .kpi-val {
+ font-family: var(--font-head);
+ font-size: 22px;
+ font-weight: 700;
+ color: #0f172a;
+ }
+ .kpi-label { font-size: 11px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: .04em; margin-top: 4px; }
+ .kpi-val.accent { color: var(--accent); }
+ .kpi-val.green { color: #15803d; }
+
+ /* Section heading */
+ .section-heading {
+ font-size: 11px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: .08em;
+ color: #94a3b8;
+ margin: 24px 0 12px;
+ }
+
+ /* Info row */
+ .info-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 0;
+ border-bottom: 1px solid #f8fafc;
+ font-size: 13px;
+ }
+ .info-row:last-child { border-bottom: none; }
+ .info-label { color: #64748b; }
+ .info-val { font-weight: 600; color: #0f172a; text-align: right; }
+
+ /* Log list in drawer */
+ .log-list { display: grid; gap: 8px; }
+ .log-entry {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ background: #f8fafc;
+ border: 1px solid #f1f5f9;
+ border-radius: 12px;
+ }
+ .log-type-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+ .log-type-dot.pem { background: #2563eb; }
+ .log-type-dot.kon { background: #f59e0b; }
+ .log-entry-info { flex: 1; min-width: 0; }
+ .log-entry-name { font-size: 12px; font-weight: 700; color: #0f172a; }
+ .log-entry-detail { font-size: 11px; color: #64748b; margin-top: 2px; }
+ .log-entry-date { font-size: 10px; color: #94a3b8; white-space: nowrap; }
+
+ /* Password reset form */
+ .reset-form {
+ display: flex;
+ gap: 8px;
+ align-items: flex-end;
+ margin-top: 8px;
+ }
+ .reset-form input {
+ flex: 1;
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ padding: 9px 12px;
+ font-size: 12px;
+ outline: none;
+ font-family: var(--font);
+ transition: all .14s;
+ }
+ .reset-form input:focus { border-color: #8eb3ff; box-shadow: 0 0 0 3px rgba(22,93,255,.08); }
+
+ /* Btn variants */
+ .btn-sm-outline {
+ padding: 7px 12px;
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ background: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ color: #475569;
+ cursor: pointer;
+ transition: all .15s;
+ }
+ .btn-sm-outline:hover { border-color: var(--accent); color: var(--accent); }
+
+ .loading-spinner-wrap { text-align: center; padding: 60px; color: #94a3b8; }
+
+ /* Modal for password reset */
+ .modal-sm {
+ width: min(420px, 100%);
+ }
+
\ No newline at end of file
diff --git a/database.sql b/database.sql
index ed87ba9..a07799d 100644
--- a/database.sql
+++ b/database.sql
@@ -45,37 +45,15 @@ CREATE TABLE `log_bantuan` (
FOREIGN KEY (`rumah_ibadah_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-CREATE TABLE `data_jalan` (
+
+CREATE TABLE `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
- `nama_jalan` varchar(255) NOT NULL,
- `status_jalan` varchar(100) NOT NULL,
- `panjang_meter` double NOT NULL,
- `keterangan` text,
- `geojson` longtext NOT NULL,
+ `username` varchar(50) NOT NULL UNIQUE,
+ `password` varchar(255) NOT NULL,
+ `role` enum('admin', 'petugas_lapangan', 'guest') NOT NULL,
+ `rumah_ibadah_id` int(11) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (`id`)
+ PRIMARY KEY (`id`),
+ FOREIGN KEY (`rumah_ibadah_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-CREATE TABLE `data_parsil` (
- `id` int(11) NOT NULL AUTO_INCREMENT,
- `nama_pemilik` varchar(255) NOT NULL,
- `no_sertifikat` varchar(100),
- `jenis_hak` varchar(100) NOT NULL,
- `luas_m2` double NOT NULL,
- `keterangan` text,
- `geojson` longtext NOT NULL,
- `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (`id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-
-CREATE TABLE `spbu` (
- `id` int(11) NOT NULL AUTO_INCREMENT,
- `nama` varchar(255) NOT NULL,
- `no_wa` varchar(20),
- `alamat` text,
- `buka_24jam` tinyint(1) NOT NULL DEFAULT 0,
- `latitude` double NOT NULL,
- `longitude` double NOT NULL,
- `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (`id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/index.html b/index.html
deleted file mode 100644
index 4d5bbaa..0000000
--- a/index.html
+++ /dev/null
@@ -1,91 +0,0 @@
-
-
-
-
-
-WebGIS - Poverty Mapping & Spatial Management
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..120f3b7
--- /dev/null
+++ b/index.php
@@ -0,0 +1,111 @@
+ 'Peta Interaktif - WebGIS Poverty Mapping',
+ 'dashboard' => 'Dashboard - WebGIS Poverty Mapping',
+ 'manajemen_akun' => 'Manajemen Akun RI - WebGIS Poverty Mapping',
+ 'daftar_pm' => 'Daftar Penduduk Miskin - WebGIS Poverty Mapping'
+];
+$pageTitle = $pageTitles[$page];
+
+// Tentukan CSS spesifik halaman
+$pageCss = null;
+if ($page === 'map') $pageCss = 'css/map.css';
+if ($page === 'dashboard') $pageCss = 'css/pages/dashboard.css';
+if ($page === 'manajemen_akun') $pageCss = 'css/pages/manajemen_akun.css';
+if ($page === 'daftar_pm') $pageCss = 'css/pages/daftar_pm.css';
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/app-core.js b/js/app-core.js
index 89d960b..b689a81 100644
--- a/js/app-core.js
+++ b/js/app-core.js
@@ -1,84 +1,22 @@
'use strict';
const API_BASE = 'api';
-const CENTER = [-0.0263, 109.3425];
-const map = L.map('map', { zoomControl: false }).setView(CENTER, 13);
-L.control.zoom({ position: 'topleft' }).addTo(map);
+// โโโ Map globals (only set on map page) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+let map = null;
+let layers = null;
+let layerVisible = null;
-const baseLayers = {
- 'OSM Standard': L.tileLayer(
- 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
- { attribution: 'ยฉ OpenStreetMap', maxZoom: 19 }
- ),
- 'CartoDB Light': L.tileLayer(
- 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
- { attribution: 'ยฉ CartoDB', maxZoom: 19 }
- ),
- 'Satellite (Esri)': L.tileLayer(
- 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
- { attribution: 'ยฉ Esri', maxZoom: 19 }
- )
-};
-baseLayers['OSM Standard'].addTo(map);
-L.control.layers(baseLayers, {}, { position: 'bottomright', collapsed: true }).addTo(map);
-
-const layers = {
- rumahIbadah: L.layerGroup().addTo(map),
- ibadahRadius: L.layerGroup().addTo(map),
- pendudukMiskin: L.layerGroup().addTo(map),
- spbu24: L.layerGroup().addTo(map),
- spbuNon24: L.layerGroup().addTo(map),
- jalanNasional: L.layerGroup().addTo(map),
- jalanProvinsi: L.layerGroup().addTo(map),
- jalanKabupaten: L.layerGroup().addTo(map),
- parsilSHM: L.layerGroup().addTo(map),
- parsilHGB: L.layerGroup().addTo(map),
- parsilHGU: L.layerGroup().addTo(map),
- parsilHP: L.layerGroup().addTo(map)
-};
-
-const layerVisible = {
- rumahIbadah: true, ibadahRadius: true, pendudukMiskin: true,
- spbu24: true, spbuNon24: true,
- jalanNasional: true, jalanProvinsi: true, jalanKabupaten: true,
- parsilSHM: true, parsilHGB: true, parsilHGU: true, parsilHP: true
-};
-
-const searchEntries = [];
-let searchActiveIndex = -1;
-const searchPointZoom = 15;
-const searchIntentPrefix = {
- jalan: 'jalan',
- spbu: 'spbu',
- rumahibadah: 'ri',
- pendudukmiskin: 'pm',
- parsil: 'parsil'
-};
-
-const drawnItems = new L.FeatureGroup().addTo(map);
-const drawControl = new L.Control.Draw({
- draw: {
- polyline: { shapeOptions: { color: '#2563eb', weight: 3 } },
- polygon: { shapeOptions: { color: '#7c3aed', weight: 2, fillOpacity: 0.15 } },
- rectangle: false, circle: false, circlemarker: false, marker: false
- },
- edit: { featureGroup: drawnItems, remove: false }
-});
-
-let pendingGeoJSON = null;
-let pendingLength = null;
-let pendingArea = null;
-let currentTab = 'kemiskinan';
-let geometryEditState = null;
-let _mapClickCb = null;
+// โโโ Utilities โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function toast(msg, type = 'info') {
+ const container = document.getElementById('toast-container');
+ if (!container) return;
const t = document.createElement('div');
t.className = `toast ${type}`;
t.textContent = msg;
- document.getElementById('toast-container').appendChild(t);
- setTimeout(() => t.remove(), 3000);
+ container.appendChild(t);
+ setTimeout(() => t.remove(), 3500);
}
function openModal(title, bodyHTML, footerHTML) {
@@ -92,54 +30,62 @@ function closeModal() {
document.getElementById('modal-overlay').classList.remove('open');
}
-document.getElementById('modal-overlay').addEventListener('click', e => {
- if (e.target.id === 'modal-overlay') closeModal();
-});
+const _modalOverlay = document.getElementById('modal-overlay');
+if (_modalOverlay) {
+ _modalOverlay.addEventListener('click', e => {
+ if (e.target.id === 'modal-overlay') closeModal();
+ });
+}
async function callAPI(ep, method = 'GET', body = null) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
try {
- return await (await fetch(`${API_BASE}/${ep}`, opts)).json();
+ const res = await fetch(`${API_BASE}/${ep}`, opts);
+ if (res.status === 401) {
+ window.location.href = '?page=login';
+ return { status: 'error', message: 'Unauthorized' };
+ }
+ return await res.json();
} catch (err) {
return { status: 'error', message: 'Koneksi gagal' };
}
}
-function showInstruction(msg) {
- const el = document.getElementById('instruction-banner');
- el.textContent = '๐ฑ ' + msg;
- el.classList.add('show');
-}
-
-function hideInstruction() {
- document.getElementById('instruction-banner').classList.remove('show');
-}
-
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
+function showInstruction(msg) {
+ const el = document.getElementById('instruction-banner');
+ if (el) { el.textContent = '๐ฑ ' + msg; el.classList.add('show'); }
+}
+
+function hideInstruction() {
+ const el = document.getElementById('instruction-banner');
+ if (el) el.classList.remove('show');
+}
+
+// โโโ Search โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+const searchEntries = [];
+let searchActiveIndex = -1;
+const searchPointZoom = 15;
+const searchIntentPrefix = { rumahibadah: 'ri', pendudukmiskin: 'pm' };
+
function normalizeSearchText(text) {
- return String(text || '')
- .toLowerCase()
- .normalize('NFKD')
- .replace(/[\u0300-\u036f]/g, '')
- .replace(/[^a-z0-9\s]/g, ' ')
- .replace(/\s+/g, ' ')
- .trim();
+ return String(text || '').toLowerCase().normalize('NFKD')
+ .replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9\s]/g, ' ')
+ .replace(/\s+/g, ' ').trim();
}
function getSearchIntent(query) {
const q = normalizeSearchText(query);
if (!q) return null;
const aliases = [
- { key: 'jalan', terms: ['jalan', 'road', 'street'] },
- { key: 'spbu', terms: ['spbu', 'pom', 'bensin', 'bbm'] },
{ key: 'rumahibadah', terms: ['masjid', 'gereja', 'pura', 'vihara', 'klenteng', 'ibadah'] },
- { key: 'pendudukmiskin', terms: ['miskin', 'penduduk', 'warga', 'keluarga'] },
- { key: 'parsil', terms: ['parsil', 'tanah', 'sertifikat', 'bidang', 'lahan'] }
+ { key: 'pendudukmiskin', terms: ['miskin', 'penduduk', 'warga', 'keluarga'] }
];
for (const alias of aliases) {
if (alias.terms.some(term => q.includes(term))) return alias.key;
@@ -167,36 +113,25 @@ function getSearchMatches(query) {
const pool = prefix
? searchEntries.filter(entry => entry.key.startsWith(prefix + ':'))
: searchEntries;
-
- return pool
- .map(entry => {
- let score = 0;
- const entryLabel = normalizeSearchText(entry.label);
- const entryType = normalizeSearchText(entry.typeLabel);
- const entryText = entry.searchText;
- if (entry.searchText === q) score += 120;
- if (entry.searchText.startsWith(q)) score += 80;
- if (entryLabel.startsWith(q)) score += 90;
- if (entryType.startsWith(q)) score += 60;
- if (entry.searchText.includes(q)) score += 50;
- const tokenHits = tokens.filter(token => entryText.includes(token) || entryLabel.includes(token)).length;
- score += tokenHits * 12;
- if (tokens.every(token => entryText.includes(token) || entryLabel.includes(token))) score += 40;
- if (tokens[0] && (entryText.split(' ').some(word => word.startsWith(tokens[0])) || entryLabel.split(' ').some(word => word.startsWith(tokens[0])))) score += 15;
- if (intent) {
- if (entry.key.startsWith(intent + ':')) score += 120;
- if (entryType.includes(intent)) score += 110;
- if (intent === 'jalan' && entryLabel.startsWith('jalan')) score += 40;
- if (intent === 'rumahibadah' && /(masjid|gereja|pura|vihara|klenteng)/.test(entryText)) score += 35;
- if (intent === 'spbu' && /(spbu|pom|bensin|bbm)/.test(entryText)) score += 35;
- if (intent === 'pendudukmiskin' && /(miskin|penduduk|warga)/.test(entryText)) score += 35;
- if (intent === 'parsil' && /(parsil|tanah|sertifikat|bidang)/.test(entryText)) score += 35;
- }
- return { ...entry, score };
- })
- .filter(entry => entry.score > 0)
- .sort((a, b) => b.score - a.score || a.label.localeCompare(b.label))
- .slice(0, 10);
+ return pool.map(entry => {
+ let score = 0;
+ const entryLabel = normalizeSearchText(entry.label);
+ const entryType = normalizeSearchText(entry.typeLabel);
+ const entryText = entry.searchText;
+ if (entry.searchText === q) score += 120;
+ if (entry.searchText.startsWith(q)) score += 80;
+ if (entryLabel.startsWith(q)) score += 90;
+ if (entryType.startsWith(q)) score += 60;
+ if (entry.searchText.includes(q)) score += 50;
+ const tokenHits = tokens.filter(token => entryText.includes(token) || entryLabel.includes(token)).length;
+ score += tokenHits * 12;
+ if (tokens.every(token => entryText.includes(token) || entryLabel.includes(token))) score += 40;
+ if (intent) {
+ if (entry.key.startsWith(intent + ':')) score += 120;
+ if (entryType.includes(intent)) score += 110;
+ }
+ return { ...entry, score };
+ }).filter(e => e.score > 0).sort((a, b) => b.score - a.score || a.label.localeCompare(b.label)).slice(0, 10);
}
function renderSearchResults(query) {
@@ -209,31 +144,26 @@ function renderSearchResults(query) {
panel.innerHTML = '';
return;
}
-
if (searchActiveIndex < 0 || searchActiveIndex >= matches.length) searchActiveIndex = 0;
-
panel.innerHTML = `
-
-
- ${matches.map((item, index) => `
-
- ${item.icon}
-
-
${esc(item.label)}
-
${esc(item.subtitle)}
-
- ${esc(item.typeLabel)}
- `).join('')}
-
`;
+
+
+ ${matches.map((item, index) => `
+
+ ${item.icon}
+
+
${esc(item.label)}
+
${esc(item.subtitle)}
+
+ ${esc(item.typeLabel)}
+ `).join('')}
+
`;
panel.classList.add('show');
}
function closeSearchResults() {
const panel = document.getElementById('search-results');
- if (!panel) return;
- panel.classList.remove('show');
+ if (panel) panel.classList.remove('show');
}
function selectSearchResult(item) {
@@ -248,34 +178,15 @@ function initSearchBar() {
const panel = document.getElementById('search-results');
const wrapper = document.getElementById('header-search');
if (!input || !panel || !wrapper) return;
-
- input.addEventListener('input', () => {
- searchActiveIndex = 0;
- renderSearchResults(input.value);
- });
+ input.addEventListener('input', () => { searchActiveIndex = 0; renderSearchResults(input.value); });
input.addEventListener('focus', () => renderSearchResults(input.value));
input.addEventListener('keydown', e => {
const matches = getSearchMatches(input.value);
- if (e.key === 'Enter') {
- e.preventDefault();
- const active = matches[searchActiveIndex] || matches[0];
- if (active) selectSearchResult(active);
- return;
- }
+ if (e.key === 'Enter') { e.preventDefault(); const active = matches[searchActiveIndex] || matches[0]; if (active) selectSearchResult(active); return; }
if (!matches.length) return;
- if (e.key === 'ArrowDown') {
- e.preventDefault();
- searchActiveIndex = Math.min(searchActiveIndex + 1, matches.length - 1);
- renderSearchResults(input.value);
- return;
- }
- if (e.key === 'ArrowUp') {
- e.preventDefault();
- searchActiveIndex = Math.max(searchActiveIndex - 1, 0);
- renderSearchResults(input.value);
- }
+ if (e.key === 'ArrowDown') { e.preventDefault(); searchActiveIndex = Math.min(searchActiveIndex + 1, matches.length - 1); renderSearchResults(input.value); return; }
+ if (e.key === 'ArrowUp') { e.preventDefault(); searchActiveIndex = Math.max(searchActiveIndex - 1, 0); renderSearchResults(input.value); }
});
-
panel.addEventListener('mousedown', e => {
const item = e.target.closest('.search-result-item');
if (!item) return;
@@ -284,142 +195,108 @@ function initSearchBar() {
const selected = matches[Number(item.dataset.index)];
if (selected) selectSearchResult(selected);
});
-
- document.addEventListener('click', e => {
- if (!wrapper.contains(e.target)) closeSearchResults();
- });
+ document.addEventListener('click', e => { if (!wrapper.contains(e.target)) closeSearchResults(); });
}
-function onceMapClick(cb) {
- if (_mapClickCb) map.off('click', _mapClickCb);
- _mapClickCb = function (e) {
- _mapClickCb = null;
- cb(e.latlng.lat, e.latlng.lng);
+// โโโ Map-specific functions (only called when map page is active) โโโโโโโโโโโโโ
+
+function initMap() {
+ const CENTER = [-0.0263, 109.3425];
+ map = window._leafletMap = L.map('map', { zoomControl: false }).setView(CENTER, 13);
+ L.control.zoom({ position: 'topleft' }).addTo(map);
+
+ const baseLayers = {
+ 'OSM Standard': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: 'ยฉ OpenStreetMap', maxZoom: 19 }),
+ 'CartoDB Light': L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { attribution: 'ยฉ CartoDB', maxZoom: 19 }),
+ 'Satellite (Esri)': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: 'ยฉ Esri', maxZoom: 19 })
};
+ baseLayers['OSM Standard'].addTo(map);
+ L.control.layers(baseLayers, {}, { position: 'bottomright', collapsed: true }).addTo(map);
+
+ layers = {
+ rumahIbadah: L.layerGroup().addTo(map),
+ ibadahRadius: L.layerGroup().addTo(map),
+ pendudukMiskin: L.layerGroup().addTo(map)
+ };
+
+ layerVisible = { rumahIbadah: true, ibadahRadius: true, pendudukMiskin: true };
+
+ let currentTab = 'rumah_ibadah';
+ window.currentTab = currentTab;
+ window._mapClickCb = null;
+
+ map.on('mousemove', e => {
+ const el = document.getElementById('coord-display');
+ if (el) el.textContent = `Lat: ${e.latlng.lat.toFixed(6)} | Lng: ${e.latlng.lng.toFixed(6)}`;
+ });
+ map.on('zoomend', () => {
+ const el = document.getElementById('status-zoom');
+ if (el) el.textContent = `Zoom: ${map.getZoom()}`;
+ });
+
+ initSearchBar();
+ return map;
+}
+
+let currentTab = 'rumah_ibadah';
+let _mapClickCb = null;
+
+function onceMapClick(cb) {
+ if (!map) return;
+ if (_mapClickCb) map.off('click', _mapClickCb);
+ _mapClickCb = function (e) { _mapClickCb = null; cb(e.latlng.lat, e.latlng.lng); };
map.once('click', _mapClickCb);
}
function cancelMapClick() {
- if (_mapClickCb) {
- map.off('click', _mapClickCb);
- _mapClickCb = null;
- }
+ if (map && _mapClickCb) { map.off('click', _mapClickCb); _mapClickCb = null; }
hideInstruction();
document.querySelectorAll('.btn-add.active').forEach(b => b.classList.remove('active'));
}
+async function getAddressFromCoords(lat, lng) {
+ try {
+ const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lng}`);
+ if (!res.ok) return '';
+ const data = await res.json();
+ return data.display_name || '';
+ } catch (err) { return ''; }
+}
+
function switchTab(tab) {
currentTab = tab;
+ window.currentTab = tab;
cancelMapClick();
- document.querySelectorAll('.nav-tab').forEach((el, i) => {
- el.classList.toggle('active', ['kemiskinan', 'spbu', 'jalan', 'parsil'][i] === tab);
+ document.querySelectorAll('.nav-tab').forEach(el => {
+ const onClickAttr = el.getAttribute('onclick') || '';
+ el.classList.toggle('active', onClickAttr.includes(`'${tab}'`));
});
- try { map.removeControl(drawControl); } catch (e) { }
- const titles = { kemiskinan: 'Ibadah & Kemiskinan', spbu: 'SPBU', jalan: 'Data Jalan', parsil: 'Parsil Tanah' };
- document.getElementById('panel-title').textContent = titles[tab];
+ const titleEl = document.getElementById('panel-title');
+ if (titleEl) titleEl.textContent = tab === 'rumah_ibadah' ? 'Daftar Rumah Ibadah' : 'Daftar Penduduk Miskin';
renderPanel(tab);
- if (tab === 'jalan' || tab === 'parsil') map.addControl(drawControl);
}
async function renderPanel(tab) {
const pb = document.getElementById('panel-body');
+ if (!pb) return;
pb.innerHTML = ' Memuat dataโฆ
';
- if (tab === 'kemiskinan') await renderKemiskinanPanel(pb);
- if (tab === 'spbu') await renderSpbuPanel(pb);
- if (tab === 'jalan') await renderJalanPanel(pb);
- if (tab === 'parsil') await renderParsilPanel(pb);
-}
-
-async function preloadFeatureLayers() {
- const sandbox = document.createElement('div');
- try {
- await Promise.all([
- renderSpbuPanel(sandbox),
- renderJalanPanel(sandbox),
- renderParsilPanel(sandbox)
- ]);
- } catch (err) {
- console.warn('Gagal memuat sebagian layer awal:', err);
- }
-}
-
-function findGeomLayer(type, id) {
- const keys = type === 'jalan'
- ? ['jalanNasional', 'jalanProvinsi', 'jalanKabupaten']
- : ['parsilSHM', 'parsilHGB', 'parsilHGU', 'parsilHP'];
- for (const key of keys) {
- let found = null;
- layers[key].eachLayer(layer => {
- if (layer._dataId === id) found = layer;
- });
- if (found) return found;
- }
- return null;
-}
-
-function getGeometryEditHandler() {
- return drawControl?._toolbars?.edit?._modes?.edit?.handler || null;
-}
-
-function getGeometryGroupKey(type, data) {
- return type === 'jalan' ? `jalan${data.status_jalan}` : `parsil${data.jenis_hak}`;
-}
-
-function cloneEditableLayer(type, sourceLayer) {
- if (type === 'jalan') {
- const coords = sourceLayer.getLatLngs().map(ll => [ll.lat, ll.lng]);
- return L.polyline(coords, { ...sourceLayer.options });
- }
- const ring = sourceLayer.getLatLngs()[0].map(ll => [ll.lat, ll.lng]);
- return L.polygon(ring, { ...sourceLayer.options });
-}
-
-function buildGeometryLayer(type, data) {
- if (type === 'jalan') {
- const cfg = jalanCfg[data.status_jalan] || jalanCfg.Kabupaten;
- const coords = (data.geojson?.coordinates || []).map(c => [c[1], c[0]]);
- return L.polyline(coords, { color: cfg.color, weight: cfg.weight, opacity: 0.9 });
- }
- const cfg = parsilCfg[data.jenis_hak] || parsilCfg.SHM;
- const coords = ((data.geojson?.coordinates || [])[0] || []).map(c => [c[1], c[0]]);
- return L.polygon(coords, { color: cfg.color, fillColor: cfg.color, fillOpacity: 0.18, weight: 2 });
-}
-
-function cancelGeometryEdit() {
- const handler = getGeometryEditHandler();
- if (handler) handler.disable();
- drawnItems.clearLayers();
- geometryEditState = null;
- switchTab(currentTab);
-}
-
-function focusGeom(id, type) {
- const keys = type === 'jalan'
- ? ['jalanNasional', 'jalanProvinsi', 'jalanKabupaten']
- : ['parsilSHM', 'parsilHGB', 'parsilHGU', 'parsilHP'];
- for (const k of keys) {
- layers[k].eachLayer(l => {
- if (l._dataId === id) {
- map.flyToBounds(l.getBounds(), { padding: [50, 50], duration: 1 });
- l.openPopup();
- }
- });
- }
+ if (tab === 'rumah_ibadah') await renderRumahIbadahPanel(pb);
+ else if (tab === 'penduduk_miskin') await renderPendudukMiskinPanel(pb);
}
function focusMarkerById(layerKey, id, zoom = searchPointZoom) {
+ if (!layers || !map) return;
const layer = layers[layerKey];
if (!layer) return;
let target = null;
- layer.eachLayer(marker => {
- if (marker._dataId === id) target = marker;
- });
+ layer.eachLayer(marker => { if (marker._dataId === id) target = marker; });
if (!target) return;
map.flyTo(target.getLatLng(), zoom, { duration: 1 });
map.once('moveend', () => target.openPopup());
}
function toggleLayer(key, el) {
+ if (!layers || !map) return;
layerVisible[key] = !layerVisible[key];
el.classList.toggle('checked', layerVisible[key]);
el.querySelector('.layer-check').textContent = layerVisible[key] ? 'โ' : '';
@@ -427,36 +304,11 @@ function toggleLayer(key, el) {
}
function updateStatusCount() {
+ if (!layers || !map) return;
let total = 0;
Object.values(layers).forEach(lg => { if (map.hasLayer(lg)) lg.eachLayer(() => total++); });
- document.getElementById('status-count').textContent = `Total fitur: ${total}`;
- document.getElementById('status-zoom').textContent = `Zoom: ${map.getZoom()}`;
+ const countEl = document.getElementById('status-count');
+ if (countEl) countEl.textContent = `Total fitur: ${total}`;
+ const zoomEl = document.getElementById('status-zoom');
+ if (zoomEl) zoomEl.textContent = `Zoom: ${map.getZoom()}`;
}
-
-map.on('mousemove', e => {
- document.getElementById('coord-display').textContent = `Lat: ${e.latlng.lat.toFixed(6)} | Lng: ${e.latlng.lng.toFixed(6)}`;
-});
-map.on('zoomend', () => {
- document.getElementById('status-zoom').textContent = `Zoom: ${map.getZoom()}`;
-});
-map.on(L.Draw.Event.CREATED, function (e) {
- const { layer, layerType: type } = e;
- if (type === 'polyline') {
- const lls = layer.getLatLngs();
- pendingLength = turf.length(turf.lineString(lls.map(c => [c.lng, c.lat])), { units: 'meters' });
- pendingGeoJSON = { type: 'LineString', coordinates: lls.map(c => [c.lng, c.lat]) };
- drawnItems.addLayer(layer);
- openJalanForm();
- }
- if (type === 'polygon') {
- const lls = layer.getLatLngs()[0];
- const ring = [...lls.map(c => [c.lng, c.lat]), [lls[0].lng, lls[0].lat]];
- pendingArea = turf.area(turf.polygon([ring]));
- pendingGeoJSON = { type: 'Polygon', coordinates: [ring] };
- drawnItems.addLayer(layer);
- openParsilForm();
- }
-});
-
-initSearchBar();
-
diff --git a/js/feature-jalan.js b/js/feature-jalan.js
deleted file mode 100644
index acfd7b3..0000000
--- a/js/feature-jalan.js
+++ /dev/null
@@ -1,220 +0,0 @@
-const jalanCfg = {
- Nasional: { color: '#dc2626', weight: 5 },
- Provinsi: { color: '#d97706', weight: 3.5 },
- Kabupaten: { color: '#2563eb', weight: 2.5 }
-};
-
-function renderJalanPanel(pb) {
- return callAPI('data_jalan.php').then(res => {
- const data = res.data || [];
- const cnt = k => data.filter(d => d.status_jalan === k).length;
-
- pb.innerHTML = `
-
-
Filter Layer
- ${['Nasional', 'Provinsi', 'Kabupaten'].map(s => `
-
-
${layerVisible['jalan' + s] ? 'โ' : ''}
-
-
Jalan ${s}
-
${cnt(s)}
-
`).join('')}
-
-
-
Cara Menambahkan Data Jalan
-
- 1. Klik ikon polyline di toolbar kiri peta
- 2. Klik titik-titik rute jalan di peta
- 3. Double-klik untuk selesai menggambar
- 4. Form simpan + panjang otomatis muncul
-
-
-
-
Data Jalan (${data.length})
-
- ${data.length ? data.map(d => `
-
-
๐ฃ
-
-
${esc(d.nama_jalan)}
-
${(d.panjang_meter / 1000).toFixed(3)} km
-
-
-
${d.status_jalan.toUpperCase()}
-
โ
-
โ
-
-
`).join('') : '
Belum ada data jalan
'}
-
-
`;
- loadJalanLayer(data);
- registerSearchEntries('jalan', data.map(d => ({
- key: `jalan:${d.id}`,
- label: d.nama_jalan,
- subtitle: `Jalan ${d.status_jalan} ยท ${(d.panjang_meter / 1000).toFixed(3)} km${d.keterangan ? ` ยท ${d.keterangan}` : ''}`,
- typeLabel: 'Jalan',
- icon: '๐ฃ',
- searchText: normalizeSearchText(`${d.nama_jalan} ${d.status_jalan} jalan`),
- focus: () => focusGeom(d.id, 'jalan')
- })));
- updateStatusCount();
- });
-}
-
-function loadJalanLayer(data) {
- ['Nasional', 'Provinsi', 'Kabupaten'].forEach(k => layers['jalan' + k].clearLayers());
- data.forEach(d => {
- const cfg = jalanCfg[d.status_jalan], coords = d.geojson.coordinates.map(c => [c[1], c[0]]);
- const line = L.polyline(coords, { color: cfg.color, weight: cfg.weight, opacity: 0.9 })
- .bindPopup(`๐ฃ ${esc(d.nama_jalan)}
- Status: Jalan ${d.status_jalan}
- Panjang: ${(d.panjang_meter / 1000).toFixed(3)} km
- ${d.keterangan ? `${esc(d.keterangan)}
` : ''}
- `);
- line._dataId = d.id;
- line._groupKey = 'jalan' + d.status_jalan;
- layers['jalan' + d.status_jalan].addLayer(line);
- });
-}
-
-function openJalanForm() {
- openModal('Simpan Data Jalan', `
- ๐ Panjang: ${pendingLength.toFixed(1)} m = ${(pendingLength / 1000).toFixed(3)} km
- Nama Jalan *
-
- Status Jalan *
-
- Jalan Nasional
- Jalan Provinsi
- Jalan Kabupaten
-
- Keterangan
-
`,
- `Batal
- ๐พ Simpan `);
-}
-
-async function saveJalan() {
- const body = {
- nama_jalan: document.getElementById('jl-nama').value.trim(),
- status_jalan: document.getElementById('jl-status').value,
- panjang_meter: pendingLength,
- keterangan: document.getElementById('jl-ket').value,
- geojson: pendingGeoJSON
- };
- if (!body.nama_jalan) return toast('Nama wajib diisi', 'error');
- const res = await callAPI('data_jalan.php', 'POST', body);
- if (res.status === 'success') {
- toast(res.message, 'success');
- closeModal();
- drawnItems.clearLayers();
- pendingGeoJSON = null;
- pendingLength = null;
- switchTab('jalan');
- } else {
- toast(res.message, 'error');
- }
-}
-
-async function editJalan(id) {
- map.closePopup();
- const res = await callAPI(`data_jalan.php?id=${id}`);
- const data = res.data;
- const sourceLayer = findGeomLayer('jalan', id);
- if (!data) return toast('Data jalan tidak ditemukan', 'error');
-
- const groupKey = getGeometryGroupKey('jalan', data);
- const editLayer = sourceLayer ? cloneEditableLayer('jalan', sourceLayer) : buildGeometryLayer('jalan', data);
- editLayer._dataId = id;
- editLayer._groupKey = groupKey;
-
- if (sourceLayer) layers[groupKey].removeLayer(sourceLayer);
- drawnItems.clearLayers();
- drawnItems.addLayer(editLayer);
-
- geometryEditState = { type: 'jalan', id, groupKey, sourceLayer, editLayer };
- if (editLayer.editing && editLayer.editing.enable) {
- editLayer.editing.enable();
- } else {
- const handler = getGeometryEditHandler();
- if (!handler) {
- drawnItems.clearLayers();
- if (sourceLayer) layers[groupKey].addLayer(sourceLayer);
- geometryEditState = null;
- return toast('Mode edit geometri belum siap', 'error');
- }
- handler.enable();
- }
- map.fitBounds(editLayer.getBounds(), { padding: [50, 50], duration: 1 });
- document.getElementById('panel-title').textContent = 'Edit Data Jalan';
- document.getElementById('panel-body').innerHTML = `
-
-
Edit Jalan
-
- Geser titik-titik garis di peta lalu simpan perubahan.
-
-
- Nama Jalan *
-
-
-
-
- Keterangan
-
-
-
`;
- document.getElementById('panel-body').insertAdjacentHTML('beforeend', `
-
- Batal
- Simpan Perubahan
-
`);
-}
-
-async function saveJalanEdit(id) {
- const state = geometryEditState;
- if (!state || state.type !== 'jalan') return;
- const layer = state.editLayer;
- const coords = layer.getLatLngs().map(ll => [ll.lng, ll.lat]);
- const panjang_meter = turf.length(turf.lineString(coords), { units: 'meters' });
- const body = {
- nama_jalan: document.getElementById('jl-edit-nama').value.trim(),
- status_jalan: document.getElementById('jl-edit-status').value,
- panjang_meter,
- keterangan: document.getElementById('jl-edit-ket').value,
- geojson: { type: 'LineString', coordinates: coords }
- };
- if (!body.nama_jalan) return toast('Nama wajib diisi', 'error');
- const res = await callAPI(`data_jalan.php?id=${id}`, 'PUT', body);
- if (res.status === 'success') {
- toast(res.message, 'success');
- if (layer.editing && layer.editing.disable) layer.editing.disable();
- const handler = getGeometryEditHandler();
- if (handler) handler.disable();
- drawnItems.clearLayers();
- geometryEditState = null;
- switchTab('jalan');
- } else {
- toast(res.message, 'error');
- }
-}
-
-async function deleteJalan(id) {
- if (!confirm('Hapus?')) return;
- const r = await callAPI(`data_jalan.php?id=${id}`, 'DELETE');
- if (r.status === 'success') {
- toast('Dihapus', 'success');
- map.closePopup();
- switchTab('jalan');
- }
-}
diff --git a/js/feature-kemiskinan.js b/js/feature-kemiskinan.js
deleted file mode 100644
index 2689c6d..0000000
--- a/js/feature-kemiskinan.js
+++ /dev/null
@@ -1,451 +0,0 @@
-function renderKemiskinanPanel(pb) {
- return Promise.all([callAPI('rumah_ibadah.php'), callAPI('penduduk_miskin.php')]).then(([riRes, pmRes]) => {
- const ri = riRes.data || [];
- const pm = pmRes.data || [];
-
- pb.innerHTML = `
-
-
Visibilitas Layer
- ${mkLayerItem('rumahIbadah', '#f59e0b', 'Rumah Ibadah', ri.length)}
- ${mkLayerItem('ibadahRadius', '#fbbf24', 'Radius Jangkauan', ri.length, true)}
- ${mkLayerItem('pendudukMiskin', '#16a34a', 'Penduduk Miskin', pm.length)}
-
-
-
Tambah Data
-
๏ผ Tambah Rumah Ibadah
-
๏ผ Tambah Penduduk Miskin
-
-
-
Rumah Ibadah (${ri.length})
-
- ${ri.length ? ri.map(r => `
-
-
โช
-
-
${esc(r.nama)}
-
${r.jenis} ยท R=${r.radius_meter}m
-
-
- โ
- โ
-
-
`).join('') : '
Belum ada data
'}
-
-
-
-
Penduduk Miskin (${pm.length})
-
- ${pm.length ? pm.map(p => `
-
-
${pmEmoji(p)}
-
-
${esc(p.nama)}
-
${p.total_bantuan} bantuan ยท ${p.jumlah_anggota} jiwa
-
-
- ${p.status_proximity === 'dalam_radius' ? 'DALAM' : 'LUAR'}
-
-
`).join('') : '
Belum ada data
'}
-
-
`;
-
- loadRILayer(ri);
- loadPMLayer(pm);
- registerSearchEntries('ri', ri.map(r => ({
- key: `ri:${r.id}`,
- label: r.nama,
- subtitle: `Rumah Ibadah ยท ${r.jenis}${r.alamat ? ` ยท ${r.alamat}` : ''}`,
- typeLabel: 'Rumah Ibadah',
- icon: 'โช',
- searchText: normalizeSearchText(`${r.nama} ${r.jenis} rumah ibadah`),
- focus: () => focusMarkerById('rumahIbadah', r.id)
- })));
- registerSearchEntries('pm', pm.map(p => ({
- key: `pm:${p.id}`,
- label: p.nama,
- subtitle: `Penduduk Miskin ยท ${p.jumlah_anggota} jiwa${p.alamat ? ` ยท ${p.alamat}` : ''}`,
- typeLabel: 'Penduduk Miskin',
- icon: '๐ค',
- searchText: normalizeSearchText(`${p.nama} ${p.nik || ''} penduduk miskin`),
- focus: () => focusMarkerById('pendudukMiskin', p.id)
- })));
- updateStatusCount();
- });
-}
-
-function mkLayerItem(key, color, label, count, isRing = false) {
- const dot = isRing
- ? `
`
- : `
`;
- return `
-
${layerVisible[key] ? 'โ' : ''}
${dot}
-
${label}
-
${count}
-
`;
-}
-
-function pmEmoji(p) {
- if (p.bantuan_pemberdayaan > 0 && p.bantuan_konsumtif > 0) return '๐';
- if (p.bantuan_pemberdayaan > 0) return '๐';
- if (p.bantuan_konsumtif > 0) return '๐';
- return '๐ค';
-}
-
-function loadRILayer(data) {
- layers.rumahIbadah.clearLayers();
- layers.ibadahRadius.clearLayers();
-
- data.forEach(r => {
- const circle = L.circle([r.latitude, r.longitude], {
- radius: r.radius_meter,
- color: '#f59e0b', fillColor: '#fef3c7', fillOpacity: 0.18,
- weight: 1.5, dashArray: '6,4'
- });
- layers.ibadahRadius.addLayer(circle);
-
- const icon = L.divIcon({
- className: '',
- html: `โช
`,
- iconSize: [32, 32], iconAnchor: [16, 16]
- });
-
- const marker = L.marker([r.latitude, r.longitude], { icon, draggable: true }).bindPopup(riPopup(r));
- marker._dataId = r.id;
- marker.on('dragend', async function (ev) {
- const { lat, lng } = ev.target.getLatLng();
- circle.setLatLng([lat, lng]);
- const res = await callAPI(`rumah_ibadah.php?id=${r.id}`, 'PUT', {
- nama: r.nama, jenis: r.jenis, alamat: r.alamat,
- no_wa: r.no_wa, latitude: lat, longitude: lng,
- radius_meter: r.radius_meter
- });
- if (res.status === 'success') {
- r.latitude = lat;
- r.longitude = lng;
- toast(`๐ ${r.nama} dipindahkan & koordinat diperbarui`, 'success');
- await recalcAllProximity();
- } else {
- toast('Gagal update lokasi: ' + res.message, 'error');
- marker.setLatLng([r.latitude, r.longitude]);
- }
- });
-
- layers.rumahIbadah.addLayer(marker);
- });
-}
-
-function riPopup(r) {
- return `โช ${esc(r.nama)}
- Jenis: ${r.jenis}
- Radius: ${r.radius_meter} m
- ${r.alamat ? `Alamat: ${esc(r.alamat)}
` : ''}
- ${r.no_wa ? `WA: ${r.no_wa}
` : ''}
-
- `;
-}
-
-function loadPMLayer(data) {
- layers.pendudukMiskin.clearLayers();
-
- data.forEach(p => {
- const inR = p.status_proximity === 'dalam_radius';
- const color = inR ? '#16a34a' : '#dc2626';
- const hasBoth = p.bantuan_pemberdayaan > 0 && p.bantuan_konsumtif > 0;
- const hasPemb = p.bantuan_pemberdayaan > 0;
- const hasKons = p.bantuan_konsumtif > 0;
-
- let dot = '';
- if (hasBoth || hasPemb) dot = `
`;
- if (hasKons && !hasPemb) dot = `
`;
-
- const icon = L.divIcon({
- className: '',
- html: ``,
- iconSize: [26, 26], iconAnchor: [13, 13]
- });
-
- const marker = L.marker([p.latitude, p.longitude], { icon, draggable: true }).bindPopup(pmPopup(p));
- marker._dataId = p.id;
- marker.on('dragend', async function (ev) {
- const { lat, lng } = ev.target.getLatLng();
- const res = await callAPI(`penduduk_miskin.php?id=${p.id}`, 'PUT', {
- nama: p.nama, no_wa: p.no_wa, nik: p.nik,
- alamat: p.alamat, jumlah_anggota: p.jumlah_anggota,
- latitude: lat, longitude: lng
- });
- if (res.status === 'success') {
- const prox = res.data?.proximity;
- const statusMsg = prox?.status === 'dalam_radius' ? 'โ
Dalam radius' : 'โ Luar radius';
- toast(`๐ ${p.nama} dipindahkan โ ${statusMsg}`, 'success');
- switchTab('kemiskinan');
- } else {
- toast('Gagal update: ' + res.message, 'error');
- marker.setLatLng([p.latitude, p.longitude]);
- }
- });
-
- layers.pendudukMiskin.addLayer(marker);
- });
-}
-
-function pmPopup(p) {
- const inR = p.status_proximity === 'dalam_radius';
- return `๐ค ${esc(p.nama)}
-
- ${inR ? `โ
Dalam radius โ ${esc(p.nama_rumah_ibadah || '')}` : 'โ Di luar radius rumah ibadah'}
-
- ${p.no_wa ? `WA: ${p.no_wa}
` : ''}
- ${p.nik ? `NIK: ${p.nik}
` : ''}
- ${p.alamat ? `Alamat: ${esc(p.alamat)}
` : ''}
- Keluarga: ${p.jumlah_anggota} jiwa
- Bantuan: ๐${p.bantuan_pemberdayaan} ยท ๐${p.bantuan_konsumtif}
-
- `;
-}
-
-function startAdd(type) {
- cancelMapClick();
- const btnId = type === 'ri' ? 'btn-add-ri' : 'btn-add-pm';
- const label = type === 'ri' ? 'lokasi Rumah Ibadah' : 'lokasi Penduduk Miskin';
- document.getElementById(btnId).classList.add('active');
- showInstruction(`Klik di peta untuk menentukan ${label}`);
- onceMapClick((lat, lng) => {
- document.getElementById(btnId).classList.remove('active');
- hideInstruction();
- if (type === 'ri') openRIForm(null, lat, lng);
- else openPMForm(null, lat, lng);
- });
-}
-
-function openRIForm(data, lat, lng) {
- const isEdit = !!data;
- const fLat = isEdit ? data.latitude : lat;
- const fLng = isEdit ? data.longitude : lng;
- openModal(isEdit ? 'Edit Rumah Ibadah' : 'Tambah Rumah Ibadah', `
- ๐ Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}
-
- Nama Rumah Ibadah *
-
-
-
-
- Alamat
-
-
-
- No. WhatsApp
-
-
`,
- `Batal
- ๐พ Simpan `);
-}
-
-async function saveRI(id, lat, lng) {
- const body = {
- nama: document.getElementById('ri-nama').value.trim(),
- jenis: document.getElementById('ri-jenis').value,
- alamat: document.getElementById('ri-alamat').value.trim(),
- no_wa: document.getElementById('ri-nowa').value.trim(),
- latitude: lat,
- longitude: lng,
- radius_meter: parseInt(document.getElementById('ri-radius').value)
- };
- if (!body.nama) return toast('Nama wajib diisi', 'error');
- const res = await callAPI(id ? `rumah_ibadah.php?id=${id}` : 'rumah_ibadah.php', id ? 'PUT' : 'POST', body);
- if (res.status === 'success') {
- toast(res.message, 'success');
- closeModal();
- await recalcAllProximity();
- switchTab('kemiskinan');
- } else {
- toast(res.message, 'error');
- }
-}
-
-function openPMForm(data, lat, lng) {
- const isEdit = !!data;
- const fLat = isEdit ? data.latitude : lat;
- const fLng = isEdit ? data.longitude : lng;
- openModal(isEdit ? 'Edit Penduduk Miskin' : 'Tambah Penduduk Miskin', `
- ๐ Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}
-
- Nama Lengkap *
-
-
-
-
- Alamat
-
-
-
- Jumlah Anggota Keluarga
-
-
`,
- `Batal
- ๐พ Simpan `);
-}
-
-async function savePM(id, lat, lng) {
- const body = {
- nama: document.getElementById('pm-nama').value.trim(),
- no_wa: document.getElementById('pm-nowa').value.trim(),
- nik: document.getElementById('pm-nik').value.trim(),
- alamat: document.getElementById('pm-alamat').value.trim(),
- jumlah_anggota: parseInt(document.getElementById('pm-jml').value) || 1,
- latitude: lat,
- longitude: lng
- };
- if (!body.nama) return toast('Nama wajib diisi', 'error');
- const res = await callAPI(id ? `penduduk_miskin.php?id=${id}` : 'penduduk_miskin.php', id ? 'PUT' : 'POST', body);
- if (res.status === 'success') {
- toast(res.message, 'success');
- closeModal();
- switchTab('kemiskinan');
- } else {
- toast(res.message, 'error');
- }
-}
-
-async function editRI(id) {
- map.closePopup();
- const res = await callAPI(`rumah_ibadah.php?id=${id}`);
- if (res.data) openRIForm(res.data, res.data.latitude, res.data.longitude);
-}
-
-async function deleteRI(id) {
- if (!confirm('Hapus rumah ibadah ini?')) return;
- const res = await callAPI(`rumah_ibadah.php?id=${id}`, 'DELETE');
- if (res.status === 'success') {
- toast('Dihapus', 'success');
- map.closePopup();
- await recalcAllProximity();
- switchTab('kemiskinan');
- }
-}
-
-async function editPM(id) {
- map.closePopup();
- const res = await callAPI(`penduduk_miskin.php?id=${id}`);
- if (res.data) openPMForm(res.data, res.data.latitude, res.data.longitude);
-}
-
-async function deletePM(id) {
- if (!confirm('Hapus data ini?')) return;
- const res = await callAPI(`penduduk_miskin.php?id=${id}`, 'DELETE');
- if (res.status === 'success') {
- toast('Dihapus', 'success');
- map.closePopup();
- await recalcAllProximity();
- switchTab('kemiskinan');
- }
-}
-
-async function openLogBantuan(pmId, pmNama) {
- map.closePopup();
- const [logRes, riRes] = await Promise.all([
- callAPI(`log_bantuan.php?penduduk_miskin_id=${pmId}`),
- callAPI('rumah_ibadah.php')
- ]);
- const logs = logRes.data || [];
- const ris = riRes.data || [];
-
- openModal(`Log Bantuan โ ${pmNama}`, `
-
-
Riwayat Bantuan
- ${logs.length ? logs.map(l => `
-
-
${l.tipe_bantuan === 'Pemberdayaan' ? '๐ Pemberdayaan' : '๐ Konsumtif'}
-
${esc(l.sub_kategori) || ''} ${l.keterangan ? 'โ ' + esc(l.keterangan) : ''}
-
๐
${l.tanggal_bantuan} ยท ${esc(l.nama_rumah_ibadah)}
-
`).join('') : '
Belum ada riwayat bantuan
'}
-
- Tambah Bantuan Baru
-
- Dari Rumah Ibadah *
-
- ${ris.map(r => `${esc(r.nama)} (${r.jenis}) `).join('')}
-
-
-
-
- Sub Kategori
-
-
- `,
- `Tutup
- โ Tambah Log `);
-}
-
-async function saveLog(pmId) {
- const body = {
- penduduk_miskin_id: pmId,
- rumah_ibadah_id: parseInt(document.getElementById('lb-ri').value),
- tipe_bantuan: document.getElementById('lb-tipe').value,
- tanggal_bantuan: document.getElementById('lb-tgl').value,
- sub_kategori: document.getElementById('lb-sub').value,
- keterangan: document.getElementById('lb-ket').value,
- nilai_bantuan: document.getElementById('lb-nilai').value || null
- };
- const res = await callAPI('log_bantuan.php', 'POST', body);
- if (res.status === 'success') {
- toast('Log dicatat!', 'success');
- closeModal();
- switchTab('kemiskinan');
- } else {
- toast(res.message, 'error');
- }
-}
-
-async function recalcAllProximity() {
- await callAPI('penduduk_miskin.php?action=recalc_all', 'POST');
-}
diff --git a/js/feature-parsil.js b/js/feature-parsil.js
deleted file mode 100644
index cbea01e..0000000
--- a/js/feature-parsil.js
+++ /dev/null
@@ -1,237 +0,0 @@
-const parsilCfg = {
- SHM: { color: '#16a34a', label: 'Sertifikat Hak Milik' },
- HGB: { color: '#2563eb', label: 'Hak Guna Bangunan' },
- HGU: { color: '#d97706', label: 'Hak Guna Usaha' },
- HP: { color: '#7c3aed', label: 'Hak Pakai' }
-};
-
-function renderParsilPanel(pb) {
- return callAPI('data_parsil.php').then(res => {
- const data = res.data || [];
- const cnt = k => data.filter(d => d.jenis_hak === k).length;
-
- pb.innerHTML = `
-
-
Filter Layer
- ${Object.entries(parsilCfg).map(([k, v]) => `
-
-
${layerVisible['parsil' + k] ? 'โ' : ''}
-
-
${k} โ ${v.label}
-
${cnt(k)}
-
`).join('')}
-
-
-
Cara Menambahkan Data Parsil
-
- 1. Klik ikon polygon di toolbar kiri peta
- 2. Klik titik-titik batas bidang tanah
- 3. Klik titik awal / double-klik untuk menutup
- 4. Form simpan + luas otomatis muncul
-
-
-
-
Data Parsil (${data.length})
-
- ${data.length ? data.map(d => `
-
-
๐
-
-
${esc(d.nama_pemilik)}
-
${d.luas_m2.toFixed(1)} mยฒ ยท ${d.no_sertifikat || 'โ'}
-
-
-
${d.jenis_hak}
-
โ
-
โ
-
-
`).join('') : '
Belum ada data parsil
'}
-
-
`;
- loadParsilLayer(data);
- registerSearchEntries('parsil', data.map(d => ({
- key: `parsil:${d.id}`,
- label: d.nama_pemilik,
- subtitle: `Parsil ${d.jenis_hak} ยท ${d.luas_m2.toFixed(1)} mยฒ${d.no_sertifikat ? ` ยท ${d.no_sertifikat}` : ''}`,
- typeLabel: 'Parsil',
- icon: '๐',
- searchText: normalizeSearchText(`${d.nama_pemilik} ${d.jenis_hak} parsil tanah`),
- focus: () => focusGeom(d.id, 'parsil')
- })));
- updateStatusCount();
- });
-}
-
-function loadParsilLayer(data) {
- ['SHM', 'HGB', 'HGU', 'HP'].forEach(k => layers['parsil' + k].clearLayers());
- data.forEach(d => {
- const cfg = parsilCfg[d.jenis_hak], coords = d.geojson.coordinates[0].map(c => [c[1], c[0]]);
- const poly = L.polygon(coords, { color: cfg.color, fillColor: cfg.color, fillOpacity: 0.18, weight: 2 })
- .bindPopup(`๐ ${esc(d.nama_pemilik)}
- Jenis Hak: ${d.jenis_hak} โ ${cfg.label}
- Luas: ${d.luas_m2.toFixed(2)} mยฒ (${(d.luas_m2 / 10000).toFixed(4)} ha)
- ${d.no_sertifikat ? `No. Sertifikat: ${d.no_sertifikat}
` : ''}
- ${d.keterangan ? `${esc(d.keterangan)}
` : ''}
- `);
- poly._dataId = d.id;
- poly._groupKey = 'parsil' + d.jenis_hak;
- layers['parsil' + d.jenis_hak].addLayer(poly);
- });
-}
-
-function openParsilForm() {
- openModal('Simpan Parsil Tanah', `
- ๐ Luas: ${pendingArea.toFixed(2)} mยฒ = ${(pendingArea / 10000).toFixed(4)} ha
- Nama Pemilik *
-
-
- Keterangan
-
`,
- `Batal
- ๐พ Simpan `);
-}
-
-async function saveParsil() {
- const body = {
- nama_pemilik: document.getElementById('ps-nama').value.trim(),
- jenis_hak: document.getElementById('ps-hak').value,
- no_sertifikat: document.getElementById('ps-sert').value,
- luas_m2: pendingArea,
- keterangan: document.getElementById('ps-ket').value,
- geojson: pendingGeoJSON
- };
- if (!body.nama_pemilik) return toast('Nama wajib diisi', 'error');
- const res = await callAPI('data_parsil.php', 'POST', body);
- if (res.status === 'success') {
- toast(res.message, 'success');
- closeModal();
- drawnItems.clearLayers();
- pendingGeoJSON = null;
- pendingArea = null;
- switchTab('parsil');
- } else {
- toast(res.message, 'error');
- }
-}
-
-async function editParsil(id) {
- map.closePopup();
- const res = await callAPI(`data_parsil.php?id=${id}`);
- const data = res.data;
- const sourceLayer = findGeomLayer('parsil', id);
- if (!data) return toast('Data parsil tidak ditemukan', 'error');
-
- const groupKey = getGeometryGroupKey('parsil', data);
- const editLayer = sourceLayer ? cloneEditableLayer('parsil', sourceLayer) : buildGeometryLayer('parsil', data);
- editLayer._dataId = id;
- editLayer._groupKey = groupKey;
-
- if (sourceLayer) layers[groupKey].removeLayer(sourceLayer);
- drawnItems.clearLayers();
- drawnItems.addLayer(editLayer);
-
- geometryEditState = { type: 'parsil', id, groupKey, sourceLayer, editLayer };
- if (editLayer.editing && editLayer.editing.enable) {
- editLayer.editing.enable();
- } else {
- const handler = getGeometryEditHandler();
- if (!handler) {
- drawnItems.clearLayers();
- if (sourceLayer) layers[groupKey].addLayer(sourceLayer);
- geometryEditState = null;
- return toast('Mode edit geometri belum siap', 'error');
- }
- handler.enable();
- }
- map.fitBounds(editLayer.getBounds(), { padding: [50, 50], duration: 1 });
- document.getElementById('panel-title').textContent = 'Edit Parsil Tanah';
- document.getElementById('panel-body').innerHTML = `
-
-
Edit Parsil
-
- Geser titik-titik batas bidang di peta lalu simpan perubahan.
-
-
- Nama Pemilik *
-
-
-
-
-
`;
- document.getElementById('panel-body').insertAdjacentHTML('beforeend', `
-
- Batal
- Simpan Perubahan
-
`);
-}
-
-async function saveParsilEdit(id) {
- const state = geometryEditState;
- if (!state || state.type !== 'parsil') return;
- const layer = state.editLayer;
- const ringLatLngs = layer.getLatLngs()[0];
- const ring = ringLatLngs.map(ll => [ll.lng, ll.lat]);
- ring.push([ringLatLngs[0].lng, ringLatLngs[0].lat]);
- const luas_m2 = turf.area(turf.polygon([ring]));
- const body = {
- nama_pemilik: document.getElementById('ps-edit-nama').value.trim(),
- jenis_hak: document.getElementById('ps-edit-hak').value,
- no_sertifikat: document.getElementById('ps-edit-sert').value,
- luas_m2,
- keterangan: document.getElementById('ps-edit-ket').value,
- geojson: { type: 'Polygon', coordinates: [ring] }
- };
- if (!body.nama_pemilik) return toast('Nama wajib diisi', 'error');
- const res = await callAPI(`data_parsil.php?id=${id}`, 'PUT', body);
- if (res.status === 'success') {
- toast(res.message, 'success');
- if (layer.editing && layer.editing.disable) layer.editing.disable();
- const handler = getGeometryEditHandler();
- if (handler) handler.disable();
- drawnItems.clearLayers();
- geometryEditState = null;
- switchTab('parsil');
- } else {
- toast(res.message, 'error');
- }
-}
-
-async function deleteParsil(id) {
- if (!confirm('Hapus?')) return;
- const r = await callAPI(`data_parsil.php?id=${id}`, 'DELETE');
- if (r.status === 'success') {
- toast('Dihapus', 'success');
- map.closePopup();
- switchTab('parsil');
- }
-}
diff --git a/js/feature-spbu.js b/js/feature-spbu.js
deleted file mode 100644
index c9fb322..0000000
--- a/js/feature-spbu.js
+++ /dev/null
@@ -1,158 +0,0 @@
-function renderSpbuPanel(pb) {
- return callAPI('spbu.php').then(res => {
- const data = res.data || [];
- const d24 = data.filter(s => s.buka_24jam);
- const dn24 = data.filter(s => !s.buka_24jam);
-
- pb.innerHTML = `
-
-
Filter Layer
- ${mkLayerItem('spbu24', '#16a34a', 'SPBU Buka 24 Jam', d24.length)}
- ${mkLayerItem('spbuNon24', '#94a3b8', 'SPBU Tidak 24 Jam', dn24.length)}
-
-
-
Tambah Data
-
๏ผ Tambah SPBU
-
-
-
Daftar SPBU (${data.length})
-
- ${data.length ? data.map(s => `
-
-
โฝ
-
-
${esc(s.nama)}
-
${s.no_wa || 'โ'}
-
-
-
${s.buka_24jam ? '24 JAM' : 'TERBATAS'}
-
โ
-
โ
-
-
`).join('') : '
Belum ada data
'}
-
-
`;
-
- loadSPBULayer(data);
- registerSearchEntries('spbu', data.map(s => ({
- key: `spbu:${s.id}`,
- label: s.nama,
- subtitle: `SPBU ยท ${s.buka_24jam ? '24 Jam' : 'Tidak 24 Jam'}${s.alamat ? ` ยท ${s.alamat}` : ''}`,
- typeLabel: 'SPBU',
- icon: 'โฝ',
- searchText: normalizeSearchText(`${s.nama} ${s.no_wa || ''} spbu`),
- focus: () => focusMarkerById(s.buka_24jam ? 'spbu24' : 'spbuNon24', s.id)
- })));
- updateStatusCount();
- });
-}
-
-function loadSPBULayer(data) {
- layers.spbu24.clearLayers();
- layers.spbuNon24.clearLayers();
- data.forEach(s => {
- const is24 = s.buka_24jam;
- const color = is24 ? '#16a34a' : '#64748b';
- const icon = L.divIcon({
- className: '',
- html: `โฝ
`,
- iconSize: [30, 30], iconAnchor: [15, 15]
- });
- const marker = L.marker([s.latitude, s.longitude], { icon, draggable: true }).bindPopup(`โฝ ${esc(s.nama)}
- Status: ${is24 ? 'โ
Buka 24 Jam ' : 'โฐ Tidak 24 Jam'}
- ${s.no_wa ? `WA: ${s.no_wa}
` : ''}
- ${s.alamat ? `Alamat: ${esc(s.alamat)}
` : ''}
-
- `);
- marker._dataId = s.id;
-
- marker.on('dragend', async function (ev) {
- const { lat, lng } = ev.target.getLatLng();
- const res = await callAPI(`spbu.php?id=${s.id}`, 'PUT', {
- nama: s.nama, no_wa: s.no_wa, alamat: s.alamat,
- buka_24jam: s.buka_24jam, latitude: lat, longitude: lng
- });
- if (res.status === 'success') {
- s.latitude = lat;
- s.longitude = lng;
- toast(`๐ ${s.nama} dipindahkan`, 'success');
- } else {
- toast('Gagal update: ' + res.message, 'error');
- marker.setLatLng([s.latitude, s.longitude]);
- }
- });
-
- is24 ? layers.spbu24.addLayer(marker) : layers.spbuNon24.addLayer(marker);
- });
-}
-
-function startAddSPBU() {
- cancelMapClick();
- document.getElementById('btn-add-spbu').classList.add('active');
- showInstruction('Klik di peta untuk menentukan lokasi SPBU');
- onceMapClick((lat, lng) => {
- document.getElementById('btn-add-spbu').classList.remove('active');
- hideInstruction();
- openSPBUForm(null, lat, lng);
- });
-}
-
-function openSPBUForm(data, lat, lng) {
- const isEdit = !!data;
- const fLat = isEdit ? data.latitude : lat;
- const fLng = isEdit ? data.longitude : lng;
- openModal(isEdit ? 'Edit SPBU' : 'Tambah SPBU', `
- ๐ Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}
- Nama SPBU *
-
- No. WhatsApp
-
- Alamat
-
- Status Operasional
-
- โ
Buka 24 Jam
- โฐ Tidak 24 Jam
-
`,
- `Batal
- ๐พ Simpan `);
-}
-
-async function saveSPBU(id, lat, lng) {
- const body = {
- nama: document.getElementById('spbu-nama').value.trim(),
- no_wa: document.getElementById('spbu-nowa').value.trim(),
- alamat: document.getElementById('spbu-alamat').value.trim(),
- buka_24jam: document.getElementById('spbu-24').value === '1',
- latitude: lat,
- longitude: lng
- };
- if (!body.nama) return toast('Nama wajib diisi', 'error');
- const res = await callAPI(id ? `spbu.php?id=${id}` : 'spbu.php', id ? 'PUT' : 'POST', body);
- if (res.status === 'success') {
- toast(res.message, 'success');
- closeModal();
- switchTab('spbu');
- } else {
- toast(res.message, 'error');
- }
-}
-
-async function editSPBU(id) {
- map.closePopup();
- const r = await callAPI(`spbu.php?id=${id}`);
- if (r.data) openSPBUForm(r.data, r.data.latitude, r.data.longitude);
-}
-
-async function deleteSPBU(id) {
- if (!confirm('Hapus SPBU?')) return;
- const r = await callAPI(`spbu.php?id=${id}`, 'DELETE');
- if (r.status === 'success') {
- toast('Dihapus', 'success');
- map.closePopup();
- switchTab('spbu');
- }
-}
diff --git a/js/features/log-bantuan.js b/js/features/log-bantuan.js
new file mode 100644
index 0000000..c36a4ef
--- /dev/null
+++ b/js/features/log-bantuan.js
@@ -0,0 +1,150 @@
+function haversineDistance(lat1, lng1, lat2, lng2) {
+ const R = 6371000;
+ const toRad = d => d * Math.PI / 180;
+ const dLat = toRad(lat2 - lat1);
+ const dLng = toRad(lng2 - lng1);
+ const a = Math.sin(dLat/2)**2 +
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng/2)**2;
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+}
+
+async function openLogBantuan(pmId, pmNama, pmLat, pmLng) {
+ map.closePopup();
+ const [logRes, riRes] = await Promise.all([
+ callAPI(`log_bantuan.php?penduduk_miskin_id=${pmId}`),
+ callAPI('rumah_ibadah.php')
+ ]);
+ const logs = logRes.data || [];
+ const ris = riRes.data || [];
+
+ // --- Determine best default RI ---
+ let defaultRiId = ris.length > 0 ? ris[0].id : null;
+
+ if (pmLat != null && pmLng != null && ris.length > 0) {
+ // Find the RI whose circle the resident falls inside (if any), prefer that
+ const insideRis = ris.filter(r => {
+ const dist = haversineDistance(pmLat, pmLng, r.latitude, r.longitude);
+ return dist <= (r.radius_meter || 500);
+ });
+
+ if (insideRis.length > 0) {
+ // Among RIs covering this resident, pick the closest center
+ insideRis.sort((a, b) =>
+ haversineDistance(pmLat, pmLng, a.latitude, a.longitude) -
+ haversineDistance(pmLat, pmLng, b.latitude, b.longitude)
+ );
+ defaultRiId = insideRis[0].id;
+ } else {
+ // Not within any radius โ pick nearest RI geometrically
+ const sorted = [...ris].sort((a, b) =>
+ haversineDistance(pmLat, pmLng, a.latitude, a.longitude) -
+ haversineDistance(pmLat, pmLng, b.latitude, b.longitude)
+ );
+ defaultRiId = sorted[0].id;
+ }
+ }
+
+ // Build RI options with distance label (admin only) or locked (petugas)
+ let riSelectHTML;
+ if (window.currentUser && window.currentUser.role === 'petugas_lapangan') {
+ // Petugas can only see their own RI
+ riSelectHTML = `
+ ${ris.filter(r => String(r.id) === String(window.currentUser.rumah_ibadah_id))
+ .map(r => `${esc(r.nama)} (${r.jenis}) `).join('')}
+ `;
+ } else {
+ // Admin โ show all, sorted by distance, with distance label if coords available
+ const sorted = (pmLat != null && pmLng != null)
+ ? [...ris].sort((a, b) =>
+ haversineDistance(pmLat, pmLng, a.latitude, a.longitude) -
+ haversineDistance(pmLat, pmLng, b.latitude, b.longitude))
+ : ris;
+
+ riSelectHTML = `
+ ${sorted.map(r => {
+ const dist = (pmLat != null && pmLng != null)
+ ? Math.round(haversineDistance(pmLat, pmLng, r.latitude, r.longitude))
+ : null;
+ const distLabel = dist != null ? ` โ ${dist < 1000 ? dist + ' m' : (dist/1000).toFixed(1) + ' km'}` : '';
+ const isInside = dist != null && dist <= (r.radius_meter || 500);
+ const label = `${esc(r.nama)} (${r.jenis})${distLabel}${isInside ? ' โ
' : ''}`;
+ const selected = String(r.id) === String(defaultRiId) ? 'selected' : '';
+ return `${label} `;
+ }).join('')}
+
+ ${pmLat != null ? `โ
= dalam radius ยท Diurutkan berdasarkan jarak terdekat
` : ''}`;
+ }
+
+ openModal(`Log Bantuan โ ${pmNama}`, `
+
+
Riwayat Bantuan
+ ${logs.length ? `
+ ${logs.slice(0, 2).map(l => `
+
+
${l.tipe_bantuan === 'Pemberdayaan' ? '๐ Pemberdayaan' : '๐ Konsumtif'}
+
${esc(l.sub_kategori) || ''} ${l.keterangan ? 'โ ' + esc(l.keterangan) : ''}
+
๐
${l.tanggal_bantuan} ยท ${esc(l.nama_rumah_ibadah)}
+
`).join('')}
+ ${logs.length > 2 ? `
+${logs.length - 2} bantuan lainnya ยท Lihat detail lengkap di halaman Daftar Penduduk Miskin
` : ''}
+ ` : '
Belum ada riwayat bantuan
'}
+
+ Tambah Bantuan Baru
+
+ Dari Rumah Ibadah *
+ ${riSelectHTML}
+
+
+
+ Sub Kategori
+
+
+ `,
+ `Tutup
+ โ Tambah Log `);
+}
+
+
+async function saveLog(pmId) {
+ const body = {
+ penduduk_miskin_id: pmId,
+ rumah_ibadah_id: parseInt(document.getElementById('lb-ri').value),
+ tipe_bantuan: document.getElementById('lb-tipe').value,
+ tanggal_bantuan: document.getElementById('lb-tgl').value,
+ sub_kategori: document.getElementById('lb-sub').value,
+ keterangan: document.getElementById('lb-ket').value,
+ nilai_bantuan: document.getElementById('lb-nilai').value || null
+ };
+ const res = await callAPI('log_bantuan.php', 'POST', body);
+ if (res.status === 'success') {
+ toast('Log dicatat!', 'success');
+ closeModal();
+ switchTab('penduduk_miskin');
+ } else {
+ toast(res.message, 'error');
+ }
+}
+
+async function recalcAllProximity() {
+ await callAPI('penduduk_miskin.php?action=recalc_all', 'POST');
+}
diff --git a/js/features/map-forms.js b/js/features/map-forms.js
new file mode 100644
index 0000000..911766b
--- /dev/null
+++ b/js/features/map-forms.js
@@ -0,0 +1,225 @@
+function startAdd(type) {
+ cancelMapClick();
+ const btnId = type === 'ri' ? 'btn-add-ri' : 'btn-add-pm';
+ const label = type === 'ri' ? 'lokasi Rumah Ibadah' : 'lokasi Penduduk Miskin';
+ document.getElementById(btnId).classList.add('active');
+ showInstruction(`Klik di peta untuk menentukan ${label}`);
+ onceMapClick((lat, lng) => {
+ document.getElementById(btnId).classList.remove('active');
+ hideInstruction();
+ if (type === 'ri') openRIForm(null, lat, lng);
+ else openPMForm(null, lat, lng);
+ });
+}
+
+function openRIForm(data, lat, lng) {
+ const isEdit = !!data;
+ const fLat = isEdit ? data.latitude : lat;
+ const fLng = isEdit ? data.longitude : lng;
+ const isAdmin = window.currentUser && window.currentUser.role === 'admin';
+ const accountFieldsHTML = isAdmin ? `
+
+ ` : '';
+
+ openModal(isEdit ? 'Edit Rumah Ibadah' : 'Tambah Rumah Ibadah', `
+ ๐ Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}
+
+ Nama Rumah Ibadah *
+
+
+
+
+ Alamat
+
+
+
+ No. WhatsApp
+
+
+ ${accountFieldsHTML}`,
+ `Batal
+ ๐พ Simpan `);
+
+ // Attach dynamic username locking listener
+ if (isAdmin) {
+ const namaEl = document.getElementById('ri-nama');
+ const usernameEl = document.getElementById('ri-username');
+ if (namaEl && usernameEl) {
+ namaEl.addEventListener('input', () => {
+ usernameEl.value = namaEl.value
+ .toLowerCase()
+ .replace(/[^a-z0-9]/g, '_')
+ .replace(/_+/g, '_')
+ .replace(/^_+|_+$/g, '');
+ });
+ }
+ }
+
+ // Geocode address asynchronously
+ if (!isEdit) {
+ getAddressFromCoords(fLat, fLng).then(addr => {
+ const el = document.getElementById('ri-alamat');
+ if (el && el.value === 'Memuat alamat otomatis...') {
+ el.value = addr || '';
+ }
+ });
+ }
+}
+
+async function saveRI(id, lat, lng) {
+ const usernameEl = document.getElementById('ri-username');
+ const passwordEl = document.getElementById('ri-password');
+ const body = {
+ nama: document.getElementById('ri-nama').value.trim(),
+ jenis: document.getElementById('ri-jenis').value,
+ alamat: document.getElementById('ri-alamat').value.trim(),
+ no_wa: document.getElementById('ri-nowa').value.trim(),
+ latitude: lat,
+ longitude: lng,
+ radius_meter: parseInt(document.getElementById('ri-radius').value),
+ username: usernameEl ? usernameEl.value.trim() : '',
+ password: passwordEl ? passwordEl.value : ''
+ };
+ if (!body.nama) return toast('Nama wajib diisi', 'error');
+
+ // Validate account password for new Rumah Ibadah
+ const isAdmin = window.currentUser && window.currentUser.role === 'admin';
+ if (isAdmin && !id) {
+ if (!body.username) return toast('Username Rumah Ibadah wajib terisi', 'error');
+ if (!body.password) return toast('Password Rumah Ibadah wajib diisi', 'error');
+ }
+
+ const res = await callAPI(id ? `rumah_ibadah.php?id=${id}` : 'rumah_ibadah.php', id ? 'PUT' : 'POST', body);
+ if (res.status === 'success') {
+ toast(res.message, 'success');
+ closeModal();
+ await recalcAllProximity();
+ switchTab('rumah_ibadah');
+ } else {
+ toast(res.message, 'error');
+ }
+}
+
+function openPMForm(data, lat, lng) {
+ const isEdit = !!data;
+ const fLat = isEdit ? data.latitude : lat;
+ const fLng = isEdit ? data.longitude : lng;
+ openModal(isEdit ? 'Edit Penduduk Miskin' : 'Tambah Penduduk Miskin', `
+ ๐ Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}
+
+ Nama Lengkap *
+
+
+
+
+ Alamat
+
+
+
+ Jumlah Anggota Keluarga
+
+
`,
+ `Batal
+ ๐พ Simpan `);
+
+ // Geocode address asynchronously
+ if (!isEdit) {
+ getAddressFromCoords(fLat, fLng).then(addr => {
+ const el = document.getElementById('pm-alamat');
+ if (el && el.value === 'Memuat alamat otomatis...') {
+ el.value = addr || '';
+ }
+ });
+ }
+}
+
+async function savePM(id, lat, lng) {
+ const body = {
+ nama: document.getElementById('pm-nama').value.trim(),
+ no_wa: document.getElementById('pm-nowa').value.trim(),
+ nik: document.getElementById('pm-nik').value.trim(),
+ alamat: document.getElementById('pm-alamat').value.trim(),
+ jumlah_anggota: parseInt(document.getElementById('pm-jml').value) || 1,
+ latitude: lat,
+ longitude: lng
+ };
+ if (!body.nama) return toast('Nama wajib diisi', 'error');
+ const res = await callAPI(id ? `penduduk_miskin.php?id=${id}` : 'penduduk_miskin.php', id ? 'PUT' : 'POST', body);
+ if (res.status === 'success') {
+ toast(res.message, 'success');
+ closeModal();
+ switchTab('penduduk_miskin');
+ } else {
+ toast(res.message, 'error');
+ }
+}
+
+async function editRI(id) {
+ map.closePopup();
+ const res = await callAPI(`rumah_ibadah.php?id=${id}`);
+ if (res.data) openRIForm(res.data, res.data.latitude, res.data.longitude);
+}
+
+async function deleteRI(id) {
+ if (!confirm('Hapus rumah ibadah ini?')) return;
+ const res = await callAPI(`rumah_ibadah.php?id=${id}`, 'DELETE');
+ if (res.status === 'success') {
+ toast('Dihapus', 'success');
+ map.closePopup();
+ await recalcAllProximity();
+ switchTab('rumah_ibadah');
+ }
+}
+
+async function editPM(id) {
+ map.closePopup();
+ const res = await callAPI(`penduduk_miskin.php?id=${id}`);
+ if (res.data) openPMForm(res.data, res.data.latitude, res.data.longitude);
+}
+
+async function deletePM(id) {
+ if (!confirm('Hapus data ini?')) return;
+ const res = await callAPI(`penduduk_miskin.php?id=${id}`, 'DELETE');
+ if (res.status === 'success') {
+ toast('Dihapus', 'success');
+ map.closePopup();
+ await recalcAllProximity();
+ switchTab('penduduk_miskin');
+ }
+}
+
+// Haversine formula โ returns distance in meters between two lat/lng points
\ No newline at end of file
diff --git a/js/features/map-layers.js b/js/features/map-layers.js
new file mode 100644
index 0000000..50a77e6
--- /dev/null
+++ b/js/features/map-layers.js
@@ -0,0 +1,138 @@
+function loadRILayer(data) {
+ layers.rumahIbadah.clearLayers();
+ layers.ibadahRadius.clearLayers();
+
+ data.forEach(r => {
+ const circle = L.circle([r.latitude, r.longitude], {
+ radius: r.radius_meter,
+ color: '#f59e0b', fillColor: '#fef3c7', fillOpacity: 0.18,
+ weight: 1.5, dashArray: '6,4'
+ });
+ layers.ibadahRadius.addLayer(circle);
+
+ const icon = L.divIcon({
+ className: '',
+ html: `โช
`,
+ iconSize: [32, 32], iconAnchor: [16, 16]
+ });
+
+ const marker = L.marker([r.latitude, r.longitude], {
+ icon,
+ draggable: (window.currentUser && window.currentUser.role === 'admin')
+ }).bindPopup(riPopup(r));
+ marker._dataId = r.id;
+ marker.on('dragend', async function (ev) {
+ const { lat, lng } = ev.target.getLatLng();
+ circle.setLatLng([lat, lng]);
+ const res = await callAPI(`rumah_ibadah.php?id=${r.id}`, 'PUT', {
+ nama: r.nama, jenis: r.jenis, alamat: r.alamat,
+ no_wa: r.no_wa, latitude: lat, longitude: lng,
+ radius_meter: r.radius_meter
+ });
+ if (res.status === 'success') {
+ r.latitude = lat;
+ r.longitude = lng;
+ toast(`๐ ${r.nama} dipindahkan & koordinat diperbarui`, 'success');
+ await recalcAllProximity();
+ switchTab(currentTab);
+ } else {
+ toast('Gagal update lokasi: ' + res.message, 'error');
+ marker.setLatLng([r.latitude, r.longitude]);
+ }
+ });
+
+ layers.rumahIbadah.addLayer(marker);
+ });
+}
+
+function riPopup(r) {
+ const canEdit = window.currentUser && (window.currentUser.role === 'admin' || (window.currentUser.role === 'petugas_lapangan' && String(window.currentUser.rumah_ibadah_id) === String(r.id)));
+ const isAdmin = window.currentUser && window.currentUser.role === 'admin';
+ return `โช ${esc(r.nama)}
+ Jenis: ${r.jenis}
+ Radius: ${r.radius_meter} m
+ ${r.alamat ? `Alamat: ${esc(r.alamat)}
` : ''}
+ ${r.no_wa ? `WA: ${r.no_wa}
` : ''}
+
+ `;
+}
+
+function loadPMLayer(data) {
+ layers.pendudukMiskin.clearLayers();
+
+ data.forEach(p => {
+ const inR = p.status_proximity === 'dalam_radius';
+ const color = inR ? '#16a34a' : '#dc2626';
+ const hasBoth = p.bantuan_pemberdayaan > 0 && p.bantuan_konsumtif > 0;
+ const hasPemb = p.bantuan_pemberdayaan > 0;
+ const hasKons = p.bantuan_konsumtif > 0;
+
+ let dot = '';
+ if (hasBoth || hasPemb) dot = `
`;
+ if (hasKons && !hasPemb) dot = `
`;
+
+ const icon = L.divIcon({
+ className: '',
+ html: ``,
+ iconSize: [26, 26], iconAnchor: [13, 13]
+ });
+
+ const isAdmin = window.currentUser && window.currentUser.role === 'admin';
+ const isPetugas = window.currentUser && window.currentUser.role === 'petugas_lapangan';
+ const isResidentInRadiusOfPetugas = isPetugas && inR && String(p.rumah_ibadah_id) === String(window.currentUser.rumah_ibadah_id);
+ const canDrag = isAdmin || isResidentInRadiusOfPetugas;
+
+ const marker = L.marker([p.latitude, p.longitude], {
+ icon,
+ draggable: canDrag
+ }).bindPopup(pmPopup(p));
+ marker._dataId = p.id;
+ marker.on('dragend', async function (ev) {
+ const { lat, lng } = ev.target.getLatLng();
+ const res = await callAPI(`penduduk_miskin.php?id=${p.id}`, 'PUT', {
+ nama: p.nama, no_wa: p.no_wa, nik: p.nik,
+ alamat: p.alamat, jumlah_anggota: p.jumlah_anggota,
+ latitude: lat, longitude: lng
+ });
+ if (res.status === 'success') {
+ const prox = res.data?.proximity;
+ const statusMsg = prox?.status === 'dalam_radius' ? 'โ
Dalam radius' : 'โ Luar radius';
+ toast(`๐ ${p.nama} dipindahkan โ ${statusMsg}`, 'success');
+ switchTab('penduduk_miskin');
+ } else {
+ toast('Gagal update: ' + res.message, 'error');
+ marker.setLatLng([p.latitude, p.longitude]);
+ }
+ });
+
+ layers.pendudukMiskin.addLayer(marker);
+ });
+}
+
+function pmPopup(p) {
+ const inR = p.status_proximity === 'dalam_radius';
+ const isAdmin = window.currentUser && window.currentUser.role === 'admin';
+ const isPetugas = window.currentUser && window.currentUser.role === 'petugas_lapangan';
+ const isResidentInRadiusOfPetugas = isPetugas && inR && String(p.rumah_ibadah_id) === String(window.currentUser.rumah_ibadah_id);
+ const canEditOrLog = isAdmin || isResidentInRadiusOfPetugas;
+
+ return `๐ค ${esc(p.nama)}
+
+ ${inR ? `โ
Dalam radius โ ${esc(p.nama_rumah_ibadah || '')}` : 'โ Di luar radius rumah ibadah'}
+
+ ${p.no_wa ? `WA: ${p.no_wa}
` : ''}
+ ${p.nik ? `NIK: ${p.nik}
` : ''}
+ ${p.alamat ? `Alamat: ${esc(p.alamat)}
` : ''}
+ Keluarga: ${p.jumlah_anggota} jiwa
+ Bantuan: ๐${p.bantuan_pemberdayaan} ยท ๐${p.bantuan_konsumtif}
+
+ `;
+}
+
\ No newline at end of file
diff --git a/js/features/map-panels.js b/js/features/map-panels.js
new file mode 100644
index 0000000..cf48351
--- /dev/null
+++ b/js/features/map-panels.js
@@ -0,0 +1,147 @@
+function renderRumahIbadahPanel(pb) {
+ return Promise.all([callAPI('rumah_ibadah.php'), callAPI('penduduk_miskin.php')]).then(([riRes, pmRes]) => {
+ const ri = riRes.data || [];
+ const pm = pmRes.data || [];
+
+ pb.innerHTML = `
+
+
Visibilitas Layer
+ ${mkLayerItem('rumahIbadah', '#f59e0b', 'Rumah Ibadah', ri.length)}
+ ${mkLayerItem('ibadahRadius', '#fbbf24', 'Radius Jangkauan', ri.length, true)}
+ ${mkLayerItem('pendudukMiskin', '#16a34a', 'Penduduk Miskin', pm.length)}
+
+
+
Tambah Data
+
๏ผ Tambah Rumah Ibadah
+
+
+
Rumah Ibadah (${ri.length})
+
+ ${ri.length ? ri.map(r => `
+
+
โช
+
+
${esc(r.nama)}
+
${r.jenis} ยท R=${r.radius_meter}m
+
+
+ ${(window.currentUser && (window.currentUser.role === 'admin' || (window.currentUser.role === 'petugas_lapangan' && String(window.currentUser.rumah_ibadah_id) === String(r.id)))) ? `โ ` : ''}
+ ${(window.currentUser && window.currentUser.role === 'admin') ? `โ ` : ''}
+
+
`).join('') : '
Belum ada data
'}
+
+
`;
+
+ loadRILayer(ri);
+ loadPMLayer(pm);
+ registerSearchEntries('ri', ri.map(r => ({
+ key: `ri:${r.id}`,
+ label: r.nama,
+ subtitle: `Rumah Ibadah ยท ${r.jenis}${r.alamat ? ` ยท ${r.alamat}` : ''}`,
+ typeLabel: 'Rumah Ibadah',
+ icon: 'โช',
+ searchText: normalizeSearchText(`${r.nama} ${r.jenis} rumah ibadah`),
+ focus: () => focusMarkerById('rumahIbadah', r.id)
+ })));
+ registerSearchEntries('pm', pm.map(p => ({
+ key: `pm:${p.id}`,
+ label: p.nama,
+ subtitle: `Penduduk Miskin ยท ${p.jumlah_anggota} jiwa${p.alamat ? ` ยท ${p.alamat}` : ''}`,
+ typeLabel: 'Penduduk Miskin',
+ icon: '๐ค',
+ searchText: normalizeSearchText(`${p.nama} ${p.nik || ''} penduduk miskin`),
+ focus: () => focusMarkerById('pendudukMiskin', p.id)
+ })));
+ updateStatusCount();
+ });
+}
+
+function renderPendudukMiskinPanel(pb) {
+ return Promise.all([callAPI('rumah_ibadah.php'), callAPI('penduduk_miskin.php')]).then(([riRes, pmRes]) => {
+ const ri = riRes.data || [];
+ const pm = pmRes.data || [];
+
+ // Filter Penduduk Miskin for petugas_lapangan to only show those in their radius
+ let filteredPm = pm;
+ if (window.currentUser && window.currentUser.role === 'petugas_lapangan') {
+ const userRiId = String(window.currentUser.rumah_ibadah_id);
+ filteredPm = pm.filter(p => p.status_proximity === 'dalam_radius' && String(p.rumah_ibadah_id) === userRiId);
+ }
+
+ pb.innerHTML = `
+
+
Visibilitas Layer
+ ${mkLayerItem('rumahIbadah', '#f59e0b', 'Rumah Ibadah', ri.length)}
+ ${mkLayerItem('ibadahRadius', '#fbbf24', 'Radius Jangkauan', ri.length, true)}
+ ${mkLayerItem('pendudukMiskin', '#16a34a', 'Penduduk Miskin', pm.length)}
+
+
+
Tambah Data
+
๏ผ Tambah Penduduk Miskin
+
+
+
Penduduk Miskin (${filteredPm.length})
+
+ ${filteredPm.length ? filteredPm.map(p => `
+
+
${pmEmoji(p)}
+
+
${esc(p.nama)}
+
${p.total_bantuan} bantuan ยท ${p.jumlah_anggota} jiwa
+
+
+
+ ${p.status_proximity === 'dalam_radius' ? 'DALAM' : 'LUAR'}
+
+ ${(window.currentUser && (window.currentUser.role === 'admin' || (window.currentUser.role === 'petugas_lapangan' && p.status_proximity === 'dalam_radius' && String(p.rumah_ibadah_id) === String(window.currentUser.rumah_ibadah_id)))) ? `
+
๐
+
โ
+ ` : ''}
+ ${(window.currentUser && window.currentUser.role === 'admin') ? `
โ ` : ''}
+
+
`).join('') : '
Belum ada data
'}
+
+
`;
+
+ loadRILayer(ri);
+ loadPMLayer(pm);
+ registerSearchEntries('ri', ri.map(r => ({
+ key: `ri:${r.id}`,
+ label: r.nama,
+ subtitle: `Rumah Ibadah ยท ${r.jenis}${r.alamat ? ` ยท ${r.alamat}` : ''}`,
+ typeLabel: 'Rumah Ibadah',
+ icon: 'โช',
+ searchText: normalizeSearchText(`${r.nama} ${r.jenis} rumah ibadah`),
+ focus: () => focusMarkerById('rumahIbadah', r.id)
+ })));
+ registerSearchEntries('pm', pm.map(p => ({
+ key: `pm:${p.id}`,
+ label: p.nama,
+ subtitle: `Penduduk Miskin ยท ${p.jumlah_anggota} jiwa${p.alamat ? ` ยท ${p.alamat}` : ''}`,
+ typeLabel: 'Penduduk Miskin',
+ icon: '๐ค',
+ searchText: normalizeSearchText(`${p.nama} ${p.nik || ''} penduduk miskin`),
+ focus: () => focusMarkerById('pendudukMiskin', p.id)
+ })));
+ updateStatusCount();
+ });
+}
+
+function mkLayerItem(key, color, label, count, isRing = false) {
+ const dot = isRing
+ ? `
`
+ : `
`;
+ return `
+
${layerVisible[key] ? 'โ' : ''}
${dot}
+
${label}
+
${count}
+
`;
+}
+
+function pmEmoji(p) {
+ if (p.bantuan_pemberdayaan > 0 && p.bantuan_konsumtif > 0) return '๐';
+ if (p.bantuan_pemberdayaan > 0) return '๐';
+ if (p.bantuan_konsumtif > 0) return '๐';
+ return '๐ค';
+}
+
\ No newline at end of file
diff --git a/js/pages/daftar_pm.js b/js/pages/daftar_pm.js
new file mode 100644
index 0000000..bfc990e
--- /dev/null
+++ b/js/pages/daftar_pm.js
@@ -0,0 +1,337 @@
+let allData = [];
+ let allRI = [];
+ let filteredData = [];
+ let sortField = 'nama';
+ let sortDir = 'asc';
+ const PAGE_SIZE = 20;
+ let currentPage = 1;
+
+ document.addEventListener('auth-ready', function (e) {
+ const user = e.detail;
+ document.body.classList.add('auth-loaded', 'role-' + user.role);
+
+ document.getElementById('user-display-name').textContent = user.username;
+ const roleMap = { 'admin': 'Admin', 'petugas_lapangan': 'Rumah Ibadah', 'guest': 'Guest' };
+ document.getElementById('user-display-role').textContent = roleMap[user.role] || user.role;
+
+ // Hide admin-nav for non-admin
+ document.querySelectorAll('.admin-nav').forEach(el => {
+ if (user.role !== 'admin') el.style.display = 'none';
+ });
+
+ loadData(user);
+ });
+
+ async function loadData(user) {
+ try {
+ const [pmRes, riRes] = await Promise.all([
+ fetch('api/penduduk_miskin.php'),
+ fetch('api/rumah_ibadah.php')
+ ]);
+ const [pmData, riData] = await Promise.all([pmRes.json(), riRes.json()]);
+
+ allData = pmData.data || [];
+ allRI = riData.data || [];
+
+ // For petugas_lapangan: filter to only show residents in their radius
+ if (user && user.role === 'petugas_lapangan') {
+ const myRiId = String(user.rumah_ibadah_id);
+ allData = allData.filter(p => p.status_proximity === 'dalam_radius' && String(p.rumah_ibadah_id) === myRiId);
+ }
+
+ // Populate RI filter
+ const riFilter = document.getElementById('filter-ri');
+ allRI.forEach(r => {
+ const opt = document.createElement('option');
+ opt.value = r.id;
+ opt.textContent = r.nama;
+ riFilter.appendChild(opt);
+ });
+
+ updateSummary();
+ applyFilters();
+ } catch (err) {
+ document.getElementById('pm-tbody').innerHTML = `Gagal memuat data: ${esc(err.message)} `;
+ }
+ }
+
+ function updateSummary() {
+ const total = allData.length;
+ const jiwa = allData.reduce((s, p) => s + (parseInt(p.jumlah_anggota, 10) || 0), 0);
+ const dalam = allData.filter(p => p.status_proximity === 'dalam_radius').length;
+ const luar = allData.filter(p => p.status_proximity !== 'dalam_radius').length;
+ const withBantuan = allData.filter(p => p.total_bantuan > 0).length;
+ document.getElementById('sum-total').textContent = total;
+ document.getElementById('sum-jiwa').textContent = jiwa;
+ document.getElementById('sum-dalam').textContent = dalam;
+ document.getElementById('sum-luar').textContent = luar;
+ document.getElementById('sum-bantuan').textContent = withBantuan;
+ }
+
+ function applyFilters() {
+ const query = document.getElementById('search-input').value.toLowerCase().trim();
+ const proximity = document.getElementById('filter-proximity').value;
+ const riId = document.getElementById('filter-ri').value;
+ const bantuan = document.getElementById('filter-bantuan').value;
+
+ filteredData = allData.filter(p => {
+ const matchQ = !query || p.nama.toLowerCase().includes(query) || (p.nik && p.nik.includes(query)) || (p.alamat && p.alamat.toLowerCase().includes(query)) || (p.nama_rumah_ibadah && p.nama_rumah_ibadah.toLowerCase().includes(query));
+ const matchProx = !proximity || p.status_proximity === proximity;
+ const matchRI = !riId || String(p.rumah_ibadah_id) === riId;
+ const matchBantuan = !bantuan || (bantuan === 'pernah' ? p.total_bantuan > 0 : p.total_bantuan === 0);
+ return matchQ && matchProx && matchRI && matchBantuan;
+ });
+
+ // Sort
+ filteredData.sort((a, b) => {
+ let va = a[sortField], vb = b[sortField];
+ if (typeof va === 'string') va = va.toLowerCase();
+ if (typeof vb === 'string') vb = vb.toLowerCase();
+ if (va < vb) return sortDir === 'asc' ? -1 : 1;
+ if (va > vb) return sortDir === 'asc' ? 1 : -1;
+ return 0;
+ });
+
+ currentPage = 1;
+ document.getElementById('filter-count').textContent = `${filteredData.length} data`;
+ renderTable();
+ }
+
+ function sortBy(field) {
+ if (sortField === field) {
+ sortDir = sortDir === 'asc' ? 'desc' : 'asc';
+ } else {
+ sortField = field;
+ sortDir = 'asc';
+ }
+ // Update sort icons
+ document.querySelectorAll('.pm-table thead th').forEach(th => {
+ th.classList.remove('sort-asc', 'sort-desc');
+ });
+ const ths = document.querySelectorAll('.pm-table thead th');
+ const fieldIdx = { 'nama': 0, 'nik': 1, 'jumlah_anggota': 2, 'total_bantuan': 5 };
+ if (fieldIdx[field] !== undefined) {
+ ths[fieldIdx[field]].classList.add(sortDir === 'asc' ? 'sort-asc' : 'sort-desc');
+ }
+ applyFilters();
+ }
+
+ function renderTable() {
+ const tbody = document.getElementById('pm-tbody');
+ const total = filteredData.length;
+
+ if (total === 0) {
+ tbody.innerHTML = `
+
+
+
๐
+
Tidak ada data
+
Coba ubah kriteria pencarian atau filter.
+
+ `;
+ document.getElementById('pagination').style.display = 'none';
+ return;
+ }
+
+ const totalPages = Math.ceil(total / PAGE_SIZE);
+ if (currentPage > totalPages) currentPage = totalPages;
+ const start = (currentPage - 1) * PAGE_SIZE;
+ const slice = filteredData.slice(start, start + PAGE_SIZE);
+
+ tbody.innerHTML = slice.map((p, idx) => {
+ const inR = p.status_proximity === 'dalam_radius';
+ const hasPemb = p.bantuan_pemberdayaan > 0;
+ const hasKons = p.bantuan_konsumtif > 0;
+ return `
+
+
+ ${esc(p.nama)}
+ ${p.alamat ? `๐ ${esc(p.alamat.substring(0, 50))}${p.alamat.length > 50 ? 'โฆ' : ''}
` : ''}
+
+ ${p.nik ? esc(p.nik) : 'โ '}
+ ${p.jumlah_anggota} jiwa
+ ${inR ? 'โ
Dalam' : 'โ Luar'}
+
+ ${p.nama_rumah_ibadah ? `โช ${esc(p.nama_rumah_ibadah)} ` : 'โ '}
+
+
+
+ ${p.total_bantuan === 0 ? 'Belum ada ' : ''}
+ ${hasPemb ? `๐ ${p.bantuan_pemberdayaan} ` : ''}
+ ${hasKons ? `๐๏ธ ${p.bantuan_konsumtif} ` : ''}
+
+
+
+
+ ๐ Detail
+
+
+ `;
+ }).join('');
+
+ // Pagination
+ const pag = document.getElementById('pagination');
+ const pagInfo = document.getElementById('pagination-info');
+ const pagBtns = document.getElementById('pagination-btns');
+ pag.style.display = 'flex';
+ pagInfo.textContent = `Menampilkan ${start + 1}โ${Math.min(start + PAGE_SIZE, total)} dari ${total} data`;
+
+ // Build page buttons
+ let btns = '';
+ btns += `โน `;
+ const rangeStart = Math.max(1, currentPage - 2);
+ const rangeEnd = Math.min(totalPages, currentPage + 2);
+ if (rangeStart > 1) btns += `1 ${rangeStart > 2 ? 'โฆ ' : ''}`;
+ for (let i = rangeStart; i <= rangeEnd; i++) {
+ btns += `${i} `;
+ }
+ if (rangeEnd < totalPages) btns += `${rangeEnd < totalPages - 1 ? 'โฆ ' : ''}${totalPages} `;
+ btns += `โบ `;
+ pagBtns.innerHTML = btns;
+ }
+
+ function goPage(page) {
+ const totalPages = Math.ceil(filteredData.length / PAGE_SIZE);
+ if (page < 1 || page > totalPages) return;
+ currentPage = page;
+ renderTable();
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }
+
+ async function openDrawer(id) {
+ document.getElementById('detail-overlay').classList.add('open');
+ document.getElementById('drawer-body').innerHTML = `
+ `;
+
+ try {
+ const [pmRes, logRes] = await Promise.all([
+ fetch(`api/penduduk_miskin.php?id=${id}`),
+ fetch(`api/log_bantuan.php?penduduk_miskin_id=${id}`)
+ ]);
+ const [pmData, logData] = await Promise.all([pmRes.json(), logRes.json()]);
+ if (pmData.status !== 'success') throw new Error(pmData.message);
+ renderDrawer(pmData.data, logData.data || []);
+ } catch (err) {
+ document.getElementById('drawer-body').innerHTML = `Gagal memuat: ${esc(err.message)}
`;
+ }
+ }
+
+ function renderDrawer(p, logs) {
+ document.getElementById('drawer-title').textContent = p.nama;
+ const inR = p.status_proximity === 'dalam_radius';
+
+ const isAdmin = window.currentUser && window.currentUser.role === 'admin';
+ const isPetugas = window.currentUser && window.currentUser.role === 'petugas_lapangan';
+ const isResidentInMyRadius = isPetugas && inR && String(p.rumah_ibadah_id) === String(window.currentUser.rumah_ibadah_id);
+ const canEdit = isAdmin || isResidentInMyRadius;
+
+ document.getElementById('drawer-body').innerHTML = `
+
+
+ ${inR ? `โ
Dalam radius jangkauan ${esc(p.nama_rumah_ibadah || 'rumah ibadah')} ` : 'โ Di luar radius semua rumah ibadah'}
+
+
+
+ Informasi Pribadi
+
+
Nama Lengkap ${esc(p.nama)}
+
NIK ${p.nik ? esc(p.nik) : 'โ'}
+
No. WhatsApp ${p.no_wa ? esc(p.no_wa) : 'โ'}
+
Jumlah Anggota ${p.jumlah_anggota} jiwa
+
Alamat ${p.alamat ? esc(p.alamat) : 'โ'}
+
Koordinat ${p.latitude.toFixed(6)}, ${p.longitude.toFixed(6)}
+
Terdata Sejak ${new Date(p.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })}
+
+
+
+ Ringkasan Bantuan
+
+ ${p.ringkasan_bantuan && p.ringkasan_bantuan.length ? p.ringkasan_bantuan.map(rb => `
+
+ ${rb.tipe_bantuan === 'Pemberdayaan' ? '๐ Pemberdayaan' : '๐๏ธ Konsumtif'}
+ ${rb.jumlah} kali
+
+ `).join('') : '
Belum pernah menerima bantuan.
'}
+
+
+
+ ${canEdit ? `
+
+ ๐ Catat Bantuan
+ โ๏ธ Edit di Peta
+
+ ` : ''}
+
+
+ ${logs.length ? `
+ Riwayat Bantuan (${logs.length})
+
+ ${logs.map(l => `
+
+
+
+
${l.tipe_bantuan === 'Pemberdayaan' ? '๐' : '๐๏ธ'} ${esc(l.tipe_bantuan)}${l.sub_kategori ? ' โ ' + esc(l.sub_kategori) : ''}
+
Dari: ${esc(l.nama_rumah_ibadah)}${l.nilai_bantuan ? ' ยท Rp' + new Intl.NumberFormat('id-ID').format(l.nilai_bantuan) : ''}${l.keterangan ? ' ยท ' + esc(l.keterangan) : ''}
+
+
${l.tanggal_bantuan}
+
+ `).join('')}
+
+ ` : ''}
+ `;
+ }
+
+ function closeDrawer(e) {
+ if (e && e.target !== document.getElementById('detail-overlay')) return;
+ document.getElementById('detail-overlay').classList.remove('open');
+ }
+
+ function openLogBantuanFromDrawer(pmId, pmNama) {
+ // Redirect to peta with log bantuan opened
+ window.location.href = '?page=map';
+ }
+
+ function exportCSV() {
+ if (!filteredData.length) return toast('Tidak ada data untuk diekspor', 'error');
+ const headers = ['Nama', 'NIK', 'No WA', 'Alamat', 'Jumlah Anggota', 'Status Proximity', 'Rumah Ibadah', 'Total Bantuan', 'Bantuan Pemberdayaan', 'Bantuan Konsumtif', 'Latitude', 'Longitude'];
+ const rows = filteredData.map(p => [
+ p.nama, p.nik || '', p.no_wa || '', p.alamat || '',
+ p.jumlah_anggota, p.status_proximity, p.nama_rumah_ibadah || '',
+ p.total_bantuan, p.bantuan_pemberdayaan, p.bantuan_konsumtif,
+ p.latitude, p.longitude
+ ]);
+ const csv = [headers, ...rows].map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n');
+ const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `penduduk_miskin_${new Date().toISOString().slice(0, 10)}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ toast(`${filteredData.length} data berhasil diekspor`, 'success');
+ }
+
+ function esc(s) {
+ if (s == null) return '';
+ return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ }
+
+ function toast(msg, type = 'info') {
+ const t = document.createElement('div');
+ t.className = `toast ${type}`;
+ t.textContent = msg;
+ document.getElementById('toast-container').appendChild(t);
+ setTimeout(() => t.remove(), 3500);
+ }
+
+ async function handleLogout() {
+ if (!confirm('Apakah Anda yakin ingin keluar?')) return;
+ try {
+ const response = await fetch('api/logout.php');
+ const data = await response.json();
+ if (data.status === 'success') window.location.href = '?page=login';
+ } catch { alert('Gagal logout.'); }
+ }
\ No newline at end of file
diff --git a/js/pages/dashboard.js b/js/pages/dashboard.js
new file mode 100644
index 0000000..ead4197
--- /dev/null
+++ b/js/pages/dashboard.js
@@ -0,0 +1,198 @@
+document.addEventListener('auth-ready', function (e) {
+ const user = e.detail;
+ document.body.classList.add('auth-loaded');
+
+ // Update User Profile in Sidebar
+ document.getElementById('user-display-name').textContent = user.username;
+
+ // Format role name nicely
+ const roleMap = {
+ 'admin': 'Admin',
+ 'petugas_lapangan': 'Rumah Ibadah',
+ 'guest': 'Guest'
+ };
+ document.getElementById('user-display-role').textContent = roleMap[user.role] || user.role;
+
+ // Show admin-only nav items
+ if (user.role === 'admin') {
+ document.getElementById('nav-akun').style.display = 'flex';
+ }
+
+ // Update Greeting
+ document.getElementById('greeting-title').textContent = `Selamat Datang, ${user.username}!`;
+
+ // Load stats data
+ loadDashboardStats();
+ });
+
+ // Format current date nicely in Indonesian
+ const dateOpts = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
+ document.getElementById('current-date').textContent = new Date().toLocaleDateString('id-ID', dateOpts);
+
+ async function handleLogout() {
+ if (!confirm('Apakah Anda yakin ingin keluar?')) return;
+ try {
+ const response = await fetch('api/logout.php');
+ const data = await response.json();
+ if (data.status === 'success') {
+ window.location.href = '?page=login';
+ }
+ } catch (err) {
+ alert('Gagal melakukan logout.');
+ }
+ }
+
+ async function loadDashboardStats() {
+ try {
+ const response = await fetch('api/dashboard_stats.php');
+ const result = await response.json();
+
+ if (result.status === 'success') {
+ const stats = result.data;
+ renderStatsCards(stats);
+ renderProximityChart(stats.proximity);
+ renderBantuanChart(stats.tipe_bantuan);
+ renderBantuanTable(stats.bantuan_terbaru);
+ renderRiTable(stats.ri_teraktif);
+ }
+ } catch (err) {
+ console.error('Gagal memuat statistik dashboard:', err);
+ }
+ }
+
+ function renderStatsCards(stats) {
+ // 1. Penduduk Miskin
+ document.getElementById('pm-total-kk').textContent = `${stats.penduduk_miskin.total_kk} KK`;
+ document.getElementById('pm-total-jiwa').textContent = `${stats.penduduk_miskin.total_jiwa} Jiwa terdata`;
+
+ // 2. Rumah Ibadah
+ document.getElementById('ri-total').textContent = stats.rumah_ibadah.total;
+
+
+ // 5. Log Bantuan
+ document.getElementById('lb-total-count').textContent = `${stats.log_bantuan.total_count} kali`;
+ const rpBantuan = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(stats.log_bantuan.total_nilai || 0);
+ document.getElementById('lb-total-nilai').textContent = `Total ${rpBantuan}`;
+ }
+
+ let proximityChart = null;
+ function renderProximityChart(proximity) {
+ const ctx = document.getElementById('chart-proximity').getContext('2d');
+ if (proximityChart) proximityChart.destroy();
+
+ proximityChart = new Chart(ctx, {
+ type: 'doughnut',
+ data: {
+ labels: ['Dalam Radius (Dalam Jangkauan)', 'Luar Radius (Di Luar Jangkauan)'],
+ datasets: [{
+ data: [proximity.dalam_radius, proximity.luar_radius],
+ backgroundColor: ['#10b981', '#f43f5e'],
+ borderWidth: 4,
+ borderColor: '#ffffff',
+ hoverOffset: 4
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 12,
+ font: { family: 'Inter', size: 11, weight: '600' },
+ color: '#475569'
+ }
+ }
+ },
+ cutout: '70%'
+ }
+ });
+ }
+
+ let bantuanChart = null;
+ function renderBantuanChart(tipeBantuan) {
+ const ctx = document.getElementById('chart-bantuan-tipe').getContext('2d');
+ if (bantuanChart) bantuanChart.destroy();
+
+ bantuanChart = new Chart(ctx, {
+ type: 'bar',
+ data: {
+ labels: ['๐ Pemberdayaan (Pelatihan)', '๐๏ธ Konsumtif (Makan/Barang)'],
+ datasets: [{
+ label: 'Penyaluran Bantuan',
+ data: [tipeBantuan.Pemberdayaan, tipeBantuan.Konsumtif],
+ backgroundColor: ['#2563eb', '#f59e0b'],
+ borderRadius: 8,
+ barThickness: 36
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: {
+ y: {
+ beginAtZero: true,
+ ticks: { stepSize: 1, color: '#64748b', font: { family: 'Inter' } },
+ grid: { color: '#f1f5f9' }
+ },
+ x: {
+ ticks: { color: '#475569', font: { family: 'Inter', weight: '600' } },
+ grid: { display: false }
+ }
+ },
+ plugins: {
+ legend: {
+ display: false
+ }
+ }
+ }
+ });
+ }
+
+ function renderBantuanTable(bantuanList) {
+ const tbody = document.getElementById('table-bantuan-body');
+ if (!bantuanList || bantuanList.length === 0) {
+ tbody.innerHTML = 'Belum ada riwayat bantuan ';
+ return;
+ }
+
+ tbody.innerHTML = bantuanList.map(b => {
+ const rp = b.nilai_bantuan ? new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(b.nilai_bantuan) : 'โ';
+ const badgeClass = b.tipe_bantuan === 'Pemberdayaan' ? 'pemberdayaan' : 'konsumtif';
+ return `
+
+ ${b.tanggal_bantuan}
+ ${escapeHTML(b.nama_penduduk)}
+ ${b.tipe_bantuan}
+ ${escapeHTML(b.sub_kategori || 'โ')}
+ ${rp}
+ ${escapeHTML(b.nama_rumah_ibadah)}
+
+ `;
+ }).join('');
+ }
+
+ function renderRiTable(riList) {
+ const tbody = document.getElementById('table-ri-body');
+ if (!riList || riList.length === 0) {
+ tbody.innerHTML = 'Belum ada data penyaluran ';
+ return;
+ }
+
+ tbody.innerHTML = riList.map(r => {
+ const rp = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(r.nilai);
+ return `
+
+ ${escapeHTML(r.nama)}
+ ${r.count} kali
+ ${rp}
+
+ `;
+ }).join('');
+ }
+
+ function escapeHTML(str) {
+ if (str == null) return '';
+ return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ }
\ No newline at end of file
diff --git a/js/pages/manajemen_akun.js b/js/pages/manajemen_akun.js
new file mode 100644
index 0000000..78a788b
--- /dev/null
+++ b/js/pages/manajemen_akun.js
@@ -0,0 +1,416 @@
+let allData = [];
+
+ document.addEventListener('auth-ready', function(e) {
+ const user = e.detail;
+ document.body.classList.add('auth-loaded', 'role-admin');
+ document.getElementById('user-display-name').textContent = user.username;
+ document.getElementById('user-display-role').textContent = 'Admin';
+ loadData();
+ });
+
+ async function loadData() {
+ try {
+ const res = await fetch('api/manajemen_akun.php');
+ const data = await res.json();
+ if (data.status !== 'success') throw new Error(data.message);
+ allData = data.data || [];
+ renderCards(allData);
+ } catch (err) {
+ document.getElementById('ri-grid').innerHTML = `
+
+
โ ๏ธ
+
Gagal memuat data
+
${esc(err.message)}
+
`;
+ }
+ }
+
+ function renderCards(data) {
+ const grid = document.getElementById('ri-grid');
+ document.getElementById('filter-count').textContent = `${data.length} rumah ibadah`;
+
+ if (!data.length) {
+ grid.innerHTML = `
+
+
๐๏ธ
+
Belum ada rumah ibadah
+
Tambahkan rumah ibadah melalui halaman Peta Interaktif.
+
`;
+ return;
+ }
+
+ grid.innerHTML = data.map(r => {
+ const hasAccount = !!r.username;
+ const rpFormatted = new Intl.NumberFormat('id-ID', { notation: 'compact', maximumFractionDigits: 1 }).format(r.total_nilai_bantuan);
+ return `
+
+
${hasAccount ? 'โ Akun Aktif' : 'โ Tanpa Akun'}
+
+
+
+
+
${r.pm_dalam_radius}
+
Warga Binaan
+
+
+
${r.total_penyaluran}
+
Penyaluran
+
+
+
${r.total_nilai_bantuan > 0 ? rpFormatted : 'โ'}
+
Total Nilai
+
+
+
+
+ ${hasAccount
+ ? `๐ค ${esc(r.username)} `
+ : `Belum ada akun terdaftar `}
+
+
+
+
`;
+ }).join('');
+ }
+
+ function filterCards() {
+ const query = document.getElementById('search-input').value.toLowerCase().trim();
+ const jenis = document.getElementById('filter-jenis').value;
+ const akun = document.getElementById('filter-akun').value;
+
+ const filtered = allData.filter(r => {
+ const matchQuery = !query || r.nama.toLowerCase().includes(query) || r.jenis.toLowerCase().includes(query) || (r.alamat && r.alamat.toLowerCase().includes(query));
+ const matchJenis = !jenis || r.jenis === jenis;
+ const matchAkun = !akun || (akun === 'active' ? !!r.username : !r.username);
+ return matchQuery && matchJenis && matchAkun;
+ });
+ renderCards(filtered);
+ }
+
+ async function openDrawer(id) {
+ pwVisible = false; // Reset password visibility on each open
+ document.getElementById('detail-overlay').classList.add('open');
+ document.getElementById('drawer-body').innerHTML = `
+ `;
+
+ try {
+ const res = await fetch(`api/manajemen_akun.php?id=${id}`);
+ const data = await res.json();
+ if (data.status !== 'success') throw new Error(data.message);
+ renderDrawer(data.data);
+ } catch (err) {
+ document.getElementById('drawer-body').innerHTML = `Gagal memuat: ${esc(err.message)}
`;
+ }
+ }
+
+ function renderDrawer(r) {
+ document.getElementById('drawer-title').textContent = r.nama;
+ const rp = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(r.total_nilai_bantuan);
+
+ document.getElementById('drawer-body').innerHTML = `
+
+
+
+
${r.pm_dalam_radius}
+
Warga Binaan
+
+
+
${r.total_penyaluran}
+
Total Penyaluran
+
+
+
${r.jumlah_penerima_unik}
+
Penerima Unik
+
+
+
+
+
+ Informasi Rumah Ibadah
+
+
Jenis ${riEmoji(r.jenis)} ${esc(r.jenis)}
+
Radius Jangkauan ${r.radius_meter} meter
+
Alamat ${r.alamat ? esc(r.alamat) : 'โ'}
+
No. WhatsApp ${r.no_wa ? esc(r.no_wa) : 'โ'}
+
Koordinat ${r.latitude.toFixed(6)}, ${r.longitude.toFixed(6)}
+
Bantuan Pemberdayaan ๐ ${r.bantuan_pemberdayaan} kali
+
Bantuan Konsumtif ๐๏ธ ${r.bantuan_konsumtif} kali
+
Penyaluran Terakhir ${r.tanggal_bantuan_terakhir || 'โ'}
+
Terdaftar Sejak ${new Date(r.created_at).toLocaleDateString('id-ID', {day:'numeric',month:'long',year:'numeric'})}
+
+
+
+ Informasi Akun
+
+ ${r.username ? `
+
Username ${esc(r.username)}
+
Password
+
+ ${r.password_plain ? 'โขโขโขโขโขโขโขโข' : 'Tidak diketahui '}
+ ${r.password_plain ? `๐ Lihat ` : ''}
+
+
+
Status โ Aktif
+
Akun Dibuat ${r.akun_dibuat ? new Date(r.akun_dibuat).toLocaleDateString('id-ID',{day:'numeric',month:'long',year:'numeric'}) : 'โ'}
+
+ ๐ Reset Password
+ ๐๏ธ Hapus Akun
+
+ ` : `
+
+ Belum ada akun terdaftar untuk rumah ibadah ini.
+
+
+ ๐ Buat Akun
+
+ `}
+
+
+
+ ${r.riwayat_bantuan && r.riwayat_bantuan.length ? `
+ Riwayat Penyaluran Terbaru
+
+ ${r.riwayat_bantuan.map(l => `
+
+
+
+
${esc(l.nama_penerima)}
+
${l.tipe_bantuan === 'Pemberdayaan' ? '๐' : '๐๏ธ'} ${esc(l.tipe_bantuan)}${l.sub_kategori ? ' ยท ' + esc(l.sub_kategori) : ''}${l.nilai_bantuan ? ' ยท Rp' + new Intl.NumberFormat('id-ID').format(l.nilai_bantuan) : ''}
+
+
${l.tanggal_bantuan}
+
+ `).join('')}
+
+ ` : ''}
+ `;
+ }
+
+ function closeDrawer(e) {
+ if (e && e.target !== document.getElementById('detail-overlay')) return;
+ document.getElementById('detail-overlay').classList.remove('open');
+ }
+
+ let pwVisible = false;
+ function togglePw(plainPassword) {
+ const display = document.getElementById('pw-display');
+ const btn = document.getElementById('pw-toggle-btn');
+ if (!display || !btn) return;
+ pwVisible = !pwVisible;
+ if (pwVisible) {
+ display.textContent = plainPassword;
+ display.style.color = '#0f172a';
+ btn.textContent = '๐ Sembunyikan';
+ btn.style.background = '#f0fdf4';
+ btn.style.borderColor = '#bbf7d0';
+ btn.style.color = '#15803d';
+ } else {
+ display.textContent = 'โขโขโขโขโขโขโขโข';
+ display.style.color = '';
+ btn.textContent = '๐ Lihat';
+ btn.style.background = '#f8fafc';
+ btn.style.borderColor = '#e2e8f0';
+ btn.style.color = '#475569';
+ }
+ }
+
+ function openResetPassword(id, username) {
+ openModal('Reset Password Akun', `
+
+ Username
+
+
+
+ Password Baru *
+
+
+
+ Konfirmasi Password *
+
+
+ `, `
+ Batal
+ ๐ Simpan Password
+ `);
+ }
+
+ async function doResetPassword(riId) {
+ const pw = document.getElementById('new-password').value;
+ const conf = document.getElementById('confirm-password').value;
+ if (!pw || pw.length < 6) return toast('Password minimal 6 karakter', 'error');
+ if (pw !== conf) return toast('Konfirmasi password tidak cocok', 'error');
+
+ // Find the data โ cast ID to number to avoid string vs number mismatch
+ const ri = allData.find(r => Number(r.id) === Number(riId));
+ if (!ri) {
+ toast('Data rumah ibadah tidak ditemukan di cache, coba refresh halaman', 'error');
+ return;
+ }
+
+ const btn = document.querySelector('#modal-footer .btn-primary');
+ if (btn) { btn.disabled = true; btn.textContent = 'Menyimpanโฆ'; }
+
+ try {
+ const res = await fetch(`api/rumah_ibadah.php?id=${riId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ nama: ri.nama, jenis: ri.jenis, alamat: ri.alamat,
+ no_wa: ri.no_wa, latitude: ri.latitude, longitude: ri.longitude,
+ radius_meter: ri.radius_meter, username: ri.username, password: pw
+ })
+ });
+ const data = await res.json();
+ if (data.status === 'success') {
+ toast('Password berhasil direset!', 'success');
+ closeModal();
+ await loadData(); // Refresh allData
+ openDrawer(riId); // Reopen drawer with fresh data
+ } else {
+ toast(data.message || 'Gagal reset password', 'error');
+ }
+ } catch (err) {
+ toast('Terjadi kesalahan: ' + err.message, 'error');
+ } finally {
+ if (btn) { btn.disabled = false; btn.textContent = '๐ Simpan Password'; }
+ }
+ }
+
+ function openBuatAkun(id, namaRI) {
+ const sanitized = namaRI.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '');
+ openModal('Buat Akun Rumah Ibadah', `
+
+ Nama Rumah Ibadah
+
+
+
+ Username (otomatis)
+
+
+
+ Password *
+
+
+ `, `
+ Batal
+ ๐ Buat Akun
+ `);
+ }
+
+ async function doBuatAkun(riId) {
+ const username = document.getElementById('buat-username').value;
+ const password = document.getElementById('buat-password').value;
+ if (!password || password.length < 6) return toast('Password minimal 6 karakter', 'error');
+
+ // Cast ID to number to avoid string vs number mismatch from PHP JSON
+ const ri = allData.find(r => Number(r.id) === Number(riId));
+ if (!ri) {
+ toast('Data rumah ibadah tidak ditemukan di cache, coba refresh halaman', 'error');
+ return;
+ }
+
+ const btn = document.querySelector('#modal-footer .btn-primary');
+ if (btn) { btn.disabled = true; btn.textContent = 'Membuatโฆ'; }
+
+ try {
+ const res = await fetch(`api/rumah_ibadah.php?id=${riId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ nama: ri.nama, jenis: ri.jenis, alamat: ri.alamat,
+ no_wa: ri.no_wa, latitude: ri.latitude, longitude: ri.longitude,
+ radius_meter: ri.radius_meter, username, password
+ })
+ });
+ const data = await res.json();
+ if (data.status === 'success') {
+ toast('Akun berhasil dibuat!', 'success');
+ closeModal();
+ await loadData(); // Refresh allData
+ openDrawer(riId); // Reopen drawer with new account info
+ } else {
+ toast(data.message || 'Gagal membuat akun', 'error');
+ }
+ } catch (err) {
+ toast('Terjadi kesalahan: ' + err.message, 'error');
+ } finally {
+ if (btn) { btn.disabled = false; btn.textContent = '๐ Buat Akun'; }
+ }
+ }
+
+ async function hapusAkun(id, nama) {
+ if (!confirm(`Hapus akun login untuk "${nama}"?\nRumah ibadah tidak akan dihapus, hanya akun loginnya.`)) return;
+ const res = await fetch(`api/manajemen_akun.php?id=${id}`, { method: 'DELETE' });
+ const data = await res.json();
+ if (data.status === 'success') {
+ toast(data.message, 'success');
+ document.getElementById('detail-overlay').classList.remove('open');
+ await loadData();
+ } else {
+ toast(data.message || 'Gagal menghapus akun', 'error');
+ }
+ }
+
+ function goToEdit(id) {
+ window.location.href = `?page=map`;
+ }
+
+ function riEmoji(jenis) {
+ const map = { 'Masjid': '๐', 'Gereja': 'โช', 'Pura': '๐', 'Vihara': '๐ฏ', 'Klenteng': '๐ฎ', 'Lainnya': '๐๏ธ' };
+ return map[jenis] || '๐๏ธ';
+ }
+
+ function esc(s) {
+ if (s == null) return '';
+ return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ }
+
+ function toast(msg, type = 'info') {
+ const t = document.createElement('div');
+ t.className = `toast ${type}`;
+ t.textContent = msg;
+ document.getElementById('toast-container').appendChild(t);
+ setTimeout(() => t.remove(), 3500);
+ }
+
+ function openModal(title, bodyHTML, footerHTML) {
+ document.getElementById('modal-title').textContent = title;
+ document.getElementById('modal-body').innerHTML = bodyHTML;
+ document.getElementById('modal-footer').innerHTML = footerHTML;
+ document.getElementById('modal-overlay').classList.add('open');
+ }
+ function closeModal() {
+ document.getElementById('modal-overlay').classList.remove('open');
+ }
+ document.getElementById('modal-overlay').addEventListener('click', e => {
+ if (e.target.id === 'modal-overlay') closeModal();
+ });
+
+ async function handleLogout() {
+ if (!confirm('Apakah Anda yakin ingin keluar?')) return;
+ try {
+ const response = await fetch('api/logout.php');
+ const data = await response.json();
+ if (data.status === 'success') window.location.href = '?page=login';
+ } catch { alert('Gagal logout.'); }
+ }
\ No newline at end of file
diff --git a/pages/daftar_pm.php b/pages/daftar_pm.php
new file mode 100644
index 0000000..ee22847
--- /dev/null
+++ b/pages/daftar_pm.php
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
๐จโ๐ฉโ๐งโ๐ฆ
+
+
+
+
+
+
๐
+
+
โ
+
Penerima Bantuan
+
+
+
+
+
+
+
+ โ
+
+
+
+ Semua Status
+ Dalam Radius
+ Luar Radius
+
+
+ Semua Rumah Ibadah
+
+
+ Semua Bantuan
+ Pernah Menerima
+ Belum Pernah
+
+
โ
+
+
+
+
+
+
+
+
+
+
+
Detail Penduduk Miskin
+ โ
+
+
+
+
diff --git a/pages/dashboard.php b/pages/dashboard.php
new file mode 100644
index 0000000..e910adb
--- /dev/null
+++ b/pages/dashboard.php
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
๐ฅ
+
+
Penduduk Miskin
+
0 KK
+
0 Jiwa terdata
+
+
+
+
+
+
โช
+
+
Rumah Ibadah
+
0
+
Rumah ibadah terdaftar
+
+
+
+
+
+
+
+
๐
+
+
Bantuan Sosial
+
0 kali
+
Total Rp 0
+
+
+
+
+
+
+
+
+
Analisis Kedekatan Warga Miskin (Proximity)
+
+
+
+
+
+
+
+
Distribusi Tipe Bantuan Sosial
+
+
+
+
+
+
+
+
+
+
+
Aktivitas Penyaluran Bantuan Terbaru
+
+
+
+
+ Tanggal
+ Penerima
+ Tipe Bantuan
+ Sub Kategori
+ Nilai (Rp)
+ Penyalur
+
+
+
+
+ Memuat data aktivitas...
+
+
+
+
+
+
+
+
+
Rumah Ibadah Penyalur Teraktif
+
+
+
+
+ Nama Rumah Ibadah
+ Frekuensi
+ Total Nilai (Rp)
+
+
+
+
+ Memuat data penyalur...
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pages/login.php b/pages/login.php
new file mode 100644
index 0000000..dcec6a9
--- /dev/null
+++ b/pages/login.php
@@ -0,0 +1,110 @@
+
+
+
+
+
+ Masuk - WebGIS Poverty Mapping
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/manajemen_akun.php b/pages/manajemen_akun.php
new file mode 100644
index 0000000..31d4bd6
--- /dev/null
+++ b/pages/manajemen_akun.php
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+ โ
+
+
+
+ Semua Jenis
+ Masjid
+ Gereja
+ Pura
+ Vihara
+ Klenteng
+ Lainnya
+
+
+ Semua Status Akun
+ Akun Aktif
+ Belum Ada Akun
+
+
โ
+
+
+
+
+
+
+
Memuat data rumah ibadahโฆ
+
+
+
+
+
+
+
+
+
Detail Rumah Ibadah
+ โ
+
+
+
+
diff --git a/pages/map.php b/pages/map.php
new file mode 100644
index 0000000..568eff3
--- /dev/null
+++ b/pages/map.php
@@ -0,0 +1,35 @@
+
\ No newline at end of file