From 468541b1c82bbb6c54d0aecf18f236a6c2dd72fc Mon Sep 17 00:00:00 2001 From: Abimanyu Ridho Date: Thu, 11 Jun 2026 01:50:37 +0700 Subject: [PATCH] mengubah sistem menjadi lebih terfokus ke poverty --- .gitignore | 10 +- README.md | 66 +- api/config/database.php | 6 +- ...local.php.example => database.php.example} | 0 api/config/setup_users.php | 51 + api/dashboard_stats.php | 73 + api/data_jalan.php | 112 -- api/data_parsil.php | 112 -- api/helpers/response.php | 199 +- api/login.php | 50 + api/logout.php | 22 + api/manajemen_akun.php | 122 ++ api/me.php | 21 + api/rumah_ibadah.php | 180 +- api/spbu.php | 105 - components/head.php | 43 + components/scripts.php | 16 + components/sidebar.php | 76 + cookie.txt | 4 + css/{app.css => base.css} | 1689 ++++++++--------- css/components.css | 389 ++++ css/layout.css | 158 ++ css/map.css | 97 + css/pages/daftar_pm.css | 566 ++++++ css/pages/dashboard.css | 149 ++ css/pages/manajemen_akun.css | 434 +++++ database.sql | 38 +- index.html | 91 - index.php | 111 ++ js/app-core.js | 452 ++--- js/feature-jalan.js | 220 --- js/feature-kemiskinan.js | 451 ----- js/feature-parsil.js | 237 --- js/feature-spbu.js | 158 -- js/features/log-bantuan.js | 150 ++ js/features/map-forms.js | 225 +++ js/features/map-layers.js | 138 ++ js/features/map-panels.js | 147 ++ js/pages/daftar_pm.js | 337 ++++ js/pages/dashboard.js | 198 ++ js/pages/manajemen_akun.js | 416 ++++ pages/daftar_pm.php | 119 ++ pages/dashboard.php | 114 ++ pages/login.php | 110 ++ pages/manajemen_akun.php | 61 + pages/map.php | 35 + 46 files changed, 5753 insertions(+), 2805 deletions(-) rename api/config/{database.local.php.example => database.php.example} (100%) create mode 100644 api/config/setup_users.php create mode 100644 api/dashboard_stats.php delete mode 100644 api/data_jalan.php delete mode 100644 api/data_parsil.php create mode 100644 api/login.php create mode 100644 api/logout.php create mode 100644 api/manajemen_akun.php create mode 100644 api/me.php delete mode 100644 api/spbu.php create mode 100644 components/head.php create mode 100644 components/scripts.php create mode 100644 components/sidebar.php create mode 100644 cookie.txt rename css/{app.css => base.css} (55%) create mode 100644 css/components.css create mode 100644 css/layout.css create mode 100644 css/map.css create mode 100644 css/pages/daftar_pm.css create mode 100644 css/pages/dashboard.css create mode 100644 css/pages/manajemen_akun.css delete mode 100644 index.html create mode 100644 index.php delete mode 100644 js/feature-jalan.js delete mode 100644 js/feature-kemiskinan.js delete mode 100644 js/feature-parsil.js delete mode 100644 js/feature-spbu.js create mode 100644 js/features/log-bantuan.js create mode 100644 js/features/map-forms.js create mode 100644 js/features/map-layers.js create mode 100644 js/features/map-panels.js create mode 100644 js/pages/daftar_pm.js create mode 100644 js/pages/dashboard.js create mode 100644 js/pages/manajemen_akun.js create mode 100644 pages/daftar_pm.php create mode 100644 pages/dashboard.php create mode 100644 pages/login.php create mode 100644 pages/manajemen_akun.php create mode 100644 pages/map.php 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 = ` -
-
Rekomendasi
-
-
- ${matches.map((item, index) => ` - `).join('')} -
`; +
Rekomendasi
+
+ ${matches.map((item, index) => ` + `).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
-
-
-
-
-
-
`, - ` - `); -} - -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. -
-
- - -
-
-
- - -
-
- - -
-
-
- - -
-
`; - document.getElementById('panel-body').insertAdjacentHTML('beforeend', ` -
- - -
`); -} - -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
- - -
-
-
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: `
๐Ÿ‘ค
${dot}
`, - 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)}
-
- - -
-
-
- - -
-
- - -
-
-
- - -
-
- - -
`, - ` - `); -} - -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)}
-
- - -
-
-
- - -
-
- - -
-
-
- - -
-
- - -
`, - ` - `); -} - -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
-
- - -
-
-
- - -
-
- - -
-
-
- - -
-
-
- - -
-
- - -
-
`, - ` - `); -} - -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
-
-
-
-
-
-
-
-
-
-
`, - ` - `); -} - -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. -
-
- - -
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
`; - document.getElementById('panel-body').insertAdjacentHTML('beforeend', ` -
- - -
`); -} - -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
- -
-
-
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)}
-
-
-
-
-
-
-
-
`, - ` - `); -} - -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 = ``; + } 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 = ` + ${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
+
+ + ${riSelectHTML} +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
`, + ` + `); +} + + +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 ? ` +
+
Akun Rumah Ibadah
+
+
+ + +
+
+ + +
+
+
+ ` : ''; + + openModal(isEdit ? 'Edit Rumah Ibadah' : 'Tambah Rumah Ibadah', ` +
๐Ÿ“ Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ ${accountFieldsHTML}`, + ` + `); + + // 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)}
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
`, + ` + `); + + // 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: `
๐Ÿ‘ค
${dot}
`, + 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
+ +
+
+
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
+ +
+
+
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}` : ''} +
+ + +
+ +
+ + `; + }).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 += `${rangeStart > 2 ? 'โ€ฆ' : ''}`; + for (let i = rangeStart; i <= rangeEnd; i++) { + btns += ``; + } + if (rangeEnd < totalPages) btns += `${rangeEnd < totalPages - 1 ? 'โ€ฆ' : ''}`; + 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 = ` +
+ +

Memuat detailโ€ฆ

+
`; + + 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 ? ` +
+ + +
+ ` : ''} + + + ${logs.length ? ` +
Riwayat Bantuan (${logs.length})
+
+ ${logs.map(l => ` +
+
+ + +
+ `).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 ` +
+ +
+
${riEmoji(r.jenis)}
+
+
${esc(r.nama)}
+ ${esc(r.jenis)} +
+
+ +
+
+
${r.pm_dalam_radius}
+
Warga Binaan
+
+
+
${r.total_penyaluran}
+
Penyaluran
+
+
+
${r.total_nilai_bantuan > 0 ? rpFormatted : 'โ€”'}
+
Total Nilai
+
+
+ + + + +
`; + }).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 = ` +
+ +

Memuat detailโ€ฆ

+
`; + + 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
+
+
+
${rp}
+
Total Nilai
+
+
+ + +
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 ? `` : ''} + +
+
Statusโ— Aktif
+
Akun Dibuat${r.akun_dibuat ? new Date(r.akun_dibuat).toLocaleDateString('id-ID',{day:'numeric',month:'long',year:'numeric'}) : 'โ€”'}
+
+ + +
+ ` : ` +
+ Belum ada akun terdaftar untuk rumah ibadah ini. +
+
+ +
+ `} +
+ + + ${r.riwayat_bantuan && r.riwayat_bantuan.length ? ` +
Riwayat Penyaluran Terbaru
+
+ ${r.riwayat_bantuan.map(l => ` +
+
+ + +
+ `).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', ` +
+ + +
+
+ + +
+
+ + +
+ `, ` + + + `); + } + + 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', ` +
+ + +
+
+ + +
+
+ + +
+ `, ` + + + `); + } + + 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 @@ +
+ + + + + +
+
+
๐Ÿ‘ฅ
+
+
โ€”
+
Total KK
+
+
+
+
๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ
+
+
โ€”
+
Total Jiwa
+
+
+
+
โœ…
+
+
โ€”
+
Dalam Radius
+
+
+
+
โŒ
+
+
โ€”
+
Luar Radius
+
+
+
+
๐ŸŽ
+
+
โ€”
+
Penerima Bantuan
+
+
+
+ + +
+ + + + + โ€” +
+ + +
+
+ + + + + + + + + + + + + + + + + +
Nama NIK Keluarga StatusRumah IbadahBantuan Aksi
+ Memuat dataโ€ฆ
+
+ +
+ +
+ +
+
+
+

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 @@ +
+ + +
+
+

Selamat Datang

+

Berikut adalah ringkasan data WebGIS Poverty saat ini.

+
+
Rabu, 10 Juni 2026
+
+ + +
+ +
+
๐Ÿ‘ฅ
+
+
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

+
+ + + + + + + + + + + + + + + + +
TanggalPenerimaTipe BantuanSub KategoriNilai (Rp)Penyalur
Memuat data aktivitas...
+
+
+ + +
+

Rumah Ibadah Penyalur Teraktif

+
+ + + + + + + + + + + + + +
Nama Rumah IbadahFrekuensiTotal 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 @@ +
+ + + + + +
+ + + + โ€” +
+ + +
+
+ +

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