mengubah sistem menjadi lebih terfokus ke poverty
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
.env
|
||||
.env.*
|
||||
api/config/database.local.php
|
||||
api/config/database.php
|
||||
*.sql
|
||||
!database.sql
|
||||
*.sqlite
|
||||
@@ -13,3 +14,10 @@ node_modules/
|
||||
.idea/
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Temporary and migration files
|
||||
backup_html/
|
||||
migrate.php
|
||||
fix_scripts.php
|
||||
split_css.js
|
||||
split_js.js
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
// api/config/setup_users.php
|
||||
require_once __DIR__ . '/database.php';
|
||||
|
||||
$db = getDB();
|
||||
|
||||
// Create users table
|
||||
$sql = "CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`username` VARCHAR(50) NOT NULL UNIQUE,
|
||||
`password` VARCHAR(255) NOT NULL,
|
||||
`role` ENUM('admin', 'petugas_lapangan', 'guest') NOT NULL,
|
||||
`rumah_ibadah_id` INT DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`rumah_ibadah_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
|
||||
|
||||
if ($db->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";
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
// api/dashboard_stats.php
|
||||
require_once __DIR__ . '/config/database.php';
|
||||
require_once __DIR__ . '/helpers/response.php';
|
||||
// CORS and RBAC are already processed globally in response.php
|
||||
|
||||
$db = getDB();
|
||||
$stats = [];
|
||||
|
||||
// 1. Penduduk Miskin (Total KK & Total Jiwa)
|
||||
$resPm = $db->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);
|
||||
@@ -1,112 +0,0 @@
|
||||
<?php
|
||||
// api/data_jalan.php
|
||||
require_once __DIR__ . '/config/database.php';
|
||||
require_once __DIR__ . '/helpers/response.php';
|
||||
setCORSHeaders();
|
||||
|
||||
$db = getDB();
|
||||
$method = getMethod();
|
||||
requireWriteAuth($method);
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
if ($id > 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);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<?php
|
||||
// api/data_parsil.php
|
||||
require_once __DIR__ . '/config/database.php';
|
||||
require_once __DIR__ . '/helpers/response.php';
|
||||
setCORSHeaders();
|
||||
|
||||
$db = getDB();
|
||||
$method = getMethod();
|
||||
requireWriteAuth($method);
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
if ($id > 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);
|
||||
}
|
||||
+179
-20
@@ -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;
|
||||
@@ -98,3 +248,12 @@ 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));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
// api/login.php
|
||||
require_once __DIR__ . '/config/database.php';
|
||||
require_once __DIR__ . '/helpers/response.php';
|
||||
setCORSHeaders();
|
||||
|
||||
$method = getMethod();
|
||||
if ($method !== 'POST') {
|
||||
sendError('Method tidak diizinkan', 405);
|
||||
}
|
||||
|
||||
$body = getBody();
|
||||
$username = trim($body['username'] ?? '');
|
||||
$password = trim($body['password'] ?? '');
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
sendError('Username dan Password wajib diisi');
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->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');
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
// api/logout.php
|
||||
require_once __DIR__ . '/helpers/response.php';
|
||||
setCORSHeaders();
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$_SESSION = [];
|
||||
|
||||
if (ini_get("session.use_cookies")) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000,
|
||||
$params["path"], $params["domain"],
|
||||
$params["secure"], $params["httponly"]
|
||||
);
|
||||
}
|
||||
|
||||
session_destroy();
|
||||
|
||||
sendSuccess(null, 'Logout berhasil');
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
// api/manajemen_akun.php
|
||||
// Endpoint khusus admin untuk mendapatkan data manajemen akun rumah ibadah
|
||||
require_once __DIR__ . '/config/database.php';
|
||||
require_once __DIR__ . '/helpers/response.php';
|
||||
// CORS dan RBAC sudah diproses secara global di response.php
|
||||
|
||||
// Hanya admin yang boleh mengakses
|
||||
if ($_SESSION['role'] !== 'admin') {
|
||||
sendError('Forbidden - Hanya admin yang dapat mengakses halaman ini', 403);
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$method = getMethod();
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
|
||||
switch ($method) {
|
||||
|
||||
// GET: Ambil semua rumah ibadah beserta statistik dan info akun
|
||||
case 'GET':
|
||||
if ($id > 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);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
// api/me.php
|
||||
require_once __DIR__ . '/helpers/response.php';
|
||||
setCORSHeaders();
|
||||
|
||||
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', 401);
|
||||
}
|
||||
|
||||
sendSuccess([
|
||||
'id' => $_SESSION['user_id'],
|
||||
'username' => $_SESSION['username'],
|
||||
'role' => $_SESSION['role'],
|
||||
'rumah_ibadah_id' => isset($_SESSION['rumah_ibadah_id']) ? (int)$_SESSION['rumah_ibadah_id'] : null
|
||||
]);
|
||||
+107
-9
@@ -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,6 +59,9 @@ switch ($method) {
|
||||
sendError("Field '$field' wajib diisi");
|
||||
}
|
||||
}
|
||||
|
||||
$db->begin_transaction();
|
||||
try {
|
||||
$stmt = $db->prepare(
|
||||
"INSERT INTO rumah_ibadah
|
||||
(nama, jenis, alamat, no_wa, latitude, longitude, radius_meter)
|
||||
@@ -72,13 +79,46 @@ switch ($method) {
|
||||
);
|
||||
executeOrFail($stmt, 'Gagal menyimpan rumah ibadah');
|
||||
$newId = $db->insert_id;
|
||||
sendSuccess(['id' => $newId], 'Rumah Ibadah berhasil disimpan', 201);
|
||||
|
||||
// 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();
|
||||
|
||||
$db->begin_transaction();
|
||||
try {
|
||||
$stmt = $db->prepare(
|
||||
"UPDATE rumah_ibadah
|
||||
SET nama=?, jenis=?, alamat=?, no_wa=?,
|
||||
@@ -93,7 +133,65 @@ switch ($method) {
|
||||
$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 ────────────────────────────────────────────
|
||||
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
<?php
|
||||
// api/spbu.php
|
||||
require_once __DIR__ . '/config/database.php';
|
||||
require_once __DIR__ . '/helpers/response.php';
|
||||
setCORSHeaders();
|
||||
|
||||
$db = getDB();
|
||||
$method = getMethod();
|
||||
requireWriteAuth($method);
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
if ($id > 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);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
$pageTitle = isset($pageTitle) ? $pageTitle : 'WebGIS Poverty Mapping';
|
||||
?>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/components.css">
|
||||
<?php if (isset($pageCss) && $pageCss) { ?>
|
||||
<link rel="stylesheet" href="<?= htmlspecialchars($pageCss) ?>">
|
||||
<?php } ?>
|
||||
|
||||
<!-- Auth init: store user in window, dispatch event AFTER DOM is ready -->
|
||||
<script>
|
||||
// Fetch auth immediately (parallel with page load), but dispatch event
|
||||
// only after DOMContentLoaded so all listeners are already attached.
|
||||
window._authPromise = (async function () {
|
||||
try {
|
||||
const res = await fetch('api/me.php');
|
||||
const data = await res.json();
|
||||
if (data.status !== 'success') {
|
||||
window.location.href = '?page=login';
|
||||
return null;
|
||||
}
|
||||
window.currentUser = data.data;
|
||||
return data.data;
|
||||
} catch (e) {
|
||||
window.location.href = '?page=login';
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
// Once DOM is ready, resolve the auth and dispatch event
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const user = await window._authPromise;
|
||||
if (!user) return; // already redirecting
|
||||
document.dispatchEvent(new CustomEvent('auth-ready', { detail: user }));
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<!-- Modal Overlay -->
|
||||
<div id="modal-overlay" aria-hidden="true">
|
||||
<div id="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<div class="modal-head">
|
||||
<h3 id="modal-title">Modal</h3>
|
||||
</div>
|
||||
<div id="modal-body"></div>
|
||||
<div id="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Container for global notifications -->
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<!-- Global scripts -->
|
||||
<script src="js/app-core.js"></script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
$currentPage = isset($_GET['page']) ? $_GET['page'] : 'map';
|
||||
?>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<span class="brand-icon">🗺️</span>
|
||||
<div>
|
||||
<h2 class="brand-title">WebGIS Poverty</h2>
|
||||
<span class="brand-sub">Sistem Informasi Kemiskinan</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<a href="?page=dashboard" class="nav-item <?= $currentPage === 'dashboard' ? 'active' : '' ?>" id="nav-dashboard">
|
||||
<span class="nav-icon">📊</span>
|
||||
<span class="nav-text">Dashboard</span>
|
||||
</a>
|
||||
<a href="?page=map" class="nav-item <?= $currentPage === 'map' ? 'active' : '' ?>" id="nav-map">
|
||||
<span class="nav-icon">🗺️</span>
|
||||
<span class="nav-text">Peta Interaktif</span>
|
||||
</a>
|
||||
<a href="?page=manajemen_akun" class="nav-item <?= $currentPage === 'manajemen_akun' ? 'active' : '' ?>" id="nav-akun" style="display:none">
|
||||
<span class="nav-icon">🏛️</span>
|
||||
<span class="nav-text">Daftar Rumah Ibadah</span>
|
||||
</a>
|
||||
<a href="?page=daftar_pm" class="nav-item <?= $currentPage === 'daftar_pm' ? 'active' : '' ?>" id="nav-pm">
|
||||
<span class="nav-icon">👥</span>
|
||||
<span class="nav-text">Daftar Penduduk Miskin</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-profile">
|
||||
<div class="user-avatar">👤</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name" id="user-display-name">Memuat...</div>
|
||||
<div class="user-role-badge" id="user-display-role">Guest</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-logout" onclick="handleLogout()">
|
||||
<span class="logout-icon">↩</span> Keluar
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Common Sidebar JS (auth info sync) -->
|
||||
<script>
|
||||
document.addEventListener('auth-ready', function(e) {
|
||||
const user = e.detail;
|
||||
document.body.classList.add('auth-loaded', 'role-' + user.role);
|
||||
|
||||
// Set user profile in sidebar
|
||||
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;
|
||||
|
||||
// Show admin-only nav items
|
||||
if (user.role === 'admin') {
|
||||
const navAkun = document.getElementById('nav-akun');
|
||||
if (navAkun) navAkun.style.display = 'flex';
|
||||
}
|
||||
});
|
||||
|
||||
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.'); }
|
||||
}
|
||||
</script>
|
||||
@@ -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.
|
||||
|
||||
+380
-481
@@ -35,7 +35,6 @@ body {
|
||||
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,
|
||||
@@ -55,39 +54,6 @@ button {
|
||||
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;
|
||||
@@ -103,25 +69,6 @@ button {
|
||||
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;
|
||||
@@ -135,144 +82,6 @@ button {
|
||||
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;
|
||||
@@ -331,21 +140,12 @@ button {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#map-wrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#instruction-banner {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-8px);
|
||||
top: 18px;
|
||||
z-index: 500;
|
||||
z-index: 5000;
|
||||
background: rgba(15, 23, 42, .92);
|
||||
color: #fff;
|
||||
padding: 10px 14px;
|
||||
@@ -353,14 +153,13 @@ button {
|
||||
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);
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
#panel {
|
||||
@@ -412,23 +211,6 @@ button {
|
||||
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;
|
||||
@@ -439,87 +221,11 @@ button {
|
||||
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;
|
||||
@@ -572,36 +278,6 @@ button {
|
||||
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;
|
||||
@@ -637,7 +313,7 @@ button {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(15, 23, 42, .42);
|
||||
z-index: 2000;
|
||||
z-index: 6000;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
@@ -656,19 +332,6 @@ button {
|
||||
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;
|
||||
@@ -684,53 +347,6 @@ button {
|
||||
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;
|
||||
@@ -760,45 +376,6 @@ button {
|
||||
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;
|
||||
@@ -808,28 +385,6 @@ button {
|
||||
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;
|
||||
@@ -854,42 +409,386 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
#header {
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
padding: 12px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
.admin-only, .petugas-only, .write-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.header-search {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
body.role-admin .admin-only,
|
||||
body.role-admin .write-only {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
body.role-admin button.admin-only,
|
||||
body.role-admin button.write-only {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
body.role-petugas_lapangan .petugas-only,
|
||||
body.role-petugas_lapangan .write-only {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
body.role-petugas_lapangan button.petugas-only,
|
||||
body.role-petugas_lapangan button.write-only {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
#panel {
|
||||
grid-template-rows: auto minmax(320px, 48vh);
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
|
||||
#modal {
|
||||
width: 100%;
|
||||
border-radius: 20px;
|
||||
/* ==========================================================================
|
||||
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 */
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+158
@@ -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;
|
||||
}
|
||||
|
||||
+97
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
+8
-30
@@ -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;
|
||||
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebGIS - Poverty Mapping & Spatial Management</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Turf.js/6.5.0/turf.min.js"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header id="header">
|
||||
<div class="brand">
|
||||
<div class="brand-title">WebGIS Poverty</div>
|
||||
<div class="brand-sub">Sistem Informasi Geografis Berbasis Web</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-tabs" role="tablist" aria-label="Fitur utama">
|
||||
<button class="nav-tab active" onclick="switchTab('kemiskinan')">Kemiskinan</button>
|
||||
<button class="nav-tab" onclick="switchTab('spbu')">SPBU</button>
|
||||
<button class="nav-tab" onclick="switchTab('jalan')">Jalan</button>
|
||||
<button class="nav-tab" onclick="switchTab('parsil')">Parsil</button>
|
||||
</div>
|
||||
|
||||
<div class="header-search" id="header-search">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">⌕</span>
|
||||
<input id="search-input" type="search" autocomplete="off" placeholder="Telusuri Webgis Poverty" aria-label="Cari objek peta" />
|
||||
</div>
|
||||
<div class="search-results" id="search-results" aria-label="Hasil pencarian"></div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="coord-display" id="coord-display">Lat: - | Lng: -</div>
|
||||
<div class="status-pill"><span id="status-zoom">Zoom: 13</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section id="map-wrap">
|
||||
<div id="instruction-banner"></div>
|
||||
<div id="map"></div>
|
||||
</section>
|
||||
|
||||
<aside id="panel">
|
||||
<div id="panel-head">
|
||||
<h2 id="panel-title">Rumah Ibadah & Kemiskinan</h2>
|
||||
<!-- <div class="panel-meta">
|
||||
<span>Drag marker untuk update lokasi</span>
|
||||
<span>Draw polyline untuk jalan</span>
|
||||
<span>Draw polygon untuk parsil</span>
|
||||
</div> -->
|
||||
</div>
|
||||
<div id="panel-body">
|
||||
<div class="loading"><span class="spinner"></span>Memuat data...</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="modal-overlay" aria-hidden="true">
|
||||
<div id="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<div class="modal-head"><h3 id="modal-title">Modal</h3></div>
|
||||
<div id="modal-body"></div>
|
||||
<div id="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="js/app-core.js"></script>
|
||||
<script src="js/feature-kemiskinan.js"></script>
|
||||
<script src="js/feature-spbu.js"></script>
|
||||
<script src="js/feature-jalan.js"></script>
|
||||
<script src="js/feature-parsil.js"></script>
|
||||
<script>
|
||||
renderPanel('kemiskinan');
|
||||
preloadFeatureLayers();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
$page = isset($_GET['page']) ? $_GET['page'] : 'dashboard';
|
||||
$validPages = ['map', 'dashboard', 'manajemen_akun', 'daftar_pm', 'login'];
|
||||
|
||||
if (!in_array($page, $validPages)) {
|
||||
$page = 'dashboard';
|
||||
}
|
||||
|
||||
if ($page === 'login') {
|
||||
include 'pages/login.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
$pageTitles = [
|
||||
'map' => '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';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<?php include 'components/head.php'; ?>
|
||||
|
||||
<?php if ($page === 'map') { ?>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
|
||||
<?php } else if ($page === 'dashboard') { ?>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
|
||||
<?php } ?>
|
||||
</head>
|
||||
|
||||
<?php
|
||||
// Map page gets its own body class for full-screen layout
|
||||
$bodyClass = ($page === 'map') ? 'page-map' : 'page-dashboard';
|
||||
?>
|
||||
<body class="<?= $bodyClass ?>">
|
||||
|
||||
<!-- Fixed left sidebar -->
|
||||
<?php include 'components/sidebar.php'; ?>
|
||||
|
||||
<!-- Main content area (offset by sidebar width via CSS) -->
|
||||
<div class="main-content-area">
|
||||
<?php if ($page === 'map') { ?>
|
||||
<?php include 'pages/map.php'; ?>
|
||||
<?php } else { ?>
|
||||
<div class="dashboard-wrapper">
|
||||
<?php include 'pages/' . $page . '.php'; ?>
|
||||
</div>
|
||||
<?php } ?>
|
||||
</div>
|
||||
|
||||
<!-- Global overlays & scripts -->
|
||||
<?php include 'components/scripts.php'; ?>
|
||||
|
||||
<!-- Page-specific scripts -->
|
||||
<?php if ($page === 'dashboard') { ?>
|
||||
<script src="js/pages/dashboard.js"></script>
|
||||
<?php } else if ($page === 'manajemen_akun') { ?>
|
||||
<script src="js/pages/manajemen_akun.js"></script>
|
||||
<?php } else if ($page === 'daftar_pm') { ?>
|
||||
<script src="js/features/log-bantuan.js"></script>
|
||||
<script src="js/pages/daftar_pm.js"></script>
|
||||
<?php } else if ($page === 'map') { ?>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
|
||||
<script src="js/features/map-panels.js"></script>
|
||||
<script src="js/features/map-layers.js"></script>
|
||||
<script src="js/features/map-forms.js"></script>
|
||||
<script src="js/features/log-bantuan.js"></script>
|
||||
<script>
|
||||
// Map page: init Leaflet after DOM ready, then trigger panel loading
|
||||
document.addEventListener('auth-ready', function(e) {
|
||||
const user = e.detail;
|
||||
|
||||
// Init the Leaflet map
|
||||
initMap();
|
||||
|
||||
// Show tabs for admin
|
||||
const tabsContainer = document.querySelector('.nav-tabs');
|
||||
if (tabsContainer) {
|
||||
if (user.role === 'admin') {
|
||||
tabsContainer.style.display = 'flex';
|
||||
tabsContainer.innerHTML = `
|
||||
<button class="nav-tab active" onclick="switchTab('rumah_ibadah')">Rumah Ibadah</button>
|
||||
<button class="nav-tab" onclick="switchTab('penduduk_miskin')">Orang Miskin</button>
|
||||
`;
|
||||
switchTab('rumah_ibadah');
|
||||
} else {
|
||||
tabsContainer.style.display = 'none';
|
||||
switchTab('penduduk_miskin');
|
||||
}
|
||||
}
|
||||
|
||||
// Fix map tile rendering after sidebar layout
|
||||
setTimeout(() => { if (map) map.invalidateSize(); }, 150);
|
||||
});
|
||||
</script>
|
||||
<?php } ?>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+123
-271
@@ -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 => {
|
||||
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, '>').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,9 +113,7 @@ function getSearchMatches(query) {
|
||||
const pool = prefix
|
||||
? searchEntries.filter(entry => entry.key.startsWith(prefix + ':'))
|
||||
: searchEntries;
|
||||
|
||||
return pool
|
||||
.map(entry => {
|
||||
return pool.map(entry => {
|
||||
let score = 0;
|
||||
const entryLabel = normalizeSearchText(entry.label);
|
||||
const entryType = normalizeSearchText(entry.typeLabel);
|
||||
@@ -182,21 +126,12 @@ function getSearchMatches(query) {
|
||||
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);
|
||||
}).filter(e => e.score > 0).sort((a, b) => b.score - a.score || a.label.localeCompare(b.label)).slice(0, 10);
|
||||
}
|
||||
|
||||
function renderSearchResults(query) {
|
||||
@@ -209,13 +144,9 @@ function renderSearchResults(query) {
|
||||
panel.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchActiveIndex < 0 || searchActiveIndex >= matches.length) searchActiveIndex = 0;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="search-results-head">
|
||||
<div class="search-results-title">Rekomendasi</div>
|
||||
</div>
|
||||
<div class="search-results-head"><div class="search-results-title">Rekomendasi</div></div>
|
||||
<div class="search-results-list">
|
||||
${matches.map((item, index) => `
|
||||
<button type="button" class="search-result-item ${index === searchActiveIndex ? 'active' : ''}" data-index="${index}">
|
||||
@@ -232,8 +163,7 @@ function renderSearchResults(query) {
|
||||
|
||||
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 = '<div class="loading"><span class="spinner"></span>Memuat data…</div>';
|
||||
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();
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Filter Layer</div>
|
||||
${['Nasional', 'Provinsi', 'Kabupaten'].map(s => `
|
||||
<div class="layer-item ${layerVisible['jalan' + s] ? 'checked' : ''}" onclick="toggleLayer('jalan${s}',this)">
|
||||
<div class="layer-check">${layerVisible['jalan' + s] ? '✓' : ''}</div>
|
||||
<div class="layer-dot" style="background:${jalanCfg[s].color};border-radius:2px;height:4px;width:18px;border-radius:2px"></div>
|
||||
<div class="layer-label">Jalan ${s}</div>
|
||||
<div class="layer-count">${cnt(s)}</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Cara Menambahkan Data Jalan</div>
|
||||
<div style="font-size:11px;color:var(--text2);line-height:1.9;padding:10px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius)">
|
||||
1. Klik ikon <b style="color:var(--accent)">polyline</b> di toolbar kiri peta<br>
|
||||
2. Klik titik-titik rute jalan di peta<br>
|
||||
3. <b>Double-klik</b> untuk selesai menggambar<br>
|
||||
4. Form simpan + panjang otomatis muncul
|
||||
</div>
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Data Jalan (${data.length})</div>
|
||||
<div class="data-list">
|
||||
${data.length ? data.map(d => `
|
||||
<div class="data-item" onclick="focusGeom(${d.id},'jalan')">
|
||||
<div class="item-icon">🛣</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">${esc(d.nama_jalan)}</div>
|
||||
<div class="item-sub">${(d.panjang_meter / 1000).toFixed(3)} km</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:5px">
|
||||
<div class="item-badge" style="background:${jalanCfg[d.status_jalan].color}18;color:${jalanCfg[d.status_jalan].color};border:1px solid ${jalanCfg[d.status_jalan].color}44">${d.status_jalan.toUpperCase()}</div>
|
||||
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editJalan(${d.id})">✏</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteJalan(${d.id})">✕</button>
|
||||
</div>
|
||||
</div>`).join('') : '<div class="loading">Belum ada data jalan</div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
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(`<h4>🛣 ${esc(d.nama_jalan)}</h4>
|
||||
<p><b>Status:</b> Jalan ${d.status_jalan}</p>
|
||||
<p><b>Panjang:</b> ${(d.panjang_meter / 1000).toFixed(3)} km</p>
|
||||
${d.keterangan ? `<p>${esc(d.keterangan)}</p>` : ''}
|
||||
<div class="popup-actions"><button class="btn btn-ghost btn-sm" onclick="editJalan(${d.id})">✏ Edit</button><button class="btn btn-danger btn-sm" onclick="deleteJalan(${d.id})">✕ Hapus</button></div>`);
|
||||
line._dataId = d.id;
|
||||
line._groupKey = 'jalan' + d.status_jalan;
|
||||
layers['jalan' + d.status_jalan].addLayer(line);
|
||||
});
|
||||
}
|
||||
|
||||
function openJalanForm() {
|
||||
openModal('Simpan Data Jalan', `
|
||||
<div class="form-calc">📏 Panjang: <b>${pendingLength.toFixed(1)} m</b> = ${(pendingLength / 1000).toFixed(3)} km </div>
|
||||
<div class="form-group"><label class="form-label">Nama Jalan *</label>
|
||||
<input class="form-input" id="jl-nama" placeholder="Jl. Ahmad Yani…"/></div>
|
||||
<div class="form-group"><label class="form-label">Status Jalan *</label>
|
||||
<select class="form-select" id="jl-status">
|
||||
<option value="Nasional">Jalan Nasional</option>
|
||||
<option value="Provinsi">Jalan Provinsi</option>
|
||||
<option value="Kabupaten">Jalan Kabupaten</option>
|
||||
</select></div>
|
||||
<div class="form-group"><label class="form-label">Keterangan</label>
|
||||
<input class="form-input" id="jl-ket" placeholder="Opsional…"/></div>`,
|
||||
`<button class="btn btn-ghost" onclick="closeModal();drawnItems.clearLayers()">Batal</button>
|
||||
<button class="btn btn-primary" onclick="saveJalan()">💾 Simpan</button>`);
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Edit Jalan</div>
|
||||
<div style="font-size:11px;line-height:1.7;color:var(--text2);padding:10px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px">
|
||||
Geser titik-titik garis di peta lalu simpan perubahan.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nama Jalan *</label>
|
||||
<input class="form-input" id="jl-edit-nama" value="${esc(data.nama_jalan)}" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status Jalan *</label>
|
||||
<select class="form-select" id="jl-edit-status">
|
||||
${['Nasional', 'Provinsi', 'Kabupaten'].map(s => `<option value="${s}"${data.status_jalan === s ? ' selected' : ''}>Jalan ${s}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Panjang Otomatis</label>
|
||||
<input class="form-input" id="jl-edit-panjang" value="${(data.panjang_meter / 1000).toFixed(3)} km" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Keterangan</label>
|
||||
<input class="form-input" id="jl-edit-ket" value="${esc(data.keterangan || '')}" />
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('panel-body').insertAdjacentHTML('beforeend', `
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-ghost" style="flex:1" onclick="cancelGeometryEdit()">Batal</button>
|
||||
<button class="btn btn-primary" style="flex:1" onclick="saveJalanEdit(${id})">Simpan Perubahan</button>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Visibilitas Layer</div>
|
||||
${mkLayerItem('rumahIbadah', '#f59e0b', 'Rumah Ibadah', ri.length)}
|
||||
${mkLayerItem('ibadahRadius', '#fbbf24', 'Radius Jangkauan', ri.length, true)}
|
||||
${mkLayerItem('pendudukMiskin', '#16a34a', 'Penduduk Miskin', pm.length)}
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Tambah Data</div>
|
||||
<button class="btn-add" id="btn-add-ri" onclick="startAdd('ri')">+ Tambah Rumah Ibadah</button>
|
||||
<button class="btn-add" id="btn-add-pm" onclick="startAdd('pm')" style="margin-top:5px">+ Tambah Penduduk Miskin</button>
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Rumah Ibadah (${ri.length})</div>
|
||||
<div class="data-list">
|
||||
${ri.length ? ri.map(r => `
|
||||
<div class="data-item" onclick="map.flyTo([${r.latitude},${r.longitude}],17)">
|
||||
<div class="item-icon">⛪</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">${esc(r.nama)}</div>
|
||||
<div class="item-sub">${r.jenis} · R=${r.radius_meter}m</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editRI(${r.id})">✏</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteRI(${r.id})">✕</button>
|
||||
</div>
|
||||
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Penduduk Miskin (${pm.length})</div>
|
||||
<div class="data-list">
|
||||
${pm.length ? pm.map(p => `
|
||||
<div class="data-item" onclick="map.flyTo([${p.latitude},${p.longitude}],17)">
|
||||
<div class="item-icon">${pmEmoji(p)}</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">${esc(p.nama)}</div>
|
||||
<div class="item-sub">${p.total_bantuan} bantuan · ${p.jumlah_anggota} jiwa</div>
|
||||
</div>
|
||||
<div class="item-badge ${p.status_proximity === 'dalam_radius' ? 'badge-green' : 'badge-red'}">
|
||||
${p.status_proximity === 'dalam_radius' ? 'DALAM' : 'LUAR'}
|
||||
</div>
|
||||
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
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
|
||||
? `<div class="layer-dot" style="background:transparent;border:2px solid ${color}"></div>`
|
||||
: `<div class="layer-dot" style="background:${color}"></div>`;
|
||||
return `<div class="layer-item ${layerVisible[key] ? 'checked' : ''}" onclick="toggleLayer('${key}',this)">
|
||||
<div class="layer-check">${layerVisible[key] ? '✓' : ''}</div>${dot}
|
||||
<div class="layer-label">${label}</div>
|
||||
<div class="layer-count">${count}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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: `<div style="width:32px;height:32px;background:#f59e0b;border:3px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 3px 10px rgba(0,0,0,.2);cursor:grab;">⛪</div>`,
|
||||
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 `<h4>⛪ ${esc(r.nama)}</h4>
|
||||
<p><b>Jenis:</b> ${r.jenis}</p>
|
||||
<p><b>Radius:</b> ${r.radius_meter} m</p>
|
||||
${r.alamat ? `<p><b>Alamat:</b> ${esc(r.alamat)}</p>` : ''}
|
||||
${r.no_wa ? `<p><b>WA:</b> ${r.no_wa}</p>` : ''}
|
||||
<div class="popup-drag-hint">↔ Seret marker untuk pindahkan lokasi — auto-save</div>
|
||||
<div class="popup-actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick="editRI(${r.id})">✏ Edit</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteRI(${r.id})">✕ Hapus</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `<div style="position:absolute;top:-3px;right:-3px;width:9px;height:9px;background:#2563eb;border:1.5px solid #fff;border-radius:50%"></div>`;
|
||||
if (hasKons && !hasPemb) dot = `<div style="position:absolute;top:-3px;right:-3px;width:9px;height:9px;background:#f59e0b;border:1.5px solid #fff;border-radius:50%"></div>`;
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="position:relative"><div style="width:26px;height:26px;background:${color};border:3px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;box-shadow:0 3px 10px ${color}66;cursor:grab;">👤</div>${dot}</div>`,
|
||||
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 `<h4>👤 ${esc(p.nama)}</h4>
|
||||
<div class="proximity-info ${inR ? 'dalam' : 'luar'}">
|
||||
${inR ? `✅ Dalam radius — ${esc(p.nama_rumah_ibadah || '')}` : '❌ Di luar radius rumah ibadah'}
|
||||
</div>
|
||||
${p.no_wa ? `<p><b>WA:</b> ${p.no_wa}</p>` : ''}
|
||||
${p.nik ? `<p><b>NIK:</b> ${p.nik}</p>` : ''}
|
||||
${p.alamat ? `<p><b>Alamat:</b> ${esc(p.alamat)}</p>` : ''}
|
||||
<p><b>Keluarga:</b> ${p.jumlah_anggota} jiwa</p>
|
||||
<p><b>Bantuan:</b> 📚${p.bantuan_pemberdayaan} · 🛍${p.bantuan_konsumtif}</p>
|
||||
<div class="popup-drag-hint">↔ Seret marker untuk pindahkan lokasi — auto-save</div>
|
||||
<div class="popup-actions">
|
||||
<button class="btn btn-primary btn-sm" onclick="openLogBantuan(${p.id},'${esc(p.nama)}')">📋 Log Bantuan</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="editPM(${p.id})">✏ Edit</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deletePM(${p.id})">✕</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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', `
|
||||
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nama Rumah Ibadah *</label>
|
||||
<input class="form-input" id="ri-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="Masjid Al-Ikhlas…"/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Jenis *</label>
|
||||
<select class="form-select" id="ri-jenis">
|
||||
${['Masjid', 'Gereja', 'Pura', 'Vihara', 'Klenteng', 'Lainnya'].map(j =>
|
||||
`<option value="${j}"${isEdit && data.jenis === j ? ' selected' : ''}>${j}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Radius (meter) *</label>
|
||||
<input class="form-input" id="ri-radius" type="number" value="${isEdit ? data.radius_meter : 500}" min="50" max="10000"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Alamat</label>
|
||||
<input class="form-input" id="ri-alamat" value="${esc(isEdit ? data.alamat || '' : '')}" placeholder="Jl. …"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">No. WhatsApp</label>
|
||||
<input class="form-input" id="ri-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/>
|
||||
</div>`,
|
||||
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
|
||||
<button class="btn btn-primary" onclick="saveRI(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
|
||||
}
|
||||
|
||||
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', `
|
||||
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nama Lengkap *</label>
|
||||
<input class="form-input" id="pm-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="Ahmad Subarjo…"/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">No. WhatsApp</label>
|
||||
<input class="form-input" id="pm-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">NIK</label>
|
||||
<input class="form-input" id="pm-nik" value="${esc(isEdit ? data.nik || '' : '')}" placeholder="617101…"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Alamat</label>
|
||||
<input class="form-input" id="pm-alamat" value="${esc(isEdit ? data.alamat || '' : '')}" placeholder="Jl. …"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Jumlah Anggota Keluarga</label>
|
||||
<input class="form-input" id="pm-jml" type="number" value="${isEdit ? data.jumlah_anggota : 1}" min="1" max="20"/>
|
||||
</div>`,
|
||||
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
|
||||
<button class="btn btn-primary" onclick="savePM(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
|
||||
}
|
||||
|
||||
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}`, `
|
||||
<div style="margin-bottom:16px">
|
||||
<div class="layer-section-title">Riwayat Bantuan</div>
|
||||
${logs.length ? logs.map(l => `
|
||||
<div class="log-item ${l.tipe_bantuan === 'Pemberdayaan' ? 'pemberdayaan' : 'konsumtif'}">
|
||||
<div class="log-type">${l.tipe_bantuan === 'Pemberdayaan' ? '📚 Pemberdayaan' : '🛍 Konsumtif'}</div>
|
||||
<div class="log-detail">${esc(l.sub_kategori) || ''} ${l.keterangan ? '— ' + esc(l.keterangan) : ''}</div>
|
||||
<div class="log-date">📅 ${l.tanggal_bantuan} · ${esc(l.nama_rumah_ibadah)}</div>
|
||||
</div>`).join('') : '<div class="loading">Belum ada riwayat bantuan</div>'}
|
||||
</div>
|
||||
<div class="layer-section-title">Tambah Bantuan Baru</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Dari Rumah Ibadah *</label>
|
||||
<select class="form-select" id="lb-ri">
|
||||
${ris.map(r => `<option value="${r.id}">${esc(r.nama)} (${r.jenis})</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tipe Bantuan *</label>
|
||||
<select class="form-select" id="lb-tipe">
|
||||
<option value="Pemberdayaan">📚 Pemberdayaan (Pelatihan)</option>
|
||||
<option value="Konsumtif">🛍 Konsumtif (Makan/Barang)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tanggal *</label>
|
||||
<input class="form-input" id="lb-tgl" type="date" value="${new Date().toISOString().slice(0, 10)}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Sub Kategori</label>
|
||||
<input class="form-input" id="lb-sub" placeholder="Pelatihan menjahit / Sembako…"/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Keterangan</label>
|
||||
<input class="form-input" id="lb-ket" placeholder="Opsional"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nilai (Rp)</label>
|
||||
<input class="form-input" id="lb-nilai" type="number" placeholder="0"/>
|
||||
</div>
|
||||
</div>`,
|
||||
`<button class="btn btn-ghost" onclick="closeModal()">Tutup</button>
|
||||
<button class="btn btn-primary" onclick="saveLog(${pmId})">➕ Tambah Log</button>`);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Filter Layer</div>
|
||||
${Object.entries(parsilCfg).map(([k, v]) => `
|
||||
<div class="layer-item ${layerVisible['parsil' + k] ? 'checked' : ''}" onclick="toggleLayer('parsil${k}',this)">
|
||||
<div class="layer-check">${layerVisible['parsil' + k] ? '✓' : ''}</div>
|
||||
<div class="layer-dot" style="background:${v.color}"></div>
|
||||
<div class="layer-label">${k} — ${v.label}</div>
|
||||
<div class="layer-count">${cnt(k)}</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Cara Menambahkan Data Parsil</div>
|
||||
<div style="font-size:11px;color:var(--text2);line-height:1.9;padding:10px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius)">
|
||||
1. Klik ikon <b style="color:var(--accent)">polygon</b> di toolbar kiri peta<br>
|
||||
2. Klik titik-titik batas bidang tanah<br>
|
||||
3. Klik titik awal / <b>double-klik</b> untuk menutup<br>
|
||||
4. Form simpan + luas otomatis muncul
|
||||
</div>
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Data Parsil (${data.length})</div>
|
||||
<div class="data-list">
|
||||
${data.length ? data.map(d => `
|
||||
<div class="data-item" onclick="focusGeom(${d.id},'parsil')">
|
||||
<div class="item-icon">📐</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">${esc(d.nama_pemilik)}</div>
|
||||
<div class="item-sub">${d.luas_m2.toFixed(1)} m² · ${d.no_sertifikat || '—'}</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:5px">
|
||||
<div class="item-badge" style="background:${parsilCfg[d.jenis_hak].color}18;color:${parsilCfg[d.jenis_hak].color};border:1px solid ${parsilCfg[d.jenis_hak].color}44">${d.jenis_hak}</div>
|
||||
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editParsil(${d.id})">✏</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteParsil(${d.id})">✕</button>
|
||||
</div>
|
||||
</div>`).join('') : '<div class="loading">Belum ada data parsil</div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
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(`<h4>📐 ${esc(d.nama_pemilik)}</h4>
|
||||
<p><b>Jenis Hak:</b> ${d.jenis_hak} — ${cfg.label}</p>
|
||||
<p><b>Luas:</b> ${d.luas_m2.toFixed(2)} m² (${(d.luas_m2 / 10000).toFixed(4)} ha)</p>
|
||||
${d.no_sertifikat ? `<p><b>No. Sertifikat:</b> ${d.no_sertifikat}</p>` : ''}
|
||||
${d.keterangan ? `<p>${esc(d.keterangan)}</p>` : ''}
|
||||
<div class="popup-actions"><button class="btn btn-ghost btn-sm" onclick="editParsil(${d.id})">✏ Edit</button><button class="btn btn-danger btn-sm" onclick="deleteParsil(${d.id})">✕ Hapus</button></div>`);
|
||||
poly._dataId = d.id;
|
||||
poly._groupKey = 'parsil' + d.jenis_hak;
|
||||
layers['parsil' + d.jenis_hak].addLayer(poly);
|
||||
});
|
||||
}
|
||||
|
||||
function openParsilForm() {
|
||||
openModal('Simpan Parsil Tanah', `
|
||||
<div class="form-calc">📐 Luas: <b>${pendingArea.toFixed(2)} m²</b> = ${(pendingArea / 10000).toFixed(4)} ha </div>
|
||||
<div class="form-group"><label class="form-label">Nama Pemilik *</label>
|
||||
<input class="form-input" id="ps-nama" placeholder="Budi Santoso…"/></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">Jenis Hak *</label>
|
||||
<select class="form-select" id="ps-hak">
|
||||
<option value="SHM">SHM — Hak Milik</option>
|
||||
<option value="HGB">HGB — Guna Bangunan</option>
|
||||
<option value="HGU">HGU — Guna Usaha</option>
|
||||
<option value="HP">HP — Hak Pakai</option>
|
||||
</select></div>
|
||||
<div class="form-group"><label class="form-label">No. Sertifikat</label>
|
||||
<input class="form-input" id="ps-sert" placeholder="00001/Kel/…"/></div>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label">Keterangan</label>
|
||||
<input class="form-input" id="ps-ket" placeholder="Opsional…"/></div>`,
|
||||
`<button class="btn btn-ghost" onclick="closeModal();drawnItems.clearLayers()">Batal</button>
|
||||
<button class="btn btn-primary" onclick="saveParsil()">💾 Simpan</button>`);
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Edit Parsil</div>
|
||||
<div style="font-size:11px;line-height:1.7;color:var(--text2);padding:10px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px">
|
||||
Geser titik-titik batas bidang di peta lalu simpan perubahan.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nama Pemilik *</label>
|
||||
<input class="form-input" id="ps-edit-nama" value="${esc(data.nama_pemilik)}" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Jenis Hak *</label>
|
||||
<select class="form-select" id="ps-edit-hak">
|
||||
${Object.entries(parsilCfg).map(([k, v]) => `<option value="${k}"${data.jenis_hak === k ? ' selected' : ''}>${k} — ${v.label}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Luas Otomatis</label>
|
||||
<input class="form-input" id="ps-edit-luas" value="${data.luas_m2.toFixed(2)} m²" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">No. Sertifikat</label>
|
||||
<input class="form-input" id="ps-edit-sert" value="${esc(data.no_sertifikat || '')}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Keterangan</label>
|
||||
<input class="form-input" id="ps-edit-ket" value="${esc(data.keterangan || '')}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('panel-body').insertAdjacentHTML('beforeend', `
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-ghost" style="flex:1" onclick="cancelGeometryEdit()">Batal</button>
|
||||
<button class="btn btn-primary" style="flex:1" onclick="saveParsilEdit(${id})">Simpan Perubahan</button>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Filter Layer</div>
|
||||
${mkLayerItem('spbu24', '#16a34a', 'SPBU Buka 24 Jam', d24.length)}
|
||||
${mkLayerItem('spbuNon24', '#94a3b8', 'SPBU Tidak 24 Jam', dn24.length)}
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Tambah Data</div>
|
||||
<button class="btn-add" id="btn-add-spbu" onclick="startAddSPBU()">+ Tambah SPBU</button>
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Daftar SPBU (${data.length})</div>
|
||||
<div class="data-list">
|
||||
${data.length ? data.map(s => `
|
||||
<div class="data-item" onclick="map.flyTo([${s.latitude},${s.longitude}],17)">
|
||||
<div class="item-icon">⛽</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">${esc(s.nama)}</div>
|
||||
<div class="item-sub">${s.no_wa || '—'}</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:5px">
|
||||
<div class="item-badge ${s.buka_24jam ? 'badge-green' : 'badge-amber'}">${s.buka_24jam ? '24 JAM' : 'TERBATAS'}</div>
|
||||
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editSPBU(${s.id})">✏</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteSPBU(${s.id})">✕</button>
|
||||
</div>
|
||||
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
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: `<div style="width:30px;height:30px;background:${color};border:3px solid #fff;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 3px 10px rgba(0,0,0,.2);cursor:grab;">⛽</div>`,
|
||||
iconSize: [30, 30], iconAnchor: [15, 15]
|
||||
});
|
||||
const marker = L.marker([s.latitude, s.longitude], { icon, draggable: true }).bindPopup(`<h4>⛽ ${esc(s.nama)}</h4>
|
||||
<p><b>Status:</b> ${is24 ? '<span style="color:#16a34a;font-weight:600">✅ Buka 24 Jam</span>' : '⏰ Tidak 24 Jam'}</p>
|
||||
${s.no_wa ? `<p><b>WA:</b> ${s.no_wa}</p>` : ''}
|
||||
${s.alamat ? `<p><b>Alamat:</b> ${esc(s.alamat)}</p>` : ''}
|
||||
<div class="popup-drag-hint">↔ Seret marker untuk pindahkan lokasi — auto-save</div>
|
||||
<div class="popup-actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick="editSPBU(${s.id})">✏ Edit</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteSPBU(${s.id})">✕ Hapus</button>
|
||||
</div>`);
|
||||
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', `
|
||||
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
|
||||
<div class="form-group"><label class="form-label">Nama SPBU *</label>
|
||||
<input class="form-input" id="spbu-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="SPBU 64.751.01"/></div>
|
||||
<div class="form-group"><label class="form-label">No. WhatsApp</label>
|
||||
<input class="form-input" id="spbu-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/></div>
|
||||
<div class="form-group"><label class="form-label">Alamat</label>
|
||||
<input class="form-input" id="spbu-alamat" value="${esc(isEdit ? data.alamat || '' : '')}" placeholder="Jl. …"/></div>
|
||||
<div class="form-group"><label class="form-label">Status Operasional</label>
|
||||
<select class="form-select" id="spbu-24">
|
||||
<option value="1"${isEdit && data.buka_24jam ? ' selected' : ''}>✅ Buka 24 Jam</option>
|
||||
<option value="0"${isEdit && !data.buka_24jam ? ' selected' : ''}>⏰ Tidak 24 Jam</option>
|
||||
</select></div>`,
|
||||
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
|
||||
<button class="btn btn-primary" onclick="saveSPBU(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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 = `<select class="form-select" id="lb-ri" disabled>
|
||||
${ris.filter(r => String(r.id) === String(window.currentUser.rumah_ibadah_id))
|
||||
.map(r => `<option value="${r.id}" selected>${esc(r.nama)} (${r.jenis})</option>`).join('')}
|
||||
</select>`;
|
||||
} 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 = `<select class="form-select" id="lb-ri">
|
||||
${sorted.map(r => {
|
||||
const dist = (pmLat != null && pmLng != null)
|
||||
? Math.round(haversineDistance(pmLat, pmLng, r.latitude, r.longitude))
|
||||
: null;
|
||||
const distLabel = dist != null ? ` — ${dist < 1000 ? dist + ' m' : (dist/1000).toFixed(1) + ' km'}` : '';
|
||||
const isInside = dist != null && dist <= (r.radius_meter || 500);
|
||||
const label = `${esc(r.nama)} (${r.jenis})${distLabel}${isInside ? ' ✅' : ''}`;
|
||||
const selected = String(r.id) === String(defaultRiId) ? 'selected' : '';
|
||||
return `<option value="${r.id}" ${selected}>${label}</option>`;
|
||||
}).join('')}
|
||||
</select>
|
||||
${pmLat != null ? `<div style="font-size:11px;color:#64748b;margin-top:4px">✅ = dalam radius · Diurutkan berdasarkan jarak terdekat</div>` : ''}`;
|
||||
}
|
||||
|
||||
openModal(`Log Bantuan — ${pmNama}`, `
|
||||
<div style="margin-bottom:16px">
|
||||
<div class="layer-section-title">Riwayat Bantuan</div>
|
||||
${logs.length ? `
|
||||
${logs.slice(0, 2).map(l => `
|
||||
<div class="log-item ${l.tipe_bantuan === 'Pemberdayaan' ? 'pemberdayaan' : 'konsumtif'}">
|
||||
<div class="log-type">${l.tipe_bantuan === 'Pemberdayaan' ? '📚 Pemberdayaan' : '🛍 Konsumtif'}</div>
|
||||
<div class="log-detail">${esc(l.sub_kategori) || ''} ${l.keterangan ? '— ' + esc(l.keterangan) : ''}</div>
|
||||
<div class="log-date">📅 ${l.tanggal_bantuan} · ${esc(l.nama_rumah_ibadah)}</div>
|
||||
</div>`).join('')}
|
||||
${logs.length > 2 ? `<div style="font-size:11px;color:#64748b;text-align:center;padding:6px 0;border-top:1px solid #f1f5f9;margin-top:4px">+${logs.length - 2} bantuan lainnya · Lihat detail lengkap di halaman Daftar Penduduk Miskin</div>` : ''}
|
||||
` : '<div class="loading">Belum ada riwayat bantuan</div>'}
|
||||
</div>
|
||||
<div class="layer-section-title">Tambah Bantuan Baru</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Dari Rumah Ibadah *</label>
|
||||
${riSelectHTML}
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tipe Bantuan *</label>
|
||||
<select class="form-select" id="lb-tipe">
|
||||
<option value="Pemberdayaan">📚 Pemberdayaan (Pelatihan)</option>
|
||||
<option value="Konsumtif">🛍 Konsumtif (Makan/Barang)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tanggal *</label>
|
||||
<input class="form-input" id="lb-tgl" type="date" value="${new Date().toISOString().slice(0, 10)}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Sub Kategori</label>
|
||||
<input class="form-input" id="lb-sub" placeholder="Pelatihan menjahit / Sembako…"/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Keterangan</label>
|
||||
<input class="form-input" id="lb-ket" placeholder="Opsional"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nilai (Rp)</label>
|
||||
<input class="form-input" id="lb-nilai" type="number" placeholder="0"/>
|
||||
</div>
|
||||
</div>`,
|
||||
`<button class="btn btn-ghost" onclick="closeModal()">Tutup</button>
|
||||
<button class="btn btn-primary" onclick="saveLog(${pmId})">➕ Tambah Log</button>`);
|
||||
}
|
||||
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -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 ? `
|
||||
<div style="margin-top:16px; border-top: 1px solid var(--line); padding-top:14px;">
|
||||
<div class="layer-section-title">Akun Rumah Ibadah</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Username Rumah Ibadah</label>
|
||||
<input class="form-input" id="ri-username" value="${esc(isEdit ? data.username || '' : '')}" placeholder="username_rumah_ibadah" autocomplete="off" readonly style="background-color:#f1f5f9;cursor:not-allowed;color:#64748b;"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Password Rumah Ibadah ${isEdit ? '(kosongkan jika tidak diubah)' : '*'}</label>
|
||||
<input class="form-input" id="ri-password" type="password" placeholder="••••••••" autocomplete="new-password"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
openModal(isEdit ? 'Edit Rumah Ibadah' : 'Tambah Rumah Ibadah', `
|
||||
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nama Rumah Ibadah *</label>
|
||||
<input class="form-input" id="ri-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="Masjid Al-Ikhlas…"/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Jenis *</label>
|
||||
<select class="form-select" id="ri-jenis">
|
||||
${['Masjid', 'Gereja', 'Pura', 'Vihara', 'Klenteng', 'Lainnya'].map(j =>
|
||||
`<option value="${j}"${isEdit && data.jenis === j ? ' selected' : ''}>${j}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Radius (meter) *</label>
|
||||
<input class="form-input" id="ri-radius" type="number" value="${isEdit ? data.radius_meter : 500}" min="50" max="10000"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Alamat</label>
|
||||
<input class="form-input" id="ri-alamat" value="${isEdit ? esc(data.alamat || '') : 'Memuat alamat otomatis...'}" placeholder="Jl. …"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">No. WhatsApp</label>
|
||||
<input class="form-input" id="ri-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/>
|
||||
</div>
|
||||
${accountFieldsHTML}`,
|
||||
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
|
||||
<button class="btn btn-primary" onclick="saveRI(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
|
||||
|
||||
// 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', `
|
||||
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nama Lengkap *</label>
|
||||
<input class="form-input" id="pm-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="Ahmad Subarjo…"/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">No. WhatsApp</label>
|
||||
<input class="form-input" id="pm-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">NIK</label>
|
||||
<input class="form-input" id="pm-nik" value="${esc(isEdit ? data.nik || '' : '')}" placeholder="617101…"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Alamat</label>
|
||||
<input class="form-input" id="pm-alamat" value="${isEdit ? esc(data.alamat || '') : 'Memuat alamat otomatis...'}" placeholder="Jl. …"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Jumlah Anggota Keluarga</label>
|
||||
<input class="form-input" id="pm-jml" type="number" value="${isEdit ? data.jumlah_anggota : 1}" min="1" max="20"/>
|
||||
</div>`,
|
||||
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
|
||||
<button class="btn btn-primary" onclick="savePM(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
|
||||
|
||||
// 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
|
||||
@@ -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: `<div style="width:32px;height:32px;background:#f59e0b;border:3px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 3px 10px rgba(0,0,0,.2);cursor:grab;">⛪</div>`,
|
||||
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 `<h4>⛪ ${esc(r.nama)}</h4>
|
||||
<p><b>Jenis:</b> ${r.jenis}</p>
|
||||
<p><b>Radius:</b> ${r.radius_meter} m</p>
|
||||
${r.alamat ? `<p><b>Alamat:</b> ${esc(r.alamat)}</p>` : ''}
|
||||
${r.no_wa ? `<p><b>WA:</b> ${r.no_wa}</p>` : ''}
|
||||
|
||||
<div class="popup-actions">
|
||||
${canEdit ? `<button class="btn btn-ghost btn-sm" onclick="editRI(${r.id})">✏ Edit</button>` : ''}
|
||||
${isAdmin ? `<button class="btn btn-danger btn-sm" onclick="deleteRI(${r.id})">✕ Hapus</button>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `<div style="position:absolute;top:-3px;right:-3px;width:9px;height:9px;background:#2563eb;border:1.5px solid #fff;border-radius:50%"></div>`;
|
||||
if (hasKons && !hasPemb) dot = `<div style="position:absolute;top:-3px;right:-3px;width:9px;height:9px;background:#f59e0b;border:1.5px solid #fff;border-radius:50%"></div>`;
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="position:relative"><div style="width:26px;height:26px;background:${color};border:3px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;box-shadow:0 3px 10px ${color}66;cursor:grab;">👤</div>${dot}</div>`,
|
||||
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 `<h4>👤 ${esc(p.nama)}</h4>
|
||||
<div class="proximity-info ${inR ? 'dalam' : 'luar'}">
|
||||
${inR ? `✅ Dalam radius — ${esc(p.nama_rumah_ibadah || '')}` : '❌ Di luar radius rumah ibadah'}
|
||||
</div>
|
||||
${p.no_wa ? `<p><b>WA:</b> ${p.no_wa}</p>` : ''}
|
||||
${p.nik ? `<p><b>NIK:</b> ${p.nik}</p>` : ''}
|
||||
${p.alamat ? `<p><b>Alamat:</b> ${esc(p.alamat)}</p>` : ''}
|
||||
<p><b>Keluarga:</b> ${p.jumlah_anggota} jiwa</p>
|
||||
<p><b>Bantuan:</b> 📚${p.bantuan_pemberdayaan} · 🛍${p.bantuan_konsumtif}</p>
|
||||
|
||||
<div class="popup-actions">
|
||||
${canEditOrLog ? `<button class="btn btn-primary btn-sm" onclick="openLogBantuan(${p.id},'${esc(p.nama)}',${p.latitude},${p.longitude})">📋 Log Bantuan</button>` : ''}
|
||||
${canEditOrLog ? `<button class="btn btn-ghost btn-sm" onclick="editPM(${p.id})">✏ Edit</button>` : ''}
|
||||
${isAdmin ? `<button class="btn btn-danger btn-sm" onclick="deletePM(${p.id})">✕</button>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -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 = `
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Visibilitas Layer</div>
|
||||
${mkLayerItem('rumahIbadah', '#f59e0b', 'Rumah Ibadah', ri.length)}
|
||||
${mkLayerItem('ibadahRadius', '#fbbf24', 'Radius Jangkauan', ri.length, true)}
|
||||
${mkLayerItem('pendudukMiskin', '#16a34a', 'Penduduk Miskin', pm.length)}
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Tambah Data</div>
|
||||
<button class="btn-add admin-only" id="btn-add-ri" onclick="startAdd('ri')">+ Tambah Rumah Ibadah</button>
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Rumah Ibadah (${ri.length})</div>
|
||||
<div class="data-list">
|
||||
${ri.length ? ri.map(r => `
|
||||
<div class="data-item" onclick="map.flyTo([${r.latitude},${r.longitude}],17)">
|
||||
<div class="item-icon">⛪</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">${esc(r.nama)}</div>
|
||||
<div class="item-sub">${r.jenis} · R=${r.radius_meter}m</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px">
|
||||
${(window.currentUser && (window.currentUser.role === 'admin' || (window.currentUser.role === 'petugas_lapangan' && String(window.currentUser.rumah_ibadah_id) === String(r.id)))) ? `<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editRI(${r.id})">✏</button>` : ''}
|
||||
${(window.currentUser && window.currentUser.role === 'admin') ? `<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteRI(${r.id})">✕</button>` : ''}
|
||||
</div>
|
||||
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
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 = `
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Visibilitas Layer</div>
|
||||
${mkLayerItem('rumahIbadah', '#f59e0b', 'Rumah Ibadah', ri.length)}
|
||||
${mkLayerItem('ibadahRadius', '#fbbf24', 'Radius Jangkauan', ri.length, true)}
|
||||
${mkLayerItem('pendudukMiskin', '#16a34a', 'Penduduk Miskin', pm.length)}
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Tambah Data</div>
|
||||
<button class="btn-add write-only" id="btn-add-pm" onclick="startAdd('pm')">+ Tambah Penduduk Miskin</button>
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Penduduk Miskin (${filteredPm.length})</div>
|
||||
<div class="data-list">
|
||||
${filteredPm.length ? filteredPm.map(p => `
|
||||
<div class="data-item" onclick="map.flyTo([${p.latitude},${p.longitude}],17)">
|
||||
<div class="item-icon">${pmEmoji(p)}</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">${esc(p.nama)}</div>
|
||||
<div class="item-sub">${p.total_bantuan} bantuan · ${p.jumlah_anggota} jiwa</div>
|
||||
</div>
|
||||
<div style="display:flex; align-items:center; gap:4px">
|
||||
<div class="item-badge ${p.status_proximity === 'dalam_radius' ? 'badge-green' : 'badge-red'}" style="margin-right:4px">
|
||||
${p.status_proximity === 'dalam_radius' ? 'DALAM' : 'LUAR'}
|
||||
</div>
|
||||
${(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)))) ? `
|
||||
<button class="btn btn-primary btn-sm" onclick="event.stopPropagation();openLogBantuan(${p.id},'${esc(p.nama)}',${p.latitude},${p.longitude})">📋</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editPM(${p.id})">✏</button>
|
||||
` : ''}
|
||||
${(window.currentUser && window.currentUser.role === 'admin') ? `<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deletePM(${p.id})">✕</button>` : ''}
|
||||
</div>
|
||||
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
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
|
||||
? `<div class="layer-dot" style="background:transparent;border:2px solid ${color}"></div>`
|
||||
: `<div class="layer-dot" style="background:${color}"></div>`;
|
||||
return `<div class="layer-item ${layerVisible[key] ? 'checked' : ''}" onclick="toggleLayer('${key}',this)">
|
||||
<div class="layer-check">${layerVisible[key] ? '✓' : ''}</div>${dot}
|
||||
<div class="layer-label">${label}</div>
|
||||
<div class="layer-count">${count}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 '👤';
|
||||
}
|
||||
|
||||
@@ -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 = `<tr><td colspan="7" style="text-align:center;padding:40px;color:#ef4444">Gagal memuat data: ${esc(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = `
|
||||
<tr><td colspan="7">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🔍</div>
|
||||
<h3>Tidak ada data</h3>
|
||||
<p>Coba ubah kriteria pencarian atau filter.</p>
|
||||
</div>
|
||||
</td></tr>`;
|
||||
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 `
|
||||
<tr onclick="openDrawer(${p.id})">
|
||||
<td>
|
||||
<div class="cell-name">${esc(p.nama)}</div>
|
||||
${p.alamat ? `<div class="cell-sub">📍 ${esc(p.alamat.substring(0, 50))}${p.alamat.length > 50 ? '…' : ''}</div>` : ''}
|
||||
</td>
|
||||
<td><span style="font-family:monospace;font-size:11px">${p.nik ? esc(p.nik) : '<span style="color:#c4c4c4">—</span>'}</span></td>
|
||||
<td><strong>${p.jumlah_anggota}</strong> jiwa</td>
|
||||
<td><span class="badge ${inR ? 'badge-dalam' : 'badge-luar'}">${inR ? '✅ Dalam' : '❌ Luar'}</span></td>
|
||||
<td>
|
||||
${p.nama_rumah_ibadah ? `<span style="font-size:12px">⛪ ${esc(p.nama_rumah_ibadah)}</span>` : '<span style="color:#c4c4c4;font-size:11px">—</span>'}
|
||||
</td>
|
||||
<td>
|
||||
<div class="aid-pills">
|
||||
${p.total_bantuan === 0 ? '<span style="color:#c4c4c4;font-size:11px">Belum ada</span>' : ''}
|
||||
${hasPemb ? `<span class="badge badge-pemberdayaan">📚 ${p.bantuan_pemberdayaan}</span>` : ''}
|
||||
${hasKons ? `<span class="badge badge-konsumtif">🛍️ ${p.bantuan_konsumtif}</span>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td onclick="event.stopPropagation()">
|
||||
<div style="display:flex;gap:4px">
|
||||
<button class="btn btn-ghost btn-sm" style="padding:5px 8px;font-size:11px" onclick="openDrawer(${p.id})">👁 Detail</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).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 += `<button class="pg-btn" onclick="goPage(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>‹</button>`;
|
||||
const rangeStart = Math.max(1, currentPage - 2);
|
||||
const rangeEnd = Math.min(totalPages, currentPage + 2);
|
||||
if (rangeStart > 1) btns += `<button class="pg-btn" onclick="goPage(1)">1</button>${rangeStart > 2 ? '<span style="padding:0 4px;color:#94a3b8">…</span>' : ''}`;
|
||||
for (let i = rangeStart; i <= rangeEnd; i++) {
|
||||
btns += `<button class="pg-btn ${i === currentPage ? 'active' : ''}" onclick="goPage(${i})">${i}</button>`;
|
||||
}
|
||||
if (rangeEnd < totalPages) btns += `${rangeEnd < totalPages - 1 ? '<span style="padding:0 4px;color:#94a3b8">…</span>' : ''}<button class="pg-btn" onclick="goPage(${totalPages})">${totalPages}</button>`;
|
||||
btns += `<button class="pg-btn" onclick="goPage(${currentPage + 1})" ${currentPage === totalPages ? 'disabled' : ''}>›</button>`;
|
||||
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 = `
|
||||
<div class="loading-spinner-wrap">
|
||||
<span class="spinner" style="width:20px;height:20px;border-width:3px;"></span>
|
||||
<p style="margin-top:12px">Memuat detail…</p>
|
||||
</div>`;
|
||||
|
||||
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 = `<p style="color:#ef4444">Gagal memuat: ${esc(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = `
|
||||
<!-- Proximity status -->
|
||||
<div class="proximity-card ${inR ? 'dalam' : 'luar'}">
|
||||
${inR ? `✅ Dalam radius jangkauan <strong>${esc(p.nama_rumah_ibadah || 'rumah ibadah')}</strong>` : '❌ Di luar radius semua rumah ibadah'}
|
||||
</div>
|
||||
|
||||
<!-- Basic info -->
|
||||
<div class="section-heading">Informasi Pribadi</div>
|
||||
<div>
|
||||
<div class="info-row"><span class="info-label">Nama Lengkap</span><span class="info-val">${esc(p.nama)}</span></div>
|
||||
<div class="info-row"><span class="info-label">NIK</span><span class="info-val" style="font-family:monospace">${p.nik ? esc(p.nik) : '—'}</span></div>
|
||||
<div class="info-row"><span class="info-label">No. WhatsApp</span><span class="info-val">${p.no_wa ? esc(p.no_wa) : '—'}</span></div>
|
||||
<div class="info-row"><span class="info-label">Jumlah Anggota</span><span class="info-val">${p.jumlah_anggota} jiwa</span></div>
|
||||
<div class="info-row"><span class="info-label">Alamat</span><span class="info-val">${p.alamat ? esc(p.alamat) : '—'}</span></div>
|
||||
<div class="info-row"><span class="info-label">Koordinat</span><span class="info-val" style="font-family:monospace;font-size:11px">${p.latitude.toFixed(6)}, ${p.longitude.toFixed(6)}</span></div>
|
||||
<div class="info-row"><span class="info-label">Terdata Sejak</span><span class="info-val">${new Date(p.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })}</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Bantuan summary -->
|
||||
<div class="section-heading">Ringkasan Bantuan</div>
|
||||
<div>
|
||||
${p.ringkasan_bantuan && p.ringkasan_bantuan.length ? p.ringkasan_bantuan.map(rb => `
|
||||
<div class="info-row">
|
||||
<span class="info-label">${rb.tipe_bantuan === 'Pemberdayaan' ? '📚 Pemberdayaan' : '🛍️ Konsumtif'}</span>
|
||||
<span class="info-val">${rb.jumlah} kali</span>
|
||||
</div>
|
||||
`).join('') : '<div style="color:#94a3b8;font-size:13px;padding:8px 0">Belum pernah menerima bantuan.</div>'}
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
${canEdit ? `
|
||||
<div style="margin-top:16px;display:flex;gap:8px">
|
||||
<button class="btn btn-primary btn-sm" onclick="openLogBantuanFromDrawer(${p.id}, '${esc(p.nama)}')">📋 Catat Bantuan</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="window.location.href='?page=map'">✏️ Edit di Peta</button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Log history -->
|
||||
${logs.length ? `
|
||||
<div class="section-heading">Riwayat Bantuan (${logs.length})</div>
|
||||
<div class="log-list">
|
||||
${logs.map(l => `
|
||||
<div class="log-entry">
|
||||
<div class="log-type-dot ${l.tipe_bantuan === 'Pemberdayaan' ? 'pem' : 'kon'}"></div>
|
||||
<div class="log-entry-info">
|
||||
<div class="log-entry-name">${l.tipe_bantuan === 'Pemberdayaan' ? '📚' : '🛍️'} ${esc(l.tipe_bantuan)}${l.sub_kategori ? ' — ' + esc(l.sub_kategori) : ''}</div>
|
||||
<div class="log-entry-detail">Dari: ${esc(l.nama_rumah_ibadah)}${l.nilai_bantuan ? ' · Rp' + new Intl.NumberFormat('id-ID').format(l.nilai_bantuan) : ''}${l.keterangan ? ' · ' + esc(l.keterangan) : ''}</div>
|
||||
</div>
|
||||
<div class="log-entry-date">${l.tanggal_bantuan}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
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, '>').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.'); }
|
||||
}
|
||||
@@ -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 = '<tr><td colspan="6" class="loading">Belum ada riwayat bantuan</td></tr>';
|
||||
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 `
|
||||
<tr>
|
||||
<td><strong>${b.tanggal_bantuan}</strong></td>
|
||||
<td>${escapeHTML(b.nama_penduduk)}</td>
|
||||
<td><span class="db-badge ${badgeClass}">${b.tipe_bantuan}</span></td>
|
||||
<td>${escapeHTML(b.sub_kategori || '—')}</td>
|
||||
<td><strong>${rp}</strong></td>
|
||||
<td>${escapeHTML(b.nama_rumah_ibadah)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderRiTable(riList) {
|
||||
const tbody = document.getElementById('table-ri-body');
|
||||
if (!riList || riList.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="loading">Belum ada data penyaluran</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = riList.map(r => {
|
||||
const rp = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(r.nilai);
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${escapeHTML(r.nama)}</strong></td>
|
||||
<td>${r.count} kali</td>
|
||||
<td><strong>${rp}</strong></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function escapeHTML(str) {
|
||||
if (str == null) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="empty-state" style="grid-column:1/-1">
|
||||
<div class="empty-state-icon">⚠️</div>
|
||||
<h3>Gagal memuat data</h3>
|
||||
<p>${esc(err.message)}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCards(data) {
|
||||
const grid = document.getElementById('ri-grid');
|
||||
document.getElementById('filter-count').textContent = `${data.length} rumah ibadah`;
|
||||
|
||||
if (!data.length) {
|
||||
grid.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🏛️</div>
|
||||
<h3>Belum ada rumah ibadah</h3>
|
||||
<p>Tambahkan rumah ibadah melalui halaman Peta Interaktif.</p>
|
||||
</div>`;
|
||||
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 `
|
||||
<div class="ri-card" onclick="openDrawer(${r.id})" data-jenis="${esc(r.jenis)}" data-account="${hasAccount ? 'active' : 'inactive'}" data-nama="${esc(r.nama.toLowerCase())}">
|
||||
<span class="ri-account-status ${hasAccount ? 'active' : 'inactive'}">${hasAccount ? '● Akun Aktif' : '○ Tanpa Akun'}</span>
|
||||
<div class="ri-card-header">
|
||||
<div class="ri-avatar">${riEmoji(r.jenis)}</div>
|
||||
<div class="ri-card-meta">
|
||||
<div class="ri-card-name">${esc(r.nama)}</div>
|
||||
<span class="ri-card-type">${esc(r.jenis)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ri-card-stats">
|
||||
<div class="stat-mini">
|
||||
<div class="stat-mini-val">${r.pm_dalam_radius}</div>
|
||||
<div class="stat-mini-label">Warga Binaan</div>
|
||||
</div>
|
||||
<div class="stat-mini">
|
||||
<div class="stat-mini-val">${r.total_penyaluran}</div>
|
||||
<div class="stat-mini-label">Penyaluran</div>
|
||||
</div>
|
||||
<div class="stat-mini">
|
||||
<div class="stat-mini-val">${r.total_nilai_bantuan > 0 ? rpFormatted : '—'}</div>
|
||||
<div class="stat-mini-label">Total Nilai</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ri-card-account">
|
||||
${hasAccount
|
||||
? `<span>👤</span> <span class="username">${esc(r.username)}</span>`
|
||||
: `<span class="no-account">Belum ada akun terdaftar</span>`}
|
||||
</div>
|
||||
|
||||
<div class="ri-card-footer">
|
||||
<div class="ri-card-alamat">📍 ${r.alamat ? esc(r.alamat) : 'Alamat belum diisi'}</div>
|
||||
<div class="ri-card-actions" onclick="event.stopPropagation()">
|
||||
<button class="btn-icon" title="Edit Rumah Ibadah" onclick="goToEdit(${r.id})">✏️</button>
|
||||
${!hasAccount
|
||||
? `<button class="btn-icon" title="Buat Akun" onclick="openBuatAkun(${r.id}, '${esc(r.nama)}')">🔑</button>`
|
||||
: `<button class="btn-icon" title="Reset Password" onclick="openResetPassword(${r.id}, '${esc(r.username)}')">🔐</button>`}
|
||||
${hasAccount ? `<button class="btn-icon danger" title="Hapus Akun" onclick="hapusAkun(${r.id}, '${esc(r.nama)}')">🗑️</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).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 = `
|
||||
<div class="loading-spinner-wrap">
|
||||
<span class="spinner" style="width:20px;height:20px;border-width:3px;"></span>
|
||||
<p style="margin-top:12px">Memuat detail…</p>
|
||||
</div>`;
|
||||
|
||||
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 = `<p style="color:#ef4444">Gagal memuat: ${esc(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = `
|
||||
<!-- KPI boxes -->
|
||||
<div class="kpi-row">
|
||||
<div class="kpi-box">
|
||||
<div class="kpi-val green">${r.pm_dalam_radius}</div>
|
||||
<div class="kpi-label">Warga Binaan</div>
|
||||
</div>
|
||||
<div class="kpi-box">
|
||||
<div class="kpi-val accent">${r.total_penyaluran}</div>
|
||||
<div class="kpi-label">Total Penyaluran</div>
|
||||
</div>
|
||||
<div class="kpi-box">
|
||||
<div class="kpi-val">${r.jumlah_penerima_unik}</div>
|
||||
<div class="kpi-label">Penerima Unik</div>
|
||||
</div>
|
||||
<div class="kpi-box">
|
||||
<div class="kpi-val" style="font-size:15px">${rp}</div>
|
||||
<div class="kpi-label">Total Nilai</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info dasar -->
|
||||
<div class="section-heading">Informasi Rumah Ibadah</div>
|
||||
<div>
|
||||
<div class="info-row"><span class="info-label">Jenis</span><span class="info-val">${riEmoji(r.jenis)} ${esc(r.jenis)}</span></div>
|
||||
<div class="info-row"><span class="info-label">Radius Jangkauan</span><span class="info-val">${r.radius_meter} meter</span></div>
|
||||
<div class="info-row"><span class="info-label">Alamat</span><span class="info-val" style="max-width:260px">${r.alamat ? esc(r.alamat) : '—'}</span></div>
|
||||
<div class="info-row"><span class="info-label">No. WhatsApp</span><span class="info-val">${r.no_wa ? esc(r.no_wa) : '—'}</span></div>
|
||||
<div class="info-row"><span class="info-label">Koordinat</span><span class="info-val" style="font-family:monospace;font-size:11px">${r.latitude.toFixed(6)}, ${r.longitude.toFixed(6)}</span></div>
|
||||
<div class="info-row"><span class="info-label">Bantuan Pemberdayaan</span><span class="info-val">📚 ${r.bantuan_pemberdayaan} kali</span></div>
|
||||
<div class="info-row"><span class="info-label">Bantuan Konsumtif</span><span class="info-val">🛍️ ${r.bantuan_konsumtif} kali</span></div>
|
||||
<div class="info-row"><span class="info-label">Penyaluran Terakhir</span><span class="info-val">${r.tanggal_bantuan_terakhir || '—'}</span></div>
|
||||
<div class="info-row"><span class="info-label">Terdaftar Sejak</span><span class="info-val">${new Date(r.created_at).toLocaleDateString('id-ID', {day:'numeric',month:'long',year:'numeric'})}</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Info akun -->
|
||||
<div class="section-heading">Informasi Akun</div>
|
||||
<div>
|
||||
${r.username ? `
|
||||
<div class="info-row"><span class="info-label">Username</span><span class="info-val" style="font-family:monospace">${esc(r.username)}</span></div>
|
||||
<div class="info-row"><span class="info-label">Password</span>
|
||||
<span class="info-val" style="display:flex;align-items:center;gap:8px">
|
||||
<span id="pw-display" style="font-family:monospace;letter-spacing:.1em">${r.password_plain ? '••••••••' : '<em style=color:#94a3b8>Tidak diketahui</em>'}</span>
|
||||
${r.password_plain ? `<button onclick="togglePw('${esc(r.password_plain)}')" id="pw-toggle-btn" style="font-size:11px;padding:3px 8px;border:1px solid #e2e8f0;border-radius:6px;background:#f8fafc;cursor:pointer;color:#475569;transition:all .15s" title="Tampilkan/Sembunyikan Password">👁 Lihat</button>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row"><span class="info-label">Status</span><span class="info-val" style="color:#15803d">● Aktif</span></div>
|
||||
<div class="info-row"><span class="info-label">Akun Dibuat</span><span class="info-val">${r.akun_dibuat ? new Date(r.akun_dibuat).toLocaleDateString('id-ID',{day:'numeric',month:'long',year:'numeric'}) : '—'}</span></div>
|
||||
<div style="margin-top:12px;display:flex;gap:8px">
|
||||
<button class="btn btn-ghost btn-sm" onclick="openResetPassword(${r.id}, '${esc(r.username)}')">🔐 Reset Password</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="hapusAkun(${r.id}, '${esc(r.nama)}')">🗑️ Hapus Akun</button>
|
||||
</div>
|
||||
` : `
|
||||
<div style="padding:16px;background:#f8fafc;border-radius:12px;text-align:center;color:#94a3b8;font-size:13px">
|
||||
Belum ada akun terdaftar untuk rumah ibadah ini.
|
||||
</div>
|
||||
<div style="margin-top:12px">
|
||||
<button class="btn btn-primary btn-sm" onclick="openBuatAkun(${r.id}, '${esc(r.nama)}')">🔑 Buat Akun</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Riwayat bantuan terbaru -->
|
||||
${r.riwayat_bantuan && r.riwayat_bantuan.length ? `
|
||||
<div class="section-heading">Riwayat Penyaluran Terbaru</div>
|
||||
<div class="log-list">
|
||||
${r.riwayat_bantuan.map(l => `
|
||||
<div class="log-entry">
|
||||
<div class="log-type-dot ${l.tipe_bantuan === 'Pemberdayaan' ? 'pem' : 'kon'}"></div>
|
||||
<div class="log-entry-info">
|
||||
<div class="log-entry-name">${esc(l.nama_penerima)}</div>
|
||||
<div class="log-entry-detail">${l.tipe_bantuan === 'Pemberdayaan' ? '📚' : '🛍️'} ${esc(l.tipe_bantuan)}${l.sub_kategori ? ' · ' + esc(l.sub_kategori) : ''}${l.nilai_bantuan ? ' · Rp' + new Intl.NumberFormat('id-ID').format(l.nilai_bantuan) : ''}</div>
|
||||
</div>
|
||||
<div class="log-entry-date">${l.tanggal_bantuan}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
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', `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Username</label>
|
||||
<input class="form-input" value="${esc(username)}" readonly style="background:#f8fafc;color:#64748b">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Password Baru *</label>
|
||||
<input class="form-input" id="new-password" type="password" placeholder="Min. 6 karakter" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Konfirmasi Password *</label>
|
||||
<input class="form-input" id="confirm-password" type="password" placeholder="Ulangi password baru" autocomplete="new-password">
|
||||
</div>
|
||||
`, `
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
|
||||
<button class="btn btn-primary" onclick="doResetPassword(${id})">🔐 Simpan Password</button>
|
||||
`);
|
||||
}
|
||||
|
||||
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', `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nama Rumah Ibadah</label>
|
||||
<input class="form-input" value="${esc(namaRI)}" readonly style="background:#f8fafc;color:#64748b">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Username (otomatis)</label>
|
||||
<input class="form-input" id="buat-username" value="${esc(sanitized)}" readonly style="background:#f1f5f9;color:#64748b;font-family:monospace">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Password *</label>
|
||||
<input class="form-input" id="buat-password" type="password" placeholder="Min. 6 karakter" autocomplete="new-password">
|
||||
</div>
|
||||
`, `
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
|
||||
<button class="btn btn-primary" onclick="doBuatAkun(${id})">🔑 Buat Akun</button>
|
||||
`);
|
||||
}
|
||||
|
||||
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, '>').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.'); }
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<div class="dashboard-content">
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="page-title-group">
|
||||
<h1>👥 Daftar Penduduk Miskin</h1>
|
||||
<p>Data lengkap seluruh penduduk miskin yang terdata dalam sistem.</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-ghost" id="btn-export" onclick="exportCSV()">📥 Ekspor CSV</button>
|
||||
<button class="btn btn-primary admin-only" onclick="window.location.href='?page=map'">+ Tambah Data</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="summary-bar" id="summary-bar">
|
||||
<div class="summary-item">
|
||||
<div class="summary-icon purple">👥</div>
|
||||
<div>
|
||||
<div class="summary-val" id="sum-total">—</div>
|
||||
<div class="summary-label">Total KK</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-icon purple">👨👩👧👦</div>
|
||||
<div>
|
||||
<div class="summary-val" id="sum-jiwa">—</div>
|
||||
<div class="summary-label">Total Jiwa</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-icon green">✅</div>
|
||||
<div>
|
||||
<div class="summary-val" id="sum-dalam">—</div>
|
||||
<div class="summary-label">Dalam Radius</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-icon red">❌</div>
|
||||
<div>
|
||||
<div class="summary-val" id="sum-luar">—</div>
|
||||
<div class="summary-label">Luar Radius</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-icon blue">🎁</div>
|
||||
<div>
|
||||
<div class="summary-val" id="sum-bantuan">—</div>
|
||||
<div class="summary-label">Penerima Bantuan</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-search">
|
||||
<span class="filter-search-icon">⌕</span>
|
||||
<input type="search" id="search-input" placeholder="Cari nama, NIK, atau alamat…" autocomplete="off"
|
||||
oninput="applyFilters()">
|
||||
</div>
|
||||
<select class="filter-select" id="filter-proximity" onchange="applyFilters()">
|
||||
<option value="">Semua Status</option>
|
||||
<option value="dalam_radius">Dalam Radius</option>
|
||||
<option value="luar_radius">Luar Radius</option>
|
||||
</select>
|
||||
<select class="filter-select" id="filter-ri" onchange="applyFilters()">
|
||||
<option value="">Semua Rumah Ibadah</option>
|
||||
</select>
|
||||
<select class="filter-select" id="filter-bantuan" onchange="applyFilters()">
|
||||
<option value="">Semua Bantuan</option>
|
||||
<option value="pernah">Pernah Menerima</option>
|
||||
<option value="belum">Belum Pernah</option>
|
||||
</select>
|
||||
<span class="filter-count" id="filter-count">—</span>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-container">
|
||||
<div class="table-scrollwrap">
|
||||
<table class="pm-table" id="pm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th onclick="sortBy('nama')">Nama <span class="sort-icon"></span></th>
|
||||
<th onclick="sortBy('nik')">NIK <span class="sort-icon"></span></th>
|
||||
<th onclick="sortBy('jumlah_anggota')">Keluarga <span class="sort-icon"></span></th>
|
||||
<th>Status</th>
|
||||
<th>Rumah Ibadah</th>
|
||||
<th onclick="sortBy('total_bantuan')">Bantuan <span class="sort-icon"></span></th>
|
||||
<th>Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pm-tbody">
|
||||
<tr>
|
||||
<td colspan="7" class="loading" style="text-align:center;padding:40px"><span class="spinner"></span>
|
||||
Memuat data…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="pagination" style="display:none">
|
||||
<div class="pagination-info" id="pagination-info"></div>
|
||||
<div class="pagination-btns" id="pagination-btns"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="detail-overlay" id="detail-overlay" onclick="closeDrawer(event)">
|
||||
<div class="detail-drawer" id="detail-drawer">
|
||||
<div class="drawer-head">
|
||||
<h2 id="drawer-title">Detail Penduduk Miskin</h2>
|
||||
<button class="drawer-close" onclick="closeDrawer()">✕</button>
|
||||
</div>
|
||||
<div class="drawer-body" id="drawer-body">
|
||||
<div class="loading-spinner-wrap"><span class="spinner" style="width:20px;height:20px;border-width:3px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,114 @@
|
||||
<div class="dashboard-content">
|
||||
|
||||
<!-- Dashboard Header -->
|
||||
<header class="dashboard-header">
|
||||
<div class="dashboard-title">
|
||||
<h1 id="greeting-title">Selamat Datang</h1>
|
||||
<p>Berikut adalah ringkasan data WebGIS Poverty saat ini.</p>
|
||||
</div>
|
||||
<div class="dashboard-date" id="current-date">Rabu, 10 Juni 2026</div>
|
||||
</header>
|
||||
|
||||
<!-- KPI Stat Cards Grid -->
|
||||
<section class="stats-grid">
|
||||
<!-- Stat Card 1 -->
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon purple">👥</div>
|
||||
<div class="stat-details">
|
||||
<div class="stat-label">Penduduk Miskin</div>
|
||||
<div class="stat-val" id="pm-total-kk">0 KK</div>
|
||||
<div class="stat-sub" id="pm-total-jiwa">0 Jiwa terdata</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stat Card 2 -->
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon green">⛪</div>
|
||||
<div class="stat-details">
|
||||
<div class="stat-label">Rumah Ibadah</div>
|
||||
<div class="stat-val" id="ri-total">0</div>
|
||||
<div class="stat-sub">Rumah ibadah terdaftar</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Stat Card 5 -->
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon rose">🎁</div>
|
||||
<div class="stat-details">
|
||||
<div class="stat-label">Bantuan Sosial</div>
|
||||
<div class="stat-val" id="lb-total-count">0 kali</div>
|
||||
<div class="stat-sub" id="lb-total-nilai">Total Rp 0</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Charts Visualization Section Grid -->
|
||||
<section class="charts-grid">
|
||||
<!-- Chart 1: Proximity Analysis -->
|
||||
<div class="chart-card">
|
||||
<h3 class="chart-title">Analisis Kedekatan Warga Miskin (Proximity)</h3>
|
||||
<div class="chart-container">
|
||||
<canvas id="chart-proximity"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart 2: Aid Distribution Type -->
|
||||
<div class="chart-card">
|
||||
<h3 class="chart-title">Distribusi Tipe Bantuan Sosial</h3>
|
||||
<div class="chart-container">
|
||||
<canvas id="chart-bantuan-tipe"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Data Tables Grid -->
|
||||
<section class="tables-grid">
|
||||
<!-- Table 1: Recent Activity (Log Bantuan Terbaru) -->
|
||||
<div class="table-card">
|
||||
<h3 class="table-title">Aktivitas Penyaluran Bantuan Terbaru</h3>
|
||||
<div class="db-table-wrapper">
|
||||
<table class="db-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tanggal</th>
|
||||
<th>Penerima</th>
|
||||
<th>Tipe Bantuan</th>
|
||||
<th>Sub Kategori</th>
|
||||
<th>Nilai (Rp)</th>
|
||||
<th>Penyalur</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-bantuan-body">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">Memuat data aktivitas...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table 2: Top Active Houses of Worship -->
|
||||
<div class="table-card">
|
||||
<h3 class="table-title">Rumah Ibadah Penyalur Teraktif</h3>
|
||||
<div class="db-table-wrapper">
|
||||
<table class="db-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nama Rumah Ibadah</th>
|
||||
<th>Frekuensi</th>
|
||||
<th>Total Nilai (Rp)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-ri-body">
|
||||
<tr>
|
||||
<td colspan="3" class="loading">Memuat data penyalur...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Masuk - WebGIS Poverty Mapping</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/components.css">
|
||||
<style>
|
||||
/* Ensure the login page body is visible immediately */
|
||||
body {
|
||||
display: flex !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="login-body">
|
||||
|
||||
<div class="login-container">
|
||||
<div class="login-header">
|
||||
<span class="login-logo">🗺️</span>
|
||||
<h1>WebGIS Poverty</h1>
|
||||
<p>Sistem Informasi Geografis & Manajemen Kemiskinan</p>
|
||||
</div>
|
||||
|
||||
<div class="login-error" id="login-error-msg"></div>
|
||||
|
||||
<form class="login-form" id="login-form" onsubmit="handleLogin(event)">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="username">Username</label>
|
||||
<input class="form-input" type="text" id="username" autocomplete="username" placeholder="Masukkan username..." required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input class="form-input" type="password" id="password" autocomplete="current-password" placeholder="Masukkan password..." required>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-login" type="submit" id="btn-submit">
|
||||
<span>Masuk</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Redirect if session is already active
|
||||
(async function() {
|
||||
try {
|
||||
const res = await fetch('api/me.php');
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') {
|
||||
window.location.href = '?page=dashboard';
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
||||
async function handleLogin(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const errorMsg = document.getElementById('login-error-msg');
|
||||
const btnSubmit = document.getElementById('btn-submit');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
|
||||
errorMsg.style.display = 'none';
|
||||
btnSubmit.disabled = true;
|
||||
btnSubmit.innerHTML = '<span>Memproses...</span>';
|
||||
|
||||
const body = {
|
||||
username: usernameInput.value.trim(),
|
||||
password: passwordInput.value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('api/login.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
// Success: redirect to default page (map)
|
||||
window.location.href = '?page=dashboard';
|
||||
} else {
|
||||
// Error: show error message
|
||||
errorMsg.textContent = result.message || 'Username atau password salah.';
|
||||
errorMsg.style.display = 'block';
|
||||
btnSubmit.disabled = false;
|
||||
btnSubmit.innerHTML = '<span>Masuk</span>';
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg.textContent = 'Gagal terhubung ke server. Pastikan Apache & MySQL menyala.';
|
||||
errorMsg.style.display = 'block';
|
||||
btnSubmit.disabled = false;
|
||||
btnSubmit.innerHTML = '<span>Masuk</span>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
<div class="dashboard-content">
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="page-title-group">
|
||||
<h1>🏛️ Manajemen Akun Rumah Ibadah</h1>
|
||||
<p>Kelola akun dan pantau statistik penyaluran bantuan masing-masing rumah ibadah.</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="window.location.href='?page=map#tambah-ri'" id="btn-tambah-ri">
|
||||
+ Tambah Rumah Ibadah
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter & Search Bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-search">
|
||||
<span class="filter-search-icon">⌕</span>
|
||||
<input type="search" id="search-input" placeholder="Cari nama atau jenis rumah ibadah…" autocomplete="off" oninput="filterCards()">
|
||||
</div>
|
||||
<select class="filter-select" id="filter-jenis" onchange="filterCards()">
|
||||
<option value="">Semua Jenis</option>
|
||||
<option value="Masjid">Masjid</option>
|
||||
<option value="Gereja">Gereja</option>
|
||||
<option value="Pura">Pura</option>
|
||||
<option value="Vihara">Vihara</option>
|
||||
<option value="Klenteng">Klenteng</option>
|
||||
<option value="Lainnya">Lainnya</option>
|
||||
</select>
|
||||
<select class="filter-select" id="filter-akun" onchange="filterCards()">
|
||||
<option value="">Semua Status Akun</option>
|
||||
<option value="active">Akun Aktif</option>
|
||||
<option value="inactive">Belum Ada Akun</option>
|
||||
</select>
|
||||
<span class="filter-count" id="filter-count">—</span>
|
||||
</div>
|
||||
|
||||
<!-- Cards Grid -->
|
||||
<div class="ri-grid" id="ri-grid">
|
||||
<div class="loading-spinner-wrap">
|
||||
<span class="spinner" style="width:20px;height:20px;border-width:3px;"></span>
|
||||
<p style="margin-top:12px">Memuat data rumah ibadah…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="detail-overlay" id="detail-overlay" onclick="closeDrawer(event)">
|
||||
<div class="detail-drawer" id="detail-drawer">
|
||||
<div class="drawer-head">
|
||||
<h2 id="drawer-title">Detail Rumah Ibadah</h2>
|
||||
<button class="drawer-close" onclick="closeDrawer()">✕</button>
|
||||
</div>
|
||||
<div class="drawer-body" id="drawer-body">
|
||||
<div class="loading-spinner-wrap">
|
||||
<span class="spinner" style="width:20px;height:20px;border-width:3px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<div id="app">
|
||||
<header id="header">
|
||||
<div class="nav-tabs" role="tablist" aria-label="Pilih layer" style="display:none"></div>
|
||||
|
||||
<div class="header-search" id="header-search">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">⌕</span>
|
||||
<input id="search-input" type="search" autocomplete="off"
|
||||
placeholder="Telusuri Webgis Poverty" aria-label="Cari objek peta" />
|
||||
</div>
|
||||
<div class="search-results" id="search-results" aria-label="Hasil pencarian"></div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="coord-display" id="coord-display">Lat: - | Lng: -</div>
|
||||
<div class="status-pill"><span id="status-zoom">Zoom: 13</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section id="map-wrap">
|
||||
<div id="instruction-banner"></div>
|
||||
<div id="map"></div>
|
||||
</section>
|
||||
|
||||
<aside id="panel">
|
||||
<div id="panel-head">
|
||||
<h2 id="panel-title">Rumah Ibadah & Kemiskinan</h2>
|
||||
</div>
|
||||
<div id="panel-body">
|
||||
<div class="loading"><span class="spinner"></span>Memuat data...</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
Reference in New Issue
Block a user