add files

This commit is contained in:
2026-06-10 19:23:50 +07:00
parent acbd9a4842
commit a9239d18cc
41 changed files with 7839 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
Options -Indexes
Options -MultiViews
# ── Security headers ──
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>
# ── Block direct access to sensitive files ──
<FilesMatch "^(config\.php|database\.sql|\.env)$">
Order allow,deny
Deny from all
</FilesMatch>
# ── Allow uploads folder but block PHP execution inside it ──
<IfModule mod_rewrite.c>
RewriteEngine On
# Block PHP execution in uploads folder
RewriteRule ^uploads/.*\.php$ - [F,L]
# Route all API requests through index if needed
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^api/([^/]+)/?$ api/$1.php [L,QSA]
</IfModule>
# ── Cache static assets ──
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/png "access plus 1 month"
ExpiresByType image/webp "access plus 1 month"
</IfModule>
+184
View File
@@ -0,0 +1,184 @@
# WebGIS Pengentasan Kemiskinan v3.0
## Kota Pontianak — Berbasis Komunitas & Rumah Ibadah
Sistem WebGIS berbasis PHP + MySQL + Leaflet.js untuk pemetaan, pendataan, dan distribusi bantuan sosial kepada penduduk miskin di Kota Pontianak.
---
## 📁 Struktur Folder
```
webgis_v3/
├── index.php ← Halaman utama (SPA: login + semua fitur)
├── config.php ← Konfigurasi database & helper functions
├── database.sql ← Skema database + data demo
├── .htaccess ← Konfigurasi Apache (keamanan + routing)
├── uploads/ ← Foto laporan masyarakat
└── api/
├── auth.php ← Login / logout / cek sesi
├── penduduk.php ← CRUD data penduduk + validasi pendidikan
├── keluarga.php ← CRUD data keluarga (KK)
├── rumah_ibadah.php ← CRUD rumah ibadah
├── bantuan.php ← Master bantuan + histori + prioritas otomatis
├── laporan.php ← Laporan masyarakat + upload foto
├── chat.php ← Chat antar pengurus
├── stats.php ← Dashboard KPI + GeoJSON untuk peta
├── pelatihan.php ← Manajemen pelatihan keterampilan
├── user.php ← Manajemen user & role (superadmin)
└── log.php ← Log aktivitas sistem
```
---
## 🚀 Cara Menjalankan di XAMPP
### Langkah 1 — Ekstrak & Taruh di htdocs
```
Ekstrak ZIP → salin folder webgis_v3 ke:
C:\xampp\htdocs\webgis_v3\
```
### Langkah 2 — Start XAMPP
Buka XAMPP Control Panel → Start **Apache** dan **MySQL**
### Langkah 3 — Import Database
1. Buka browser → `http://localhost/phpmyadmin`
2. Klik tab **SQL** di bagian atas
3. Salin seluruh isi file `database.sql`
4. Tempel ke kotak SQL → klik **Go**
Database `webgis_kemiskinan` akan terbuat otomatis dengan semua tabel dan data demo.
### Langkah 4 — Cek config.php (jika perlu)
```php
define('DB_HOST', 'localhost');
define('DB_USER', 'root'); // default XAMPP
define('DB_PASS', ''); // default XAMPP = kosong
define('DB_NAME', 'webgis_kemiskinan');
```
### Langkah 5 — Buka Aplikasi
```
http://localhost/webgis_v3
```
---
## 👥 Akun Demo
| Role | Username | Password | Akses |
|------|----------|----------|-------|
| 👑 Super Admin | `superadmin` | `password` | Semua fitur + manajemen user |
| 🏛️ Pimpinan | `pimpinan1` | `password` | Dashboard monitoring (read-only) |
| 🕌 Pengurus RI | `pengurus1` | `password` | Input data + distribusi bantuan |
| 🕌 Pengurus RI | `pengurus2` | `password` | Input data + distribusi bantuan |
| 🚑 Relawan | `relawan1` | `password` | Survey lapangan + laporan |
| 👤 Masyarakat | `masyarakat1` | `password` | Lihat peta + buat laporan |
> Laporan masyarakat bisa dikirim **tanpa login** (klik chip "Lapor Tanpa Login" di halaman login).
---
## 🗄️ Struktur Database (13 Tabel)
| Tabel | Deskripsi |
|-------|-----------|
| `role` | 5 role sistem |
| `user` | Akun pengguna + relasi ke role & rumah ibadah |
| `rumah_ibadah` | Data masjid, gereja, vihara, klenteng, pura |
| `keluarga` | Data Kartu Keluarga (KK) |
| `penduduk` | Data penduduk lengkap (NIK, umur otomatis, profil umur) |
| `pelatihan` | Program pelatihan keterampilan |
| `riwayat_pelatihan` | Peserta per pelatihan |
| `bantuan` | Master program bantuan (PKH, BPNT, BLT, dll) |
| `histori_bantuan` | Riwayat penyaluran bantuan per penduduk |
| `laporan` | Laporan kondisi warga dari masyarakat |
| `chat` | Pesan koordinasi antar pengurus |
| `log_aktivitas` | Audit trail semua aktivitas sistem |
---
## ⚡ Fitur Utama
### 🗺️ WebGIS Interaktif
- Peta gelap (dark mode) berbasis OpenStreetMap + CartoDB
- **3 warna marker**: 🔴 Miskin | 🟡 Rentan | 🟢 Mampu
- Marker laporan masyarakat dengan kode urgensi
- Layer toggle per kategori
- Radius distribusi adjustable (1001000m)
- Popup detail klik marker
### 👥 Manajemen Data Penduduk
- NIK 16 digit sebagai primary key
- **Umur dihitung otomatis** dari tanggal lahir (MySQL Generated Column)
- **Profil umur otomatis**: Balita / Anak / Remaja / Dewasa / Lansia
- Validasi pendidikan vs umur:
- Usia 715 → peringatan jika tidak sekolah
- Usia 1618 → peringatan jika tidak sekolah
- Relasi ke Kartu Keluarga
### 🏠 Manajemen Keluarga
- Data KK lengkap (No KK, RT/RW, kelurahan, kecamatan)
- Relasi ke rumah ibadah terdekat
- Daftar anggota keluarga dengan detail kondisi
### 🎁 Sistem Bantuan
- **Prioritas otomatis**: miskin + sakit = SANGAT TINGGI; lansia sendiri = SANGAT TINGGI
- Deteksi warga yang belum pernah menerima bantuan
- Histori penyaluran bantuan per individu yang transparan
- Catat distribusi langsung dari halaman bantuan
### 📢 Laporan Masyarakat
- Bisa dikirim **tanpa login**
- Upload foto dokumentasi (JPG/PNG, maks 5MB)
- 4 tingkat urgensi: Rendah / Sedang / Tinggi / 🆘 Darurat
- Alur status: Pending → Diverifikasi → Diproses → Selesai
- Koordinat laporan via klik peta atau GPS
### 💬 Chat Koordinasi
- Chat realtime antar pengurus rumah ibadah
- Polling otomatis setiap 8 detik
- Notifikasi dot merah jika ada pesan baru
- Pesan broadcast ke semua pengurus
### 📊 Dashboard & Statistik
- KPI card: total penduduk, KK, miskin, rentan, laporan aktif
- Progress bar distribusi per rumah ibadah
- Analisis singkat di sidebar
---
## 🔒 Keamanan
- Input sanitasi (anti XSS) di semua endpoint
- Prepared statements PDO (anti SQL Injection)
- Session-based auth dengan cookie HttpOnly
- Role-based access control (RBAC) di backend
- Block akses langsung ke `config.php` dan `database.sql` via `.htaccess`
- Block eksekusi PHP di folder `uploads/`
---
## 🛠️ Teknologi
| Komponen | Teknologi |
|----------|-----------|
| Backend | PHP 7.4+ (PDO, Sessions) |
| Database | MySQL 5.7+ / MariaDB 10.3+ |
| Peta | Leaflet.js v1.9.4 |
| Tile Map | CartoDB Dark Matter (OpenStreetMap) |
| Geocoding | Nominatim API (OSM) |
| Frontend | Vanilla JS ES6+, CSS3 Variables |
| Font | Plus Jakarta Sans + DM Mono |
---
## 📝 Catatan Pengembangan Lanjutan
1. **Backend database**: Tambah `localStorage` atau backend sinkronisasi untuk multi-device
2. **Export laporan**: Tambah ekspor PDF/Excel menggunakan mPDF atau PhpSpreadsheet
3. **Notifikasi push**: Integrasikan Firebase Cloud Messaging untuk notifikasi laporan darurat
4. **PWA offline**: Service Worker untuk penggunaan lapangan tanpa internet
5. **Integrasi DTKS**: Sinkronisasi dengan Data Terpadu Kesejahteraan Sosial Kemensos RI
---
*Dikembangkan sebagai tugas Rekayasa Perangkat Lunak — Program Studi Informatika*
*WebGIS Pengentasan Kemiskinan Berbasis Komunitas dan Rumah Ibadah — Kota Pontianak*
+60
View File
@@ -0,0 +1,60 @@
<?php
require_once __DIR__ . '/../config.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'login':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonErr('Method not allowed', 405);
$b = json_decode(file_get_contents('php://input'), true);
$username = sanitize($b['username'] ?? '');
$password = $b['password'] ?? '';
if (!$username || !$password) jsonErr('Username dan password wajib diisi.');
$db = getDB();
$st = $db->prepare("SELECT u.id,u.username,u.password,u.nama_lengkap,u.telepon,u.aktif,
r.kode as role, r.nama as role_nama, u.rumah_ibadah_id,
ri.nama as ri_nama
FROM user u
JOIN role r ON r.id = u.role_id
LEFT JOIN rumah_ibadah ri ON ri.id = u.rumah_ibadah_id
WHERE u.username = ?");
$st->execute([$username]);
$user = $st->fetch();
if (!$user) jsonErr('Username atau password salah.', 401);
if (!$user['aktif']) jsonErr('Akun ini dinonaktifkan. Hubungi administrator.', 403);
$valid = password_verify($password, $user['password']) || $user['password'] === $password;
if (!$valid) jsonErr('Username atau password salah.', 401);
startSession();
$sess = [
'id' => (int)$user['id'],
'username' => $user['username'],
'nama_lengkap'=> $user['nama_lengkap'],
'role' => $user['role'],
'role_nama' => $user['role_nama'],
'ri_id' => $user['rumah_ibadah_id'] ? (int)$user['rumah_ibadah_id'] : null,
'ri_nama' => $user['ri_nama'],
];
$_SESSION['user'] = $sess;
log_aksi($sess['id'], 'LOGIN', 'user', $sess['id'], 'Login berhasil');
jsonOk($sess, 'Selamat datang, '.$user['nama_lengkap'].'!');
break;
case 'logout':
$u = getCurrentUser();
if ($u) log_aksi($u['id'], 'LOGOUT', 'user', $u['id'], 'Logout');
startSession(); session_destroy();
jsonOk(null, 'Logout berhasil.');
break;
case 'check':
$u = getCurrentUser();
if ($u) jsonOk($u);
else jsonErr('Tidak ada sesi aktif.', 401);
break;
default:
jsonErr('Action tidak dikenali.', 404);
}
+114
View File
@@ -0,0 +1,114 @@
<?php
require_once __DIR__ . '/../config.php';
$user = requireLogin();
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$db = getDB();
// ── HISTORI BANTUAN ──
if ($action === 'histori') {
if ($method === 'GET') {
$pid = (int)($_GET['penduduk_id'] ?? 0);
if (!$pid) jsonErr('penduduk_id wajib.');
$st = $db->prepare("SELECT hb.*, b.nama as bantuan_nama, b.jenis, b.satuan, b.sumber,
u.nama_lengkap as disalurkan_nama
FROM histori_bantuan hb
JOIN bantuan b ON b.id=hb.bantuan_id
LEFT JOIN user u ON u.id=hb.disalurkan_oleh
WHERE hb.penduduk_id=? ORDER BY hb.tanggal DESC");
$st->execute([$pid]);
jsonOk($st->fetchAll());
}
if ($method === 'POST') {
requireRole(['superadmin','pengurus']);
$b = json_decode(file_get_contents('php://input'), true);
$pid = (int)($b['penduduk_id'] ?? 0);
$bid = (int)($b['bantuan_id'] ?? 0);
if (!$pid || !$bid) jsonErr('penduduk_id dan bantuan_id wajib.');
$db->prepare("INSERT INTO histori_bantuan (penduduk_id,bantuan_id,disalurkan_oleh,tanggal,jumlah,status,catatan) VALUES (?,?,?,?,?,?,?)")
->execute([$pid,$bid,$user['id'],$b['tanggal']??date('Y-m-d'),(float)($b['jumlah']??1),$b['status']??'disalurkan',sanitize($b['catatan']??'')]);
$newId=(int)$db->lastInsertId();
log_aksi($user['id'],'CREATE','histori_bantuan',$newId,"Catat bantuan penduduk_id:$pid bantuan_id:$bid");
jsonOk(['id'=>$newId],'Histori bantuan dicatat.');
}
if ($method === 'PUT') {
requireRole(['superadmin','pengurus']);
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$db->prepare("UPDATE histori_bantuan SET status=?,catatan=? WHERE id=?")
->execute([$b['status']??'disalurkan',sanitize($b['catatan']??''),$id]);
jsonOk(null,'Status bantuan diperbarui.');
}
jsonErr('Method tidak didukung.', 405);
}
// ── PRIORITAS OTOMATIS ──
if ($action === 'prioritas') {
$st = $db->query("
SELECT p.id, p.nik, p.nama, p.umur, p.profil_umur, p.kondisi_kesehatan,
p.status_ekonomi, p.penghasilan, p.status_perkawinan,
k.alamat, k.kelurahan, k.lat, k.lng,
(SELECT COUNT(*) FROM histori_bantuan hb WHERE hb.penduduk_id=p.id AND hb.status='disalurkan') as total_bantuan,
CASE
WHEN p.status_ekonomi='miskin' AND p.kondisi_kesehatan IN ('sakit_berat','disabilitas') THEN 'SANGAT TINGGI'
WHEN p.profil_umur='Lansia' AND p.status_perkawinan IN ('cerai_mati','cerai_hidup') THEN 'SANGAT TINGGI'
WHEN p.status_ekonomi='miskin' AND p.kondisi_kesehatan='sakit_ringan' THEN 'TINGGI'
WHEN p.status_ekonomi='miskin' AND p.profil_umur='Lansia' THEN 'TINGGI'
WHEN p.status_ekonomi='miskin' THEN 'SEDANG'
WHEN p.status_ekonomi='rentan' AND p.kondisi_kesehatan != 'sehat' THEN 'SEDANG'
ELSE 'RENDAH'
END as prioritas
FROM v_penduduk p
JOIN keluarga k ON k.id=p.keluarga_id
WHERE p.status_hidup='hidup'
ORDER BY FIELD(prioritas,'SANGAT TINGGI','TINGGI','SEDANG','RENDAH'), total_bantuan ASC
LIMIT 100
");
jsonOk($st->fetchAll());
}
// ── BELUM TERIMA BANTUAN ──
if ($action === 'belum_terima') {
$st = $db->query("
SELECT p.id, p.nik, p.nama, p.umur, p.profil_umur, p.status_ekonomi,
p.kondisi_kesehatan, k.kelurahan, k.alamat, k.lat, k.lng
FROM v_penduduk p
JOIN keluarga k ON k.id=p.keluarga_id
WHERE p.status_hidup='hidup'
AND p.status_ekonomi IN ('miskin','rentan')
AND p.id NOT IN (SELECT DISTINCT penduduk_id FROM histori_bantuan WHERE status='disalurkan')
ORDER BY p.status_ekonomi DESC, p.umur DESC
");
jsonOk($st->fetchAll());
}
// ── CRUD MASTER BANTUAN ──
switch ($method) {
case 'GET':
if ($id) {
$st = $db->prepare("SELECT * FROM bantuan WHERE id=?"); $st->execute([$id]);
jsonOk($st->fetch());
}
jsonOk($db->query("SELECT * FROM bantuan ORDER BY nama")->fetchAll());
break;
case 'POST':
requireRole(['superadmin','pengurus']);
$b = json_decode(file_get_contents('php://input'), true);
$db->prepare("INSERT INTO bantuan (nama,jenis,sumber,satuan,nilai_per_satuan,periode) VALUES (?,?,?,?,?,?)")
->execute([sanitize($b['nama']??''),$b['jenis']??'lainnya',sanitize($b['sumber']??''),sanitize($b['satuan']??''),(float)($b['nilai_per_satuan']??0),$b['periode']??'sekali']);
jsonOk(['id'=>(int)$db->lastInsertId()],'Bantuan ditambahkan.');
break;
case 'PUT':
requireRole(['superadmin','pengurus']);
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$db->prepare("UPDATE bantuan SET nama=?,jenis=?,sumber=?,satuan=?,nilai_per_satuan=?,periode=?,aktif=? WHERE id=?")
->execute([sanitize($b['nama']??''),$b['jenis']??'lainnya',sanitize($b['sumber']??''),sanitize($b['satuan']??''),(float)($b['nilai_per_satuan']??0),$b['periode']??'sekali',(int)($b['aktif']??1),$id]);
jsonOk(null,'Bantuan diperbarui.');
break;
default: jsonErr('Method tidak didukung.', 405);
}
+55
View File
@@ -0,0 +1,55 @@
<?php
require_once __DIR__ . '/../config.php';
$user = requireLogin();
$method = $_SERVER['REQUEST_METHOD'];
$db = getDB();
switch ($method) {
case 'GET':
$since = $_GET['since'] ?? '1970-01-01 00:00:00';
// ambil pesan broadcast + pesan ke/dari user ini
$st = $db->prepare("SELECT c.id, c.pesan, c.created_at, c.dibaca,
u.nama_lengkap as dari_nama, u.id as dari_id,
r.kode as dari_role, u.rumah_ibadah_id,
ri.nama as dari_ri,
c.ke_user_id
FROM chat c
JOIN user u ON u.id=c.dari_user_id
JOIN role r ON r.id=u.role_id
LEFT JOIN rumah_ibadah ri ON ri.id=u.rumah_ibadah_id
WHERE (c.ke_user_id IS NULL OR c.ke_user_id=? OR c.dari_user_id=?)
AND c.created_at > ?
ORDER BY c.created_at ASC LIMIT 100");
$st->execute([$user['id'], $user['id'], $since]);
$msgs = $st->fetchAll();
// tandai sudah dibaca
$db->prepare("UPDATE chat SET dibaca=1 WHERE ke_user_id=? AND dibaca=0")->execute([$user['id']]);
jsonOk($msgs);
break;
case 'POST':
// hanya pengurus, relawan, superadmin
requireRole(['superadmin','pengurus','relawan']);
$b = json_decode(file_get_contents('php://input'), true);
$pesan = sanitize($b['pesan'] ?? '');
if (!$pesan) jsonErr('Pesan tidak boleh kosong.');
$ke = !empty($b['ke_user_id']) ? (int)$b['ke_user_id'] : null;
$db->prepare("INSERT INTO chat (dari_user_id, ke_user_id, pesan) VALUES (?,?,?)")
->execute([$user['id'], $ke, $pesan]);
$newId = (int)$db->lastInsertId();
// ambil pesan lengkap untuk response
$st = $db->prepare("SELECT c.id, c.pesan, c.created_at, u.nama_lengkap as dari_nama,
r.kode as dari_role, ri.nama as dari_ri, c.ke_user_id
FROM chat c JOIN user u ON u.id=c.dari_user_id
JOIN role r ON r.id=u.role_id
LEFT JOIN rumah_ibadah ri ON ri.id=u.rumah_ibadah_id
WHERE c.id=?");
$st->execute([$newId]);
jsonOk($st->fetch(), 'Pesan terkirim.');
break;
default: jsonErr('Method tidak didukung.', 405);
}
+107
View File
@@ -0,0 +1,107 @@
<?php
require_once __DIR__ . '/../config.php';
$user = requireLogin();
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$db = getDB();
switch ($method) {
case 'GET':
if ($id) {
$st = $db->prepare("SELECT k.*, ri.nama as ri_nama, ri.jenis as ri_jenis
FROM keluarga k LEFT JOIN rumah_ibadah ri ON ri.id=k.rumah_ibadah_id
WHERE k.id=?");
$st->execute([$id]);
$row = $st->fetch();
if (!$row) jsonErr('Tidak ditemukan.', 404);
// anggota keluarga
$an = $db->prepare("SELECT id,nik,nama,jenis_kelamin,umur,profil_umur,
status_keluarga,status_perkawinan,status_ekonomi,
kondisi_kesehatan,pekerjaan,penghasilan,
status_pendidikan,pendidikan_terakhir
FROM penduduk WHERE keluarga_id=? AND status_hidup='hidup' ORDER BY
FIELD(status_keluarga,'kepala','istri','anak','orang_tua','lainnya')");
$an->execute([$id]);
$row['anggota'] = $an->fetchAll();
jsonOk($row);
break;
}
$where = []; $params = [];
if (!empty($_GET['status_ekonomi'])) { $where[] = "k.status_ekonomi=?"; $params[] = $_GET['status_ekonomi']; }
if (!empty($_GET['kelurahan'])) { $where[] = "k.kelurahan=?"; $params[] = $_GET['kelurahan']; }
if (!empty($_GET['rumah_ibadah_id'])) { $where[] = "k.rumah_ibadah_id=?"; $params[] = (int)$_GET['rumah_ibadah_id']; }
if (!empty($_GET['q'])) { $where[] = "(k.nama_kk LIKE ? OR k.no_kk LIKE ?)"; $params[] = '%'.$_GET['q'].'%'; $params[] = '%'.$_GET['q'].'%'; }
$wStr = $where ? 'WHERE '.implode(' AND ',$where) : '';
$st = $db->prepare("SELECT k.id, k.no_kk, k.nama_kk, k.alamat, k.rt, k.rw,
k.kelurahan, k.kecamatan, k.lat, k.lng, k.status_ekonomi,
ri.nama as ri_nama,
(SELECT COUNT(*) FROM penduduk p WHERE p.keluarga_id=k.id AND p.status_hidup='hidup') as jumlah_jiwa,
(SELECT COUNT(*) FROM histori_bantuan hb JOIN penduduk p ON p.id=hb.penduduk_id WHERE p.keluarga_id=k.id AND hb.status='disalurkan') as total_bantuan_diterima
FROM keluarga k LEFT JOIN rumah_ibadah ri ON ri.id=k.rumah_ibadah_id
$wStr ORDER BY k.nama_kk ASC LIMIT 500");
$st->execute($params);
jsonOk($st->fetchAll());
break;
case 'POST':
requireRole(['superadmin','pengurus','relawan']);
$b = json_decode(file_get_contents('php://input'), true);
$no_kk = sanitize($b['no_kk'] ?? '');
$nama_kk = sanitize($b['nama_kk'] ?? '');
if (!$no_kk || !$nama_kk) jsonErr('No KK dan nama KK wajib diisi.');
if (strlen($no_kk) !== 16) jsonErr('No KK harus 16 digit.');
$chk = $db->prepare("SELECT id FROM keluarga WHERE no_kk=?"); $chk->execute([$no_kk]);
if ($chk->fetch()) jsonErr('No KK sudah terdaftar.');
$db->prepare("INSERT INTO keluarga (no_kk,nama_kk,alamat,rt,rw,kelurahan,kecamatan,lat,lng,rumah_ibadah_id,status_ekonomi,created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)")
->execute([
$no_kk, $nama_kk,
sanitize($b['alamat'] ?? ''),
sanitize($b['rt'] ?? ''), sanitize($b['rw'] ?? ''),
sanitize($b['kelurahan'] ?? ''), sanitize($b['kecamatan'] ?? ''),
isset($b['lat']) ? (float)$b['lat'] : null,
isset($b['lng']) ? (float)$b['lng'] : null,
!empty($b['rumah_ibadah_id']) ? (int)$b['rumah_ibadah_id'] : null,
$b['status_ekonomi'] ?? 'rentan',
$user['id']
]);
$newId = (int)$db->lastInsertId();
log_aksi($user['id'], 'CREATE', 'keluarga', $newId, "Tambah KK: $nama_kk");
jsonOk(['id'=>$newId], 'Keluarga berhasil ditambahkan.');
break;
case 'PUT':
requireRole(['superadmin','pengurus','relawan']);
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$db->prepare("UPDATE keluarga SET nama_kk=?,alamat=?,rt=?,rw=?,kelurahan=?,kecamatan=?,
lat=?,lng=?,rumah_ibadah_id=?,status_ekonomi=?,updated_at=NOW() WHERE id=?")
->execute([
sanitize($b['nama_kk'] ?? ''), sanitize($b['alamat'] ?? ''),
sanitize($b['rt'] ?? ''), sanitize($b['rw'] ?? ''),
sanitize($b['kelurahan'] ?? ''), sanitize($b['kecamatan'] ?? ''),
isset($b['lat']) ? (float)$b['lat'] : null,
isset($b['lng']) ? (float)$b['lng'] : null,
!empty($b['rumah_ibadah_id']) ? (int)$b['rumah_ibadah_id'] : null,
$b['status_ekonomi'] ?? 'rentan',
$id
]);
log_aksi($user['id'], 'UPDATE', 'keluarga', $id, "Edit KK ID: $id");
jsonOk(null, 'Data keluarga diperbarui.');
break;
case 'DELETE':
requireRole(['superadmin']);
if (!$id) jsonErr('ID tidak valid.');
$db->prepare("DELETE FROM keluarga WHERE id=?")->execute([$id]);
log_aksi($user['id'], 'DELETE', 'keluarga', $id, "Hapus KK ID: $id");
jsonOk(null, 'Data keluarga dihapus.');
break;
default: jsonErr('Method tidak didukung.', 405);
}
+74
View File
@@ -0,0 +1,74 @@
<?php
require_once __DIR__ . '/../config.php';
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$db = getDB();
switch ($method) {
case 'GET':
if ($id) {
$st = $db->prepare("SELECT l.*, p.nama as penduduk_nama, u.nama_lengkap as verifikator
FROM laporan l
LEFT JOIN penduduk p ON p.id=l.penduduk_id
LEFT JOIN user u ON u.id=l.diverifikasi_oleh
WHERE l.id=?");
$st->execute([$id]); jsonOk($st->fetch()); break;
}
$where=[]; $params=[];
if (!empty($_GET['status'])) { $where[]="l.status=?"; $params[]=$_GET['status']; }
if (!empty($_GET['urgensi'])) { $where[]="l.urgensi=?"; $params[]=$_GET['urgensi']; }
$w = $where ? 'WHERE '.implode(' AND ',$where) : '';
$st = $db->prepare("SELECT l.id, l.pelapor_nama, l.deskripsi, l.lat, l.lng,
l.alamat_laporan, l.urgensi, l.status, l.foto,
l.created_at, p.nama as penduduk_nama
FROM laporan l LEFT JOIN penduduk p ON p.id=l.penduduk_id
$w ORDER BY FIELD(l.urgensi,'darurat','tinggi','sedang','rendah'), l.created_at DESC");
$st->execute($params); jsonOk($st->fetchAll()); break;
case 'POST':
// masyarakat umum pun bisa lapor (tidak perlu login)
$pelapor = sanitize($_POST['pelapor_nama'] ?? '');
$telp = sanitize($_POST['pelapor_telepon']?? '');
$deskripsi = sanitize($_POST['deskripsi'] ?? '');
$alamat = sanitize($_POST['alamat_laporan'] ?? '');
$lat = isset($_POST['lat']) ? (float)$_POST['lat'] : null;
$lng = isset($_POST['lng']) ? (float)$_POST['lng'] : null;
$urgensi = $_POST['urgensi'] ?? 'sedang';
$pid = !empty($_POST['penduduk_id']) ? (int)$_POST['penduduk_id'] : null;
if (!$deskripsi) jsonErr('Deskripsi laporan wajib diisi.');
// Upload foto
$fotoPath = null;
if (!empty($_FILES['foto']['tmp_name'])) {
$ext = strtolower(pathinfo($_FILES['foto']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg','jpeg','png','webp'])) jsonErr('Format foto harus JPG/PNG/WebP.');
if ($_FILES['foto']['size'] > 5 * 1024 * 1024) jsonErr('Ukuran foto maks 5MB.');
$fname = 'laporan_' . time() . '_' . rand(1000,9999) . '.' . $ext;
move_uploaded_file($_FILES['foto']['tmp_name'], UPLOAD_DIR . $fname);
$fotoPath = 'uploads/' . $fname;
}
$db->prepare("INSERT INTO laporan (pelapor_nama,pelapor_telepon,penduduk_id,deskripsi,lat,lng,alamat_laporan,foto,urgensi) VALUES (?,?,?,?,?,?,?,?,?)")
->execute([$pelapor,$telp,$pid,$deskripsi,$lat,$lng,$alamat,$fotoPath,$urgensi]);
$newId = (int)$db->lastInsertId();
$u = getCurrentUser();
if ($u) log_aksi($u['id'],'CREATE','laporan',$newId,"Laporan baru: $urgensi");
jsonOk(['id'=>$newId],'Laporan berhasil dikirim. Terima kasih!'); break;
case 'PUT':
$u = requireLogin();
requireRole(['superadmin','pengurus','relawan']);
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$chk=$db->prepare("SELECT id FROM laporan WHERE id=?"); $chk->execute([$id]);
if (!$chk->fetch()) jsonErr('Laporan tidak ditemukan.',404);
$db->prepare("UPDATE laporan SET status=?,catatan_admin=?,diverifikasi_oleh=?,updated_at=NOW() WHERE id=?")
->execute([$b['status']??'pending',sanitize($b['catatan_admin']??''),$u['id'],$id]);
log_aksi($u['id'],'UPDATE','laporan',$id,"Update status: ".($b['status']??''));
jsonOk(null,'Status laporan diperbarui.'); break;
default: jsonErr('Method tidak didukung.',405);
}
+27
View File
@@ -0,0 +1,27 @@
<?php
// api/log.php — Log Aktivitas
require_once __DIR__ . '/../config.php';
requireRole(['superadmin', 'pimpinan']);
$db = getDB();
$limit = min((int)($_GET['limit'] ?? 100), 500);
$offset = (int)($_GET['offset'] ?? 0);
$tabel = $_GET['tabel'] ?? '';
$uid = (int)($_GET['user_id'] ?? 0);
$where = []; $params = [];
if ($tabel) { $where[] = "l.tabel=?"; $params[] = $tabel; }
if ($uid) { $where[] = "l.user_id=?"; $params[] = $uid; }
$w = $where ? 'WHERE '.implode(' AND ',$where) : '';
$st = getDB()->prepare("SELECT l.id, l.aksi, l.tabel, l.record_id, l.keterangan, l.ip_address, l.created_at,
u.username, u.nama_lengkap, r.kode as role
FROM log_aktivitas l
LEFT JOIN user u ON u.id=l.user_id
LEFT JOIN role r ON r.id=u.role_id
$w ORDER BY l.created_at DESC LIMIT ? OFFSET ?");
$params[] = $limit; $params[] = $offset;
$st->execute($params);
$total = (int)getDB()->query("SELECT COUNT(*) FROM log_aktivitas $w")->fetchColumn();
jsonOk(['rows' => $st->fetchAll(), 'total' => $total, 'limit' => $limit, 'offset' => $offset]);
+95
View File
@@ -0,0 +1,95 @@
<?php
// api/pelatihan.php — Manajemen Pelatihan & Riwayat
require_once __DIR__ . '/../config.php';
$user = requireLogin();
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$db = getDB();
// ── RIWAYAT PELATIHAN PER PENDUDUK ──
if ($action === 'riwayat') {
$pid = (int)($_GET['penduduk_id'] ?? 0);
if (!$pid) jsonErr('penduduk_id wajib.');
$st = $db->prepare("SELECT rp.*, p.nama_pelatihan, p.penyelenggara, p.tanggal as tgl_pelatihan
FROM riwayat_pelatihan rp
JOIN pelatihan p ON p.id=rp.pelatihan_id
WHERE rp.penduduk_id=? ORDER BY rp.tanggal_ikut DESC");
$st->execute([$pid]);
jsonOk($st->fetchAll());
}
// ── DAFTARKAN PESERTA ──
if ($action === 'daftar' && $method === 'POST') {
requireRole(['superadmin', 'pengurus', 'relawan']);
$b = json_decode(file_get_contents('php://input'), true);
$pid = (int)($b['penduduk_id'] ?? 0);
$tid = (int)($b['pelatihan_id'] ?? 0);
if (!$pid || !$tid) jsonErr('penduduk_id dan pelatihan_id wajib.');
// validasi: cek umur >= 18 untuk pelatihan kerja
$p = $db->prepare("SELECT umur FROM penduduk WHERE id=?"); $p->execute([$pid]);
$row = $p->fetch();
if ($row && (int)$row['umur'] < 15) jsonErr('Peserta minimal berusia 15 tahun untuk mengikuti pelatihan.');
$db->prepare("INSERT INTO riwayat_pelatihan (penduduk_id, pelatihan_id, tanggal_ikut) VALUES (?,?,?)")
->execute([$pid, $tid, $b['tanggal_ikut'] ?? date('Y-m-d')]);
log_aksi($user['id'], 'CREATE', 'riwayat_pelatihan', (int)$db->lastInsertId(), "Daftar pelatihan pid:$pid tid:$tid");
jsonOk(null, 'Peserta berhasil didaftarkan ke pelatihan.');
}
// ── CRUD MASTER PELATIHAN ──
switch ($method) {
case 'GET':
if ($id) {
$st = $db->prepare("SELECT p.*, COUNT(rp.id) as jumlah_peserta FROM pelatihan p
LEFT JOIN riwayat_pelatihan rp ON rp.pelatihan_id=p.id WHERE p.id=? GROUP BY p.id");
$st->execute([$id]);
$row = $st->fetch();
if (!$row) jsonErr('Tidak ditemukan.', 404);
// peserta
$ps = $db->prepare("SELECT rp.id, rp.tanggal_ikut, pd.nama, pd.nik, pd.umur
FROM riwayat_pelatihan rp JOIN v_penduduk pd ON pd.id=rp.penduduk_id
WHERE rp.pelatihan_id=? ORDER BY rp.tanggal_ikut DESC");
$ps->execute([$id]);
$row['peserta'] = $ps->fetchAll();
jsonOk($row);
break;
}
$rows = $db->query("SELECT p.*, COUNT(rp.id) as jumlah_peserta
FROM pelatihan p LEFT JOIN riwayat_pelatihan rp ON rp.pelatihan_id=p.id
GROUP BY p.id ORDER BY p.tanggal DESC")->fetchAll();
jsonOk($rows);
break;
case 'POST':
requireRole(['superadmin', 'pengurus']);
$b = json_decode(file_get_contents('php://input'), true);
$nama = sanitize($b['nama_pelatihan'] ?? '');
if (!$nama) jsonErr('Nama pelatihan wajib diisi.');
$db->prepare("INSERT INTO pelatihan (nama_pelatihan, penyelenggara, tanggal, durasi_hari, deskripsi) VALUES (?,?,?,?,?)")
->execute([$nama, sanitize($b['penyelenggara'] ?? ''), $b['tanggal'] ?? null,
(int)($b['durasi_hari'] ?? 1), sanitize($b['deskripsi'] ?? '')]);
jsonOk(['id' => (int)$db->lastInsertId()], 'Pelatihan ditambahkan.');
break;
case 'PUT':
requireRole(['superadmin', 'pengurus']);
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$db->prepare("UPDATE pelatihan SET nama_pelatihan=?, penyelenggara=?, tanggal=?, durasi_hari=?, deskripsi=? WHERE id=?")
->execute([sanitize($b['nama_pelatihan'] ?? ''), sanitize($b['penyelenggara'] ?? ''),
$b['tanggal'] ?? null, (int)($b['durasi_hari'] ?? 1),
sanitize($b['deskripsi'] ?? ''), $id]);
jsonOk(null, 'Pelatihan diperbarui.');
break;
case 'DELETE':
requireRole(['superadmin']);
if (!$id) jsonErr('ID tidak valid.');
$db->prepare("DELETE FROM pelatihan WHERE id=?")->execute([$id]);
jsonOk(null, 'Pelatihan dihapus.');
break;
default: jsonErr('Method tidak didukung.', 405);
}
+174
View File
@@ -0,0 +1,174 @@
<?php
require_once __DIR__ . '/../config.php';
$user = requireLogin();
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$db = getDB();
switch ($method) {
// ── GET LIST / SINGLE ──
case 'GET':
if ($id) {
// single dengan histori bantuan & riwayat pelatihan
$st = $db->prepare("SELECT p.*, k.no_kk, k.alamat as alamat_kk, k.rt, k.rw,
k.kelurahan, k.kecamatan, k.rumah_ibadah_id
FROM v_penduduk p
JOIN keluarga k ON k.id = p.keluarga_id
WHERE p.id = ?");
$st->execute([$id]);
$row = $st->fetch();
if (!$row) jsonErr('Data tidak ditemukan.', 404);
// histori bantuan
$hb = $db->prepare("SELECT hb.*, b.nama as bantuan_nama, b.jenis, b.satuan,
u.nama_lengkap as disalurkan_nama
FROM histori_bantuan hb
JOIN bantuan b ON b.id = hb.bantuan_id
LEFT JOIN user u ON u.id = hb.disalurkan_oleh
WHERE hb.penduduk_id = ? ORDER BY hb.tanggal DESC");
$hb->execute([$id]);
$row['histori_bantuan'] = $hb->fetchAll();
// pelatihan
$plt = $db->prepare("SELECT rp.*, p.nama_pelatihan, p.penyelenggara
FROM riwayat_pelatihan rp
JOIN pelatihan p ON p.id = rp.pelatihan_id
WHERE rp.penduduk_id = ?");
$plt->execute([$id]);
$row['riwayat_pelatihan'] = $plt->fetchAll();
// validasi pendidikan
$warn = validasiPendidikan((int)$row['umur'], $row['pendidikan_terakhir'], $row['status_pendidikan']);
$row['peringatan_pendidikan'] = $warn;
jsonOk($row);
}
// list dengan filter
$where = ["p.status_hidup = 'hidup'"]; $params = [];
if (!empty($_GET['status_ekonomi'])) { $where[] = "p.status_ekonomi = ?"; $params[] = $_GET['status_ekonomi']; }
if (!empty($_GET['keluarga_id'])) { $where[] = "p.keluarga_id = ?"; $params[] = (int)$_GET['keluarga_id']; }
if (!empty($_GET['kelurahan'])) { $where[] = "k.kelurahan = ?"; $params[] = $_GET['kelurahan']; }
if (!empty($_GET['q'])) { $where[] = "(p.nama LIKE ? OR p.nik LIKE ?)"; $params[] = '%'.$_GET['q'].'%'; $params[] = '%'.$_GET['q'].'%'; }
$wStr = implode(' AND ', $where);
$st = $db->prepare("SELECT p.id, p.nik, p.nama, p.jenis_kelamin, p.tanggal_lahir, p.umur,
p.profil_umur, p.status_keluarga, p.status_perkawinan,
p.status_ekonomi, p.kondisi_kesehatan, p.pekerjaan,
p.penghasilan, p.lat, p.lng, p.keluarga_id,
k.no_kk, k.kelurahan, k.alamat as alamat_kk,
(SELECT COUNT(*) FROM histori_bantuan hb WHERE hb.penduduk_id=p.id AND hb.status='disalurkan') as total_bantuan
FROM v_penduduk p JOIN keluarga k ON k.id=p.keluarga_id
WHERE $wStr ORDER BY p.nama ASC LIMIT 500");
$st->execute($params);
jsonOk($st->fetchAll());
break;
// ── POST: Tambah ──
case 'POST':
requireRole(['superadmin','pengurus','relawan']);
$b = json_decode(file_get_contents('php://input'), true);
$nik = sanitize($b['nik'] ?? '');
$nama = sanitize($b['nama'] ?? '');
$tgl = $b['tanggal_lahir'] ?? '';
$kjId = (int)($b['keluarga_id'] ?? 0);
if (!$nik || strlen($nik) !== 16) jsonErr('NIK harus 16 digit.');
if (!$nama) jsonErr('Nama wajib diisi.');
if (!$tgl) jsonErr('Tanggal lahir wajib diisi.');
if (!$kjId) jsonErr('Keluarga (KK) wajib dipilih.');
// cek NIK unik
$chk = $db->prepare("SELECT id FROM penduduk WHERE nik=?"); $chk->execute([$nik]);
if ($chk->fetch()) jsonErr('NIK sudah terdaftar di sistem.');
$umur = hitungUmur($tgl);
$pendid = $b['pendidikan_terakhir'] ?? 'tidak_sekolah';
$stPend = $b['status_pendidikan'] ?? 'tidak_sekolah';
$db->prepare("INSERT INTO penduduk
(nik,nama,jenis_kelamin,tanggal_lahir,status_keluarga,status_perkawinan,status_hidup,
keluarga_id,pendidikan_terakhir,status_pendidikan,pekerjaan,penghasilan,
status_ekonomi,kondisi_kesehatan,lat,lng,catatan,created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
->execute([
$nik, $nama,
$b['jenis_kelamin'] ?? 'L',
$tgl,
$b['status_keluarga'] ?? 'anggota',
$b['status_perkawinan'] ?? 'belum_kawin',
$b['status_hidup'] ?? 'hidup',
$kjId, $pendid, $stPend,
sanitize($b['pekerjaan'] ?? ''),
(float)($b['penghasilan'] ?? 0),
$b['status_ekonomi'] ?? 'rentan',
$b['kondisi_kesehatan'] ?? 'sehat',
isset($b['lat']) ? (float)$b['lat'] : null,
isset($b['lng']) ? (float)$b['lng'] : null,
sanitize($b['catatan'] ?? ''),
$user['id']
]);
$newId = (int)$db->lastInsertId();
log_aksi($user['id'], 'CREATE', 'penduduk', $newId, "Tambah: $nama (NIK: $nik)");
$warn = validasiPendidikan($umur, $pendid, $stPend);
jsonOk(['id'=>$newId,'peringatan'=>$warn], 'Penduduk berhasil ditambahkan.');
break;
// ── PUT: Edit ──
case 'PUT':
requireRole(['superadmin','pengurus','relawan']);
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$chk = $db->prepare("SELECT id,nik FROM penduduk WHERE id=?"); $chk->execute([$id]);
if (!$chk->fetch()) jsonErr('Data tidak ditemukan.', 404);
$tgl = $b['tanggal_lahir'] ?? '';
$umur = $tgl ? hitungUmur($tgl) : 0;
$pendid = $b['pendidikan_terakhir'] ?? 'tidak_sekolah';
$stPend = $b['status_pendidikan'] ?? 'tidak_sekolah';
$db->prepare("UPDATE penduduk SET
nama=?,jenis_kelamin=?,tanggal_lahir=?,status_keluarga=?,status_perkawinan=?,
status_hidup=?,keluarga_id=?,pendidikan_terakhir=?,status_pendidikan=?,
pekerjaan=?,penghasilan=?,status_ekonomi=?,kondisi_kesehatan=?,
lat=?,lng=?,catatan=?,updated_at=NOW() WHERE id=?")
->execute([
sanitize($b['nama'] ?? ''),
$b['jenis_kelamin'] ?? 'L',
$tgl,
$b['status_keluarga'] ?? 'anggota',
$b['status_perkawinan'] ?? 'belum_kawin',
$b['status_hidup'] ?? 'hidup',
(int)($b['keluarga_id'] ?? 0),
$pendid, $stPend,
sanitize($b['pekerjaan'] ?? ''),
(float)($b['penghasilan'] ?? 0),
$b['status_ekonomi'] ?? 'rentan',
$b['kondisi_kesehatan'] ?? 'sehat',
isset($b['lat']) ? (float)$b['lat'] : null,
isset($b['lng']) ? (float)$b['lng'] : null,
sanitize($b['catatan'] ?? ''),
$id
]);
log_aksi($user['id'], 'UPDATE', 'penduduk', $id, "Edit ID: $id");
$warn = validasiPendidikan($umur, $pendid, $stPend);
jsonOk(['peringatan'=>$warn], 'Data berhasil diperbarui.');
break;
// ── DELETE ──
case 'DELETE':
requireRole(['superadmin']);
if (!$id) jsonErr('ID tidak valid.');
$chk = $db->prepare("SELECT nama FROM penduduk WHERE id=?"); $chk->execute([$id]);
$row = $chk->fetch();
if (!$row) jsonErr('Data tidak ditemukan.', 404);
$db->prepare("DELETE FROM penduduk WHERE id=?")->execute([$id]);
log_aksi($user['id'], 'DELETE', 'penduduk', $id, 'Hapus: '.$row['nama']);
jsonOk(null, 'Data berhasil dihapus.');
break;
default: jsonErr('Method tidak didukung.', 405);
}
+52
View File
@@ -0,0 +1,52 @@
<?php
require_once __DIR__ . '/../config.php';
$user = requireLogin();
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$db = getDB();
switch ($method) {
case 'GET':
if ($id) {
$st = $db->prepare("SELECT ri.*,
(SELECT COUNT(*) FROM keluarga k WHERE k.rumah_ibadah_id=ri.id) as jumlah_kk,
(SELECT COUNT(*) FROM keluarga k JOIN penduduk p ON p.keluarga_id=k.id WHERE k.rumah_ibadah_id=ri.id AND p.status_hidup='hidup') as jumlah_jiwa,
(SELECT COUNT(*) FROM keluarga k WHERE k.rumah_ibadah_id=ri.id AND k.status_ekonomi='miskin') as kk_miskin
FROM rumah_ibadah ri WHERE ri.id=?");
$st->execute([$id]); jsonOk($st->fetch()); break;
}
$rows = $db->query("SELECT ri.id, ri.nama, ri.jenis, ri.kontak, ri.lat, ri.lng, ri.alamat, ri.kelurahan,
(SELECT COUNT(*) FROM keluarga k WHERE k.rumah_ibadah_id=ri.id) as jumlah_kk,
(SELECT COUNT(*) FROM keluarga k WHERE k.rumah_ibadah_id=ri.id AND k.status_ekonomi='miskin') as kk_miskin
FROM rumah_ibadah ri ORDER BY ri.nama")->fetchAll();
foreach ($rows as &$r) { $r['id']=(int)$r['id']; $r['lat']=(float)$r['lat']; $r['lng']=(float)$r['lng']; }
jsonOk($rows); break;
case 'POST':
requireRole(['superadmin']);
$b = json_decode(file_get_contents('php://input'), true);
$nama = sanitize($b['nama'] ?? '');
if (!$nama) jsonErr('Nama wajib diisi.');
$db->prepare("INSERT INTO rumah_ibadah (nama,jenis,kontak,lat,lng,alamat,kelurahan) VALUES (?,?,?,?,?,?,?)")
->execute([$nama,$b['jenis']??'Masjid',sanitize($b['kontak']??''),(float)($b['lat']??0),(float)($b['lng']??0),sanitize($b['alamat']??''),sanitize($b['kelurahan']??'')]);
jsonOk(['id'=>(int)$db->lastInsertId()],'Rumah ibadah ditambahkan.');
break;
case 'PUT':
requireRole(['superadmin','pengurus']);
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$db->prepare("UPDATE rumah_ibadah SET nama=?,jenis=?,kontak=?,lat=?,lng=?,alamat=?,kelurahan=?,updated_at=NOW() WHERE id=?")
->execute([sanitize($b['nama']??''),$b['jenis']??'Masjid',sanitize($b['kontak']??''),(float)($b['lat']??0),(float)($b['lng']??0),sanitize($b['alamat']??''),sanitize($b['kelurahan']??''),$id]);
jsonOk(null,'Data diperbarui.');
break;
case 'DELETE':
requireRole(['superadmin']);
if (!$id) jsonErr('ID tidak valid.');
$db->prepare("DELETE FROM rumah_ibadah WHERE id=?")->execute([$id]);
jsonOk(null,'Data dihapus.');
break;
default: jsonErr('Method tidak didukung.', 405);
}
+96
View File
@@ -0,0 +1,96 @@
<?php
require_once __DIR__ . '/../config.php';
requireLogin();
$db = getDB();
// KPI Utama
$totalPenduduk = (int)$db->query("SELECT COUNT(*) FROM penduduk WHERE status_hidup='hidup'")->fetchColumn();
$totalKeluarga = (int)$db->query("SELECT COUNT(*) FROM keluarga")->fetchColumn();
$totalMiskin = (int)$db->query("SELECT COUNT(*) FROM penduduk WHERE status_ekonomi='miskin' AND status_hidup='hidup'")->fetchColumn();
$totalRentan = (int)$db->query("SELECT COUNT(*) FROM penduduk WHERE status_ekonomi='rentan' AND status_hidup='hidup'")->fetchColumn();
$totalMampu = (int)$db->query("SELECT COUNT(*) FROM penduduk WHERE status_ekonomi='mampu' AND status_hidup='hidup'")->fetchColumn();
$totalLaporan = (int)$db->query("SELECT COUNT(*) FROM laporan WHERE status != 'selesai'")->fetchColumn();
$laporanDarurat= (int)$db->query("SELECT COUNT(*) FROM laporan WHERE urgensi='darurat' AND status NOT IN ('selesai')")->fetchColumn();
$totalBantuan = (int)$db->query("SELECT COUNT(*) FROM histori_bantuan WHERE status='disalurkan'")->fetchColumn();
$belumBantuan = (int)$db->query("SELECT COUNT(*) FROM penduduk WHERE status_ekonomi IN ('miskin','rentan') AND status_hidup='hidup' AND id NOT IN (SELECT DISTINCT penduduk_id FROM histori_bantuan WHERE status='disalurkan')")->fetchColumn();
$unreadChat = (int)$db->query("SELECT COUNT(*) FROM chat WHERE dibaca=0")->fetchColumn();
// Distribusi ekonomi per kelurahan
$byKelurahan = $db->query("SELECT k.kelurahan,
SUM(CASE WHEN k.status_ekonomi='miskin' THEN 1 ELSE 0 END) as miskin,
SUM(CASE WHEN k.status_ekonomi='rentan' THEN 1 ELSE 0 END) as rentan,
SUM(CASE WHEN k.status_ekonomi='mampu' THEN 1 ELSE 0 END) as mampu,
COUNT(*) as total
FROM keluarga k GROUP BY k.kelurahan ORDER BY miskin DESC LIMIT 10")->fetchAll();
// Distribusi bantuan per jenis
$byBantuan = $db->query("SELECT b.nama, b.jenis, COUNT(hb.id) as total_disalurkan
FROM bantuan b LEFT JOIN histori_bantuan hb ON hb.bantuan_id=b.id AND hb.status='disalurkan'
GROUP BY b.id ORDER BY total_disalurkan DESC")->fetchAll();
// Distribusi per rumah ibadah
$byRI = $db->query("SELECT ri.nama, ri.jenis, ri.lat, ri.lng,
COUNT(DISTINCT k.id) as jumlah_kk,
SUM(CASE WHEN k.status_ekonomi='miskin' THEN 1 ELSE 0 END) as kk_miskin,
(SELECT COUNT(*) FROM v_penduduk p JOIN keluarga k2 ON k2.id=p.keluarga_id WHERE k2.rumah_ibadah_id=ri.id AND p.status_hidup='hidup') as jumlah_jiwa
FROM rumah_ibadah ri LEFT JOIN keluarga k ON k.rumah_ibadah_id=ri.id
GROUP BY ri.id ORDER BY kk_miskin DESC")->fetchAll();
// Profil umur
$byUmur = $db->query("SELECT profil_umur, COUNT(*) as jumlah
FROM v_penduduk WHERE status_hidup='hidup' GROUP BY profil_umur")->fetchAll();
// Laporan per status
$byLaporan = $db->query("SELECT status, urgensi, COUNT(*) as jumlah
FROM laporan GROUP BY status, urgensi ORDER BY FIELD(urgensi,'darurat','tinggi','sedang','rendah')")->fetchAll();
// GeoJSON penduduk untuk peta
$geoRows = $db->query("SELECT p.id, p.nik, p.nama, p.umur, p.profil_umur, p.status_ekonomi,
p.kondisi_kesehatan, p.penghasilan, k.kelurahan, k.lat, k.lng, k.no_kk,
(SELECT COUNT(*) FROM histori_bantuan hb WHERE hb.penduduk_id=p.id AND hb.status='disalurkan') as total_bantuan
FROM v_penduduk p JOIN keluarga k ON k.id=p.keluarga_id
WHERE p.status_hidup='hidup' AND k.lat IS NOT NULL AND k.lng IS NOT NULL
LIMIT 1000")->fetchAll();
$features = [];
foreach ($geoRows as $r) {
$features[] = [
'type' => 'Feature',
'geometry' => ['type'=>'Point','coordinates'=>[(float)$r['lng'],(float)$r['lat']]],
'properties' => $r
];
}
// GeoJSON laporan untuk peta
$laporanRows = $db->query("SELECT id,deskripsi,lat,lng,urgensi,status,created_at,foto FROM laporan WHERE lat IS NOT NULL AND lng IS NOT NULL")->fetchAll();
$laporanFeatures = [];
foreach ($laporanRows as $r) {
$laporanFeatures[] = [
'type' => 'Feature',
'geometry' => ['type'=>'Point','coordinates'=>[(float)$r['lng'],(float)$r['lat']]],
'properties' => $r
];
}
jsonOk([
'kpi' => [
'total_penduduk' => $totalPenduduk,
'total_keluarga' => $totalKeluarga,
'total_miskin' => $totalMiskin,
'total_rentan' => $totalRentan,
'total_mampu' => $totalMampu,
'total_laporan_aktif' => $totalLaporan,
'laporan_darurat' => $laporanDarurat,
'total_bantuan_disalurkan' => $totalBantuan,
'belum_dapat_bantuan' => $belumBantuan,
'unread_chat' => $unreadChat,
'pct_miskin' => $totalPenduduk ? round($totalMiskin/$totalPenduduk*100,1) : 0,
],
'by_kelurahan' => $byKelurahan,
'by_bantuan' => $byBantuan,
'by_ri' => $byRI,
'by_umur' => $byUmur,
'by_laporan' => $byLaporan,
'geojson_penduduk' => ['type'=>'FeatureCollection','features'=>$features],
'geojson_laporan' => ['type'=>'FeatureCollection','features'=>$laporanFeatures],
]);
+87
View File
@@ -0,0 +1,87 @@
<?php
// api/user.php — Manajemen User (superadmin only)
require_once __DIR__ . '/../config.php';
$user = requireRole(['superadmin']);
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$db = getDB();
switch ($method) {
case 'GET':
if ($id) {
$st = $db->prepare("SELECT u.id, u.username, u.nama_lengkap, u.telepon, u.aktif,
r.kode as role, r.nama as role_nama, r.id as role_id,
ri.nama as ri_nama, u.rumah_ibadah_id, u.created_at
FROM user u JOIN role r ON r.id=u.role_id
LEFT JOIN rumah_ibadah ri ON ri.id=u.rumah_ibadah_id
WHERE u.id=?");
$st->execute([$id]);
jsonOk($st->fetch());
}
$rows = $db->query("SELECT u.id, u.username, u.nama_lengkap, u.telepon, u.aktif,
r.kode as role, r.nama as role_nama, ri.nama as ri_nama, u.created_at
FROM user u JOIN role r ON r.id=u.role_id
LEFT JOIN rumah_ibadah ri ON ri.id=u.rumah_ibadah_id
ORDER BY r.id, u.nama_lengkap")->fetchAll();
jsonOk($rows);
break;
case 'POST':
$b = json_decode(file_get_contents('php://input'), true);
$username = sanitize($b['username'] ?? '');
$password = $b['password'] ?? '';
$nama = sanitize($b['nama_lengkap'] ?? '');
$roleId = (int)($b['role_id'] ?? 5);
if (!$username || !$password || !$nama) jsonErr('Username, password, dan nama wajib diisi.');
if (strlen($username) < 4) jsonErr('Username minimal 4 karakter.');
if (strlen($password) < 6) jsonErr('Password minimal 6 karakter.');
$chk = $db->prepare("SELECT id FROM user WHERE username=?"); $chk->execute([$username]);
if ($chk->fetch()) jsonErr('Username sudah digunakan.');
$hash = password_hash($password, PASSWORD_BCRYPT);
$db->prepare("INSERT INTO user (username, password, nama_lengkap, role_id, rumah_ibadah_id, telepon)
VALUES (?,?,?,?,?,?)")
->execute([$username, $hash, $nama, $roleId,
!empty($b['rumah_ibadah_id']) ? (int)$b['rumah_ibadah_id'] : null,
sanitize($b['telepon'] ?? '')]);
$newId = (int)$db->lastInsertId();
log_aksi($user['id'], 'CREATE', 'user', $newId, "Tambah user: $username");
jsonOk(['id' => $newId], 'User berhasil ditambahkan.');
break;
case 'PUT':
if (!$id) jsonErr('ID tidak valid.');
$b = json_decode(file_get_contents('php://input'), true);
$nama = sanitize($b['nama_lengkap'] ?? '');
if (!$nama) jsonErr('Nama wajib diisi.');
$chk = $db->prepare("SELECT id FROM user WHERE id=?"); $chk->execute([$id]);
if (!$chk->fetch()) jsonErr('User tidak ditemukan.', 404);
// Update data dasar
$db->prepare("UPDATE user SET nama_lengkap=?, role_id=?, rumah_ibadah_id=?, telepon=?, aktif=? WHERE id=?")
->execute([$nama, (int)($b['role_id'] ?? 5),
!empty($b['rumah_ibadah_id']) ? (int)$b['rumah_ibadah_id'] : null,
sanitize($b['telepon'] ?? ''), (int)($b['aktif'] ?? 1), $id]);
// Ganti password jika diisi
if (!empty($b['password']) && strlen($b['password']) >= 6) {
$db->prepare("UPDATE user SET password=? WHERE id=?")
->execute([password_hash($b['password'], PASSWORD_BCRYPT), $id]);
}
log_aksi($user['id'], 'UPDATE', 'user', $id, "Edit user ID: $id");
jsonOk(null, 'Data user diperbarui.');
break;
case 'DELETE':
if (!$id) jsonErr('ID tidak valid.');
if ($id === $user['id']) jsonErr('Tidak dapat menghapus akun sendiri.');
$db->prepare("DELETE FROM user WHERE id=?")->execute([$id]);
log_aksi($user['id'], 'DELETE', 'user', $id, "Hapus user ID: $id");
jsonOk(null, 'User dihapus.');
break;
default: jsonErr('Method tidak didukung.', 405);
}
+101
View File
@@ -0,0 +1,101 @@
<?php
// ============================================================
// config.php — WebGIS Kemiskinan v3.0
// ============================================================
define('DB_HOST', getenv('DB_HOST') ?: 'localhost');
define('DB_USER', getenv('DB_USER') ?: 'root');
define('DB_PASS', getenv('DB_PASSWORD') ?: '');
define('DB_NAME', getenv('DB_NAME') ?: 'webgis_kemiskinan');
define('UPLOAD_DIR', __DIR__ . '/uploads/');
date_default_timezone_set('Asia/Pontianak');
function getDB(): PDO {
static $pdo = null;
if ($pdo === null) {
try {
$pdo = new PDO("mysql:host=".DB_HOST.";dbname=".DB_NAME.";charset=utf8mb4", DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
jsonErr('Koneksi database gagal: ' . $e->getMessage(), 500);
}
}
return $pdo;
}
function startSession(): void {
if (session_status() === PHP_SESSION_NONE) {
session_name('webgis_v3');
session_set_cookie_params(['lifetime'=>28800,'path'=>'/','httponly'=>true,'samesite'=>'Strict']);
session_start();
}
}
function getCurrentUser(): ?array {
startSession();
return $_SESSION['user'] ?? null;
}
function requireLogin(): array {
$u = getCurrentUser();
if (!$u) jsonErr('Silakan login terlebih dahulu.', 401);
return $u;
}
function requireRole(array $roles): array {
$u = requireLogin();
if (!in_array($u['role'], $roles, true)) jsonErr('Akses ditolak untuk role: '.$u['role'], 403);
return $u;
}
function canWrite(): bool {
$u = getCurrentUser();
return $u && in_array($u['role'], ['superadmin','pengurus','relawan'], true);
}
function jsonOk($data = null, string $msg = 'Berhasil'): void {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success'=>true,'message'=>$msg,'data'=>$data], JSON_UNESCAPED_UNICODE);
exit;
}
function jsonErr(string $msg, int $code = 400): void {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success'=>false,'message'=>$msg], JSON_UNESCAPED_UNICODE);
exit;
}
function log_aksi(int $userId, string $aksi, string $tabel, int $recId, string $ket = ''): void {
try {
getDB()->prepare("INSERT INTO log_aktivitas (user_id,aksi,tabel,record_id,keterangan,ip_address) VALUES (?,?,?,?,?,?)")
->execute([$userId, $aksi, $tabel, $recId, $ket, $_SERVER['REMOTE_ADDR'] ?? '']);
} catch (Exception $e) {}
}
function sanitize(string $s): string {
return htmlspecialchars(strip_tags(trim($s)), ENT_QUOTES, 'UTF-8');
}
// Hitung umur dari tanggal lahir
function hitungUmur(string $tgl): int {
return (int) date_diff(date_create($tgl), date_create('today'))->y;
}
// Validasi pendidikan vs umur
function validasiPendidikan(int $umur, string $pendidikan, string $statusPendidikan): ?string {
if ($umur >= 7 && $umur <= 15 && $statusPendidikan === 'tidak_sekolah'
&& in_array($pendidikan, ['tidak_sekolah','sd'])) {
return 'Peringatan: Usia 7-15 tahun seharusnya masih sekolah SD/SMP.';
}
if ($umur >= 16 && $umur <= 18 && $statusPendidikan === 'tidak_sekolah') {
return 'Peringatan: Usia 16-18 tahun seharusnya masih sekolah SMA/SMK.';
}
return null;
}
header('Content-Type: application/json; charset=utf-8');
if (!is_dir(UPLOAD_DIR)) mkdir(UPLOAD_DIR, 0755, true);
+375
View File
@@ -0,0 +1,375 @@
-- ================================================================
-- WebGIS Pengentasan Kemiskinan — Kota Pontianak v3.0
-- Database Lengkap: 13 tabel, 5 role, relasi kuat
-- Jalankan di phpMyAdmin > tab SQL > klik Go
-- ================================================================
DROP DATABASE IF EXISTS webgis_kemiskinan;
CREATE DATABASE webgis_kemiskinan CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE webgis_kemiskinan;
-- ────────────────────────────────────────────────
-- 1. ROLE
-- ────────────────────────────────────────────────
CREATE TABLE role (
id TINYINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
kode VARCHAR(30) NOT NULL UNIQUE,
nama VARCHAR(80) NOT NULL
) ENGINE=InnoDB;
INSERT INTO role (kode, nama) VALUES
('superadmin', 'Super Admin'),
('pimpinan', 'Pimpinan Daerah / Pemerintah'),
('pengurus', 'Pengurus Rumah Ibadah'),
('relawan', 'Relawan Lapangan'),
('masyarakat', 'Masyarakat Umum');
-- ────────────────────────────────────────────────
-- 2. RUMAH IBADAH
-- ────────────────────────────────────────────────
CREATE TABLE rumah_ibadah (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(200) NOT NULL,
jenis ENUM('Masjid','Gereja','Vihara','Klenteng','Pura') NOT NULL,
kontak VARCHAR(60),
lat DECIMAL(10,7) NOT NULL,
lng DECIMAL(10,7) NOT NULL,
alamat TEXT,
kelurahan VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 3. USER
-- ────────────────────────────────────────────────
CREATE TABLE user (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(80) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
nama_lengkap VARCHAR(150) NOT NULL,
role_id TINYINT UNSIGNED NOT NULL,
rumah_ibadah_id INT DEFAULT NULL,
telepon VARCHAR(20),
aktif TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (role_id) REFERENCES role(id),
FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 4. KELUARGA (KK)
-- ────────────────────────────────────────────────
CREATE TABLE keluarga (
id INT AUTO_INCREMENT PRIMARY KEY,
no_kk VARCHAR(20) NOT NULL UNIQUE,
nama_kk VARCHAR(200) NOT NULL, -- nama kepala keluarga
alamat TEXT,
rt VARCHAR(5),
rw VARCHAR(5),
kelurahan VARCHAR(100),
kecamatan VARCHAR(100),
lat DECIMAL(10,7),
lng DECIMAL(10,7),
rumah_ibadah_id INT DEFAULT NULL,
status_ekonomi ENUM('miskin','rentan','mampu') NOT NULL DEFAULT 'rentan',
created_by INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (rumah_ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES user(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 5. PENDUDUK
-- ────────────────────────────────────────────────
CREATE TABLE penduduk (
id INT AUTO_INCREMENT PRIMARY KEY,
nik VARCHAR(16) NOT NULL UNIQUE,
nama VARCHAR(200) NOT NULL,
jenis_kelamin ENUM('L','P') NOT NULL,
tanggal_lahir DATE NOT NULL,
-- umur & profil_umur dihitung via VIEW (kompatibel semua versi MySQL)
-- kolom ini diisi/diperbarui otomatis oleh PHP saat INSERT/UPDATE
status_keluarga ENUM('kepala','istri','anak','orang_tua','lainnya') NOT NULL DEFAULT 'kepala',
status_perkawinan ENUM('belum_kawin','kawin','cerai_hidup','cerai_mati') NOT NULL DEFAULT 'belum_kawin',
status_hidup ENUM('hidup','meninggal') NOT NULL DEFAULT 'hidup',
keluarga_id INT NOT NULL,
-- pendidikan
pendidikan_terakhir ENUM('tidak_sekolah','sd','smp','sma','smk','d3','s1','s2','s3') DEFAULT 'tidak_sekolah',
status_pendidikan ENUM('masih_sekolah','tidak_sekolah') DEFAULT 'tidak_sekolah',
-- ekonomi
pekerjaan VARCHAR(100),
penghasilan DECIMAL(12,2) DEFAULT 0,
status_ekonomi ENUM('miskin','rentan','mampu') NOT NULL DEFAULT 'rentan',
-- kondisi kesehatan
kondisi_kesehatan ENUM('sehat','sakit_ringan','sakit_berat','disabilitas') DEFAULT 'sehat',
-- lokasi (bisa berbeda dari KK jika merantau)
lat DECIMAL(10,7),
lng DECIMAL(10,7),
-- meta
foto VARCHAR(255),
catatan TEXT,
created_by INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (keluarga_id) REFERENCES keluarga(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES user(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 6. PELATIHAN
-- ────────────────────────────────────────────────
CREATE TABLE pelatihan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama_pelatihan VARCHAR(200) NOT NULL,
penyelenggara VARCHAR(150),
tanggal DATE,
durasi_hari TINYINT UNSIGNED DEFAULT 1,
deskripsi TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE riwayat_pelatihan (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL,
pelatihan_id INT NOT NULL,
tanggal_ikut DATE,
sertifikat VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (penduduk_id) REFERENCES penduduk(id) ON DELETE CASCADE,
FOREIGN KEY (pelatihan_id) REFERENCES pelatihan(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 7. BANTUAN
-- ────────────────────────────────────────────────
CREATE TABLE bantuan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(200) NOT NULL,
jenis ENUM('sembako','uang_tunai','kesehatan','pendidikan','perumahan','lainnya') NOT NULL,
sumber VARCHAR(150), -- PKH, BPNT, Baznas, Dinsos, dll
satuan VARCHAR(50), -- karung, paket, Rp, dll
nilai_per_satuan DECIMAL(12,2) DEFAULT 0,
periode ENUM('sekali','bulanan','triwulan','tahunan') DEFAULT 'sekali',
aktif TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE histori_bantuan (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL,
bantuan_id INT NOT NULL,
disalurkan_oleh INT, -- user_id penyalur
tanggal DATE NOT NULL,
jumlah DECIMAL(10,2) DEFAULT 1,
status ENUM('dijadwalkan','disalurkan','gagal') DEFAULT 'dijadwalkan',
catatan TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (penduduk_id) REFERENCES penduduk(id) ON DELETE CASCADE,
FOREIGN KEY (bantuan_id) REFERENCES bantuan(id) ON DELETE CASCADE,
FOREIGN KEY (disalurkan_oleh) REFERENCES user(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 8. LAPORAN MASYARAKAT
-- ────────────────────────────────────────────────
CREATE TABLE laporan (
id INT AUTO_INCREMENT PRIMARY KEY,
pelapor_nama VARCHAR(150), -- bisa anonim
pelapor_telepon VARCHAR(20),
penduduk_id INT DEFAULT NULL, -- jika tahu NIK
deskripsi TEXT NOT NULL,
lat DECIMAL(10,7),
lng DECIMAL(10,7),
alamat_laporan TEXT,
foto VARCHAR(255),
urgensi ENUM('rendah','sedang','tinggi','darurat') DEFAULT 'sedang',
status ENUM('pending','diverifikasi','diproses','selesai') DEFAULT 'pending',
diverifikasi_oleh INT DEFAULT NULL,
catatan_admin TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (penduduk_id) REFERENCES penduduk(id) ON DELETE SET NULL,
FOREIGN KEY (diverifikasi_oleh) REFERENCES user(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 9. CHAT
-- ────────────────────────────────────────────────
CREATE TABLE chat (
id INT AUTO_INCREMENT PRIMARY KEY,
dari_user_id INT NOT NULL,
ke_user_id INT DEFAULT NULL, -- NULL = broadcast ke semua pengurus
pesan TEXT NOT NULL,
dibaca TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (dari_user_id) REFERENCES user(id) ON DELETE CASCADE,
FOREIGN KEY (ke_user_id) REFERENCES user(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- 10. LOG AKTIVITAS
-- ────────────────────────────────────────────────
CREATE TABLE log_aktivitas (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
aksi VARCHAR(60) NOT NULL,
tabel VARCHAR(60),
record_id INT,
keterangan TEXT,
ip_address VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ────────────────────────────────────────────────
-- VIEW: Hitung umur & profil_umur otomatis
-- Kompatibel semua versi MySQL/MariaDB
-- ────────────────────────────────────────────────
CREATE OR REPLACE VIEW v_penduduk AS
SELECT
p.*,
TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) AS umur,
CASE
WHEN TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) < 5 THEN 'Balita'
WHEN TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) < 12 THEN 'Anak'
WHEN TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) < 18 THEN 'Remaja'
WHEN TIMESTAMPDIFF(YEAR, p.tanggal_lahir, CURDATE()) < 60 THEN 'Dewasa'
ELSE 'Lansia'
END AS profil_umur
FROM penduduk p;
-- ────────────────────────────────────────────────
-- INDEX PERFORMA
-- ────────────────────────────────────────────────
CREATE INDEX idx_penduduk_nik ON penduduk(nik);
CREATE INDEX idx_penduduk_keluarga ON penduduk(keluarga_id);
CREATE INDEX idx_penduduk_ekonomi ON penduduk(status_ekonomi);
CREATE INDEX idx_penduduk_hidup ON penduduk(status_hidup);
CREATE INDEX idx_keluarga_ekonomi ON keluarga(status_ekonomi);
CREATE INDEX idx_keluarga_ri ON keluarga(rumah_ibadah_id);
CREATE INDEX idx_histori_penduduk ON histori_bantuan(penduduk_id);
CREATE INDEX idx_histori_tgl ON histori_bantuan(tanggal);
CREATE INDEX idx_laporan_status ON laporan(status);
CREATE INDEX idx_laporan_urgensi ON laporan(urgensi);
CREATE INDEX idx_chat_ke ON chat(ke_user_id);
CREATE INDEX idx_log_user ON log_aktivitas(user_id);
-- ================================================================
-- DATA AWAL
-- ================================================================
-- RUMAH IBADAH
INSERT INTO rumah_ibadah (nama, jenis, kontak, lat, lng, alamat, kelurahan) VALUES
('Masjid Raya Mujahidin', 'Masjid', '0561-731234', -0.0193000, 109.3421000, 'Jl. Ahmad Yani No.1', 'Akcaya'),
('Gereja Katedral St. Yosef', 'Gereja', '0561-734567', -0.0298000, 109.3334000, 'Jl. Pattimura No.8', 'Tengah'),
('Masjid Al-Falah', 'Masjid', '0561-762111', -0.0089000, 109.3508000, 'Jl. Sultan Syarif No.3', 'Batu Layang'),
('Vihara Bodhisattva Karaniya', 'Vihara', '0561-748999', -0.0351000, 109.3287000, 'Jl. Diponegoro No.12', 'Parit Tokaya'),
('Gereja Bethel Indonesia', 'Gereja', '0561-712888', -0.0431000, 109.3376000, 'Jl. Imam Bonjol No.5', 'Benua Melayu Darat'),
('Masjid Jami Sultan Syarif', 'Masjid', '0561-721000', -0.0145000, 109.3289000, 'Jl. Tanjung Raya II No.2', 'Saigon'),
('Klenteng Kwan Tie Miau', 'Klenteng','0561-737000', -0.0265000, 109.3301000, 'Jl. Diponegoro No.3', 'Mariana');
-- USER (password = "password" plaintext, bisa di-hash bcrypt via PHP)
INSERT INTO user (username, password, nama_lengkap, role_id, rumah_ibadah_id, telepon) VALUES
('superadmin', 'password', 'Super Administrator', 1, NULL, '08100000001'),
('pimpinan1', 'password', 'H. Andi Rachman (Walikota)', 2, NULL, '08100000002'),
('pengurus1', 'password', 'Ustadz H. Mahmud (Mujahidin)',3, 1, '08100000003'),
('pengurus2', 'password', 'Rm. Petrus Lioe (Katedral)', 3, 2, '08100000004'),
('pengurus3', 'password', 'Pak Budi (Al-Falah)', 3, 3, '08100000005'),
('relawan1', 'password', 'Sri Wahyuni (Relawan Utara)', 4, NULL, '08100000006'),
('relawan2', 'password', 'Doni Pratama (Relawan Selatan)',4,NULL, '08100000007'),
('masyarakat1', 'password', 'Warga Umum 1', 5, NULL, '08100000008');
-- KELUARGA
INSERT INTO keluarga (no_kk, nama_kk, alamat, rt, rw, kelurahan, kecamatan, lat, lng, rumah_ibadah_id, status_ekonomi, created_by) VALUES
('6171011234560001','Suharto', 'Jl. Tanjung Raya I No.12', '03','05','Saigon', 'Pontianak Timur', -0.0211000, 109.3445000, 1, 'miskin', 1),
('6171011234560002','Ahmad Fauzi', 'Jl. Parit Makmur No.7', '01','02','Batu Layang', 'Pontianak Utara', -0.0178000, 109.3398000, 3, 'miskin', 1),
('6171011234560003','Yulianus Sari', 'Jl. Veteran No.3', '02','01','Tengah', 'Pontianak Kota', -0.0323000, 109.3312000, 2, 'rentan', 1),
('6171011234560004','Robert Tanto', 'Jl. Gajahmada No.21', '04','03','Mariana', 'Pontianak Kota', -0.0289000, 109.3356000, 7, 'rentan', 1),
('6171011234560005','Samsul Bahri', 'Jl. Khatulistiwa No.5', '01','01','Batu Layang', 'Pontianak Utara', -0.0072000, 109.3521000, 3, 'miskin', 1),
('6171011234560006','Dewi Rahmawati', 'Jl. Sultan Hamid II No.8','02','04','Benua Melayu Darat', 'Pontianak Selatan',-0.0431000, 109.3401000, 5, 'miskin', 1),
('6171011234560007','Suparjo', 'Jl. Budi Utomo No.3', '05','02','Saigon', 'Pontianak Timur', -0.0148000, 109.3467000, 6, 'rentan', 1),
('6171011234560008','Maria Goreti Ngo','Jl. Sutoyo No.15', '03','01','Parit Tokaya', 'Pontianak Selatan',-0.0372000, 109.3271000, 4, 'miskin', 1),
('6171011234560009','Lim Beng Huat', 'Jl. Diponegoro No.4', '01','01','Mariana', 'Pontianak Kota', -0.0340000, 109.3290000, 7, 'rentan', 1),
('6171011234560010','Daud Kristianto', 'Jl. Hos Cokroaminoto No.9','04','02','Akcaya', 'Pontianak Selatan',-0.0198000, 109.3372000, 1, 'miskin', 1);
-- PENDUDUK (contoh lengkap dengan NIK, umur, dll)
INSERT INTO penduduk (nik, nama, jenis_kelamin, tanggal_lahir, status_keluarga, status_perkawinan, keluarga_id, pendidikan_terakhir, status_pendidikan, pekerjaan, penghasilan, status_ekonomi, kondisi_kesehatan, lat, lng, created_by) VALUES
('6171011234560001','Suharto', 'L','1975-03-12','kepala','kawin', 1,'sd', 'tidak_sekolah','Buruh Harian', 500000, 'miskin', 'sehat', -0.0211000,109.3445000,1),
('6171011234560002','Sri Mulyani', 'P','1978-07-20','istri', 'kawin', 1,'smp', 'tidak_sekolah','Ibu Rumah Tangga',0, 'miskin', 'sakit_ringan', -0.0211000,109.3445000,1),
('6171011234560003','Budi Suharto', 'L','2008-01-15','anak', 'belum_kawin', 1,'smp', 'masih_sekolah', NULL, 0, 'miskin', 'sehat', -0.0211000,109.3445000,1),
('6171011234560004','Ahmad Fauzi', 'L','1968-11-05','kepala','kawin', 2,'sd', 'tidak_sekolah','Nelayan', 800000,'miskin', 'sehat', -0.0178000,109.3398000,1),
('6171011234560005','Siti Aminah', 'P','1972-04-17','istri', 'kawin', 2,'sd', 'tidak_sekolah','Pedagang Kecil',300000,'miskin', 'sehat', -0.0178000,109.3398000,1),
('6171011234560006','Yulianus Sari', 'L','1980-09-22','kepala','kawin', 3,'sma', 'tidak_sekolah','Ojek Online', 1200000,'rentan','sehat', -0.0323000,109.3312000,1),
('6171011234560007','Robert Tanto', 'L','1985-06-18','kepala','kawin', 4,'sma', 'tidak_sekolah','Karyawan Swasta',2000000,'rentan','sehat', -0.0289000,109.3356000,1),
('6171011234560008','Samsul Bahri', 'L','1958-12-30','kepala','kawin', 5,'sd', 'tidak_sekolah','Petani', 400000,'miskin','sakit_ringan', -0.0072000,109.3521000,1),
('6171011234560009','Dewi Rahmawati', 'P','1990-02-14','kepala','cerai_mati', 6,'smp', 'tidak_sekolah','Buruh Cuci', 600000,'miskin','sehat', -0.0431000,109.3401000,1),
('6171011234560010','Nur Azizah', 'P','2010-05-10','anak', 'belum_kawin', 6,'sd', 'masih_sekolah', NULL, 0, 'miskin', 'sehat', -0.0431000,109.3401000,1),
('6171011234560011','Suparjo', 'L','1970-08-25','kepala','kawin', 7,'sd', 'tidak_sekolah','Kuli Bangunan', 700000,'rentan','sehat', -0.0148000,109.3467000,1),
('6171011234560012','Maria Goreti', 'P','1963-10-08','kepala','cerai_mati', 8,'sd', 'tidak_sekolah','Penjual Sayur', 450000,'miskin','disabilitas', -0.0372000,109.3271000,1),
('6171011234560013','Lim Beng Huat', 'L','1955-03-17','kepala','kawin', 9,'smp', 'tidak_sekolah','Pensiunan', 800000,'rentan','sakit_ringan', -0.0340000,109.3290000,1),
('6171011234560014','Daud Kristianto', 'L','1972-07-04','kepala','kawin', 10,'sma', 'tidak_sekolah','Pedagang', 900000,'miskin','sehat', -0.0198000,109.3372000,1),
('6171011234560015','Yanto Pramono', 'L','2007-09-12','anak', 'belum_kawin', 7,'smp', 'masih_sekolah', NULL, 0, 'rentan','sehat', -0.0148000,109.3467000,1);
-- PELATIHAN
INSERT INTO pelatihan (nama_pelatihan, penyelenggara, tanggal, durasi_hari, deskripsi) VALUES
('Pelatihan Menjahit Dasar', 'Dinsos Pontianak', '2024-03-15', 7, 'Pelatihan menjahit untuk ibu rumah tangga'),
('Kursus Memasak & Katering', 'Baznas Kalbar', '2024-04-20', 5, 'Pelatihan memasak dan usaha katering'),
('Budidaya Sayuran Hidroponik', 'Dinas Pertanian', '2024-05-10', 3, 'Teknik menanam sayuran di lahan terbatas'),
('Keterampilan Las Listrik', 'BLK Pontianak', '2024-06-05', 14, 'Keterampilan las untuk remaja putus sekolah'),
('Digital Marketing UMKM', 'Kominfo Pontianak', '2024-07-01', 2, 'Pemasaran online untuk usaha mikro');
-- RIWAYAT PELATIHAN
INSERT INTO riwayat_pelatihan (penduduk_id, pelatihan_id, tanggal_ikut) VALUES
(6, 1, '2024-03-15'), (9, 1, '2024-03-15'),
(7, 2, '2024-04-20'), (12, 3, '2024-05-10');
-- BANTUAN
INSERT INTO bantuan (nama, jenis, sumber, satuan, nilai_per_satuan, periode) VALUES
('PKH (Program Keluarga Harapan)', 'uang_tunai', 'Kemensos RI', 'Rp', 750000, 'triwulan'),
('BPNT (Sembako)', 'sembako', 'Kemensos RI', 'paket', 200000, 'bulanan'),
('BLT Dana Desa', 'uang_tunai', 'APBDes', 'Rp', 300000, 'bulanan'),
('Bantuan Baznas Kalbar', 'sembako', 'Baznas Kalbar', 'paket', 150000, 'bulanan'),
('JKN PBI (BPJS Gratis)', 'kesehatan', 'APBN/APBD', 'kartu', 0, 'tahunan'),
('Beasiswa Anak Miskin', 'pendidikan', 'Pemkot Pontianak', 'Rp', 500000, 'triwulan'),
('Bantuan Sembako Masjid Mujahidin','sembako', 'ZIS Mujahidin', 'paket', 100000, 'bulanan'),
('Program Bedah Rumah', 'perumahan', 'APBD Kota', 'unit', 15000000,'sekali');
-- HISTORI BANTUAN
INSERT INTO histori_bantuan (penduduk_id, bantuan_id, disalurkan_oleh, tanggal, jumlah, status, catatan) VALUES
(4, 1, 3, '2024-01-15', 1, 'disalurkan', 'PKH triwulan 1 2024'),
(4, 2, 3, '2024-01-20', 1, 'disalurkan', 'BPNT Januari'),
(4, 2, 3, '2024-02-20', 1, 'disalurkan', 'BPNT Februari'),
(9, 1, 4, '2024-01-15', 1, 'disalurkan', 'PKH triwulan 1 2024'),
(9, 2, 4, '2024-01-22', 1, 'disalurkan', 'BPNT Januari'),
(1, 4, 3, '2024-02-01', 1, 'disalurkan', 'Bantuan Baznas Februari'),
(8, 4, 3, '2024-02-01', 1, 'disalurkan', 'Bantuan Baznas Februari'),
(12, 7, 4, '2024-02-05', 1, 'disalurkan', 'Sembako Katedral Februari'),
(14, 3, 5, '2024-02-10', 1, 'disalurkan', 'BLT Dana Desa'),
(7, 2, 3, '2024-03-20', 1, 'dijadwalkan','Dijadwalkan Maret');
-- LAPORAN MASYARAKAT
INSERT INTO laporan (pelapor_nama, penduduk_id, deskripsi, lat, lng, alamat_laporan, urgensi, status) VALUES
('Anonim', 8, 'Pak Samsul Bahri sakit keras sudah 2 minggu, tidak ada yang merawat dan tidak punya biaya berobat.', -0.0072000, 109.3521000, 'Jl. Khatulistiwa Pontianak Utara', 'darurat', 'diverifikasi'),
('Ani Marlina', 12, 'Ibu Maria sudah tua dan mengalami cacat, tinggal sendirian dan kesulitan memenuhi kebutuhan sehari-hari.', -0.0372000, 109.3271000, 'Jl. Sutoyo Pontianak Selatan', 'tinggi', 'diproses'),
('Warga RT 03', NULL, 'Ada keluarga baru pindah di Jl. Karet yang tampak sangat tidak mampu, rumah tidak layak huni.', -0.0195000, 109.3388000, 'Jl. Karet RT 03 RW 02', 'sedang', 'pending'),
('Pak Hasan', 1, 'Keluarga Suharto belum dapat bantuan sama sekali padahal sangat membutuhkan.', -0.0211000, 109.3445000, 'Jl. Tanjung Raya I', 'tinggi', 'pending');
-- CHAT
INSERT INTO chat (dari_user_id, ke_user_id, pesan) VALUES
(3, NULL, 'Mohon perhatian semua pengurus: ada warga darurat di wilayah Utara, Pak Samsul Bahri butuh bantuan segera 🆘'),
(4, 3, 'Siap, kami dari Katedral akan kirim relawan ke sana hari ini.'),
(3, 4, 'Terima kasih Rm. Petrus. Koordinasi dengan Pak Budi dari Al-Falah ya karena lebih dekat.'),
(5, NULL, 'Update: bantuan sembako sudah diserahkan ke Pak Samsul. Beliau butuh juga bantuan biaya berobat.'),
(6, NULL, 'Laporan survey: 3 rumah tidak layak huni ditemukan di Jl. Karet. Foto sudah diupload.');
-- LOG AKTIVITAS
INSERT INTO log_aktivitas (user_id, aksi, tabel, record_id, keterangan) VALUES
(1, 'CREATE', 'keluarga', 1, 'Input keluarga baru: Suharto'),
(1, 'CREATE', 'penduduk', 1, 'Input penduduk baru: NIK 6171011234560001'),
(3, 'UPDATE', 'histori_bantuan',1, 'Catat penyaluran PKH ke Ahmad Fauzi'),
(3, 'CREATE', 'laporan', 1, 'Laporan darurat: Samsul Bahri sakit keras'),
(4, 'UPDATE', 'laporan', 2, 'Update status laporan Maria Goreti ke diproses'),
(6, 'CREATE', 'laporan', 3, 'Survey lapangan: 3 rumah tidak layak di Jl. Karet');
+767
View File
@@ -0,0 +1,767 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>WebGIS Kemiskinan Kota Pontianak</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{
--bg:#0a0d14;--bg2:#111520;--bg3:#181d2e;--bg4:#1e253a;--bg5:#252e45;
--b:rgba(255,255,255,.06);--b2:rgba(255,255,255,.11);--b3:rgba(255,255,255,.17);
--t:#dde2f0;--t2:#7e899e;--t3:#4a5368;
--blue:#4f8ef7;--bdim:rgba(79,142,247,.14);--bglow:rgba(79,142,247,.28);
--green:#34d399;--gdim:rgba(52,211,153,.13);
--red:#f87171;--rdim:rgba(248,113,113,.13);
--amber:#fbbf24;--adim:rgba(251,191,36,.13);
--purple:#a78bfa;--pdim:rgba(167,139,250,.13);
--rose:#fb7185;--rosedim:rgba(251,113,133,.13);
--r:8px;--rs:6px;--rl:12px;
--font:'Inter',sans-serif;--mono:'JetBrains Mono',monospace;
--shadow:0 4px 24px rgba(0,0,0,.45);
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-font-smoothing:antialiased}
html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--t);font-size:13px;overflow:hidden}
body{display:flex;flex-direction:column}
::-webkit-scrollbar{width:4px;height:4px}
::-webkit-scrollbar-thumb{background:var(--bg5);border-radius:4px}
/* LOGIN */
#login-screen{position:fixed;inset:0;background:var(--bg);z-index:99999;display:flex;align-items:center;justify-content:center;background-image:radial-gradient(ellipse at 20% 50%,rgba(79,142,247,.06) 0%,transparent 60%),radial-gradient(ellipse at 80% 20%,rgba(167,139,250,.05) 0%,transparent 50%)}
#login-screen.hidden{display:none}
.lcard{background:var(--bg2);border:1px solid var(--b2);border-radius:16px;padding:40px 36px;width:420px;max-width:96vw;box-shadow:var(--shadow)}
.lbrand{display:flex;align-items:center;gap:14px;margin-bottom:32px;padding-bottom:24px;border-bottom:1px solid var(--b)}
.lbrand-icon{width:44px;height:44px;background:linear-gradient(135deg,rgba(79,142,247,.9),rgba(167,139,250,.9));border-radius:11px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.lbrand h1{font-size:15px;font-weight:700;letter-spacing:-.3px;line-height:1.2}
.lbrand p{font-size:11px;color:var(--t3);margin-top:3px;font-family:var(--mono)}
.lheading{font-size:22px;font-weight:700;letter-spacing:-.5px;margin-bottom:6px}
.lsub{font-size:12px;color:var(--t3);margin-bottom:24px;line-height:1.6}
.lerr{font-size:12px;color:var(--red);background:var(--rdim);border:1px solid rgba(248,113,113,.2);border-radius:var(--rs);padding:10px 13px;margin-bottom:16px;display:none;line-height:1.5}
.lf{margin-bottom:16px}
.lf label{display:flex;align-items:center;gap:6px;font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.7px;margin-bottom:7px}
.lf input{width:100%;padding:11px 14px;background:var(--bg3);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-family:var(--font);font-size:13px;outline:none;transition:all .18s}
.lf input:focus{border-color:var(--blue);background:var(--bg4);box-shadow:0 0 0 3px var(--bdim)}
.lf input::placeholder{color:var(--t3)}
.lbtn{width:100%;height:46px;background:linear-gradient(135deg,var(--blue),#6366f1);border:none;border-radius:var(--rs);color:#fff;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;transition:all .18s;margin-top:8px;letter-spacing:-.1px;box-shadow:0 2px 12px rgba(79,142,247,.35)}
.lbtn:hover{opacity:.92;transform:translateY(-1px)}
.lbtn:disabled{background:var(--bg4);box-shadow:none;cursor:not-allowed;transform:none;color:var(--t3)}
.laccs{margin-top:20px;padding:14px;background:var(--bg3);border:1px solid var(--b);border-radius:var(--rs)}
.laccs-title{font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.7px;margin-bottom:10px}
.laccs table{width:100%;border-collapse:collapse;font-size:11px}
.laccs td{padding:4px 0}
.laccs td:first-child{color:var(--t2);font-weight:600;width:44%}
.laccs td:last-child{font-family:var(--mono);color:var(--t3);font-size:10px}
/* TOPBAR */
#topbar{height:52px;background:var(--bg2);border-bottom:1px solid var(--b);display:flex;align-items:center;padding:0 14px;gap:6px;flex-shrink:0;z-index:200}
.brand{display:flex;align-items:center;gap:10px;margin-right:auto}
.brand-icon{width:30px;height:30px;background:linear-gradient(135deg,var(--blue),var(--purple));border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.brand h1{font-size:13px;font-weight:700;letter-spacing:-.3px}
.brand p{font-size:10px;color:var(--t3);font-family:var(--mono)}
.upill{display:flex;align-items:center;gap:7px;padding:5px 11px;background:var(--bg3);border:1px solid var(--b2);border-radius:20px}
.upill .uname{font-size:11px;font-weight:600}
.rbadge{font-size:9px;font-weight:700;padding:2px 8px;border-radius:20px;text-transform:uppercase;letter-spacing:.5px}
.r-superadmin{background:var(--pdim);color:var(--purple)}
.r-pimpinan{background:var(--bdim);color:var(--blue)}
.r-pengurus{background:var(--gdim);color:var(--green)}
.r-relawan{background:var(--adim);color:var(--amber)}
.r-masyarakat{background:var(--b);color:var(--t2)}
.nav-btn{height:32px;padding:0 12px;border-radius:var(--rs);border:1px solid transparent;background:transparent;color:var(--t2);font-family:var(--font);font-size:12px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px;transition:all .15s;white-space:nowrap}
.nav-btn:hover{background:var(--bg3);color:var(--t)}
.nav-btn.active{background:var(--bdim);color:var(--blue);border-color:rgba(79,142,247,.3)}
.ndot{width:6px;height:6px;border-radius:50%;background:var(--red);flex-shrink:0;animation:pulse 1.8s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.2}}
.tsep{width:1px;height:22px;background:var(--b2);flex-shrink:0;margin:0 2px}
/* BUTTONS */
.btn{height:32px;padding:0 13px;border-radius:var(--rs);border:1px solid var(--b2);background:var(--bg3);color:var(--t);font-family:var(--font);font-size:12px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px;transition:all .15s;white-space:nowrap}
.btn:hover{background:var(--bg4);border-color:var(--b3)}.btn:active{transform:scale(.97)}.btn:disabled{opacity:.3;cursor:not-allowed;pointer-events:none}
.btn-primary{background:var(--blue);border-color:var(--blue);color:#fff}.btn-primary:hover{background:#3b7cf0}
.btn-success{background:var(--green);border-color:var(--green);color:#0a0d14;font-weight:600}.btn-success:hover{background:#2ec48e}
.btn-danger{background:var(--red);border-color:var(--red);color:#fff}.btn-danger:hover{background:#f25c5c}
.btn-ghost{background:transparent;border-color:transparent;color:var(--t2)}.btn-ghost:hover{background:var(--bg3);color:var(--t);border-color:var(--b)}
.btn-sm{height:28px;padding:0 10px;font-size:11px;border-radius:5px}
.btn-xs{height:24px;padding:0 8px;font-size:10px;border-radius:4px}
/* LAYOUT */
#app{display:flex;flex:1;overflow:hidden}
#sidebar{width:284px;min-width:284px;background:var(--bg2);border-right:1px solid var(--b);display:flex;flex-direction:column;overflow:hidden;transition:width .25s,min-width .25s}
#sidebar.off{width:0;min-width:0}
.stabs{display:flex;border-bottom:1px solid var(--b);flex-shrink:0}
.stab{flex:1;padding:10px 4px;text-align:center;cursor:pointer;font-size:9px;font-weight:700;color:var(--t3);border-bottom:2px solid transparent;transition:all .15s;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}
.stab:hover{color:var(--t2)}.stab.active{color:var(--blue);border-bottom-color:var(--blue)}
.sbody{flex:1;overflow-y:auto;padding:14px;scrollbar-width:thin}
/* STAT CARDS */
.sgrid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px}
.scard{background:var(--bg3);border:1px solid var(--b);border-radius:var(--r);padding:12px 14px;cursor:pointer;transition:all .15s}
.scard:hover{border-color:var(--b2)}
.scard .num{font-size:24px;font-weight:700;font-family:var(--mono);line-height:1;margin-bottom:4px}
.scard .lbl{font-size:9px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.5px}
.cb{color:var(--blue)}.cr{color:var(--red)}.cg{color:var(--green)}.ca{color:var(--amber)}.cp{color:var(--purple)}.cro{color:var(--rose)}
.slbl{font-size:9px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.8px;margin:16px 0 9px;display:flex;align-items:center;justify-content:space-between}
/* ITEM CARDS */
.ilist{display:flex;flex-direction:column;gap:5px}
.icard{background:var(--bg3);border:1px solid var(--b);border-radius:var(--r);padding:10px 12px;cursor:pointer;transition:all .15s}
.icard:hover{border-color:var(--b2);background:var(--bg4)}
.ihead{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:3px}
.iname{font-size:12px;font-weight:600;color:var(--t);line-height:1.3}
.isub{font-size:11px;color:var(--t3);margin-bottom:3px;line-height:1.4}
.imeta{font-size:10px;color:var(--t3);font-family:var(--mono)}
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;white-space:nowrap;flex-shrink:0}
.b-g{background:var(--gdim);color:var(--green)}.b-r{background:var(--rdim);color:var(--red)}
.b-b{background:var(--bdim);color:var(--blue)}.b-a{background:var(--adim);color:var(--amber)}
.b-p{background:var(--pdim);color:var(--purple)}.b-ro{background:var(--rosedim);color:var(--rose)}
.b-gray{background:var(--b2);color:var(--t2)}
.frow{display:flex;gap:6px;margin-bottom:10px}
.frow input,.frow select{flex:1;height:30px;padding:0 10px;background:var(--bg3);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-family:var(--font);font-size:11px;outline:none;transition:all .15s}
.frow input:focus,.frow select:focus{border-color:var(--blue)}.frow input::placeholder{color:var(--t3)}
.frow select option{background:var(--bg3)}
.prog{height:3px;background:var(--bg5);border-radius:2px;overflow:hidden;margin-top:6px}
.pfill{height:100%;border-radius:2px;transition:width .4s}
/* MAP */
#map-container{flex:1;position:relative;display:flex;flex-direction:column;overflow:hidden}
#map{flex:1}
.leaflet-container{background:#0d1120;font-family:var(--font)}
.leaflet-control-attribution{background:rgba(10,13,20,.9)!important;color:var(--t3)!important;font-size:9px!important}
.leaflet-control-zoom{border:1px solid var(--b2)!important;border-radius:var(--rs)!important;overflow:hidden}
.leaflet-control-zoom a{background:var(--bg2)!important;border:none!important;border-bottom:1px solid var(--b)!important;color:var(--t2)!important;width:28px!important;height:28px!important;line-height:28px!important}
.leaflet-control-zoom a:last-child{border-bottom:none!important}
.leaflet-control-zoom a:hover{background:var(--bg4)!important;color:var(--t)!important}
.leaflet-popup-content-wrapper{background:var(--bg2)!important;border:1px solid var(--b2)!important;border-radius:var(--r)!important;box-shadow:var(--shadow)!important;color:var(--t)!important;font-family:var(--font)!important;padding:0!important}
.leaflet-popup-content{margin:0!important}
.leaflet-popup-tip{background:var(--bg2)!important}
.leaflet-popup-close-button{color:var(--t3)!important;top:10px!important;right:12px!important;font-size:16px!important}
.mpop{padding:14px 16px;min-width:200px;max-width:256px}
.mpop-name{font-size:13px;font-weight:700;margin-bottom:5px;padding-right:20px;letter-spacing:-.2px}
.mpop-info{font-size:11px;color:var(--t2);margin-bottom:10px;line-height:1.65}
.mpop-btn{width:100%;padding:7px;background:var(--bdim);border:1px solid rgba(79,142,247,.25);border-radius:var(--rs);color:var(--blue);font-family:var(--font);font-size:11px;font-weight:600;cursor:pointer;transition:all .15s}
.mpop-btn:hover{background:var(--blue);color:#fff}
/* MAP LAYER PANEL */
#mcp{position:absolute;top:12px;right:12px;z-index:500;background:var(--bg2);border:1px solid var(--b2);border-radius:var(--r);padding:12px 14px;min-width:190px;box-shadow:0 4px 18px rgba(0,0,0,.4)}
.mcp-h{font-size:9px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.8px;margin-bottom:10px}
.lyr{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:7px;font-size:11px;color:var(--t2)}
.lyr:last-of-type{margin-bottom:0}
.lyr-l{display:flex;align-items:center;gap:7px}
.ldot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.tog{width:30px;height:16px;background:var(--bg5);border-radius:8px;cursor:pointer;position:relative;transition:background .2s;flex-shrink:0;border:1px solid var(--b2)}
.tog.on{background:var(--blue);border-color:var(--blue)}
.tog::after{content:'';position:absolute;width:11px;height:11px;background:#fff;border-radius:50%;top:2px;left:2px;transition:left .2s}
.tog.on::after{left:15px}
.mcp-div{height:1px;background:var(--b);margin:10px 0}
.rad-lbl{display:flex;justify-content:space-between;font-size:10px;color:var(--t3);margin-bottom:5px}
.rad-lbl span:last-child{font-family:var(--mono);color:var(--blue);font-weight:600}
input[type=range]{width:100%;height:3px;-webkit-appearance:none;background:var(--bg5);border-radius:2px;outline:none;cursor:pointer}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:13px;height:13px;border-radius:50%;background:var(--blue);border:2px solid var(--bg2);box-shadow:0 0 0 2px var(--bglow)}
/* MODALS */
.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:9999;align-items:center;justify-content:center;backdrop-filter:blur(3px)}
.overlay.open{display:flex;animation:fi .18s ease}
@keyframes fi{from{opacity:0}to{opacity:1}}
.modal{background:var(--bg2);border:1px solid var(--b2);border-radius:var(--rl);padding:24px;width:90%;max-width:520px;max-height:90vh;overflow-y:auto;scrollbar-width:thin;animation:su .2s ease;box-shadow:var(--shadow)}
.modal.wide{max-width:700px}
@keyframes su{from{transform:translateY(16px);opacity:0}to{transform:none;opacity:1}}
.mtitle{font-size:16px;font-weight:700;margin-bottom:20px;display:flex;align-items:center;gap:10px;letter-spacing:-.3px}
.mico{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.mi-b{background:var(--bdim)}.mi-g{background:var(--gdim)}.mi-a{background:var(--adim)}.mi-r{background:var(--rdim)}
.fg{margin-bottom:14px}
.fg label{display:block;font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px}
.fg input,.fg select,.fg textarea{width:100%;padding:9px 12px;background:var(--bg3);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-family:var(--font);font-size:12px;outline:none;transition:all .18s}
.fg input:focus,.fg select:focus,.fg textarea:focus{border-color:var(--blue);background:var(--bg4);box-shadow:0 0 0 3px var(--bdim)}
.fg input::placeholder{color:var(--t3)}.fg input[readonly]{background:var(--bg);color:var(--t2)}
.fg select option{background:var(--bg3)}.fg textarea{resize:vertical;min-height:72px;line-height:1.6}
.fr2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.fr3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px}
.fhint{font-size:10px;color:var(--t3);margin-top:6px;display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.wbox{background:rgba(251,191,36,.08);border:1px solid rgba(251,191,36,.25);border-radius:var(--rs);padding:9px 12px;font-size:11px;color:var(--amber);margin-top:8px;line-height:1.5;display:none}
.mfooter{display:flex;gap:8px;justify-content:flex-end;margin-top:20px;padding-top:16px;border-top:1px solid var(--b)}
.dtbl{width:100%;border-collapse:collapse;font-size:12px}
.dtbl td{padding:7px 0;vertical-align:top;border-bottom:1px solid var(--b);line-height:1.5}
.dtbl td:first-child{color:var(--t3);width:40%;padding-right:12px;font-size:11px}
.dtbl tr:last-child td{border-bottom:none}
.dklist{background:var(--bg3);border:1px solid var(--b);border-radius:var(--rs);overflow:hidden;max-height:200px;overflow-y:auto}
.dkrow{display:flex;justify-content:space-between;align-items:center;padding:7px 11px;border-bottom:1px solid var(--b);font-size:11px}
.dkrow:last-child{border-bottom:none}
/* CHAT */
.chat-msgs{overflow-y:auto;display:flex;flex-direction:column;gap:10px;min-height:300px;max-height:420px;padding:2px 0}
.cmsg{max-width:82%}
.cmsg.mine{align-self:flex-end}.cmsg.other{align-self:flex-start}
.cmsg-meta{font-size:9px;color:var(--t3);margin-bottom:3px;font-weight:700;text-transform:uppercase;letter-spacing:.3px}
.cmsg.mine .cmsg-meta{text-align:right}
.cmsg-bubble{padding:9px 13px;border-radius:10px;font-size:12px;line-height:1.6}
.cmsg.mine .cmsg-bubble{background:var(--blue);color:#fff;border-bottom-right-radius:3px}
.cmsg.other .cmsg-bubble{background:var(--bg4);border:1px solid var(--b2);border-bottom-left-radius:3px}
.cmsg-time{font-size:9px;opacity:.55;margin-top:3px;font-family:var(--mono)}
.cmsg.mine .cmsg-time{text-align:right}
.cinp-row{display:flex;gap:8px;margin-top:12px;padding-top:12px;border-top:1px solid var(--b)}
.cinp{flex:1;padding:9px 12px;background:var(--bg3);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-family:var(--font);font-size:12px;outline:none;resize:none;height:38px;transition:all .15s;line-height:1.4}
.cinp:focus{border-color:var(--blue);background:var(--bg4)}
/* DATA TABLE */
.dtable{width:100%;border-collapse:collapse;font-size:12px}
.dtable thead tr{background:var(--bg3)}
.dtable th{padding:9px 12px;text-align:left;font-weight:700;font-size:9px;text-transform:uppercase;letter-spacing:.5px;color:var(--t2);border-bottom:1px solid var(--b2);white-space:nowrap}
.dtable td{padding:9px 12px;border-bottom:1px solid var(--b);vertical-align:middle;line-height:1.4}
.dtable tr:hover td{background:rgba(255,255,255,.015)}
.dtable tr:last-child td{border-bottom:none}
/* PAGE */
.pwrap{flex:1;overflow-y:auto;background:var(--bg);padding:20px}
.pinner{max-width:960px;margin:0 auto}
.pheader{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;gap:12px;flex-wrap:wrap}
.ptitle{font-size:20px;font-weight:700;letter-spacing:-.5px}
.ptitle small{font-size:13px;font-weight:400;color:var(--t3);margin-left:8px}
.scard2{background:var(--bg2);border:1px solid var(--b);border-radius:var(--r);padding:16px;margin-bottom:14px}
.scard2 h3{font-size:13px;font-weight:600;margin-bottom:14px;letter-spacing:-.2px;display:flex;align-items:center;gap:8px;color:var(--t2)}
/* URGENCY */
.u-darurat{background:var(--rosedim);color:var(--rose)}
.u-tinggi{background:var(--rdim);color:var(--red)}
.u-sedang{background:var(--adim);color:var(--amber)}
.u-rendah{background:var(--gdim);color:var(--green)}
/* TOAST */
#toast{position:fixed;bottom:20px;right:20px;z-index:99999;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:340px}
.ti{padding:11px 15px;border-radius:var(--rs);font-size:12px;font-weight:500;animation:tin .25s ease;pointer-events:auto;line-height:1.5;box-shadow:0 4px 16px rgba(0,0,0,.4)}
@keyframes tin{from{transform:translateX(24px);opacity:0}to{transform:none;opacity:1}}
.t-ok{background:#15803d;color:#fff}.t-err{background:#b91c1c;color:#fff}
.t-info{background:var(--bg4);color:var(--t);border:1px solid var(--b2)}.t-warn{background:#92400e;color:#fef3c7}
/* LOADING */
#loading{position:fixed;inset:0;background:rgba(10,13,20,.8);z-index:99998;display:none;align-items:center;justify-content:center;flex-direction:column;gap:14px}
#loading.show{display:flex}
.spin{width:32px;height:32px;border:2px solid var(--b2);border-top-color:var(--blue);border-radius:50%;animation:sp .65s linear infinite}
@keyframes sp{to{transform:rotate(360deg)}}
.empty{text-align:center;padding:32px 16px;color:var(--t3)}
.empty svg{opacity:.2;margin:0 auto 10px;display:block}
.empty p{font-size:12px}
</style>
</head>
<body>
<div id="loading"><div class="spin"></div><span style="font-size:12px;color:var(--t2)">Memuat sistem...</span></div>
<div id="toast"></div>
<!-- ===== LOGIN ===== -->
<div id="login-screen">
<div class="lcard">
<div class="lbrand">
<div class="lbrand-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 5.25 8 14 8 14s8-8.75 8-14a8 8 0 0 0-8-8z"/></svg>
</div>
<div><h1>WebGIS Pengentasan Kemiskinan</h1><p>Kota Pontianak &middot; v3.0</p></div>
</div>
<div class="lheading">Masuk ke Sistem</div>
<p class="lsub">Masukkan username dan password yang telah diberikan oleh administrator sistem.</p>
<div class="lerr" id="lerr"></div>
<div class="lf">
<label>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Username
</label>
<input type="text" id="lu" placeholder="Masukkan username" autocomplete="username">
</div>
<div class="lf">
<label>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Password
</label>
<input type="password" id="lp" placeholder="Masukkan password" autocomplete="current-password">
</div>
<button class="lbtn" id="lbtn" onclick="doLogin()">Masuk ke Sistem</button>
<div class="laccs">
<div class="laccs-title">Akun Tersedia &mdash; semua password: <span style="font-family:var(--mono);color:var(--blue)">password</span></div>
<table>
<tr><td>Super Admin</td><td>superadmin</td></tr>
<tr><td>Pimpinan Daerah</td><td>pimpinan1</td></tr>
<tr><td>Pengurus Masjid</td><td>pengurus1</td></tr>
<tr><td>Pengurus Gereja</td><td>pengurus2</td></tr>
<tr><td>Pengurus Al-Falah</td><td>pengurus3</td></tr>
<tr><td>Relawan Lapangan</td><td>relawan1</td></tr>
<tr><td>Masyarakat Umum</td><td>masyarakat1</td></tr>
</table>
</div>
</div>
</div>
<!-- ===== TOPBAR ===== -->
<div id="topbar">
<button class="btn btn-ghost btn-sm" onclick="toggleSB()" style="padding:0 9px">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="brand">
<div class="brand-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 5.25 8 14 8 14s8-8.75 8-14a8 8 0 0 0-8-8z"/></svg>
</div>
<div><h1>WebGIS Kemiskinan &mdash; Kota Pontianak</h1><p>Sistem Pemetaan &amp; Distribusi Bantuan Sosial Berbasis Komunitas</p></div>
</div>
<button class="nav-btn" id="nb-peta" onclick="goPage('peta')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"/><line x1="9" y1="3" x2="9" y2="18"/><line x1="15" y1="6" x2="15" y2="21"/></svg>
Peta
</button>
<button class="nav-btn" id="nb-penduduk" onclick="goPage('penduduk')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Penduduk
</button>
<button class="nav-btn" id="nb-bantuan" onclick="goPage('bantuan')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 12 20 22 4 22 4 12"/><rect x="2" y="7" width="20" height="5"/><line x1="12" y1="22" x2="12" y2="7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/></svg>
Bantuan
</button>
<button class="nav-btn" id="nb-laporan" onclick="goPage('laporan')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
Laporan
<span class="ndot" id="dlap" style="display:none"></span>
</button>
<button class="nav-btn" id="nb-chat" onclick="goPage('chat')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Koordinasi
<span class="ndot" id="dchat" style="display:none"></span>
</button>
<div class="tsep"></div>
<div class="upill">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--t3)"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span class="uname" id="uname"></span>
<span class="rbadge" id="urole"></span>
<button class="btn btn-ghost btn-xs" onclick="doLogout()" title="Keluar" style="padding:0 6px;margin-left:2px">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</div>
</div>
<!-- ===== MAIN APP ===== -->
<div id="app">
<div id="sidebar">
<div class="stabs" id="stabs"></div>
<div class="sbody" id="sbody"></div>
</div>
<div id="map-container">
<div id="map"></div>
<div id="mcp">
<div class="mcp-h">Layer Peta</div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#f87171"></div>Penduduk Miskin</div><div class="tog on" id="ly-miskin" onclick="togLyr(this,'miskin')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#fbbf24"></div>Penduduk Rentan</div><div class="tog on" id="ly-rentan" onclick="togLyr(this,'rentan')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#34d399"></div>Penduduk Mampu</div><div class="tog" id="ly-mampu" onclick="togLyr(this,'mampu')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#4f8ef7"></div>Rumah Ibadah</div><div class="tog on" id="ly-ri" onclick="togLyr(this,'ri')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#fb7185"></div>Laporan Warga</div><div class="tog on" id="ly-lap" onclick="togLyr(this,'laporan')"></div></div>
<div class="mcp-div"></div>
<div class="rad-lbl"><span>Radius Distribusi</span><span id="rv">300 m</span></div>
<input type="range" min="100" max="1000" step="50" value="300" id="rsl" oninput="setRad(this.value)">
</div>
</div>
</div>
<!-- ===== MODAL KK ===== -->
<div class="overlay" id="modal-kk">
<div class="modal">
<div class="mtitle"><div class="mico mi-b"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--blue)" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></div><span id="mkk-t">Tambah Kartu Keluarga</span></div>
<div class="fr2"><div class="fg"><label>No. KK (16 digit)</label><input type="text" id="kk-nokk" maxlength="16" placeholder="617101XXXXXXXXXX"></div><div class="fg"><label>Nama Kepala Keluarga</label><input type="text" id="kk-nama" placeholder="Nama sesuai KK"></div></div>
<div class="fg"><label>Alamat Lengkap</label><input type="text" id="kk-alamat" placeholder="Jl. ..."></div>
<div class="fr3"><div class="fg"><label>RT</label><input type="text" id="kk-rt" maxlength="4" placeholder="03"></div><div class="fg"><label>RW</label><input type="text" id="kk-rw" maxlength="4" placeholder="05"></div><div class="fg"><label>Status Ekonomi</label><select id="kk-se"><option value="miskin">Miskin</option><option value="rentan" selected>Rentan</option><option value="mampu">Mampu</option></select></div></div>
<div class="fr2"><div class="fg"><label>Kelurahan</label><input type="text" id="kk-kel" placeholder="Saigon"></div><div class="fg"><label>Kecamatan</label><input type="text" id="kk-kec" placeholder="Pontianak Timur"></div></div>
<div class="fr2"><div class="fg"><label>Latitude</label><input type="number" id="kk-lat" step="0.000001" placeholder="-0.021100"></div><div class="fg"><label>Longitude</label><input type="number" id="kk-lng" step="0.000001" placeholder="109.344500"></div></div>
<div class="fhint">
<button class="btn btn-sm" onclick="pickFor('kk')"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 5.25 8 14 8 14s8-8.75 8-14a8 8 0 0 0-8-8z"/></svg> Pilih di Peta</button>
<button class="btn btn-sm" onclick="gpsTo('kk')"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg> GPS Saya</button>
</div>
<div class="fg" style="margin-top:12px"><label>Rumah Ibadah Terdekat</label><select id="kk-ri"><option value="">-- Pilih --</option></select></div>
<div class="mfooter"><button class="btn btn-ghost" onclick="closeM('kk')">Batal</button><button class="btn btn-primary" onclick="saveKK()">Simpan KK</button></div>
</div>
</div>
<!-- ===== MODAL PENDUDUK ===== -->
<div class="overlay" id="modal-p">
<div class="modal wide">
<div class="mtitle"><div class="mico mi-g"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--green)" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div><span id="mp-t">Tambah Data Penduduk</span></div>
<div class="fr2"><div class="fg"><label>NIK (16 digit)</label><input type="text" id="p-nik" maxlength="16" placeholder="6171XXXXXXXXXXXX"></div><div class="fg"><label>Nama Lengkap</label><input type="text" id="p-nama" placeholder="Nama sesuai KTP"></div></div>
<div class="fr3"><div class="fg"><label>Jenis Kelamin</label><select id="p-jk"><option value="L">Laki-laki</option><option value="P">Perempuan</option></select></div><div class="fg"><label>Tanggal Lahir</label><input type="date" id="p-tgl" oninput="cekPend()"></div><div class="fg"><label>Status dalam KK</label><select id="p-sk"><option value="kepala">Kepala KK</option><option value="istri">Istri</option><option value="anak">Anak</option><option value="orang_tua">Orang Tua</option><option value="lainnya">Lainnya</option></select></div></div>
<div class="fr3"><div class="fg"><label>Status Perkawinan</label><select id="p-sp"><option value="belum_kawin">Belum Kawin</option><option value="kawin">Kawin</option><option value="cerai_hidup">Cerai Hidup</option><option value="cerai_mati">Cerai Mati</option></select></div><div class="fg"><label>Status Hidup</label><select id="p-sh"><option value="hidup">Hidup</option><option value="meninggal">Meninggal</option></select></div><div class="fg"><label>Status Ekonomi</label><select id="p-se"><option value="miskin">Miskin</option><option value="rentan" selected>Rentan</option><option value="mampu">Mampu</option></select></div></div>
<div class="fr3"><div class="fg"><label>Pendidikan Terakhir</label><select id="p-pend" onchange="cekPend()"><option value="tidak_sekolah">Tidak Sekolah</option><option value="sd">SD</option><option value="smp">SMP</option><option value="sma">SMA</option><option value="smk">SMK</option><option value="d3">D3</option><option value="s1">S1</option><option value="s2">S2</option><option value="s3">S3</option></select></div><div class="fg"><label>Status Pendidikan</label><select id="p-spend" onchange="cekPend()"><option value="tidak_sekolah">Sudah / Tidak Sekolah</option><option value="masih_sekolah">Masih Sekolah</option></select></div><div class="fg"><label>Kondisi Kesehatan</label><select id="p-kes"><option value="sehat">Sehat</option><option value="sakit_ringan">Sakit Ringan</option><option value="sakit_berat">Sakit Berat</option><option value="disabilitas">Disabilitas</option></select></div></div>
<div class="wbox" id="wp"></div>
<div class="fr2"><div class="fg"><label>Pekerjaan</label><input type="text" id="p-kerja" placeholder="Buruh Harian, Nelayan..."></div><div class="fg"><label>Penghasilan / Bulan (Rp)</label><input type="number" id="p-gaji" placeholder="0" min="0"></div></div>
<div class="fg"><label>Kartu Keluarga (KK)</label><select id="p-kk"><option value="">-- Pilih KK --</option></select></div>
<div class="fg"><label>Catatan</label><textarea id="p-cat" placeholder="Kondisi khusus atau keterangan tambahan..."></textarea></div>
<div class="mfooter"><button class="btn btn-ghost" onclick="closeM('p')">Batal</button><button class="btn btn-success" onclick="saveP()">Simpan Penduduk</button></div>
</div>
</div>
<!-- ===== MODAL LAPORAN ===== -->
<div class="overlay" id="modal-lap">
<div class="modal">
<div class="mtitle"><div class="mico mi-r"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--red)" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>Buat Laporan Kondisi Warga</div>
<div class="fr2"><div class="fg"><label>Nama Pelapor <span style="opacity:.5;font-weight:400">(opsional)</span></label><input type="text" id="lap-nama" placeholder="Nama atau Anonim"></div><div class="fg"><label>Telepon <span style="opacity:.5;font-weight:400">(opsional)</span></label><input type="text" id="lap-telp" placeholder="08xx-xxxx-xxxx"></div></div>
<div class="fg"><label>Deskripsi Kondisi yang Dilaporkan</label><textarea id="lap-desc" placeholder="Jelaskan kondisi warga secara detail..."></textarea></div>
<div class="fg"><label>Alamat Lokasi</label><input type="text" id="lap-alamat" placeholder="Jl. ... RT/RW Kelurahan ..."></div>
<div class="fr2"><div class="fg"><label>Latitude</label><input type="number" id="lap-lat" step="0.000001"></div><div class="fg"><label>Longitude</label><input type="number" id="lap-lng" step="0.000001"></div></div>
<div class="fhint">
<button class="btn btn-sm" onclick="pickFor('lap')"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 5.25 8 14 8 14s8-8.75 8-14a8 8 0 0 0-8-8z"/></svg> Pilih di Peta</button>
<button class="btn btn-sm" onclick="gpsTo('lap')"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg> GPS Saya</button>
</div>
<div class="fr2" style="margin-top:12px"><div class="fg"><label>Tingkat Urgensi</label><select id="lap-urg"><option value="rendah">Rendah</option><option value="sedang" selected>Sedang</option><option value="tinggi">Tinggi</option><option value="darurat">DARURAT</option></select></div><div class="fg"><label>Foto <span style="opacity:.5;font-weight:400">maks 5MB</span></label><input type="file" id="lap-foto" accept="image/*" style="padding:6px;font-size:11px"></div></div>
<div class="mfooter"><button class="btn btn-ghost" onclick="closeM('lap')">Batal</button><button class="btn btn-danger" onclick="saveLap()">Kirim Laporan</button></div>
</div>
</div>
<!-- ===== MODAL DETAIL ===== -->
<div class="overlay" id="modal-det">
<div class="modal wide">
<div class="mtitle" id="det-h"></div>
<div id="det-b"></div>
<div class="mfooter" id="det-f"></div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const EP={auth:'api/auth.php',p:'api/penduduk.php',kk:'api/keluarga.php',ri:'api/rumah_ibadah.php',b:'api/bantuan.php',lap:'api/laporan.php',chat:'api/chat.php',stats:'api/stats.php'};
const PT=[-0.0263,109.3425];
const EC={miskin:'#f87171',rentan:'#fbbf24',mampu:'#34d399'};
const UC={darurat:'#fb7185',tinggi:'#f87171',sedang:'#fbbf24',rendah:'#34d399'};
let map,cu=null,riD=[],pD=[],lapD=[],bD=[],kkD=[];
let lyrs={miskin:true,rentan:true,mampu:false,ri:true,laporan:true};
let rad=300,pick=null,eKK=null,eP=null;
let mk={ri:[],p:[],lap:[],c:[]};
let cpoll=null,lastC='1970-01-01 00:00:00',curPg='peta';
const ld=v=>document.getElementById('loading').classList.toggle('show',v);
function toast(m,t='info'){const e=document.createElement('div');e.className='ti t-'+t;e.textContent=m;document.getElementById('toast').appendChild(e);setTimeout(()=>e.remove(),3800);}
async function rq(ep,method='GET',body=null,qs=''){
const r=await fetch(ep+(qs?'?'+qs:''),{method,headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
const d=await r.json();if(!d.success)throw new Error(d.message||'Error');return d.data;
}
// SVG helpers
const riSVG=j=>{const m=`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2 20h20M4 20V10c0-1.1.4-2.1 1.2-2.8L12 3l6.8 4.2c.8.7 1.2 1.7 1.2 2.8v10"/><path d="M9 20v-5a3 3 0 0 1 6 0v5"/><path d="M12 3V1m-4 3L6 2m10 2 2-2"/></svg>`;const g=`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 20h18M5 20V9.5L12 4l7 5.5V20"/><path d="M9 20v-5h6v5"/><line x1="12" y1="4" x2="12" y2="1"/><line x1="10" y1="2" x2="14" y2="2"/></svg>`;const v=`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2 20h20M4 20v-7l8-9 8 9v7"/><path d="M9 20v-4h6v4"/><path d="M1 13h3m16 0h3"/></svg>`;if(!j)return m;const k=j.toLowerCase();return k==='masjid'?m:k==='gereja'?g:v;};
const icoUser=`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
const icoHome=`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`;
const icoWarn=`<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
const icoGift=`<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 12 20 22 4 22 4 12"/><rect x="2" y="7" width="20" height="5"/><line x1="12" y1="22" x2="12" y2="7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/></svg>`;
// LOGIN
async function doLogin(){
const u=document.getElementById('lu').value.trim(),p=document.getElementById('lp').value;
const err=document.getElementById('lerr'),btn=document.getElementById('lbtn');
err.style.display='none';
if(!u||!p){err.textContent='Username dan password wajib diisi.';err.style.display='block';return;}
btn.disabled=true;btn.textContent='Memverifikasi...';
try{
cu=await rq(EP.auth,'POST',{username:u,password:p},'action=login');
applyRole();document.getElementById('login-screen').classList.add('hidden');
toast('Selamat datang, '+cu.nama_lengkap,'ok');
initApp();
}catch(e){err.textContent=e.message;err.style.display='block';btn.disabled=false;btn.textContent='Masuk ke Sistem';}
}
async function doLogout(){
try{await rq(EP.auth,'POST',null,'action=logout');}catch(e){}
if(cpoll)clearInterval(cpoll);cu=null;if(map){map.remove();map=null;}
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('lp').value='';document.getElementById('lerr').style.display='none';
document.getElementById('lbtn').disabled=false;document.getElementById('lbtn').textContent='Masuk ke Sistem';
}
function applyRole(){
if(!cu)return;
document.getElementById('uname').textContent=cu.nama_lengkap;
const rb=document.getElementById('urole');rb.textContent=cu.role_nama;rb.className='rbadge r-'+cu.role;
const H=id=>{const e=document.getElementById(id);if(e)e.style.display='none';};
if(cu.role==='masyarakat'){H('nb-bantuan');H('nb-chat');}
if(cu.role==='pimpinan'){H('nb-bantuan');H('nb-chat');}
}
// APP INIT
function initApp(){
initMap();loadAll();
if(['superadmin','pengurus','relawan'].includes(cu.role)) cpoll=setInterval(pollChat,8000);
buildSB();goPage('peta');
}
// MAP
function initMap(){
map=L.map('map',{zoomControl:true}).setView(PT,13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',{attribution:'&copy; OSM &copy; CARTO',maxZoom:19}).addTo(map);
map.on('click',onMapClick);
}
function onMapClick(e){
if(!pick)return;
const{lat,lng}=e.latlng;
if(pick==='kk'){document.getElementById('kk-lat').value=lat.toFixed(6);document.getElementById('kk-lng').value=lng.toFixed(6);map.closePopup();openM('kk');}
if(pick==='lap'){document.getElementById('lap-lat').value=lat.toFixed(6);document.getElementById('lap-lng').value=lng.toFixed(6);map.closePopup();openM('lap');}
pick=null;map.getContainer().style.cursor='';
}
function pickFor(t){pick=t;map.getContainer().style.cursor='crosshair';closeM(t==='kk'?'kk':'lap');L.popup({closeButton:false}).setLatLng(map.getCenter()).setContent('<div style="background:var(--bg3);border:1px solid var(--b2);border-radius:8px;padding:10px 14px;color:var(--t);font-size:12px">Klik lokasi di peta untuk memilih koordinat</div>').openOn(map);}
function gpsTo(t){if(!navigator.geolocation){toast('GPS tidak didukung','err');return;}navigator.geolocation.getCurrentPosition(pos=>{const pref=t==='kk'?'kk-':'lap-';document.getElementById(pref+'lat').value=pos.coords.latitude.toFixed(6);document.getElementById(pref+'lng').value=pos.coords.longitude.toFixed(6);},()=>toast('Gagal mengambil GPS','err'));}
function setRad(v){rad=parseInt(v);document.getElementById('rv').textContent=v+' m';mk.c.forEach(c=>c.setRadius(rad));}
function togLyr(el,t){el.classList.toggle('on');lyrs[t]=el.classList.contains('on');rerender();}
// LOAD
async function loadAll(){
ld(true);
try{
const[ri,p,lap,b,kk]=await Promise.all([rq(EP.ri),rq(EP.p),rq(EP.lap),rq(EP.b),rq(EP.kk)]);
riD=ri||[];pD=p||[];lapD=lap||[];bD=b||[];kkD=kk||[];
rerender();rSB();checkAlerts();toast('Data berhasil dimuat','ok');
}catch(e){toast('Gagal memuat data: '+e.message,'err');}
finally{ld(false);}
}
// MARKERS
function clrMk(){[...mk.ri,...mk.p,...mk.lap].forEach(m=>map&&map.removeLayer(m));mk.c.forEach(c=>map&&map.removeLayer(c));mk.ri=[];mk.p=[];mk.lap=[];mk.c=[];}
function rerender(){
clrMk();
if(lyrs.ri) riD.forEach(addRI);
pD.forEach(p=>{if(lyrs[p.status_ekonomi]) addP(p);});
if(lyrs.laporan) lapD.forEach(l=>{if(l.lat&&l.lng) addLap(l);});
}
function addRI(ri){
const m=L.marker([ri.lat,ri.lng],{icon:L.divIcon({html:`<div style="width:34px;height:34px;background:#111520;border:2px solid #4f8ef7;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#4f8ef7;box-shadow:0 0 0 4px rgba(79,142,247,.18),0 3px 10px rgba(0,0,0,.6)">${riSVG(ri.jenis)}</div>`,iconSize:[34,34],iconAnchor:[17,17],className:''})}).addTo(map);
m.bindPopup(`<div class="mpop"><div class="mpop-name">${ri.nama}</div><div class="mpop-info">${ri.jenis} &middot; ${ri.kelurahan||'—'}<br>${ri.kontak||'—'}<br><strong>${ri.kk_miskin||0}</strong> KK miskin dalam jangkauan</div><button class="mpop-btn" onclick="detRI(${ri.id});map.closePopup()">Lihat Detail</button></div>`,{maxWidth:240});
const c=L.circle([ri.lat,ri.lng],{radius:rad,color:'#4f8ef7',fillColor:'#4f8ef7',fillOpacity:.05,dashArray:'5,5',weight:1.5}).addTo(map);
mk.ri.push(m);mk.c.push(c);
}
function addP(p){
const col=EC[p.status_ekonomi]||'#888';const sk=p.kondisi_kesehatan&&p.kondisi_kesehatan!=='sehat';
const m=L.marker([p.lat||0,p.lng||0],{icon:L.divIcon({html:`<div style="width:12px;height:12px;background:${col};border-radius:50%;border:2px solid rgba(255,255,255,.8);box-shadow:0 0 0 3px ${sk?'rgba(251,113,133,.5)':'rgba(255,255,255,.1)'},0 2px 6px rgba(0,0,0,.5)"></div>`,iconSize:[12,12],iconAnchor:[6,6],className:''})}).addTo(map);
m.bindPopup(`<div class="mpop"><div class="mpop-name">${p.nama}</div><div class="mpop-info">NIK: ${p.nik||'—'}<br>${p.umur||'?'} thn &middot; ${p.profil_umur||'—'}<br>Ekonomi: <strong style="color:${col}">${(p.status_ekonomi||'').toUpperCase()}</strong><br>Kesehatan: ${p.kondisi_kesehatan||'sehat'}<br>Bantuan: ${p.total_bantuan||0}x</div><button class="mpop-btn" onclick="detP(${p.id});map.closePopup()">Lihat Detail Lengkap</button></div>`,{maxWidth:240});
mk.p.push(m);
}
function addLap(l){
const col=UC[l.urgensi]||'#888';
const m=L.marker([l.lat,l.lng],{icon:L.divIcon({html:`<div style="width:17px;height:17px;background:${col};border-radius:50%;border:2.5px solid #fff;box-shadow:0 0 0 3px ${col}44,0 3px 8px rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center">${l.urgensi==='darurat'?'<span style="font-size:9px;color:#fff;font-weight:900;line-height:1">!</span>':''}</div>`,iconSize:[17,17],iconAnchor:[8,8],className:''})}).addTo(map);
m.bindPopup(`<div class="mpop"><div class="mpop-name">Laporan Warga</div><div class="mpop-info"><span class="badge u-${l.urgensi}" style="display:inline-flex;margin-bottom:5px">${(l.urgensi||'').toUpperCase()}</span><br>${(l.deskripsi||'').substring(0,80)}${(l.deskripsi||'').length>80?'…':''}</div><button class="mpop-btn" onclick="detLap(${l.id});map.closePopup()">Lihat Detail</button></div>`,{maxWidth:240});
mk.lap.push(m);
}
// SIDEBAR
function buildSB(){
document.getElementById('stabs').innerHTML=['Ringkasan','Ibadah','Keluarga','Prioritas'].map((t,i)=>`<div class="stab${i===0?' active':''}" onclick="showTab(${i})">${t}</div>`).join('');
showTab(0);
}
function showTab(i){
document.querySelectorAll('.stab').forEach((e,j)=>e.classList.toggle('active',i===j));
if(i===0)rDash();else if(i===1)rRITab();else if(i===2)rKKTab();else rPrioTab();
}
function rSB(){const i=[...document.querySelectorAll('.stab')].findIndex(e=>e.classList.contains('active'));showTab(i>=0?i:0);}
function rDash(){
const mis=pD.filter(p=>p.status_ekonomi==='miskin').length,ren=pD.filter(p=>p.status_ekonomi==='rentan').length;
const bel=pD.filter(p=>['miskin','rentan'].includes(p.status_ekonomi)&&!parseInt(p.total_bantuan)).length;
const lAkt=lapD.filter(l=>l.status!=='selesai').length,lDar=lapD.filter(l=>l.urgensi==='darurat'&&l.status!=='selesai').length;
document.getElementById('sbody').innerHTML=`
<div class="sgrid">
<div class="scard" onclick="goPage('penduduk')"><div class="num cb">${pD.length}</div><div class="lbl">Total Penduduk</div></div>
<div class="scard"><div class="num">${kkD.length}</div><div class="lbl">Total KK</div></div>
<div class="scard"><div class="num cr">${mis}</div><div class="lbl">Miskin</div></div>
<div class="scard"><div class="num ca">${ren}</div><div class="lbl">Rentan</div></div>
<div class="scard"><div class="num cro">${bel}</div><div class="lbl">Belum Dibantu</div></div>
<div class="scard" onclick="goPage('laporan')"><div class="num cp">${lAkt}${lDar?`<span style="font-size:11px;color:var(--rose)"> (${lDar}!)</span>`:''}</div><div class="lbl">Laporan Aktif</div></div>
</div>
<div class="slbl">Jangkauan per Rumah Ibadah</div>
<div style="display:flex;flex-direction:column;gap:6px">
${riD.map(ri=>{const k=kkD.filter(x=>x.rumah_ibadah_id==ri.id);const m=k.filter(x=>x.status_ekonomi==='miskin').length;const p=k.length?Math.round(m/k.length*100):0;return`<div style="background:var(--bg3);border:1px solid var(--b);border-radius:var(--rs);padding:9px 11px"><div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:4px"><span style="display:flex;align-items:center;gap:5px;color:var(--t2);font-weight:500">${riSVG(ri.jenis)} ${ri.nama.length>22?ri.nama.substring(0,22)+'…':ri.nama}</span><span style="font-family:var(--mono);font-size:10px;color:var(--t3)">${k.length} KK</span></div><div class="prog"><div class="pfill" style="width:${p}%;background:var(--red)"></div></div></div>`;}).join('')}
</div>
<div class="slbl" style="margin-top:18px">Legenda</div>
<div style="display:flex;flex-direction:column;gap:7px;font-size:11px;color:var(--t2)">
${[['#f87171','Penduduk Miskin'],['#fbbf24','Penduduk Rentan'],['#34d399','Penduduk Mampu'],['#4f8ef7','Rumah Ibadah'],['#fb7185','Laporan Darurat']].map(([c,l])=>`<div style="display:flex;align-items:center;gap:8px"><div style="width:9px;height:9px;background:${c};border-radius:50%;flex-shrink:0"></div>${l}</div>`).join('')}
</div>`;
}
function rRITab(){
const cA=cu?.role==='superadmin';
document.getElementById('sbody').innerHTML=`
${cA?`<button class="btn btn-primary btn-sm" style="width:100%;justify-content:center;margin-bottom:10px" onclick="toast('Fitur ini untuk Super Admin','info')"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Tambah Rumah Ibadah</button>`:''}
<div class="frow"><input type="text" id="q-ri" placeholder="Cari nama..." oninput="fRI()"></div>
<div class="ilist" id="l-ri">${riCards(riD)}</div>`;
}
function riCards(d){if(!d.length)return`<div class="empty"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><p>Tidak ada data</p></div>`;return d.map(r=>{const m=kkD.filter(k=>k.rumah_ibadah_id==r.id&&k.status_ekonomi==='miskin').length;return`<div class="icard" onclick="detRI(${r.id})"><div class="ihead"><div class="iname" style="display:flex;align-items:center;gap:6px;color:var(--blue)">${riSVG(r.jenis)} ${r.nama}</div><span class="badge b-b">${r.jenis}</span></div><div class="isub">${r.kelurahan||'—'}</div><div class="imeta">${r.jumlah_kk||0} KK &ensp;&middot;&ensp; ${m} miskin</div></div>`;}).join('');}
function fRI(){const q=(document.getElementById('q-ri')?.value||'').toLowerCase();const el=document.getElementById('l-ri');if(el)el.innerHTML=riCards(riD.filter(r=>r.nama.toLowerCase().includes(q)));}
function rKKTab(){
const cW=['superadmin','pengurus','relawan'].includes(cu?.role);
document.getElementById('sbody').innerHTML=`
${cW?`<div style="display:flex;gap:6px;margin-bottom:10px">
<button class="btn btn-primary btn-sm" style="flex:1;justify-content:center" onclick="openAddKK()"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> KK</button>
<button class="btn btn-success btn-sm" style="flex:1;justify-content:center" onclick="openAddP()"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Penduduk</button>
</div>`:''}
<div class="frow"><input type="text" id="q-kk" placeholder="Cari nama / No. KK..." oninput="fKK()"></div>
<div class="frow"><select id="fse-kk" onchange="fKK()"><option value="">Semua Status</option><option value="miskin">Miskin</option><option value="rentan">Rentan</option><option value="mampu">Mampu</option></select></div>
<div class="ilist" id="l-kk">${kkCards(kkD)}</div>`;
}
function kkCards(d){if(!d.length)return`<div class="empty"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><p>Tidak ada data</p></div>`;return d.slice(0,60).map(k=>`<div class="icard" onclick="detKK(${k.id})"><div class="ihead"><div class="iname" style="display:flex;align-items:center;gap:6px">${icoHome} ${k.nama_kk}</div><span class="badge ${k.status_ekonomi==='miskin'?'b-r':k.status_ekonomi==='rentan'?'b-a':'b-g'}">${k.status_ekonomi}</span></div><div class="isub">${(k.alamat||'—').substring(0,38)}</div><div class="imeta">${k.jumlah_jiwa||0} jiwa &ensp;&middot;&ensp; Bantuan: ${k.total_bantuan_diterima||0}x</div></div>`).join('');}
function fKK(){const q=(document.getElementById('q-kk')?.value||'').toLowerCase();const fse=document.getElementById('fse-kk')?.value||'';const f=kkD.filter(k=>(!q||(k.nama_kk.toLowerCase().includes(q)||k.no_kk.includes(q)))&&(!fse||k.status_ekonomi===fse));const el=document.getElementById('l-kk');if(el)el.innerHTML=kkCards(f);}
async function rPrioTab(){
document.getElementById('sbody').innerHTML=`<div class="empty" style="margin-top:50px"><div class="spin" style="margin:0 auto 12px"></div><p>Menganalisis prioritas...</p></div>`;
try{
const d=await rq(EP.b,'GET',null,'action=prioritas');
document.getElementById('sbody').innerHTML=`
<div style="font-size:10px;color:var(--t3);margin-bottom:10px;padding:10px;background:var(--bg3);border:1px solid var(--b);border-radius:var(--rs);line-height:1.6">Warga miskin dengan penyakit berat atau lansia yang hidup sendiri mendapat prioritas tertinggi.</div>
<div class="ilist">${d.slice(0,30).map(p=>{const c=p.prioritas==='SANGAT TINGGI'?'b-ro':p.prioritas==='TINGGI'?'b-r':p.prioritas==='SEDANG'?'b-a':'b-g';return`<div class="icard" onclick="detP(${p.id})"><div class="ihead"><div class="iname" style="display:flex;align-items:center;gap:6px">${icoUser} ${p.nama}</div><span class="badge ${c}">${p.prioritas}</span></div><div class="isub">${p.umur||'?'} thn &middot; ${p.kondisi_kesehatan||'—'}</div><div class="imeta">${p.kelurahan||'—'} &middot; Bantuan: ${p.total_bantuan||0}x</div></div>`;}).join('')}</div>`;
}catch(e){toast(e.message,'err');}
}
// PAGES
function goPage(pg){
curPg=pg;
['peta','penduduk','bantuan','laporan','chat'].forEach(x=>document.getElementById('nb-'+x)?.classList.remove('active'));
document.getElementById('nb-'+pg)?.classList.add('active');
if(pg==='peta') rebuildPeta();
else if(pg==='penduduk') pgPenduduk();
else if(pg==='bantuan') pgBantuan();
else if(pg==='laporan') pgLaporan();
else if(pg==='chat') pgChat();
}
function rebuildPeta(){
document.getElementById('app').innerHTML=`
<div id="sidebar"><div class="stabs" id="stabs"></div><div class="sbody" id="sbody"></div></div>
<div id="map-container">
<div id="map"></div>
<div id="mcp">
<div class="mcp-h">Layer Peta</div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#f87171"></div>Penduduk Miskin</div><div class="tog ${lyrs.miskin?'on':''}" id="ly-miskin" onclick="togLyr(this,'miskin')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#fbbf24"></div>Penduduk Rentan</div><div class="tog ${lyrs.rentan?'on':''}" id="ly-rentan" onclick="togLyr(this,'rentan')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#34d399"></div>Penduduk Mampu</div><div class="tog ${lyrs.mampu?'on':''}" id="ly-mampu" onclick="togLyr(this,'mampu')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#4f8ef7"></div>Rumah Ibadah</div><div class="tog ${lyrs.ri?'on':''}" id="ly-ri" onclick="togLyr(this,'ri')"></div></div>
<div class="lyr"><div class="lyr-l"><div class="ldot" style="background:#fb7185"></div>Laporan Warga</div><div class="tog ${lyrs.laporan?'on':''}" id="ly-lap" onclick="togLyr(this,'laporan')"></div></div>
<div class="mcp-div"></div>
<div class="rad-lbl"><span>Radius Distribusi</span><span id="rv">${rad} m</span></div>
<input type="range" min="100" max="1000" step="50" value="${rad}" id="rsl" oninput="setRad(this.value)">
</div>
</div>`;
if(map){map.remove();map=null;}
initMap();rerender();buildSB();
}
function pgPenduduk(){
const cW=['superadmin','pengurus','relawan'].includes(cu?.role);
document.getElementById('app').innerHTML=`
<div class="pwrap">
<div class="pinner">
<div class="pheader">
<div class="ptitle">Data Penduduk <small>${pD.length} terdaftar</small></div>
${cW?`<div style="display:flex;gap:8px">
<button class="btn btn-success" onclick="openAddP()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Tambah Penduduk</button>
<button class="btn btn-primary" onclick="openAddKK()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Tambah KK</button>
</div>`:''}
</div>
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
<input type="text" id="qp" placeholder="Cari nama atau NIK..." style="flex:1;min-width:180px;height:34px;padding:0 12px;background:var(--bg2);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-size:12px;outline:none" oninput="fP()">
<select id="fsep" style="height:34px;padding:0 10px;background:var(--bg2);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-size:12px;outline:none" onchange="fP()"><option value="">Semua Ekonomi</option><option value="miskin">Miskin</option><option value="rentan">Rentan</option><option value="mampu">Mampu</option></select>
<select id="fkesp" style="height:34px;padding:0 10px;background:var(--bg2);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-size:12px;outline:none" onchange="fP()"><option value="">Semua Kesehatan</option><option value="sehat">Sehat</option><option value="sakit_ringan">Sakit Ringan</option><option value="sakit_berat">Sakit Berat</option><option value="disabilitas">Disabilitas</option></select>
</div>
<div style="background:var(--bg2);border:1px solid var(--b);border-radius:var(--r);overflow:hidden">
<div id="ptbl" style="overflow-x:auto">${pTbl(pD)}</div>
</div>
</div>
</div>`;
}
function pTbl(d){
if(!d.length)return`<div class="empty"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg><p>Tidak ada data</p></div>`;
return`<table class="dtable"><thead><tr><th>NIK</th><th>Nama</th><th>JK</th><th>Umur</th><th>Profil</th><th>Ekonomi</th><th>Kesehatan</th><th>Pekerjaan</th><th>Bantuan</th><th></th></tr></thead><tbody>${d.slice(0,100).map(p=>`<tr><td style="font-family:var(--mono);font-size:10px;color:var(--t3)">${p.nik||'—'}</td><td style="font-weight:600">${p.nama}</td><td>${p.jenis_kelamin||'—'}</td><td style="font-family:var(--mono)">${p.umur||'?'}</td><td><span class="badge b-b">${p.profil_umur||'—'}</span></td><td><span class="badge ${p.status_ekonomi==='miskin'?'b-r':p.status_ekonomi==='rentan'?'b-a':'b-g'}">${p.status_ekonomi}</span></td><td style="font-size:11px;color:${p.kondisi_kesehatan==='sehat'?'var(--green)':p.kondisi_kesehatan==='disabilitas'?'var(--rose)':'var(--amber)'}">${p.kondisi_kesehatan||'sehat'}</td><td style="font-size:11px;color:var(--t2)">${p.pekerjaan||'—'}</td><td style="text-align:center;font-family:var(--mono)">${p.total_bantuan||0}x</td><td><button class="btn btn-xs btn-ghost" onclick="detP(${p.id})">Detail</button></td></tr>`).join('')}${d.length>100?`<tr><td colspan="10" style="text-align:center;padding:12px;font-size:11px;color:var(--t3)">Menampilkan 100 dari ${d.length}. Gunakan filter untuk mempersempit pencarian.</td></tr>`:''}</tbody></table>`;
}
function fP(){const q=(document.getElementById('qp')?.value||'').toLowerCase();const fse=document.getElementById('fsep')?.value||'';const fkes=document.getElementById('fkesp')?.value||'';const f=pD.filter(p=>(!q||p.nama.toLowerCase().includes(q)||(p.nik||'').includes(q))&&(!fse||p.status_ekonomi===fse)&&(!fkes||p.kondisi_kesehatan===fkes));const el=document.getElementById('ptbl');if(el)el.innerHTML=pTbl(f);}
function pgBantuan(){
document.getElementById('app').innerHTML=`
<div class="pwrap">
<div class="pinner">
<div class="pheader"><div class="ptitle">Manajemen Bantuan Sosial</div></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px">
<div class="scard2"><h3>${icoGift} Program Bantuan</h3><div style="display:flex;flex-direction:column;gap:6px">${bD.map(b=>`<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid var(--b);font-size:12px"><span>${b.nama}</span><span class="badge b-b">${b.jenis}</span></div>`).join('')}</div></div>
<div class="scard2"><h3>${icoWarn} Belum Terima Bantuan</h3><div id="blist" style="display:flex;flex-direction:column;gap:5px;max-height:240px;overflow-y:auto;font-size:12px"><span style="color:var(--t3)">Memuat...</span></div></div>
</div>
<div class="scard2">
<h3>Catat Penyaluran Bantuan</h3>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:10px;align-items:end">
<div><div style="font-size:10px;color:var(--t3);font-weight:700;text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px">Penduduk</div><select id="hb-pid" style="width:100%;height:32px;padding:0 9px;background:var(--bg3);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-size:11px;outline:none"><option value="">-- Pilih --</option>${pD.map(p=>`<option value="${p.id}">${p.nama}</option>`).join('')}</select></div>
<div><div style="font-size:10px;color:var(--t3);font-weight:700;text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px">Program Bantuan</div><select id="hb-bid" style="width:100%;height:32px;padding:0 9px;background:var(--bg3);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-size:11px;outline:none"><option value="">-- Pilih --</option>${bD.map(b=>`<option value="${b.id}">${b.nama}</option>`).join('')}</select></div>
<div><div style="font-size:10px;color:var(--t3);font-weight:700;text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px">Tanggal</div><input type="date" id="hb-tgl" value="${new Date().toISOString().split('T')[0]}" style="width:100%;height:32px;padding:0 9px;background:var(--bg3);border:1px solid var(--b2);border-radius:var(--rs);color:var(--t);font-size:12px;outline:none"></div>
<button class="btn btn-success" onclick="catatB()">Catat</button>
</div>
</div>
</div>
</div>`;
rq(EP.b,'GET',null,'action=belum_terima').then(d=>{const el=document.getElementById('blist');if(!el)return;if(!d.length){el.innerHTML=`<div style="color:var(--green);font-size:12px">Semua warga sudah pernah menerima bantuan</div>`;return;}el.innerHTML=d.slice(0,20).map(p=>`<div style="display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--b)"><span>${p.nama}</span><span class="badge ${p.status_ekonomi==='miskin'?'b-r':'b-a'}">${p.status_ekonomi}</span></div>`).join('');}).catch(()=>{});
}
async function catatB(){const pid=document.getElementById('hb-pid')?.value;const bid=document.getElementById('hb-bid')?.value;const tgl=document.getElementById('hb-tgl')?.value;if(!pid||!bid){toast('Pilih penduduk dan program bantuan','err');return;}try{await rq(EP.b,'POST',{penduduk_id:parseInt(pid),bantuan_id:parseInt(bid),tanggal:tgl,status:'disalurkan'},'action=histori');toast('Penyaluran bantuan berhasil dicatat','ok');}catch(e){toast(e.message,'err');}}
function pgLaporan(){
document.getElementById('app').innerHTML=`
<div class="pwrap">
<div class="pinner">
<div class="pheader">
<div class="ptitle">Laporan Masyarakat <small>${lapD.filter(l=>l.status!=='selesai').length} aktif</small></div>
<button class="btn btn-danger" onclick="openM('lap')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Buat Laporan</button>
</div>
<div style="display:flex;gap:7px;margin-bottom:16px;flex-wrap:wrap">${['','pending','diverifikasi','diproses','selesai'].map((s,i)=>`<button class="btn btn-sm ${i===0?'btn-primary':''}" id="fb-${s||'all'}" onclick="fLap('${s}',this)">${s||'Semua'}</button>`).join('')}</div>
<div id="lap-list" style="display:flex;flex-direction:column;gap:10px">${lapCards(lapD)}</div>
</div>
</div>`;
document.getElementById('dlap').style.display='none';
}
function lapCards(d){if(!d.length)return`<div class="empty"><svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><p>Tidak ada laporan</p></div>`;const cW=['superadmin','pengurus','relawan'].includes(cu?.role);return d.map(l=>{const bd=l.urgensi==='darurat'?'rgba(251,113,133,.3)':l.urgensi==='tinggi'?'rgba(248,113,113,.2)':'var(--b)';return`<div style="background:var(--bg2);border:1px solid ${bd};border-radius:var(--r);padding:16px"><div style="display:flex;justify-content:space-between;gap:12px;margin-bottom:8px"><div style="font-size:13px;font-weight:600;line-height:1.5;letter-spacing:-.2px">${(l.deskripsi||'').substring(0,90)}${(l.deskripsi||'').length>90?'…':''}</div><div style="display:flex;gap:5px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end"><span class="badge u-${l.urgensi}">${(l.urgensi||'').toUpperCase()}</span><span class="badge b-gray">${l.status}</span></div></div><div style="font-size:11px;color:var(--t3);margin-bottom:10px">${l.alamat_laporan||'—'} &middot; ${l.pelapor_nama||'Anonim'} &middot; ${new Date(l.created_at).toLocaleDateString('id-ID')}</div><div style="display:flex;gap:7px;align-items:center;flex-wrap:wrap"><button class="btn btn-sm" onclick="detLap(${l.id})">Lihat Detail</button>${cW?`<select class="btn btn-sm" style="height:28px;font-size:11px;cursor:pointer" onchange="uLS(${l.id},this.value,this)"><option value="">Update Status</option>${['pending','diverifikasi','diproses','selesai'].map(s=>`<option value="${s}"${l.status===s?' selected':''}>${s}</option>`).join('')}</select>`:''}</div></div>`;}).join('');}
async function fLap(s,btn){document.querySelectorAll('[id^="fb-"]').forEach(b=>b.classList.remove('btn-primary'));btn?.classList.add('btn-primary');try{const d=await rq(EP.lap,'GET',null,s?'status='+s:'');const el=document.getElementById('lap-list');if(el)el.innerHTML=lapCards(d);}catch(e){toast(e.message,'err');}}
async function uLS(id,s,sel){if(!s)return;try{await rq(EP.lap,'PUT',{status:s},'id='+id);const i=lapD.findIndex(l=>l.id===id);if(i>-1)lapD[i].status=s;toast('Status diperbarui','ok');}catch(e){toast(e.message,'err');if(sel)sel.value='';}}
function pgChat(){
document.getElementById('app').innerHTML=`
<div class="pwrap">
<div class="pinner" style="max-width:680px">
<div class="pheader"><div class="ptitle">Koordinasi Pengurus</div><span style="font-size:11px;color:var(--t3)">Pesan dapat dilihat semua pengurus & relawan</span></div>
<div style="background:var(--bg2);border:1px solid var(--b2);border-radius:var(--r);padding:18px">
<div class="chat-msgs" id="cmsgs"></div>
<div class="cinp-row">
<textarea class="cinp" id="cinp" placeholder="Tulis pesan... (Enter untuk kirim)" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendChat();}"></textarea>
<button class="btn btn-primary btn-sm" onclick="sendChat()" style="height:38px;padding:0 14px">Kirim</button>
</div>
</div>
</div>
</div>`;
loadChat();document.getElementById('dchat').style.display='none';
}
async function loadChat(){try{const msgs=await rq(EP.chat,'GET',null,'since='+lastC);if(!msgs.length)return;lastC=msgs[msgs.length-1].created_at;const el=document.getElementById('cmsgs');if(!el)return;msgs.forEach(m=>{const mine=m.dari_id===cu.id;const d=document.createElement('div');d.className='cmsg '+(mine?'mine':'other');d.innerHTML=`<div class="cmsg-meta">${mine?'Anda':m.dari_nama}${m.dari_ri?' &middot; '+m.dari_ri:''}</div><div class="cmsg-bubble">${m.pesan}</div><div class="cmsg-time">${new Date(m.created_at).toLocaleTimeString('id',{hour:'2-digit',minute:'2-digit'})}</div>`;el.appendChild(d);});el.scrollTop=el.scrollHeight;}catch(e){}}
async function pollChat(){if(curPg==='chat'){loadChat();return;}try{const msgs=await rq(EP.chat,'GET',null,'since='+lastC);if(msgs.length){lastC=msgs[msgs.length-1].created_at;document.getElementById('dchat').style.display='inline-block';}}catch(e){}}
async function sendChat(){const inp=document.getElementById('cinp');const p=inp?.value.trim();if(!p)return;try{await rq(EP.chat,'POST',{pesan:p});inp.value='';loadChat();}catch(e){toast(e.message,'err');}}
// DETAILS
async function detRI(id){ld(true);try{const ri=await rq(EP.ri,'GET',null,'id='+id);const kk2=kkD.filter(k=>k.rumah_ibadah_id==id);document.getElementById('det-h').innerHTML=`<div class="mico mi-b">${riSVG(ri.jenis)}</div>${ri.nama}`;document.getElementById('det-b').innerHTML=`<span class="badge b-b" style="margin-bottom:14px;display:inline-block">${ri.jenis}</span><table class="dtbl"><tr><td>Kontak</td><td>${ri.kontak||'—'}</td></tr><tr><td>Alamat</td><td>${ri.alamat||'—'}</td></tr><tr><td>Kelurahan</td><td>${ri.kelurahan||'—'}</td></tr><tr><td>Jumlah KK</td><td>${ri.jumlah_kk||0} KK (${ri.jumlah_jiwa||0} jiwa)</td></tr><tr><td>KK Miskin</td><td style="color:var(--red);font-weight:600">${ri.kk_miskin||0} KK</td></tr></table>${kk2.length?`<div style="font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.6px;margin:16px 0 8px">KK Terdampak (${kk2.length})</div><div class="dklist">${kk2.map(k=>`<div class="dkrow"><span>${k.nama_kk}</span><span class="badge ${k.status_ekonomi==='miskin'?'b-r':k.status_ekonomi==='rentan'?'b-a':'b-g'}">${k.status_ekonomi}</span></div>`).join('')}</div>`:''}`;document.getElementById('det-f').innerHTML=`<button class="btn btn-ghost" onclick="closeM('det')">Tutup</button>`;openM('det');}catch(e){toast(e.message,'err');}finally{ld(false);}}
async function detP(id){ld(true);try{const p=await rq(EP.p,'GET',null,'id='+id);const col=EC[p.status_ekonomi]||'#888';document.getElementById('det-h').innerHTML=`<div class="mico mi-g"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--green)" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div>${p.nama}`;document.getElementById('det-b').innerHTML=`<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px"><span class="badge ${p.status_ekonomi==='miskin'?'b-r':p.status_ekonomi==='rentan'?'b-a':'b-g'}">${(p.status_ekonomi||'').toUpperCase()}</span><span class="badge b-b">${p.profil_umur||'—'}</span><span class="badge ${p.kondisi_kesehatan==='sehat'?'b-g':'b-r'}">${p.kondisi_kesehatan||'sehat'}</span></div>${p.peringatan_pendidikan?`<div class="wbox" style="display:block;margin-bottom:14px">${p.peringatan_pendidikan}</div>`:''}<table class="dtbl"><tr><td>NIK</td><td style="font-family:var(--mono)">${p.nik||'—'}</td></tr><tr><td>Jenis Kelamin</td><td>${p.jenis_kelamin==='L'?'Laki-laki':'Perempuan'}</td></tr><tr><td>Tanggal Lahir</td><td>${p.tanggal_lahir||'—'} (${p.umur||'?'} tahun)</td></tr><tr><td>Status Keluarga</td><td>${(p.status_keluarga||'—').replace('_',' ')}</td></tr><tr><td>Status Perkawinan</td><td>${(p.status_perkawinan||'—').replace(/_/g,' ')}</td></tr><tr><td>Pekerjaan</td><td>${p.pekerjaan||'—'}</td></tr><tr><td>Penghasilan</td><td>Rp ${Number(p.penghasilan||0).toLocaleString('id-ID')}/bulan</td></tr><tr><td>Pendidikan</td><td>${(p.pendidikan_terakhir||'—').toUpperCase()} &middot; ${(p.status_pendidikan||'—').replace('_',' ')}</td></tr><tr><td>No. KK</td><td style="font-family:var(--mono)">${p.no_kk||'—'}</td></tr><tr><td>Alamat</td><td>${p.alamat_kk||'—'}${p.rt?' RT '+p.rt+'/RW '+p.rw:''}</td></tr><tr><td>Kelurahan</td><td>${p.kelurahan||'—'}, ${p.kecamatan||'—'}</td></tr></table>${p.histori_bantuan?.length?`<div style="font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.6px;margin:16px 0 8px">Histori Bantuan (${p.histori_bantuan.length}x)</div><div class="dklist">${p.histori_bantuan.map(h=>`<div class="dkrow"><span>${h.bantuan_nama}</span><div style="display:flex;align-items:center;gap:8px"><span style="font-family:var(--mono);font-size:10px;color:var(--t3)">${h.tanggal}</span><span class="badge ${h.status==='disalurkan'?'b-g':'b-a'}">${h.status}</span></div></div>`).join('')}</div>`:`<div style="font-size:12px;padding:10px;background:var(--rdim);border-radius:var(--rs);margin-top:14px;color:var(--red)">Belum pernah menerima bantuan apapun</div>`}${p.riwayat_pelatihan?.length?`<div style="font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.6px;margin:16px 0 8px">Riwayat Pelatihan</div><div class="dklist">${p.riwayat_pelatihan.map(r=>`<div class="dkrow"><span>${r.nama_pelatihan}</span><span style="font-family:var(--mono);font-size:10px;color:var(--t3)">${r.tanggal_ikut}</span></div>`).join('')}</div>`:''}`;const cW=['superadmin','pengurus','relawan'].includes(cu?.role);document.getElementById('det-f').innerHTML=`<button class="btn btn-ghost" onclick="closeM('det')">Tutup</button>${cW?`<button class="btn" onclick="closeM('det');openEditP(${p.id})">Edit Data</button>`:''}${cu?.role==='superadmin'?`<button class="btn btn-danger" onclick="delP(${p.id})">Hapus</button>`:''}`;openM('det');}catch(e){toast(e.message,'err');}finally{ld(false);}}
async function detKK(id){ld(true);try{const k=await rq(EP.kk,'GET',null,'id='+id);document.getElementById('det-h').innerHTML=`<div class="mico mi-b">${icoHome}</div>${k.nama_kk}`;document.getElementById('det-b').innerHTML=`<span class="badge ${k.status_ekonomi==='miskin'?'b-r':k.status_ekonomi==='rentan'?'b-a':'b-g'}" style="margin-bottom:14px;display:inline-block">${(k.status_ekonomi||'').toUpperCase()}</span><table class="dtbl"><tr><td>No. KK</td><td style="font-family:var(--mono)">${k.no_kk||'—'}</td></tr><tr><td>Alamat</td><td>${k.alamat||'—'} RT ${k.rt||'-'}/RW ${k.rw||'-'}</td></tr><tr><td>Kelurahan</td><td>${k.kelurahan||'—'}, ${k.kecamatan||'—'}</td></tr><tr><td>Rumah Ibadah</td><td>${k.ri_nama||'—'}</td></tr></table>${k.anggota?.length?`<div style="font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.6px;margin:16px 0 8px">Anggota (${k.anggota.length} jiwa)</div><div class="dklist">${k.anggota.map(a=>`<div class="dkrow"><span><strong>${a.nama}</strong> &middot; ${(a.status_keluarga||'').replace('_',' ')} &middot; ${a.umur}thn</span><span class="badge ${a.kondisi_kesehatan==='sehat'?'b-g':a.kondisi_kesehatan==='disabilitas'?'b-ro':'b-a'}">${a.kondisi_kesehatan||'sehat'}</span></div>`).join('')}</div>`:''}`;const cW=['superadmin','pengurus','relawan'].includes(cu?.role);document.getElementById('det-f').innerHTML=`<button class="btn btn-ghost" onclick="closeM('det')">Tutup</button>${cW?`<button class="btn btn-success" onclick="closeM('det');openAddP(${id})">+ Tambah Anggota</button>`:''}`;openM('det');}catch(e){toast(e.message,'err');}finally{ld(false);}}
async function detLap(id){const l=lapD.find(x=>x.id===id);if(!l)return;const cW=['superadmin','pengurus','relawan'].includes(cu?.role);document.getElementById('det-h').innerHTML=`<div class="mico mi-r">${icoWarn}</div>Laporan Masyarakat`;document.getElementById('det-b').innerHTML=`<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px"><span class="badge u-${l.urgensi}">${(l.urgensi||'').toUpperCase()}</span><span class="badge b-gray">${l.status}</span></div><table class="dtbl"><tr><td>Deskripsi</td><td style="line-height:1.7">${l.deskripsi||'—'}</td></tr><tr><td>Pelapor</td><td>${l.pelapor_nama||'Anonim'}${l.pelapor_telepon?' ('+l.pelapor_telepon+')':''}</td></tr><tr><td>Lokasi</td><td>${l.alamat_laporan||'—'}</td></tr><tr><td>Tanggal</td><td>${new Date(l.created_at).toLocaleDateString('id-ID',{weekday:'long',day:'numeric',month:'long',year:'numeric'})}</td></tr></table>${l.foto?`<img src="${l.foto}" style="width:100%;max-height:200px;object-fit:cover;border-radius:var(--rs);margin-top:14px">`:''}${cW?`<div style="margin-top:16px;padding-top:16px;border-top:1px solid var(--b)"><div style="font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.6px;margin-bottom:8px">Update Status</div><div style="display:flex;gap:7px;flex-wrap:wrap">${['diverifikasi','diproses','selesai'].map(s=>`<button class="btn btn-sm ${l.status===s?'btn-primary':''}" onclick="uLS(${l.id},'${s}',null);closeM('det');toast('Status diperbarui','ok')">${s}</button>`).join('')}</div></div>`:''}`;document.getElementById('det-f').innerHTML=`<button class="btn btn-ghost" onclick="closeM('det')">Tutup</button>`;openM('det');}
// FORMS
function openAddKK(){eKK=null;document.getElementById('mkk-t').textContent='Tambah Kartu Keluarga';['kk-nokk','kk-nama','kk-alamat','kk-rt','kk-rw','kk-kel','kk-kec','kk-lat','kk-lng'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';});document.getElementById('kk-se').value='rentan';const sel=document.getElementById('kk-ri');if(sel)sel.innerHTML='<option value="">-- Pilih --</option>'+riD.map(r=>`<option value="${r.id}">${r.nama}</option>`).join('');openM('kk');}
async function saveKK(){const no=document.getElementById('kk-nokk')?.value.trim(),nm=document.getElementById('kk-nama')?.value.trim();if(!no||!nm){toast('No KK dan nama wajib diisi','err');return;}if(no.length!==16){toast('No KK harus 16 digit','err');return;}const body={no_kk:no,nama_kk:nm,alamat:document.getElementById('kk-alamat')?.value||'',rt:document.getElementById('kk-rt')?.value||'',rw:document.getElementById('kk-rw')?.value||'',kelurahan:document.getElementById('kk-kel')?.value||'',kecamatan:document.getElementById('kk-kec')?.value||'',lat:parseFloat(document.getElementById('kk-lat')?.value)||null,lng:parseFloat(document.getElementById('kk-lng')?.value)||null,rumah_ibadah_id:document.getElementById('kk-ri')?.value||null,status_ekonomi:document.getElementById('kk-se')?.value||'rentan'};ld(true);try{if(eKK){await rq(EP.kk,'PUT',body,'id='+eKK);eKK=null;toast('KK diperbarui','ok');}else{await rq(EP.kk,'POST',body);toast('KK berhasil ditambahkan','ok');}closeM('kk');const kk=await rq(EP.kk);kkD=kk||[];fKK();rDash();}catch(e){toast(e.message,'err');}finally{ld(false);}}
function openAddP(kkId=null){eP=null;document.getElementById('mp-t').textContent='Tambah Data Penduduk';document.getElementById('p-nik').readOnly=false;['p-nik','p-nama','p-tgl','p-kerja','p-gaji','p-cat'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';});['p-jk','p-sk','p-sp','p-sh','p-se','p-pend','p-spend','p-kes'].forEach(id=>{const el=document.getElementById(id);if(!el)return;if(id==='p-sh')el.value='hidup';else if(id==='p-se')el.value='rentan';else if(id==='p-pend')el.value='tidak_sekolah';else if(id==='p-spend')el.value='tidak_sekolah';else if(id==='p-kes')el.value='sehat';else el.value=el.options[0]?.value;});document.getElementById('wp').style.display='none';const sel=document.getElementById('p-kk');if(sel)sel.innerHTML='<option value="">-- Pilih KK --</option>'+kkD.map(k=>`<option value="${k.id}"${k.id==kkId?' selected':''}>${k.nama_kk} (${k.no_kk})</option>`).join('');openM('p');}
async function openEditP(id){const p=pD.find(x=>x.id===id);if(!p){toast('Data tidak ditemukan','err');return;}eP=id;document.getElementById('mp-t').textContent='Edit Data Penduduk';document.getElementById('p-nik').value=p.nik||'';document.getElementById('p-nik').readOnly=true;document.getElementById('p-nama').value=p.nama||'';document.getElementById('p-tgl').value=p.tanggal_lahir||'';document.getElementById('p-jk').value=p.jenis_kelamin||'L';document.getElementById('p-sk').value=p.status_keluarga||'kepala';document.getElementById('p-sp').value=p.status_perkawinan||'belum_kawin';document.getElementById('p-sh').value=p.status_hidup||'hidup';document.getElementById('p-se').value=p.status_ekonomi||'rentan';document.getElementById('p-pend').value=p.pendidikan_terakhir||'tidak_sekolah';document.getElementById('p-spend').value=p.status_pendidikan||'tidak_sekolah';document.getElementById('p-kes').value=p.kondisi_kesehatan||'sehat';document.getElementById('p-kerja').value=p.pekerjaan||'';document.getElementById('p-gaji').value=p.penghasilan||0;document.getElementById('p-cat').value=p.catatan||'';const sel=document.getElementById('p-kk');if(sel)sel.innerHTML='<option value="">-- Pilih KK --</option>'+kkD.map(k=>`<option value="${k.id}"${k.id==p.keluarga_id?' selected':''}>${k.nama_kk} (${k.no_kk})</option>`).join('');cekPend();openM('p');}
async function saveP(){const nm=document.getElementById('p-nama')?.value.trim(),tgl=document.getElementById('p-tgl')?.value,kkid=document.getElementById('p-kk')?.value,nik=document.getElementById('p-nik')?.value.trim();if(!nm||!tgl||!kkid){toast('Nama, tanggal lahir, dan KK wajib diisi','err');return;}if(!eP&&(!nik||nik.length!==16)){toast('NIK harus 16 digit','err');return;}const body={nik,nama:nm,tanggal_lahir:tgl,jenis_kelamin:document.getElementById('p-jk')?.value||'L',status_keluarga:document.getElementById('p-sk')?.value||'kepala',status_perkawinan:document.getElementById('p-sp')?.value||'belum_kawin',status_hidup:document.getElementById('p-sh')?.value||'hidup',status_ekonomi:document.getElementById('p-se')?.value||'rentan',pendidikan_terakhir:document.getElementById('p-pend')?.value||'tidak_sekolah',status_pendidikan:document.getElementById('p-spend')?.value||'tidak_sekolah',kondisi_kesehatan:document.getElementById('p-kes')?.value||'sehat',pekerjaan:document.getElementById('p-kerja')?.value||'',penghasilan:parseFloat(document.getElementById('p-gaji')?.value||0),keluarga_id:parseInt(kkid),catatan:document.getElementById('p-cat')?.value||''};ld(true);try{let res;if(eP){res=await rq(EP.p,'PUT',body,'id='+eP);eP=null;toast('Data penduduk diperbarui','ok');}else{res=await rq(EP.p,'POST',body);toast('Penduduk berhasil ditambahkan','ok');}if(res?.peringatan)setTimeout(()=>toast('Perhatian: '+res.peringatan,'warn'),500);closeM('p');const pd=await rq(EP.p);pD=pd||[];rerender();rSB();}catch(e){toast(e.message,'err');}finally{ld(false);}}
async function delP(id){if(!confirm('Hapus data penduduk ini secara permanen?'))return;ld(true);try{await rq(EP.p,'DELETE',null,'id='+id);pD=pD.filter(p=>p.id!==id);closeM('det');rerender();rSB();toast('Data dihapus','ok');}catch(e){toast(e.message,'err');}finally{ld(false);}}
function cekPend(){const tgl=document.getElementById('p-tgl')?.value;if(!tgl)return;const umur=Math.floor((new Date()-new Date(tgl))/31557600000);const pend=document.getElementById('p-pend')?.value;const spend=document.getElementById('p-spend')?.value;const el=document.getElementById('wp');if(!el)return;let w='';if(umur>=7&&umur<=15&&spend==='tidak_sekolah'&&['tidak_sekolah','sd'].includes(pend))w=`Usia ${umur} tahun seharusnya masih bersekolah SD/SMP.`;else if(umur>=16&&umur<=18&&spend==='tidak_sekolah')w=`Usia ${umur} tahun seharusnya masih bersekolah SMA/SMK.`;el.style.display=w?'block':'none';el.textContent=w?'Perhatian: '+w:'';}
async function saveLap(){const desc=document.getElementById('lap-desc')?.value.trim();if(!desc){toast('Deskripsi laporan wajib diisi','err');return;}const fd=new FormData();fd.append('deskripsi',desc);fd.append('pelapor_nama',document.getElementById('lap-nama')?.value||'');fd.append('pelapor_telepon',document.getElementById('lap-telp')?.value||'');fd.append('alamat_laporan',document.getElementById('lap-alamat')?.value||'');fd.append('urgensi',document.getElementById('lap-urg')?.value||'sedang');const lat=document.getElementById('lap-lat')?.value;const lng=document.getElementById('lap-lng')?.value;if(lat)fd.append('lat',lat);if(lng)fd.append('lng',lng);const foto=document.getElementById('lap-foto')?.files?.[0];if(foto)fd.append('foto',foto);ld(true);try{const r=await fetch(EP.lap,{method:'POST',body:fd});const d=await r.json();if(!d.success)throw new Error(d.message);toast('Laporan berhasil dikirim. Terima kasih!','ok');closeM('lap');const lp=await rq(EP.lap);lapD=lp||[];rerender();checkAlerts();}catch(e){toast(e.message,'err');}finally{ld(false);}}
function checkAlerts(){const dar=lapD.filter(l=>l.urgensi==='darurat'&&l.status!=='selesai');document.getElementById('dlap').style.display=dar.length?'inline-block':'none';}
// MODAL
function openM(n){document.getElementById('modal-'+n)?.classList.add('open');}
function closeM(n){document.getElementById('modal-'+n)?.classList.remove('open');}
document.querySelectorAll('.overlay').forEach(o=>o.addEventListener('click',e=>{if(e.target===o)closeM(o.id.replace('modal-',''));}));
function toggleSB(){document.getElementById('sidebar')?.classList.toggle('off');}
document.getElementById('lp')?.addEventListener('keydown',e=>{if(e.key==='Enter')doLogin();});
document.getElementById('lu')?.addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('lp')?.focus();});
</script>
</body>
</html>
View File
+7
View File
@@ -0,0 +1,7 @@
# Block semua eksekusi PHP di folder uploads
<FilesMatch "\.php$">
Order allow,deny
Deny from all
</FilesMatch>
Options -Indexes