chore: prepare docker webgis deployment
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# WebGIS Poverty Mapping - Apache hardening
|
||||
|
||||
Options -Indexes
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# Test scripts are for local CLI verification only.
|
||||
# They must not be executable from a browser.
|
||||
RewriteRule ^tests(/|$) - [F,L]
|
||||
|
||||
# Runtime/session scratch data must not be browsable.
|
||||
RewriteRule ^tmp(/|$) - [F,L]
|
||||
|
||||
# Local agent/tooling metadata is not part of the application.
|
||||
RewriteRule ^\.claude(/|$) - [F,L]
|
||||
RewriteRule ^\.superpowers(/|$) - [F,L]
|
||||
</IfModule>
|
||||
@@ -0,0 +1,170 @@
|
||||
# WebGIS Poverty Mapping
|
||||
|
||||
Aplikasi utama Tugas Besar SIG untuk pemetaan warga miskin, rumah ibadah, kebutuhan bantuan, status bantuan, dan blank spot layanan berbasis Leaflet, PHP, dan MySQL/MariaDB.
|
||||
|
||||
Status audit terakhir: 2026-06-03. Kode aktif sudah memakai validasi koordinat bersama, CSRF untuk mutasi terautentikasi, session cookie hardening, scope operator, masking data publik, verifikasi warga sebelum workflow bantuan/kebutuhan, dan kebijakan import admin langsung `Terverifikasi`.
|
||||
|
||||
Catatan submit: project final ini sekarang dijalankan lewat Docker Compose dari root repository `webgis/`. Runtime utama memakai PHP 8.2 Apache dan MariaDB 11.4; database `db_webgis` otomatis dibuat dari `setup_database.sql` saat volume MariaDB masih kosong. SQL setup menggunakan syntax MariaDB-friendly seperti `ADD COLUMN IF NOT EXISTS`.
|
||||
|
||||
## Stack
|
||||
|
||||
- PHP procedural dengan MySQLi
|
||||
- MySQL/MariaDB
|
||||
- Leaflet untuk peta interaktif
|
||||
- Chart.js untuk statistik
|
||||
- Vanilla JavaScript modules
|
||||
- Docker Compose sebagai runtime utama
|
||||
- XAMPP/Laragon/WAMP hanya sebagai opsi legacy lokal
|
||||
|
||||
## Struktur Utama
|
||||
|
||||
```text
|
||||
WebgisPovertyMapping/
|
||||
├── index.php # Shell peta utama
|
||||
├── dashboard.php # Dashboard administrator
|
||||
├── config.php # Konfigurasi app, session, dan database
|
||||
├── koneksi.php # Koneksi DB + Haversine/proximity helper
|
||||
├── setup_database.sql # Schema database dan seed admin
|
||||
├── auth/ # Login, change password, session/RBAC helper
|
||||
├── api/ # Endpoint JSON/CSV/HTML per domain
|
||||
├── pages/ # Halaman admin/operator
|
||||
├── modules/ # JavaScript map modules
|
||||
├── css/ # CSS admin
|
||||
├── tests/ # Test PHP ad-hoc
|
||||
└── docs/ # Dokumentasi audit, deployment, dan superpowers plans/specs
|
||||
```
|
||||
|
||||
## Setup Lokal Dengan Docker
|
||||
|
||||
Jalankan dari root repository `webgis/`, bukan dari folder `WebgisPovertyMapping/`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build -d
|
||||
```
|
||||
|
||||
Akses aplikasi:
|
||||
|
||||
```text
|
||||
http://localhost:8080/WebgisPovertyMapping/
|
||||
```
|
||||
|
||||
Tambahkan `APP_PORT=8081` di file `.env` root jika port `8080` sudah dipakai. Database akan otomatis diinisialisasi oleh container MariaDB dari `WebgisPovertyMapping/setup_database.sql`.
|
||||
|
||||
Untuk reset database Docker dari awal:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml down -v
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build -d
|
||||
```
|
||||
|
||||
XAMPP tidak lagi menjadi jalur utama. Dokumentasi XAMPP tetap ada sebagai referensi legacy di `docs/deployment-xampp.md`.
|
||||
|
||||
## Akun Default
|
||||
|
||||
Default akun demo untuk penilaian:
|
||||
|
||||
```text
|
||||
Administrator: admin / password
|
||||
Operator : operator / password
|
||||
Viewer : viewer / password
|
||||
```
|
||||
|
||||
Untuk demo penilaian, akun tersebut tidak dipaksa mengganti password saat login pertama.
|
||||
|
||||
## Role
|
||||
|
||||
- `viewer`: akses publik/read-only, data sensitif penduduk dimasking.
|
||||
- `operator`: tambah/mengelola warga dan kebutuhan dalam rumah ibadah yang ditugaskan.
|
||||
- `administrator`: kelola semua data, rumah ibadah, pengguna, import/export, verifikasi, dan recalculation.
|
||||
|
||||
Publik tanpa login tetap bisa membuka peta sebagai `viewer` read-only. Dashboard admin tidak menawarkan pembuatan akun `viewer` baru karena akses publik tidak membutuhkan akun; role tersebut dipertahankan sebagai fallback RBAC dan kompatibilitas akun lama.
|
||||
|
||||
Operator harus dikaitkan ke satu `rumah_ibadah` melalui halaman manajemen user.
|
||||
|
||||
Model ownership warga saat ini adalah `proximity-only`: `penduduk_miskin.ibadah_id` dihitung dari radius rumah ibadah terdekat dan menjadi dasar scope operator. Jika posisi/radius rumah ibadah berubah, admin dapat menjalankan hitung ulang proximity dan assignment warga bisa berubah mengikuti hasil spasial terbaru.
|
||||
|
||||
## Fitur Utama
|
||||
|
||||
- Peta interaktif Pontianak berbasis Leaflet.
|
||||
- Layer rumah ibadah dengan radius layanan.
|
||||
- Layer penduduk miskin dengan proximity dan blank spot.
|
||||
- Status bantuan dan riwayat perubahan.
|
||||
- Kebutuhan bantuan per warga dan papan kebutuhan publik.
|
||||
- Dashboard statistik dan heatmap untuk operator/admin.
|
||||
- Import CSV dan export laporan.
|
||||
- RBAC berbasis session.
|
||||
- Proteksi data publik: hanya warga `Terverifikasi` yang tampil, PII dimasking, dan koordinat rumah warga tidak dikirim untuk pengunjung publik.
|
||||
- Workflow bantuan/kebutuhan hanya aktif untuk warga `Terverifikasi`.
|
||||
|
||||
## Dokumentasi Teknis
|
||||
|
||||
- `docs/codebase-navigation.md`: navigasi struktur repo, schema, endpoint, modul frontend, proximity, dan RBAC.
|
||||
- `docs/codebase-audit-report.md`: laporan proses bisnis, wiring backend/frontend, data lifecycle, catatan logika, prioritas fix, dan saran pengembangan.
|
||||
- `docs/business-process-sop.md`: SOP proses bisnis aktif untuk admin, operator, publik, dan donatur.
|
||||
- `docs/deployment-docker.md`: panduan deployment Docker/Coolify, reverse proxy, database container, dan troubleshooting.
|
||||
- `docs/deployment-xampp.md`: referensi legacy jika harus menjalankan aplikasi tanpa Docker.
|
||||
- `docs/audit-fix-task-plan.md`: task plan audit/hardening lintas fase.
|
||||
- `docs/superpowers/README.md`: indeks dokumen superpowers aktif.
|
||||
- `docs/superpowers/plans/2026-06-03-codebase-audit-refresh.md`: ringkasan audit implementasi terbaru.
|
||||
- `Tugas/` dan `DESIGN.md` adalah artifact lama/pendukung; gunakan dokumen di atas sebagai referensi implementasi aktif.
|
||||
|
||||
## Catatan Audit 2026-06-03
|
||||
|
||||
Yang sudah terlihat terpasang di kode:
|
||||
|
||||
- Validasi koordinat terpusat di `includes/validation.php` dan bounds wilayah studi di `config.php`.
|
||||
- Import CSV admin menyimpan warga sebagai `Terverifikasi` beserta `verified_by` dan `verified_at`.
|
||||
- Duplikasi NIK dicek terhadap semua record, termasuk arsip/inactive, agar validasi PHP sejalan dengan unique index database.
|
||||
- Endpoint kebutuhan dan status bantuan menolak warga yang belum `Terverifikasi`.
|
||||
- Statistik operator sudah scoped ke rumah ibadah operator.
|
||||
- Heatmap publik disembunyikan; heatmap menggunakan dataset internal untuk operator/admin.
|
||||
- User management mencegah self-demotion administrator dan operator ke rumah ibadah yang sudah dihapus.
|
||||
|
||||
Sisa pekerjaan yang masih disarankan:
|
||||
|
||||
- Rate limit session sederhana sudah aktif untuk form kontak donatur publik; pertimbangkan CAPTCHA atau rate limit IP untuk deploy publik serius.
|
||||
- Pertimbangkan model ownership `hybrid` jika tanggung jawab operasional operator harus dipisah dari hasil proximity spasial.
|
||||
- Perluas `tests/run_all.php` jika ingin mencakup semua test DB smoke test dalam satu runner.
|
||||
- Siapkan mirror lokal untuk CDN/tile provider bila deployment berada di jaringan tertutup.
|
||||
|
||||
## Test dan Verifikasi Dengan Docker
|
||||
|
||||
Lint semua file PHP:
|
||||
|
||||
```bash
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app sh -lc 'find /var/www/html/WebgisPovertyMapping -name "*.php" -print0 | xargs -0 -n1 php -l'
|
||||
```
|
||||
|
||||
Test ad-hoc statis yang aman dijalankan dari root project:
|
||||
|
||||
```bash
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/run_all.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_verifikasi_binding.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_update_posisi_scope.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_operator_scope_endpoints.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_public_verification_filters.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_frontend_module_loader.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_frontend_reliability_guards.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_no_legacy_poi.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_viewer_public_filters.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_operator_scope_strict.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_penduduk_binding_types.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_csrf_enforcement.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_session_cookie_security.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_operator_ibadah_highlight.php
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app php /var/www/html/WebgisPovertyMapping/tests/test_viewer_account_ui_hidden.php
|
||||
```
|
||||
|
||||
Test yang memuat `../config.php` atau `../koneksi.php` perlu dijalankan dari folder `tests`:
|
||||
|
||||
```bash
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app sh -lc 'cd /var/www/html/WebgisPovertyMapping/tests && php test_auth_helper.php'
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app sh -lc 'cd /var/www/html/WebgisPovertyMapping/tests && php test_db.php'
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app sh -lc 'cd /var/www/html/WebgisPovertyMapping/tests && php test_ibadah.php'
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app sh -lc 'cd /var/www/html/WebgisPovertyMapping/tests && php test_kebutuhan.php'
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app sh -lc 'cd /var/www/html/WebgisPovertyMapping/tests && php test_penduduk.php'
|
||||
docker compose -f ../docker-compose.yml -f ../docker-compose.local.yml exec app sh -lc 'cd /var/www/html/WebgisPovertyMapping/tests && php test_proximity.php'
|
||||
```
|
||||
|
||||
Beberapa test membutuhkan container database yang sudah sehat. Jika menjalankan mode XAMPP legacy, sesuaikan command PHP dan port database mengikuti `docs/deployment-xampp.md`.
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
// api/auth/change_password.php — POST: ganti password akun sendiri
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('viewer');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$new_password = $_POST['new_password'] ?? '';
|
||||
$confirm = $_POST['confirm_password'] ?? '';
|
||||
|
||||
// Validasi policy: min 8 karakter, kombinasi huruf + angka
|
||||
if (strlen($new_password) < 8) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Password minimal 8 karakter']);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('/[a-zA-Z]/', $new_password) || !preg_match('/[0-9]/', $new_password)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Password harus mengandung huruf dan angka']);
|
||||
exit;
|
||||
}
|
||||
if ($new_password !== $confirm) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Konfirmasi password tidak cocok']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$hash = password_hash($new_password, PASSWORD_BCRYPT);
|
||||
$user_id = get_user_id();
|
||||
|
||||
$stmt = $conn->prepare("UPDATE users SET password = ?, must_change_password = 0 WHERE id = ?");
|
||||
$stmt->bind_param('si', $hash, $user_id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$_SESSION['must_change_password'] = 0;
|
||||
echo json_encode(['status' => 'success', 'message' => 'Password berhasil diubah']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan password']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
// api/auth/login.php — POST: validasi kredensial + buat sesi
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = trim($_POST['password'] ?? '');
|
||||
|
||||
if (!$username || !$password) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Username dan password wajib diisi']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ambil user dari DB (prepared statement — SQL injection safe)
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT id, nama_lengkap, password, role, ibadah_id, is_active,
|
||||
must_change_password, login_attempts, locked_until
|
||||
FROM users WHERE username = ? LIMIT 1"
|
||||
);
|
||||
$stmt->bind_param('s', $username);
|
||||
$stmt->execute();
|
||||
$user = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if (!$user) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Username atau password salah']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$user['is_active']) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akun dinonaktifkan. Hubungi Administrator.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Cek lockout
|
||||
if ($user['locked_until'] && strtotime($user['locked_until']) > time()) {
|
||||
$menit = ceil((strtotime($user['locked_until']) - time()) / 60);
|
||||
echo json_encode(['status' => 'error', 'message' => "Akun terkunci. Coba lagi dalam {$menit} menit."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verifikasi password
|
||||
if (!password_verify($password, $user['password'])) {
|
||||
$attempts = $user['login_attempts'] + 1;
|
||||
|
||||
if ($attempts >= MAX_LOGIN_ATTEMPTS) {
|
||||
$locked_until = date('Y-m-d H:i:s', time() + LOCKOUT_MINUTES * 60);
|
||||
$stmt = $conn->prepare("UPDATE users SET login_attempts = ?, locked_until = ? WHERE id = ?");
|
||||
$stmt->bind_param('isi', $attempts, $locked_until, $user['id']);
|
||||
} else {
|
||||
$stmt = $conn->prepare("UPDATE users SET login_attempts = ? WHERE id = ?");
|
||||
$stmt->bind_param('ii', $attempts, $user['id']);
|
||||
}
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
$sisa = MAX_LOGIN_ATTEMPTS - $attempts;
|
||||
$msg = $sisa > 0
|
||||
? "Username atau password salah. Sisa percobaan: {$sisa}"
|
||||
: "Akun terkunci " . LOCKOUT_MINUTES . " menit karena terlalu banyak percobaan gagal.";
|
||||
echo json_encode(['status' => 'error', 'message' => $msg]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Login berhasil — reset attempts
|
||||
$stmt = $conn->prepare("UPDATE users SET login_attempts = 0, locked_until = NULL WHERE id = ?");
|
||||
$stmt->bind_param('i', $user['id']);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
// Rotate session ID sebelum menulis data (cegah session fixation)
|
||||
session_regenerate_id(true);
|
||||
|
||||
// Buat sesi
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['username'] = $username;
|
||||
$_SESSION['nama'] = $user['nama_lengkap'];
|
||||
$_SESSION['role'] = $user['role'];
|
||||
$_SESSION['ibadah_id'] = $user['ibadah_id'];
|
||||
$_SESSION['must_change_password'] = (int)$user['must_change_password'];
|
||||
$_SESSION['last_activity'] = time();
|
||||
|
||||
// Redirect target: admin → dashboard, operator/viewer → map
|
||||
$redirect = ($user['role'] === 'administrator') ? '../dashboard.php' : '../index.php';
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'redirect' => $redirect,
|
||||
'data' => [
|
||||
'user_id' => $user['id'],
|
||||
'nama' => $user['nama_lengkap'],
|
||||
'role' => $user['role'],
|
||||
'ibadah_id' => $user['ibadah_id'],
|
||||
'must_change_password'=> (bool)$user['must_change_password'],
|
||||
]
|
||||
]);
|
||||
|
||||
$conn->close();
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
// api/auth/logout.php — Hancurkan sesi dan redirect ke login
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
|
||||
session_destroy();
|
||||
header('Location: ../../auth/login.php');
|
||||
exit;
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/**
|
||||
* api/dashboard/trend.php
|
||||
* Hitung tren persen cakupan 6 bulan terakhir.
|
||||
* Metode: untuk tiap bulan, query penduduk yang created_at <= akhir bulan itu
|
||||
* (kumulatif), lalu hitung % jiwa terjangkau vs total jiwa.
|
||||
*/
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Build last 6 months (oldest first)
|
||||
$months = [];
|
||||
for ($i = 5; $i >= 0; $i--) {
|
||||
$year = (int) date('Y');
|
||||
$month = (int) date('n') - $i;
|
||||
while ($month <= 0) { $month += 12; $year--; }
|
||||
while ($month > 12) { $month -= 12; $year++; }
|
||||
|
||||
$last_day = date('Y-m-d', mktime(0, 0, 0, $month + 1, 0, $year)); // last day of month
|
||||
|
||||
$month_names = ['Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agt','Sep','Okt','Nov','Des'];
|
||||
$months[] = [
|
||||
'label' => $month_names[$month - 1] . ' ' . $year,
|
||||
'end_dt' => $last_day,
|
||||
];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT
|
||||
COALESCE(SUM(jumlah_jiwa), 0) AS total_jiwa,
|
||||
COALESCE(SUM(CASE WHEN is_blank_spot = 0 THEN jumlah_jiwa ELSE 0 END), 0) AS jiwa_terjangkau
|
||||
FROM penduduk_miskin
|
||||
WHERE is_active = 1
|
||||
AND deleted_at IS NULL
|
||||
AND created_at <= ?"
|
||||
);
|
||||
|
||||
foreach ($months as $m) {
|
||||
$end = $m['end_dt'];
|
||||
$stmt->bind_param('s', $end);
|
||||
$stmt->execute();
|
||||
$r = $stmt->get_result()->fetch_assoc();
|
||||
|
||||
$total = (int) ($r['total_jiwa'] ?? 0);
|
||||
$terjangkau = (int) ($r['jiwa_terjangkau'] ?? 0);
|
||||
$pct = $total > 0 ? round($terjangkau / $total * 100, 2) : 0;
|
||||
|
||||
$rows[] = [
|
||||
'bulan' => $m['label'],
|
||||
'total_jiwa' => $total,
|
||||
'jiwa_terjangkau' => $terjangkau,
|
||||
'pct_cakupan' => $pct,
|
||||
];
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $rows]);
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
|
||||
$ibadah_id = (int)($_GET['ibadah_id'] ?? 0);
|
||||
if (!$ibadah_id) {
|
||||
http_response_code(400);
|
||||
die('ibadah_id diperlukan');
|
||||
}
|
||||
|
||||
$r = $conn->prepare("SELECT nama FROM rumah_ibadah WHERE id=? AND deleted_at IS NULL");
|
||||
$r->bind_param('i', $ibadah_id);
|
||||
$r->execute();
|
||||
$row = $r->get_result()->fetch_assoc();
|
||||
$r->close();
|
||||
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
die('Rumah ibadah tidak ditemukan');
|
||||
}
|
||||
|
||||
$nama_ibadah = preg_replace('/[^a-zA-Z0-9\-_]/', '_', $row['nama']);
|
||||
$tanggal = date('Y-m-d');
|
||||
$filename = "binaan_{$nama_ibadah}_{$tanggal}.csv";
|
||||
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header("Content-Disposition: attachment; filename=\"$filename\"");
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
|
||||
function csv_safe_cell($value): string {
|
||||
$text = (string)($value ?? '');
|
||||
$trimmed = ltrim($text);
|
||||
if ($trimmed !== '' && in_array($trimmed[0], ['=', '+', '-', '@'], true)) {
|
||||
return "'" . $text;
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
fputcsv($out, ['nama_kk', 'jumlah_jiwa', 'kategori', 'alamat', 'status_bantuan', 'catatan']);
|
||||
|
||||
$stmt = $conn->prepare("
|
||||
SELECT nama_kk, jumlah_jiwa, kategori, alamat, status_bantuan, catatan
|
||||
FROM penduduk_miskin
|
||||
WHERE ibadah_id=? AND is_active=1 AND deleted_at IS NULL
|
||||
ORDER BY nama_kk
|
||||
");
|
||||
$stmt->bind_param('i', $ibadah_id);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
while ($w = $res->fetch_assoc()) {
|
||||
fputcsv($out, [
|
||||
csv_safe_cell($w['nama_kk']),
|
||||
csv_safe_cell($w['jumlah_jiwa']),
|
||||
csv_safe_cell($w['kategori']),
|
||||
csv_safe_cell($w['alamat'] ?? ''),
|
||||
csv_safe_cell($w['status_bantuan']),
|
||||
csv_safe_cell($w['catatan'] ?? ''),
|
||||
]);
|
||||
}
|
||||
$stmt->close();
|
||||
fclose($out);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
|
||||
$ibadah_id = (int)($_GET['ibadah_id'] ?? 0);
|
||||
if (!$ibadah_id) { http_response_code(400); die('ibadah_id diperlukan'); }
|
||||
|
||||
$r = $conn->prepare("SELECT * FROM rumah_ibadah WHERE id=? AND deleted_at IS NULL");
|
||||
$r->bind_param('i', $ibadah_id);
|
||||
$r->execute();
|
||||
$ib = $r->get_result()->fetch_assoc();
|
||||
$r->close();
|
||||
if (!$ib) { http_response_code(404); die('Tidak ditemukan'); }
|
||||
|
||||
$s = $conn->prepare("
|
||||
SELECT COUNT(*) AS jml_kk,
|
||||
COALESCE(SUM(jumlah_jiwa), 0) AS jml_jiwa,
|
||||
COALESCE(SUM(CASE WHEN status_bantuan='Sudah Ditangani' THEN 1 ELSE 0 END), 0) AS ditangani
|
||||
FROM penduduk_miskin
|
||||
WHERE ibadah_id=? AND is_active=1 AND deleted_at IS NULL
|
||||
");
|
||||
$s->bind_param('i', $ibadah_id);
|
||||
$s->execute();
|
||||
$st = $s->get_result()->fetch_assoc();
|
||||
$s->close();
|
||||
|
||||
$pct = $st['jml_kk'] > 0 ? round($st['ditangani'] / $st['jml_kk'] * 100) : 0;
|
||||
|
||||
$w = $conn->prepare("
|
||||
SELECT nama_kk, jumlah_jiwa, kategori, status_bantuan
|
||||
FROM penduduk_miskin
|
||||
WHERE ibadah_id=? AND is_active=1 AND deleted_at IS NULL
|
||||
ORDER BY nama_kk
|
||||
");
|
||||
$w->bind_param('i', $ibadah_id);
|
||||
$w->execute();
|
||||
$warga_res = $w->get_result();
|
||||
$warga_list = [];
|
||||
while ($row = $warga_res->fetch_assoc()) $warga_list[] = $row;
|
||||
$w->close();
|
||||
$conn->close();
|
||||
|
||||
$tanggal = date('d F Y');
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Laporan Binaan — <?= htmlspecialchars($ib['nama']) ?></title>
|
||||
<style>
|
||||
* { box-sizing:border-box; margin:0; padding:0; }
|
||||
body { font-family: Arial, sans-serif; font-size: 12px; color: #111; padding: 20px; }
|
||||
.header { border-bottom: 2px solid #333; margin-bottom: 16px; padding-bottom: 10px; }
|
||||
.header h1 { font-size: 16px; margin-bottom: 4px; }
|
||||
.header p { font-size: 11px; color: #555; margin-top: 2px; }
|
||||
.cards { display: flex; gap: 16px; margin-bottom: 16px; }
|
||||
.card { border: 1px solid #ddd; border-radius: 6px; padding: 10px 16px; text-align: center; flex: 1; }
|
||||
.card .val { font-size: 22px; font-weight: 700; color: #1a1a1a; }
|
||||
.card .lbl { font-size: 10px; color: #777; margin-top: 2px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; font-size: 11px; }
|
||||
th { background: #f0f0f0; font-weight: 700; }
|
||||
tr:nth-child(even) td { background: #fafafa; }
|
||||
.status-done { color: #166534; font-weight: 700; }
|
||||
.status-process { color: #92400e; font-weight: 700; }
|
||||
.status-none { color: #6b7280; }
|
||||
.footer { margin-top: 20px; font-size: 10px; color: #999; text-align: right; }
|
||||
@media print { body { padding: 0; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Laporan Warga Binaan — <?= htmlspecialchars($ib['nama']) ?></h1>
|
||||
<p><?= htmlspecialchars($ib['jenis']) ?> · <?= htmlspecialchars($ib['alamat'] ?? '-') ?></p>
|
||||
<p>Digenerate: <?= $tanggal ?></p>
|
||||
</div>
|
||||
<div class="cards">
|
||||
<div class="card"><div class="val"><?= $st['jml_kk'] ?></div><div class="lbl">Total KK</div></div>
|
||||
<div class="card"><div class="val"><?= $st['jml_jiwa']?></div><div class="lbl">Total Jiwa</div></div>
|
||||
<div class="card"><div class="val"><?= $pct ?>%</div> <div class="lbl">Sudah Ditangani</div></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>#</th><th>Nama KK</th><th>Jiwa</th><th>Kategori</th><th>Status Bantuan</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($warga_list as $i => $warga): ?>
|
||||
<?php $cls = match($warga['status_bantuan']) {
|
||||
'Sudah Ditangani' => 'status-done',
|
||||
'Dalam Proses' => 'status-process',
|
||||
default => 'status-none',
|
||||
}; ?>
|
||||
<tr>
|
||||
<td><?= $i + 1 ?></td>
|
||||
<td><?= htmlspecialchars($warga['nama_kk']) ?></td>
|
||||
<td><?= $warga['jumlah_jiwa'] ?></td>
|
||||
<td><?= $warga['kategori'] ?></td>
|
||||
<td class="<?= $cls ?>"><?= $warga['status_bantuan'] ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer"><?= APP_NAME ?> · <?= $tanggal ?></div>
|
||||
<script>window.onload = () => window.print();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
// Public viewer boleh akses — tidak perlu require_auth
|
||||
|
||||
// Hanya tampilkan warga yang sudah Terverifikasi di agregat publik
|
||||
$sql = "
|
||||
SELECT ri.id, ri.nama, ri.jenis, ri.alamat, ri.lat, ri.lng,
|
||||
ri.radius_m, ri.kontak, ri.created_at, ri.updated_at,
|
||||
COUNT(pm.id) AS total_kk,
|
||||
COALESCE(SUM(pm.jumlah_jiwa), 0) AS total_jiwa,
|
||||
COALESCE(SUM(CASE WHEN pm.is_blank_spot = 0 THEN pm.jumlah_jiwa ELSE 0 END), 0) AS jiwa_terjangkau,
|
||||
COALESCE(SUM(CASE WHEN pm.is_blank_spot = 1 THEN pm.jumlah_jiwa ELSE 0 END), 0) AS jiwa_blankspot
|
||||
FROM rumah_ibadah ri
|
||||
LEFT JOIN penduduk_miskin pm
|
||||
ON pm.ibadah_id = ri.id
|
||||
AND pm.is_active = 1
|
||||
AND pm.deleted_at IS NULL
|
||||
AND pm.status_verifikasi = 'Terverifikasi'
|
||||
WHERE ri.deleted_at IS NULL
|
||||
GROUP BY ri.id
|
||||
ORDER BY ri.created_at DESC
|
||||
";
|
||||
|
||||
$result = $conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) $data[] = $row;
|
||||
|
||||
echo json_encode(['status' => 'success', 'total' => count($data), 'data' => $data]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if (!$id) { echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); exit; }
|
||||
|
||||
$active_stmt = $conn->prepare("SELECT id FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$active_stmt->bind_param('i', $id);
|
||||
$active_stmt->execute();
|
||||
$active_row = $active_stmt->get_result()->fetch_assoc();
|
||||
$active_stmt->close();
|
||||
if (!$active_row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah dihapus']); exit;
|
||||
}
|
||||
|
||||
// Cek operator yang terkait
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT nama_lengkap FROM users WHERE ibadah_id = ? AND is_active = 1 LIMIT 5"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$ops_result = $stmt->get_result();
|
||||
$operators = [];
|
||||
while ($op = $ops_result->fetch_assoc()) $operators[] = $op['nama_lengkap'];
|
||||
$stmt->close();
|
||||
|
||||
// Jika belum konfirmasi dan ada operator terkait, kembalikan warning
|
||||
$confirmed = ($_POST['confirmed'] ?? '0') === '1';
|
||||
if (!$confirmed && count($operators) > 0) {
|
||||
echo json_encode([
|
||||
'status' => 'warning',
|
||||
'operators' => $operators,
|
||||
'message' => 'Rumah ibadah ini dikaitkan dengan akun Operator: ' . implode(', ', $operators)
|
||||
. '. Operator tidak akan bisa login sampai dikaitkan ke rumah ibadah lain.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$conn->begin_transaction();
|
||||
try {
|
||||
// Soft delete
|
||||
$stmt = $conn->prepare("UPDATE rumah_ibadah SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL");
|
||||
if (!$stmt) {
|
||||
throw new RuntimeException('prepare delete failed: ' . $conn->error);
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
throw new RuntimeException('delete failed: ' . $stmt->error);
|
||||
}
|
||||
$deleted = $stmt->affected_rows > 0;
|
||||
$stmt->close();
|
||||
|
||||
if (!$deleted) {
|
||||
$conn->rollback();
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah dihapus']);
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
$recalc = $conn->query("SELECT id, lat, lng FROM penduduk_miskin WHERE is_active=1 AND deleted_at IS NULL");
|
||||
if (!$recalc) {
|
||||
throw new RuntimeException('read penduduk failed: ' . $conn->error);
|
||||
}
|
||||
while ($warga = $recalc->fetch_assoc()) {
|
||||
_recalc_proximity($conn, $warga['id'], $warga['lat'], $warga['lng']);
|
||||
}
|
||||
$conn->commit();
|
||||
echo json_encode(['status' => 'success', 'message' => 'Rumah ibadah berhasil dihapus']);
|
||||
} catch (Throwable $e) {
|
||||
$conn->rollback();
|
||||
error_log('ibadah/hapus transaction failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menghapus rumah ibadah.']);
|
||||
}
|
||||
|
||||
function _recalc_proximity($conn, $penduduk_id, $lat, $lng) {
|
||||
$p = calc_proximity($conn, (float)$lat, (float)$lng);
|
||||
$s = $conn->prepare(
|
||||
"UPDATE penduduk_miskin SET ibadah_id=?, jarak_m=?, is_blank_spot=? WHERE id=?"
|
||||
);
|
||||
if (!$s) {
|
||||
throw new RuntimeException('prepare recalc failed: ' . $conn->error);
|
||||
}
|
||||
$s->bind_param('idii', $p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'], $penduduk_id);
|
||||
if (!$s->execute()) {
|
||||
throw new RuntimeException('recalc update failed: ' . $s->error);
|
||||
}
|
||||
$s->close();
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$conn->begin_transaction();
|
||||
try {
|
||||
$warga_list = $conn->query(
|
||||
"SELECT id, lat, lng FROM penduduk_miskin WHERE is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
if (!$warga_list) {
|
||||
throw new RuntimeException('read penduduk failed: ' . $conn->error);
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
while ($warga = $warga_list->fetch_assoc()) {
|
||||
$p = calc_proximity($conn, (float)$warga['lat'], (float)$warga['lng']);
|
||||
$s = $conn->prepare(
|
||||
"UPDATE penduduk_miskin SET ibadah_id=?, jarak_m=?, is_blank_spot=? WHERE id=?"
|
||||
);
|
||||
if (!$s) {
|
||||
throw new RuntimeException('prepare recalc failed: ' . $conn->error);
|
||||
}
|
||||
$s->bind_param('idii', $p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'], $warga['id']);
|
||||
if (!$s->execute()) {
|
||||
throw new RuntimeException('recalc update failed: ' . $s->error);
|
||||
}
|
||||
$s->close();
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'updated' => $updated,
|
||||
'message' => "$updated data penduduk berhasil diperbarui",
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$conn->rollback();
|
||||
error_log('ibadah/recalculate transaction failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menghitung ulang proximity.']);
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$nama = trim($_POST['nama'] ?? '');
|
||||
$jenis = trim($_POST['jenis'] ?? 'Masjid');
|
||||
$alamat = trim($_POST['alamat'] ?? '');
|
||||
$kontak = trim($_POST['kontak'] ?? '');
|
||||
$coord = validate_lat_lng($_POST['lat'] ?? null, $_POST['lng'] ?? null);
|
||||
if (!$coord['ok']) {
|
||||
echo json_encode(['status' => 'error', 'message' => $coord['message']]); exit;
|
||||
}
|
||||
$lat = $coord['lat'];
|
||||
$lng = $coord['lng'];
|
||||
$radius = (int) ($_POST['radius_m'] ?? 500);
|
||||
|
||||
if (!$nama) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama ibadah tidak boleh kosong']); exit;
|
||||
}
|
||||
if ($radius < 100 || $radius > 5000) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Radius minimum 100 meter, maksimum 5.000 meter']); exit;
|
||||
}
|
||||
|
||||
$valid_jenis = ['Masjid','Mushola','Gereja','Pura','Vihara','Klenteng'];
|
||||
if (!in_array($jenis, $valid_jenis)) $jenis = 'Masjid';
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO rumah_ibadah (nama, jenis, alamat, lat, lng, radius_m, kontak) VALUES (?,?,?,?,?,?,?)"
|
||||
);
|
||||
$stmt->bind_param('sssddis', $nama, $jenis, $alamat, $lat, $lng, $radius, $kontak);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Data berhasil disimpan',
|
||||
'data' => ['id'=>$conn->insert_id,'nama'=>$nama,'jenis'=>$jenis,
|
||||
'alamat'=>$alamat,'kontak'=>$kontak,'lat'=>$lat,'lng'=>$lng,'radius_m'=>$radius]]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: '.$stmt->error]);
|
||||
}
|
||||
$stmt->close(); $conn->close();
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$nama = trim( $_POST['nama'] ?? '');
|
||||
$jenis = trim( $_POST['jenis'] ?? 'Masjid');
|
||||
$alamat = trim( $_POST['alamat'] ?? '');
|
||||
$kontak = trim( $_POST['kontak'] ?? '');
|
||||
$coord = validate_lat_lng($_POST['lat'] ?? null, $_POST['lng'] ?? null);
|
||||
if (!$coord['ok']) {
|
||||
echo json_encode(['status' => 'error', 'message' => $coord['message']]);
|
||||
$conn->close(); exit;
|
||||
}
|
||||
$lat = $coord['lat'];
|
||||
$lng = $coord['lng'];
|
||||
$radius = (int) ($_POST['radius_m'] ?? 500);
|
||||
|
||||
$valid_jenis = ['Masjid', 'Mushola', 'Gereja', 'Pura', 'Vihara', 'Klenteng'];
|
||||
|
||||
if (!$id) { echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); exit; }
|
||||
if (!$nama) { echo json_encode(['status' => 'error', 'message' => 'Nama wajib diisi']); exit; }
|
||||
if (!in_array($jenis, $valid_jenis)) { echo json_encode(['status' => 'error', 'message' => 'Jenis tidak valid']); exit; }
|
||||
if ($radius < 100 || $radius > 5000) { echo json_encode(['status' => 'error', 'message' => 'Radius minimum 100 meter, maksimum 5.000 meter']); exit; }
|
||||
|
||||
$active_stmt = $conn->prepare("SELECT id FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$active_stmt->bind_param('i', $id);
|
||||
$active_stmt->execute();
|
||||
$active_row = $active_stmt->get_result()->fetch_assoc();
|
||||
$active_stmt->close();
|
||||
if (!$active_row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah dihapus']);
|
||||
$conn->close(); exit;
|
||||
}
|
||||
|
||||
$conn->begin_transaction();
|
||||
try {
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE rumah_ibadah SET nama=?, jenis=?, alamat=?, kontak=?, lat=?, lng=?, radius_m=? WHERE id=? AND deleted_at IS NULL"
|
||||
);
|
||||
if (!$stmt) {
|
||||
throw new RuntimeException('prepare update failed: ' . $conn->error);
|
||||
}
|
||||
$stmt->bind_param('ssssddii', $nama, $jenis, $alamat, $kontak, $lat, $lng, $radius, $id);
|
||||
if (!$stmt->execute()) {
|
||||
throw new RuntimeException('update failed: ' . $stmt->error);
|
||||
}
|
||||
$changed = $stmt->affected_rows > 0;
|
||||
$stmt->close();
|
||||
|
||||
// Recalculate proximity for all active penduduk (radius may have changed)
|
||||
if ($changed) {
|
||||
$warga = $conn->query("SELECT id, lat, lng FROM penduduk_miskin WHERE is_active=1 AND deleted_at IS NULL");
|
||||
if (!$warga) {
|
||||
throw new RuntimeException('read penduduk failed: ' . $conn->error);
|
||||
}
|
||||
while ($w = $warga->fetch_assoc()) {
|
||||
if ($w['lat'] === null || $w['lng'] === null) continue;
|
||||
$p = calc_proximity($conn, (float)$w['lat'], (float)$w['lng']);
|
||||
$s = $conn->prepare("UPDATE penduduk_miskin SET ibadah_id=?, jarak_m=?, is_blank_spot=? WHERE id=?");
|
||||
if (!$s) {
|
||||
throw new RuntimeException('prepare recalc failed: ' . $conn->error);
|
||||
}
|
||||
$s->bind_param('idii', $p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'], $w['id']);
|
||||
if (!$s->execute()) {
|
||||
throw new RuntimeException('recalc update failed: ' . $s->error);
|
||||
}
|
||||
$s->close();
|
||||
}
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
echo json_encode(['status' => 'success', 'message' => 'Rumah ibadah berhasil diperbarui']);
|
||||
} catch (Throwable $e) {
|
||||
$conn->rollback();
|
||||
error_log('ibadah/update transaction failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal memproses perubahan rumah ibadah.']);
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
// api/ibadah/update_kontak.php — Update kontak rumah ibadah
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$kontak = trim($_POST['kontak'] ?? '');
|
||||
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$active_stmt = $conn->prepare("SELECT id FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$active_stmt->bind_param('i', $id);
|
||||
$active_stmt->execute();
|
||||
$active_row = $active_stmt->get_result()->fetch_assoc();
|
||||
$active_stmt->close();
|
||||
if (!$active_row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah dihapus']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("UPDATE rumah_ibadah SET kontak = ? WHERE id = ? AND deleted_at IS NULL");
|
||||
$stmt->bind_param('si', $kontak, $id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Kontak berhasil diperbarui']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal mengupdate kontak: ' . $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$coord = validate_lat_lng($_POST['lat'] ?? null, $_POST['lng'] ?? null);
|
||||
if (!$coord['ok']) {
|
||||
echo json_encode(['status' => 'error', 'message' => $coord['message']]);
|
||||
exit;
|
||||
}
|
||||
$lat = $coord['lat'];
|
||||
$lng = $coord['lng'];
|
||||
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$active_stmt = $conn->prepare("SELECT id FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$active_stmt->bind_param('i', $id);
|
||||
$active_stmt->execute();
|
||||
$active_row = $active_stmt->get_result()->fetch_assoc();
|
||||
$active_stmt->close();
|
||||
if (!$active_row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah dihapus']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$conn->begin_transaction();
|
||||
try {
|
||||
$stmt = $conn->prepare("UPDATE rumah_ibadah SET lat = ?, lng = ? WHERE id = ? AND deleted_at IS NULL");
|
||||
if (!$stmt) {
|
||||
throw new RuntimeException('prepare update failed: ' . $conn->error);
|
||||
}
|
||||
$stmt->bind_param('ddi', $lat, $lng, $id);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
throw new RuntimeException('update failed: ' . $stmt->error);
|
||||
}
|
||||
$changed = $stmt->affected_rows > 0;
|
||||
$stmt->close();
|
||||
|
||||
// Recalculate proximity for all active penduduk
|
||||
if ($changed) {
|
||||
$warga_list = $conn->query(
|
||||
"SELECT id, lat, lng FROM penduduk_miskin WHERE is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
if (!$warga_list) {
|
||||
throw new RuntimeException('read penduduk failed: ' . $conn->error);
|
||||
}
|
||||
while ($warga = $warga_list->fetch_assoc()) {
|
||||
$p = calc_proximity($conn, (float)$warga['lat'], (float)$warga['lng']);
|
||||
$s = $conn->prepare("UPDATE penduduk_miskin SET ibadah_id=?, jarak_m=?, is_blank_spot=? WHERE id=?");
|
||||
if (!$s) {
|
||||
throw new RuntimeException('prepare recalc failed: ' . $conn->error);
|
||||
}
|
||||
$s->bind_param('idii', $p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'], $warga['id']);
|
||||
if (!$s->execute()) {
|
||||
throw new RuntimeException('recalc update failed: ' . $s->error);
|
||||
}
|
||||
$s->close();
|
||||
}
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
echo json_encode(['status' => 'success', 'message' => 'Posisi berhasil diperbarui']);
|
||||
} catch (Throwable $e) {
|
||||
$conn->rollback();
|
||||
error_log('ibadah/update_posisi transaction failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal memproses perubahan posisi.']);
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$radius = (int) ($_POST['radius_m'] ?? 500);
|
||||
|
||||
if (!$id) { echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); exit; }
|
||||
|
||||
if ($radius < 100 || $radius > 5000) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Radius minimum 100 meter, maksimum 5.000 meter']); exit;
|
||||
}
|
||||
|
||||
$active_stmt = $conn->prepare("SELECT id FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$active_stmt->bind_param('i', $id);
|
||||
$active_stmt->execute();
|
||||
$active_row = $active_stmt->get_result()->fetch_assoc();
|
||||
$active_stmt->close();
|
||||
if (!$active_row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah dihapus']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$conn->begin_transaction();
|
||||
try {
|
||||
$stmt = $conn->prepare("UPDATE rumah_ibadah SET radius_m = ? WHERE id = ? AND deleted_at IS NULL");
|
||||
if (!$stmt) {
|
||||
throw new RuntimeException('prepare update failed: ' . $conn->error);
|
||||
}
|
||||
$stmt->bind_param('ii', $radius, $id);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
throw new RuntimeException('update failed: ' . $stmt->error);
|
||||
}
|
||||
$changed = $stmt->affected_rows > 0;
|
||||
$stmt->close();
|
||||
|
||||
// Recalculate proximity for all active penduduk
|
||||
if ($changed) {
|
||||
$warga_list = $conn->query(
|
||||
"SELECT id, lat, lng FROM penduduk_miskin WHERE is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
if (!$warga_list) {
|
||||
throw new RuntimeException('read penduduk failed: ' . $conn->error);
|
||||
}
|
||||
while ($warga = $warga_list->fetch_assoc()) {
|
||||
$p = calc_proximity($conn, (float)$warga['lat'], (float)$warga['lng']);
|
||||
$s = $conn->prepare("UPDATE penduduk_miskin SET ibadah_id=?, jarak_m=?, is_blank_spot=? WHERE id=?");
|
||||
if (!$s) {
|
||||
throw new RuntimeException('prepare recalc failed: ' . $conn->error);
|
||||
}
|
||||
$s->bind_param('idii', $p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'], $warga['id']);
|
||||
if (!$s->execute()) {
|
||||
throw new RuntimeException('recalc update failed: ' . $s->error);
|
||||
}
|
||||
$s->close();
|
||||
}
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
echo json_encode(['status' => 'success', 'message' => 'Radius berhasil diperbarui', 'radius_m' => $radius]);
|
||||
} catch (Throwable $e) {
|
||||
$conn->rollback();
|
||||
error_log('ibadah/update_radius transaction failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal memproses perubahan radius.']);
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$mode = trim($_POST['mode'] ?? 'preview'); // 'preview' | 'import'
|
||||
$max_rows = 500;
|
||||
|
||||
$file = $_FILES['csv_file'] ?? null;
|
||||
if (!$file || empty($file['tmp_name'])) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'File CSV tidak ditemukan']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Upload CSV gagal']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$max_bytes = 2 * 1024 * 1024;
|
||||
if (($file['size'] ?? 0) <= 0 || $file['size'] > $max_bytes) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Ukuran CSV maksimal 2 MB']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION));
|
||||
if ($ext !== 'csv') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'File harus berformat .csv']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$handle = fopen($file['tmp_name'], 'r');
|
||||
if (!$handle) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal membuka file']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Deteksi dan lewati BOM UTF-8
|
||||
$bom = fread($handle, 3);
|
||||
if ($bom !== "\xEF\xBB\xBF") rewind($handle);
|
||||
|
||||
$header = fgetcsv($handle);
|
||||
if (!$header) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'File kosong atau format salah']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$header = array_map(fn($h) => strtolower(trim($h)), $header);
|
||||
$required_cols = ['nama_kk', 'jumlah_jiwa', 'kategori', 'lat', 'lng'];
|
||||
foreach ($required_cols as $col) {
|
||||
if (!in_array($col, $header)) {
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => "Kolom '$col' tidak ditemukan. Gunakan template yang disediakan.",
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$col = array_flip($header);
|
||||
|
||||
$rows_ok = [];
|
||||
$rows_error = [];
|
||||
$row_num = 1;
|
||||
|
||||
while (($raw = fgetcsv($handle)) !== false) {
|
||||
$row_num++;
|
||||
if ($row_num > $max_rows + 1) {
|
||||
$rows_error[] = ['row' => $row_num, 'error' => 'Batas 500 baris terlampaui'];
|
||||
continue;
|
||||
}
|
||||
if (count($raw) < count($header)) {
|
||||
$rows_error[] = ['row' => $row_num, 'error' => 'Jumlah kolom tidak sesuai'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$get = fn($name) => isset($col[$name]) ? trim($raw[$col[$name]] ?? '') : '';
|
||||
|
||||
$nama_kk = $get('nama_kk');
|
||||
$nik = $get('nik');
|
||||
$jumlah_jiwa = max(1, (int)$get('jumlah_jiwa'));
|
||||
$kategori = $get('kategori');
|
||||
$alamat = $get('alamat');
|
||||
$catatan = $get('catatan');
|
||||
|
||||
$lat_raw = $get('lat');
|
||||
$lng_raw = $get('lng');
|
||||
|
||||
$errors = [];
|
||||
if (!$nama_kk) $errors[] = 'nama_kk kosong';
|
||||
|
||||
$coord = validate_lat_lng($lat_raw, $lng_raw);
|
||||
if (!$coord['ok']) {
|
||||
$errors[] = $coord['message'];
|
||||
} else {
|
||||
$lat = $coord['lat'];
|
||||
$lng = $coord['lng'];
|
||||
}
|
||||
|
||||
$valid_kat = ['Sangat Miskin', 'Miskin', 'Hampir Miskin'];
|
||||
if (!in_array($kategori, $valid_kat)) {
|
||||
if ($kategori === '') $kategori = 'Miskin';
|
||||
else $errors[] = "kategori '$kategori' tidak valid";
|
||||
}
|
||||
|
||||
if ($nik !== '' && !preg_match('/^\d{16}$/', $nik)) {
|
||||
$errors[] = 'NIK harus 16 digit jika diisi';
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
$rows_error[] = ['row' => $row_num, 'nama_kk' => $nama_kk, 'error' => implode('; ', $errors)];
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows_ok[] = compact('nama_kk', 'nik', 'jumlah_jiwa', 'kategori', 'alamat', 'catatan', 'lat', 'lng');
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
if ($mode === 'preview') {
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'total_ok' => count($rows_ok),
|
||||
'total_error' => count($rows_error),
|
||||
'preview' => array_slice($rows_ok, 0, 10),
|
||||
'errors' => $rows_error,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Mode import: INSERT semua baris valid
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
$status_verif = 'Terverifikasi';
|
||||
$verified_by = get_user_id();
|
||||
$verified_at = date('Y-m-d H:i:s');
|
||||
|
||||
foreach ($rows_ok as $r) {
|
||||
// Cek duplikat NIK pada semua data, termasuk arsip/inactive.
|
||||
if ($r['nik'] !== '') {
|
||||
$chk = $conn->prepare(
|
||||
"SELECT id FROM penduduk_miskin WHERE nik=? LIMIT 1"
|
||||
);
|
||||
$chk->bind_param('s', $r['nik']);
|
||||
$chk->execute();
|
||||
if ($chk->get_result()->num_rows > 0) {
|
||||
$skipped++;
|
||||
$chk->close();
|
||||
continue;
|
||||
}
|
||||
$chk->close();
|
||||
}
|
||||
|
||||
// PRD-correct proximity (sama dengan simpan.php)
|
||||
$p = calc_proximity($conn, $r['lat'], $r['lng']);
|
||||
|
||||
$nik_val = $r['nik'] !== '' ? $r['nik'] : null;
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO penduduk_miskin
|
||||
(nama_kk, nik, jumlah_jiwa, kategori, alamat, catatan, lat, lng, ibadah_id, jarak_m, is_blank_spot,
|
||||
status_verifikasi, verified_by, verified_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->bind_param(
|
||||
'ssisssddidisis',
|
||||
$r['nama_kk'], $nik_val, $r['jumlah_jiwa'], $r['kategori'],
|
||||
$r['alamat'], $r['catatan'], $r['lat'], $r['lng'],
|
||||
$p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'],
|
||||
$status_verif, $verified_by, $verified_at
|
||||
);
|
||||
if ($stmt->execute()) $imported++;
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'imported' => $imported,
|
||||
'skipped' => $skipped,
|
||||
'message' => "$imported baris berhasil diimport sebagai Terverifikasi, $skipped dilewati (NIK pernah terdaftar/duplikat)",
|
||||
]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_auth('administrator');
|
||||
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="template_penduduk_miskin.csv"');
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
fwrite($out, "\xEF\xBB\xBF"); // BOM UTF-8 agar Excel membaca dengan benar
|
||||
|
||||
fputcsv($out, ['nama_kk', 'nik', 'jumlah_jiwa', 'kategori', 'alamat', 'catatan', 'lat', 'lng']);
|
||||
fputcsv($out, ['Budi Santoso', '3271011234567890', '4', 'Miskin', 'Jl. Merdeka No. 1', 'Rumah semi permanen', '-0.0557', '109.3487']);
|
||||
fputcsv($out, ['Siti Rahayu', '', '2', 'Sangat Miskin', 'Jl. Damai No. 5', '', '-0.0560', '109.3490']);
|
||||
fclose($out);
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('operator');
|
||||
|
||||
$penduduk_id = (int)($_GET['penduduk_id'] ?? 0);
|
||||
if ($penduduk_id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Parameter tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Operator hanya boleh lihat kebutuhan warga dalam ibadah-nya
|
||||
if (!has_role('administrator')) {
|
||||
$op_ibadah = get_ibadah_id();
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT ibadah_id FROM penduduk_miskin
|
||||
WHERE id=? AND is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('i', $penduduk_id);
|
||||
$stmt->execute();
|
||||
$r = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
if (!$r || (int)$r['ibadah_id'] !== (int)$op_ibadah) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akses ditolak.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("
|
||||
SELECT k.id, k.kategori, k.deskripsi, k.status, k.created_at,
|
||||
u.nama_lengkap AS created_by_nama
|
||||
FROM kebutuhan k
|
||||
LEFT JOIN users u ON u.id = k.created_by
|
||||
WHERE k.penduduk_id = ?
|
||||
ORDER BY k.created_at DESC
|
||||
");
|
||||
$stmt->bind_param('i', $penduduk_id);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
$rows = [];
|
||||
while ($r = $res->fetch_assoc()) $rows[] = $r;
|
||||
$stmt->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $rows]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('operator');
|
||||
require_csrf();
|
||||
|
||||
$penduduk_id = (int)($_POST['penduduk_id'] ?? 0);
|
||||
$kategori = trim($_POST['kategori'] ?? '');
|
||||
$deskripsi = trim($_POST['deskripsi'] ?? '');
|
||||
|
||||
$valid_kategori = ['Sembako','Biaya Sekolah','Biaya Kesehatan','Modal Usaha',
|
||||
'Renovasi Rumah','Perlengkapan Rumah','Pakaian','Lainnya'];
|
||||
|
||||
if ($penduduk_id <= 0 || !in_array($kategori, $valid_kategori, true)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Parameter tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verifikasi akses warga
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT ibadah_id, status_verifikasi FROM penduduk_miskin
|
||||
WHERE id=? AND is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('i', $penduduk_id);
|
||||
$stmt->execute();
|
||||
$warga = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if (!$warga) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data warga tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!has_role('administrator')) {
|
||||
$op_ibadah = get_ibadah_id();
|
||||
if ($warga['ibadah_id'] === null || (int)$warga['ibadah_id'] !== (int)$op_ibadah) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Anda tidak memiliki akses untuk warga ini.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if ($warga['status_verifikasi'] !== 'Terverifikasi') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data warga belum terverifikasi']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$user_id = (int)$_SESSION['user_id'];
|
||||
$deskripsi = $deskripsi !== '' ? mb_substr($deskripsi, 0, 300) : null;
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO kebutuhan (penduduk_id, kategori, deskripsi, created_by) VALUES (?,?,?,?)"
|
||||
);
|
||||
$stmt->bind_param('issi', $penduduk_id, $kategori, $deskripsi, $user_id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['status' => 'success', 'id' => $conn->insert_id]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: ' . $stmt->error]);
|
||||
}
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('operator');
|
||||
require_csrf();
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$status = trim($_POST['status'] ?? '');
|
||||
$catatan = trim($_POST['catatan'] ?? '');
|
||||
|
||||
$valid_status = ['Belum Terpenuhi', 'Dalam Proses', 'Terpenuhi'];
|
||||
if ($id <= 0 || !in_array($status, $valid_status, true)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Parameter tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ambil kebutuhan + ibadah warga untuk cek akses
|
||||
$stmt = $conn->prepare("
|
||||
SELECT k.id, k.status AS status_lama, pm.ibadah_id, pm.status_verifikasi
|
||||
FROM kebutuhan k
|
||||
JOIN penduduk_miskin pm ON pm.id = k.penduduk_id
|
||||
AND pm.is_active = 1
|
||||
AND pm.deleted_at IS NULL
|
||||
WHERE k.id = ?
|
||||
");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if (!$row) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!has_role('administrator')) {
|
||||
$op_ibadah = get_ibadah_id();
|
||||
if ($row['ibadah_id'] === null || (int)$row['ibadah_id'] !== (int)$op_ibadah) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akses ditolak.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if ($row['status_verifikasi'] !== 'Terverifikasi') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data warga belum terverifikasi']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$user_id = (int)$_SESSION['user_id'];
|
||||
$old_status = $row['status_lama'];
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE kebutuhan SET status=?, updated_by=?, updated_at=NOW() WHERE id=?"
|
||||
);
|
||||
$stmt->bind_param('sii', $status, $user_id, $id);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO riwayat_kebutuhan (kebutuhan_id, operator_id, status_lama, status_baru, catatan)
|
||||
VALUES (?,?,?,?,?)"
|
||||
);
|
||||
$catatan_val = $catatan !== '' ? $catatan : null;
|
||||
$stmt->bind_param('iisss', $id, $user_id, $old_status, $status, $catatan_val);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'status_baru' => $status]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if (!is_logged_in()) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$is_admin = has_role('administrator');
|
||||
$is_op = has_role('operator');
|
||||
$items = [];
|
||||
|
||||
// ── Admin only ───────────────────────────────────────────────────────
|
||||
if ($is_admin) {
|
||||
$r = $conn->query("
|
||||
SELECT COUNT(*) AS n FROM penduduk_miskin
|
||||
WHERE is_active=1 AND deleted_at IS NULL AND status_verifikasi='Pending'
|
||||
");
|
||||
if ($r && ($n = (int)$r->fetch_assoc()['n'])) {
|
||||
$items[] = [
|
||||
'type' => 'pending_verif',
|
||||
'label' => "$n penduduk menunggu verifikasi",
|
||||
'count' => $n,
|
||||
'page' => 'pages/penduduk.php',
|
||||
'icon' => 'clock',
|
||||
];
|
||||
}
|
||||
|
||||
$de = $conn->query(
|
||||
"SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='kontak_donatur' LIMIT 1"
|
||||
)->num_rows > 0;
|
||||
if ($de) {
|
||||
$r = $conn->query("SELECT COUNT(*) AS n FROM kontak_donatur WHERE is_read=0");
|
||||
if ($r && ($n = (int)$r->fetch_assoc()['n'])) {
|
||||
$items[] = [
|
||||
'type' => 'donatur',
|
||||
'label' => "$n pesan donatur belum dibaca",
|
||||
'count' => $n,
|
||||
'page' => 'pages/kebutuhan.php',
|
||||
'icon' => 'mail',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin + Operator ────────────────────────────────────────────────
|
||||
if ($is_admin || $is_op) {
|
||||
$scope = '';
|
||||
if ($is_op && !$is_admin) {
|
||||
$iid = (int)get_ibadah_id();
|
||||
$scope = $iid > 0 ? " AND pm.ibadah_id = $iid" : " AND 1=0";
|
||||
}
|
||||
|
||||
$ke = $conn->query(
|
||||
"SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='kebutuhan' LIMIT 1"
|
||||
)->num_rows > 0;
|
||||
if ($ke) {
|
||||
$r = $conn->query("
|
||||
SELECT COUNT(*) AS n FROM kebutuhan k
|
||||
JOIN penduduk_miskin pm
|
||||
ON pm.id = k.penduduk_id AND pm.is_active=1 AND pm.deleted_at IS NULL
|
||||
WHERE k.status = 'Belum Terpenuhi' $scope
|
||||
");
|
||||
if ($r && ($n = (int)$r->fetch_assoc()['n'])) {
|
||||
$items[] = [
|
||||
'type' => 'kebutuhan',
|
||||
'label' => "$n kebutuhan belum terpenuhi",
|
||||
'count' => $n,
|
||||
'page' => 'pages/kebutuhan.php',
|
||||
'icon' => 'heart',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$r = $conn->query("
|
||||
SELECT COUNT(*) AS n FROM penduduk_miskin pm
|
||||
WHERE pm.is_active=1 AND pm.deleted_at IS NULL
|
||||
AND pm.status_verifikasi = 'Terverifikasi'
|
||||
AND pm.status_bantuan = 'Belum Ditangani'
|
||||
$scope
|
||||
");
|
||||
if ($r && ($n = (int)$r->fetch_assoc()['n'])) {
|
||||
$items[] = [
|
||||
'type' => 'belum_ditangani',
|
||||
'label' => "$n data belum ditangani",
|
||||
'count' => $n,
|
||||
'page' => 'pages/status-bantuan.php',
|
||||
'icon' => 'clipboard-list',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'items' => $items,
|
||||
'total' => array_sum(array_column($items, 'count')),
|
||||
]);
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
// Publik — tidak perlu auth
|
||||
// Agregasi kebutuhan Belum Terpenuhi per kategori × rumah ibadah (area)
|
||||
|
||||
$rows = $conn->query("
|
||||
SELECT
|
||||
k.kategori,
|
||||
COALESCE(ri.nama, 'Di luar radius ibadah') AS area,
|
||||
COUNT(DISTINCT k.penduduk_id) AS jumlah_kk
|
||||
FROM kebutuhan k
|
||||
JOIN penduduk_miskin pm ON pm.id = k.penduduk_id
|
||||
AND pm.is_active = 1
|
||||
AND pm.deleted_at IS NULL
|
||||
AND pm.status_verifikasi = 'Terverifikasi'
|
||||
LEFT JOIN rumah_ibadah ri ON ri.id = pm.ibadah_id AND ri.deleted_at IS NULL
|
||||
WHERE k.status = 'Belum Terpenuhi'
|
||||
GROUP BY k.kategori, pm.ibadah_id, ri.nama
|
||||
ORDER BY jumlah_kk DESC, k.kategori ASC
|
||||
");
|
||||
|
||||
$data = [];
|
||||
while ($r = $rows->fetch_assoc()) {
|
||||
$data[] = [
|
||||
'kategori' => $r['kategori'],
|
||||
'area' => $r['area'],
|
||||
'jumlah_kk' => (int)$r['jumlah_kk'],
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $data]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('administrator');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
require_csrf();
|
||||
|
||||
$action = trim($_POST['action'] ?? 'mark_all');
|
||||
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
|
||||
|
||||
if ($action === 'hapus') {
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']);
|
||||
$conn->close(); exit;
|
||||
}
|
||||
$stmt = $conn->prepare("DELETE FROM kontak_donatur WHERE id=?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
echo json_encode(['status' => 'success']);
|
||||
$conn->close(); exit;
|
||||
}
|
||||
|
||||
if ($action === 'mark_one') {
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']);
|
||||
$conn->close(); exit;
|
||||
}
|
||||
$stmt = $conn->prepare("UPDATE kontak_donatur SET is_read=1 WHERE id=?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
echo json_encode(['status' => 'success']);
|
||||
$conn->close(); exit;
|
||||
}
|
||||
|
||||
// mark_all — default, also handles legacy calls without action param
|
||||
$conn->query("UPDATE kontak_donatur SET is_read=1 WHERE is_read=0");
|
||||
echo json_encode(['status' => 'success', 'marked_read' => $conn->affected_rows]);
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
$rows = $conn->query("
|
||||
SELECT id, nama, kontak, kategori_minat, pesan, is_read, created_at
|
||||
FROM kontak_donatur
|
||||
ORDER BY is_read ASC, created_at DESC
|
||||
LIMIT 100
|
||||
");
|
||||
|
||||
$data = [];
|
||||
while ($r = $rows->fetch_assoc()) {
|
||||
$data[] = $r;
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $data]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$rate_window = 60;
|
||||
$client_ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$rate_dir = dirname(__DIR__, 2) . '/tmp/donor-rate-limit';
|
||||
if (!is_dir($rate_dir)) {
|
||||
@mkdir($rate_dir, 0775, true);
|
||||
}
|
||||
$rate_file = $rate_dir . '/' . hash('sha256', $client_ip) . '.txt';
|
||||
$last_submit = max(
|
||||
(int)($_SESSION['donor_last_submit'] ?? 0),
|
||||
is_file($rate_file) ? (int)filemtime($rate_file) : 0
|
||||
);
|
||||
if ($last_submit > 0 && ($now - $last_submit) < $rate_window) {
|
||||
http_response_code(429);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => 'Terlalu sering mengirim. Coba lagi dalam 1 menit.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$nama = trim($_POST['nama'] ?? '');
|
||||
$kontak = trim($_POST['kontak'] ?? '');
|
||||
$kategori_minat = trim($_POST['kategori_minat'] ?? '');
|
||||
$pesan = trim($_POST['pesan'] ?? '');
|
||||
|
||||
if (!$nama || !$kontak) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama dan kontak wajib diisi.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$valid_kategori = ['Sembako','Biaya Sekolah','Biaya Kesehatan','Modal Usaha',
|
||||
'Renovasi Rumah','Perlengkapan Rumah','Pakaian','Lainnya'];
|
||||
$kat = in_array($kategori_minat, $valid_kategori, true) ? $kategori_minat : null;
|
||||
$pesan_val = $pesan !== '' ? mb_substr($pesan, 0, 1000) : null;
|
||||
$nama_safe = mb_substr($nama, 0, 150);
|
||||
$kontak_safe = mb_substr($kontak, 0, 150);
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO kontak_donatur (nama, kontak, kategori_minat, pesan) VALUES (?,?,?,?)"
|
||||
);
|
||||
$stmt->bind_param('ssss', $nama_safe, $kontak_safe, $kat, $pesan_val);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$_SESSION['donor_last_submit'] = time();
|
||||
@file_put_contents($rate_file, (string)$_SESSION['donor_last_submit'], LOCK_EX);
|
||||
echo json_encode(['status' => 'success']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan.']);
|
||||
}
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
$is_authenticated = is_logged_in();
|
||||
$is_privileged = $is_authenticated && has_role('operator');
|
||||
$role = $is_authenticated ? get_role() : 'viewer';
|
||||
$is_admin = $is_authenticated && has_role('administrator');
|
||||
$can_see_sensitive = $is_privileged;
|
||||
$can_see_coords = $is_privileged;
|
||||
$public_verif_sql = $is_privileged ? "" : " AND pm.status_verifikasi = 'Terverifikasi'";
|
||||
|
||||
// Publik: hanya data Terverifikasi
|
||||
// Operator: semua data ibadahnya sendiri (semua status)
|
||||
// Admin: semua data tanpa filter
|
||||
$operator_scope_sql = '';
|
||||
|
||||
if ($is_privileged && !$is_admin) {
|
||||
$linked_ibadah_id = get_ibadah_id();
|
||||
$operator_scope_sql = $linked_ibadah_id
|
||||
? ' AND pm.ibadah_id = ' . (int)$linked_ibadah_id
|
||||
: ' AND 1 = 0';
|
||||
// operator lihat semua status miliknya (termasuk Pending/Ditolak)
|
||||
}
|
||||
|
||||
// Cek apakah tabel kebutuhan sudah ada (migrasi F14 sudah dijalankan)
|
||||
$kebutuhan_exists = $conn->query(
|
||||
"SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'kebutuhan' LIMIT 1"
|
||||
)->num_rows > 0;
|
||||
|
||||
if ($kebutuhan_exists) {
|
||||
$sql = "
|
||||
SELECT
|
||||
pm.id, pm.nama_kk, pm.nik, pm.kategori, pm.alamat, pm.catatan,
|
||||
pm.jumlah_jiwa, pm.lat, pm.lng, pm.ibadah_id, pm.jarak_m,
|
||||
pm.is_blank_spot, pm.status_bantuan, pm.status_verifikasi, pm.created_at,
|
||||
ri.nama AS nama_ibadah,
|
||||
ri.jenis AS jenis_ibadah,
|
||||
COALESCE(kstat.kebutuhan_open, 0) AS kebutuhan_open,
|
||||
COALESCE(kstat.kebutuhan_proses, 0) AS kebutuhan_proses,
|
||||
COALESCE(kstat.kebutuhan_terpenuhi,0) AS kebutuhan_terpenuhi,
|
||||
kstat.kebutuhan_kategori_open
|
||||
FROM penduduk_miskin pm
|
||||
LEFT JOIN rumah_ibadah ri ON pm.ibadah_id = ri.id AND ri.deleted_at IS NULL
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
penduduk_id,
|
||||
SUM(CASE WHEN status='Belum Terpenuhi' THEN 1 ELSE 0 END) AS kebutuhan_open,
|
||||
SUM(CASE WHEN status='Dalam Proses' THEN 1 ELSE 0 END) AS kebutuhan_proses,
|
||||
SUM(CASE WHEN status='Terpenuhi' THEN 1 ELSE 0 END) AS kebutuhan_terpenuhi,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN status='Belum Terpenuhi' THEN kategori ELSE NULL END
|
||||
ORDER BY kategori SEPARATOR ',') AS kebutuhan_kategori_open
|
||||
FROM kebutuhan
|
||||
GROUP BY penduduk_id
|
||||
) kstat ON kstat.penduduk_id = pm.id
|
||||
WHERE pm.is_active = 1 AND pm.deleted_at IS NULL {$public_verif_sql} {$operator_scope_sql}
|
||||
ORDER BY pm.created_at DESC
|
||||
";
|
||||
} else {
|
||||
$sql = "
|
||||
SELECT
|
||||
pm.id, pm.nama_kk, pm.nik, pm.kategori, pm.alamat, pm.catatan,
|
||||
pm.jumlah_jiwa, pm.lat, pm.lng, pm.ibadah_id, pm.jarak_m,
|
||||
pm.is_blank_spot, pm.status_bantuan, pm.status_verifikasi, pm.created_at,
|
||||
ri.nama AS nama_ibadah,
|
||||
ri.jenis AS jenis_ibadah,
|
||||
0 AS kebutuhan_open, 0 AS kebutuhan_proses,
|
||||
0 AS kebutuhan_terpenuhi, NULL AS kebutuhan_kategori_open
|
||||
FROM penduduk_miskin pm
|
||||
LEFT JOIN rumah_ibadah ri ON pm.ibadah_id = ri.id AND ri.deleted_at IS NULL
|
||||
WHERE pm.is_active = 1 AND pm.deleted_at IS NULL {$public_verif_sql} {$operator_scope_sql}
|
||||
ORDER BY pm.created_at DESC
|
||||
";
|
||||
}
|
||||
|
||||
$result = $conn->query($sql);
|
||||
|
||||
if (!$result) {
|
||||
error_log('penduduk/ambil query failed: ' . $conn->error);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal memuat data penduduk.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if (!$can_see_sensitive) {
|
||||
// Publik tidak boleh lihat data pribadi
|
||||
$row['nik'] = null;
|
||||
$row['nama_kk'] = null;
|
||||
$row['alamat'] = null;
|
||||
$row['catatan'] = null;
|
||||
}
|
||||
if (!$can_see_coords) {
|
||||
// Publik tidak boleh lihat koordinat rumah warga
|
||||
$row['lat'] = null;
|
||||
$row['lng'] = null;
|
||||
}
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'success', 'total' => count($data), 'data' => $data]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('administrator');
|
||||
|
||||
// Return inactive (is_active=0) AND soft-deleted warga for admin audit
|
||||
$stmt = $conn->prepare("
|
||||
SELECT pm.id, pm.nama_kk, pm.jumlah_jiwa, pm.kategori, pm.status_bantuan,
|
||||
pm.is_active, pm.deleted_at,
|
||||
ri.nama AS nama_ibadah,
|
||||
(SELECT COUNT(*) FROM riwayat_bantuan WHERE penduduk_id = pm.id) AS jml_riwayat
|
||||
FROM penduduk_miskin pm
|
||||
LEFT JOIN rumah_ibadah ri ON ri.id = pm.ibadah_id
|
||||
WHERE pm.is_active = 0 OR pm.deleted_at IS NOT NULL
|
||||
ORDER BY COALESCE(pm.deleted_at, pm.updated_at) DESC
|
||||
LIMIT 200
|
||||
");
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
$rows = [];
|
||||
while ($r = $res->fetch_assoc()) $rows[] = $r;
|
||||
$stmt->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $rows]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE penduduk_miskin SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Data berhasil dihapus']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah dihapus']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE penduduk_miskin SET is_active = 0 WHERE id = ? AND is_active = 1 AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Warga berhasil dinonaktifkan']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau sudah tidak aktif']);
|
||||
}
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('operator');
|
||||
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Operator hanya boleh lihat riwayat warga dalam ibadah-nya
|
||||
if (!has_role('administrator')) {
|
||||
$op_ibadah = get_ibadah_id();
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT ibadah_id FROM penduduk_miskin
|
||||
WHERE id=? AND is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$r = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
if (!$r || (int)$r['ibadah_id'] !== (int)$op_ibadah) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akses ditolak.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("
|
||||
SELECT r.id, r.status_lama, r.status_baru, r.catatan, r.created_at,
|
||||
u.nama_lengkap AS user_nama
|
||||
FROM riwayat_bantuan r
|
||||
LEFT JOIN users u ON u.id = r.operator_id
|
||||
WHERE r.penduduk_id = ?
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 20
|
||||
");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
$rows = [];
|
||||
while ($r = $res->fetch_assoc()) $rows[] = $r;
|
||||
$stmt->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $rows]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('operator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$nama_kk = trim($_POST['nama_kk'] ?? '');
|
||||
$nik = trim($_POST['nik'] ?? '');
|
||||
$jumlah_jiwa = max(1, (int)($_POST['jumlah_jiwa'] ?? 1));
|
||||
$kategori = trim($_POST['kategori'] ?? 'Miskin');
|
||||
$alamat = trim($_POST['alamat'] ?? '');
|
||||
$catatan = trim($_POST['catatan'] ?? '');
|
||||
$coord = validate_lat_lng($_POST['lat'] ?? null, $_POST['lng'] ?? null);
|
||||
if (!$coord['ok']) {
|
||||
echo json_encode(['status' => 'error', 'message' => $coord['message']]);
|
||||
exit;
|
||||
}
|
||||
$lat = $coord['lat'];
|
||||
$lng = $coord['lng'];
|
||||
|
||||
if (!$nama_kk) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama Kepala Keluarga tidak boleh kosong']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$valid_kategori = ['Sangat Miskin', 'Miskin', 'Hampir Miskin'];
|
||||
if (!in_array($kategori, $valid_kategori)) $kategori = 'Miskin';
|
||||
|
||||
// Validasi & dedup NIK
|
||||
$nik_val = $nik !== '' ? $nik : null;
|
||||
if ($nik_val !== null) {
|
||||
if (!preg_match('/^\d{16}$/', $nik_val)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'NIK harus 16 digit angka']);
|
||||
exit;
|
||||
}
|
||||
$stmt_nik = $conn->prepare(
|
||||
"SELECT id, nama_kk FROM penduduk_miskin
|
||||
WHERE nik = ? LIMIT 1"
|
||||
);
|
||||
$stmt_nik->bind_param('s', $nik_val);
|
||||
$stmt_nik->execute();
|
||||
$existing = $stmt_nik->get_result()->fetch_assoc();
|
||||
$stmt_nik->close();
|
||||
if ($existing) {
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => "NIK {$nik_val} pernah terdaftar atas nama {$existing['nama_kk']}. Cek arsip sebelum menambah ulang.",
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// PRD-correct proximity calculation
|
||||
$p = calc_proximity($conn, $lat, $lng);
|
||||
|
||||
if (!has_role('administrator')) {
|
||||
$op_ibadah_id = get_ibadah_id();
|
||||
if (!$op_ibadah_id) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => 'Operator belum dikaitkan dengan rumah ibadah'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
if (empty($p['ibadah_id']) || (int)$p['ibadah_id'] !== $op_ibadah_id) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => 'Lokasi warga berada di luar wilayah rumah ibadah operator'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Admin langsung Terverifikasi; operator masuk Pending untuk direview
|
||||
$status_verif = has_role('administrator') ? 'Terverifikasi' : 'Pending';
|
||||
$verified_by = has_role('administrator') ? get_user_id() : null;
|
||||
$verified_at = has_role('administrator') ? date('Y-m-d H:i:s') : null;
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO penduduk_miskin
|
||||
(nama_kk, nik, jumlah_jiwa, kategori, alamat, catatan, lat, lng,
|
||||
ibadah_id, jarak_m, is_blank_spot, status_verifikasi, verified_by, verified_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->bind_param(
|
||||
'ssisssddidisis',
|
||||
$nama_kk, $nik_val, $jumlah_jiwa, $kategori, $alamat, $catatan,
|
||||
$lat, $lng, $p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'],
|
||||
$status_verif, $verified_by, $verified_at
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$new_id = $conn->insert_id;
|
||||
|
||||
$nama_ibadah = null;
|
||||
$jenis_ibadah = null;
|
||||
if ($p['ibadah_id']) {
|
||||
$s2 = $conn->prepare("SELECT nama, jenis FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL");
|
||||
$s2->bind_param('i', $p['ibadah_id']);
|
||||
$s2->execute();
|
||||
$ri = $s2->get_result()->fetch_assoc();
|
||||
$s2->close();
|
||||
if ($ri) { $nama_ibadah = $ri['nama']; $jenis_ibadah = $ri['jenis']; }
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Data berhasil disimpan',
|
||||
'data' => [
|
||||
'id' => $new_id,
|
||||
'nama_kk' => $nama_kk,
|
||||
'nik' => $nik_val,
|
||||
'kategori' => $kategori,
|
||||
'alamat' => $alamat,
|
||||
'catatan' => $catatan,
|
||||
'jumlah_jiwa' => $jumlah_jiwa,
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'ibadah_id' => $p['ibadah_id'],
|
||||
'jarak_m' => $p['jarak_m'],
|
||||
'is_blank_spot' => $p['is_blank_spot'],
|
||||
'status_verifikasi' => $status_verif,
|
||||
'nama_ibadah' => $nama_ibadah,
|
||||
'jenis_ibadah' => $jenis_ibadah,
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
error_log('penduduk/simpan insert failed: ' . $stmt->error);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan data penduduk.']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('operator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$nama_kk = trim($_POST['nama_kk'] ?? '');
|
||||
$nik = trim($_POST['nik'] ?? '');
|
||||
$jumlah_jiwa = max(1, (int)($_POST['jumlah_jiwa'] ?? 1));
|
||||
$kategori = trim($_POST['kategori'] ?? 'Miskin');
|
||||
$alamat = trim($_POST['alamat'] ?? '');
|
||||
$catatan = trim($_POST['catatan'] ?? '');
|
||||
|
||||
if ($id <= 0 || !$nama_kk) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID dan nama kepala keluarga wajib diisi.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$valid_kategori = ['Sangat Miskin', 'Miskin', 'Hampir Miskin'];
|
||||
if (!in_array($kategori, $valid_kategori)) $kategori = 'Miskin';
|
||||
|
||||
// Ambil data existing untuk scope check
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT ibadah_id FROM penduduk_miskin WHERE id=? AND is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$warga = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if (!$warga) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data warga tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Operator hanya boleh edit warga dalam ibadah-nya
|
||||
if (!has_role('administrator')) {
|
||||
$op_ibadah = get_ibadah_id();
|
||||
if ($warga['ibadah_id'] === null || (int)$warga['ibadah_id'] !== (int)$op_ibadah) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Anda tidak memiliki akses untuk warga ini.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Validasi & dedup NIK (exclude diri sendiri)
|
||||
$nik_val = $nik !== '' ? $nik : null;
|
||||
if ($nik_val !== null) {
|
||||
if (!preg_match('/^\d{16}$/', $nik_val)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'NIK harus 16 digit angka.']);
|
||||
exit;
|
||||
}
|
||||
$stmt_nik = $conn->prepare(
|
||||
"SELECT id, nama_kk FROM penduduk_miskin WHERE nik=? AND id != ? LIMIT 1"
|
||||
);
|
||||
$stmt_nik->bind_param('si', $nik_val, $id);
|
||||
$stmt_nik->execute();
|
||||
$existing = $stmt_nik->get_result()->fetch_assoc();
|
||||
$stmt_nik->close();
|
||||
if ($existing) {
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => "NIK {$nik_val} sudah terdaftar atas nama {$existing['nama_kk']}.",
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$catatan_val = $catatan !== '' ? $catatan : null;
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE penduduk_miskin
|
||||
SET nama_kk=?, nik=?, jumlah_jiwa=?, kategori=?, alamat=?, catatan=?, updated_at=NOW()
|
||||
WHERE id=? AND is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('ssisssi', $nama_kk, $nik_val, $jumlah_jiwa, $kategori, $alamat, $catatan_val, $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows >= 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Data berhasil diperbarui.']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal memperbarui data: ' . $stmt->error]);
|
||||
}
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('operator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$coord = validate_lat_lng($_POST['lat'] ?? null, $_POST['lng'] ?? null);
|
||||
if (!$coord['ok']) {
|
||||
echo json_encode(['status' => 'error', 'message' => $coord['message']]);
|
||||
exit;
|
||||
}
|
||||
$lat = $coord['lat'];
|
||||
$lng = $coord['lng'];
|
||||
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!has_role('administrator')) {
|
||||
$linked_ibadah_id = get_ibadah_id();
|
||||
if (!$linked_ibadah_id) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akses ditolak untuk data warga ini']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$scope = $conn->prepare(
|
||||
"SELECT id FROM penduduk_miskin
|
||||
WHERE id = ? AND ibadah_id = ? AND is_active = 1 AND deleted_at IS NULL"
|
||||
);
|
||||
$scope->bind_param('ii', $id, $linked_ibadah_id);
|
||||
$scope->execute();
|
||||
$allowed = $scope->get_result()->fetch_assoc();
|
||||
$scope->close();
|
||||
|
||||
if (!$allowed) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akses ditolak untuk data warga ini']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// PRD-correct proximity calculation
|
||||
$p = calc_proximity($conn, $lat, $lng);
|
||||
|
||||
if (!has_role('administrator')) {
|
||||
if (empty($p['ibadah_id']) || (int)$p['ibadah_id'] !== $linked_ibadah_id) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => 'Posisi baru berada di luar wilayah rumah ibadah operator'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE penduduk_miskin
|
||||
SET lat = ?, lng = ?, ibadah_id = ?, jarak_m = ?, is_blank_spot = ?
|
||||
WHERE id = ? AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('ddidii', $lat, $lng, $p['ibadah_id'], $p['jarak_m'], $p['is_blank_spot'], $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Posisi berhasil diperbarui',
|
||||
'data' => [
|
||||
'ibadah_id' => $p['ibadah_id'],
|
||||
'jarak_m' => $p['jarak_m'],
|
||||
'is_blank_spot' => $p['is_blank_spot'],
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal update atau data tidak ditemukan']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
require_auth('operator');
|
||||
require_csrf();
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$status = trim($_POST['status'] ?? '');
|
||||
$catatan = trim($_POST['catatan'] ?? '');
|
||||
|
||||
$allowed = ['Belum Ditangani', 'Dalam Proses', 'Sudah Ditangani'];
|
||||
if ($id <= 0 || !in_array($status, $allowed, true)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Parameter tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"SELECT id, ibadah_id, status_bantuan, status_verifikasi FROM penduduk_miskin
|
||||
WHERE id=? AND is_active=1 AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if (!$row) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Operator hanya boleh update warga dalam ibadah-nya sendiri
|
||||
if (!has_role('administrator')) {
|
||||
$op_ibadah = get_ibadah_id();
|
||||
if ($row['ibadah_id'] === null || (int)$row['ibadah_id'] !== (int)$op_ibadah) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Anda tidak memiliki akses untuk warga ini.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if ($row['status_verifikasi'] !== 'Terverifikasi') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data warga belum terverifikasi']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$old_status = $row['status_bantuan'];
|
||||
$user_id = (int)$_SESSION['user_id'];
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE penduduk_miskin SET status_bantuan=?, updated_at=NOW() WHERE id=?"
|
||||
);
|
||||
$stmt->bind_param('si', $status, $id);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO riwayat_bantuan (penduduk_id, operator_id, status_lama, status_baru, catatan)
|
||||
VALUES (?,?,?,?,?)"
|
||||
);
|
||||
$stmt->bind_param('iisss', $id, $user_id, $old_status, $status, $catatan);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'status_baru' => $status]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int)trim($_POST['id'] ?? 0);
|
||||
$aksi = trim($_POST['aksi'] ?? ''); // 'approve' | 'reject'
|
||||
$catatan = trim($_POST['catatan'] ?? '');
|
||||
|
||||
if ($id <= 0 || !in_array($aksi, ['approve', 'reject'], true)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Parameter tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$status_baru = $aksi === 'approve' ? 'Terverifikasi' : 'Ditolak';
|
||||
$admin_id = get_user_id();
|
||||
$catatan_val = $catatan !== '' ? mb_substr($catatan, 0, 500) : null;
|
||||
|
||||
$stmt = $conn->prepare("
|
||||
UPDATE penduduk_miskin
|
||||
SET status_verifikasi = ?,
|
||||
verified_by = ?,
|
||||
verified_at = NOW(),
|
||||
catatan_verifikasi = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
");
|
||||
$stmt->bind_param('sisi', $status_baru, $admin_id, $catatan_val, $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'status_verifikasi' => $status_baru, 'id' => $id]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau tidak ada perubahan.']);
|
||||
}
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
$is_auth = is_logged_in();
|
||||
$is_privileged = $is_auth && has_role('operator');
|
||||
$is_admin = $is_auth && has_role('administrator');
|
||||
$public_verif_sql = $is_privileged ? "" : " AND status_verifikasi = 'Terverifikasi'";
|
||||
$public_verif_pm_sql = $is_privileged ? "" : " AND pm.status_verifikasi = 'Terverifikasi'";
|
||||
$operator_ibadah_id = null;
|
||||
$operator_scope_sql = '';
|
||||
$operator_scope_pm_sql = '';
|
||||
if ($is_privileged && !$is_admin) {
|
||||
$operator_ibadah_id = (int)get_ibadah_id();
|
||||
if ($operator_ibadah_id > 0) {
|
||||
$operator_scope_sql = " AND ibadah_id = {$operator_ibadah_id}";
|
||||
$operator_scope_pm_sql = " AND pm.ibadah_id = {$operator_ibadah_id}";
|
||||
} else {
|
||||
$operator_scope_sql = " AND 1 = 0";
|
||||
$operator_scope_pm_sql = " AND 1 = 0";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Statistik dasar (semua role) ────────────────────────────────────────────
|
||||
$r = $conn->query("
|
||||
SELECT
|
||||
COUNT(*) AS total_kk,
|
||||
COALESCE(SUM(jumlah_jiwa), 0) AS total_jiwa,
|
||||
COALESCE(SUM(CASE WHEN is_blank_spot=1 THEN jumlah_jiwa ELSE 0 END), 0) AS jiwa_blank_spot,
|
||||
COALESCE(SUM(CASE WHEN is_blank_spot=0 THEN jumlah_jiwa ELSE 0 END), 0) AS jiwa_terjangkau
|
||||
FROM penduduk_miskin pm
|
||||
WHERE pm.is_active = 1 AND pm.deleted_at IS NULL {$public_verif_pm_sql} {$operator_scope_pm_sql}
|
||||
");
|
||||
$base = $r->fetch_assoc();
|
||||
|
||||
$total_jiwa = (int)$base['total_jiwa'];
|
||||
$jiwa_terjangkau = (int)$base['jiwa_terjangkau'];
|
||||
$jiwa_blank_spot = (int)$base['jiwa_blank_spot'];
|
||||
$pct_cakupan = $total_jiwa > 0 ? round($jiwa_terjangkau / $total_jiwa * 100, 1) : 0;
|
||||
|
||||
// ── Sebaran kategori ────────────────────────────────────────────────────────
|
||||
$kat_rows = $conn->query("
|
||||
SELECT kategori, COUNT(*) AS jml_kk, COALESCE(SUM(jumlah_jiwa),0) AS jml_jiwa
|
||||
FROM penduduk_miskin pm
|
||||
WHERE pm.is_active=1 AND pm.deleted_at IS NULL {$public_verif_pm_sql} {$operator_scope_pm_sql}
|
||||
GROUP BY kategori
|
||||
");
|
||||
$kategori = [];
|
||||
while ($row = $kat_rows->fetch_assoc()) $kategori[] = $row;
|
||||
|
||||
// ── Total ibadah aktif ──────────────────────────────────────────────────────
|
||||
if ($is_privileged && !$is_admin) {
|
||||
$total_ibadah = $operator_ibadah_id
|
||||
? (int)$conn->query("SELECT COUNT(*) AS n FROM rumah_ibadah WHERE deleted_at IS NULL AND id = {$operator_ibadah_id}")->fetch_assoc()['n']
|
||||
: 0;
|
||||
} else {
|
||||
$total_ibadah = (int)$conn->query(
|
||||
"SELECT COUNT(*) AS n FROM rumah_ibadah WHERE deleted_at IS NULL"
|
||||
)->fetch_assoc()['n'];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'total_ibadah' => $total_ibadah,
|
||||
'total_kk' => (int)$base['total_kk'],
|
||||
'total_jiwa' => $total_jiwa,
|
||||
'jiwa_terjangkau' => $jiwa_terjangkau,
|
||||
'jiwa_blank_spot' => $jiwa_blank_spot,
|
||||
'pct_cakupan' => $pct_cakupan,
|
||||
'kategori' => $kategori,
|
||||
];
|
||||
|
||||
// ── Tabel rekapitulasi per ibadah (admin only) ──────────────────────────────
|
||||
if ($is_admin) {
|
||||
$rekap_rows = $conn->query("
|
||||
SELECT
|
||||
ri.id,
|
||||
ri.nama,
|
||||
ri.jenis,
|
||||
COUNT(pm.id) AS jml_kk,
|
||||
COALESCE(SUM(pm.jumlah_jiwa), 0) AS jml_jiwa,
|
||||
COALESCE(SUM(CASE WHEN pm.status_bantuan='Sudah Ditangani' THEN 1 ELSE 0 END), 0) AS jml_ditangani
|
||||
FROM rumah_ibadah ri
|
||||
LEFT JOIN penduduk_miskin pm
|
||||
ON pm.ibadah_id = ri.id
|
||||
AND pm.is_active = 1
|
||||
AND pm.deleted_at IS NULL
|
||||
WHERE ri.deleted_at IS NULL
|
||||
GROUP BY ri.id
|
||||
ORDER BY jml_jiwa DESC
|
||||
");
|
||||
$rekap = [];
|
||||
while ($row = $rekap_rows->fetch_assoc()) $rekap[] = $row;
|
||||
$payload['rekap_ibadah'] = $rekap;
|
||||
}
|
||||
|
||||
// ── Kebutuhan stats (hanya jika tabel sudah ada) ───────────────────────────
|
||||
$kebutuhan_exists = $conn->query(
|
||||
"SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'kebutuhan' LIMIT 1"
|
||||
)->num_rows > 0;
|
||||
|
||||
$payload['kk_kebutuhan_open'] = 0;
|
||||
|
||||
if ($kebutuhan_exists) {
|
||||
$payload['kk_kebutuhan_open'] = (int)$conn->query("
|
||||
SELECT COUNT(DISTINCT penduduk_id) AS n
|
||||
FROM kebutuhan k
|
||||
JOIN penduduk_miskin pm ON pm.id = k.penduduk_id
|
||||
AND pm.is_active = 1
|
||||
AND pm.deleted_at IS NULL
|
||||
{$public_verif_pm_sql}
|
||||
{$operator_scope_pm_sql}
|
||||
WHERE k.status = 'Belum Terpenuhi'
|
||||
")->fetch_assoc()['n'];
|
||||
|
||||
if ($is_admin) {
|
||||
$rekap_kat = $conn->query("
|
||||
SELECT kategori,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN status='Belum Terpenuhi' THEN 1 ELSE 0 END) AS belum,
|
||||
SUM(CASE WHEN status='Dalam Proses' THEN 1 ELSE 0 END) AS proses,
|
||||
SUM(CASE WHEN status='Terpenuhi' THEN 1 ELSE 0 END) AS terpenuhi
|
||||
FROM kebutuhan
|
||||
GROUP BY kategori ORDER BY belum DESC, kategori ASC
|
||||
");
|
||||
$rekap_kebutuhan = [];
|
||||
while ($r = $rekap_kat->fetch_assoc()) $rekap_kebutuhan[] = $r;
|
||||
$payload['rekap_kebutuhan'] = $rekap_kebutuhan;
|
||||
|
||||
$donatur_exists = $conn->query(
|
||||
"SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'kontak_donatur' LIMIT 1"
|
||||
)->num_rows > 0;
|
||||
$payload['donatur_unread'] = $donatur_exists
|
||||
? (int)$conn->query("SELECT COUNT(*) AS n FROM kontak_donatur WHERE is_read=0")->fetch_assoc()['n']
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $payload]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
|
||||
$sql = "
|
||||
SELECT u.id, u.username, u.nama_lengkap, u.role, u.ibadah_id,
|
||||
u.is_active, u.must_change_password, u.created_at,
|
||||
ri.nama AS nama_ibadah
|
||||
FROM users u
|
||||
LEFT JOIN rumah_ibadah ri ON u.ibadah_id = ri.id AND ri.deleted_at IS NULL
|
||||
ORDER BY u.created_at DESC
|
||||
";
|
||||
$result = $conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) $data[] = $row;
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $data]);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if (!$id) { echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); exit; }
|
||||
|
||||
$chars = 'abcdefghjkmnpqrstuvwxyz';
|
||||
$digits = '23456789';
|
||||
$temp_pw = '';
|
||||
for ($i = 0; $i < 4; $i++) $temp_pw .= $chars[random_int(0, strlen($chars)-1)];
|
||||
for ($i = 0; $i < 4; $i++) $temp_pw .= $digits[random_int(0, strlen($digits)-1)];
|
||||
$temp_pw = str_shuffle($temp_pw);
|
||||
|
||||
$hash = password_hash($temp_pw, PASSWORD_BCRYPT);
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE users SET password=?, must_change_password=1, login_attempts=0, locked_until=NULL WHERE id=?"
|
||||
);
|
||||
$stmt->bind_param('si', $hash, $id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'temp_password' => $temp_pw,
|
||||
'message' => 'Password direset. Sampaikan password sementara ini ke pengguna secara langsung.',
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
|
||||
}
|
||||
$stmt->close(); $conn->close();
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$nama_lengkap = trim($_POST['nama_lengkap'] ?? '');
|
||||
$password = trim($_POST['password'] ?? '');
|
||||
$role = trim($_POST['role'] ?? '');
|
||||
$ibadah_id = ($_POST['ibadah_id'] ?? '') !== '' ? (int)$_POST['ibadah_id'] : null;
|
||||
|
||||
$valid_roles = ['administrator', 'operator', 'viewer'];
|
||||
if (!$username || !$nama_lengkap || !$password || !in_array($role, $valid_roles)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap atau role tidak valid']); exit;
|
||||
}
|
||||
if (strlen($password) < 8 || !preg_match('/[a-zA-Z]/', $password) || !preg_match('/[0-9]/', $password)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Password min 8 karakter, kombinasi huruf dan angka']); exit;
|
||||
}
|
||||
if ($role === 'operator' && !$ibadah_id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Operator harus dikaitkan ke rumah ibadah']); exit;
|
||||
}
|
||||
if ($role !== 'operator') {
|
||||
$ibadah_id = null;
|
||||
}
|
||||
if ($role === 'operator') {
|
||||
$ri_stmt = $conn->prepare("SELECT id FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$ri_stmt->bind_param('i', $ibadah_id);
|
||||
$ri_stmt->execute();
|
||||
$ri = $ri_stmt->get_result()->fetch_assoc();
|
||||
$ri_stmt->close();
|
||||
if (!$ri) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Rumah ibadah operator tidak valid atau sudah dihapus']); exit;
|
||||
}
|
||||
}
|
||||
|
||||
$hash = password_hash($password, PASSWORD_BCRYPT);
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO users (username, nama_lengkap, password, role, ibadah_id, must_change_password)
|
||||
VALUES (?, ?, ?, ?, ?, 1)"
|
||||
);
|
||||
$stmt->bind_param('ssssi', $username, $nama_lengkap, $hash, $role, $ibadah_id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Akun berhasil dibuat', 'id' => $conn->insert_id]);
|
||||
} else {
|
||||
$msg = str_contains($conn->error, 'Duplicate') ? "Username '$username' sudah digunakan" : $conn->error;
|
||||
echo json_encode(['status' => 'error', 'message' => $msg]);
|
||||
}
|
||||
$stmt->close(); $conn->close();
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if (!$id) { echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); exit; }
|
||||
|
||||
if ($id === get_user_id()) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Tidak dapat menonaktifkan akun sendiri']); exit;
|
||||
}
|
||||
|
||||
$target_stmt = $conn->prepare("SELECT role, is_active FROM users WHERE id = ? LIMIT 1");
|
||||
$target_stmt->bind_param('i', $id);
|
||||
$target_stmt->execute();
|
||||
$target = $target_stmt->get_result()->fetch_assoc();
|
||||
$target_stmt->close();
|
||||
if (!$target) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akun tidak ditemukan']); exit;
|
||||
}
|
||||
|
||||
if ($target['role'] === 'administrator' && (int)$target['is_active'] === 1) {
|
||||
$admin_stmt = $conn->prepare(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE role='administrator' AND is_active=1 AND id <> ?"
|
||||
);
|
||||
$admin_stmt->bind_param('i', $id);
|
||||
$admin_stmt->execute();
|
||||
$other_admins = (int)$admin_stmt->get_result()->fetch_assoc()['n'];
|
||||
$admin_stmt->close();
|
||||
if ($other_admins === 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Tidak dapat menonaktifkan administrator aktif terakhir']); exit;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("UPDATE users SET is_active = NOT is_active WHERE id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$s2 = $conn->prepare("SELECT is_active FROM users WHERE id = ?");
|
||||
$s2->bind_param('i', $id);
|
||||
$s2->execute();
|
||||
$r = $s2->get_result()->fetch_assoc();
|
||||
$s2->close();
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'is_active' => (int)$r['is_active'],
|
||||
'message' => $r['is_active'] ? 'Akun diaktifkan' : 'Akun dinonaktifkan',
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
|
||||
}
|
||||
$stmt->close(); $conn->close();
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../config.php';
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../koneksi.php';
|
||||
require_auth('administrator');
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']); exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$nama_lengkap = trim($_POST['nama_lengkap'] ?? '');
|
||||
$role = trim($_POST['role'] ?? '');
|
||||
$ibadah_id = ($_POST['ibadah_id'] ?? '') !== '' ? (int)$_POST['ibadah_id'] : null;
|
||||
|
||||
if (!$id || !$nama_lengkap || !in_array($role, ['administrator','operator','viewer'])) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak valid']); exit;
|
||||
}
|
||||
if ($role === 'operator' && !$ibadah_id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Operator harus dikaitkan ke rumah ibadah']); exit;
|
||||
}
|
||||
if ($role !== 'operator') {
|
||||
$ibadah_id = null;
|
||||
}
|
||||
|
||||
$current_stmt = $conn->prepare("SELECT role, is_active FROM users WHERE id = ? LIMIT 1");
|
||||
$current_stmt->bind_param('i', $id);
|
||||
$current_stmt->execute();
|
||||
$current = $current_stmt->get_result()->fetch_assoc();
|
||||
$current_stmt->close();
|
||||
if (!$current) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akun tidak ditemukan']); exit;
|
||||
}
|
||||
|
||||
if ($id === get_user_id() && $current['role'] === 'administrator' && $role !== 'administrator') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Tidak dapat menurunkan role akun sendiri']); exit;
|
||||
}
|
||||
|
||||
if ($current['role'] === 'administrator' && (int)$current['is_active'] === 1 && $role !== 'administrator') {
|
||||
$admin_stmt = $conn->prepare(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE role='administrator' AND is_active=1 AND id <> ?"
|
||||
);
|
||||
$admin_stmt->bind_param('i', $id);
|
||||
$admin_stmt->execute();
|
||||
$other_admins = (int)$admin_stmt->get_result()->fetch_assoc()['n'];
|
||||
$admin_stmt->close();
|
||||
if ($other_admins === 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Tidak dapat mengubah administrator aktif terakhir']); exit;
|
||||
}
|
||||
}
|
||||
|
||||
if ($role === 'operator') {
|
||||
$ri_stmt = $conn->prepare("SELECT id FROM rumah_ibadah WHERE id = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$ri_stmt->bind_param('i', $ibadah_id);
|
||||
$ri_stmt->execute();
|
||||
$ri = $ri_stmt->get_result()->fetch_assoc();
|
||||
$ri_stmt->close();
|
||||
if (!$ri) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Rumah ibadah operator tidak valid atau sudah dihapus']); exit;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("UPDATE users SET nama_lengkap=?, role=?, ibadah_id=? WHERE id=?");
|
||||
$stmt->bind_param('ssii', $nama_lengkap, $role, $ibadah_id, $id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Akun berhasil diperbarui']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
|
||||
}
|
||||
$stmt->close(); $conn->close();
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
// auth/change_password.php — Paksa ganti password saat pertama login
|
||||
require_once '../config.php';
|
||||
require_once '../auth/helper.php';
|
||||
|
||||
if (!is_logged_in()) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ganti Password — <?= APP_NAME ?></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root { --c-bg:#f5f0eb; --c-surface:#fafaf9; --c-border:#ddd8d2;
|
||||
--c-text:#201515; --c-muted:#7a7067; --c-accent:#0d7490;
|
||||
--c-accent-h:#0a5f7a; --c-danger:#ef4444; --font-body:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
|
||||
body { min-height:100vh; background:var(--c-bg); color:var(--c-text);
|
||||
font-family:var(--font-body); display:flex; align-items:center;
|
||||
justify-content:center; padding:20px; }
|
||||
.card { width:min(420px,100%); background:var(--c-surface);
|
||||
border:1px solid var(--c-border); border-radius:12px;
|
||||
padding:32px; box-shadow:0 4px 12px rgba(32,21,21,.08); }
|
||||
.notice { background:#fffbeb; border:1px solid #fde68a;
|
||||
border-radius:8px; padding:12px 16px; font-size:13px;
|
||||
color:#d97706; margin-bottom:22px; }
|
||||
.form-group { margin-bottom:16px; }
|
||||
.form-group label { display:block; font-size:13px; font-weight:500;
|
||||
color:#3d3530; margin-bottom:6px; }
|
||||
.form-group input { width:100%; padding:10px 14px; background:#fafaf9;
|
||||
border:1px solid var(--c-border); border-radius:8px;
|
||||
color:var(--c-text); font-size:14px; outline:none; height:40px;
|
||||
transition:border-color .15s; font-family:var(--font-body); }
|
||||
.form-group input:focus { border-color:#0d7490; }
|
||||
.hint { font-size:12px; color:var(--c-muted); margin-top:5px; }
|
||||
.btn { width:100%; padding:11px; background:#0d7490; color:#fff;
|
||||
border:none; border-radius:8px; font-size:14px; font-weight:600;
|
||||
cursor:pointer; margin-top:8px; transition:background .15s;
|
||||
font-family:var(--font-body); }
|
||||
.btn:hover { background:#0a5f7a; }
|
||||
.btn:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.msg { margin-top:12px; font-size:13px; text-align:center;
|
||||
min-height:18px; }
|
||||
.msg.error { color:#ef4444; }
|
||||
.msg.success { color:#16a34a; }
|
||||
|
||||
/* Lucide & Spinner styles */
|
||||
.lucide { width: 16px; height: 16px; display: inline-block; vertical-align: middle; stroke-width: 2px; }
|
||||
.notice .lucide { color: #d97706; margin-right: 6px; }
|
||||
.spinner {
|
||||
display: inline-block; width: 14px; height: 14px;
|
||||
border: 2px solid #ddd8d2; border-top-color: #0d7490;
|
||||
border-radius: 50%; animation: spin .7s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="notice"><i data-lucide="alert-triangle"></i> Anda diminta mengganti password sebelum dapat mengakses sistem.</div>
|
||||
<form id="cpForm" onsubmit="doChange(event)">
|
||||
<div class="form-group">
|
||||
<label>Password Baru</label>
|
||||
<input type="password" id="new_password" name="new_password"
|
||||
placeholder="Min. 8 karakter" required>
|
||||
<div class="hint">Minimal 8 karakter, kombinasi huruf dan angka.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Konfirmasi Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password"
|
||||
placeholder="Ulangi password baru" required>
|
||||
</div>
|
||||
<button type="submit" class="btn" id="btnSave">Simpan Password</button>
|
||||
</form>
|
||||
<div class="msg" id="cpMsg"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.APP_CSRF_TOKEN = <?= json_encode(csrf_token()) ?>;
|
||||
function appendCsrf(fd) {
|
||||
const token = window.APP_CSRF_TOKEN || null;
|
||||
if (token) fd.append('csrf_token', token);
|
||||
return fd;
|
||||
}
|
||||
async function doChange(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('btnSave');
|
||||
const msg = document.getElementById('cpMsg');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Menyimpan...';
|
||||
msg.className = 'msg';
|
||||
msg.textContent = '';
|
||||
|
||||
const fd = new FormData(document.getElementById('cpForm'));
|
||||
try {
|
||||
const res = await fetch('../api/auth/change_password.php', { method:'POST', body: appendCsrf(fd) });
|
||||
const j = await res.json();
|
||||
if (j.status === 'success') {
|
||||
msg.innerHTML = '<i data-lucide="check-circle" style="color:#16a34a;margin-right:4px;"></i> Password berhasil diubah. Mengalihkan...';
|
||||
msg.className = 'msg success';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
setTimeout(() => { window.location.href = '../index.php'; }, 1200);
|
||||
} else {
|
||||
msg.textContent = j.message;
|
||||
msg.className = 'msg error';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = 'Simpan Password';
|
||||
}
|
||||
} catch (_) {
|
||||
msg.textContent = 'Gagal menghubungi server.';
|
||||
msg.className = 'msg error';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = 'Simpan Password';
|
||||
}
|
||||
}
|
||||
// Boot Lucide on load
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
// auth/helper.php — Session management dan RBAC helpers
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
$is_https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_SSL'] ?? '') === 'on');
|
||||
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'httponly' => true,
|
||||
'secure' => $is_https,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
function is_logged_in(): bool {
|
||||
if (empty($_SESSION['user_id'])) return false;
|
||||
if (!defined('SESSION_TIMEOUT')) return true;
|
||||
$last = $_SESSION['last_activity'] ?? 0;
|
||||
if (time() - $last > SESSION_TIMEOUT) {
|
||||
session_destroy();
|
||||
return false;
|
||||
}
|
||||
$_SESSION['last_activity'] = time(); // extend only after passing timeout check
|
||||
return true;
|
||||
}
|
||||
|
||||
function require_password_changed(string $redirect = '../auth/change_password.php'): void {
|
||||
if (is_logged_in() && !empty($_SESSION['must_change_password'])) {
|
||||
header('Location: ' . $redirect);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
function get_role(): string {
|
||||
return $_SESSION['role'] ?? 'viewer';
|
||||
}
|
||||
|
||||
function get_user_id(): int {
|
||||
return (int) ($_SESSION['user_id'] ?? 0);
|
||||
}
|
||||
|
||||
function get_user_nama(): string {
|
||||
return $_SESSION['nama'] ?? '';
|
||||
}
|
||||
|
||||
function get_ibadah_id(): ?int {
|
||||
$id = $_SESSION['ibadah_id'] ?? null;
|
||||
return $id ? (int)$id : null;
|
||||
}
|
||||
|
||||
function csrf_token(): string {
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
function require_csrf(): void {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
$session_token = $_SESSION['csrf_token'] ?? '';
|
||||
|
||||
if (!$token || !$session_token || !hash_equals($session_token, $token)) {
|
||||
http_response_code(403);
|
||||
die(json_encode(['status' => 'error', 'message' => 'Token CSRF tidak valid']));
|
||||
}
|
||||
}
|
||||
|
||||
// Hierarki: administrator > operator > viewer
|
||||
function has_role(string $required): bool {
|
||||
$hierarchy = ['viewer' => 0, 'operator' => 1, 'administrator' => 2];
|
||||
$current = get_role();
|
||||
return ($hierarchy[$current] ?? 0) >= ($hierarchy[$required] ?? 99);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gunakan di API endpoint. Jika tidak login / role kurang, die() dengan JSON error.
|
||||
* Contoh: require_auth('operator');
|
||||
*/
|
||||
function require_auth(string $min_role = 'viewer'): void {
|
||||
if (!is_logged_in()) {
|
||||
http_response_code(401);
|
||||
die(json_encode(['status' => 'error', 'message' => 'Tidak terautentikasi']));
|
||||
}
|
||||
if (!has_role($min_role)) {
|
||||
http_response_code(403);
|
||||
die(json_encode(['status' => 'error', 'message' => 'Akses ditolak']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
<?php
|
||||
// auth/login.php — Halaman login standalone
|
||||
require_once '../config.php';
|
||||
require_once '../auth/helper.php';
|
||||
|
||||
if (is_logged_in()) {
|
||||
$dest = (get_role() === 'administrator') ? '../dashboard.php' : '../index.php';
|
||||
header('Location: ' . $dest);
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Masuk — <?= APP_NAME ?></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--sb-bg: #1c1612;
|
||||
--body-bg: #f5f0eb;
|
||||
--card-bg: #fafaf9;
|
||||
--card-border:#ddd8d2;
|
||||
--accent: #0d7490;
|
||||
--accent-h: #0a5f7a;
|
||||
--accent-dim: rgba(13,116,144,.06);
|
||||
--text: #201515;
|
||||
--text-sec: #3d3530;
|
||||
--text-muted: #7a7067;
|
||||
--danger: #ef4444;
|
||||
--danger-dim: rgba(239,68,68,.08);
|
||||
--danger-bdr: rgba(239,68,68,.25);
|
||||
--border: #ddd8d2;
|
||||
--border-f: #0d7490;
|
||||
--radius: 12px;
|
||||
--shadow: 0 4px 12px rgba(32,21,21,.08), 0 1px 2px rgba(32,21,21,.04);
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: var(--font);
|
||||
background: var(--body-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Layout: 2 kolom ────────────────────────────────── */
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page { grid-template-columns: 1fr; }
|
||||
.brand-side { display: none; }
|
||||
}
|
||||
|
||||
/* ── Sisi Kiri (brand) ──────────────────────────────── */
|
||||
.brand-side {
|
||||
background: var(--sb-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 48px 52px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand-side::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 50% at 30% 20%, rgba(14,165,233,.18) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 60% at 80% 80%, rgba(56,189,248,.12) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.brand-logo-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: #0d7490;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.brand-logo-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.brand-logo-sub {
|
||||
font-size: 11px;
|
||||
color: #a89f96;
|
||||
}
|
||||
|
||||
.brand-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.brand-headline {
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
line-height: 1.35;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.brand-headline span {
|
||||
color: #a89f96;
|
||||
}
|
||||
|
||||
.brand-desc {
|
||||
font-size: 14px;
|
||||
color: #a89f96;
|
||||
line-height: 1.7;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.brand-stats {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
.brand-stat-num {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.brand-stat-label {
|
||||
font-size: 11px;
|
||||
color: #a89f96;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.brand-footer {
|
||||
font-size: 11px;
|
||||
color: #a89f96;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Sisi Kanan (form) ──────────────────────────────── */
|
||||
.form-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 32px;
|
||||
background: var(--body-bg);
|
||||
}
|
||||
|
||||
.form-box {
|
||||
width: min(420px, 100%);
|
||||
}
|
||||
|
||||
.form-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-welcome {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-welcome-sub {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Card ────────────────────────────────────────────── */
|
||||
.login-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 28px 32px 32px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* ── Alert ───────────────────────────────────────────── */
|
||||
.alert {
|
||||
display: none;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
background: var(--danger-dim);
|
||||
border: 1px solid var(--danger-bdr);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--danger);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.alert.show { display: flex; }
|
||||
.alert-icon { font-size: 16px; flex-shrink: 0; margin-top: 1px; }
|
||||
|
||||
/* ── Form Groups ─────────────────────────────────────── */
|
||||
.form-group { margin-bottom: 18px; }
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-sec);
|
||||
margin-bottom: 7px;
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
background: #ede8e2;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 9px;
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color .2s, background .2s, box-shadow .2s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: var(--accent);
|
||||
background: #fafaf9;
|
||||
box-shadow: 0 0 0 3px rgba(13,116,144,.08);
|
||||
}
|
||||
|
||||
.form-group input::placeholder { color: var(--text-muted); }
|
||||
|
||||
/* Password toggle */
|
||||
.input-wrap input[type="password"],
|
||||
.input-wrap input[type="text"] {
|
||||
padding-right: 44px;
|
||||
}
|
||||
|
||||
.pw-toggle {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
padding: 4px;
|
||||
border-radius: 5px;
|
||||
transition: color .2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.pw-toggle:hover { color: var(--text-sec); }
|
||||
|
||||
/* ── Submit button ───────────────────────────────────── */
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 6px;
|
||||
transition: background .2s, transform .1s, box-shadow .2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
letter-spacing: .2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-login:hover:not(:disabled) {
|
||||
background: var(--accent-h);
|
||||
box-shadow: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-login:active:not(:disabled) { transform: translateY(0); }
|
||||
.btn-login:disabled { opacity: .6; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
.btn-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255,255,255,.35);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin .7s linear infinite;
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-login.loading .btn-spinner { display: block; }
|
||||
.btn-login.loading .btn-label::after { content: '...'; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Divider ─────────────────────────────────────────── */
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 22px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.divider::before, .divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* ── Public link ─────────────────────────────────────── */
|
||||
.public-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
background: transparent;
|
||||
color: var(--text-sec);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 9px;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: border-color .2s, color .2s, background .2s;
|
||||
}
|
||||
.public-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
.public-btn-icon { font-size: 16px; }
|
||||
|
||||
/* ── Footer note ─────────────────────────────────────── */
|
||||
.form-note {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Lucide Icons */
|
||||
.lucide {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke-width: 2px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.brand-logo-icon .lucide {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.brand-stat-num .lucide {
|
||||
color: #16a34a;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.alert-icon .lucide {
|
||||
color: var(--danger);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<!-- ═══ SISI KIRI: Brand ═══ -->
|
||||
<div class="brand-side">
|
||||
<div class="brand-logo">
|
||||
<div class="brand-logo-icon"><i data-lucide="map"></i></div>
|
||||
<div>
|
||||
<div class="brand-logo-name"><?= APP_NAME ?></div>
|
||||
<div class="brand-logo-sub">Sistem Pemetaan Kemiskinan</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="brand-body">
|
||||
<div class="brand-headline">
|
||||
Pemetaan Kemiskinan<br>Berbasis <span>Partisipasi</span><br>Rumah Ibadah
|
||||
</div>
|
||||
<div class="brand-desc">
|
||||
Platform kolaboratif untuk memetakan, memantau, dan menjangkau keluarga miskin
|
||||
melalui jaringan rumah ibadah di seluruh wilayah.
|
||||
</div>
|
||||
<div class="brand-stats">
|
||||
<div>
|
||||
<div class="brand-stat-num">GIS</div>
|
||||
<div class="brand-stat-label">Berbasis Peta</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="brand-stat-num">RT</div>
|
||||
<div class="brand-stat-label">Data Real-time</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="brand-stat-num"><i data-lucide="check"></i></div>
|
||||
<div class="brand-stat-label">Multi-peran</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="brand-footer">
|
||||
© <?= date('Y') ?> <?= APP_NAME ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SISI KANAN: Form Login ═══ -->
|
||||
<div class="form-side">
|
||||
<div class="form-box">
|
||||
|
||||
<div class="form-header">
|
||||
<div class="form-welcome">Selamat datang 👋</div>
|
||||
<div class="form-welcome-sub">Masuk ke akun Anda untuk mengelola data pemetaan kemiskinan.</div>
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
<div class="alert" id="alertMsg" role="alert">
|
||||
<span class="alert-icon"><i data-lucide="alert-triangle"></i></span>
|
||||
<span id="alertText"></span>
|
||||
</div>
|
||||
|
||||
<form id="loginForm" onsubmit="doLogin(event)" novalidate>
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username"
|
||||
autocomplete="username"
|
||||
placeholder="Masukkan username Anda"
|
||||
required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<div class="input-wrap">
|
||||
<input type="password" id="password" name="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="Masukkan password Anda"
|
||||
required>
|
||||
<input type="password" style="display:none;"> <!-- Prevent autofill bug -->
|
||||
<button type="button" class="pw-toggle" id="pwToggle"
|
||||
aria-label="Tampilkan / Sembunyikan password"
|
||||
onclick="togglePw()">
|
||||
<i data-lucide="eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-login" id="btnLogin">
|
||||
<div class="btn-spinner" id="btnSpinner"></div>
|
||||
<span class="btn-label">Masuk</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="divider">atau</div>
|
||||
|
||||
<a href="../index.php" class="public-btn">
|
||||
<span class="public-btn-icon"><i data-lucide="map"></i></span>
|
||||
Lihat Peta sebagai Pengunjung
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="form-note">
|
||||
Lupa password? Hubungi administrator sistem Anda.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function togglePw() {
|
||||
const pw = document.getElementById('password');
|
||||
const btn = document.getElementById('pwToggle');
|
||||
const show = pw.type === 'password';
|
||||
pw.type = show ? 'text' : 'password';
|
||||
btn.innerHTML = show ? '<i data-lucide="eye-off"></i>' : '<i data-lucide="eye"></i>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
// Boot Lucide on load
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
async function doLogin(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('btnLogin');
|
||||
const alert = document.getElementById('alertMsg');
|
||||
const alertTx = document.getElementById('alertText');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.classList.add('loading');
|
||||
alert.classList.remove('show');
|
||||
|
||||
const fd = new FormData(document.getElementById('loginForm'));
|
||||
try {
|
||||
const res = await fetch('../api/auth/login.php', { method: 'POST', body: fd });
|
||||
const j = await res.json();
|
||||
if (j.status === 'success') {
|
||||
if (j.data.must_change_password) {
|
||||
window.location.href = 'change_password.php';
|
||||
} else {
|
||||
window.location.href = j.redirect || '../index.php';
|
||||
}
|
||||
} else {
|
||||
alertTx.textContent = j.message;
|
||||
alert.classList.add('show');
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
document.getElementById('password').value = '';
|
||||
document.getElementById('password').focus();
|
||||
}
|
||||
} catch (_) {
|
||||
alertTx.textContent = 'Gagal menghubungi server. Periksa koneksi Anda.';
|
||||
alert.classList.add('show');
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
// config.php — Konfigurasi aplikasi WebGIS Poverty Mapping
|
||||
// Edit CENTER_LAT, CENTER_LNG, ZOOM_LEVEL untuk wilayah baru
|
||||
|
||||
define('APP_NAME', 'WebGIS Poverty Mapping');
|
||||
define('CENTER_LAT', -0.0557);
|
||||
define('CENTER_LNG', 109.3487);
|
||||
define('ZOOM_LEVEL', 15);
|
||||
|
||||
define('MAP_MIN_LAT', -0.35);
|
||||
define('MAP_MAX_LAT', 0.20);
|
||||
define('MAP_MIN_LNG', 109.15);
|
||||
define('MAP_MAX_LNG', 109.60);
|
||||
|
||||
define('SESSION_TIMEOUT', 7200); // 2 jam dalam detik
|
||||
define('MAX_LOGIN_ATTEMPTS', 5);
|
||||
define('LOCKOUT_MINUTES', 15);
|
||||
|
||||
define('DB_HOST', getenv('DB_HOST') ?: 'localhost');
|
||||
define('DB_USER', getenv('DB_USER') ?: 'root');
|
||||
define('DB_PASS', getenv('DB_PASS') ?: '');
|
||||
define('DB_NAME', getenv('DB_WEBGIS_NAME') ?: (getenv('DB_NAME') ?: 'db_webgis'));
|
||||
define('DB_PORT', (int)(getenv('DB_PORT') ?: 3307));
|
||||
@@ -0,0 +1,683 @@
|
||||
/* css/admin.css — Cal.com Design System */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
/* Layout */
|
||||
--sb-w: 240px;
|
||||
/* Sidebar — coffee dark, satu-satunya dark zone */
|
||||
--sb-bg: #1c1612;
|
||||
--sb-active-bg: rgba(255,255,255,.08);
|
||||
--sb-active-bar: #0d7490;
|
||||
--sb-text: #a89f96;
|
||||
--sb-text-hover: #ffffff;
|
||||
--sb-section-label: #5c5148;
|
||||
/* Header */
|
||||
--hd-bg: #fafaf9;
|
||||
--hd-border: #ddd8d2;
|
||||
/* Page */
|
||||
--body-bg: #f5f0eb;
|
||||
/* Cards */
|
||||
--card-bg: #fafaf9;
|
||||
--card-border: #ddd8d2;
|
||||
--card-shadow: 0 1px 2px rgba(32,21,21,.06);
|
||||
/* Text */
|
||||
--text-primary: #201515;
|
||||
--text-secondary: #3d3530;
|
||||
--text-muted: #7a7067;
|
||||
/* Accent */
|
||||
--accent: #0d7490;
|
||||
--accent-hover: #0a5f7a;
|
||||
/* Inset surface */
|
||||
--canvas-inset: #ede8e2;
|
||||
/* Radii */
|
||||
--radius: 12px;
|
||||
--radius-btn: 8px;
|
||||
/* Font */
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: var(--font);
|
||||
}
|
||||
|
||||
html, body { height: 100%; font-family: var(--font); background: var(--body-bg); color: var(--text-primary); }
|
||||
|
||||
/* ── Layout Shell ───────────────────────────────────── */
|
||||
.al-wrap { display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────── */
|
||||
.al-sidebar {
|
||||
width: var(--sb-w);
|
||||
min-width: var(--sb-w);
|
||||
background: var(--sb-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
transition: width .3s cubic-bezier(.4,0,.2,1), min-width .3s;
|
||||
flex-shrink: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
.al-sidebar.collapsed { width: 60px; min-width: 60px; }
|
||||
.al-sidebar.collapsed .sb-label,
|
||||
.al-sidebar.collapsed .sb-section-label,
|
||||
.al-sidebar.collapsed .sb-logo-text,
|
||||
.al-sidebar.collapsed .sb-toggle-label { display: none; }
|
||||
.al-sidebar.collapsed .sb-item { justify-content: center; padding: 0 18px; }
|
||||
.al-sidebar.collapsed .sb-icon { margin-right: 0; }
|
||||
|
||||
.sb-logo {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 18px 16px 14px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sb-logo-icon {
|
||||
width: 36px; height: 36px; border-radius: 9px;
|
||||
background: #0d7490;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px; flex-shrink: 0;
|
||||
}
|
||||
.sb-logo-text { overflow: hidden; }
|
||||
.sb-logo-name { font-size: 13px; font-weight: 600; color: #ffffff; line-height: 1.3; white-space: nowrap; letter-spacing: -0.3px; }
|
||||
.sb-logo-sub { font-size: 10px; color: var(--sb-text); white-space: nowrap; }
|
||||
|
||||
.sb-nav { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 8px 0; }
|
||||
.sb-nav::-webkit-scrollbar { width: 4px; }
|
||||
.sb-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 4px; }
|
||||
|
||||
.sb-section-label {
|
||||
font-size: 10px; font-weight: 600; color: var(--sb-section-label);
|
||||
text-transform: uppercase; letter-spacing: .8px;
|
||||
padding: 12px 16px 4px; white-space: nowrap;
|
||||
}
|
||||
|
||||
.sb-item {
|
||||
display: flex; align-items: center; gap: 0;
|
||||
padding: 0 10px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
margin: 1px 8px;
|
||||
cursor: pointer;
|
||||
color: var(--sb-text);
|
||||
font-size: 13px; font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background .15s, color .15s;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
}
|
||||
.sb-item:hover { background: rgba(255,255,255,.06); color: var(--sb-text-hover); }
|
||||
.sb-item.active { background: rgba(255,255,255,.08); color: #ffffff; }
|
||||
.sb-item.active::before {
|
||||
content: ''; position: absolute; left: -8px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px; height: 22px; border-radius: 0 3px 3px 0;
|
||||
background: #0d7490;
|
||||
}
|
||||
.sb-icon { font-size: 16px; width: 32px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.sb-label { overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.sb-footer {
|
||||
padding: 10px 8px;
|
||||
border-top: 1px solid rgba(255,255,255,.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sb-toggle-btn {
|
||||
display: flex; align-items: center; gap: 0;
|
||||
padding: 0 10px; height: 38px; width: 100%;
|
||||
background: transparent; border: none;
|
||||
border-radius: 8px; cursor: pointer;
|
||||
color: var(--sb-text); font-size: 13px; font-weight: 500;
|
||||
font-family: var(--font);
|
||||
transition: background .15s, color .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sb-toggle-btn:hover { background: rgba(255,255,255,.06); color: var(--sb-text-hover); }
|
||||
.sb-toggle-icon { font-size: 16px; width: 32px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; transition: transform .3s; }
|
||||
.al-sidebar.collapsed .sb-toggle-icon { transform: rotate(180deg); }
|
||||
.sb-toggle-label { overflow: hidden; }
|
||||
|
||||
/* ── Main Area ──────────────────────────────────────── */
|
||||
.al-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
||||
|
||||
/* ── Header ─────────────────────────────────────────── */
|
||||
.al-header {
|
||||
height: 60px; background: var(--hd-bg);
|
||||
border-bottom: 1px solid var(--hd-border);
|
||||
display: flex; align-items: center;
|
||||
padding: 0 24px; gap: 16px;
|
||||
flex-shrink: 0;
|
||||
z-index: 40;
|
||||
}
|
||||
.hd-title-block { flex: 1; min-width: 0; }
|
||||
.hd-title { font-size: 18px; font-weight: 600; color: var(--text-primary); line-height: 1.2; letter-spacing: -0.3px; }
|
||||
.hd-subtitle { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
.hd-right { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
|
||||
|
||||
.hd-status {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; font-weight: 500; color: #16a34a;
|
||||
}
|
||||
.hd-status-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; background: #16a34a;
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-dot { 0%,100%{opacity:1;} 50%{opacity:.4;} }
|
||||
|
||||
.hd-notif-btn {
|
||||
position: relative; width: 36px; height: 36px;
|
||||
background: transparent; border: 1px solid var(--card-border);
|
||||
border-radius: 9px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 16px; color: var(--text-secondary);
|
||||
transition: background .15s;
|
||||
}
|
||||
.hd-notif-btn:hover { background: #ede8e2; }
|
||||
.hd-notif-badge {
|
||||
position: absolute; top: -5px; right: -5px;
|
||||
min-width: 18px; height: 18px; border-radius: 9px;
|
||||
background: #ef4444; color: #fff;
|
||||
font-size: 10px; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0 4px; border: 2px solid var(--hd-bg);
|
||||
}
|
||||
|
||||
/* ── Notification Panel ─────────────────────────────────────────────── */
|
||||
.notif-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.notif-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 300px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(32,21,21,.14);
|
||||
z-index: 500;
|
||||
overflow: hidden;
|
||||
}
|
||||
.notif-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 13px 18px 11px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
}
|
||||
.notif-panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.notif-panel-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
border-radius: 5px;
|
||||
transition: background .15s;
|
||||
}
|
||||
.notif-panel-close:hover { background: var(--card-border); }
|
||||
.notif-panel-close .lucide { width: 15px; height: 15px; }
|
||||
.notif-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.notif-loading,
|
||||
.notif-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 28px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.notif-empty .lucide { width: 18px; height: 18px; opacity: .6; }
|
||||
.notif-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 18px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
transition: background .15s;
|
||||
}
|
||||
.notif-item:last-child { border-bottom: none; }
|
||||
.notif-item:hover { background: #f0ebe4; }
|
||||
.notif-item-icon {
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 7px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.notif-item-icon.type-pending_verif { background: rgba(234,179,8,.12); color: #ca8a04; }
|
||||
.notif-item-icon.type-donatur { background: rgba(59,130,246,.12); color: #2563eb; }
|
||||
.notif-item-icon.type-kebutuhan { background: rgba(239,68,68,.12); color: #dc2626; }
|
||||
.notif-item-icon.type-belum_ditangani { background: rgba(107,114,128,.12); color: #4b5563; }
|
||||
.notif-item-icon .lucide { width: 15px; height: 15px; }
|
||||
.notif-item-text {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.notif-item-count {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 7px;
|
||||
border-radius: 20px;
|
||||
background: #f0ebe4;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hd-user {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 10px; border-radius: 9px;
|
||||
border: 1px solid var(--card-border);
|
||||
cursor: default; transition: background .15s;
|
||||
}
|
||||
.hd-avatar {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: #ede8e2; border: 1px solid #ddd8d2;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 13px; font-weight: 600; color: #201515;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hd-user-name { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||
.hd-user-role { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
.hd-logout-btn {
|
||||
background: transparent; border: none;
|
||||
color: var(--text-muted); cursor: pointer; font-size: 15px;
|
||||
padding: 4px; border-radius: 6px;
|
||||
transition: color .15s, background .15s;
|
||||
line-height: 1; text-decoration: none;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.hd-logout-btn:hover { color: #ef4444; background: #fef2f2; }
|
||||
|
||||
.hd-hamburger {
|
||||
width: 36px; height: 36px; background: transparent;
|
||||
border: none; cursor: pointer; border-radius: 8px;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px;
|
||||
transition: background .15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hd-hamburger:hover { background: #ede8e2; }
|
||||
.hd-hamburger span {
|
||||
display: block; width: 18px; height: 2px;
|
||||
background: var(--text-secondary); border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ── Content Area ─────────────────────────────────────── */
|
||||
.al-content { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px; }
|
||||
.al-content::-webkit-scrollbar { width: 6px; }
|
||||
.al-content::-webkit-scrollbar-thumb { background: #ddd8d2; border-radius: 6px; }
|
||||
|
||||
/* ── Dark Footer ─────────────────────────────────────── */
|
||||
.al-footer {
|
||||
background: #1c1612;
|
||||
color: #a89f96;
|
||||
font-size: 13px; font-weight: 400;
|
||||
padding: 14px 24px;
|
||||
border-top: 1px solid #130e0b;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.al-footer a { color: #a89f96; text-decoration: none; }
|
||||
.al-footer a:hover { color: #ffffff; }
|
||||
|
||||
/* ── Stats Cards ────────────────────────────────────── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--card-shadow);
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.stat-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||||
.stat-icon {
|
||||
width: 40px; height: 40px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; flex-shrink: 0;
|
||||
background: #ede8e2;
|
||||
}
|
||||
.stat-icon.teal { background: #f0fdf4; }
|
||||
.stat-icon.purple { background: #faf5ff; }
|
||||
.stat-icon.green { background: #f0fdf4; }
|
||||
.stat-icon.slate { background: #ede8e2; }
|
||||
.stat-icon.amber { background: #fffbeb; }
|
||||
.stat-icon.blue { background: #eff6ff; }
|
||||
|
||||
.stat-label { font-size: 12px; font-weight: 500; color: var(--text-muted); line-height: 1.4; }
|
||||
.stat-value { font-size: 32px; font-weight: 600; color: var(--text-primary); letter-spacing: -1px; line-height: 1; }
|
||||
.stat-footer { font-size: 12px; color: var(--text-muted); font-weight: 500; text-decoration: none; }
|
||||
.stat-footer:hover { color: var(--text-primary); text-decoration: underline; }
|
||||
|
||||
/* ── Dashboard Grid ─────────────────────────────────── */
|
||||
.dash-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 340px;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1300px) { .dash-grid { grid-template-columns: 1fr 1fr; } .dash-right { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3,1fr); gap:16px; } }
|
||||
@media (max-width: 900px) { .dash-grid { grid-template-columns: 1fr; } .dash-right { grid-template-columns: 1fr; } }
|
||||
|
||||
.dash-charts { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.dash-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
.dash-card-title {
|
||||
font-size: 15px; font-weight: 600; letter-spacing: -0.3px;
|
||||
color: var(--text-primary); margin-bottom: 16px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
}
|
||||
.dash-card-subtitle { font-size: 11px; color: var(--text-muted); font-weight: 400; }
|
||||
.chart-wrap { position: relative; height: 220px; }
|
||||
|
||||
/* ── Side Panel ──────────────────────────────────────── */
|
||||
.dash-right { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.dash-right .notif-item {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--hd-border);
|
||||
}
|
||||
.dash-right .notif-item:last-of-type { border-bottom: none; }
|
||||
.notif-dot {
|
||||
width: 32px; height: 32px; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; flex-shrink: 0;
|
||||
}
|
||||
.notif-dot.danger { background: #fef2f2; }
|
||||
.notif-dot.warn { background: #fffbeb; }
|
||||
.notif-dot.info { background: #eff6ff; }
|
||||
.notif-text { flex: 1; min-width: 0; }
|
||||
.notif-title { font-size: 12px; font-weight: 600; color: var(--text-primary); }
|
||||
.notif-body { font-size: 11px; color: var(--text-secondary); margin-top: 2px; line-height: 1.4; }
|
||||
.notif-time { font-size: 10px; color: var(--text-muted); white-space: nowrap; flex-shrink: 0; margin-top: 2px; }
|
||||
|
||||
.quick-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.quick-btn {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
padding: 12px 8px;
|
||||
background: #ede8e2; border: 1px solid #ddd8d2;
|
||||
border-radius: 9px; cursor: pointer;
|
||||
font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||||
font-family: var(--font); text-decoration: none;
|
||||
transition: background .15s, border-color .15s, color .15s;
|
||||
text-align: center; line-height: 1.3;
|
||||
}
|
||||
.quick-btn:hover { background: #0d7490; color: #ffffff; border-color: #0d7490; }
|
||||
.quick-btn-icon { font-size: 20px; }
|
||||
|
||||
.need-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.need-stat {
|
||||
background: #ede8e2; border: 1px solid #ddd8d2;
|
||||
border-radius: 10px; padding: 12px 14px;
|
||||
}
|
||||
.need-stat-val { font-size: 22px; font-weight: 600; letter-spacing: -0.5px; color: var(--text-primary); }
|
||||
.need-stat-val.amber { color: #d97706; }
|
||||
.need-stat-val.blue { color: #2563eb; }
|
||||
.need-stat-val.yellow { color: #ca8a04; }
|
||||
.need-stat-val.green { color: #16a34a; }
|
||||
.need-stat-lbl { font-size: 11px; color: var(--text-muted); margin-top: 2px; line-height: 1.3; }
|
||||
|
||||
/* ── Table ───────────────────────────────────────────── */
|
||||
.table-card {
|
||||
background: var(--card-bg); border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius); box-shadow: var(--card-shadow); overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.table-card-header {
|
||||
padding: 16px 20px; display: flex; align-items: center; justify-content: space-between;
|
||||
border-bottom: 1px solid var(--hd-border);
|
||||
}
|
||||
.table-card-title { font-size: 15px; font-weight: 600; letter-spacing: -0.3px; color: var(--text-primary); }
|
||||
.table-card-count { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
.btn-see-all {
|
||||
padding: 6px 14px; background: transparent;
|
||||
border: 1px solid var(--card-border); border-radius: 7px;
|
||||
font-size: 12px; font-weight: 600; color: var(--text-primary);
|
||||
cursor: pointer; font-family: var(--font);
|
||||
text-decoration: none; white-space: nowrap;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-see-all:hover { background: #ede8e2; }
|
||||
|
||||
table.admin-table { width: 100%; border-collapse: collapse; }
|
||||
table.admin-table th {
|
||||
text-align: left; font-size: 12px; font-weight: 500;
|
||||
color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px;
|
||||
padding: 12px 16px; background: #ede8e2;
|
||||
border-bottom: 1px solid var(--hd-border);
|
||||
}
|
||||
table.admin-table td { padding: 14px 16px; font-size: 13px; color: var(--text-primary); border-bottom: 1px solid #f3f4f6; vertical-align: middle; }
|
||||
table.admin-table tr:last-child td { border-bottom: none; }
|
||||
table.admin-table tr:hover td { background: #f5f0eb; }
|
||||
|
||||
.progress-bar-wrap { width: 100%; background: #ddd8d2; border-radius: 4px; height: 6px; overflow: hidden; }
|
||||
.progress-bar-fill { height: 100%; border-radius: 4px; background: #0d7490; transition: width .4s ease; }
|
||||
|
||||
/* ── Jenis Badges (pill shape) ───────────────────────── */
|
||||
.jenis-badge {
|
||||
display: inline-block; padding: 3px 10px; border-radius: 9999px;
|
||||
font-size: 11px; font-weight: 500;
|
||||
}
|
||||
.jenis-badge.masjid { background: #f0fdf4; color: #16a34a; }
|
||||
.jenis-badge.mushola { background: #eff6ff; color: #2563eb; }
|
||||
.jenis-badge.gereja { background: #faf5ff; color: #7c3aed; }
|
||||
.jenis-badge.pura { background: #fffbeb; color: #d97706; }
|
||||
.jenis-badge.vihara { background: #fff7ed; color: #ea580c; }
|
||||
.jenis-badge.klenteng { background: #fef2f2; color: #dc2626; }
|
||||
|
||||
.tbl-action-btn {
|
||||
width: 30px; height: 30px; border-radius: 7px;
|
||||
background: transparent; border: 1px solid var(--card-border);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; font-size: 14px; color: var(--text-muted);
|
||||
transition: background .15s, color .15s;
|
||||
text-decoration: none; line-height: 1;
|
||||
}
|
||||
.tbl-action-btn:hover { background: #ede8e2; color: #201515; border-color: #ddd8d2; }
|
||||
|
||||
.pagination {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 16px; border-top: 1px solid var(--hd-border);
|
||||
font-size: 12px; color: var(--text-muted);
|
||||
flex-wrap: wrap; gap: 8px;
|
||||
}
|
||||
.page-btns { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.page-btn {
|
||||
min-width: 30px; height: 30px; border-radius: 7px;
|
||||
background: transparent; border: 1px solid var(--card-border);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||||
font-family: var(--font); padding: 0 6px;
|
||||
transition: background .15s;
|
||||
}
|
||||
.page-btn:hover:not(:disabled) { background: #ede8e2; color: #201515; border-color: #ddd8d2; }
|
||||
.page-btn.active { background: #0d7490; border-color: #0d7490; color: #fff; }
|
||||
.page-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────── */
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 10px 20px; height: 40px;
|
||||
background: #0d7490; color: #ffffff;
|
||||
border: none; border-radius: 8px;
|
||||
font-family: var(--font); font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; text-decoration: none; white-space: nowrap;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-primary:hover { background: #0a5f7a; }
|
||||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 10px 20px; height: 40px;
|
||||
background: #fafaf9; color: #201515;
|
||||
border: 1px solid #ddd8d2; border-radius: 8px;
|
||||
font-family: var(--font); font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; text-decoration: none; white-space: nowrap;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-secondary:hover { background: #ede8e2; }
|
||||
|
||||
.btn-danger {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 10px 20px; height: 40px;
|
||||
background: #ef4444; color: #ffffff;
|
||||
border: none; border-radius: 8px;
|
||||
font-family: var(--font); font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
|
||||
/* ── Confirm Modal ───────────────────────────────────── */
|
||||
.confirm-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(32,21,21,.55);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px; z-index: 9999;
|
||||
}
|
||||
.confirm-box {
|
||||
width: min(420px, 100%);
|
||||
background: #fafaf9;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 16px rgba(32,21,21,.1);
|
||||
}
|
||||
.confirm-title { font-size: 15px; font-weight: 600; color: var(--text-primary); margin-bottom: 10px; }
|
||||
.confirm-msg { font-size: 13px; color: var(--text-secondary); line-height: 1.6; margin: 0; }
|
||||
.confirm-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
||||
|
||||
/* ── Forms & Inputs ──────────────────────────────────── */
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
height: 40px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #ddd8d2;
|
||||
border-radius: 8px;
|
||||
color: #201515;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
textarea { height: auto; min-height: 80px; }
|
||||
input:focus, select:focus, textarea:focus { border-color: #0d7490; }
|
||||
input:disabled, select:disabled, textarea:disabled { opacity: .5; cursor: not-allowed; background: #ede8e2; }
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px; font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ── Badge Pills ─────────────────────────────────────── */
|
||||
.badge-pill {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 3px 10px; border-radius: 9999px;
|
||||
font-size: 12px; font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Kategori Kemiskinan */
|
||||
.badge-sangat-miskin { background: #fef2f2; color: #ef4444; }
|
||||
.badge-miskin { background: #fffbeb; color: #f59e0b; }
|
||||
.badge-hampir-miskin { background: #f0fdf4; color: #16a34a; }
|
||||
/* Status Bantuan */
|
||||
.badge-belum { background: #ede8e2; color: #7a7067; }
|
||||
.badge-proses { background: #eff6ff; color: #3b82f6; }
|
||||
.badge-sudah { background: #f0fdf4; color: #16a34a; }
|
||||
/* Blank Spot / Covered */
|
||||
.badge-blankspot { background: #fef2f2; color: #ef4444; }
|
||||
.badge-covered { background: #f0fdf4; color: #16a34a; }
|
||||
|
||||
/* ── General Utilities ──────────────────────────────── */
|
||||
.text-success { color: #16a34a; }
|
||||
.text-warn { color: #d97706; }
|
||||
.text-danger { color: #dc2626; }
|
||||
.link-muted {
|
||||
font-size: 13px; color: var(--text-primary);
|
||||
font-weight: 500; text-decoration: none;
|
||||
}
|
||||
.link-muted:hover { text-decoration: underline; }
|
||||
.spinner {
|
||||
display: inline-block; width: 14px; height: 14px;
|
||||
border: 2px solid #ddd8d2; border-top-color: #0d7490;
|
||||
border-radius: 50%; animation: spin .7s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Page header for non-dashboard pages ────────────── */
|
||||
.page-header { margin-bottom: 24px; }
|
||||
.page-header h1 {
|
||||
font-size: 22px; font-weight: 600; letter-spacing: -0.5px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.page-header p { font-size: 14px; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
/* ── Lucide Icons Styling ───────────────────────────── */
|
||||
.lucide {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke-width: 2px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.sb-logo-icon .lucide {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.sb-icon .lucide {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.sb-toggle-icon .lucide {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.hd-notif-btn .lucide {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.hd-logout-btn .lucide {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once 'auth/helper.php';
|
||||
require_once 'koneksi.php';
|
||||
|
||||
if (!is_logged_in() || !has_role('administrator')) {
|
||||
header('Location: auth/login.php'); exit;
|
||||
}
|
||||
require_password_changed('auth/change_password.php');
|
||||
|
||||
$page_title = 'Dashboard Administrator';
|
||||
$page_subtitle = 'Ringkasan situasi kemiskinan di wilayah Anda';
|
||||
$active_nav = 'dashboard';
|
||||
include 'includes/page-start.php';
|
||||
?>
|
||||
|
||||
<!-- ── Stats Cards ──────────────────────────────────────── -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon teal"><i data-lucide="map-pin"></i></div>
|
||||
<div class="stat-label">Total Rumah Ibadah Aktif</div>
|
||||
</div>
|
||||
<div class="stat-value" id="sc-ibadah"><span class="spinner"></span></div>
|
||||
<a href="pages/map.php" class="stat-footer">Lihat di peta →</a>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon purple"><i data-lucide="home"></i></div>
|
||||
<div class="stat-label">Total KK Miskin Aktif</div>
|
||||
</div>
|
||||
<div class="stat-value" id="sc-kk"><span class="spinner"></span></div>
|
||||
<a href="pages/map.php" class="stat-footer">Lihat detail →</a>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon green"><i data-lucide="users"></i></div>
|
||||
<div class="stat-label">Total Jiwa Miskin Aktif</div>
|
||||
</div>
|
||||
<div class="stat-value" id="sc-jiwa"><span class="spinner"></span></div>
|
||||
<a href="pages/map.php" class="stat-footer">Lihat detail →</a>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon slate"><i data-lucide="check-circle-2"></i></div>
|
||||
<div class="stat-label">Total Jiwa Terjangkau</div>
|
||||
</div>
|
||||
<div class="stat-value" id="sc-terjangkau"><span class="spinner"></span></div>
|
||||
<a href="pages/map.php" class="stat-footer">Lihat detail →</a>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon amber"><i data-lucide="alert-triangle"></i></div>
|
||||
<div class="stat-label">Total Jiwa Blank Spot</div>
|
||||
</div>
|
||||
<div class="stat-value" id="sc-blankspot"><span class="spinner"></span></div>
|
||||
<a href="pages/map.php" class="stat-footer">Filter di peta →</a>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top">
|
||||
<div class="stat-icon blue"><i data-lucide="trending-up"></i></div>
|
||||
<div class="stat-label">Persentase Cakupan</div>
|
||||
</div>
|
||||
<div class="stat-value" id="sc-pct" style="font-size:22px;"><span class="spinner"></span></div>
|
||||
<a href="pages/map.php" class="stat-footer">Lihat detail →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Charts + Side Panel ──────────────────────────────── -->
|
||||
<div class="dash-grid">
|
||||
|
||||
<div class="dash-charts">
|
||||
|
||||
<!-- Donut: Sebaran Kategori -->
|
||||
<div class="dash-card">
|
||||
<div class="dash-card-title">
|
||||
Sebaran Kategori Kemiskinan
|
||||
<span class="dash-card-subtitle" id="donut-total"></span>
|
||||
</div>
|
||||
<div class="chart-wrap"><canvas id="chartKategori"></canvas></div>
|
||||
<div id="donut-legend" style="margin-top:14px;display:flex;flex-direction:column;gap:6px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Line: Tren Cakupan -->
|
||||
<div class="dash-card">
|
||||
<div class="dash-card-title">
|
||||
Tren Cakupan Penjangkauan
|
||||
<span class="dash-card-subtitle">(6 Bulan Terakhir)</span>
|
||||
</div>
|
||||
<div class="chart-wrap" style="height:260px;"><canvas id="chartTren"></canvas></div>
|
||||
<div id="tren-note" style="margin-top:8px;font-size:11px;color:var(--text-muted);">
|
||||
Persentase Cakupan = (Jiwa Terjangkau / Total Jiwa) × 100%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right Panel -->
|
||||
<div class="dash-right">
|
||||
|
||||
<!-- Peringatan & Notifikasi -->
|
||||
<div class="dash-card">
|
||||
<div class="dash-card-title">
|
||||
Peringatan & Notifikasi
|
||||
<span id="dash-notif-badge"
|
||||
style="display:none;background:#ef4444;color:#fff;font-size:10px;font-weight:600;padding:1px 7px;border-radius:10px;"></span>
|
||||
</div>
|
||||
<div id="dash-notif-list">
|
||||
<div style="font-size:12px;color:var(--text-muted);">
|
||||
<span class="spinner"></span> Memuat...
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="link-muted" disabled style="display:block;text-align:center;margin-top:12px;border:0;background:transparent;width:100%;cursor:not-allowed;">
|
||||
Semua notifikasi aktif tampil di panel ini
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Aksi Cepat -->
|
||||
<div class="dash-card">
|
||||
<div class="dash-card-title">Aksi Cepat</div>
|
||||
<div class="quick-grid">
|
||||
<a href="pages/map.php" class="quick-btn">
|
||||
<span class="quick-btn-icon"><i data-lucide="map-pin"></i></span> Buka Peta
|
||||
</a>
|
||||
<a href="pages/penduduk.php" class="quick-btn">
|
||||
<span class="quick-btn-icon"><i data-lucide="home"></i></span> Kelola Penduduk
|
||||
</a>
|
||||
<a href="pages/import.php" class="quick-btn" id="qa-import">
|
||||
<span class="quick-btn-icon"><i data-lucide="file-input"></i></span> Import CSV Penduduk
|
||||
</a>
|
||||
<a href="pages/analisis.php" class="quick-btn" id="qa-recalc">
|
||||
<span class="quick-btn-icon"><i data-lucide="refresh-cw"></i></span> Hitung Ulang Semua
|
||||
</a>
|
||||
<a href="pages/users.php" class="quick-btn">
|
||||
<span class="quick-btn-icon"><i data-lucide="users"></i></span> Manajemen Pengguna
|
||||
</a>
|
||||
<a href="pages/laporan.php" class="quick-btn">
|
||||
<span class="quick-btn-icon"><i data-lucide="file-output"></i></span> Export Laporan
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistik Kebutuhan -->
|
||||
<div class="dash-card">
|
||||
<div class="dash-card-title">
|
||||
Statistik Kebutuhan
|
||||
<span class="dash-card-subtitle" id="need-date"></span>
|
||||
</div>
|
||||
<div class="need-stats-grid">
|
||||
<div class="need-stat">
|
||||
<div class="need-stat-val amber" id="ns-kk-open">—</div>
|
||||
<div class="need-stat-lbl">KK dengan Kebutuhan Belum Terpenuhi</div>
|
||||
</div>
|
||||
<div class="need-stat">
|
||||
<div class="need-stat-val blue" id="ns-belum">—</div>
|
||||
<div class="need-stat-lbl">Total Kebutuhan Belum Terpenuhi</div>
|
||||
</div>
|
||||
<div class="need-stat">
|
||||
<div class="need-stat-val yellow" id="ns-proses">—</div>
|
||||
<div class="need-stat-lbl">Dalam Proses</div>
|
||||
</div>
|
||||
<div class="need-stat">
|
||||
<div class="need-stat-val green" id="ns-terpenuhi">—</div>
|
||||
<div class="need-stat-lbl">Terpenuhi</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="papan-kebutuhan.php" class="link-muted" style="display:block;text-align:right;margin-top:10px;">
|
||||
Lihat detail kebutuhan →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div><!-- .dash-right -->
|
||||
</div><!-- .dash-grid -->
|
||||
|
||||
<!-- ── Tabel Rekapitulasi ────────────────────────────────── -->
|
||||
<div class="table-card">
|
||||
<div class="table-card-header">
|
||||
<div>
|
||||
<div class="table-card-title">Rekapitulasi per Rumah Ibadah</div>
|
||||
<div class="table-card-count" id="rekap-count"></div>
|
||||
</div>
|
||||
<a href="pages/map.php" class="btn-see-all">Lihat semua</a>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:44px;">No</th>
|
||||
<th>Nama Rumah Ibadah</th>
|
||||
<th>Jenis</th>
|
||||
<th style="text-align:right;">KK Binaan</th>
|
||||
<th style="text-align:right;">Jiwa Binaan</th>
|
||||
<th style="min-width:160px;">% Sudah Ditangani</th>
|
||||
<th style="width:88px;text-align:center;">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rekapTbody">
|
||||
<tr><td colspan="7" style="text-align:center;padding:28px;color:var(--text-muted);">
|
||||
<span class="spinner"></span> Memuat data...
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<span id="rekap-info"></span>
|
||||
<div class="page-btns" id="rekap-pages"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
function fmt(n) { return Number(n || 0).toLocaleString('id-ID'); }
|
||||
function fmtP(n) { return Number(n || 0).toFixed(2).replace('.', ',') + '%'; }
|
||||
function esc(s) { const d = document.createElement('div'); d.appendChild(document.createTextNode(s ?? '')); return d.innerHTML; }
|
||||
function now_label() {
|
||||
const d = new Date();
|
||||
const M = ['Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agt','Sep','Okt','Nov','Des'];
|
||||
return `Data per ${d.getDate()} ${M[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
// ── Load Stats ────────────────────────────────────────────
|
||||
async function loadStats() {
|
||||
let j;
|
||||
try {
|
||||
const r = await fetch('api/stats/ambil.php?_=' + Date.now());
|
||||
j = await r.json();
|
||||
} catch(e) { return; }
|
||||
if (j.status !== 'success') return;
|
||||
const d = j.data;
|
||||
|
||||
document.getElementById('sc-ibadah').textContent = fmt(d.total_ibadah);
|
||||
document.getElementById('sc-kk').textContent = fmt(d.total_kk);
|
||||
document.getElementById('sc-jiwa').textContent = fmt(d.total_jiwa);
|
||||
document.getElementById('sc-terjangkau').textContent = fmt(d.jiwa_terjangkau);
|
||||
document.getElementById('sc-blankspot').textContent = fmt(d.jiwa_blank_spot);
|
||||
document.getElementById('sc-pct').textContent = fmtP(d.pct_cakupan);
|
||||
|
||||
// Kebutuhan stats
|
||||
const rk = d.rekap_kebutuhan || [];
|
||||
let belum = 0, proses = 0, terpenuhi = 0;
|
||||
rk.forEach(k => {
|
||||
belum += parseInt(k.belum || 0);
|
||||
proses += parseInt(k.proses || 0);
|
||||
terpenuhi += parseInt(k.terpenuhi || 0);
|
||||
});
|
||||
document.getElementById('ns-kk-open').textContent = fmt(d.kk_kebutuhan_open);
|
||||
document.getElementById('ns-belum').textContent = fmt(belum);
|
||||
document.getElementById('ns-proses').textContent = fmt(proses);
|
||||
document.getElementById('ns-terpenuhi').textContent = fmt(terpenuhi);
|
||||
document.getElementById('need-date').textContent = now_label();
|
||||
|
||||
renderDonut(d.kategori || []);
|
||||
renderRekap(d.rekap_ibadah || []);
|
||||
}
|
||||
|
||||
// ── Donut Chart ────────────────────────────────────────────
|
||||
let _donut = null;
|
||||
function renderDonut(rows) {
|
||||
const labels = rows.map(r => r.kategori);
|
||||
const data = rows.map(r => parseInt(r.jml_jiwa));
|
||||
const total = data.reduce((a, b) => a + b, 0);
|
||||
const colors = ['#ef4444', '#f59e0b', '#3b82f6'];
|
||||
|
||||
document.getElementById('donut-total').textContent = total > 0 ? fmt(total) + ' jiwa total' : '';
|
||||
|
||||
if (_donut) _donut.destroy();
|
||||
_donut = new Chart(document.getElementById('chartKategori'), {
|
||||
type: 'doughnut',
|
||||
data: { labels, datasets: [{ data, backgroundColor: colors, borderWidth: 3, borderColor: '#fff' }] },
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false, cutout: '65%',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: { label: ctx => ` ${fmt(ctx.parsed)} jiwa (${fmtP(total > 0 ? ctx.parsed / total * 100 : 0)})` } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('donut-legend').innerHTML = rows.map((r, i) => {
|
||||
const pct = total > 0 ? fmtP(parseInt(r.jml_jiwa) / total * 100) : '0%';
|
||||
return `<div style="display:flex;align-items:center;gap:8px;font-size:12px;">
|
||||
<div style="width:11px;height:11px;border-radius:3px;background:${colors[i]};flex-shrink:0;"></div>
|
||||
<span style="font-weight:600;color:var(--text-primary);">${esc(r.kategori)}</span>
|
||||
<span style="color:var(--text-muted);">${fmt(r.jml_jiwa)} jiwa · ${pct}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Trend Line Chart ───────────────────────────────────────
|
||||
let _tren = null;
|
||||
async function loadTren() {
|
||||
let j;
|
||||
try {
|
||||
const r = await fetch('api/dashboard/trend.php?_=' + Date.now());
|
||||
j = await r.json();
|
||||
} catch(e) { return; }
|
||||
if (j.status !== 'success' || !j.data) return;
|
||||
|
||||
const labels = j.data.map(d => d.bulan);
|
||||
const data = j.data.map(d => parseFloat(d.pct_cakupan));
|
||||
|
||||
if (_tren) _tren.destroy();
|
||||
_tren = new Chart(document.getElementById('chartTren'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Cakupan (%)', data,
|
||||
borderColor: '#0d7490', backgroundColor: 'rgba(13,116,144,.06)',
|
||||
fill: true, tension: 0.4,
|
||||
pointBackgroundColor: '#0d7490', pointBorderColor: '#fafaf9',
|
||||
pointBorderWidth: 2, pointRadius: 5, pointHoverRadius: 7,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: { min: -10, max: 110,
|
||||
ticks: { callback: v => v >= 0 && v <= 100 ? v + '%' : null, font: { size: 10 }, color: '#7a7067', stepSize: 10 },
|
||||
grid: { color: '#ede8e2' }
|
||||
},
|
||||
x: {
|
||||
ticks: { font: { size: 10 }, color: '#7a7067' },
|
||||
grid: { display: false }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: { label: ctx => ' ' + fmtP(ctx.parsed.y) } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Notifikasi ─────────────────────────────────────────────
|
||||
function renderNotif(items, total) {
|
||||
const el = document.getElementById('dash-notif-list');
|
||||
const badge = document.getElementById('dash-notif-badge');
|
||||
if (!el) return;
|
||||
|
||||
if (badge) {
|
||||
badge.textContent = total > 99 ? '99+' : total;
|
||||
badge.style.display = total > 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
el.innerHTML = '<div style="font-size:12px;color:var(--text-muted);text-align:center;padding:12px 0;"><i data-lucide="check" style="color:var(--text-success);margin-right:4px;"></i> Tidak ada notifikasi baru.</div>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons({attrs: {class: 'lucide'}});
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = items.map(it => `
|
||||
<a href="${esc(it.page || '#')}" class="notif-item">
|
||||
<div class="notif-dot ${esc(it.type)}"><i data-lucide="${esc(it.icon)}"></i></div>
|
||||
<div class="notif-text">
|
||||
<div class="notif-title">${esc(it.label)}</div>
|
||||
<div class="notif-body">${fmt(it.count)} item aktif</div>
|
||||
</div>
|
||||
<div class="notif-time">Aktif</div>
|
||||
</a>`).join('');
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons({attrs: {class: 'lucide'}});
|
||||
}
|
||||
|
||||
async function loadDashboardNotifs() {
|
||||
const el = document.getElementById('dash-notif-list');
|
||||
if (el) {
|
||||
el.innerHTML = '<div style="font-size:12px;color:var(--text-muted);"><span class="spinner"></span> Memuat...</div>';
|
||||
}
|
||||
try {
|
||||
const r = await fetch('api/notif/ambil.php?_=' + Date.now(), { cache: 'no-store' });
|
||||
const j = await r.json();
|
||||
if (j.status !== 'success') throw new Error('notif failed');
|
||||
renderNotif(j.items || [], parseInt(j.total || 0));
|
||||
} catch (e) {
|
||||
if (el) el.innerHTML = '<div style="font-size:12px;color:var(--text-muted);text-align:center;padding:12px 0;">Gagal memuat notifikasi.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rekap Table + Pagination ───────────────────────────────
|
||||
const PER_PAGE = 5;
|
||||
let _allRows = [], _page = 1;
|
||||
|
||||
function renderRekap(rows) {
|
||||
_allRows = rows;
|
||||
_page = 1;
|
||||
document.getElementById('rekap-count').textContent =
|
||||
`Menampilkan ${Math.min(PER_PAGE, rows.length)} dari ${rows.length} rumah ibadah`;
|
||||
_drawPage();
|
||||
}
|
||||
|
||||
function _drawPage() {
|
||||
const start = (_page - 1) * PER_PAGE;
|
||||
const slice = _allRows.slice(start, start + PER_PAGE);
|
||||
const pages = Math.ceil(_allRows.length / PER_PAGE) || 1;
|
||||
const tbody = document.getElementById('rekapTbody');
|
||||
|
||||
if (slice.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:28px;color:var(--text-muted);">Belum ada data rumah ibadah.</td></tr>';
|
||||
document.getElementById('rekap-info').textContent = '';
|
||||
document.getElementById('rekap-pages').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const badgeCls = { Masjid:'masjid', Mushola:'mushola', Gereja:'gereja', Pura:'pura', Vihara:'vihara', Klenteng:'klenteng' };
|
||||
tbody.innerHTML = slice.map((r, i) => {
|
||||
const jiwa = parseInt(r.jml_jiwa || 0);
|
||||
const tang = parseInt(r.jml_ditangani || 0);
|
||||
const pct = jiwa > 0 ? Math.round(tang / jiwa * 100) : 0;
|
||||
const col = pct >= 60 ? '#16a34a' : pct >= 30 ? '#f59e0b' : '#ef4444';
|
||||
const badge = `<span class="jenis-badge ${badgeCls[r.jenis] || ''}">${esc(r.jenis)}</span>`;
|
||||
return `<tr>
|
||||
<td style="color:var(--text-muted);font-size:12px;">${start + i + 1}</td>
|
||||
<td style="font-weight:600;">${esc(r.nama)}</td>
|
||||
<td>${badge}</td>
|
||||
<td style="text-align:right;font-family:var(--font-mono);">${fmt(r.jml_kk)}</td>
|
||||
<td style="text-align:right;font-family:var(--font-mono);">${fmt(jiwa)}</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div class="progress-bar-wrap" style="flex:1;">
|
||||
<div class="progress-bar-fill" style="width:${pct}%;background:${col};"></div>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:600;color:${col};min-width:36px;">${pct}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<a href="pages/map.php" class="tbl-action-btn" title="Lihat di Peta"><i data-lucide="eye"></i></a>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const endRow = Math.min(start + PER_PAGE, _allRows.length);
|
||||
document.getElementById('rekap-info').textContent =
|
||||
`Menampilkan ${start + 1}–${endRow} dari ${_allRows.length} rumah ibadah`;
|
||||
|
||||
let html = `<button class="page-btn" onclick="goPage(${_page - 1})" ${_page === 1 ? 'disabled' : ''}><i data-lucide="chevron-left"></i></button>`;
|
||||
for (let p = 1; p <= pages; p++)
|
||||
html += `<button class="page-btn ${p === _page ? 'active' : ''}" onclick="goPage(${p})">${p}</button>`;
|
||||
html += `<button class="page-btn" onclick="goPage(${_page + 1})" ${_page === pages ? 'disabled' : ''}><i data-lucide="chevron-right"></i></button>`;
|
||||
document.getElementById('rekap-pages').innerHTML = html;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons({attrs: {class: 'lucide'}});
|
||||
}
|
||||
|
||||
function goPage(p) {
|
||||
const pages = Math.ceil(_allRows.length / PER_PAGE) || 1;
|
||||
if (p < 1 || p > pages) return;
|
||||
_page = p;
|
||||
_drawPage();
|
||||
}
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────
|
||||
window.addEventListener('load', function () {
|
||||
loadStats();
|
||||
loadTren();
|
||||
loadDashboardNotifs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php include 'includes/page-end.php'; ?>
|
||||
@@ -0,0 +1,52 @@
|
||||
# Business Process SOP - WebGIS Poverty Mapping
|
||||
|
||||
## Status Dokumen
|
||||
|
||||
Dokumen ini adalah acuan proses bisnis aktif. Untuk detail teknis, lihat `docs/codebase-navigation.md`.
|
||||
|
||||
## Aktor
|
||||
|
||||
### Administrator
|
||||
|
||||
Administrator mengatur master data, user, verifikasi, import, export, dan recalculate proximity. Administrator juga bertanggung jawab memastikan data yang tampil ke publik sudah `Terverifikasi`.
|
||||
|
||||
### Operator
|
||||
|
||||
Operator mengelola warga, kebutuhan, dan status bantuan dalam rumah ibadah yang ditugaskan. Data warga yang dibuat operator masuk `Pending` sampai Administrator melakukan Verifikasi.
|
||||
|
||||
### Publik
|
||||
|
||||
Publik dapat melihat peta dan papan kebutuhan tanpa melihat NIK, nama KK, alamat detail, catatan, atau koordinat rumah warga. Publik diperlakukan sebagai `viewer` read-only.
|
||||
|
||||
### Donatur
|
||||
|
||||
Donatur mengirim kontak dan kategori minat bantuan melalui papan kebutuhan publik. Saat ini flow Donatur masih berupa kontak umum, belum pledge ke kebutuhan spesifik.
|
||||
|
||||
## Alur Data Warga
|
||||
|
||||
1. Administrator atau Operator menambahkan warga.
|
||||
2. Sistem menghitung Proximity dari titik warga ke rumah ibadah aktif.
|
||||
3. Data yang dibuat Administrator langsung `Terverifikasi`.
|
||||
4. Data yang dibuat Operator masuk `Pending`.
|
||||
5. Administrator melakukan Verifikasi: approve menjadi `Terverifikasi` atau reject menjadi `Ditolak`.
|
||||
6. Hanya warga `Terverifikasi` yang dapat masuk workflow bantuan/kebutuhan publik.
|
||||
|
||||
## Recalculate Proximity
|
||||
|
||||
Recalculate dapat mengubah rumah ibadah terdekat warga. Karena model saat ini `proximity-only`, assignment operator dapat berubah mengikuti hasil spasial terbaru setelah posisi/radius rumah ibadah diubah.
|
||||
|
||||
Untuk data kecil-menengah, recalculate langsung masih dapat dipahami dan dirawat. Jika jumlah warga/rumah ibadah menjadi besar, gunakan pendekatan batch atau queue agar proses tidak membuat request admin terasa lama.
|
||||
|
||||
## Status Bantuan dan Kebutuhan
|
||||
|
||||
Status bantuan warga bergerak dari `Belum Ditangani`, `Dalam Proses`, sampai `Sudah Ditangani`.
|
||||
|
||||
Status kebutuhan bergerak dari `Belum Terpenuhi`, `Dalam Proses`, sampai `Terpenuhi`.
|
||||
|
||||
## Catatan Audit
|
||||
|
||||
Setiap perubahan status bantuan atau kebutuhan sebaiknya memiliki Catatan agar riwayat mudah dipahami. Catatan menjawab pertanyaan sederhana: kenapa status ini berubah.
|
||||
|
||||
## Batas Flow Donatur Saat Ini
|
||||
|
||||
Donatur belum memilih kebutuhan spesifik. Admin membaca kontak donatur, lalu menindaklanjuti manual di luar sistem. Jika ingin closed-loop, perlu tabel donasi/pledge dan status konfirmasi.
|
||||
@@ -0,0 +1,303 @@
|
||||
# Codebase Audit Report - WebGIS Poverty Mapping
|
||||
|
||||
> Updated: 2026-06-03. Laporan ini merangkum proses bisnis aktif, wiring backend/frontend, lifecycle data, catatan logika, prioritas fix, dan saran pengembangan berdasarkan traverse codebase dan dokumentasi lokal.
|
||||
|
||||
## Ringkasan Eksekutif
|
||||
|
||||
Proyek ini adalah aplikasi WebGIS PHP prosedural + MySQLi + Leaflet untuk memetakan penduduk miskin, rumah ibadah, radius layanan, blank spot, status bantuan, kebutuhan bantuan, papan kebutuhan publik, import/export, dan dashboard admin.
|
||||
|
||||
Implementasi aktif sudah cukup konsisten dengan dokumentasi teknis di `README.md`, `docs/codebase-navigation.md`, dan `docs/superpowers/plans/2026-06-03-codebase-audit-refresh.md`. Dokumen lama di `Tugas/` dan sebagian plan historis bukan sumber kebenaran implementasi aktif karena masih membawa artifact lama atau rencana fase yang sudah selesai.
|
||||
|
||||
Status audit refresh 2026-06-03:
|
||||
|
||||
1. Validasi koordinat sudah dipusatkan di `includes/validation.php` dan dipakai oleh endpoint mutasi/import utama.
|
||||
2. Import admin sudah menghasilkan warga `Terverifikasi` beserta `verified_by` dan `verified_at`.
|
||||
3. Policy NIK sudah dibuat ketat: NIK dicek terhadap semua record, termasuk arsip/inactive, agar sejalan dengan unique index.
|
||||
4. Statistik operator sudah memakai scope rumah ibadah operator.
|
||||
5. Workflow kebutuhan dan status bantuan sudah dikunci untuk warga `Terverifikasi`.
|
||||
6. Heatmap publik sudah disembunyikan karena koordinat warga publik tetap dimasking.
|
||||
7. User management dan soft delete rumah ibadah sudah memiliki guard tambahan.
|
||||
|
||||
Risiko residual terbesar saat ini:
|
||||
|
||||
1. Form kontak donatur publik belum memiliki rate limit/CAPTCHA.
|
||||
2. Model ownership `proximity-only` bisa mengubah scope operator setelah recalculate; pertimbangkan model hybrid jika dipakai multi-operator serius.
|
||||
3. Test runner masih ad-hoc; belum ada satu command tunggal untuk semua static guard dan DB smoke test.
|
||||
4. Dependency CDN/tile provider eksternal perlu strategi mirror lokal untuk deployment jaringan tertutup.
|
||||
|
||||
## Triage Review Tambahan 2026-06-03
|
||||
|
||||
Review tambahan menyorot beberapa risiko bisnis yang perlu dibedakan antara bug yang sudah jelas, keputusan produk yang perlu dikunci, dan optimasi jangka menengah.
|
||||
|
||||
### Valid Dan Perlu Masuk Prioritas
|
||||
|
||||
1. Aksi kebutuhan pada warga `Pending` belum dikunci. Endpoint `api/kebutuhan/simpan.php` dan `api/kebutuhan/update_status.php` hanya mengecek warga aktif, tidak mengecek `status_verifikasi`. UI popup juga membangun section kebutuhan untuk user login tanpa membedakan status verifikasi. Jika warga ditolak admin, data kebutuhan yang sudah dibuat tetap tertinggal dan bisa menjadi data tidak relevan.
|
||||
2. Heatmap publik tidak akan berfungsi dengan data warga saat ini karena `api/penduduk/ambil.php` mengosongkan `lat` dan `lng` untuk non-privileged user, sementara `modules/heatmap.js` membutuhkan koordinat valid. Ini adalah tradeoff privasi vs visualisasi yang perlu diputuskan eksplisit.
|
||||
3. Import admin memang perlu policy yang lebih jelas. Solusi minimum adalah set hasil import admin ke `Terverifikasi`; solusi lebih matang adalah staging/bulk verify sebelum commit permanen.
|
||||
4. NIK vs soft delete memang perlu strategi lifecycle. Mengubah NIK saat soft delete adalah opsi cepat, tetapi perlu audit trail jelas agar data historis tidak membingungkan.
|
||||
5. Refresh data lintas modul sebaiknya distandarkan. Pola event atau `window.refreshAllData()` akan mengurangi state drift antar map, list, stats, dan heatmap.
|
||||
|
||||
### Perlu Keputusan Produk
|
||||
|
||||
1. Kepemilikan warga operator saat ini ditentukan oleh hasil proximity, bukan input bebas operator. Pada `api/penduduk/simpan.php`, data operator tidak dipindahkan diam-diam ke rumah ibadah lain; request ditolak jika titik tidak masuk rumah ibadah operator. Namun, `ibadah_id` warga dapat berubah setelah admin mengubah posisi/radius rumah ibadah atau menjalankan recalculation. Produk perlu memilih:
|
||||
- ownership dinamis berbasis proximity, cocok untuk analisis spasial otomatis;
|
||||
- ownership terkunci berbasis operator input, cocok untuk tanggung jawab operasional;
|
||||
- hybrid: simpan `assigned_ibadah_id` untuk ownership operasional dan `nearest_ibadah_id` untuk hasil analisis spasial.
|
||||
2. Spatial fuzzing untuk publik dapat menghidupkan heatmap publik, tetapi titik jitter 50-100 meter tetap perlu aturan konsisten agar tidak menciptakan kesan akurasi palsu. Alternatif yang lebih aman adalah endpoint agregat grid/kelurahan untuk heatmap publik, bukan titik warga individual.
|
||||
|
||||
### Keputusan Eksekusi Audit 2026-06-03
|
||||
|
||||
1. Model ownership warga untuk batch fix ini tetap `proximity-only`. `penduduk_miskin.ibadah_id` tetap menjadi hasil `calc_proximity()` dan menjadi dasar scope operator. Perubahan schema `assigned_ibadah_id`/`nearest_ibadah_id` tidak dilakukan dalam batch ini agar tidak mengubah kontrak bisnis besar tanpa PRD lanjutan.
|
||||
2. Strategi heatmap publik untuk batch fix ini adalah `hidden`: kontrol heatmap disembunyikan untuk viewer publik karena koordinat warga publik tetap dimasking. Endpoint agregat grid/kelurahan dapat dirancang sebagai pengembangan lanjutan jika produk membutuhkan visualisasi heatmap publik tanpa membocorkan koordinat rumah warga.
|
||||
|
||||
### Optimasi Jangka Menengah
|
||||
|
||||
1. Bounding box untuk Haversine bagus untuk pencarian kandidat terdekat, tetapi recalculation saat perubahan rumah ibadah tetap perlu menyentuh semua warga aktif jika sistem ingin memastikan semua assignment global tetap benar. Optimasi yang lebih tepat bisa berupa index lat/lng, batch processing, atau recalculation queue.
|
||||
2. Closed-loop donatur ke kebutuhan spesifik akan meningkatkan akuntabilitas, tetapi membutuhkan model data tambahan seperti `donasi`, `donasi_kebutuhan`, status pledge/confirmed, dan audit penyelesaian.
|
||||
|
||||
### Batas Skala Proximity Saat Ini
|
||||
|
||||
`calc_proximity()` melakukan scan semua rumah ibadah aktif untuk setiap warga. Ini sederhana dan benar untuk data kecil-menengah, tetapi recalculate/import dapat melambat jika jumlah warga atau rumah ibadah besar.
|
||||
|
||||
Opsi peningkatan:
|
||||
|
||||
1. Tambah index `lat` dan `lng` untuk membantu query kandidat.
|
||||
2. Gunakan bounding box kasar sebelum Haversine.
|
||||
3. Jalankan recalculate dalam batch.
|
||||
4. Simpan job recalculate jika proses lebih dari beberapa detik.
|
||||
|
||||
Jangan mengganti proximity engine sebelum ada data performa nyata. Algoritma saat ini mudah dipahami dan sudah punya test. Optimasi harus menjaga hasil assignment tetap sama.
|
||||
|
||||
## Dokumentasi Yang Berlaku
|
||||
|
||||
| File | Status | Catatan |
|
||||
| --- | --- | --- |
|
||||
| `README.md` | Aktif | Setup, stack, role, fitur, dan test. |
|
||||
| `docs/codebase-navigation.md` | Aktif | Referensi teknis paling lengkap untuk struktur, API, modul frontend, DB, RBAC, proximity. |
|
||||
| `setup_database.sql` | Aktif | Schema otoritatif dan seed admin default. |
|
||||
| `docs/superpowers/specs/2026-05-24-poverty-mapping-prd.md` | Historis/produk | PRD berguna untuk konteks fitur, tetapi status implementasi perlu diupdate. |
|
||||
| `docs/superpowers/plans/*.md` | Historis implementasi | Log rencana/fase kerja. |
|
||||
| `Tugas/*` | Artifact tugas lama | Masih menyebut jalan/parsil/POI; jangan dijadikan acuan implementasi aktif. |
|
||||
| `DESIGN.md` | Artifact desain lama | Bukan referensi desain aktif WebGIS. |
|
||||
|
||||
## Proses Bisnis Aktif
|
||||
|
||||
1. Administrator menyiapkan sistem dengan mengatur `config.php` dan menjalankan `setup_database.sql`.
|
||||
2. Administrator login memakai akun default `admin / Admin1234`, lalu wajib mengganti password.
|
||||
3. Administrator membuat dan mengelola `rumah_ibadah`: nama, jenis, alamat, kontak, koordinat, dan `radius_m`.
|
||||
4. Administrator membuat akun operator dan mengaitkannya ke satu rumah ibadah melalui `users.ibadah_id`.
|
||||
5. Administrator atau operator mendata `penduduk_miskin` berbasis koordinat.
|
||||
6. Backend menjalankan `calc_proximity()` di `koneksi.php` untuk menentukan rumah ibadah terdekat yang mencakup warga.
|
||||
7. Jika warga masuk radius salah satu rumah ibadah, sistem menyimpan `ibadah_id`, `jarak_m`, dan `is_blank_spot=0`; jika tidak, `ibadah_id=NULL`, `jarak_m` ke rumah ibadah terdekat, dan `is_blank_spot=1`.
|
||||
8. Data yang dibuat administrator langsung `Terverifikasi`; data yang dibuat operator masuk `Pending` dan perlu diverifikasi admin.
|
||||
9. Publik tanpa login diperlakukan sebagai viewer: hanya data `Terverifikasi`, PII dimasking, dan koordinat rumah warga tidak dikirim.
|
||||
10. Operator mengelola warga dalam scope rumah ibadahnya: status bantuan, riwayat bantuan, dan kebutuhan bantuan.
|
||||
11. Kebutuhan `Belum Terpenuhi` dari warga terverifikasi diagregasi ke `papan-kebutuhan.php`.
|
||||
12. Donatur publik mengirim kontak/minat bantuan; admin membaca dan menandai kontak donatur sebagai read.
|
||||
13. Admin mengakses dashboard, tren, import CSV, export CSV/PDF, arsip, verifikasi, user management, dan recalculation proximity.
|
||||
|
||||
## Wiring Backend
|
||||
|
||||
Backend tidak memakai framework MVC. File endpoint di `api/` berperan sebagai controller, `koneksi.php` dan `auth/helper.php` sebagai helper/service utama, dan schema di `setup_database.sql` sebagai model data.
|
||||
|
||||
### Entry Point Dan Helper
|
||||
|
||||
| Area | File | Peran |
|
||||
| --- | --- | --- |
|
||||
| Konfigurasi | `config.php` | Konstanta app, session, database, map center. |
|
||||
| DB/proximity | `koneksi.php` | Koneksi global `$conn`, `haversine_distance()`, `calc_proximity()`. |
|
||||
| Auth/RBAC | `auth/helper.php` | Session, role hierarchy, `require_auth()`, `has_role()`, CSRF. |
|
||||
| Peta publik/auth-aware | `index.php` | Shell utama Leaflet dan module loader. |
|
||||
| Dashboard | `dashboard.php` | Ringkasan admin dan trend dashboard. |
|
||||
| Halaman admin/operator | `pages/*.php` | UI domain per fitur. |
|
||||
| API | `api/**/*.php` | Endpoint JSON/CSV/HTML per domain. |
|
||||
|
||||
### Endpoint Per Domain
|
||||
|
||||
| Domain | Endpoint Utama | Akses |
|
||||
| --- | --- | --- |
|
||||
| Auth | `api/auth/login.php`, `change_password.php`, `logout.php` | Login/logout, ganti password, lockout. |
|
||||
| Rumah ibadah | `api/ibadah/ambil.php`, `simpan.php`, `update.php`, `hapus.php`, `update_posisi.php`, `update_radius.php`, `update_kontak.php`, `recalculate.php` | GET publik untuk data aktif; mutasi admin + CSRF. |
|
||||
| Penduduk | `api/penduduk/ambil.php`, `simpan.php`, `update_posisi.php`, `update_status.php`, `riwayat.php`, `verifikasi.php`, `hapus.php`, `nonaktifkan.php`, `arsip.php` | Public read masked; operator/admin mutasi scoped; verifikasi/admin. |
|
||||
| Kebutuhan | `api/kebutuhan/ambil.php`, `simpan.php`, `update_status.php` | Operator/admin scoped + CSRF untuk mutasi. |
|
||||
| Statistik | `api/stats/ambil.php`, `api/dashboard/trend.php` | Public/operator/admin stats; trend admin. |
|
||||
| Papan publik | `api/papan/ambil.php`, `kontak_donatur.php`, `donatur.php` | Public needs/donor form; admin donor inbox. |
|
||||
| Users | `api/users/ambil.php`, `simpan.php`, `update.php`, `reset_password.php`, `toggle_active.php` | Admin + CSRF untuk mutasi. |
|
||||
| Import/export | `api/import/csv.php`, `template.php`, `api/export/csv.php`, `pdf.php` | Admin. |
|
||||
|
||||
### Auth Dan Scope
|
||||
|
||||
- Role hierarchy: `viewer < operator < administrator`.
|
||||
- Unauthenticated map user diperlakukan sebagai viewer.
|
||||
- `require_auth('operator')` mengizinkan operator dan administrator.
|
||||
- Mutasi terautentikasi memakai `require_csrf()`.
|
||||
- Operator dibatasi oleh `get_ibadah_id()` dan `penduduk_miskin.ibadah_id` di endpoint penduduk/kebutuhan/riwayat.
|
||||
- Public endpoint penduduk hanya mengirim data terverifikasi dan memasking `nik`, `nama_kk`, `alamat`, `catatan`, `lat`, dan `lng`.
|
||||
|
||||
## Wiring Frontend
|
||||
|
||||
Frontend adalah PHP-rendered pages + vanilla JavaScript global modules. Tidak ada bundler, framework SPA, atau package manager JS.
|
||||
|
||||
### Entrypoint
|
||||
|
||||
| File | Peran |
|
||||
| --- | --- |
|
||||
| `index.html` | Redirect legacy ke `index.php`. |
|
||||
| `index.php` | Shell peta utama, inject `window.APP_USER`, Leaflet, Chart.js, module loader. |
|
||||
| `papan-kebutuhan.php` | Papan kebutuhan publik dan form kontak donatur. |
|
||||
| `auth/login.php` | Login form POST ke API auth. |
|
||||
| `auth/change_password.php` | Ganti password dengan CSRF. |
|
||||
| `dashboard.php` | Dashboard admin. |
|
||||
| `pages/map.php` | Iframe `../index.php?embedded=1` untuk map di shell admin. |
|
||||
| `pages/*.php` | Halaman domain admin/operator. |
|
||||
|
||||
### Modul Map
|
||||
|
||||
| Module | State/Entry | API Yang Dipakai |
|
||||
| --- | --- | --- |
|
||||
| `modules/ibadah.js` | `window.initIbadah()`, `window._ibadahList` | `api/ibadah/ambil.php`, `simpan.php`, `update_posisi.php`, `update_kontak.php`, `update_radius.php`, `hapus.php`, `recalculate.php`. |
|
||||
| `modules/penduduk.js` | `window.initPenduduk()`, `window._pendudukList` | `api/penduduk/ambil.php`, `simpan.php`, `update_posisi.php`, `update_status.php`, `riwayat.php`, `hapus.php`, `nonaktifkan.php`, `verifikasi.php`. |
|
||||
| `modules/kebutuhan.js` | Loaded only for authenticated user | `api/kebutuhan/ambil.php`, `simpan.php`, `update_status.php`. |
|
||||
| `modules/stats.js` | `window._showDashboard()`, `window._statsUpdate()` | `api/stats/ambil.php`. |
|
||||
| `modules/heatmap.js` | `window._toggleHeatmap()`, `window._heatmapUpdate()` | Uses `window._pendudukList`; no direct API. |
|
||||
|
||||
### Flow Frontend-ke-Frontend
|
||||
|
||||
- `index.php` injects `window.APP_USER` for role, login status, assigned `ibadahId`, and CSRF token.
|
||||
- `window.MAP_MODULES` loads module scripts sequentially; once complete, calls `initIbadah()` then `initPenduduk()`.
|
||||
- `ibadah.js` fills `window._ibadahList`, then `penduduk.js` uses it for client-side proximity preview.
|
||||
- `penduduk.js` fills `window._pendudukList`, then `stats.js` and `heatmap.js` read it for modal stats/heatmap.
|
||||
- `ibadah.js` can call `_pendudukRecalcAll()` after ibadah changes.
|
||||
- `penduduk.js` calls `_statsUpdate()`, `_heatmapUpdate()`, and blank spot counter update after list changes.
|
||||
- `pages/map.php` embeds map in iframe, so page state and map state communicate through API/server rather than shared memory.
|
||||
|
||||
## Data Lifecycle
|
||||
|
||||
### Rumah Ibadah
|
||||
|
||||
- Dibuat/diedit oleh admin.
|
||||
- `radius_m` menentukan cakupan layanan.
|
||||
- Perubahan posisi/radius/delete memicu recalculation semua penduduk aktif.
|
||||
- Delete bersifat soft delete dengan `deleted_at`.
|
||||
|
||||
### Penduduk Miskin
|
||||
|
||||
- Data aktif adalah `is_active=1 AND deleted_at IS NULL`.
|
||||
- `deleted_at` dipakai untuk koreksi entry salah.
|
||||
- `is_active=0` dipakai untuk warga pindah/meninggal/arsip real-world.
|
||||
- `status_verifikasi` mengontrol visibilitas publik: hanya `Terverifikasi` yang muncul ke publik/papan kebutuhan.
|
||||
- `status_bantuan` mengontrol workflow bantuan: `Belum Ditangani`, `Dalam Proses`, `Sudah Ditangani`.
|
||||
- `riwayat_bantuan` append-only untuk audit perubahan status bantuan.
|
||||
|
||||
### Kebutuhan
|
||||
|
||||
- Kebutuhan menempel ke `penduduk_miskin`.
|
||||
- Status: `Belum Terpenuhi`, `Dalam Proses`, `Terpenuhi`.
|
||||
- `riwayat_kebutuhan` append-only untuk audit status kebutuhan.
|
||||
- Papan publik mengagregasi kebutuhan `Belum Terpenuhi` dari warga aktif dan terverifikasi.
|
||||
|
||||
### User
|
||||
|
||||
- `administrator` mengelola sistem penuh.
|
||||
- `operator` wajib dikaitkan ke satu rumah ibadah untuk scope kerja.
|
||||
- `viewer` masih ada di backend sebagai fallback/kompatibilitas, tetapi UI tidak menawarkan pembuatan akun viewer baru.
|
||||
- Password default/temp memaksa `must_change_password=1`.
|
||||
|
||||
### Donatur
|
||||
|
||||
- Publik mengirim kontak ke `kontak_donatur`.
|
||||
- Admin melihat dan menandai semua unread sebagai read.
|
||||
- Belum ada rate limit/spam mitigation yang terdokumentasi.
|
||||
|
||||
## Catatan Logika Dan Risiko
|
||||
|
||||
### Prioritas Tinggi Audit Awal - Status Refresh 2026-06-03
|
||||
|
||||
| Temuan Audit Awal | Status Saat Ini | Referensi |
|
||||
| --- | --- | --- |
|
||||
| Validasi koordinat tidak konsisten. | Selesai: helper `validate_lat_lng()` dipakai di endpoint utama dan import. | `includes/validation.php`, `api/penduduk/simpan.php`, `api/penduduk/update_posisi.php`, `api/ibadah/simpan.php`, `api/ibadah/update.php`, `api/ibadah/update_posisi.php`, `api/import/csv.php` |
|
||||
| Import admin tidak mengisi `status_verifikasi`. | Selesai: import admin set `Terverifikasi`, `verified_by`, `verified_at`. | `api/import/csv.php` |
|
||||
| Unique NIK global tidak cocok dengan pengecekan duplikat hanya data aktif. | Selesai dengan policy ketat: semua record dicek, termasuk arsip/inactive. | `api/penduduk/simpan.php`, `api/import/csv.php`, `setup_database.sql` |
|
||||
| Statistik operator berpotensi global. | Selesai: stats memakai scope operator. | `api/stats/ambil.php` |
|
||||
| Update status bantuan dari popup map bisa membuat UI stale. | Sebagian besar selesai: update state lokal, refresh list, dan dispatch event data changed. Tetap perlu test manual end-to-end di browser. | `modules/penduduk.js`, `index.php` |
|
||||
| Kebutuhan dapat dibuat/diubah untuk warga `Pending`. | Selesai: endpoint menolak non-`Terverifikasi`, UI dibuat read-only/filtered. | `api/kebutuhan/simpan.php`, `api/kebutuhan/update_status.php`, `modules/kebutuhan.js`, `pages/kebutuhan.php` |
|
||||
| Heatmap publik kosong karena koordinat publik dimasking. | Selesai dengan strategi `hidden`: kontrol heatmap hanya untuk operator/admin. | `index.php`, `modules/heatmap.js` |
|
||||
|
||||
### Prioritas Menengah
|
||||
|
||||
| Temuan | Dampak | Referensi |
|
||||
| --- | --- | --- |
|
||||
| Soft delete rumah ibadah tidak memakai `AND deleted_at IS NULL` dan tidak cek `affected_rows`. | Request ke ID deleted/tidak ada bisa tampak sukses. | `api/ibadah/hapus.php:40` |
|
||||
| Update kontak rumah ibadah tidak mengecek `deleted_at IS NULL`. | Rumah ibadah soft-deleted masih bisa diubah kontaknya. | `api/ibadah/update_kontak.php:23` |
|
||||
| Client dan server sama-sama punya logic proximity. | Rawan drift jika aturan proximity berubah. | `koneksi.php:28`, `modules/penduduk.js` |
|
||||
| Heatmap memakai `_pendudukList` yang bisa sudah terfilter/visible. | Heatmap berubah diam-diam saat filter/layer berubah, tidak selalu merepresentasikan dataset penuh. | `modules/heatmap.js` |
|
||||
| UI status bantuan tidak mengirim `catatan`, padahal backend menyimpan audit note. | Audit trail kurang informatif. | `api/penduduk/update_status.php`, `modules/penduduk.js` |
|
||||
| Backend masih menerima create/update role `viewer`. | Kebijakan UI dan backend tidak sepenuhnya sinkron. | `api/users/simpan.php`, `api/users/update.php`, `README.md` |
|
||||
| Admin bisa self-demotion via update user. | Risiko kehilangan akses admin jika tidak ada admin lain. | `api/users/update.php` |
|
||||
|
||||
### Prioritas Rendah
|
||||
|
||||
| Temuan | Dampak | Referensi |
|
||||
| --- | --- | --- |
|
||||
| Sidebar punya dead link `Pengaturan Sistem` dan `Aktivitas & Audit Log`. | Navigasi terlihat belum selesai. | `includes/page-start.php` |
|
||||
| Naming legacy `searchPoi` dan toast POI masih ada. | Membingungkan karena fitur POI legacy sudah dihapus. | `index.php` |
|
||||
| Papan publik memakai inline `onclick` untuk string kategori. | Saat ini mitigated oleh enum kategori, tetapi pola rapuh. | `papan-kebutuhan.php` |
|
||||
| `DESIGN.md` berada di root walau bukan referensi aktif. | Developer baru bisa salah membaca arah desain. | `DESIGN.md` |
|
||||
|
||||
## Rekomendasi Fix
|
||||
|
||||
### Backend
|
||||
|
||||
1. Tambahkan helper validasi koordinat bersama, misalnya `validate_lat_lng($lat, $lng)` dan pakai di semua endpoint penduduk/ibadah/import.
|
||||
2. Putuskan policy import admin: jika import dianggap aksi admin final, set `status_verifikasi='Terverifikasi'`, `verified_by=get_user_id()`, `verified_at=NOW()` pada insert import.
|
||||
3. Selaraskan NIK dengan lifecycle soft delete/inactive:
|
||||
- opsi cepat: cek semua NIK termasuk inactive/deleted dan tampilkan pesan jelas;
|
||||
- opsi lebih baik: ubah constraint ke unique untuk NIK aktif saja via generated column/partial strategy sesuai DB yang dipakai.
|
||||
4. Kunci endpoint kebutuhan untuk warga non-`Terverifikasi`, atau set policy eksplisit bahwa kebutuhan hanya boleh dibuat setelah approve admin.
|
||||
5. Tambahkan operator scope ke `api/stats/ambil.php` bila operator tidak boleh melihat agregat global.
|
||||
6. Perketat endpoint soft-delete/update dengan `deleted_at IS NULL`, `affected_rows`, dan response 404/error saat data tidak ditemukan.
|
||||
7. Tambahkan guard user management untuk self-demotion dan admin terakhir.
|
||||
8. Ekstrak helper response JSON, method guard, CSRF/method utility, coordinate validation, recalc-all, dan operator scope check agar endpoint tidak terus menduplikasi pola.
|
||||
|
||||
### Frontend
|
||||
|
||||
1. Setelah update status bantuan berhasil, update state lokal lalu panggil refresh list/stat/heatmap.
|
||||
2. Jadikan backend sebagai sumber kebenaran proximity setelah perubahan posisi/radius rumah ibadah; reload penduduk dari API setelah recalc penting.
|
||||
3. Pisahkan dataset mentah dan dataset visible, misalnya `_pendudukAll` dan `_pendudukVisible`.
|
||||
4. Buat helper JS bersama untuk `fetchJson`, `appendCsrf`, escape, render badge, dan status update.
|
||||
5. Tambahkan modal input `catatan` untuk perubahan status bantuan/kebutuhan, atau hapus ekspektasi catatan dari audit UI jika tidak dipakai.
|
||||
6. Disable action kebutuhan/status bantuan untuk warga `Pending`/`Ditolak` di UI operator.
|
||||
7. Pilih strategi heatmap publik: sembunyikan kontrol untuk viewer, gunakan endpoint agregat grid, atau gunakan spatial fuzzing dengan disclaimer akurasi.
|
||||
8. Ganti inline event string di `papan-kebutuhan.php` dengan `data-*` dan `addEventListener`.
|
||||
9. Bersihkan dead link dan naming legacy POI.
|
||||
|
||||
### Dokumentasi/Operasional
|
||||
|
||||
1. Dokumen deployment utama sekarang ada di `docs/deployment-docker.md`; `docs/deployment-xampp.md` dipertahankan sebagai referensi legacy.
|
||||
2. Tambahkan dokumentasi dependency eksternal: Leaflet, Chart.js, leaflet.heat, Google Fonts, OSM/Esri/Carto tiles, Nominatim.
|
||||
3. Tambahkan `config.example.php` atau `.env.example` jika proyek akan dipakai lintas mesin.
|
||||
4. Update PRD dengan status implemented/partial/pending.
|
||||
5. Pindahkan atau arsipkan `DESIGN.md` ke `docs/archive/` agar root documentation tidak ambigu.
|
||||
|
||||
## Saran Pengembangan
|
||||
|
||||
- Tambahkan audit log admin yang bisa dicari untuk perubahan user, ibadah, verifikasi, dan import.
|
||||
- Tambahkan rate limiting atau CAPTCHA ringan untuk `api/papan/kontak_donatur.php`.
|
||||
- Tambahkan batch job/recalculate queue jika jumlah data penduduk besar, karena update radius/posisi ibadah saat ini loop semua warga aktif.
|
||||
- Tambahkan export laporan untuk blank spot dan kebutuhan per kategori.
|
||||
- Tambahkan map bounds/wilayah studi di config agar koordinat di luar Pontianak bisa ditolak lebih awal.
|
||||
- Tambahkan test runner tunggal untuk menjalankan seluruh `tests/test_*.php` dengan urutan yang jelas.
|
||||
- Pertimbangkan service/helper layer ringan untuk domain `Penduduk`, `Ibadah`, `Kebutuhan`, dan `User` agar endpoint procedural tetap mudah dirawat.
|
||||
|
||||
## Prioritas Eksekusi Disarankan
|
||||
|
||||
1. Fix validasi koordinat dan import verification policy.
|
||||
2. Kunci workflow kebutuhan/status bantuan untuk warga belum `Terverifikasi`.
|
||||
3. Fix NIK lifecycle/constraint mismatch.
|
||||
4. Putuskan ownership model: proximity-only, operator-locked, atau hybrid `assigned_ibadah_id` + `nearest_ibadah_id`.
|
||||
5. Putuskan strategi heatmap publik: hidden, agregat grid, atau spatial fuzzing.
|
||||
6. Scope operator untuk statistik bila memang data agregat harus terbatas.
|
||||
7. Fix stale UI status bantuan dan heatmap dataset.
|
||||
8. Hardening user management dan soft-delete endpoint.
|
||||
9. Cleanup dead link/legacy naming.
|
||||
10. Tambah deployment/config docs.
|
||||
@@ -0,0 +1,347 @@
|
||||
# Codebase Navigation — WebGIS Poverty Mapping
|
||||
|
||||
> Quick reference for navigating the project. Updated: 2026-06-03.
|
||||
|
||||
---
|
||||
|
||||
## Directory Tree at a Glance
|
||||
|
||||
```
|
||||
WebgisPovertyMapping/
|
||||
├── index.php ← Main app shell (auth-aware)
|
||||
├── index.html ← Redirect/legacy entry from older prototype
|
||||
├── dashboard.php ← Authenticated admin/operator shell
|
||||
├── papan-kebutuhan.php ← Public needs board page
|
||||
├── config.php ← All app constants (DB, session, map center)
|
||||
├── koneksi.php ← DB connection + Haversine + calc_proximity()
|
||||
├── setup_database.sql ← Full schema (idempotent, run to init DB)
|
||||
│
|
||||
├── auth/
|
||||
│ ├── helper.php ← RBAC core: is_logged_in(), require_auth(), has_role()
|
||||
│ ├── login.php ← Login page
|
||||
│ └── change_password.php ← Forced password-change page
|
||||
│
|
||||
├── pages/
|
||||
│ ├── map.php ← Dashboard wrapper embedding index.php?embedded=1
|
||||
│ ├── penduduk.php ← Operator/admin penduduk management
|
||||
│ ├── kebutuhan.php ← Operator/admin kebutuhan workflow
|
||||
│ ├── status-bantuan.php ← Operator/admin aid status workflow
|
||||
│ ├── ibadah.php ← Admin rumah ibadah management
|
||||
│ ├── users.php ← Admin user management
|
||||
│ ├── import.php ← Admin CSV import
|
||||
│ ├── laporan.php ← Admin export/report page
|
||||
│ └── analisis.php ← Admin analytics/recalculation page
|
||||
│
|
||||
├── modules/ ← Frontend JS modules (loaded by index.php)
|
||||
│ ├── ibadah.js ← Rumah Ibadah layer: markers, circles, CRUD
|
||||
│ ├── penduduk.js ← Penduduk layer: proximity coloring, CRUD, filters
|
||||
│ ├── stats.js ← Dashboard modal: cards + chart + rekap table
|
||||
│ ├── heatmap.js ← Heatmap overlay (leaflet.heat, weighted by jiwa)
|
||||
│ └── kebutuhan.js ← Kebutuhan bantuan popup/actions
|
||||
│
|
||||
├── css/
|
||||
│ └── admin.css ← Shared dashboard/page styling
|
||||
│
|
||||
├── includes/
|
||||
│ ├── page-start.php ← Authenticated page shell start
|
||||
│ └── page-end.php ← Authenticated page shell end
|
||||
│
|
||||
├── api/
|
||||
│ ├── auth/ ← login.php · logout.php · change_password.php
|
||||
│ ├── users/ ← ambil · simpan · update · reset_password · toggle_active
|
||||
│ ├── ibadah/ ← ambil · simpan · update · hapus · update_posisi · update_kontak · update_radius · recalculate
|
||||
│ ├── penduduk/ ← ambil · simpan · verifikasi · hapus · nonaktifkan · update_posisi · update_status · riwayat · arsip
|
||||
│ ├── stats/ ← ambil.php (aggregations)
|
||||
│ ├── kebutuhan/ ← ambil · simpan · update_status
|
||||
│ ├── papan/ ← public board + donor contact endpoints
|
||||
│ ├── import/ ← csv.php · template.php
|
||||
│ └── export/ ← csv.php · pdf.php
|
||||
│
|
||||
├── tests/
|
||||
│ ├── test_db.php ← Schema smoke test
|
||||
│ ├── test_auth_helper.php ← Auth helper unit tests
|
||||
│ ├── test_ibadah.php ← Ibadah column + radius tests
|
||||
│ ├── test_penduduk.php ← Penduduk columns + NIK index tests
|
||||
│ ├── test_kebutuhan.php ← Kebutuhan schema/query smoke tests
|
||||
│ ├── test_proximity.php ← Haversine + tie-breaking unit tests
|
||||
│ ├── test_verifikasi_binding.php ← Verification endpoint bind order guard
|
||||
│ ├── test_update_posisi_scope.php ← Penduduk move scope guard
|
||||
│ ├── test_operator_scope_endpoints.php ← Operator endpoint scope checks
|
||||
│ ├── test_public_verification_filters.php← Public verification filters
|
||||
│ ├── test_frontend_module_loader.php ← Module loader cache-busting guard
|
||||
│ ├── test_frontend_reliability_guards.php ← Loader/heatmap/recalc guard checks
|
||||
│ ├── test_no_legacy_poi.php ← Legacy POI removal guard
|
||||
│ ├── test_viewer_public_filters.php ← Viewer public-data leakage guard
|
||||
│ ├── test_operator_scope_strict.php ← Strict operator ibadah scope guard
|
||||
│ ├── test_penduduk_binding_types.php ← Penduduk/import bind type guard
|
||||
│ ├── test_csrf_enforcement.php ← CSRF helper/endpoint/frontend wiring guard
|
||||
│ ├── test_session_cookie_security.php ← Session cookie security guard
|
||||
│ ├── test_operator_ibadah_highlight.php ← Operator assigned-ibadah highlight guard
|
||||
│ └── test_viewer_account_ui_hidden.php ← Viewer account creation UI guard
|
||||
│
|
||||
├── Tugas/ ← Assignment docs (SRS, use-case diagram)
|
||||
└── docs/
|
||||
├── codebase-navigation.md ← This file
|
||||
└── superpowers/
|
||||
├── specs/ ← PRD and design specs
|
||||
└── plans/ ← Phase implementation plans
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Essential Files — Start Here
|
||||
|
||||
| File | Why You Need It |
|
||||
|------|-----------------|
|
||||
| [config.php](../config.php) | All configurable constants — DB credentials, map center (CENTER_LAT/LNG), SESSION_TIMEOUT, MAX_LOGIN_ATTEMPTS |
|
||||
| [koneksi.php](../koneksi.php) | DB connection + the two core functions: `haversine_distance()` and `calc_proximity()` — the heart of the proximity engine |
|
||||
| [setup_database.sql](../setup_database.sql) | Authoritative schema — run this to initialize or reset the DB |
|
||||
| [auth/helper.php](../auth/helper.php) | RBAC foundation used by every endpoint: `require_auth()`, `has_role()`, `is_logged_in()`, session timeout logic |
|
||||
| [index.php](../index.php) | Main SPA shell — role detection, toolbar rendering, all modals, map init, utility functions (`showToast`, `showDeleteConfirm`, etc.) |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| [codebase-audit-report.md](codebase-audit-report.md) | End-to-end business process, backend/frontend wiring, data lifecycle, current logic concerns, fix priorities, and development suggestions. |
|
||||
| [business-process-sop.md](business-process-sop.md) | SOP proses bisnis aktif untuk administrator, operator, publik, donatur, verifikasi, proximity, dan catatan audit. |
|
||||
| [deployment-docker.md](deployment-docker.md) | Docker/Coolify deployment, reverse proxy routing, database container, and troubleshooting. |
|
||||
| [deployment-xampp.md](deployment-xampp.md) | Legacy XAMPP setup if the app must run without Docker. |
|
||||
| [superpowers/README.md](superpowers/README.md) | Index PRD, implementation plans, and the latest superpowers audit refresh. |
|
||||
| [superpowers/plans/2026-06-03-codebase-audit-refresh.md](superpowers/plans/2026-06-03-codebase-audit-refresh.md) | Latest codebase audit snapshot after hardening. |
|
||||
| [../README.md](../README.md) | Setup, stack, default account, roles, features, and test commands. |
|
||||
| [../setup_database.sql](../setup_database.sql) | Authoritative database schema and default admin seed. |
|
||||
|
||||
---
|
||||
|
||||
## Database Tables
|
||||
|
||||
### Core v1.0 Tables
|
||||
|
||||
**`rumah_ibadah`** — Worship places with service radius
|
||||
| Key Columns | Notes |
|
||||
|-------------|-------|
|
||||
| `id`, `nama`, `jenis` (ENUM 6 types), `lat/lng` | Core identity |
|
||||
| `radius_m` INT DEFAULT 500 | Validated 100–5000 |
|
||||
| `deleted_at` TIMESTAMP NULL | Soft delete — `WHERE deleted_at IS NULL` for all queries |
|
||||
|
||||
**`penduduk_miskin`** — Poor residents (core data)
|
||||
| Key Columns | Notes |
|
||||
|-------------|-------|
|
||||
| `id`, `nama_kk`, `nik` VARCHAR(16) UNIQUE | NIK nullable but unique |
|
||||
| `lat/lng`, `ibadah_id` FK, `jarak_m`, `is_blank_spot` | Proximity results — computed by `calc_proximity()` |
|
||||
| `kategori` ENUM 3 levels, `status_bantuan` ENUM 3 states | Classification + aid tracking |
|
||||
| `status_verifikasi` ENUM('Pending','Terverifikasi','Ditolak') | Public visibility gate |
|
||||
| `is_active` TINYINT(1) | 0 = deceased/relocated (archived) |
|
||||
| `deleted_at` TIMESTAMP NULL | Admin error correction (soft delete) |
|
||||
|
||||
> **`is_active=0` vs `deleted_at IS NOT NULL`:** Both hide the record from all active queries. `is_active=0` = real person, archived. `deleted_at` = data entry error, logically cancelled.
|
||||
|
||||
**`users`** — System accounts
|
||||
| Key Columns | Notes |
|
||||
|-------------|-------|
|
||||
| `role` ENUM('administrator','operator','viewer') | RBAC basis |
|
||||
| `ibadah_id` FK | Operator's assigned worship place |
|
||||
| `must_change_password` TINYINT(1) | Forces redirect to change_password.php on login |
|
||||
| `login_attempts`, `locked_until` | Brute-force protection state |
|
||||
|
||||
**`riwayat_bantuan`** — Aid status audit log (append-only)
|
||||
| Key Columns | Notes |
|
||||
|-------------|-------|
|
||||
| `penduduk_id`, `operator_id` FK | Who changed what |
|
||||
| `status_lama`, `status_baru`, `catatan` | Change record |
|
||||
| No `deleted_at` | Intentionally — immutable audit trail |
|
||||
|
||||
**`kebutuhan`** — Assistance needs per resident
|
||||
| Key Columns | Notes |
|
||||
|-------------|-------|
|
||||
| `penduduk_id`, `kategori`, `deskripsi`, `status` | Need details and fulfillment state |
|
||||
| `created_by`, `updated_by` | Operator/admin audit references |
|
||||
|
||||
**`riwayat_kebutuhan`** — Kebutuhan status audit log (append-only)
|
||||
| Key Columns | Notes |
|
||||
|-------------|-------|
|
||||
| `kebutuhan_id`, `operator_id` FK | Which need changed and who changed it |
|
||||
| `status_lama`, `status_baru`, `catatan` | Status transition record |
|
||||
| `created_at` | Append-only timestamp for audit chronology |
|
||||
|
||||
**`kontak_donatur`** — Public donor contact submissions
|
||||
| Key Columns | Notes |
|
||||
|-------------|-------|
|
||||
| `nama`, `kontak`, `kategori_minat`, `pesan` | Public contact form data |
|
||||
| `is_read` | Admin dashboard notification state |
|
||||
|
||||
## API Endpoint Map
|
||||
|
||||
### Public (no auth required)
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `api/ibadah/ambil.php` | GET | All active ibadah + KK/jiwa counts |
|
||||
| `api/penduduk/ambil.php` | GET | Active penduduk — PII masked for non-auth |
|
||||
| `api/stats/ambil.php` | GET | Aggregate stats; rekap_ibadah only for admin |
|
||||
| `api/papan/ambil.php` | GET | Public verified kebutuhan board aggregation |
|
||||
| `api/papan/kontak_donatur.php` | POST | Public donor contact submission |
|
||||
|
||||
### Operator+ (operator or admin)
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `api/penduduk/simpan.php` | POST | Add penduduk (auto-calls calc_proximity) |
|
||||
| `api/penduduk/update_posisi.php` | POST | Move marker (recalcs proximity) |
|
||||
| `api/penduduk/update_status.php` | POST | Change aid status + write audit log |
|
||||
| `api/penduduk/riwayat.php` | GET | Aid history for a penduduk (scoped to own ibadah for operators) |
|
||||
| `api/kebutuhan/ambil.php` | GET | List kebutuhan for a scoped penduduk |
|
||||
| `api/kebutuhan/simpan.php` | POST | Add kebutuhan for a scoped penduduk |
|
||||
| `api/kebutuhan/update_status.php` | POST | Update kebutuhan status + write audit log |
|
||||
|
||||
### Admin only
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `api/ibadah/simpan.php` | POST | Create ibadah |
|
||||
| `api/ibadah/update.php` | POST | Update ibadah master data from admin page |
|
||||
| `api/ibadah/hapus.php` | POST | Soft-delete ibadah (warns if operators linked) |
|
||||
| `api/ibadah/update_posisi.php` | POST | Move ibadah → triggers full recalc |
|
||||
| `api/ibadah/update_radius.php` | POST | Change radius → triggers full recalc |
|
||||
| `api/ibadah/update_kontak.php` | POST | Update contact only |
|
||||
| `api/ibadah/recalculate.php` | POST | Bulk recalculate all proximity |
|
||||
| `api/penduduk/verifikasi.php` | POST | Approve/reject pending penduduk records |
|
||||
| `api/penduduk/hapus.php` | POST | Soft-delete penduduk (data entry error) |
|
||||
| `api/penduduk/nonaktifkan.php` | POST | Set is_active=0 (deceased/relocated) |
|
||||
| `api/penduduk/arsip.php` | GET | List inactive/deleted warga for audit (up to 200 rows) |
|
||||
| `api/papan/donatur.php` | GET/POST | GET lists donor contacts; POST marks unread entries as read with CSRF |
|
||||
| `api/users/ambil.php` | GET | List all accounts |
|
||||
| `api/users/simpan.php` | POST | Create account (must_change_password=1 forced) |
|
||||
| `api/users/update.php` | POST | Edit nama/role/ibadah_id |
|
||||
| `api/users/reset_password.php` | POST | Generate temp password + must_change=1 |
|
||||
| `api/users/toggle_active.php` | POST | Enable/disable account (cannot self-deactivate) |
|
||||
| `api/import/csv.php` | POST | CSV import (mode=preview or mode=import) |
|
||||
| `api/import/template.php` | GET | Download CSV template |
|
||||
| `api/export/csv.php` | GET | Export warga binaan as CSV (no NIK) |
|
||||
| `api/export/pdf.php` | GET | Print-ready HTML report (no NIK) |
|
||||
|
||||
---
|
||||
|
||||
## Frontend Module Map
|
||||
|
||||
### `modules/ibadah.js`
|
||||
- **Entry:** `window.initIbadah()` — called by index.php on load
|
||||
- **Shared state:** `window._ibadahList[]` — read by penduduk.js for client-side proximity preview
|
||||
- **Key functions:**
|
||||
- `addToMap(data)` — creates draggable marker + radius circle
|
||||
- `buildInfoPopup(data)` — admin: editable kontak/radius/export links; viewer: read-only
|
||||
- `_ibadahHapus(id)` — two-step: operator-warning check → confirm → soft-delete
|
||||
- `_recalcAll()` — calls recalculate.php, disables all CRUD buttons during operation
|
||||
- `window._ibadahToggleLayer(bool)` / `_setBufferVisible(bool)` — layer controls
|
||||
|
||||
### `modules/penduduk.js`
|
||||
- **Entry:** `window.initPenduduk()` — called by index.php on load
|
||||
- **Shared state:** `window._pendudukList[]` — read by stats.js and heatmap.js
|
||||
- **Key functions:**
|
||||
- `buildInfoPopup(data, ibadah)` — proximity section + NIK (auth only) + status buttons (operator) + riwayat
|
||||
- `findNearestIbadah(lat, lng)` — client-side Haversine mirror of PHP `calc_proximity()`
|
||||
- `applyFilters()` — AND-logic filter across 4 dimensions + search
|
||||
- `window._pendudukRecalcAll()` — client-side repaint after ibadah change (no server call)
|
||||
- `_updateBlankSpotCounter()` / `_filterBlankSpot()` — blank spot counter + click-to-filter
|
||||
|
||||
### `modules/stats.js`
|
||||
- **Entry:** `window._showDashboard()` — opens modal
|
||||
- `window._statsUpdate()` — re-fetches and re-renders (only if modal visible)
|
||||
- Destroys and recreates Chart.js instance on each update to avoid memory leaks
|
||||
|
||||
### `modules/heatmap.js`
|
||||
- **Entry:** `window._toggleHeatmap(bool)` — creates/destroys heatmap layer
|
||||
- Intensity = `0.2 + 0.8 * (jumlah_jiwa / maxJiwa)` — proportional to household size
|
||||
- `window._heatmapSetKategori(k)` — filter heatmap by category
|
||||
|
||||
### `modules/kebutuhan.js`
|
||||
- **Entry:** loaded for authenticated users through `window.MAP_MODULES`
|
||||
- Builds kebutuhan controls inside penduduk popups
|
||||
- Calls `api/kebutuhan/ambil.php`, `api/kebutuhan/simpan.php`, and `api/kebutuhan/update_status.php`
|
||||
|
||||
---
|
||||
|
||||
## Core Algorithm: Proximity Engine
|
||||
|
||||
**Location:** [koneksi.php](../koneksi.php) — `calc_proximity($conn, $lat, $lng)`
|
||||
|
||||
**Logic:**
|
||||
1. Query all `rumah_ibadah WHERE deleted_at IS NULL ORDER BY id ASC`
|
||||
2. For each, compute Haversine distance
|
||||
3. Track two candidates: `best_in_radius` (nearest within radius) and `best_overall` (nearest regardless)
|
||||
4. Winner = `best_in_radius` if exists; else `best_overall` (blank spot)
|
||||
5. Tie-breaking: `ORDER BY id ASC` + strict `<` comparison → lowest id wins on equal distance
|
||||
|
||||
**Triggers:**
|
||||
- `api/penduduk/simpan.php` — on each new penduduk
|
||||
- `api/penduduk/update_posisi.php` — when penduduk moved
|
||||
- `api/ibadah/update_posisi.php` — after ibadah position change (loops ALL penduduk)
|
||||
- `api/ibadah/update_radius.php` — after radius change (loops ALL penduduk)
|
||||
- `api/ibadah/hapus.php` — after soft-delete (loops ALL penduduk)
|
||||
- `api/ibadah/recalculate.php` — manual bulk trigger (admin "Hitung Ulang Semua")
|
||||
- `api/import/csv.php` — per-row during import
|
||||
|
||||
**Client-side mirror:** `modules/penduduk.js` — `findNearestIbadah()` for instant visual feedback during drag/radius resize (no server call).
|
||||
|
||||
---
|
||||
|
||||
## RBAC Quick Reference
|
||||
|
||||
```
|
||||
viewer (0) → public/fallback read-only map, aggregated stats, PII masked
|
||||
operator (1) → + add/move penduduk, update aid status (own ibadah only)
|
||||
administrator (2) → + everything: CRUD ibadah, manage users, import/export, recalculate
|
||||
```
|
||||
|
||||
Operators are created by administrators in `pages/users.php` and must be linked to one `rumah_ibadah`.
|
||||
The shared admin shell renders navigation by role: administrators see dashboard/master/import/export/user management, while operators see map, penduduk, kebutuhan, and status bantuan only.
|
||||
Unauthenticated visitors are treated as `viewer`, so the admin user-management UI does not offer `viewer` for new accounts. The backend role remains for fallback RBAC and existing read-only accounts.
|
||||
|
||||
**Session flow:**
|
||||
1. Unauthenticated → `index.php` renders as `viewer` (no redirect)
|
||||
2. Login → `api/auth/login.php` → session set → if `must_change_password=1`, redirect to `auth/change_password.php`
|
||||
3. Every page/API: `auth/helper.php:is_logged_in()` validates session + renews `last_activity`
|
||||
4. Idle 2hr → session expired → treated as viewer on next request
|
||||
|
||||
---
|
||||
|
||||
## Legacy Status
|
||||
|
||||
The obsolete road/polyline, parcel/polygon, and business-location prototype features have been removed from the active app.
|
||||
|
||||
Active core:
|
||||
- `rumah_ibadah`
|
||||
- `penduduk_miskin`
|
||||
- `users`
|
||||
- `kebutuhan`
|
||||
- `riwayat_bantuan`
|
||||
- `riwayat_kebutuhan`
|
||||
- `kontak_donatur`
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
Tests are intended to run from CLI/local PHP. Browser access to `/tests/` is blocked by `.htaccess` for deployment safety.
|
||||
|
||||
Run selected static/smoke tests from the project root:
|
||||
|
||||
```powershell
|
||||
& 'C:\Program Files\Xampp\php\php.exe' tests\run_all.php
|
||||
```
|
||||
|
||||
Some database smoke tests load `../config.php` or `../koneksi.php` and should be run from the `tests` directory after importing `setup_database.sql`.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Quick-Start
|
||||
|
||||
Edit [config.php](../config.php) to set:
|
||||
```php
|
||||
define('CENTER_LAT', -0.0557); // Map center latitude
|
||||
define('CENTER_LNG', 109.3487); // Map center longitude
|
||||
define('ZOOM_LEVEL', 15); // Initial zoom
|
||||
define('DB_HOST', 'localhost');
|
||||
define('DB_PORT', 3307); // Local configured MySQL port
|
||||
define('DB_NAME', 'db_webgis');
|
||||
```
|
||||
|
||||
Default admin credentials (must change on first login): `admin` / `Admin1234`
|
||||
@@ -0,0 +1,105 @@
|
||||
# Deployment Docker
|
||||
|
||||
Panduan ini menjadi referensi utama untuk menjalankan WebGIS Poverty Mapping. XAMPP tidak lagi menjadi jalur deploy utama; gunakan XAMPP hanya sebagai fallback legacy.
|
||||
|
||||
## Runtime
|
||||
|
||||
- PHP 8.2 Apache dari `Dockerfile` root repository.
|
||||
- MariaDB 11.4 dari `docker-compose.yml`.
|
||||
- Database aplikasi final: `db_webgis`.
|
||||
- Database project kelas `01/`: `db_webgis_01`.
|
||||
- Aplikasi final tersedia di path `/WebgisPovertyMapping/`.
|
||||
|
||||
## Struktur Docker
|
||||
|
||||
File Docker berada di root repository `webgis/`:
|
||||
|
||||
```text
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
docker-compose.local.yml
|
||||
.env.example
|
||||
docker/apache-webgis.conf
|
||||
```
|
||||
|
||||
Service:
|
||||
|
||||
- `app`: Apache + PHP, melayani root landing page, `01/`, dan `WebgisPovertyMapping/`.
|
||||
- `db`: MariaDB, private untuk service `app`.
|
||||
|
||||
## Local Development
|
||||
|
||||
Jalankan dari root repository:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build -d
|
||||
```
|
||||
|
||||
Buka:
|
||||
|
||||
```text
|
||||
http://localhost:8080/
|
||||
http://localhost:8080/WebgisPovertyMapping/
|
||||
```
|
||||
|
||||
Jika port `8080` sudah dipakai, tambahkan di `.env`:
|
||||
|
||||
```env
|
||||
APP_PORT=8081
|
||||
```
|
||||
|
||||
Lalu jalankan ulang compose lokal.
|
||||
|
||||
## Database Init
|
||||
|
||||
MariaDB menjalankan file berikut hanya saat volume database masih kosong:
|
||||
|
||||
```text
|
||||
01/setup_database.sql
|
||||
WebgisPovertyMapping/setup_database.sql
|
||||
```
|
||||
|
||||
Untuk reset database lokal:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml down -v
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build -d
|
||||
```
|
||||
|
||||
## Production Di Coolify
|
||||
|
||||
Gunakan `docker-compose.yml` saja.
|
||||
|
||||
1. Push root repository `webgis/` ke Git provider.
|
||||
2. Buat resource baru di Coolify dari repository tersebut.
|
||||
3. Pilih deployment type `Docker Compose`.
|
||||
4. Set compose file ke `docker-compose.yml`.
|
||||
5. Assign domain ke service `app`, misalnya `https://ifuntanhub.dev`.
|
||||
6. Set environment variable:
|
||||
|
||||
```env
|
||||
MARIADB_ROOT_PASSWORD=isi-password-kuat
|
||||
```
|
||||
|
||||
`docker-compose.yml` production hanya memakai `expose: 80`. Jangan tambahkan `ports:` untuk production, karena routing publik harus lewat reverse proxy Coolify.
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
Coolify/reverse proxy menerima traffic publik dari domain dan meneruskannya ke container `app` port `80`.
|
||||
|
||||
Alur request:
|
||||
|
||||
```text
|
||||
https://ifuntanhub.dev -> Coolify reverse proxy -> app:80 -> /WebgisPovertyMapping/
|
||||
```
|
||||
|
||||
Header `X-Forwarded-Proto: https` dipakai oleh aplikasi untuk mengenali HTTPS saat menentukan secure session cookie.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- App tidak bisa dibuka lokal: pastikan memakai `docker-compose.local.yml`, bukan hanya `docker-compose.yml`.
|
||||
- DB gagal terhubung: cek env `MARIADB_ROOT_PASSWORD`, service `db`, dan healthcheck MariaDB.
|
||||
- SQL init tidak berubah setelah edit schema: reset volume database; MariaDB hanya menjalankan init script saat volume kosong.
|
||||
- Domain Coolify tidak masuk ke app: pastikan domain diassign ke service `app` dan container port `80`.
|
||||
- Peta/chart/font gagal load: cek akses internet ke CDN dan tile provider eksternal.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Deployment XAMPP Legacy
|
||||
|
||||
Panduan ini hanya referensi legacy jika aplikasi harus dijalankan tanpa Docker. Jalur utama saat ini adalah Docker Compose dan Coolify; lihat `deployment-docker.md`.
|
||||
|
||||
## Minimum Runtime
|
||||
|
||||
- Apache `>= 2.4`.
|
||||
- PHP `>= 7.4`; pengujian lokal berjalan di PHP `8.2`.
|
||||
- MySQL `>= 5.7` atau MariaDB `>= 10.5`.
|
||||
|
||||
## Port Dan Konfigurasi
|
||||
|
||||
- Contoh URL lokal: `http://localhost:8081/webgis/WebgisPovertyMapping/`.
|
||||
- Apache dapat berjalan di `80`, `8080`, atau `8081`; sesuaikan URL dengan port XAMPP.
|
||||
- MySQL/MariaDB default proyek memakai port `3307`.
|
||||
- Jika MySQL berjalan di `3306`, ubah `DB_PORT` di `config.php`.
|
||||
- Konfigurasi koneksi utama ada di `config.php`: `DB_HOST`, `DB_USER`, `DB_PASS`, `DB_NAME`, dan `DB_PORT`.
|
||||
|
||||
## Import Database
|
||||
|
||||
1. Buat database dari schema:
|
||||
|
||||
```powershell
|
||||
mysql -u root -P 3307 < setup_database.sql
|
||||
```
|
||||
|
||||
2. Alternatif: buka phpMyAdmin, pilih tab SQL, lalu jalankan isi `setup_database.sql`.
|
||||
3. Pastikan nama database sesuai `DB_NAME` di `config.php`.
|
||||
|
||||
## Akun Default
|
||||
|
||||
- Username: `admin`
|
||||
- Password awal: `Admin1234`
|
||||
- Setelah login pertama, admin wajib mengganti password.
|
||||
|
||||
## Proteksi Folder Non-Publik
|
||||
|
||||
Blokir folder tests dan tmp dari akses browser.
|
||||
|
||||
Kenapa: file di tests hanya untuk pengecekan developer. Beberapa test dapat menulis data ke database. Folder tmp dapat berisi session atau file sementara.
|
||||
|
||||
Pastikan `.htaccess` aktif dan Apache mengizinkan override:
|
||||
|
||||
```apache
|
||||
AllowOverride All
|
||||
```
|
||||
|
||||
Smoke test manual:
|
||||
|
||||
```text
|
||||
http://localhost:8081/webgis/WebgisPovertyMapping/tests/test_proximity.php
|
||||
```
|
||||
|
||||
Expected: `403 Forbidden`.
|
||||
|
||||
## Dependency Eksternal
|
||||
|
||||
- Leaflet CSS/JS dari `unpkg.com`.
|
||||
- Chart.js dari `cdn.jsdelivr.net`.
|
||||
- `leaflet.heat@0.2.0` dari `cdn.jsdelivr.net`.
|
||||
- Google Fonts `Inter`.
|
||||
- OpenStreetMap tiles dari `tile.openstreetmap.org`.
|
||||
- Esri imagery dari `server.arcgisonline.com`.
|
||||
- Carto dark tiles dari `basemaps.cartocdn.com`.
|
||||
- Nominatim reverse geocoding dari `nominatim.openstreetmap.org`.
|
||||
|
||||
Jika CDN atau tile server tidak dapat diakses, peta dasar, chart, heatmap, font, atau reverse geocoding bisa terganggu walaupun data aplikasi tetap tersedia.
|
||||
|
||||
## Strategi Mirror Lokal
|
||||
|
||||
Jika aplikasi dipakai di jaringan tertutup, siapkan salinan lokal untuk:
|
||||
|
||||
- Leaflet CSS/JS.
|
||||
- leaflet.heat.
|
||||
- Chart.js.
|
||||
- Lucide icons.
|
||||
- Google Fonts Inter.
|
||||
|
||||
Tile peta juga perlu strategi sendiri:
|
||||
|
||||
- Gunakan tile server internal; atau
|
||||
- Batasi penggunaan ke jaringan yang boleh mengakses OpenStreetMap, Esri, dan Carto.
|
||||
|
||||
## HTTPS Behind Reverse Proxy
|
||||
|
||||
Session cookie `Secure` aktif berdasarkan deteksi HTTPS di PHP. Jika Apache berada di belakang reverse proxy, pastikan header proxy HTTPS diteruskan dan server PHP mengenali request sebagai HTTPS.
|
||||
|
||||
## Abuse Control Publik
|
||||
|
||||
Form kontak donatur memiliki rate limit session sederhana: satu submit per 60 detik per browser session.
|
||||
Untuk deploy publik serius, pertimbangkan CAPTCHA atau rate limit IP di web server.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- DB gagal terhubung: cek service MySQL/MariaDB XAMPP, `DB_PORT`, username, password, dan nama database.
|
||||
- Port Apache berbeda: gunakan URL sesuai port aktif XAMPP, misalnya `8081`.
|
||||
- Import SQL gagal: pastikan MySQL/MariaDB aktif dan user punya izin membuat database/tabel.
|
||||
- CDN tidak bisa diakses: siapkan mirror lokal untuk Leaflet, Chart.js, leaflet.heat, dan font bila deployment berada di jaringan tertutup.
|
||||
- Tile peta lambat/gagal: cek akses ke OpenStreetMap, Esri, dan Carto.
|
||||
- Nominatim gagal: reverse geocoding akan kosong, tetapi input koordinat tetap bisa dipakai.
|
||||
- session timeout: nilai `SESSION_TIMEOUT` di `config.php` mengatur durasi sesi login.
|
||||
@@ -0,0 +1,105 @@
|
||||
<!-- page content ends here -->
|
||||
</main><!-- .al-content -->
|
||||
|
||||
<footer class="al-footer">
|
||||
<span>© 2026 WebGIS Pemetaan Kemiskinan</span>
|
||||
<span>Pemetaan Partisipatif Berbasis Rumah Ibadah</span>
|
||||
</footer>
|
||||
|
||||
</div><!-- .al-main -->
|
||||
</div><!-- .al-wrap -->
|
||||
|
||||
<!-- Chart.js v4 CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
|
||||
<script>
|
||||
function toggleAdminSidebar() {
|
||||
const sb = document.getElementById('alSidebar');
|
||||
if (!sb) return;
|
||||
sb.classList.toggle('collapsed');
|
||||
try { localStorage.setItem('admin_sb_collapsed', sb.classList.contains('collapsed') ? '1' : '0'); } catch(e) {}
|
||||
}
|
||||
(function() {
|
||||
try {
|
||||
if (localStorage.getItem('admin_sb_collapsed') === '1') {
|
||||
const sb = document.getElementById('alSidebar');
|
||||
if (sb) sb.classList.add('collapsed');
|
||||
}
|
||||
} catch(e) {}
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
})();
|
||||
|
||||
// ── Notification panel ───────────────────────────────────────────────
|
||||
(function() {
|
||||
const btn = document.getElementById('hd-notif-btn');
|
||||
const panel = document.getElementById('notif-panel');
|
||||
const badge = document.getElementById('hd-notif-badge');
|
||||
const list = document.getElementById('notif-list');
|
||||
const close = document.getElementById('notif-close');
|
||||
if (!btn || !panel) return;
|
||||
|
||||
const root = window.APP_ROOT || '';
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function renderItems(items) {
|
||||
if (!items.length) {
|
||||
return '<div class="notif-empty"><i data-lucide="check-circle"></i><span>Semua sudah beres!</span></div>';
|
||||
}
|
||||
return items.map(function(item) {
|
||||
return '<a href="' + esc(root + item.page) + '" class="notif-item">' +
|
||||
'<div class="notif-item-icon type-' + esc(item.type) + '">' +
|
||||
'<i data-lucide="' + esc(item.icon) + '"></i>' +
|
||||
'</div>' +
|
||||
'<div class="notif-item-text">' + esc(item.label) + '</div>' +
|
||||
'<div class="notif-item-count">' + esc(item.count) + '</div>' +
|
||||
'</a>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function loadNotifs() {
|
||||
list.innerHTML = '<div class="notif-loading">Memuat…</div>';
|
||||
fetch(root + 'api/notif/ambil.php')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.status !== 'success') throw new Error();
|
||||
var total = data.total || 0;
|
||||
badge.textContent = total > 99 ? '99+' : total;
|
||||
badge.style.display = total > 0 ? '' : 'none';
|
||||
list.innerHTML = renderItems(data.items || []);
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
})
|
||||
.catch(function() {
|
||||
list.innerHTML = '<div class="notif-empty">Gagal memuat notifikasi.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var open = panel.style.display !== 'none';
|
||||
panel.style.display = open ? 'none' : 'block';
|
||||
if (!open) loadNotifs();
|
||||
});
|
||||
|
||||
if (close) {
|
||||
close.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
panel.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (panel.style.display !== 'none' &&
|
||||
!panel.contains(e.target) && e.target !== btn && !btn.contains(e.target)) {
|
||||
panel.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Load badge count on page load (silent)
|
||||
loadNotifs();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
/**
|
||||
* includes/page-start.php
|
||||
* Shared admin layout — output HTML shell + sidebar + header + open main content.
|
||||
*
|
||||
* Required vars (set by including page before this include):
|
||||
* string $page_title — e.g. 'Dashboard Administrator'
|
||||
* string $page_subtitle — e.g. 'Ringkasan situasi kemiskinan...' (can be '')
|
||||
* string $active_nav — slug of active nav item:
|
||||
* 'dashboard' | 'map' | 'ibadah' | 'penduduk' |
|
||||
* 'kebutuhan' | 'analisis' | 'status' | 'laporan' |
|
||||
* 'import' | 'users' | 'settings' | 'audit'
|
||||
*
|
||||
* Session must already be started; auth must be checked by calling page.
|
||||
*/
|
||||
|
||||
$_nav_role = get_role();
|
||||
$_nav_nama = get_user_nama();
|
||||
$_nav_init = mb_strtoupper(mb_substr(trim($_nav_nama), 0, 1, 'UTF-8'), 'UTF-8');
|
||||
|
||||
// Calculate relative path from the calling script's directory back to app root
|
||||
$_app_root = str_replace('\\', '/', dirname(__DIR__)); // parent of includes/ = app root
|
||||
$_script_dir = str_replace('\\', '/', dirname(realpath($_SERVER['SCRIPT_FILENAME'])));
|
||||
$_rel = ltrim(str_replace($_app_root, '', $_script_dir), '/');
|
||||
$_depth = ($_rel === '') ? 0 : (substr_count($_rel, '/') + 1);
|
||||
$_root = str_repeat('../', $_depth);
|
||||
|
||||
function _sb_item(string $href, string $icon, string $label, string $slug, string $active): string {
|
||||
$cls = ($active === $slug) ? 'sb-item active' : 'sb-item';
|
||||
$h = htmlspecialchars($href);
|
||||
$l = htmlspecialchars($label);
|
||||
return "<a href=\"{$h}\" class=\"{$cls}\"><span class=\"sb-icon\"><i data-lucide=\"{$icon}\"></i></span><span class=\"sb-label\">{$l}</span></a>\n";
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($page_title) ?> — <?= APP_NAME ?></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="<?= $_root ?>css/admin.css">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script>
|
||||
window.APP_CSRF_TOKEN = <?= json_encode(is_logged_in() ? csrf_token() : null) ?>;
|
||||
window.APP_ROOT = <?= json_encode($_root) ?>;
|
||||
function appendCsrf(fd) {
|
||||
const token = window.APP_USER?.csrfToken || window.APP_CSRF_TOKEN || null;
|
||||
if (token) fd.append('csrf_token', token);
|
||||
return fd;
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="al-wrap">
|
||||
|
||||
<!-- ═══ SIDEBAR ═══ -->
|
||||
<aside class="al-sidebar" id="alSidebar">
|
||||
|
||||
<div class="sb-logo">
|
||||
<div class="sb-logo-icon" style="color: #fff;"><i data-lucide="map"></i></div>
|
||||
<div class="sb-logo-text">
|
||||
<div class="sb-logo-name"><?= APP_NAME ?></div>
|
||||
<div class="sb-logo-sub">Pemetaan Kemiskinan</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sb-nav">
|
||||
<div class="sb-section-label">Navigasi Utama</div>
|
||||
|
||||
<?php if ($_nav_role === 'administrator'): ?>
|
||||
<?= _sb_item("{$_root}dashboard.php", 'layout-dashboard', 'Dashboard', 'dashboard', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/map.php", 'map', 'Peta Interaktif', 'map', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/ibadah.php", 'map-pin', 'Rumah Ibadah', 'ibadah', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/penduduk.php", 'home', 'Penduduk Miskin', 'penduduk', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/kebutuhan.php", 'heart', 'Kebutuhan & Papan Publik', 'kebutuhan', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/analisis.php", 'alert-triangle', 'Analisis & Blank Spot', 'analisis', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/status-bantuan.php",'clipboard-list', 'Status Bantuan', 'status', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/laporan.php", 'file-text', 'Laporan & Export', 'laporan', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/import.php", 'file-input', 'Import CSV', 'import', $active_nav) ?>
|
||||
|
||||
<div class="sb-section-label">Manajemen</div>
|
||||
<?= _sb_item("{$_root}pages/users.php", 'users', 'Pengguna & Akun', 'users', $active_nav) ?>
|
||||
<?php elseif ($_nav_role === 'operator'): ?>
|
||||
<?= _sb_item("{$_root}pages/map.php", 'map', 'Peta Interaktif', 'map', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/penduduk.php", 'home', 'Penduduk Miskin', 'penduduk', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/kebutuhan.php", 'heart', 'Kebutuhan & Papan Publik', 'kebutuhan', $active_nav) ?>
|
||||
<?= _sb_item("{$_root}pages/status-bantuan.php",'clipboard-list', 'Status Bantuan', 'status', $active_nav) ?>
|
||||
<?php else: ?>
|
||||
<?= _sb_item("{$_root}pages/map.php", 'map', 'Peta Interaktif', 'map', $active_nav) ?>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
|
||||
<div class="sb-footer">
|
||||
<button class="sb-toggle-btn" onclick="toggleAdminSidebar()" title="Sembunyikan / Tampilkan Menu">
|
||||
<span class="sb-toggle-icon"><i data-lucide="chevron-left"></i></span>
|
||||
<span class="sb-toggle-label">Sembunyikan Menu</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</aside><!-- .al-sidebar -->
|
||||
|
||||
<!-- ═══ MAIN ═══ -->
|
||||
<div class="al-main">
|
||||
|
||||
<header class="al-header">
|
||||
<button class="hd-hamburger" onclick="toggleAdminSidebar()" aria-label="Toggle sidebar">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
|
||||
<div class="hd-title-block">
|
||||
<div class="hd-title"><?= htmlspecialchars($page_title) ?></div>
|
||||
<?php if (!empty($page_subtitle)): ?>
|
||||
<div class="hd-subtitle"><?= htmlspecialchars($page_subtitle) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="hd-right">
|
||||
<div class="hd-status">
|
||||
<div class="hd-status-dot"></div>
|
||||
Online
|
||||
</div>
|
||||
|
||||
<div class="notif-wrap" id="notif-wrap">
|
||||
<button class="hd-notif-btn" title="Notifikasi" id="hd-notif-btn">
|
||||
<i data-lucide="bell"></i>
|
||||
<span class="hd-notif-badge" id="hd-notif-badge" style="display:none;">0</span>
|
||||
</button>
|
||||
<div class="notif-panel" id="notif-panel" style="display:none;" role="dialog" aria-label="Notifikasi">
|
||||
<div class="notif-panel-header">
|
||||
<span class="notif-panel-title">Notifikasi</span>
|
||||
<button class="notif-panel-close" id="notif-close" aria-label="Tutup"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="notif-list" id="notif-list">
|
||||
<div class="notif-loading">Memuat…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hd-user">
|
||||
<div class="hd-avatar"><?= htmlspecialchars($_nav_init ?: '?') ?></div>
|
||||
<div>
|
||||
<div class="hd-user-name"><?= htmlspecialchars($_nav_nama) ?></div>
|
||||
<div class="hd-user-role"><?= ucfirst(htmlspecialchars($_nav_role)) ?></div>
|
||||
</div>
|
||||
<a href="<?= $_root ?>api/auth/logout.php" class="hd-logout-btn" title="Keluar"><i data-lucide="log-out"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="al-content">
|
||||
<!-- page content starts here -->
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
// Shared validation helpers for request payloads.
|
||||
|
||||
function normalize_coordinate_value($value): array {
|
||||
if ($value === null) {
|
||||
return ['ok' => false, 'message' => 'Koordinat tidak boleh kosong'];
|
||||
}
|
||||
|
||||
$raw = is_string($value)
|
||||
? trim(str_replace([',', "\xe2\x88\x92"], ['.', '-'], $value))
|
||||
: $value;
|
||||
|
||||
if ($raw === '') {
|
||||
return ['ok' => false, 'message' => 'Koordinat tidak boleh kosong'];
|
||||
}
|
||||
|
||||
if (!is_numeric($raw)) {
|
||||
return ['ok' => false, 'message' => 'Koordinat harus berupa angka'];
|
||||
}
|
||||
|
||||
$float = (float)$raw;
|
||||
if (!is_finite($float)) {
|
||||
return ['ok' => false, 'message' => 'Koordinat tidak valid'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'value' => $float];
|
||||
}
|
||||
|
||||
function validate_lat_lng($lat, $lng, $strict_bounds = true): array {
|
||||
$lat_result = normalize_coordinate_value($lat);
|
||||
if (!$lat_result['ok']) {
|
||||
return ['ok' => false, 'message' => 'Latitude: ' . $lat_result['message']];
|
||||
}
|
||||
|
||||
$lng_result = normalize_coordinate_value($lng);
|
||||
if (!$lng_result['ok']) {
|
||||
return ['ok' => false, 'message' => 'Longitude: ' . $lng_result['message']];
|
||||
}
|
||||
|
||||
$lat_value = $lat_result['value'];
|
||||
$lng_value = $lng_result['value'];
|
||||
|
||||
if ($lat_value < -90 || $lat_value > 90) {
|
||||
return ['ok' => false, 'message' => 'Latitude di luar rentang -90 sampai 90'];
|
||||
}
|
||||
if ($lng_value < -180 || $lng_value > 180) {
|
||||
return ['ok' => false, 'message' => 'Longitude di luar rentang -180 sampai 180'];
|
||||
}
|
||||
|
||||
if (
|
||||
$strict_bounds
|
||||
&& defined('MAP_MIN_LAT') && defined('MAP_MAX_LAT')
|
||||
&& defined('MAP_MIN_LNG') && defined('MAP_MAX_LNG')
|
||||
&& (
|
||||
$lat_value < MAP_MIN_LAT || $lat_value > MAP_MAX_LAT
|
||||
|| $lng_value < MAP_MIN_LNG || $lng_value > MAP_MAX_LNG
|
||||
)
|
||||
) {
|
||||
return ['ok' => false, 'message' => 'Koordinat di luar wilayah studi'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'lat' => $lat_value, 'lng' => $lng_value];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta http-equiv="refresh" content="0;url=index.php"></head>
|
||||
<body><a href="index.php">Klik di sini jika tidak otomatis redirect</a></body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
// koneksi.php — Koneksi database + Haversine helper
|
||||
if (!defined('DB_HOST')) require_once __DIR__ . '/config.php';
|
||||
|
||||
mysqli_report(MYSQLI_REPORT_OFF);
|
||||
$conn = @new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
error_log('Database connection failed: ' . $conn->connect_error);
|
||||
http_response_code(500);
|
||||
die(json_encode([
|
||||
'status' => 'error',
|
||||
'message' => 'Koneksi database gagal. Hubungi administrator.'
|
||||
]));
|
||||
}
|
||||
$conn->set_charset('utf8mb4');
|
||||
|
||||
function haversine_distance($lat1, $lon1, $lat2, $lon2) {
|
||||
$R = 6371000;
|
||||
$dLat = deg2rad($lat2 - $lat1);
|
||||
$dLon = deg2rad($lon2 - $lon1);
|
||||
$a = sin($dLat/2)**2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon/2)**2;
|
||||
return $R * 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||||
}
|
||||
|
||||
// PRD-correct proximity: nearest ibadah whose radius covers the point wins;
|
||||
// among ties (equal distance), ORDER BY id ASC via strict < ensures smallest id.
|
||||
// jarak_m = distance to nearest-overall ibadah when blank spot.
|
||||
function calc_proximity(mysqli $conn, float $lat, float $lng): array {
|
||||
$res = $conn->query(
|
||||
"SELECT id, lat, lng, radius_m FROM rumah_ibadah WHERE deleted_at IS NULL ORDER BY id ASC"
|
||||
);
|
||||
$nearest_overall_dist = PHP_FLOAT_MAX;
|
||||
$best_in_radius = null;
|
||||
$best_in_radius_dist = PHP_FLOAT_MAX;
|
||||
while ($ri = $res->fetch_assoc()) {
|
||||
$d = haversine_distance($lat, $lng, (float)$ri['lat'], (float)$ri['lng']);
|
||||
if ($d < $nearest_overall_dist) $nearest_overall_dist = $d;
|
||||
if ($d <= (float)$ri['radius_m'] && $d < $best_in_radius_dist) {
|
||||
$best_in_radius_dist = $d;
|
||||
$best_in_radius = $ri;
|
||||
}
|
||||
}
|
||||
if ($best_in_radius) {
|
||||
return [
|
||||
'ibadah_id' => (int)$best_in_radius['id'],
|
||||
'jarak_m' => round($best_in_radius_dist, 2),
|
||||
'is_blank_spot' => 0,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'ibadah_id' => null,
|
||||
'jarak_m' => $nearest_overall_dist < PHP_FLOAT_MAX ? round($nearest_overall_dist, 2) : null,
|
||||
'is_blank_spot' => 1,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// modules/heatmap.js — Heatmap kepadatan, intensitas proporsional jumlah_jiwa (F9)
|
||||
(function () {
|
||||
let heatmapLayer = null;
|
||||
let active = false;
|
||||
let heatmapKategori = '';
|
||||
|
||||
function canUseHeatmap() {
|
||||
return !!(window.APP_USER?.isOp || window.APP_USER?.isAdmin);
|
||||
}
|
||||
|
||||
function buildHeatData() {
|
||||
const source = (window._pendudukAll || window._pendudukList || []).filter(function (p) {
|
||||
return !heatmapKategori || p.kategori === heatmapKategori;
|
||||
});
|
||||
const validSource = source
|
||||
.map(function (p) {
|
||||
const rawLat = p.lat;
|
||||
const rawLng = p.lng;
|
||||
const lat = Number(p.lat);
|
||||
const lng = Number(p.lng);
|
||||
return {
|
||||
hasCoordinate: rawLat !== null && rawLat !== '' && rawLng !== null && rawLng !== '',
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
jumlah_jiwa: Number(p.jumlah_jiwa || 1)
|
||||
};
|
||||
})
|
||||
.filter(function (p) {
|
||||
const lat = p.lat;
|
||||
const lng = p.lng;
|
||||
return p.hasCoordinate && Number.isFinite(lat) && Number.isFinite(lng);
|
||||
});
|
||||
const maxJiwa = Math.max(1, ...validSource.map(function (p) {
|
||||
return p.jumlah_jiwa || 1;
|
||||
}));
|
||||
return validSource.map(function (p) {
|
||||
return [p.lat, p.lng, 0.2 + 0.8 * ((p.jumlah_jiwa || 1) / maxJiwa)];
|
||||
});
|
||||
}
|
||||
|
||||
window._heatmapUpdate = function () {
|
||||
if (!window.APP_USER?.isOp && !window.APP_USER?.isAdmin) {
|
||||
active = false;
|
||||
if (heatmapLayer && typeof map !== 'undefined' && map.hasLayer(heatmapLayer)) {
|
||||
map.removeLayer(heatmapLayer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!active || !heatmapLayer) return;
|
||||
heatmapLayer.setLatLngs(buildHeatData());
|
||||
};
|
||||
|
||||
window._heatmapSetKategori = function (k) {
|
||||
heatmapKategori = k;
|
||||
window._heatmapUpdate();
|
||||
};
|
||||
|
||||
window._toggleHeatmap = function (visible) {
|
||||
if (!canUseHeatmap() || typeof L === 'undefined' || !L.heatLayer || typeof map === 'undefined') {
|
||||
active = false;
|
||||
const checkbox = document.getElementById('chkHeatmap');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
if (heatmapLayer && typeof map !== 'undefined' && map.hasLayer(heatmapLayer)) {
|
||||
map.removeLayer(heatmapLayer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
active = visible;
|
||||
if (active) {
|
||||
if (!heatmapLayer) {
|
||||
heatmapLayer = L.heatLayer([], {
|
||||
radius : 25,
|
||||
blur : 15,
|
||||
maxZoom : 17,
|
||||
gradient: { 0.4: 'blue', 0.6: 'cyan', 0.7: 'lime', 0.8: 'yellow', 1.0: 'red' }
|
||||
});
|
||||
}
|
||||
heatmapLayer.addTo(map);
|
||||
window._heatmapUpdate();
|
||||
showToast('Heatmap Kepadatan diaktifkan.');
|
||||
} else {
|
||||
if (heatmapLayer) map.removeLayer(heatmapLayer);
|
||||
showToast('Heatmap dinonaktifkan.');
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,887 @@
|
||||
// modules/ibadah.js — Manajemen Rumah Ibadah dengan Radius Buffer & Reverse Geocoding
|
||||
(function () {
|
||||
const layerGroup = L.layerGroup();
|
||||
window._ibadahLayer = layerGroup;
|
||||
|
||||
// Data list: [{id, lat, lng, nama, jenis, radius}] — dibaca oleh penduduk.js
|
||||
window._ibadahList = [];
|
||||
|
||||
let allMarkers = {}; // { id: { marker, circle } }
|
||||
let tempMarker = null;
|
||||
let _previewCircle = null;
|
||||
let active = false;
|
||||
let bufferVisible = true;
|
||||
|
||||
// ── Icon & Color Factory ──────────────────────────────────────────
|
||||
const JENIS_ICON = {
|
||||
'Masjid' : '🕌',
|
||||
'Mushola' : '🕌',
|
||||
'Gereja' : '⛪',
|
||||
'Pura' : '🛕',
|
||||
'Vihara' : '🏯',
|
||||
'Klenteng' : '🏮',
|
||||
'Lainnya' : '🕍',
|
||||
};
|
||||
|
||||
// Warna border circle per jenis (sesuai PRD F2)
|
||||
const JENIS_COLOR = {
|
||||
'Masjid' : { stroke: '#38bdf8', fill: '#0ea5e9' }, // biru
|
||||
'Mushola' : { stroke: '#67e8f9', fill: '#06b6d4' }, // cyan
|
||||
'Gereja' : { stroke: '#4ade80', fill: '#16a34a' }, // hijau
|
||||
'Pura' : { stroke: '#f97316', fill: '#ea580c' }, // oranye
|
||||
'Vihara' : { stroke: '#facc15', fill: '#ca8a04' }, // kuning
|
||||
'Klenteng' : { stroke: '#f43f5e', fill: '#be123c' }, // merah
|
||||
'Lainnya' : { stroke: '#a78bfa', fill: '#7c3aed' }, // ungu
|
||||
};
|
||||
|
||||
const OPERATOR_HIGHLIGHT = {
|
||||
stroke: '#f59e0b',
|
||||
fill: '#fbbf24',
|
||||
ring: 'rgba(245,158,11,0.22)',
|
||||
shadow: 'rgba(180,83,9,0.35)'
|
||||
};
|
||||
|
||||
function isOwnOperatorIbadah(id) {
|
||||
const appUser = window.APP_USER || {};
|
||||
if (!appUser.isOp || appUser.ibadahId === null || appUser.ibadahId === undefined) return false;
|
||||
return parseInt(appUser.ibadahId) === parseInt(id);
|
||||
}
|
||||
|
||||
// ── KK list helper (called on popupopen & after setPopupContent) ──
|
||||
function _refreshKkList(id) {
|
||||
const bodyEl = document.getElementById('kkListBody_' + id);
|
||||
const countEl = document.getElementById('kkCount_' + id);
|
||||
if (!bodyEl) return;
|
||||
|
||||
const list = (window._pendudukAll || window._pendudukList || []).filter(p => p.ibadah && parseInt(p.ibadah.id) === parseInt(id));
|
||||
|
||||
// Update live count (overrides stale DB value from initial load)
|
||||
if (countEl) {
|
||||
const totalJiwa = list.reduce((s, p) => s + (parseInt(p.jumlah_jiwa) || 1), 0);
|
||||
countEl.innerHTML = `<span style="color:#38bdf8;font-weight:700;">${list.length}</span> KK · <span style="color:#38bdf8;font-weight:700;">${totalJiwa}</span> jiwa`;
|
||||
}
|
||||
|
||||
if (list.length === 0) {
|
||||
bodyEl.innerHTML = '<span style="color:var(--c-muted);font-size:12px;">Belum ada warga binaan.</span>';
|
||||
return;
|
||||
}
|
||||
const MAX = 8;
|
||||
let html = list.slice(0, MAX).map(p => `
|
||||
<div onclick="window._pendudukFlyTo(${p.id})" style="
|
||||
padding:5px 8px;margin:2px 0;border-radius:6px;cursor:pointer;
|
||||
background:rgba(255,255,255,.04);display:flex;justify-content:space-between;
|
||||
align-items:center;border:1px solid rgba(255,255,255,.07);transition:background .15s;
|
||||
"
|
||||
onmouseover="this.style.background='rgba(255,255,255,.1)'"
|
||||
onmouseout="this.style.background='rgba(255,255,255,.04)'">
|
||||
<span style="font-size:12px;">${escapeHTML(p.nama_kk || '(Anonim)')}</span>
|
||||
<span style="font-size:10px;color:var(--c-muted);white-space:nowrap;margin-left:6px;">${p.jumlah_jiwa || 1} jiwa</span>
|
||||
</div>`).join('');
|
||||
if (list.length > MAX) {
|
||||
html += `<div style="font-size:11px;color:var(--c-muted);text-align:center;margin-top:4px;">+${list.length - MAX} lainnya</div>`;
|
||||
}
|
||||
bodyEl.innerHTML = html;
|
||||
}
|
||||
|
||||
function createIbadahIcon(jenis, ownOperatorIbadah = false) {
|
||||
const emoji = JENIS_ICON[jenis] || '🕍';
|
||||
const size = ownOperatorIbadah ? 46 : 36;
|
||||
const border = ownOperatorIbadah ? `4px solid ${OPERATOR_HIGHLIGHT.stroke}` : '2px solid rgba(56,189,248,0.8)';
|
||||
const background = ownOperatorIbadah ? 'linear-gradient(135deg, #fffbeb, #fef3c7)' : 'linear-gradient(135deg, #ffffff, #f0f9ff)';
|
||||
const shadow = ownOperatorIbadah
|
||||
? `0 0 0 8px ${OPERATOR_HIGHLIGHT.ring}, 0 8px 22px ${OPERATOR_HIGHLIGHT.shadow}`
|
||||
: '0 3px 12px rgba(14,165,233,0.45)';
|
||||
const fontSize = ownOperatorIbadah ? 18 : 16;
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:${size}px; height:${size}px;
|
||||
background: ${background};
|
||||
border: ${border};
|
||||
border-radius: 50% 50% 50% 0;
|
||||
transform: rotate(-45deg);
|
||||
box-shadow: ${shadow};
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
">
|
||||
<span style="transform:rotate(45deg); font-size:${fontSize}px; line-height:1;">${emoji}</span>
|
||||
</div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: ownOperatorIbadah ? [12, 46] : [8, 36],
|
||||
popupAnchor: ownOperatorIbadah ? [10, -48] : [10, -38]
|
||||
});
|
||||
}
|
||||
|
||||
const iconTemp = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:32px; height:32px;
|
||||
background: rgba(56,189,248,0.15);
|
||||
border: 2px dashed #38bdf8;
|
||||
border-radius: 50%;
|
||||
animation: pulse-ibadah 1s ease-in-out infinite;
|
||||
"></div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
});
|
||||
|
||||
// ── Tambah ke peta ────────────────────────────────────────────────
|
||||
function addToMap(data) {
|
||||
const lat = parseFloat(data.lat);
|
||||
const lng = parseFloat(data.lng);
|
||||
const radius = parseInt(data.radius_m) || 500;
|
||||
const jenis = data.jenis || 'Lainnya';
|
||||
const emoji = JENIS_ICON[jenis] || '🕍';
|
||||
const ownOperatorIbadah = isOwnOperatorIbadah(data.id);
|
||||
|
||||
// Lingkaran radius — warna berbeda per jenis
|
||||
const clr = JENIS_COLOR[jenis] || JENIS_COLOR['Lainnya'];
|
||||
const circle = L.circle([lat, lng], {
|
||||
radius: radius,
|
||||
color: ownOperatorIbadah ? OPERATOR_HIGHLIGHT.stroke : clr.stroke,
|
||||
weight: ownOperatorIbadah ? 3 : 1.5,
|
||||
opacity: ownOperatorIbadah ? 1 : 0.8,
|
||||
fillColor: ownOperatorIbadah ? OPERATOR_HIGHLIGHT.fill : clr.fill,
|
||||
fillOpacity: ownOperatorIbadah ? 0.12 : 0.07,
|
||||
});
|
||||
|
||||
const isAdmin = window.APP_USER && window.APP_USER.isAdmin;
|
||||
const marker = L.marker([lat, lng], {
|
||||
icon: createIbadahIcon(jenis, ownOperatorIbadah),
|
||||
draggable: isAdmin,
|
||||
zIndexOffset: ownOperatorIbadah ? 1000 : 100
|
||||
});
|
||||
|
||||
marker._ibadahId = data.id;
|
||||
marker._ibadahNama = data.nama;
|
||||
marker._ibadahJenis = jenis;
|
||||
marker._ibadahRadius = radius;
|
||||
marker._circle = circle;
|
||||
|
||||
// Drag: update posisi + recalculate penduduk
|
||||
marker.on('dragend', function (e) {
|
||||
const { lat: newLat, lng: newLng } = e.target.getLatLng();
|
||||
circle.setLatLng([newLat, newLng]);
|
||||
|
||||
// Update _ibadahList (parseInt to guard against string/number type mismatch)
|
||||
const entry = window._ibadahList.find(x => parseInt(x.id) === parseInt(data.id));
|
||||
if (entry) { entry.lat = newLat; entry.lng = newLng; }
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', data.id);
|
||||
fd.append('lat', newLat.toFixed(8));
|
||||
fd.append('lng', newLng.toFixed(8));
|
||||
|
||||
fetch('api/ibadah/update_posisi.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
showToast(`Posisi "${escapeHTML(data.nama)}" diperbarui.`);
|
||||
// Recalculate semua penduduk
|
||||
if (typeof window._pendudukRecalcAll === 'function') {
|
||||
window._pendudukRecalcAll();
|
||||
}
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => showToast(err.message || 'Gagal update posisi.', 'error'));
|
||||
});
|
||||
|
||||
// Popup info
|
||||
marker.bindPopup(buildInfoPopup(data), { maxWidth: 340 });
|
||||
|
||||
// KK list: populate on popup open and after inline edits
|
||||
marker.on('popupopen', function () { _refreshKkList(data.id); });
|
||||
|
||||
if (bufferVisible) circle.addTo(layerGroup);
|
||||
marker.addTo(layerGroup);
|
||||
|
||||
// Simpan data lengkap agar bisa di-update nanti
|
||||
allMarkers[data.id] = { marker, circle, data: { ...data, radius_m: radius } };
|
||||
|
||||
// Update global list untuk penduduk.js
|
||||
updateIbadahList();
|
||||
}
|
||||
|
||||
function buildInfoPopup(data) {
|
||||
const isAdmin = window.APP_USER && window.APP_USER.isAdmin;
|
||||
const ownOperatorIbadah = isOwnOperatorIbadah(data.id);
|
||||
const jenis = data.jenis || 'Lainnya';
|
||||
const emoji = JENIS_ICON[jenis] || '🕍';
|
||||
const r = parseInt(data.radius_m) || 500;
|
||||
const alamatHtml = data.alamat
|
||||
? `<div class="info-row">
|
||||
<div class="info-row-icon"><i data-lucide="map-pin"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label">Alamat</div>
|
||||
<div class="info-row-value" style="font-size:12px;line-height:1.4;">${escapeHTML(data.alamat)}</div>
|
||||
</div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const kontakValue = data.kontak ? escapeHTML(data.kontak) : '';
|
||||
const kontakHtml = (isAdmin || data.kontak) ? `
|
||||
<div class="info-row" style="align-items:flex-start;">
|
||||
<div class="info-row-icon"><i data-lucide="phone"></i></div>
|
||||
<div style="flex:1;">
|
||||
<div class="info-row-label">Kontak Pengurus</div>
|
||||
${isAdmin ? `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
|
||||
<input type="text" id="edit_kontak_${data.id}"
|
||||
value="${kontakValue}" placeholder="No HP/WA (Opsional)"
|
||||
style="flex:1;padding:6px 8px;border-radius:4px;border:1px solid rgba(255,255,255,0.15);background:rgba(0,0,0,0.2);color:var(--c-text);font-size:12px;font-family:var(--font-body);outline:none;">
|
||||
<button onclick="window._ibadahSimpanKontak(${data.id})"
|
||||
style="padding:6px 10px;background:rgba(124,58,237,.15);border:1px solid rgba(168,85,247,.4);border-radius:6px;color:#a855f7;font-size:12px;font-weight:700;cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;gap:4px;"
|
||||
onmouseover="this.style.background='rgba(124,58,237,.25)'"
|
||||
onmouseout="this.style.background='rgba(124,58,237,.15)'">
|
||||
<i data-lucide="save" style="width:12px;height:12px;"></i> Simpan
|
||||
</button>
|
||||
</div>
|
||||
<div id="kontak_status_${data.id}" style="font-size:11px;margin-top:4px;font-family:var(--font-mono);"></div>
|
||||
` : `<div class="info-row-value" style="margin-top:4px;">${kontakValue || '—'}</div>`}
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
return `<div class="info-popup">
|
||||
<div class="info-popup-header">
|
||||
<div class="info-popup-icon" style="background:rgba(124,58,237,.2);">${emoji}</div>
|
||||
<div>
|
||||
<div class="info-popup-name">${escapeHTML(data.nama)}</div>
|
||||
<div class="info-popup-id">#${data.id} · ${escapeHTML(jenis)}</div>
|
||||
</div>
|
||||
</div>
|
||||
${ownOperatorIbadah ? `
|
||||
<div class="info-row" style="border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.10);">
|
||||
<div class="info-row-icon" style="color:#f59e0b;"><i data-lucide="star"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label" style="color:#92400e;">Rumah ibadah Anda</div>
|
||||
<div class="info-row-value" style="font-size:12px;line-height:1.4;color:#78350f;">Akun operator ini terhubung ke rumah ibadah tersebut.</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
${alamatHtml}
|
||||
${kontakHtml}
|
||||
${isAdmin ? `
|
||||
<div class="info-row" style="align-items:flex-start;">
|
||||
<div class="info-row-icon"><i data-lucide="ruler"></i></div>
|
||||
<div style="flex:1;">
|
||||
<div class="info-row-label">Radius Wilayah</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
|
||||
<input type="range" id="edit_radius_${data.id}"
|
||||
value="${r}" min="100" max="5000" step="50"
|
||||
oninput="document.getElementById('edit_radius_val_${data.id}').textContent=this.value; window._ibadahPreviewRadiusById(${data.id},this.value)"
|
||||
style="flex:1;height:4px;accent-color:#38bdf8;cursor:pointer;">
|
||||
<span id="edit_radius_val_${data.id}"
|
||||
style="min-width:45px;text-align:right;font-family:var(--font-mono);
|
||||
font-size:12px;color:#38bdf8;font-weight:700;">${r}m</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--c-muted);margin-top:2px;">
|
||||
<span>100m</span><span>5000m</span>
|
||||
</div>
|
||||
<button onclick="window._ibadahSimpanRadius(${data.id})"
|
||||
style="margin-top:8px;width:100%;padding:6px;background:rgba(14,165,233,.12);
|
||||
border:1px solid rgba(56,189,248,.4);border-radius:7px;
|
||||
color:#38bdf8;font-size:12px;font-weight:700;cursor:pointer;
|
||||
font-family:var(--font-body);transition:all .2s;display:inline-flex;align-items:center;justify-content:center;gap:4px;"
|
||||
onmouseover="this.style.background='rgba(14,165,233,.25)'"
|
||||
onmouseout="this.style.background='rgba(14,165,233,.12)'">
|
||||
<i data-lucide="save" style="width:12px;height:12px;"></i> Simpan Radius
|
||||
</button>
|
||||
<div id="radius_status_${data.id}" style="font-size:11px;margin-top:4px;font-family:var(--font-mono);"></div>
|
||||
</div>
|
||||
</div>` : `
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon"><i data-lucide="ruler"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label">Radius Wilayah</div>
|
||||
<div class="info-row-value" style="font-family:var(--font-mono);color:#38bdf8;">${r}m</div>
|
||||
</div>
|
||||
</div>`}
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon"><i data-lucide="users"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label">Warga Binaan <span style="font-size:9px;color:var(--c-muted);font-weight:400;">(terverifikasi)</span></div>
|
||||
<div id="kkCount_${data.id}" class="info-row-value" style="font-family:var(--font-mono);">
|
||||
<span style="color:#38bdf8;font-weight:700;">${parseInt(data.total_kk) || 0}</span> KK
|
||||
·
|
||||
<span style="color:#38bdf8;font-weight:700;">${parseInt(data.total_jiwa) || 0}</span> jiwa
|
||||
</div>
|
||||
${parseInt(data.jiwa_terjangkau) > 0 || parseInt(data.jiwa_blankspot) > 0 ? `
|
||||
<div style="font-size:10px;color:var(--c-muted);margin-top:3px;">
|
||||
<span style="color:#22c55e;">● ${parseInt(data.jiwa_terjangkau)||0} terjangkau</span>
|
||||
·
|
||||
<span style="color:#ef4444;">● ${parseInt(data.jiwa_blankspot)||0} blank spot</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon"><i data-lucide="globe"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label">Koordinat</div>
|
||||
<div class="info-row-value" style="font-family:var(--font-mono);font-size:11px;">
|
||||
${parseFloat(data.lat).toFixed(6)}, ${parseFloat(data.lng).toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${(window.APP_USER && window.APP_USER.loggedIn) ? `
|
||||
<div style="margin-top:8px;">
|
||||
<div class="info-row-label" style="margin-bottom:6px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;">
|
||||
Daftar KK Binaan <span style="font-weight:400;color:var(--c-muted);">(klik untuk fokus)</span>
|
||||
</div>
|
||||
<div id="kkListBody_${data.id}" style="max-height:180px;overflow-y:auto;">
|
||||
<span style="color:var(--c-muted);font-size:12px;display:inline-flex;align-items:center;gap:4px;"><i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;"></i> Memuat...</span>
|
||||
</div>
|
||||
</div>` : `
|
||||
<div style="margin-top:10px;padding:8px 10px;background:rgba(56,139,253,.06);
|
||||
border:1px solid rgba(56,139,253,.15);border-radius:8px;font-size:11px;
|
||||
color:var(--c-muted);line-height:1.5;text-align:center;">
|
||||
<i data-lucide="lock" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Data detail warga hanya tersedia untuk petugas yang login.<br>
|
||||
<a href="auth/login.php" style="color:#38bdf8;text-decoration:none;font-weight:700;">Masuk →</a>
|
||||
</div>`}
|
||||
|
||||
${(window.APP_USER && window.APP_USER.isAdmin) ? `
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<a href="api/export/csv.php?ibadah_id=${data.id}"
|
||||
style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;
|
||||
padding:9px;background:rgba(56,139,253,.1);color:#79c0ff;
|
||||
border:1px solid rgba(56,139,253,.3);border-radius:7px;
|
||||
font-size:12px;font-weight:700;text-decoration:none;transition:all .2s;"
|
||||
onmouseover="this.style.background='rgba(56,139,253,.2)'"
|
||||
onmouseout="this.style.background='rgba(56,139,253,.1)'">
|
||||
<i data-lucide="file-text" style="width:12px;height:12px;"></i> Export CSV
|
||||
</a>
|
||||
<a href="api/export/pdf.php?ibadah_id=${data.id}" target="_blank"
|
||||
style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;
|
||||
padding:9px;background:rgba(248,81,73,.08);color:#ffb3ad;
|
||||
border:1px solid rgba(248,81,73,.25);border-radius:7px;
|
||||
font-size:12px;font-weight:700;text-decoration:none;transition:all .2s;"
|
||||
onmouseover="this.style.background='rgba(248,81,73,.18)'"
|
||||
onmouseout="this.style.background='rgba(248,81,73,.08)'">
|
||||
<i data-lucide="printer" style="width:12px;height:12px;"></i> Print PDF
|
||||
</a>
|
||||
</div>` : ''}
|
||||
${isAdmin ? `<button class="btn-hapus" onclick="window._ibadahHapus(${data.id}, this)" style="display:inline-flex;align-items:center;justify-content:center;gap:4px;"><i data-lucide="trash-2" style="width:12px;height:12px;"></i> Hapus Rumah Ibadah</button>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Update global list ────────────────────────────────────────────
|
||||
function updateIbadahList() {
|
||||
window._ibadahList = Object.values(allMarkers).map(entry => {
|
||||
const m = entry.marker;
|
||||
const ll = m.getLatLng();
|
||||
return {
|
||||
id: m._ibadahId,
|
||||
lat: ll.lat,
|
||||
lng: ll.lng,
|
||||
nama: m._ibadahNama,
|
||||
jenis: m._ibadahJenis,
|
||||
radius: m._ibadahRadius
|
||||
};
|
||||
});
|
||||
if (typeof window._statsUpdate === 'function') window._statsUpdate();
|
||||
}
|
||||
|
||||
// ── Load data ─────────────────────────────────────────────────────
|
||||
function loadData() {
|
||||
layerGroup.clearLayers();
|
||||
allMarkers = {};
|
||||
window._ibadahList = [];
|
||||
|
||||
fetch('api/ibadah/ambil.php?_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
j.data.forEach(addToMap);
|
||||
updateIbadahList();
|
||||
refreshList(j.data);
|
||||
// Setelah load ibadah, recalculate penduduk
|
||||
if (typeof window._pendudukRecalcAll === 'function') {
|
||||
window._pendudukRecalcAll();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('Gagal memuat data ibadah.', 'error');
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Refresh panel list ────────────────────────────────────────────
|
||||
function refreshList(data) {
|
||||
if (typeof window.dlRefreshList !== 'function') return;
|
||||
const items = (data || []).map(d => ({
|
||||
id: d.id,
|
||||
name: d.nama,
|
||||
badge: d.jenis,
|
||||
badgeColor: '#a855f7',
|
||||
dotColor: '#a855f7',
|
||||
}));
|
||||
window.dlRefreshList('Ibadah', items);
|
||||
}
|
||||
|
||||
// ── Hapus ─────────────────────────────────────────────────────────
|
||||
window._ibadahHapus = function (id, btnEl) {
|
||||
// Step 1: cek operator terkait (confirmed=0)
|
||||
const fd0 = new FormData();
|
||||
fd0.append('id', id);
|
||||
fd0.append('confirmed', '0');
|
||||
|
||||
fetch('api/ibadah/hapus.php', { method: 'POST', body: appendCsrf(fd0) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'warning') {
|
||||
showDeleteConfirm(j.message + '\n\nLanjutkan hapus?').then(ok => {
|
||||
if (ok) _doHapusIbadah(id, btnEl);
|
||||
});
|
||||
} else if (j.status === 'success') {
|
||||
_applyHapusIbadah(id);
|
||||
showToast('Rumah ibadah berhasil dihapus.');
|
||||
} else {
|
||||
showToast(j.message, 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('Gagal menghubungi server.', 'error'));
|
||||
};
|
||||
|
||||
function _doHapusIbadah(id, btnEl) {
|
||||
showDeleteConfirm('Yakin ingin menghapus permanen rumah ibadah ini?').then(ok => {
|
||||
if (!ok) return;
|
||||
btnEl.disabled = true;
|
||||
btnEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menghapus...';
|
||||
lucide.createIcons();
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('confirmed', '1');
|
||||
fetch('api/ibadah/hapus.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
_applyHapusIbadah(id);
|
||||
showToast('Rumah ibadah berhasil dihapus.');
|
||||
if (typeof window._pendudukRecalcAll === 'function') window._pendudukRecalcAll();
|
||||
} else {
|
||||
showToast(j.message, 'error');
|
||||
btnEl.disabled = false;
|
||||
btnEl.innerHTML = '<i data-lucide="trash-2" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Hapus Rumah Ibadah';
|
||||
lucide.createIcons();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _applyHapusIbadah(id) {
|
||||
if (allMarkers[id]) {
|
||||
layerGroup.removeLayer(allMarkers[id].marker);
|
||||
layerGroup.removeLayer(allMarkers[id].circle);
|
||||
delete allMarkers[id];
|
||||
}
|
||||
updateIbadahList();
|
||||
refreshList(Object.values(allMarkers).map(e => ({
|
||||
id: e.marker._ibadahId,
|
||||
nama: e.marker._ibadahNama,
|
||||
jenis: e.marker._ibadahJenis,
|
||||
})));
|
||||
}
|
||||
|
||||
// ── Simpan perubahan kontak (dari popup info) ─────────────────────
|
||||
window._ibadahSimpanKontak = function (id) {
|
||||
const inputEl = document.getElementById('edit_kontak_' + id);
|
||||
const statusEl = document.getElementById('kontak_status_' + id);
|
||||
if (!inputEl) return;
|
||||
|
||||
const kontak = inputEl.value.trim();
|
||||
|
||||
statusEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menyimpan...';
|
||||
statusEl.style.color = 'var(--c-muted)';
|
||||
lucide.createIcons();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('kontak', kontak);
|
||||
|
||||
fetch('api/ibadah/update_kontak.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
if (allMarkers[id]) {
|
||||
allMarkers[id].data.kontak = kontak;
|
||||
allMarkers[id].marker.setPopupContent(buildInfoPopup(allMarkers[id].data));
|
||||
_refreshKkList(id);
|
||||
}
|
||||
showToast(`Kontak berhasil diperbarui.`);
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
statusEl.innerHTML = '<i data-lucide="x" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> ' + escapeHTML(err.message);
|
||||
statusEl.style.color = 'var(--c-danger)';
|
||||
lucide.createIcons();
|
||||
});
|
||||
};
|
||||
|
||||
// ── Simpan perubahan radius (dari popup info) ─────────────────────
|
||||
window._ibadahSimpanRadius = function (id) {
|
||||
const inputEl = document.getElementById('edit_radius_' + id);
|
||||
const statusEl = document.getElementById('radius_status_' + id);
|
||||
if (!inputEl) return;
|
||||
|
||||
const radius = Math.max(100, Math.min(5000, parseInt(inputEl.value) || 500));
|
||||
inputEl.value = radius; // normalize
|
||||
|
||||
statusEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menyimpan...';
|
||||
statusEl.style.color = 'var(--c-muted)';
|
||||
lucide.createIcons();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('radius_m', radius);
|
||||
|
||||
fetch('api/ibadah/update_radius.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
if (allMarkers[id]) {
|
||||
allMarkers[id].circle.setRadius(radius);
|
||||
allMarkers[id].marker._ibadahRadius = radius;
|
||||
|
||||
// Update objek data yang disimpan
|
||||
allMarkers[id].data.radius_m = radius;
|
||||
|
||||
// Update isi popup agar saat dibuka lagi nilainya sudah yang baru
|
||||
allMarkers[id].marker.setPopupContent(buildInfoPopup(allMarkers[id].data));
|
||||
_refreshKkList(id);
|
||||
}
|
||||
|
||||
// Update _ibadahList
|
||||
const entry = window._ibadahList.find(x => parseInt(x.id) === id);
|
||||
if (entry) entry.radius = radius;
|
||||
|
||||
// Recalculate semua penduduk dengan radius baru
|
||||
if (typeof window._pendudukRecalcAll === 'function') {
|
||||
window._pendudukRecalcAll();
|
||||
}
|
||||
|
||||
// Alert jika ada warga baru yang jadi blank spot akibat radius diperkecil
|
||||
setTimeout(() => {
|
||||
const newBlankCount = (window._pendudukAll || window._pendudukList || [])
|
||||
.filter(p => !p.ibadah).length;
|
||||
if (newBlankCount > 0) {
|
||||
showToast(
|
||||
`⚠ Radius diperkecil — ${newBlankCount} warga kini menjadi Blank Spot.`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
if (typeof window._updateBlankSpotCounter === 'function') {
|
||||
window._updateBlankSpotCounter();
|
||||
}
|
||||
}, 200);
|
||||
|
||||
showToast(`Radius berhasil diubah ke ${radius}m.`);
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
statusEl.innerHTML = '<i data-lucide="x" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> ' + escapeHTML(err.message);
|
||||
statusEl.style.color = 'var(--c-danger)';
|
||||
lucide.createIcons();
|
||||
});
|
||||
};
|
||||
|
||||
// ── Klik peta: Form tambah ibadah ─────────────────────────────────
|
||||
function onMapClick(e) {
|
||||
if (typeof activeTool === 'undefined' || activeTool !== 'ibadah') return;
|
||||
|
||||
const lat = e.latlng.lat.toFixed(8);
|
||||
const lng = e.latlng.lng.toFixed(8);
|
||||
|
||||
if (tempMarker) map.removeLayer(tempMarker);
|
||||
tempMarker = L.marker([lat, lng], { icon: iconTemp }).addTo(map);
|
||||
|
||||
// Buka popup dengan alamat "Loading..."
|
||||
const popup = L.popup({ maxWidth: 340, closeOnClick: false })
|
||||
.setLatLng(e.latlng)
|
||||
.setContent(buildFormPopup(lat, lng, 'Mengambil alamat...'))
|
||||
.openOn(map);
|
||||
|
||||
// Reverse Geocoding via Nominatim
|
||||
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&accept-language=id`, {
|
||||
headers: { 'Accept-Language': 'id' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(geo => {
|
||||
const alamat = geo.display_name || '';
|
||||
// Update field alamat di form
|
||||
const el = document.getElementById('f_ibadah_alamat');
|
||||
if (el) el.value = alamat;
|
||||
})
|
||||
.catch(() => {
|
||||
const el = document.getElementById('f_ibadah_alamat');
|
||||
if (el) el.value = '';
|
||||
});
|
||||
|
||||
setTimeout(() => { const el = document.getElementById('f_ibadah_nama'); if (el) el.focus(); }, 200);
|
||||
|
||||
// Tampilkan preview circle radius saat popup terbuka
|
||||
if (_previewCircle) { layerGroup.removeLayer(_previewCircle); _previewCircle = null; }
|
||||
_previewCircle = L.circle([lat, lng], {
|
||||
radius: 500, color: '#38bdf8', weight: 1.5, opacity: 0.6,
|
||||
fillColor: '#0ea5e9', fillOpacity: 0.05, dashArray: '6,4'
|
||||
}).addTo(layerGroup);
|
||||
|
||||
// Hapus preview circle & temp marker saat popup ditutup
|
||||
const _markerThisClick = tempMarker;
|
||||
const _circleThisClick = _previewCircle;
|
||||
map.once('popupclose', function () {
|
||||
if (_circleThisClick) { try { layerGroup.removeLayer(_circleThisClick); } catch(_){} }
|
||||
if (_previewCircle === _circleThisClick) _previewCircle = null;
|
||||
if (_markerThisClick) { try { map.removeLayer(_markerThisClick); } catch(_){} }
|
||||
if (tempMarker === _markerThisClick) tempMarker = null;
|
||||
});
|
||||
}
|
||||
|
||||
function buildFormPopup(lat, lng, alamatPlaceholder) {
|
||||
return `<div class="form-popup">
|
||||
<div class="form-popup-header">
|
||||
<div class="form-popup-icon" style="background:rgba(14,165,233,.12);"><i data-lucide="map-pin"></i></div>
|
||||
<div>
|
||||
<div class="form-popup-title">Tambah Rumah Ibadah</div>
|
||||
<div class="form-popup-coords">${lat}, ${lng}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nama Tempat *</label>
|
||||
<input type="text" id="f_ibadah_nama" placeholder="cth. Masjid Al-Ikhlas" maxlength="100" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Jenis</label>
|
||||
<select id="f_ibadah_jenis">
|
||||
<option value="Masjid">🕌 Masjid</option>
|
||||
<option value="Mushola">🕌 Mushola</option>
|
||||
<option value="Gereja">⛪ Gereja</option>
|
||||
<option value="Pura">🛕 Pura</option>
|
||||
<option value="Vihara">🏯 Vihara</option>
|
||||
<option value="Klenteng">🏮 Klenteng</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><i data-lucide="ruler" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Radius Wilayah</label>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-top:4px;">
|
||||
<input type="range" id="f_ibadah_radius" value="500" min="100" max="5000" step="50"
|
||||
oninput="document.getElementById('f_ibadah_radius_val').textContent=this.value+'m'; window._ibadahPreviewRadius(this.value)"
|
||||
style="flex:1;height:4px;accent-color:#38bdf8;cursor:pointer;">
|
||||
<span id="f_ibadah_radius_val"
|
||||
style="min-width:50px;text-align:right;font-family:var(--font-mono);
|
||||
font-size:12px;color:#38bdf8;font-weight:700;">500m</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--c-muted);margin-top:2px;">
|
||||
<span>100m</span><span>5000m</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Alamat (Otomatis)</label>
|
||||
<input type="text" id="f_ibadah_alamat" value="${escapeHTML(alamatPlaceholder)}" placeholder="Mendeteksi alamat..." autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kontak Pengurus</label>
|
||||
<input type="text" id="f_ibadah_kontak" placeholder="No HP/WA (Opsional)" maxlength="100" autocomplete="off">
|
||||
</div>
|
||||
<input type="hidden" id="f_ibadah_lat" value="${lat}">
|
||||
<input type="hidden" id="f_ibadah_lng" value="${lng}">
|
||||
<button class="btn-save" id="btnSimpanIbadah" onclick="window._ibadahSimpan()" style="background:linear-gradient(135deg,#7c3aed,#a855f7);display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<i data-lucide="save" style="width:14px;height:14px;"></i> Simpan Rumah Ibadah
|
||||
</button>
|
||||
<div class="form-status" id="ibadahStatus"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Live preview radius (form tambah baru) ────────────────────────
|
||||
window._ibadahPreviewRadius = function (val) {
|
||||
const r = Math.max(100, Math.min(5000, parseInt(val) || 500));
|
||||
if (_previewCircle) _previewCircle.setRadius(r);
|
||||
};
|
||||
|
||||
// ── Live preview radius (edit marker yg sudah ada) ────────────────
|
||||
let _recalcDebounce = null;
|
||||
window._ibadahPreviewRadiusById = function (id, val) {
|
||||
const r = Math.max(100, Math.min(5000, parseInt(val) || 500));
|
||||
const targetId = parseInt(id);
|
||||
|
||||
// 1. Update visual circle & marker property
|
||||
if (allMarkers[targetId]) {
|
||||
allMarkers[targetId].circle.setRadius(r);
|
||||
allMarkers[targetId].marker._ibadahRadius = r;
|
||||
}
|
||||
|
||||
// 2. Paksa sinkronisasi window._ibadahList agar penduduk.js melihat radius terbaru
|
||||
if (window._ibadahList) {
|
||||
const entry = window._ibadahList.find(x => parseInt(x.id) === targetId);
|
||||
if (entry) {
|
||||
entry.radius = r;
|
||||
} else {
|
||||
// Jika tidak ketemu (jarang terjadi), panggil sync full
|
||||
updateIbadahList();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Recalculate warna penduduk (debounce agar ringan)
|
||||
clearTimeout(_recalcDebounce);
|
||||
_recalcDebounce = setTimeout(() => {
|
||||
console.log(`[Ibadah] Recalculating proximity for Radius: ${r}m`);
|
||||
if (typeof window._pendudukRecalcAll === 'function') {
|
||||
window._pendudukRecalcAll();
|
||||
}
|
||||
}, 50); // Dipercepat ke 50ms untuk feel yang lebih instan
|
||||
};
|
||||
|
||||
// ── Simpan ────────────────────────────────────────────────────────
|
||||
window._ibadahSimpan = function () {
|
||||
const nama = document.getElementById('f_ibadah_nama').value.trim();
|
||||
const jenis = document.getElementById('f_ibadah_jenis').value;
|
||||
const radius = Math.max(100, Math.min(5000, parseInt(document.getElementById('f_ibadah_radius')?.value) || 500));
|
||||
const alamat = document.getElementById('f_ibadah_alamat').value.trim();
|
||||
const kontak = document.getElementById('f_ibadah_kontak')?.value.trim() || '';
|
||||
const lat = document.getElementById('f_ibadah_lat').value;
|
||||
const lng = document.getElementById('f_ibadah_lng').value;
|
||||
const status = document.getElementById('ibadahStatus');
|
||||
const btn = document.getElementById('btnSimpanIbadah');
|
||||
|
||||
if (!nama) {
|
||||
status.textContent = '⚠ Nama wajib diisi.';
|
||||
status.className = 'form-status error';
|
||||
document.getElementById('f_ibadah_nama').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menyimpan...';
|
||||
lucide.createIcons();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('nama', nama);
|
||||
fd.append('jenis', jenis);
|
||||
fd.append('alamat', alamat);
|
||||
fd.append('kontak', kontak);
|
||||
fd.append('lat', lat);
|
||||
fd.append('lng', lng);
|
||||
fd.append('radius_m', radius);
|
||||
|
||||
fetch('api/ibadah/simpan.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (_previewCircle) { layerGroup.removeLayer(_previewCircle); _previewCircle = null; }
|
||||
addToMap(j.data);
|
||||
updateIbadahList();
|
||||
refreshList(Object.values(allMarkers).map(e => ({
|
||||
id: e.marker._ibadahId,
|
||||
nama: e.marker._ibadahNama,
|
||||
jenis: e.marker._ibadahJenis,
|
||||
})));
|
||||
showToast(`"${escapeHTML(nama)}" berhasil ditambahkan!`);
|
||||
// Recalculate penduduk setelah ibadah baru ditambah
|
||||
if (typeof window._pendudukRecalcAll === 'function') {
|
||||
window._pendudukRecalcAll();
|
||||
}
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
status.innerHTML = '<i data-lucide="x" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> ' + escapeHTML(err.message);
|
||||
status.className = 'form-status error';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i data-lucide="save" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i> Simpan Rumah Ibadah';
|
||||
lucide.createIcons();
|
||||
});
|
||||
};
|
||||
|
||||
// ── Recalculate All proximity (Admin only, with race-condition guard) ──
|
||||
let _recalcInProgress = false;
|
||||
|
||||
window._recalcAll = function () {
|
||||
if (_recalcInProgress) {
|
||||
showToast('Proses hitung ulang sedang berjalan...', 'error');
|
||||
return;
|
||||
}
|
||||
showDeleteConfirm(
|
||||
'Proses ini dapat mengubah assignment warga ke rumah ibadah/operator berdasarkan radius terkini. Lanjutkan?',
|
||||
{ type: 'warn', iconName: 'refresh-cw', title: 'Konfirmasi Hitung Ulang Proximity', btnLabel: 'Ya, Hitung Ulang' }
|
||||
).then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
_recalcInProgress = true;
|
||||
|
||||
const allBtns = document.querySelectorAll(
|
||||
'#btnToolIbadah, #btnToolPenduduk, #btnRecalcAll, .btn-hapus, .btn-save'
|
||||
);
|
||||
allBtns.forEach(b => { b.disabled = true; });
|
||||
|
||||
const btn = document.getElementById('btnRecalcAll');
|
||||
const icon = btn ? btn.querySelector('.tb-icon') : null;
|
||||
if (btn) {
|
||||
if (icon) icon.textContent = '⏳';
|
||||
btn.style.color = 'var(--c-muted)';
|
||||
}
|
||||
|
||||
showToast('Menghitung ulang proximity...');
|
||||
|
||||
fetch('api/ibadah/recalculate.php', { method: 'POST', body: appendCsrf(new FormData()) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
showToast(`✓ ${j.message}`);
|
||||
if (typeof window._pendudukReload === 'function') {
|
||||
window._pendudukReload();
|
||||
} else if (typeof window._pendudukRecalcAll === 'function') {
|
||||
window._pendudukRecalcAll();
|
||||
}
|
||||
} else {
|
||||
showToast(j.message || 'Gagal menghitung ulang.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('Gagal menghubungi server.', 'error'))
|
||||
.finally(() => {
|
||||
_recalcInProgress = false;
|
||||
allBtns.forEach(b => { b.disabled = false; });
|
||||
if (btn) {
|
||||
if (icon) icon.textContent = '🔄';
|
||||
btn.style.color = 'var(--c-warn)';
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
window._ibadahReload = loadData;
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────
|
||||
window.initIbadah = function () {
|
||||
if (active) { loadData(); return; }
|
||||
active = true;
|
||||
|
||||
layerGroup.addTo(map);
|
||||
map.on('click', onMapClick);
|
||||
|
||||
window._dlFocusFns['Ibadah'] = function (id) {
|
||||
const entry = allMarkers[id];
|
||||
if (!entry) return;
|
||||
map.setView(entry.marker.getLatLng(), Math.max(map.getZoom(), 17), { animate: true });
|
||||
setTimeout(() => entry.marker.openPopup(), 350);
|
||||
};
|
||||
|
||||
loadData();
|
||||
};
|
||||
|
||||
// ── Toggle visibilitas layer ──────────────────────────────────────
|
||||
window._ibadahToggleLayer = function (visible) {
|
||||
if (visible) {
|
||||
if (!map.hasLayer(layerGroup)) map.addLayer(layerGroup);
|
||||
} else {
|
||||
map.removeLayer(layerGroup);
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
}
|
||||
};
|
||||
|
||||
window._setBufferVisible = function (visible) {
|
||||
bufferVisible = visible;
|
||||
Object.values(allMarkers).forEach(function (entry) {
|
||||
if (visible) {
|
||||
if (!layerGroup.hasLayer(entry.circle)) entry.circle.addTo(layerGroup);
|
||||
} else {
|
||||
if (layerGroup.hasLayer(entry.circle)) layerGroup.removeLayer(entry.circle);
|
||||
}
|
||||
});
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,195 @@
|
||||
// modules/kebutuhan.js — Manajemen Kebutuhan per Warga Miskin (F14)
|
||||
(function () {
|
||||
const KATEGORI_ICON = {
|
||||
'Sembako' : 'shopping-cart',
|
||||
'Biaya Sekolah' : 'graduation-cap',
|
||||
'Biaya Kesehatan' : 'heart-pulse',
|
||||
'Modal Usaha' : 'briefcase',
|
||||
'Renovasi Rumah' : 'home',
|
||||
'Perlengkapan Rumah': 'armchair',
|
||||
'Pakaian' : 'shirt',
|
||||
'Lainnya' : 'clipboard-list',
|
||||
};
|
||||
const STATUS_COLOR = {
|
||||
'Belum Terpenuhi': '#ef4444',
|
||||
'Dalam Proses' : '#e3b341',
|
||||
'Terpenuhi' : '#3fb950',
|
||||
};
|
||||
const KATEGORI_LIST = [
|
||||
'Sembako','Biaya Sekolah','Biaya Kesehatan','Modal Usaha',
|
||||
'Renovasi Rumah','Perlengkapan Rumah','Pakaian','Lainnya',
|
||||
];
|
||||
|
||||
function isOp() { return window.APP_USER && (window.APP_USER.isOp || window.APP_USER.isAdmin); }
|
||||
function esc(str) {
|
||||
const d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(str ?? ''));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ── Bangun seksi kebutuhan untuk popup penduduk ───────────────────────────
|
||||
window._kebutuhanBuildSection = function (pendudukId, kebutuhanOpen, statusVerifikasi) {
|
||||
const count = parseInt(kebutuhanOpen) || 0;
|
||||
const canMutate = isOp() && statusVerifikasi === 'Terverifikasi';
|
||||
const badgeHtml = count > 0
|
||||
? `<span id="kebutuhanBadge_${pendudukId}"
|
||||
style="background:#ef444422;color:#ef4444;border:1px solid #ef444444;
|
||||
font-size:10px;font-weight:700;padding:2px 7px;border-radius:20px;
|
||||
margin-left:4px;">${count} Belum Terpenuhi</span>`
|
||||
: `<span id="kebutuhanBadge_${pendudukId}"></span>`;
|
||||
|
||||
const formHtml = canMutate ? `
|
||||
<div id="kebutuhanForm_${pendudukId}" style="display:none;margin-top:8px;padding:8px;
|
||||
background:rgba(255,255,255,.03);border-radius:6px;border:1px solid var(--c-border);">
|
||||
<select id="kf_kat_${pendudukId}"
|
||||
style="width:100%;padding:5px 8px;background:var(--c-surface2);border:1px solid var(--c-border);
|
||||
border-radius:6px;color:var(--c-text);font-size:12px;margin-bottom:6px;">
|
||||
${KATEGORI_LIST.map(k => `<option value="${esc(k)}">${esc(k)}</option>`).join('')}
|
||||
</select>
|
||||
<input type="text" id="kf_desc_${pendudukId}"
|
||||
placeholder="Deskripsi opsional (maks 300 karakter)" maxlength="300"
|
||||
style="width:100%;padding:5px 8px;background:var(--c-surface2);border:1px solid var(--c-border);
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button onclick="window._kebutuhanSimpan(${pendudukId})"
|
||||
style="flex:1;padding:5px;font-size:11px;background:rgba(63,185,80,.1);color:#3fb950;
|
||||
border:1px solid rgba(63,185,80,.3);border-radius:6px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<i data-lucide="save" style="width:12px;height:12px;"></i> Simpan
|
||||
</button>
|
||||
<button onclick="document.getElementById('kebutuhanForm_${pendudukId}').style.display='none'"
|
||||
style="padding:5px 10px;font-size:11px;background:transparent;color:var(--c-muted);
|
||||
border:1px solid var(--c-border);border-radius:6px;cursor:pointer;">
|
||||
Batal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btnTambahKebutuhan_${pendudukId}"
|
||||
onclick="document.getElementById('kebutuhanForm_${pendudukId}').style.display='';
|
||||
document.getElementById('btnTambahKebutuhan_${pendudukId}').style.display='none';
|
||||
if(typeof lucide !== 'undefined') lucide.createIcons();"
|
||||
style="font-size:11px;padding:3px 8px;margin-top:6px;border-radius:4px;cursor:pointer;
|
||||
border:1px solid rgba(63,185,80,.3);background:transparent;color:#3fb950;">
|
||||
+ Tambah Kebutuhan
|
||||
</button>` : (isOp() ? `<div style="font-size:11px;color:var(--c-muted);font-weight:600;margin-top:6px;">Menunggu verifikasi admin</div>` : '');
|
||||
|
||||
return `<div class="info-row" style="align-items:flex-start;border-top:1px solid var(--c-border);padding-top:8px;margin-top:4px;">
|
||||
<div class="info-row-icon"><i data-lucide="clipboard-list"></i></div>
|
||||
<div style="flex:1;">
|
||||
<div class="info-row-label">Kebutuhan ${badgeHtml}</div>
|
||||
<div id="kebutuhanBody_${pendudukId}" data-verifikasi="${esc(statusVerifikasi || 'Pending')}" style="margin-top:4px;">
|
||||
<button onclick="window._kebutuhanLoad(${pendudukId})"
|
||||
style="font-size:11px;padding:2px 7px;border-radius:4px;cursor:pointer;
|
||||
border:1px solid var(--c-border);background:transparent;color:var(--c-muted);">
|
||||
Muat Kebutuhan
|
||||
</button>
|
||||
</div>
|
||||
${formHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// ── Muat dan render daftar kebutuhan ─────────────────────────────────────
|
||||
window._kebutuhanLoad = function (pendudukId) {
|
||||
const body = document.getElementById('kebutuhanBody_' + pendudukId);
|
||||
if (!body) return;
|
||||
const statusVerifikasi = body.dataset.verifikasi || 'Pending';
|
||||
const canMutate = isOp() && statusVerifikasi === 'Terverifikasi';
|
||||
body.innerHTML = '<span style="font-size:11px;color:var(--c-muted);display:inline-flex;align-items:center;gap:4px;"><i data-lucide="loader-2" class="lucide animate-spin" style="width:12px;height:12px;"></i> Memuat...</span>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
fetch('api/kebutuhan/ambil.php?penduduk_id=' + pendudukId + '&_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
const rows = j.data;
|
||||
const openCount = rows.filter(r => r.status === 'Belum Terpenuhi').length;
|
||||
if (typeof window._pendudukRefreshKebutuhanCount === 'function') {
|
||||
window._pendudukRefreshKebutuhanCount(pendudukId, openCount);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
body.innerHTML = '<span style="font-size:11px;color:var(--c-muted);">Belum ada kebutuhan tercatat.</span>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = rows.map(r => {
|
||||
const sc = STATUS_COLOR[r.status] || '#8b949e';
|
||||
const icon = KATEGORI_ICON[r.kategori] || 'clipboard-list';
|
||||
const iconHtml = `<i data-lucide="${icon}" class="lucide-inline" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i>`;
|
||||
const statusBtns = canMutate
|
||||
? ['Belum Terpenuhi', 'Dalam Proses', 'Terpenuhi'].map(s =>
|
||||
`<button onclick="window._kebutuhanUpdateStatus(${r.id},'${s}',${pendudukId})"
|
||||
style="font-size:10px;padding:2px 6px;border-radius:4px;cursor:pointer;
|
||||
border:1px solid ${STATUS_COLOR[s]};
|
||||
background:${s === r.status ? STATUS_COLOR[s] + '33' : 'transparent'};
|
||||
color:${STATUS_COLOR[s]};font-weight:${s === r.status ? '700' : '400'};">${s}</button>`
|
||||
).join('')
|
||||
: `<span style="font-size:11px;color:${sc};font-weight:700;">${esc(r.status)}</span>`;
|
||||
return `<div style="padding:5px 0;border-bottom:1px solid rgba(255,255,255,.06);">
|
||||
<div style="font-size:12px;font-weight:600;color:var(--c-text);display:flex;align-items:center;gap:2px;">
|
||||
${iconHtml} ${esc(r.kategori)}
|
||||
</div>
|
||||
${r.deskripsi ? `<div style="font-size:11px;color:var(--c-muted);margin:2px 0;">${esc(r.deskripsi)}</div>` : ''}
|
||||
<div style="display:flex;gap:4px;margin-top:4px;flex-wrap:wrap;">${statusBtns}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
})
|
||||
.catch(err => {
|
||||
const b = document.getElementById('kebutuhanBody_' + pendudukId);
|
||||
if (b) {
|
||||
b.innerHTML = `<span style="font-size:11px;color:#ef4444;display:inline-flex;align-items:center;gap:4px;"><i data-lucide="x-circle" style="width:12px;height:12px;"></i> Gagal: ${esc(err.message)}</span>`;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ── Simpan kebutuhan baru ─────────────────────────────────────────────────
|
||||
window._kebutuhanSimpan = function (pendudukId) {
|
||||
const kat = document.getElementById('kf_kat_' + pendudukId)?.value;
|
||||
const desc = document.getElementById('kf_desc_' + pendudukId)?.value?.trim() || '';
|
||||
if (!kat) return;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('penduduk_id', pendudukId);
|
||||
fd.append('kategori', kat);
|
||||
fd.append('deskripsi', desc);
|
||||
|
||||
fetch('api/kebutuhan/simpan.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
const formEl = document.getElementById('kebutuhanForm_' + pendudukId);
|
||||
const btnEl = document.getElementById('btnTambahKebutuhan_' + pendudukId);
|
||||
const descEl = document.getElementById('kf_desc_' + pendudukId);
|
||||
if (formEl) formEl.style.display = 'none';
|
||||
if (btnEl) btnEl.style.display = '';
|
||||
if (descEl) descEl.value = '';
|
||||
window._kebutuhanLoad(pendudukId);
|
||||
if (typeof window.dispatchDataChanged === 'function') {
|
||||
window.dispatchDataChanged('kebutuhan', { pendudukId, mutation: 'create' });
|
||||
}
|
||||
showToast('Kebutuhan berhasil disimpan.');
|
||||
})
|
||||
.catch(err => showToast(err.message || 'Gagal menyimpan kebutuhan.', 'error'));
|
||||
};
|
||||
|
||||
// ── Update status kebutuhan ───────────────────────────────────────────────
|
||||
window._kebutuhanUpdateStatus = function (kebutuhanId, status, pendudukId) {
|
||||
const catatan = prompt('Catatan perubahan kebutuhan (opsional):', '');
|
||||
if (catatan === null) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', kebutuhanId);
|
||||
fd.append('status', status);
|
||||
fd.append('catatan', catatan);
|
||||
|
||||
fetch('api/kebutuhan/update_status.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
window._kebutuhanLoad(pendudukId);
|
||||
if (typeof window.dispatchDataChanged === 'function') {
|
||||
window.dispatchDataChanged('kebutuhan', { pendudukId, kebutuhanId, mutation: 'status' });
|
||||
}
|
||||
showToast('Status kebutuhan diperbarui.');
|
||||
})
|
||||
.catch(err => showToast(err.message || 'Gagal memperbarui status.', 'error'));
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,975 @@
|
||||
// modules/penduduk.js — Manajemen Penduduk Miskin dengan Proximity Logic (Haversine)
|
||||
(function () {
|
||||
const layerGroup = L.layerGroup();
|
||||
window._pendudukLayer = layerGroup;
|
||||
window._pendudukAll = []; // Dataset penuh dari API.
|
||||
window._pendudukVisible = []; // Dataset yang sedang terlihat/listed.
|
||||
window._pendudukList = window._pendudukVisible; // Alias kompatibilitas.
|
||||
|
||||
let allMarkers = {}; // { id: circleMarker }
|
||||
let filterState = { search: '', kategori: '', keterjangkauan: '', jenisIbadah: '', statusBantuan: '', kebutuhanKategori: '', kebutuhanStatus: '' };
|
||||
let subLayerVisible = { terjangkau: true, blankspot: true };
|
||||
let tempMarker = null;
|
||||
let active = false;
|
||||
|
||||
// ── Warna ─────────────────────────────────────────────────────────
|
||||
const COLOR_TERJANGKAU = '#22c55e'; // Hijau — dalam radius (terjangkau)
|
||||
const COLOR_BLANK_SPOT = '#ef4444'; // Merah — di luar semua radius (blank spot)
|
||||
const STATUS_COLOR = { 'Belum Ditangani': '#ef4444', 'Dalam Proses': '#e3b341', 'Sudah Ditangani': '#3fb950' };
|
||||
|
||||
// ── Formula Haversine (return jarak dalam meter) ──────────────────
|
||||
function haversine(lat1, lng1, lat2, lng2) {
|
||||
const R = 6371000; // radius bumi dalam meter
|
||||
const toR = x => x * Math.PI / 180;
|
||||
const dLat = toR(lat2 - lat1);
|
||||
const dLng = toR(lng2 - lng1);
|
||||
const a = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(toR(lat1)) * Math.cos(toR(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
// ── Cari ibadah terdekat dalam radius 500m ────────────────────────
|
||||
function findNearestIbadah(lat, lng) {
|
||||
let nearest = null;
|
||||
let minDist = Infinity;
|
||||
const list = window._ibadahList || [];
|
||||
|
||||
for (const ib of list) {
|
||||
const d = haversine(lat, lng, ib.lat, ib.lng);
|
||||
// Gunakan ib.radius yang sudah di-update real-time oleh ibadah.js
|
||||
const activeRadius = parseInt(ib.radius) || 500;
|
||||
if (d <= activeRadius && d < minDist) {
|
||||
minDist = d;
|
||||
nearest = { ...ib, jarak: Math.round(d), radius: activeRadius };
|
||||
}
|
||||
}
|
||||
return nearest; // null = di luar semua radius
|
||||
}
|
||||
|
||||
// ── Render / update warna satu marker ────────────────────────────
|
||||
function applyColor(cm, ibadah) {
|
||||
const color = ibadah ? COLOR_TERJANGKAU : COLOR_BLANK_SPOT;
|
||||
cm.setStyle({
|
||||
color : color,
|
||||
fillColor : color,
|
||||
fillOpacity: 0.55,
|
||||
weight : 2,
|
||||
});
|
||||
cm._nearestIbadah = ibadah;
|
||||
}
|
||||
|
||||
// ── Buat popup teks ───────────────────────────────────────────────
|
||||
function buildInfoPopup(data, ibadah) {
|
||||
const isAdmin = window.APP_USER && window.APP_USER.isAdmin;
|
||||
|
||||
const ibadahSection = ibadah
|
||||
? `<div class="info-row" style="background:rgba(34,197,94,.06);border-radius:8px;padding:6px 8px;margin:2px 0;">
|
||||
<div class="info-row-icon"><i data-lucide="map-pin"></i></div>
|
||||
<div style="flex:1;">
|
||||
<div class="info-row-label">Rumah Ibadah Terdekat</div>
|
||||
<div class="info-row-value" style="color:#22c55e;font-weight:600;">${escapeHTML(ibadah.nama)}</div>
|
||||
<div style="font-size:11px;color:var(--c-muted);margin-top:3px;display:inline-flex;align-items:center;gap:3px;flex-wrap:wrap;">
|
||||
<i data-lucide="ruler" style="width:12px;height:12px;vertical-align:middle;margin-right:2px;"></i> ${ibadah.jarak}m · <i data-lucide="circle" style="width:10px;height:10px;vertical-align:middle;margin-right:2px;"></i> Radius ${ibadah.radius || 500}m · ${ibadah.jenis || ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
: `<div class="info-row" style="background:rgba(239,68,68,.06);border-radius:8px;padding:6px 8px;margin:2px 0;">
|
||||
<div class="info-row-icon" style="color:#ef4444;"><i data-lucide="alert-triangle"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label">Status Wilayah</div>
|
||||
<div class="info-row-value" style="color:#ef4444;font-weight:600;">Di luar semua radius ibadah (Blank Spot)</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const nikHtml = (data.nik && window.APP_USER && window.APP_USER.loggedIn)
|
||||
? `<div style="font-size:11px;color:var(--c-muted);margin-top:2px;">NIK: ${escapeHTML(data.nik)}</div>` : '';
|
||||
|
||||
const catatanHtml = (data.catatan && window.APP_USER && window.APP_USER.loggedIn)
|
||||
? `<div class="info-row">
|
||||
<div class="info-row-icon"><i data-lucide="file-text"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label">Catatan Kondisi</div>
|
||||
<div class="info-row-value" style="font-size:12px;line-height:1.4;">${escapeHTML(data.catatan)}</div>
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
const isOp = window.APP_USER && window.APP_USER.isOp;
|
||||
const st = data.status_bantuan || 'Belum Ditangani';
|
||||
const stC = STATUS_COLOR[st] || '#8b949e';
|
||||
const sv = data.status_verifikasi || 'Pending';
|
||||
const statusBtns = sv === 'Terverifikasi'
|
||||
? ['Belum Ditangani', 'Dalam Proses', 'Sudah Ditangani'].map(s =>
|
||||
`<button onclick="window._updateStatus(${data.id},'${s}',this)"
|
||||
style="font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer;
|
||||
border:1px solid ${STATUS_COLOR[s]};
|
||||
background:${s === st ? STATUS_COLOR[s] + '33' : 'transparent'};
|
||||
color:${STATUS_COLOR[s]};font-weight:${s === st ? '700' : '400'};">${escapeHTML(s)}</button>`
|
||||
).join('')
|
||||
: `<span style="font-size:11px;color:var(--c-muted);font-weight:600;">Menunggu verifikasi admin</span>`;
|
||||
|
||||
const statusSection = isOp
|
||||
? `<div class="info-row" style="align-items:flex-start;">
|
||||
<div class="info-row-icon"><i data-lucide="clipboard-list"></i></div>
|
||||
<div style="flex:1;">
|
||||
<div class="info-row-label">Status Bantuan</div>
|
||||
<span style="display:inline-block;font-size:11px;font-weight:700;padding:2px 8px;border-radius:20px;
|
||||
background:${stC}22;color:${stC}0d;margin-top:3px;border:1px solid ${stC}44;color:${stC};">${escapeHTML(st)}</span>
|
||||
<div data-sg="${data.id}" style="display:flex;gap:4px;margin-top:6px;flex-wrap:wrap;">${statusBtns}</div>
|
||||
<button onclick="window._showRiwayat(${data.id})"
|
||||
style="font-size:11px;padding:3px 8px;margin-top:6px;border-radius:4px;cursor:pointer;
|
||||
border:1px solid var(--c-border);background:transparent;color:var(--c-muted);display:inline-flex;align-items:center;gap:4px;">
|
||||
<i data-lucide="history" style="width:11px;height:11px;"></i> Lihat Riwayat
|
||||
</button>
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
// ── Badge & aksi verifikasi ───────────────────────────────
|
||||
const svClr = { Terverifikasi: '#22c55e', Pending: '#e3b341', Ditolak: '#ef4444' }[sv] || '#8b949e';
|
||||
const svIcon = {
|
||||
Terverifikasi: '<i data-lucide="check-circle" style="width:11px;height:11px;vertical-align:middle;margin-right:4px;"></i>',
|
||||
Pending: '<i data-lucide="clock" style="width:11px;height:11px;vertical-align:middle;margin-right:4px;"></i>',
|
||||
Ditolak: '<i data-lucide="x-circle" style="width:11px;height:11px;vertical-align:middle;margin-right:4px;"></i>'
|
||||
}[sv] || '';
|
||||
const verifBadge = (isAdmin || isOp)
|
||||
? `<div style="display:inline-flex;align-items:center;font-size:10px;font-weight:700;
|
||||
padding:2px 8px;border-radius:20px;margin-top:4px;
|
||||
background:${svClr}20;color:${svClr};border:1px solid ${svClr}44;">
|
||||
${svIcon}<span>${sv}</span>
|
||||
</div>` : '';
|
||||
|
||||
const verifActions = (isAdmin && sv === 'Pending')
|
||||
? `<div style="display:flex;gap:6px;margin-top:8px;padding-top:10px;border-top:1px solid rgba(255,255,255,.08);">
|
||||
<button onclick="window._verifikasiPenduduk(${data.id},'approve',this)"
|
||||
style="flex:1;padding:7px;border-radius:7px;cursor:pointer;font-size:12px;font-weight:700;
|
||||
border:1px solid #22c55e;background:rgba(34,197,94,.1);color:#22c55e;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<i data-lucide="check" style="width:12px;height:12px;"></i> Verifikasi
|
||||
</button>
|
||||
<button onclick="window._verifikasiPenduduk(${data.id},'reject',this)"
|
||||
style="flex:1;padding:7px;border-radius:7px;cursor:pointer;font-size:12px;font-weight:700;
|
||||
border:1px solid #ef4444;background:rgba(239,68,68,.1);color:#ef4444;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<i data-lucide="x" style="width:12px;height:12px;"></i> Tolak
|
||||
</button>
|
||||
</div>` : '';
|
||||
|
||||
const actionBtns = isAdmin
|
||||
? `<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<button class="btn-hapus" style="flex:1;background:rgba(227,179,65,.08);color:#f0c240;border-color:rgba(227,179,65,.3);display:inline-flex;align-items:center;justify-content:center;gap:4px;"
|
||||
onclick="window._pendudukNonaktifkan(${data.id}, this)">
|
||||
<i data-lucide="user-x" style="width:12px;height:12px;"></i> Nonaktifkan
|
||||
</button>
|
||||
<button class="btn-hapus" style="flex:1;display:inline-flex;align-items:center;justify-content:center;gap:4px;"
|
||||
onclick="window._pendudukHapus(${data.id}, this)">
|
||||
<i data-lucide="trash-2" style="width:12px;height:12px;"></i> Hapus Data
|
||||
</button>
|
||||
</div>` : '';
|
||||
|
||||
const kebutuhanSection = (window.APP_USER && window.APP_USER.loggedIn && typeof window._kebutuhanBuildSection === 'function')
|
||||
? window._kebutuhanBuildSection(data.id, data.kebutuhan_open, sv)
|
||||
: '';
|
||||
|
||||
return `<div class="info-popup">
|
||||
<div class="info-popup-header">
|
||||
<div class="info-popup-icon" style="background:${ibadah ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)'};"><i data-lucide="home"></i></div>
|
||||
<div>
|
||||
<div class="info-popup-name">${escapeHTML(data.nama_kk || 'Warga #' + data.id)}</div>
|
||||
${nikHtml}
|
||||
<div class="info-popup-id">#${data.id} · ${data.kategori || 'Miskin'}</div>
|
||||
${verifBadge}
|
||||
</div>
|
||||
</div>
|
||||
${ibadahSection}
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon"><i data-lucide="users"></i></div>
|
||||
<div><div class="info-row-label">Jumlah Jiwa</div>
|
||||
<div class="info-row-value">${data.jumlah_jiwa} orang</div></div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon"><i data-lucide="map-pin"></i></div>
|
||||
<div><div class="info-row-label">Alamat</div>
|
||||
<div class="info-row-value">${escapeHTML(data.alamat || '-')}</div></div>
|
||||
</div>
|
||||
${catatanHtml}
|
||||
${statusSection}
|
||||
${kebutuhanSection}
|
||||
${verifActions}
|
||||
${actionBtns}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Tambah marker ke peta ─────────────────────────────────────────
|
||||
function addToMap(data) {
|
||||
const lat = parseFloat(data.lat);
|
||||
const lng = parseFloat(data.lng);
|
||||
|
||||
// Koordinat null = publik — simpan ke list saja tanpa marker
|
||||
if (isNaN(lat) || isNaN(lng)) {
|
||||
const ibadahRef = data.ibadah_id
|
||||
? { id: data.ibadah_id, nama: data.nama_ibadah, jenis: data.jenis_ibadah }
|
||||
: null;
|
||||
allMarkers[data.id] = { cm: null, dragHandle: null, data, _ibadahRef: ibadahRef };
|
||||
return;
|
||||
}
|
||||
|
||||
const ibadah = findNearestIbadah(lat, lng);
|
||||
const color = ibadah ? COLOR_TERJANGKAU : COLOR_BLANK_SPOT;
|
||||
|
||||
const cm = L.circleMarker([lat, lng], {
|
||||
radius : 8,
|
||||
color : color,
|
||||
fillColor : color,
|
||||
fillOpacity: 0.55,
|
||||
weight : 2,
|
||||
});
|
||||
|
||||
cm._pendudukId = data.id;
|
||||
cm._pendudukNama = data.nama_kk;
|
||||
cm._pendudukJiwa = data.jumlah_jiwa;
|
||||
cm._nearestIbadah = ibadah;
|
||||
|
||||
cm.bindPopup(buildInfoPopup(data, ibadah), { maxWidth: 280 });
|
||||
|
||||
// Drag to update position
|
||||
// circleMarker tidak draggable by default, gunakan workaround dengan L.marker invisible
|
||||
cm.on('click', function () {
|
||||
cm.openPopup();
|
||||
});
|
||||
|
||||
// Draggable via L.marker overlay (admin only)
|
||||
const isAdmin = window.APP_USER && window.APP_USER.isAdmin;
|
||||
const dragHandle = L.marker([lat, lng], {
|
||||
icon: L.divIcon({
|
||||
className: '',
|
||||
html: '<div style="width:16px;height:16px;cursor:move;"></div>',
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8],
|
||||
}),
|
||||
draggable: isAdmin,
|
||||
zIndexOffset: -500,
|
||||
opacity: 0,
|
||||
});
|
||||
|
||||
dragHandle._cmRef = cm;
|
||||
dragHandle._dataRef = data;
|
||||
|
||||
// Forward klik ke popup circleMarker
|
||||
dragHandle.on('click', function () {
|
||||
cm.openPopup();
|
||||
});
|
||||
dragHandle.on('drag', function (e) {
|
||||
const { lat: nLat, lng: nLng } = e.target.getLatLng();
|
||||
cm.setLatLng([nLat, nLng]);
|
||||
if (allMarkers[data.id]) {
|
||||
allMarkers[data.id].data.lat = nLat;
|
||||
allMarkers[data.id].data.lng = nLng;
|
||||
}
|
||||
updatePendudukList();
|
||||
});
|
||||
|
||||
dragHandle.on('dragend', function (e) {
|
||||
const { lat: nLat, lng: nLng } = e.target.getLatLng();
|
||||
cm.setLatLng([nLat, nLng]);
|
||||
cm.closePopup();
|
||||
|
||||
const ibadahBaru = findNearestIbadah(nLat, nLng);
|
||||
applyColor(cm, ibadahBaru);
|
||||
// Spread from allMarkers (live, may have updated status_bantuan etc.), not stale closure
|
||||
const updatedData = { ...allMarkers[data.id].data, lat: nLat, lng: nLng, is_blank_spot: ibadahBaru ? 0 : 1 };
|
||||
allMarkers[data.id].data = updatedData;
|
||||
cm.bindPopup(buildInfoPopup(updatedData, ibadahBaru), { maxWidth: 280 });
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', data.id);
|
||||
fd.append('lat', nLat.toFixed(8));
|
||||
fd.append('lng', nLng.toFixed(8));
|
||||
fd.append('ibadah_id', ibadahBaru ? ibadahBaru.id : '');
|
||||
|
||||
fetch('api/penduduk/update_posisi.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
showToast(`Posisi "${escapeHTML(data.nama_kk)}" diperbarui.`);
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => showToast(err.message || 'Gagal update posisi.', 'error'));
|
||||
});
|
||||
|
||||
cm.addTo(layerGroup);
|
||||
dragHandle.addTo(layerGroup);
|
||||
|
||||
allMarkers[data.id] = { cm, dragHandle, data };
|
||||
}
|
||||
|
||||
// ── Recalculate semua warna penduduk (dipanggil saat ibadah berubah) ──
|
||||
window._pendudukRecalcAll = function () {
|
||||
Object.values(allMarkers).forEach(({ cm, data }) => {
|
||||
if (!cm) return; // publik — tidak ada marker
|
||||
const ll = cm.getLatLng();
|
||||
const ibadah = findNearestIbadah(ll.lat, ll.lng);
|
||||
applyColor(cm, ibadah);
|
||||
const updatedData = { ...data, lat: ll.lat, lng: ll.lng, is_blank_spot: ibadah ? 0 : 1 };
|
||||
allMarkers[data.id].data = updatedData;
|
||||
cm.bindPopup(buildInfoPopup(updatedData, ibadah), { maxWidth: 280 });
|
||||
});
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
};
|
||||
|
||||
// ── Load data ─────────────────────────────────────────────────────
|
||||
function loadData() {
|
||||
// Reset blank spot filter so imported/reloaded data renders unfiltered
|
||||
_blankSpotFilterActive = false;
|
||||
const btn = document.getElementById('btnBlankSpot');
|
||||
if (btn) btn.style.background = 'transparent';
|
||||
|
||||
layerGroup.clearLayers();
|
||||
allMarkers = {};
|
||||
|
||||
fetch('api/penduduk/ambil.php?_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
j.data.forEach(addToMap);
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('Gagal memuat data penduduk.', 'error');
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Refresh panel list ────────────────────────────────────────────
|
||||
window._pendudukSetFilter = function () {
|
||||
filterState.search = (document.getElementById('searchPenduduk')?.value || '').toLowerCase().trim();
|
||||
filterState.kategori = document.getElementById('filterKategori')?.value || '';
|
||||
filterState.keterjangkauan = document.getElementById('filterKeterjangkauan')?.value || '';
|
||||
filterState.jenisIbadah = document.getElementById('filterJenisIbadah')?.value || '';
|
||||
filterState.statusBantuan = document.getElementById('filterStatusBantuan')?.value || '';
|
||||
filterState.kebutuhanKategori = document.getElementById('filterKebutuhanKategori')?.value || '';
|
||||
filterState.kebutuhanStatus = document.getElementById('filterKebutuhanStatus')?.value || '';
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
// Update kebutuhan_open count in live data + badge (called by kebutuhan.js)
|
||||
window._pendudukRefreshKebutuhanCount = function (pendudukId, newCount) {
|
||||
const entry = allMarkers[pendudukId];
|
||||
if (!entry) return;
|
||||
entry.data.kebutuhan_open = newCount;
|
||||
const badgeEl = document.getElementById('kebutuhanBadge_' + pendudukId);
|
||||
if (badgeEl) {
|
||||
if (newCount > 0) {
|
||||
badgeEl.textContent = newCount + ' Belum Terpenuhi';
|
||||
badgeEl.style.cssText = 'background:#ef444422;color:#ef4444;border:1px solid #ef444444;font-size:10px;font-weight:700;padding:2px 7px;border-radius:20px;margin-left:4px;';
|
||||
} else {
|
||||
badgeEl.textContent = '';
|
||||
badgeEl.style.cssText = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window._setPendudukSubLayerVisible = function (sub, visible) {
|
||||
subLayerVisible[sub] = visible;
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
function applyFilters() {
|
||||
Object.values(allMarkers).forEach(function (m) {
|
||||
// Entry publik tanpa marker — skip layer toggle
|
||||
if (!m.cm) return;
|
||||
const d = m.data;
|
||||
let show = true;
|
||||
|
||||
// Sub-layer toggles
|
||||
if (parseInt(d.is_blank_spot) === 1) {
|
||||
if (!subLayerVisible.blankspot) show = false;
|
||||
} else {
|
||||
if (!subLayerVisible.terjangkau) show = false;
|
||||
}
|
||||
|
||||
// Search: nama KK atau nama ibadah (OR), AND dengan filter lain
|
||||
if (show && filterState.search) {
|
||||
const q = filterState.search;
|
||||
const matchKK = (d.nama_kk || '').toLowerCase().includes(q);
|
||||
const matchIbadah = (d.nama_ibadah || '').toLowerCase().includes(q);
|
||||
if (!matchKK && !matchIbadah) show = false;
|
||||
}
|
||||
|
||||
// Kategori
|
||||
if (show && filterState.kategori && d.kategori !== filterState.kategori) show = false;
|
||||
|
||||
// Keterjangkauan
|
||||
if (show && filterState.keterjangkauan) {
|
||||
if (filterState.keterjangkauan === 'terjangkau' && parseInt(d.is_blank_spot) === 1) show = false;
|
||||
if (filterState.keterjangkauan === 'blankspot' && parseInt(d.is_blank_spot) !== 1) show = false;
|
||||
}
|
||||
|
||||
// Jenis ibadah terdekat
|
||||
if (show && filterState.jenisIbadah && d.jenis_ibadah !== filterState.jenisIbadah) show = false;
|
||||
|
||||
// Status bantuan
|
||||
if (show && filterState.statusBantuan && d.status_bantuan !== filterState.statusBantuan) show = false;
|
||||
|
||||
// Kebutuhan — kategori (cek apakah ada kebutuhan open di kategori tsb)
|
||||
if (show && filterState.kebutuhanKategori) {
|
||||
const openCats = (d.kebutuhan_kategori_open || '').split(',').filter(Boolean);
|
||||
if (!openCats.includes(filterState.kebutuhanKategori)) show = false;
|
||||
}
|
||||
|
||||
// Kebutuhan — status (ada ≥1 kebutuhan dalam status tsb)
|
||||
if (show && filterState.kebutuhanStatus) {
|
||||
const ks = filterState.kebutuhanStatus;
|
||||
if (ks === 'Belum Terpenuhi' && !(parseInt(d.kebutuhan_open) > 0)) show = false;
|
||||
if (ks === 'Dalam Proses' && !(parseInt(d.kebutuhan_proses) > 0)) show = false;
|
||||
if (ks === 'Terpenuhi' && !(parseInt(d.kebutuhan_terpenuhi) > 0)) show = false;
|
||||
}
|
||||
|
||||
if (show) {
|
||||
if (!layerGroup.hasLayer(m.cm)) m.cm.addTo(layerGroup);
|
||||
if (!layerGroup.hasLayer(m.dragHandle)) m.dragHandle.addTo(layerGroup);
|
||||
} else {
|
||||
if (layerGroup.hasLayer(m.cm)) layerGroup.removeLayer(m.cm);
|
||||
if (layerGroup.hasLayer(m.dragHandle)) layerGroup.removeLayer(m.dragHandle);
|
||||
}
|
||||
});
|
||||
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
}
|
||||
|
||||
function updatePendudukList() {
|
||||
function toListItem(m) {
|
||||
const hasMarker = !!m.cm;
|
||||
const ibadah = hasMarker ? m.cm._nearestIbadah : m._ibadahRef;
|
||||
return {
|
||||
id : m.data.id,
|
||||
lat : hasMarker ? m.cm.getLatLng().lat : (m.data.lat ?? null),
|
||||
lng : hasMarker ? m.cm.getLatLng().lng : (m.data.lng ?? null),
|
||||
jiwa : parseInt(m.data.jumlah_jiwa) || 1,
|
||||
jumlah_jiwa : parseInt(m.data.jumlah_jiwa) || 1,
|
||||
nama_kk : m.data.nama_kk,
|
||||
kategori : m.data.kategori || 'Miskin',
|
||||
is_blank_spot : parseInt(m.data.is_blank_spot) || 0,
|
||||
status_bantuan : m.data.status_bantuan || 'Belum Ditangani',
|
||||
ibadah_jenis : m.data.jenis_ibadah,
|
||||
ibadah_nama : m.data.nama_ibadah,
|
||||
isRadius : !!ibadah,
|
||||
ibadah : ibadah,
|
||||
kebutuhan_open : parseInt(m.data.kebutuhan_open) || 0,
|
||||
kebutuhan_proses : parseInt(m.data.kebutuhan_proses) || 0,
|
||||
kebutuhan_terpenuhi : parseInt(m.data.kebutuhan_terpenuhi) || 0,
|
||||
kebutuhan_kategori_open : m.data.kebutuhan_kategori_open || '',
|
||||
};
|
||||
}
|
||||
|
||||
const entries = Object.values(allMarkers);
|
||||
const visibleEntries = entries.filter(m => !m.cm || layerGroup.hasLayer(m.cm));
|
||||
|
||||
window._pendudukAll = entries.map(toListItem);
|
||||
window._pendudukVisible = visibleEntries.map(toListItem);
|
||||
window._pendudukList = window._pendudukVisible;
|
||||
|
||||
console.log('[Penduduk] List updated, all:', window._pendudukAll.length, 'visible:', window._pendudukVisible.length);
|
||||
if (typeof window._statsUpdate === 'function') window._statsUpdate();
|
||||
if (typeof window._heatmapUpdate === 'function') window._heatmapUpdate();
|
||||
if (typeof window._updateBlankSpotCounter === 'function') window._updateBlankSpotCounter();
|
||||
}
|
||||
|
||||
function refreshList() {
|
||||
const listEl = document.getElementById('dlListPenduduk');
|
||||
const countEl = document.getElementById('dlCountPenduduk');
|
||||
const headerEl = document.getElementById('dlHeaderPenduduk');
|
||||
if (!listEl) return;
|
||||
|
||||
const entries = Object.values(allMarkers).filter(m => !m.cm || layerGroup.hasLayer(m.cm));
|
||||
const total = entries.length;
|
||||
|
||||
if (countEl) countEl.textContent = total;
|
||||
|
||||
if (!total) {
|
||||
listEl.innerHTML = '<div class="dl-empty">Belum ada data.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const cats = [
|
||||
{ key: 'Sangat Miskin', color: '#ef4444' },
|
||||
{ key: 'Miskin', color: '#f59e0b' },
|
||||
{ key: 'Hampir Miskin', color: '#e3b341' },
|
||||
];
|
||||
|
||||
const counts = {}, jiwaMap = {};
|
||||
entries.forEach(m => {
|
||||
const k = m.data.kategori || 'Miskin';
|
||||
counts[k] = (counts[k] || 0) + 1;
|
||||
jiwaMap[k] = (jiwaMap[k] || 0) + (parseInt(m.data.jumlah_jiwa) || 1);
|
||||
});
|
||||
|
||||
listEl.innerHTML = cats
|
||||
.filter(c => counts[c.key])
|
||||
.map(c => {
|
||||
const n = counts[c.key];
|
||||
const j = jiwaMap[c.key];
|
||||
return '<div class="dl-item" style="cursor:default;">' +
|
||||
'<span class="dl-item-dot" style="background:' + c.color + ';"></span>' +
|
||||
'<span class="dl-item-name" style="color:var(--sb-text);">' + c.key + '</span>' +
|
||||
'<span class="dl-item-badge" style="color:' + c.color + ';border-color:' + c.color + '22;background:' + c.color + '11;">' +
|
||||
n + ' KK · ' + j + ' jiwa' +
|
||||
'</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
if (headerEl) headerEl.classList.add('open');
|
||||
listEl.classList.add('open');
|
||||
}
|
||||
|
||||
// ── Hapus (soft delete — untuk data yang keliru diinput) ──────────
|
||||
window._pendudukHapus = function (id, btnEl) {
|
||||
showDeleteConfirm('Hapus data ini? Tindakan ini untuk data yang diinput salah.').then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
btnEl.disabled = true;
|
||||
btnEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menghapus...';
|
||||
lucide.createIcons();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
|
||||
fetch('api/penduduk/hapus.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (allMarkers[id]) {
|
||||
layerGroup.removeLayer(allMarkers[id].cm);
|
||||
layerGroup.removeLayer(allMarkers[id].dragHandle);
|
||||
delete allMarkers[id];
|
||||
}
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
showToast('Data penduduk dihapus (soft delete).');
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
showToast(err.message || 'Gagal menghapus.', 'error');
|
||||
btnEl.disabled = false;
|
||||
btnEl.innerHTML = '<i data-lucide="trash-2" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Hapus Data';
|
||||
lucide.createIcons();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ── Nonaktifkan (warga meninggal / pindah — arsip tetap ada) ─────
|
||||
window._pendudukNonaktifkan = function (id, btnEl) {
|
||||
showDeleteConfirm(
|
||||
'Tandai warga ini sebagai tidak aktif (meninggal/pindah)? Data akan disimpan untuk arsip.',
|
||||
{ type: 'warn', iconName: 'user-x', title: 'Konfirmasi Nonaktifkan', btnLabel: 'Ya, Nonaktifkan' }
|
||||
).then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
btnEl.disabled = true;
|
||||
btnEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Memproses...';
|
||||
lucide.createIcons();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
|
||||
fetch('api/penduduk/nonaktifkan.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (allMarkers[id]) {
|
||||
layerGroup.removeLayer(allMarkers[id].cm);
|
||||
layerGroup.removeLayer(allMarkers[id].dragHandle);
|
||||
delete allMarkers[id];
|
||||
}
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
showToast('Warga dinonaktifkan (arsip tersimpan).');
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
showToast(err.message || 'Gagal.', 'error');
|
||||
btnEl.disabled = false;
|
||||
btnEl.innerHTML = '<i data-lucide="user-x" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Nonaktifkan';
|
||||
lucide.createIcons();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ── Klik peta: Form tambah penduduk ──────────────────────────────
|
||||
function onMapClick(e) {
|
||||
if (typeof activeTool === 'undefined' || activeTool !== 'penduduk') return;
|
||||
|
||||
const lat = e.latlng.lat.toFixed(8);
|
||||
const lng = e.latlng.lng.toFixed(8);
|
||||
|
||||
if (tempMarker) map.removeLayer(tempMarker);
|
||||
|
||||
// Preview warna sebelum disimpan
|
||||
const ibadahPreview = findNearestIbadah(parseFloat(lat), parseFloat(lng));
|
||||
const colorPreview = ibadahPreview ? COLOR_TERJANGKAU : COLOR_BLANK_SPOT;
|
||||
const statusPreview = ibadahPreview
|
||||
? `<span style="color:#22c55e;display:inline-flex;align-items:center;gap:4px;"><i data-lucide="check" style="width:12px;height:12px;"></i> Dalam radius <b>${escapeHTML(ibadahPreview.nama)}</b> (${ibadahPreview.jarak}m)</span>`
|
||||
: `<span style="color:#ef4444;display:inline-flex;align-items:center;gap:4px;"><i data-lucide="alert-triangle" style="width:12px;height:12px;"></i> Di luar semua radius ibadah (Blank Spot)</span>`;
|
||||
|
||||
tempMarker = L.circleMarker([lat, lng], {
|
||||
radius: 10, color: colorPreview, fillColor: colorPreview, fillOpacity: 0.4, weight: 2
|
||||
}).addTo(map);
|
||||
|
||||
L.popup({ maxWidth: 300, closeOnClick: false })
|
||||
.setLatLng(e.latlng)
|
||||
.setContent(`<div class="form-popup">
|
||||
<div class="form-popup-header">
|
||||
<div class="form-popup-icon" style="background:rgba(34,197,94,.12);"><i data-lucide="home"></i></div>
|
||||
<div>
|
||||
<div class="form-popup-title">Tambah Penduduk Miskin</div>
|
||||
<div class="form-popup-coords">${lat}, ${lng}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:8px 12px;background:rgba(255,255,255,0.04);border-radius:7px;font-size:12px;margin-bottom:14px;color:var(--c-muted);">
|
||||
${statusPreview}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nama Kepala Keluarga *</label>
|
||||
<input type="text" id="f_pddk_nama" placeholder="cth. Budi Santoso" maxlength="150" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>NIK</label>
|
||||
<input type="text" id="f_pddk_nik" placeholder="16 digit NIK" maxlength="16" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kategori</label>
|
||||
<select id="f_pddk_kategori">
|
||||
<option value="Sangat Miskin">Sangat Miskin</option>
|
||||
<option value="Miskin" selected>Miskin</option>
|
||||
<option value="Hampir Miskin">Hampir Miskin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Jumlah Jiwa</label>
|
||||
<input type="number" id="f_pddk_jiwa" value="1" min="1" max="99">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Alamat (Otomatis)</label>
|
||||
<textarea id="f_pddk_alamat" placeholder="Mendeteksi alamat..." rows="2">Mendeteksi alamat...</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Catatan Kondisi Khusus</label>
|
||||
<textarea id="f_pddk_catatan" placeholder="Kebutuhan khusus, kondisi rumah, dll." rows="2"
|
||||
style="width:100%;padding:8px 12px;background:var(--c-bg);border:1px solid var(--c-border);
|
||||
border-radius:7px;color:var(--c-text);font-family:var(--font-body);font-size:13px;
|
||||
outline:none;resize:vertical;"></textarea>
|
||||
</div>
|
||||
<input type="hidden" id="f_pddk_lat" value="${lat}">
|
||||
<input type="hidden" id="f_pddk_lng" value="${lng}">
|
||||
<input type="hidden" id="f_pddk_ibadah_id" value="${ibadahPreview ? ibadahPreview.id : ''}">
|
||||
<button class="btn-save" id="btnSimpanPenduduk" onclick="window._pendudukSimpan()"
|
||||
style="background:linear-gradient(135deg,#16a34a,#22c55e);display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<i data-lucide="save" style="width:14px;height:14px;"></i> Simpan Penduduk
|
||||
</button>
|
||||
<div class="form-status" id="pendudukStatus"></div>
|
||||
</div>`)
|
||||
.openOn(map);
|
||||
|
||||
// Hapus temp marker saat popup dibatalkan/ditutup
|
||||
const _markerThisClick = tempMarker;
|
||||
map.once('popupclose', function () {
|
||||
if (_markerThisClick) { try { map.removeLayer(_markerThisClick); } catch(_){} }
|
||||
if (tempMarker === _markerThisClick) tempMarker = null;
|
||||
});
|
||||
|
||||
// Reverse Geocoding via Nominatim
|
||||
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&accept-language=id`, {
|
||||
headers: { 'Accept-Language': 'id' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(geo => {
|
||||
const alamat = geo.display_name || '';
|
||||
const el = document.getElementById('f_pddk_alamat');
|
||||
if (el) el.value = alamat;
|
||||
})
|
||||
.catch(() => {
|
||||
const el = document.getElementById('f_pddk_alamat');
|
||||
if (el) el.value = '';
|
||||
});
|
||||
|
||||
setTimeout(() => { const el = document.getElementById('f_pddk_nama'); if (el) el.focus(); }, 200);
|
||||
}
|
||||
|
||||
// ── Simpan ────────────────────────────────────────────────────────
|
||||
window._pendudukSimpan = function () {
|
||||
const nama = document.getElementById('f_pddk_nama').value.trim();
|
||||
const nik = document.getElementById('f_pddk_nik').value.trim();
|
||||
const kategori = document.getElementById('f_pddk_kategori').value;
|
||||
const alamat = document.getElementById('f_pddk_alamat').value.trim();
|
||||
const jiwa = parseInt(document.getElementById('f_pddk_jiwa').value) || 1;
|
||||
const lat = document.getElementById('f_pddk_lat').value;
|
||||
const lng = document.getElementById('f_pddk_lng').value;
|
||||
const ibadahId = document.getElementById('f_pddk_ibadah_id').value;
|
||||
const status = document.getElementById('pendudukStatus');
|
||||
const btn = document.getElementById('btnSimpanPenduduk');
|
||||
|
||||
if (!nama) {
|
||||
status.textContent = '⚠ Nama kepala keluarga wajib diisi.';
|
||||
status.className = 'form-status error';
|
||||
document.getElementById('f_pddk_nama').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menyimpan...';
|
||||
lucide.createIcons();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('nama_kk', nama);
|
||||
fd.append('nik', nik);
|
||||
fd.append('kategori', kategori);
|
||||
fd.append('alamat', alamat);
|
||||
fd.append('catatan', document.getElementById('f_pddk_catatan')?.value?.trim() || '');
|
||||
fd.append('jumlah_jiwa', jiwa);
|
||||
fd.append('lat', lat);
|
||||
fd.append('lng', lng);
|
||||
fd.append('ibadah_id', ibadahId);
|
||||
|
||||
fetch('api/penduduk/simpan.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
addToMap(j.data);
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
if (j.data.is_blank_spot) {
|
||||
showToast(`⚠ "${escapeHTML(nama)}" ditambahkan sebagai BLANK SPOT — tidak terjangkau ibadah manapun.`, 'error');
|
||||
} else {
|
||||
showToast(`"${escapeHTML(nama)}" berhasil ditambahkan!`);
|
||||
}
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
status.innerHTML = '<i data-lucide="x" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> ' + escapeHTML(err.message);
|
||||
status.className = 'form-status error';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i data-lucide="save" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i> Simpan Penduduk';
|
||||
lucide.createIcons();
|
||||
});
|
||||
};
|
||||
|
||||
// ── Update status bantuan ─────────────────────────────────────────
|
||||
window._updateStatus = function (id, newStatus, btnEl) {
|
||||
btnEl.disabled = true;
|
||||
const catatan = prompt('Catatan perubahan status (opsional):', '');
|
||||
if (catatan === null) {
|
||||
btnEl.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('status', newStatus);
|
||||
fd.append('catatan', catatan);
|
||||
|
||||
fetch('api/penduduk/update_status.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
const entry = allMarkers[id];
|
||||
if (entry && entry.cm) {
|
||||
entry.data.status_bantuan = newStatus;
|
||||
const ibadah = entry.cm._nearestIbadah;
|
||||
entry.cm.bindPopup(buildInfoPopup(entry.data, ibadah), { maxWidth: 280 });
|
||||
entry.cm.openPopup();
|
||||
}
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
if (typeof window.dispatchDataChanged === 'function') {
|
||||
window.dispatchDataChanged('penduduk', { id, mutation: 'status_bantuan' });
|
||||
}
|
||||
showToast('Status bantuan diperbarui.');
|
||||
})
|
||||
.catch(err => {
|
||||
btnEl.disabled = false;
|
||||
showToast(err.message || 'Gagal memperbarui status.', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
// ── Lihat riwayat bantuan ─────────────────────────────────────────
|
||||
window._showRiwayat = function (id) {
|
||||
fetch('api/penduduk/riwayat.php?id=' + id + '&_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
const rows = j.data;
|
||||
|
||||
const tbody = rows.length === 0
|
||||
? '<tr><td colspan="4" style="text-align:center;padding:12px;color:var(--c-muted);">Belum ada riwayat.</td></tr>'
|
||||
: rows.map(r => {
|
||||
const lama = r.status_lama || '—';
|
||||
const baru = r.status_baru || '—';
|
||||
const tgl = r.created_at ? r.created_at.substring(0, 16) : '—';
|
||||
return `<tr style="border-bottom:1px solid var(--c-border);">
|
||||
<td style="padding:6px 8px;font-size:12px;">${escapeHTML(tgl)}</td>
|
||||
<td style="padding:6px 8px;font-size:12px;color:${STATUS_COLOR[baru]||'#ccc'};font-weight:700;">${escapeHTML(baru)}</td>
|
||||
<td style="padding:6px 8px;font-size:12px;color:var(--c-muted);">${escapeHTML(lama)}</td>
|
||||
<td style="padding:6px 8px;font-size:12px;color:var(--c-muted);">${escapeHTML(r.user_nama||'—')}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
let overlay = document.getElementById('_riwayatOverlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = '_riwayatOverlay';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;';
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
overlay.innerHTML = `
|
||||
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;
|
||||
padding:20px;width:min(480px,94vw);max-height:80vh;overflow-y:auto;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;">
|
||||
<div style="font-weight:700;font-size:14px;color:var(--c-text);">Riwayat Bantuan</div>
|
||||
<button onclick="document.getElementById('_riwayatOverlay').remove()"
|
||||
style="background:none;border:none;color:var(--c-muted);font-size:18px;cursor:pointer;">×</button>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--c-border);">
|
||||
<th style="text-align:left;padding:6px 8px;font-size:11px;color:var(--c-muted);font-weight:600;">Waktu</th>
|
||||
<th style="text-align:left;padding:6px 8px;font-size:11px;color:var(--c-muted);font-weight:600;">Status Baru</th>
|
||||
<th style="text-align:left;padding:6px 8px;font-size:11px;color:var(--c-muted);font-weight:600;">Sebelumnya</th>
|
||||
<th style="text-align:left;padding:6px 8px;font-size:11px;color:var(--c-muted);font-weight:600;">Oleh</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${tbody}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.catch(err => showToast(err.message || 'Gagal memuat riwayat.', 'error'));
|
||||
};
|
||||
|
||||
// ── Blank Spot counter + filter ───────────────────────────────────
|
||||
window._updateBlankSpotCounter = function () {
|
||||
const count = Object.values(allMarkers).filter(m => m.cm && !m.cm._nearestIbadah).length;
|
||||
const btn = document.getElementById('btnBlankSpot');
|
||||
const lbl = document.getElementById('blankSpotCount');
|
||||
if (!btn || !lbl) return;
|
||||
lbl.textContent = count + ' Blank Spot';
|
||||
btn.style.display = count > 0 ? '' : 'none';
|
||||
};
|
||||
|
||||
let _blankSpotFilterActive = false;
|
||||
window._filterBlankSpot = function () {
|
||||
_blankSpotFilterActive = !_blankSpotFilterActive;
|
||||
const btn = document.getElementById('btnBlankSpot');
|
||||
if (btn) btn.style.background = _blankSpotFilterActive ? 'rgba(248,81,73,.15)' : 'transparent';
|
||||
|
||||
Object.values(allMarkers).forEach(m => {
|
||||
if (!m.cm) return; // publik — tidak ada marker
|
||||
const show = !_blankSpotFilterActive || !m.cm._nearestIbadah;
|
||||
if (show) {
|
||||
if (!layerGroup.hasLayer(m.cm)) {
|
||||
m.cm.addTo(layerGroup);
|
||||
m.dragHandle.addTo(layerGroup);
|
||||
}
|
||||
} else {
|
||||
layerGroup.removeLayer(m.cm);
|
||||
layerGroup.removeLayer(m.dragHandle);
|
||||
}
|
||||
});
|
||||
updatePendudukList();
|
||||
refreshList();
|
||||
};
|
||||
|
||||
// ── Reload dari server (dipanggil setelah recalculate massal) ────
|
||||
window._pendudukReload = function () {
|
||||
loadData();
|
||||
};
|
||||
|
||||
// ── FlyTo penduduk marker (dipanggil dari popup ibadah KK list) ───
|
||||
window._pendudukFlyTo = function (id) {
|
||||
const entry = allMarkers[id];
|
||||
if (!entry || !entry.cm) return; // publik tidak punya marker
|
||||
map.closePopup();
|
||||
map.flyTo(entry.cm.getLatLng(), Math.max(map.getZoom(), 17), { animate: true, duration: 0.8 });
|
||||
setTimeout(() => entry.cm.openPopup(), 900);
|
||||
};
|
||||
|
||||
// ── Verifikasi warga (admin only) ────────────────────────────────
|
||||
window._verifikasiPenduduk = function (id, aksi, btnEl) {
|
||||
const entry = allMarkers[id];
|
||||
const label = aksi === 'approve' ? 'Terverifikasi' : 'Ditolak';
|
||||
const nama = entry?.data?.nama_kk ? `"${entry.data.nama_kk}"` : `#${id}`;
|
||||
const title = aksi === 'approve' ? 'Konfirmasi Verifikasi' : 'Konfirmasi Tolak';
|
||||
const msg = `Ubah status verifikasi ${nama} menjadi <strong>${label}</strong>?`;
|
||||
showDeleteConfirm(
|
||||
`Ubah status verifikasi ${entry?.data?.nama_kk || '#'+id} menjadi ${label}?`,
|
||||
{
|
||||
type: 'warn',
|
||||
iconName: aksi === 'approve' ? 'check-circle' : 'x-circle',
|
||||
title,
|
||||
btnLabel: aksi === 'approve' ? 'Ya, Verifikasi' : 'Ya, Tolak'
|
||||
}
|
||||
).then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
if (btnEl) {
|
||||
btnEl.disabled = true;
|
||||
btnEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i>';
|
||||
lucide.createIcons();
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('aksi', aksi);
|
||||
fetch('api/penduduk/verifikasi.php', { method: 'POST', body: appendCsrf(fd) })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
const lbl = aksi === 'approve' ? 'Terverifikasi' : 'Ditolak';
|
||||
showToast(`Data #${id} → ${lbl}`, 'success');
|
||||
if (entry) {
|
||||
entry.data.status_verifikasi = j.status_verifikasi;
|
||||
if (entry.cm) {
|
||||
const ibadah = entry.cm._nearestIbadah;
|
||||
entry.cm.bindPopup(buildInfoPopup(entry.data, ibadah), { maxWidth: 280 });
|
||||
entry.cm.openPopup();
|
||||
}
|
||||
}
|
||||
refreshList();
|
||||
})
|
||||
.catch(err => {
|
||||
showToast(err.message || 'Gagal memperbarui verifikasi.', 'error');
|
||||
if (btnEl) btnEl.disabled = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────
|
||||
window.initPenduduk = function () {
|
||||
if (active) { loadData(); return; }
|
||||
active = true;
|
||||
|
||||
layerGroup.addTo(map);
|
||||
map.on('click', onMapClick);
|
||||
|
||||
window._dlFocusFns['Penduduk'] = function (id) {
|
||||
const entry = allMarkers[id];
|
||||
if (!entry || !entry.cm) return; // publik tidak punya marker
|
||||
map.setView(entry.cm.getLatLng(), Math.max(map.getZoom(), 17), { animate: true });
|
||||
setTimeout(() => entry.cm.openPopup(), 350);
|
||||
};
|
||||
|
||||
loadData();
|
||||
};
|
||||
|
||||
// ── Toggle visibilitas layer ──────────────────────────────────────
|
||||
window._pendudukToggleLayer = function (visible) {
|
||||
if (visible) {
|
||||
if (!map.hasLayer(layerGroup)) map.addLayer(layerGroup);
|
||||
} else {
|
||||
map.removeLayer(layerGroup);
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,210 @@
|
||||
// modules/stats.js — Dashboard statistik role-aware (v1.0)
|
||||
(function () {
|
||||
let charts = { kategori: null };
|
||||
|
||||
window._statsUpdate = function () {
|
||||
const modal = document.getElementById('modalStats');
|
||||
if (!modal) return;
|
||||
if (modal.classList.contains('show')) {
|
||||
loadAndRender();
|
||||
}
|
||||
};
|
||||
|
||||
window._showDashboard = function () {
|
||||
const modal = document.getElementById('modalStats');
|
||||
if (!modal) return;
|
||||
modal.classList.add('show');
|
||||
loadAndRender();
|
||||
};
|
||||
|
||||
window._hideDashboard = function () {
|
||||
const modal = document.getElementById('modalStats');
|
||||
if (!modal) return;
|
||||
modal.classList.remove('show');
|
||||
};
|
||||
|
||||
function loadAndRender() {
|
||||
fetch('api/stats/ambil.php?_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') return;
|
||||
renderCards(j.data);
|
||||
renderKategoriChart(j.data.kategori || []);
|
||||
renderRekapTable(j.data.rekap_ibadah ?? null);
|
||||
renderRekapKebutuhan(j.data.rekap_kebutuhan ?? null);
|
||||
updateDonaturBadge(j.data.donatur_unread ?? 0);
|
||||
})
|
||||
.catch(err => console.error('[Stats]', err));
|
||||
}
|
||||
|
||||
function set(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
function updateDonaturBadge(count) {
|
||||
const badge = document.getElementById('donaturBadge');
|
||||
if (!badge) return;
|
||||
badge.textContent = count > 0 ? count : '';
|
||||
badge.style.display = count > 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
function renderCards(d) {
|
||||
set('statTotalIbadah', d.total_ibadah);
|
||||
set('statTotalKK', d.total_kk);
|
||||
set('statTotalJiwa', d.total_jiwa);
|
||||
set('statJiwaTerjangkau', d.jiwa_terjangkau);
|
||||
set('statJiwaBlankSpot', d.jiwa_blank_spot);
|
||||
set('statPctCakupan', d.pct_cakupan + '%');
|
||||
set('statKebutuhanOpen', d.kk_kebutuhan_open ?? 0);
|
||||
|
||||
const emptyMsg = document.getElementById('statsEmptyMsg');
|
||||
if (emptyMsg) {
|
||||
emptyMsg.style.display = (d.total_kk === 0 && d.total_ibadah === 0) ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderKategoriChart(rows) {
|
||||
const el = document.getElementById('chartKategori');
|
||||
if (!el) return;
|
||||
|
||||
const labels = rows.map(r => r.kategori);
|
||||
const data = rows.map(r => parseInt(r.jml_jiwa));
|
||||
const colors = {
|
||||
'Sangat Miskin': '#f85149',
|
||||
'Miskin' : '#e3b341',
|
||||
'Hampir Miskin' : '#388bfd',
|
||||
};
|
||||
const bgColors = labels.map(l => (colors[l] || '#8b949e') + '99');
|
||||
const bdColors = labels.map(l => colors[l] || '#8b949e');
|
||||
|
||||
if (charts.kategori) charts.kategori.destroy();
|
||||
charts.kategori = new Chart(el.getContext('2d'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{ data, backgroundColor: bgColors, borderColor: bdColors, borderWidth: 2, hoverOffset: 6 }]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: '#8b949e', font: { size: 11 }, padding: 14 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderRekapTable(rows) {
|
||||
const wrapper = document.getElementById('rekapTableWrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
if (rows === null) { wrapper.style.display = 'none'; return; }
|
||||
wrapper.style.display = '';
|
||||
|
||||
if (rows.length === 0) {
|
||||
wrapper.innerHTML = '<p style="color:var(--c-muted);font-size:13px;text-align:center;padding:16px 0;">Belum ada data rumah ibadah.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(s ?? ''));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
const tbody = rows.map(r => {
|
||||
const pct = r.jml_kk > 0 ? Math.round(r.jml_ditangani / r.jml_kk * 100) : 0;
|
||||
return `<tr>
|
||||
<td style="padding:6px 10px">${esc(r.nama)}</td>
|
||||
<td style="padding:6px 10px;color:var(--c-muted)">${esc(r.jenis)}</td>
|
||||
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono)">${r.jml_kk}</td>
|
||||
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono)">${r.jml_jiwa}</td>
|
||||
<td style="padding:6px 10px;text-align:right">
|
||||
<span style="color:${pct>=60?'#3fb950':'#e3b341'};font-weight:700;font-family:var(--font-mono)">${pct}%</span>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div style="font-size:11px;font-weight:700;color:var(--c-muted);text-transform:uppercase;
|
||||
letter-spacing:.6px;margin-bottom:10px;">Rekapitulasi per Rumah Ibadah</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:12px;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--c-border)">
|
||||
<th style="text-align:left;padding:6px 10px;color:var(--c-muted);font-weight:600">Nama</th>
|
||||
<th style="text-align:left;padding:6px 10px;color:var(--c-muted);font-weight:600">Jenis</th>
|
||||
<th style="text-align:right;padding:6px 10px;color:var(--c-muted);font-weight:600">KK</th>
|
||||
<th style="text-align:right;padding:6px 10px;color:var(--c-muted);font-weight:600">Jiwa</th>
|
||||
<th style="text-align:right;padding:6px 10px;color:var(--c-muted);font-weight:600">% Ditangani</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${tbody}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
function renderRekapKebutuhan(rows) {
|
||||
const wrapper = document.getElementById('rekapKebutuhanWrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
if (rows === null) { wrapper.style.display = 'none'; return; }
|
||||
wrapper.style.display = '';
|
||||
|
||||
if (rows.length === 0) {
|
||||
wrapper.innerHTML = '<p style="color:var(--c-muted);font-size:13px;text-align:center;padding:16px 0;">Belum ada kebutuhan tercatat.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(s ?? ''));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
const ICON = {
|
||||
'Sembako': 'shopping-cart',
|
||||
'Biaya Sekolah': 'graduation-cap',
|
||||
'Biaya Kesehatan': 'heart-pulse',
|
||||
'Modal Usaha': 'briefcase',
|
||||
'Renovasi Rumah': 'home',
|
||||
'Perlengkapan Rumah': 'armchair',
|
||||
'Pakaian': 'shirt',
|
||||
'Lainnya': 'clipboard-list'
|
||||
};
|
||||
|
||||
const tbody = rows.map(r => {
|
||||
const iconName = ICON[r.kategori] || 'clipboard-list';
|
||||
const iconHtml = `<i data-lucide="${iconName}" class="lucide-inline" style="width:14px;height:14px;vertical-align:middle;margin-right:6px;"></i>`;
|
||||
return `<tr style="border-bottom:1px solid rgba(255,255,255,.05)">
|
||||
<td style="padding:6px 10px;display:flex;align-items:center;gap:2px;">${iconHtml} ${esc(r.kategori)}</td>
|
||||
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono);color:#ef4444">${r.belum}</td>
|
||||
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono);color:#e3b341">${r.proses}</td>
|
||||
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono);color:#3fb950">${r.terpenuhi}</td>
|
||||
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono);color:var(--c-muted)">${r.total}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div style="font-size:11px;font-weight:700;color:var(--c-muted);text-transform:uppercase;
|
||||
letter-spacing:.6px;margin-bottom:10px;">Rekapitulasi Kebutuhan per Kategori</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:12px;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--c-border)">
|
||||
<th style="text-align:left;padding:6px 10px;color:var(--c-muted);font-weight:600">Kategori</th>
|
||||
<th style="text-align:right;padding:6px 10px;color:#ef4444;font-weight:600">Belum</th>
|
||||
<th style="text-align:right;padding:6px 10px;color:#e3b341;font-weight:600">Proses</th>
|
||||
<th style="text-align:right;padding:6px 10px;color:#3fb950;font-weight:600">Terpenuhi</th>
|
||||
<th style="text-align:right;padding:6px 10px;color:var(--c-muted);font-weight:600">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${tbody}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
require_once '../config.php';
|
||||
require_once '../auth/helper.php';
|
||||
require_once '../koneksi.php';
|
||||
if (!is_logged_in() || !has_role('administrator')) { header('Location: ../auth/login.php'); exit; }
|
||||
require_password_changed('../auth/change_password.php');
|
||||
$page_title = 'Analisis & Blank Spot';
|
||||
$page_subtitle = 'Warga yang tidak terjangkau radius rumah ibadah manapun';
|
||||
$active_nav = 'analisis';
|
||||
include '../includes/page-start.php';
|
||||
?>
|
||||
|
||||
<style>
|
||||
.kat-sm{background:#fef2f2;color:#ef4444;}
|
||||
.kat-m{background:#fffbeb;color:#f59e0b;}
|
||||
.kat-hm{background:#eff6ff;color:#3b82f6;}
|
||||
.text-danger{color:#dc2626;}
|
||||
.text-success{color:#16a34a;}
|
||||
</style>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px;margin-bottom:24px;">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top"><div class="stat-icon amber"><i data-lucide="alert-triangle"></i></div><div class="stat-label">Jiwa Blank Spot</div></div>
|
||||
<div class="stat-value text-danger" id="an-blank"><span class="spinner"></span></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top"><div class="stat-icon blue"><i data-lucide="percent"></i></div><div class="stat-label">% Blank Spot dari Total</div></div>
|
||||
<div class="stat-value" style="font-size:22px;" id="an-pct"><span class="spinner"></span></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top"><div class="stat-icon green"><i data-lucide="check-circle-2"></i></div><div class="stat-label">Jiwa Terjangkau</div></div>
|
||||
<div class="stat-value text-success" id="an-cover"><span class="spinner"></span></div>
|
||||
</div>
|
||||
<div class="stat-card" style="cursor:pointer;transition:box-shadow .2s;" onmouseover="this.style.boxShadow='0 4px 12px rgba(0,0,0,.12)'" onmouseout="this.style.boxShadow=''" onclick="recalcAll()">
|
||||
<div class="stat-card-top"><div class="stat-icon slate"><i data-lucide="refresh-cw"></i></div><div class="stat-label">Hitung Ulang Proximity</div></div>
|
||||
<div class="stat-value" style="font-size:14px;font-family:var(--font);color:var(--text-secondary);" id="an-recalc">Klik untuk jalankan</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="table-card-header">
|
||||
<div>
|
||||
<div class="table-card-title">Daftar Warga Blank Spot</div>
|
||||
<div class="table-card-count" id="an-count"></div>
|
||||
</div>
|
||||
<a href="map.php" class="btn-see-all"><i data-lucide="map" style="width:14px;height:14px;margin-right:4px;"></i> Lihat di Peta</a>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:44px;">No</th>
|
||||
<th>Nama KK</th>
|
||||
<th>Kategori</th>
|
||||
<th style="text-align:right;">Jiwa</th>
|
||||
<th>Alamat</th>
|
||||
<th>Jarak ke Ibadah Terdekat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="anTbody">
|
||||
<tr><td colspan="6" style="text-align:center;padding:28px;color:var(--text-muted);"><span class="spinner"></span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function esc(s){const d=document.createElement('div');d.appendChild(document.createTextNode(s??''));return d.innerHTML;}
|
||||
function fmt(n){return Number(n||0).toLocaleString('id-ID');}
|
||||
|
||||
async function loadAnalisis() {
|
||||
try {
|
||||
const [rs, rp] = await Promise.all([
|
||||
fetch('../api/stats/ambil.php?_='+Date.now()),
|
||||
fetch('../api/penduduk/ambil.php?_='+Date.now())
|
||||
]);
|
||||
const js = await rs.json();
|
||||
const jp = await rp.json();
|
||||
|
||||
if (js.status === 'success') {
|
||||
const d = js.data;
|
||||
document.getElementById('an-blank').textContent = fmt(d.jiwa_blank_spot);
|
||||
document.getElementById('an-cover').textContent = fmt(d.jiwa_terjangkau);
|
||||
const pct = d.total_jiwa > 0
|
||||
? ((d.jiwa_blank_spot / d.total_jiwa) * 100).toFixed(1) + '%'
|
||||
: '0%';
|
||||
document.getElementById('an-pct').textContent = pct;
|
||||
}
|
||||
|
||||
if (jp.status === 'success') {
|
||||
const blanks = jp.data.filter(p => parseInt(p.is_blank_spot) === 1);
|
||||
document.getElementById('an-count').textContent = blanks.length + ' warga blank spot';
|
||||
const katCls = {'Sangat Miskin':'kat-sm','Miskin':'kat-m','Hampir Miskin':'kat-hm'};
|
||||
document.getElementById('anTbody').innerHTML = blanks.length
|
||||
? blanks.map((p, i) => `<tr>
|
||||
<td style="color:var(--text-muted);font-size:12px;">${i + 1}</td>
|
||||
<td style="font-weight:600;">${esc(p.nama_kk||'—')}</td>
|
||||
<td><span style="display:inline-block;padding:2px 9px;border-radius:9999px;font-size:11px;font-weight:500;" class="${esc(katCls[p.kategori]||'')}">${esc(p.kategori||'—')}</span></td>
|
||||
<td style="text-align:right;font-family:var(--font-mono);">${esc(String(p.jumlah_jiwa||0))}</td>
|
||||
<td style="font-size:12px;color:var(--text-muted);">${esc(p.alamat||'—')}</td>
|
||||
<td style="font-size:12px;color:var(--text-muted);">${p.jarak_m ? esc(String(Math.round(p.jarak_m)))+' m (di luar radius)' : '—'}</td>
|
||||
</tr>`).join('')
|
||||
: '<tr><td colspan="6" style="text-align:center;padding:24px;color:var(--text-muted);"><i data-lucide="check" style="color:var(--text-success);margin-right:4px;"></i> Tidak ada blank spot saat ini.</td></tr>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
} catch(e) {
|
||||
document.getElementById('anTbody').innerHTML =
|
||||
'<tr><td colspan="6" style="text-align:center;padding:24px;color:#ef4444;">Gagal memuat data.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function showConfirm({ title, message, confirmLabel = 'Ya, Lanjutkan', cancelLabel = 'Batal', danger = false }) {
|
||||
return new Promise(resolve => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'confirm-overlay';
|
||||
el.innerHTML = `<div class="confirm-box">
|
||||
<div class="confirm-title">${title}</div>
|
||||
<p class="confirm-msg">${message}</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="btn-secondary" id="_sc_cancel">${cancelLabel}</button>
|
||||
<button class="${danger ? 'btn-danger' : 'btn-primary'}" id="_sc_ok">${confirmLabel}</button>
|
||||
</div></div>`;
|
||||
document.body.appendChild(el);
|
||||
const done = val => { el.remove(); resolve(val); };
|
||||
el.querySelector('#_sc_ok').onclick = () => done(true);
|
||||
el.querySelector('#_sc_cancel').onclick = () => done(false);
|
||||
el.addEventListener('click', e => { if (e.target === el) done(false); });
|
||||
});
|
||||
}
|
||||
|
||||
async function recalcAll() {
|
||||
const ok = await showConfirm({
|
||||
title: 'Hitung Ulang Proximity',
|
||||
message: 'Proses ini akan mengubah assignment warga ke rumah ibadah/operator berdasarkan radius terkini. Lanjutkan?',
|
||||
confirmLabel: 'Ya, Hitung Ulang'
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
const lbl = document.getElementById('an-recalc');
|
||||
lbl.innerHTML = '<span class="spinner"></span> Menghitung...';
|
||||
try {
|
||||
const r = await fetch('../api/ibadah/recalculate.php', {method:'POST', body: appendCsrf(new FormData())});
|
||||
const j = await r.json();
|
||||
lbl.innerHTML = j.status === 'success' ? '<i data-lucide="check"></i> Selesai!' : '<i data-lucide="x"></i> Gagal: '+(j.message||'');
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
setTimeout(() => { lbl.textContent = 'Klik untuk jalankan'; loadAnalisis(); }, 2500);
|
||||
} catch(e) {
|
||||
lbl.innerHTML = '<i data-lucide="x"></i> Error koneksi';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
setTimeout(() => { lbl.textContent = 'Klik untuk jalankan'; }, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
loadAnalisis();
|
||||
</script>
|
||||
|
||||
<?php include '../includes/page-end.php'; ?>
|
||||
@@ -0,0 +1,560 @@
|
||||
<?php
|
||||
require_once '../config.php';
|
||||
require_once '../auth/helper.php';
|
||||
require_once '../koneksi.php';
|
||||
if (!is_logged_in() || !has_role('administrator')) { header('Location: ../auth/login.php'); exit; }
|
||||
require_password_changed('../auth/change_password.php');
|
||||
$page_title = 'Rumah Ibadah';
|
||||
$page_subtitle = 'Kelola data rumah ibadah dan radius pelayanan';
|
||||
$active_nav = 'ibadah';
|
||||
include '../includes/page-start.php';
|
||||
?>
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
||||
|
||||
<style>
|
||||
.ib-filter-bar { display:flex; flex-direction:column; gap:8px; margin-bottom:16px; }
|
||||
.ib-search { width:100%; box-sizing:border-box; padding:8px 12px; border:1px solid var(--card-border); border-radius:8px; font-family:var(--font); font-size:13px; background:#fafaf9; color:var(--text-primary); outline:none; }
|
||||
.ib-search:focus { border-color:#0d7490; }
|
||||
.ib-filter-chips { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.ib-chip { width:auto; height:auto; padding:7px 11px; border:1px solid var(--card-border); border-radius:8px; font-family:var(--font); font-size:13px; background:#fafaf9; color:var(--text-primary); cursor:pointer; white-space:nowrap; }
|
||||
.ib-chip:focus { outline:none; border-color:#0d7490; }
|
||||
.modal-dark { position:fixed; inset:0; background:rgba(32,21,21,.55); backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center; padding:20px; z-index:9999; opacity:0; pointer-events:none; transition:opacity .2s; }
|
||||
.modal-dark.show { opacity:1; pointer-events:auto; }
|
||||
.modal-box { width:min(600px,100%); background:#fafaf9; border:1px solid var(--card-border); border-radius:12px; padding:28px; box-shadow:0 4px 16px rgba(32,21,21,.1); max-height:90vh; overflow-y:auto; }
|
||||
.modal-box h3 { font-size:16px; font-weight:600; margin-bottom:20px; color:var(--text-primary); }
|
||||
.mfg { margin-bottom:14px; }
|
||||
.mfg label { display:block; font-size:11px; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:.5px; margin-bottom:5px; }
|
||||
.mfg input, .mfg select { width:100%; padding:9px 12px; border:1px solid var(--card-border); border-radius:8px; font-family:var(--font); font-size:13px; color:var(--text-primary); background:#fafaf9; outline:none; transition:border-color .2s; }
|
||||
.mfg input:focus, .mfg select:focus { border-color:#0d7490; }
|
||||
.mfg small { font-size:11px; color:var(--text-muted); margin-top:4px; display:block; }
|
||||
.modal-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:20px; }
|
||||
.btn-cancel { padding:9px 16px; background:transparent; border:1px solid var(--card-border); border-radius:8px; font-family:var(--font); font-size:13px; font-weight:600; color:var(--text-secondary); cursor:pointer; transition:background .15s; }
|
||||
.btn-cancel:hover { background:#ede8e2; }
|
||||
.btn-submit { padding:9px 18px; background:#0d7490; border:none; border-radius:8px; font-family:var(--font); font-size:14px; font-weight:600; color:#fff; cursor:pointer; transition:background .15s; }
|
||||
.btn-submit:hover { background:#0a5f7a; }
|
||||
.btn-submit:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.btn-danger-submit { padding:9px 18px; background:#ef4444; border:none; border-radius:8px; font-family:var(--font); font-size:13px; font-weight:600; color:#fff; cursor:pointer; transition:background .15s; }
|
||||
.btn-danger-submit:hover { background:#dc2626; }
|
||||
.msg-err { font-size:12px; color:#ef4444; margin-top:8px; min-height:16px; }
|
||||
|
||||
/* ── Map picker ── */
|
||||
.map-picker-wrap { border:1px solid var(--card-border); border-radius:10px; overflow:hidden; position:relative; }
|
||||
.map-picker-wrap #ibMapPicker { height:260px; cursor:crosshair; }
|
||||
.map-picker-hint { display:flex; align-items:center; gap:6px; font-size:11px; color:var(--text-muted); margin-top:6px; }
|
||||
.map-geocoding { font-size:11px; color:var(--text-muted); margin-top:5px; min-height:16px; font-style:italic; }
|
||||
/* Override Leaflet z-index agar tidak keluar modal */
|
||||
#ibMapPicker .leaflet-control-zoom { display:block; }
|
||||
</style>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="ib-filter-bar">
|
||||
<input type="text" class="ib-search" id="ibSearch" placeholder="Cari nama rumah ibadah..." oninput="filterTable()">
|
||||
<div class="ib-filter-chips">
|
||||
<select class="ib-chip" id="ibFilterJenis" onchange="filterTable()">
|
||||
<option value="">Semua Jenis</option>
|
||||
<option>Masjid</option><option>Mushola</option><option>Gereja</option>
|
||||
<option>Pura</option><option>Vihara</option><option>Klenteng</option>
|
||||
</select>
|
||||
<div style="width:1px;height:20px;background:var(--card-border);margin:0 2px;align-self:center;flex-shrink:0;"></div>
|
||||
<select class="ib-chip" id="ibSort" onchange="filterTable()">
|
||||
<option value="">Urutkan</option>
|
||||
<option value="nama_asc">Nama A→Z</option>
|
||||
<option value="nama_desc">Nama Z→A</option>
|
||||
<option value="radius_desc">Radius Terbesar</option>
|
||||
<option value="radius_asc">Radius Terkecil</option>
|
||||
<option value="kk_desc">KK Binaan Terbanyak</option>
|
||||
<option value="kk_asc">KK Binaan Tersedikit</option>
|
||||
<option value="jiwa_desc">Jiwa Terbanyak</option>
|
||||
<option value="jiwa_asc">Jiwa Tersedikit</option>
|
||||
</select>
|
||||
<div style="flex:1;"></div>
|
||||
<a href="map.php" class="btn-see-all" style="text-decoration:none;padding:6px 12px;border-radius:8px;font-size:12px;font-weight:600;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;"><i data-lucide="map"></i> Lihat di Peta</a>
|
||||
<button class="btn-submit" onclick="openAdd()" style="white-space:nowrap;">+ Tambah Ibadah</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-card">
|
||||
<div class="table-card-header">
|
||||
<div>
|
||||
<div class="table-card-title">Daftar Rumah Ibadah</div>
|
||||
<div class="table-card-count" id="ibCount"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:44px;">No</th>
|
||||
<th>Nama</th>
|
||||
<th>Jenis</th>
|
||||
<th style="text-align:right;">Radius (m)</th>
|
||||
<th style="text-align:right;">KK Binaan</th>
|
||||
<th style="text-align:right;">Jiwa</th>
|
||||
<th>Kontak</th>
|
||||
<th style="width:100px;text-align:center;">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ibTbody">
|
||||
<tr><td colspan="8" style="text-align:center;padding:28px;color:var(--text-muted);"><span class="spinner"></span> Memuat...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Add/Edit -->
|
||||
<div class="modal-dark" id="ibModal">
|
||||
<div class="modal-box">
|
||||
<h3 id="ibModalTitle">Tambah Rumah Ibadah</h3>
|
||||
<input type="hidden" id="ibEditId">
|
||||
<div class="mfg">
|
||||
<label>Nama Rumah Ibadah</label>
|
||||
<input type="text" id="ibNama" placeholder="Masjid Al-Ikhlas">
|
||||
</div>
|
||||
<div class="mfg">
|
||||
<label>Jenis</label>
|
||||
<select id="ibJenis">
|
||||
<option>Masjid</option><option>Mushola</option><option>Gereja</option>
|
||||
<option>Pura</option><option>Vihara</option><option>Klenteng</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mfg">
|
||||
<label>Alamat</label>
|
||||
<input type="text" id="ibAlamat" placeholder="Jl. Contoh No. 1">
|
||||
</div>
|
||||
<div class="mfg">
|
||||
<label>Kontak Pengurus</label>
|
||||
<input type="text" id="ibKontak" placeholder="08xx-xxxx-xxxx">
|
||||
</div>
|
||||
<!-- Map Picker -->
|
||||
<div class="mfg">
|
||||
<label>Lokasi di Peta <span style="font-weight:400;color:var(--text-muted);text-transform:none;letter-spacing:0;">— klik untuk set titik, drag marker untuk geser</span></label>
|
||||
<div class="map-picker-wrap">
|
||||
<div id="ibMapPicker"></div>
|
||||
</div>
|
||||
<div class="map-picker-hint" style="display:flex;align-items:center;gap:12px;">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;"><i data-lucide="mouse-pointer"></i> Klik peta</span>
|
||||
<span style="color:var(--card-border);">·</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;"><i data-lucide="map-pin"></i> Drag marker</span>
|
||||
<span style="color:var(--card-border);">·</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;"><i data-lucide="zoom-in"></i> Scroll untuk zoom</span>
|
||||
</div>
|
||||
<div class="map-geocoding" id="ibGeocodingStatus"></div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||
<div class="mfg">
|
||||
<label>Latitude</label>
|
||||
<input type="number" id="ibLat" step="any" placeholder="-0.0557"
|
||||
oninput="onCoordsManualChange()">
|
||||
</div>
|
||||
<div class="mfg">
|
||||
<label>Longitude</label>
|
||||
<input type="number" id="ibLng" step="any" placeholder="109.3487"
|
||||
oninput="onCoordsManualChange()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mfg">
|
||||
<label>Radius Pelayanan</label>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<input type="range" id="ibRadius" value="500" min="100" max="5000" step="50"
|
||||
oninput="onRadiusChange(this.value)"
|
||||
style="flex:1;height:4px;accent-color:#111111;cursor:pointer;">
|
||||
<span id="ibRadiusVal"
|
||||
style="min-width:56px;text-align:right;font-family:var(--font);
|
||||
font-size:14px;font-weight:600;color:#374151;">500m</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--text-muted);margin-top:3px;">
|
||||
<span>100m</span><span>500m</span><span>1km</span><span>2.5km</span><span>5km</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-err" id="ibMsg"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" onclick="closeIbModal()">Batal</button>
|
||||
<button class="btn-submit" id="ibSaveBtn" onclick="saveIbadah()">Simpan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Konfirmasi Hapus (2-step) -->
|
||||
<div class="modal-dark" id="ibHapusModal">
|
||||
<div class="modal-box" style="max-width:440px;">
|
||||
<h3 id="ibHapusTitle">Konfirmasi Hapus</h3>
|
||||
<div id="ibHapusWarning" style="font-size:13px;color:var(--text-secondary);line-height:1.6;margin-bottom:16px;"></div>
|
||||
<input type="hidden" id="ibHapusId">
|
||||
<input type="hidden" id="ibHapusConfirmed" value="0">
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" onclick="closeHapusModal()">Batal</button>
|
||||
<button class="btn-danger-submit" id="ibHapusBtn" onclick="doHapus()">Ya, Hapus</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _ibAll = [];
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(s ?? ''));
|
||||
return d.innerHTML;
|
||||
}
|
||||
function attr(s) {
|
||||
return String(s ?? '').replace(/&/g, '&').replace(/"/g, '"')
|
||||
.replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
async function loadIbadah() {
|
||||
try {
|
||||
const r = await fetch('../api/ibadah/ambil.php?_=' + Date.now());
|
||||
const j = await r.json();
|
||||
if (j.status !== 'success') return;
|
||||
_ibAll = j.data;
|
||||
document.getElementById('ibCount').textContent = j.total + ' rumah ibadah';
|
||||
filterTable();
|
||||
} catch(e) {
|
||||
document.getElementById('ibTbody').innerHTML =
|
||||
'<tr><td colspan="8" style="text-align:center;padding:24px;color:#ef4444;">Gagal memuat data.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function filterTable() {
|
||||
const q = document.getElementById('ibSearch').value.toLowerCase();
|
||||
const jenis = document.getElementById('ibFilterJenis').value;
|
||||
const sort = document.getElementById('ibSort').value;
|
||||
const rows = _ibAll.filter(r =>
|
||||
(!q || r.nama.toLowerCase().includes(q)) &&
|
||||
(!jenis || r.jenis === jenis)
|
||||
);
|
||||
if (sort) {
|
||||
rows.sort((a, b) => {
|
||||
switch (sort) {
|
||||
case 'nama_asc': return (a.nama||'').localeCompare(b.nama||'', 'id');
|
||||
case 'nama_desc': return (b.nama||'').localeCompare(a.nama||'', 'id');
|
||||
case 'radius_desc': return (b.radius_m||0) - (a.radius_m||0);
|
||||
case 'radius_asc': return (a.radius_m||0) - (b.radius_m||0);
|
||||
case 'kk_desc': return (b.total_kk||0) - (a.total_kk||0);
|
||||
case 'kk_asc': return (a.total_kk||0) - (b.total_kk||0);
|
||||
case 'jiwa_desc': return (b.total_jiwa||0) - (a.total_jiwa||0);
|
||||
case 'jiwa_asc': return (a.total_jiwa||0) - (b.total_jiwa||0);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
const jenisMap = { Masjid:'masjid', Mushola:'mushola', Gereja:'gereja', Pura:'pura', Vihara:'vihara', Klenteng:'klenteng' };
|
||||
const tbody = document.getElementById('ibTbody');
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:24px;color:var(--text-muted);">Tidak ada data.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = rows.map((r, i) => `<tr>
|
||||
<td style="color:var(--text-muted);font-size:12px;">${i + 1}</td>
|
||||
<td style="font-weight:600;">${esc(r.nama)}</td>
|
||||
<td><span class="jenis-badge ${jenisMap[r.jenis] || ''}">${esc(r.jenis)}</span></td>
|
||||
<td style="text-align:right;font-family:var(--font-mono);">${esc(String(r.radius_m))}</td>
|
||||
<td style="text-align:right;font-family:var(--font-mono);">${esc(String(r.total_kk || 0))}</td>
|
||||
<td style="text-align:right;font-family:var(--font-mono);">${esc(String(r.total_jiwa || 0))}</td>
|
||||
<td style="color:var(--text-muted);font-size:12px;">${esc(r.kontak || '—')}</td>
|
||||
<td style="text-align:center;display:flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<button class="tbl-action-btn" title="Edit" data-id="${attr(parseInt(r.id, 10) || 0)}" onclick="openEditById(this.dataset.id)"><i data-lucide="pencil"></i></button>
|
||||
<button class="tbl-action-btn" title="Hapus" style="color:#ef4444;" data-id="${attr(parseInt(r.id, 10) || 0)}" onclick="openHapus(this.dataset.id)"><i data-lucide="trash-2"></i></button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
document.getElementById('ibEditId').value = '';
|
||||
document.getElementById('ibModalTitle').textContent = 'Tambah Rumah Ibadah';
|
||||
['ibNama', 'ibAlamat', 'ibKontak', 'ibLat', 'ibLng'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
el.value = '';
|
||||
delete el.dataset.userEdited;
|
||||
});
|
||||
document.getElementById('ibRadius').value = 500;
|
||||
document.getElementById('ibRadiusVal').textContent = '500m';
|
||||
document.getElementById('ibJenis').value = 'Masjid';
|
||||
document.getElementById('ibMsg').textContent = '';
|
||||
document.getElementById('ibGeocodingStatus').textContent = '';
|
||||
document.getElementById('ibSaveBtn').textContent = 'Simpan';
|
||||
document.getElementById('ibModal').classList.add('show');
|
||||
// Init map tanpa koordinat awal — center ke default wilayah
|
||||
initMapPicker(null, null);
|
||||
}
|
||||
|
||||
function openEdit(r) {
|
||||
document.getElementById('ibEditId').value = r.id;
|
||||
document.getElementById('ibModalTitle').textContent = 'Edit Rumah Ibadah';
|
||||
document.getElementById('ibNama').value = r.nama;
|
||||
document.getElementById('ibJenis').value = r.jenis;
|
||||
document.getElementById('ibKontak').value = r.kontak || '';
|
||||
document.getElementById('ibLat').value = r.lat;
|
||||
document.getElementById('ibLng').value = r.lng;
|
||||
const rv = parseInt(r.radius_m) || 500;
|
||||
document.getElementById('ibRadius').value = rv;
|
||||
document.getElementById('ibRadiusVal').textContent = rv >= 1000
|
||||
? (rv / 1000).toFixed(rv % 1000 === 0 ? 0 : 1) + 'km' : rv + 'm';
|
||||
document.getElementById('ibMsg').textContent = '';
|
||||
document.getElementById('ibGeocodingStatus').textContent = '';
|
||||
document.getElementById('ibSaveBtn').textContent = 'Simpan';
|
||||
|
||||
// Set alamat dan tandai sudah diedit (jangan di-overwrite geocoding)
|
||||
const alamatEl = document.getElementById('ibAlamat');
|
||||
alamatEl.value = r.alamat || '';
|
||||
alamatEl.dataset.userEdited = r.alamat ? '1' : '';
|
||||
|
||||
document.getElementById('ibModal').classList.add('show');
|
||||
// Init map pada koordinat yang sudah ada
|
||||
initMapPicker(parseFloat(r.lat), parseFloat(r.lng));
|
||||
}
|
||||
|
||||
function openEditById(id) {
|
||||
const row = _ibAll.find(r => String(r.id) === String(id));
|
||||
if (row) openEdit(row);
|
||||
}
|
||||
|
||||
function closeIbModal() {
|
||||
document.getElementById('ibModal').classList.remove('show');
|
||||
clearTimeout(_geocodeTimer);
|
||||
if (_ibMap) { _ibMap.remove(); _ibMap = null; _ibMarker = null; _ibCircle = null; }
|
||||
}
|
||||
|
||||
async function saveIbadah() {
|
||||
const id = document.getElementById('ibEditId').value;
|
||||
const btn = document.getElementById('ibSaveBtn');
|
||||
const msg = document.getElementById('ibMsg');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Menyimpan...';
|
||||
msg.textContent = '';
|
||||
|
||||
const namaVal = document.getElementById('ibNama').value.trim();
|
||||
const latVal = document.getElementById('ibLat').value.trim();
|
||||
const lngVal = document.getElementById('ibLng').value.trim();
|
||||
if (!namaVal) {
|
||||
msg.textContent = 'Nama wajib diisi.';
|
||||
btn.disabled = false; btn.textContent = 'Simpan'; return;
|
||||
}
|
||||
if (!latVal || !lngVal) {
|
||||
msg.textContent = 'Koordinat latitude dan longitude wajib diisi.';
|
||||
btn.disabled = false; btn.textContent = 'Simpan'; return;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
if (id) fd.append('id', id);
|
||||
fd.append('nama', document.getElementById('ibNama').value.trim());
|
||||
fd.append('jenis', document.getElementById('ibJenis').value);
|
||||
fd.append('alamat', document.getElementById('ibAlamat').value.trim());
|
||||
fd.append('kontak', document.getElementById('ibKontak').value.trim());
|
||||
fd.append('lat', document.getElementById('ibLat').value);
|
||||
fd.append('lng', document.getElementById('ibLng').value);
|
||||
fd.append('radius_m', document.getElementById('ibRadius').value);
|
||||
|
||||
const url = id ? '../api/ibadah/update.php' : '../api/ibadah/simpan.php';
|
||||
try {
|
||||
const r = await fetch(url, { method: 'POST', body: appendCsrf(fd) });
|
||||
const j = await r.json();
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Simpan';
|
||||
if (j.status === 'success') { closeIbModal(); loadIbadah(); }
|
||||
else { msg.textContent = j.message || 'Terjadi kesalahan.'; }
|
||||
} catch(e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Simpan';
|
||||
msg.textContent = 'Gagal terhubung ke server.';
|
||||
}
|
||||
}
|
||||
|
||||
function openHapus(id) {
|
||||
document.getElementById('ibHapusId').value = id;
|
||||
document.getElementById('ibHapusConfirmed').value = '0';
|
||||
document.getElementById('ibHapusTitle').textContent = 'Konfirmasi Hapus';
|
||||
document.getElementById('ibHapusWarning').textContent =
|
||||
'Yakin ingin menghapus rumah ibadah ini? Semua data proximity akan dihitung ulang.';
|
||||
document.getElementById('ibHapusBtn').textContent = 'Ya, Hapus';
|
||||
document.getElementById('ibHapusModal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeHapusModal() {
|
||||
document.getElementById('ibHapusModal').classList.remove('show');
|
||||
}
|
||||
|
||||
async function doHapus() {
|
||||
const id = document.getElementById('ibHapusId').value;
|
||||
const confirmed = document.getElementById('ibHapusConfirmed').value;
|
||||
const btn = document.getElementById('ibHapusBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Menghapus...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
if (confirmed === '1') fd.append('confirmed', '1');
|
||||
|
||||
try {
|
||||
const r = await fetch('../api/ibadah/hapus.php', { method: 'POST', body: appendCsrf(fd) });
|
||||
const j = await r.json();
|
||||
btn.disabled = false;
|
||||
|
||||
if (j.status === 'warning') {
|
||||
const opNames = (j.operators || []).map(o => esc(o.nama_lengkap)).join(', ');
|
||||
document.getElementById('ibHapusTitle').innerHTML = '<i data-lucide="alert-triangle" style="color:#f59e0b;vertical-align:middle;"></i> Peringatan';
|
||||
document.getElementById('ibHapusWarning').innerHTML =
|
||||
`Rumah ibadah ini terhubung dengan Operator: <strong>${opNames}</strong>.<br>
|
||||
Operator tidak bisa login sampai dikaitkan ke ibadah lain.<br><br>Tetap hapus?`;
|
||||
document.getElementById('ibHapusConfirmed').value = '1';
|
||||
document.getElementById('ibHapusBtn').textContent = 'Ya, Tetap Hapus';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
} else if (j.status === 'success') {
|
||||
closeHapusModal();
|
||||
loadIbadah();
|
||||
} else {
|
||||
document.getElementById('ibHapusWarning').innerHTML =
|
||||
`<span style="color:#ef4444;">${esc(j.message || 'Terjadi kesalahan.')}</span>`;
|
||||
btn.textContent = 'Ya, Hapus';
|
||||
}
|
||||
} catch(e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Ya, Hapus';
|
||||
document.getElementById('ibHapusWarning').innerHTML =
|
||||
'<span style="color:#ef4444;">Gagal terhubung ke server.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Map Picker ──────────────────────────────────────────────
|
||||
const DEFAULT_LAT = <?= CENTER_LAT ?>;
|
||||
const DEFAULT_LNG = <?= CENTER_LNG ?>;
|
||||
const DEFAULT_ZOOM = <?= ZOOM_LEVEL ?>;
|
||||
|
||||
let _ibMap = null;
|
||||
let _ibMarker = null;
|
||||
let _ibCircle = null;
|
||||
let _geocodeTimer = null;
|
||||
|
||||
function initMapPicker(lat, lng) {
|
||||
const centerLat = lat || DEFAULT_LAT;
|
||||
const centerLng = lng || DEFAULT_LNG;
|
||||
const zoom = lat ? 17 : DEFAULT_ZOOM;
|
||||
|
||||
if (_ibMap) {
|
||||
_ibMap.remove();
|
||||
_ibMap = null;
|
||||
_ibMarker = null;
|
||||
}
|
||||
|
||||
_ibMap = L.map('ibMapPicker', { zoomControl: true }).setView([centerLat, centerLng], zoom);
|
||||
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(_ibMap);
|
||||
|
||||
// Marker awal jika ada koordinat
|
||||
if (lat && lng) {
|
||||
placeMarker(lat, lng);
|
||||
}
|
||||
|
||||
// Klik peta → set lokasi
|
||||
_ibMap.on('click', function (e) {
|
||||
placeMarker(e.latlng.lat, e.latlng.lng);
|
||||
fillCoords(e.latlng.lat, e.latlng.lng);
|
||||
scheduleGeocode(e.latlng.lat, e.latlng.lng);
|
||||
});
|
||||
|
||||
// Fix ukuran setelah modal terlihat
|
||||
setTimeout(() => _ibMap && _ibMap.invalidateSize(), 80);
|
||||
}
|
||||
|
||||
function placeMarker(lat, lng) {
|
||||
if (_ibMarker) {
|
||||
_ibMarker.setLatLng([lat, lng]);
|
||||
} else {
|
||||
_ibMarker = L.marker([lat, lng], { draggable: true }).addTo(_ibMap);
|
||||
_ibMarker.on('dragend', function (e) {
|
||||
const ll = e.target.getLatLng();
|
||||
fillCoords(ll.lat, ll.lng);
|
||||
updateCircle(ll.lat, ll.lng);
|
||||
scheduleGeocode(ll.lat, ll.lng);
|
||||
});
|
||||
}
|
||||
updateCircle(lat, lng);
|
||||
_ibMap.panTo([lat, lng]);
|
||||
}
|
||||
|
||||
function updateCircle(lat, lng) {
|
||||
const r = parseInt(document.getElementById('ibRadius').value) || 500;
|
||||
if (_ibCircle) {
|
||||
_ibCircle.setLatLng([lat, lng]).setRadius(r);
|
||||
} else if (_ibMap) {
|
||||
_ibCircle = L.circle([lat, lng], {
|
||||
radius: r, color: '#111111', weight: 1.5,
|
||||
fillColor: '#111111', fillOpacity: 0.05, opacity: 0.6
|
||||
}).addTo(_ibMap);
|
||||
}
|
||||
}
|
||||
|
||||
function onRadiusChange(val) {
|
||||
document.getElementById('ibRadiusVal').textContent = val >= 1000
|
||||
? (val / 1000).toFixed(val % 1000 === 0 ? 0 : 1) + 'km'
|
||||
: val + 'm';
|
||||
if (_ibCircle) _ibCircle.setRadius(parseInt(val));
|
||||
}
|
||||
|
||||
function fillCoords(lat, lng) {
|
||||
document.getElementById('ibLat').value = lat.toFixed(8);
|
||||
document.getElementById('ibLng').value = lng.toFixed(8);
|
||||
}
|
||||
|
||||
// Ketik manual di field lat/lng → pindahkan marker
|
||||
function onCoordsManualChange() {
|
||||
const lat = parseFloat(document.getElementById('ibLat').value);
|
||||
const lng = parseFloat(document.getElementById('ibLng').value);
|
||||
if (!isNaN(lat) && !isNaN(lng) && _ibMap) {
|
||||
placeMarker(lat, lng);
|
||||
scheduleGeocode(lat, lng);
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce reverse geocoding (delay 600ms setelah berhenti drag/klik)
|
||||
function scheduleGeocode(lat, lng) {
|
||||
clearTimeout(_geocodeTimer);
|
||||
const status = document.getElementById('ibGeocodingStatus');
|
||||
status.innerHTML = '<span class="spinner"></span> Mencari alamat...';
|
||||
_geocodeTimer = setTimeout(() => reverseGeocode(lat, lng), 600);
|
||||
}
|
||||
|
||||
async function reverseGeocode(lat, lng) {
|
||||
const status = document.getElementById('ibGeocodingStatus');
|
||||
const alamatEl = document.getElementById('ibAlamat');
|
||||
try {
|
||||
const r = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`,
|
||||
{ headers: { 'Accept-Language': 'id' } }
|
||||
);
|
||||
const j = await r.json();
|
||||
if (j && j.display_name) {
|
||||
// Hanya isi alamat jika masih kosong atau user belum mengetik manual
|
||||
if (!alamatEl.dataset.userEdited) {
|
||||
alamatEl.value = j.display_name;
|
||||
}
|
||||
status.innerHTML = '<i data-lucide="check" style="color:var(--text-success);margin-right:4px;"></i> ' + (j.address?.road || j.address?.suburb || j.display_name.split(',')[0]);
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
} else {
|
||||
status.textContent = '';
|
||||
}
|
||||
} catch {
|
||||
status.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Tandai alamat sudah diedit manual → jangan di-overwrite geocoding
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.getElementById('ibAlamat').addEventListener('input', function () {
|
||||
this.dataset.userEdited = '1';
|
||||
});
|
||||
});
|
||||
|
||||
loadIbadah();
|
||||
</script>
|
||||
|
||||
<?php include '../includes/page-end.php'; ?>
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
require_once '../config.php';
|
||||
require_once '../auth/helper.php';
|
||||
require_once '../koneksi.php';
|
||||
if (!is_logged_in() || !has_role('administrator')) { header('Location: ../auth/login.php'); exit; }
|
||||
require_password_changed('../auth/change_password.php');
|
||||
$page_title = 'Import CSV';
|
||||
$page_subtitle = 'Upload data penduduk miskin dari file CSV';
|
||||
$active_nav = 'import';
|
||||
include '../includes/page-start.php';
|
||||
?>
|
||||
|
||||
<div style="max-width:700px;">
|
||||
<div class="dash-card">
|
||||
<div class="dash-card-title">Import Data Penduduk via CSV</div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-bottom:20px;align-items:center;flex-wrap:wrap;">
|
||||
<a href="../api/import/template.php" class="btn-primary" style="text-decoration:none;padding:9px 16px;font-size:13px;display:inline-flex;align-items:center;gap:6px;"><i data-lucide="download"></i> Download Template CSV</a>
|
||||
<span style="font-size:12px;color:var(--text-muted);">Maks. 500 baris per upload</span>
|
||||
<span style="font-size:12px;color:var(--text-muted);">Data valid otomatis masuk sebagai Terverifikasi.</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<label style="display:block;font-size:11px;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;">Pilih File CSV</label>
|
||||
<input type="file" id="importFile" accept=".csv" style="font-size:13px;color:var(--text-primary);" onchange="previewCSV()">
|
||||
</div>
|
||||
|
||||
<div id="importPreviewArea" style="display:none;">
|
||||
<div id="importSummary" style="margin-bottom:12px;font-size:13px;"></div>
|
||||
<div id="importPreviewTable" style="overflow-x:auto;margin-bottom:12px;"></div>
|
||||
<div id="importErrorList" style="display:none;margin-bottom:12px;"></div>
|
||||
<button id="btnDoImport" class="btn-primary" style="font-size:14px;display:inline-flex;align-items:center;gap:6px;" onclick="doImport()"><i data-lucide="check"></i> Import Sekarang</button>
|
||||
</div>
|
||||
<div id="importResultMsg" style="font-size:13px;margin-top:12px;min-height:16px;font-family:var(--font);"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function esc(s){const d=document.createElement('div');d.appendChild(document.createTextNode(String(s??'')));return d.innerHTML;}
|
||||
|
||||
async function previewCSV() {
|
||||
const file = document.getElementById('importFile').files[0];
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append('csv_file', file);
|
||||
fd.append('mode', 'preview');
|
||||
document.getElementById('importPreviewArea').style.display = 'none';
|
||||
const msg = document.getElementById('importResultMsg');
|
||||
msg.style.color = 'var(--text-muted)';
|
||||
msg.innerHTML = '<span class="spinner"></span> Menganalisis...';
|
||||
try {
|
||||
const res = await fetch('../api/import/csv.php', {method:'POST', body: appendCsrf(fd)});
|
||||
const j = await res.json();
|
||||
msg.textContent = '';
|
||||
if (j.status !== 'success') {
|
||||
msg.style.color = '#ef4444';
|
||||
msg.innerHTML = '<i data-lucide="x-circle" style="color:#ef4444;vertical-align:middle;margin-right:4px;"></i> ' + esc(j.message || 'Error tidak diketahui.');
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
document.getElementById('importSummary').innerHTML =
|
||||
`<span style="color:#16a34a;font-weight:600;">${esc(String(j.total_ok))} baris valid</span>` +
|
||||
(j.total_error > 0
|
||||
? ` · <span style="color:#ef4444;font-weight:600;">${esc(String(j.total_error))} baris error</span>`
|
||||
: '');
|
||||
if (j.preview && j.preview.length) {
|
||||
const cols = ['nama_kk','nik','jumlah_jiwa','kategori','lat','lng'];
|
||||
document.getElementById('importPreviewTable').innerHTML =
|
||||
`<table style="width:100%;border-collapse:collapse;background:#fafaf9;border:1px solid var(--card-border);border-radius:8px;overflow:hidden;font-size:12px;">
|
||||
<thead><tr>${cols.map(c=>`<th style="padding:6px 10px;font-size:10px;font-weight:600;color:var(--text-secondary);text-transform:uppercase;background:#ede8e2;text-align:left;">${esc(c)}</th>`).join('')}</tr></thead>
|
||||
<tbody>${j.preview.map(row=>'<tr>'+cols.map(c=>`<td style="padding:5px 10px;border-top:1px solid var(--card-border);">${esc(String(row[c]??''))}</td>`).join('')+'</tr>').join('')}</tbody>
|
||||
</table>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">Menampilkan ${esc(String(j.preview.length))} dari ${esc(String(j.total_ok))} baris valid</div>`;
|
||||
} else {
|
||||
document.getElementById('importPreviewTable').innerHTML =
|
||||
'<div style="font-size:12px;color:var(--text-muted);padding:10px 0;">Tidak ada baris valid untuk ditampilkan.</div>';
|
||||
}
|
||||
if (j.errors && j.errors.length) {
|
||||
document.getElementById('importErrorList').innerHTML =
|
||||
`<div style="background:#fee2e2;border:1px solid #fca5a5;border-radius:8px;padding:10px 14px;">
|
||||
${j.errors.slice(0,10).map(e=>`<div style="font-size:11px;color:#ef4444;padding:2px 0;">Baris ${esc(String(e.row))}: ${esc(e.error)}</div>`).join('')}
|
||||
${j.errors.length>10?`<div style="font-size:11px;color:var(--text-muted);">...dan ${esc(String(j.errors.length-10))} error lainnya</div>`:''}
|
||||
</div>`;
|
||||
document.getElementById('importErrorList').style.display = '';
|
||||
} else {
|
||||
document.getElementById('importErrorList').style.display = 'none';
|
||||
}
|
||||
document.getElementById('importPreviewArea').style.display = '';
|
||||
document.getElementById('btnDoImport').disabled = (j.total_ok === 0);
|
||||
} catch(e) {
|
||||
msg.style.color = '#ef4444';
|
||||
msg.innerHTML = '<i data-lucide="x-circle" style="color:#ef4444;vertical-align:middle;margin-right:4px;"></i> Gagal menghubungi server.';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
const file = document.getElementById('importFile').files[0];
|
||||
if (!file) return;
|
||||
const btn = document.getElementById('btnDoImport');
|
||||
const msg = document.getElementById('importResultMsg');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Mengimport...';
|
||||
msg.textContent = '';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('csv_file', file);
|
||||
fd.append('mode', 'import');
|
||||
const res = await fetch('../api/import/csv.php', {method:'POST', body: appendCsrf(fd)});
|
||||
const j = await res.json();
|
||||
if (j.status === 'success') {
|
||||
msg.style.color = '#16a34a';
|
||||
msg.innerHTML = '<i data-lucide="check-circle" style="color:#16a34a;vertical-align:middle;margin-right:4px;"></i> ' + esc(j.message);
|
||||
document.getElementById('importFile').value = '';
|
||||
document.getElementById('importPreviewArea').style.display = 'none';
|
||||
document.getElementById('importErrorList').style.display = 'none';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
} else {
|
||||
msg.style.color = '#ef4444';
|
||||
msg.innerHTML = '<i data-lucide="x-circle" style="color:#ef4444;vertical-align:middle;margin-right:4px;"></i> ' + esc(j.message || 'Import gagal.');
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
} catch(e) {
|
||||
msg.style.color = '#ef4444';
|
||||
msg.innerHTML = '<i data-lucide="x-circle" style="color:#ef4444;vertical-align:middle;margin-right:4px;"></i> Gagal menghubungi server.';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i data-lucide="check"></i> Import Sekarang';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php include '../includes/page-end.php'; ?>
|
||||
@@ -0,0 +1,443 @@
|
||||
<?php
|
||||
require_once '../config.php';
|
||||
require_once '../auth/helper.php';
|
||||
require_once '../koneksi.php';
|
||||
if (!is_logged_in() || !has_role('operator')) { header('Location: ../auth/login.php'); exit; }
|
||||
require_password_changed('../auth/change_password.php');
|
||||
$is_admin = has_role('administrator');
|
||||
$ibadah_id = get_ibadah_id();
|
||||
$page_title = 'Kebutuhan & Papan Publik';
|
||||
$page_subtitle = 'Kelola kebutuhan warga dan pantau papan publik';
|
||||
$active_nav = 'kebutuhan';
|
||||
include '../includes/page-start.php';
|
||||
?>
|
||||
<script>window._IBADAH_ID=<?= $ibadah_id?(int)$ibadah_id:'null'?>;window._IS_ADMIN=<?= $is_admin?'true':'false'?>;</script>
|
||||
|
||||
<style>
|
||||
.keb-wrap{display:grid;grid-template-columns:1fr 360px;gap:16px;align-items:start;}
|
||||
@media(max-width:900px){.keb-wrap{grid-template-columns:1fr;}}
|
||||
.keb-list-item{padding:10px 16px;border-bottom:1px solid var(--hd-border);cursor:pointer;transition:background .15s;}
|
||||
.keb-list-item:hover{background:#ede8e2;}
|
||||
.keb-list-item.active{background:#ede8e2;border-left:3px solid #0d7490;padding-left:13px;}
|
||||
.keb-open-badge{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;border-radius:9999px;background:#fef2f2;color:#ef4444;font-size:11px;font-weight:500;padding:0 6px;}
|
||||
.keb-status-sel{padding:5px 10px;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid var(--card-border);background:#fafaf9;font-family:var(--font);color:var(--text-secondary);}
|
||||
.keb-status-sel.belum{background:#f5f0eb;color:#7a7067;border-color:#c5bdb5;}
|
||||
.keb-status-sel.proses{background:#fffbeb;color:#d97706;border-color:#fcd34d;}
|
||||
.keb-status-sel.terpenuhi{background:#f0fdf4;color:#16a34a;border-color:#86efac;}
|
||||
/* ── Tab navigation ─────────────────────────────────────────── */
|
||||
.tab-nav{display:flex;gap:2px;margin-bottom:16px;border-bottom:2px solid var(--card-border);padding-bottom:0;}
|
||||
.tab-btn{background:none;border:none;padding:10px 18px;font-family:var(--font);font-size:13px;font-weight:500;
|
||||
color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-2px;
|
||||
display:inline-flex;align-items:center;gap:6px;transition:color .15s;}
|
||||
.tab-btn:hover{color:var(--text-primary);}
|
||||
.tab-btn.active{color:#0d7490;border-bottom-color:#0d7490;}
|
||||
.tab-badge{background:#ef4444;color:#fff;font-size:10px;font-weight:700;min-width:18px;height:18px;
|
||||
border-radius:9999px;display:inline-flex;align-items:center;justify-content:center;padding:0 5px;}
|
||||
/* ── Donor inbox cards ──────────────────────────────────────── */
|
||||
.donor-card{padding:14px 16px;border-bottom:1px solid var(--hd-border);}
|
||||
.donor-card.unread{border-left:3px solid #0d7490;padding-left:13px;background:#f7fbfd;}
|
||||
.donor-card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;}
|
||||
.donor-nama{font-weight:600;font-size:14px;}
|
||||
.donor-meta{font-size:11px;color:var(--text-muted);}
|
||||
.donor-kontak{font-size:12px;color:var(--text-secondary);margin-bottom:6px;display:flex;align-items:center;gap:4px;}
|
||||
.keb-copyable{cursor:pointer;border-radius:5px;padding:2px 5px;margin:-2px -5px;transition:background .15s,color .15s;}
|
||||
.keb-copyable:hover{background:#ede8e2;color:var(--text-primary);}
|
||||
.donor-pesan{font-size:13px;line-height:1.5;margin-bottom:8px;}
|
||||
.donor-actions{display:flex;gap:8px;flex-wrap:wrap;}
|
||||
.btn-hubungi{background:#16a34a;color:#fff;border:none;border-radius:6px;padding:5px 12px;
|
||||
font-size:12px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:4px;text-decoration:none;}
|
||||
.btn-hubungi:hover{background:#15803d;}
|
||||
.btn-hapus{background:transparent;color:#ef4444;border:1px solid #fca5a5;border-radius:6px;
|
||||
padding:5px 12px;font-size:12px;font-weight:600;cursor:pointer;}
|
||||
.btn-hapus:hover{background:#fef2f2;}
|
||||
/* ── Toast ──────────────────────────────────────────────────── */
|
||||
.keb-toast{position:fixed;bottom:24px;right:24px;z-index:10000;display:flex;align-items:center;gap:10px;
|
||||
padding:12px 18px;border-radius:10px;font-size:13px;font-weight:500;box-shadow:0 4px 16px rgba(32,21,21,.15);
|
||||
transform:translateY(12px);opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;}
|
||||
.keb-toast.show{opacity:1;transform:translateY(0);pointer-events:auto;}
|
||||
.keb-toast.success{background:#f0fdf4;color:#15803d;border:1px solid #86efac;}
|
||||
.keb-toast.error{background:#fef2f2;color:#dc2626;border:1px solid #fca5a5;}
|
||||
</style>
|
||||
|
||||
<div class="tab-nav">
|
||||
<button class="tab-btn active" data-tab="kebutuhan" onclick="switchTab('kebutuhan')">
|
||||
<i data-lucide="heart" style="width:14px;height:14px;"></i> Kebutuhan Warga
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="donatur" onclick="switchTab('donatur')">
|
||||
<i data-lucide="mail" style="width:14px;height:14px;"></i> Pesan Donatur
|
||||
<span class="tab-badge" id="donaturBadge" style="display:none;"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="tabKebutuhan" class="tab-panel">
|
||||
<div class="keb-wrap">
|
||||
<!-- Left: penduduk list -->
|
||||
<div class="table-card">
|
||||
<div class="table-card-header">
|
||||
<div class="table-card-title">Penduduk dengan Kebutuhan</div>
|
||||
<a href="../papan-kebutuhan.php" target="_blank" class="btn-see-all">🌐 Papan Publik</a>
|
||||
</div>
|
||||
<div style="padding:10px 14px;border-bottom:1px solid var(--hd-border);">
|
||||
<input type="text" id="kebSearch" style="width:100%;padding:7px 12px;border:1px solid var(--card-border);border-radius:8px;font-family:var(--font);font-size:13px;outline:none;" placeholder="Cari nama KK..." oninput="filterKebList()">
|
||||
</div>
|
||||
<div id="kebList" style="max-height:65vh;overflow-y:auto;">
|
||||
<div style="padding:20px;text-align:center;color:var(--text-muted);"><span class="spinner"></span> Memuat...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: kebutuhan detail -->
|
||||
<div class="dash-card" id="kebDetail" style="position:sticky;top:16px;">
|
||||
<div class="dash-card-title" id="kebDetailTitle" style="margin-bottom:12px;">Detail Kebutuhan</div>
|
||||
<div id="kebDetailBody" style="font-size:13px;color:var(--text-muted);text-align:center;padding:32px 0;">
|
||||
Pilih warga di sebelah kiri untuk melihat dan mengelola kebutuhannya.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- #tabKebutuhan -->
|
||||
|
||||
<div id="kebToast" class="keb-toast" role="alert" aria-live="polite"></div>
|
||||
|
||||
<div id="tabDonatur" class="tab-panel" style="display:none;">
|
||||
<?php if ($is_admin): ?>
|
||||
<div class="table-card">
|
||||
<div class="table-card-header">
|
||||
<div>
|
||||
<div class="table-card-title">Pesan dari Calon Donatur</div>
|
||||
<div class="table-card-count" id="donaturCount"></div>
|
||||
</div>
|
||||
<button class="btn-primary" id="btnMarkAll" onclick="markAllRead()"
|
||||
style="font-size:12px;padding:6px 14px;white-space:nowrap;">
|
||||
Tandai Semua Sudah Dibaca
|
||||
</button>
|
||||
</div>
|
||||
<div id="donaturList" style="padding:16px;">
|
||||
<span class="spinner"></span> Memuat...
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="dash-card" style="text-align:center;padding:48px 24px;color:var(--text-muted);">
|
||||
<div style="font-size:13px;">Hanya administrator yang dapat melihat pesan donatur.</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div><!-- #tabDonatur -->
|
||||
|
||||
<script>
|
||||
let _kebAll = [], _kebSelectedId = null;
|
||||
function esc(s){const d=document.createElement('div');d.appendChild(document.createTextNode(s??''));return d.innerHTML;}
|
||||
function attr(s){return String(s??'').replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');}
|
||||
|
||||
async function loadKeb() {
|
||||
try {
|
||||
const r = await fetch('../api/penduduk/ambil.php?_='+Date.now());
|
||||
const j = await r.json();
|
||||
if (j.status !== 'success') return;
|
||||
let data = j.data.filter(p => p.status_verifikasi === 'Terverifikasi');
|
||||
if (window._IBADAH_ID && !window._IS_ADMIN) {
|
||||
data = data.filter(p => parseInt(p.ibadah_id) === window._IBADAH_ID);
|
||||
}
|
||||
_kebAll = data;
|
||||
filterKebList();
|
||||
} catch(e) {
|
||||
document.getElementById('kebList').innerHTML = '<div style="padding:16px;color:#ef4444;">Gagal memuat data.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function filterKebList() {
|
||||
const q = document.getElementById('kebSearch').value.toLowerCase();
|
||||
const rows = _kebAll.filter(p => (p.nama_kk||'').toLowerCase().includes(q));
|
||||
const el = document.getElementById('kebList');
|
||||
if (!rows.length) {
|
||||
el.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);">Tidak ada data.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = rows.map(p => {
|
||||
const open = parseInt(p.kebutuhan_open||0) + parseInt(p.kebutuhan_proses||0);
|
||||
const active = _kebSelectedId === p.id ? ' active' : '';
|
||||
return `<div class="keb-list-item${active}" data-id="${attr(parseInt(p.id, 10) || 0)}" data-nama="${attr(p.nama_kk||'?')}" onclick="selectPenduduk(parseInt(this.dataset.id, 10), this.dataset.nama)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<span style="font-weight:600;font-size:13px;">${esc(p.nama_kk||'—')}</span>
|
||||
<span class="keb-open-badge">${open}</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:3px;">${esc(p.nama_ibadah||'Blank Spot')} · ${esc(p.kategori||'—')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const KEB_KATEGORI = ['Sembako','Biaya Sekolah','Biaya Kesehatan','Modal Usaha','Renovasi Rumah','Perlengkapan Rumah','Pakaian','Lainnya'];
|
||||
|
||||
async function selectPenduduk(id, nama) {
|
||||
_kebSelectedId = id;
|
||||
filterKebList();
|
||||
document.getElementById('kebDetailTitle').textContent = nama;
|
||||
document.getElementById('kebDetailBody').innerHTML = '<span class="spinner"></span> Memuat...';
|
||||
try {
|
||||
const r = await fetch('../api/kebutuhan/ambil.php?penduduk_id='+id+'&_='+Date.now());
|
||||
const j = await r.json();
|
||||
const clsMap = {'Belum Terpenuhi':'belum','Dalam Proses':'proses','Terpenuhi':'terpenuhi'};
|
||||
|
||||
const listHtml = (!j.data || !j.data.length)
|
||||
? '<p style="color:var(--text-muted);font-size:13px;margin-bottom:4px;">Belum ada kebutuhan tercatat.</p>'
|
||||
: j.data.map(k => `
|
||||
<div style="padding:10px 0;border-bottom:1px solid var(--hd-border);">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:5px;">
|
||||
<span style="font-weight:600;font-size:13px;">${esc(k.kategori)}</span>
|
||||
<select class="keb-status-sel ${clsMap[k.status]||''}"
|
||||
data-id="${attr(parseInt(k.id,10)||0)}" data-orig="${attr(k.status||'')}" onchange="updateKeb(this)">
|
||||
<option ${k.status==='Belum Terpenuhi'?'selected':''}>Belum Terpenuhi</option>
|
||||
<option ${k.status==='Dalam Proses'?'selected':''}>Dalam Proses</option>
|
||||
<option ${k.status==='Terpenuhi'?'selected':''}>Terpenuhi</option>
|
||||
</select>
|
||||
</div>
|
||||
${k.deskripsi?`<div style="font-size:12px;color:var(--text-muted);">${esc(k.deskripsi)}</div>`:''}
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">Oleh ${esc(k.created_by_nama||'—')} · ${esc((k.created_at||'').slice(0,10))}</div>
|
||||
</div>`).join('');
|
||||
|
||||
const addFormHtml = `
|
||||
<div style="margin-top:14px;padding-top:14px;border-top:2px dashed var(--hd-border);">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;">Tambah Kebutuhan</div>
|
||||
<select id="kebAddKat_${id}" style="width:100%;padding:7px 10px;border:1px solid var(--card-border);border-radius:7px;font-family:var(--font);font-size:13px;background:#fafaf9;color:var(--text-primary);outline:none;margin-bottom:8px;">
|
||||
${KEB_KATEGORI.map(k=>`<option value="${esc(k)}">${esc(k)}</option>`).join('')}
|
||||
</select>
|
||||
<input type="text" id="kebAddDesc_${id}" placeholder="Deskripsi (opsional, maks 300 karakter)" maxlength="300"
|
||||
style="width:100%;padding:7px 10px;border:1px solid var(--card-border);border-radius:7px;font-family:var(--font);font-size:13px;background:#fafaf9;color:var(--text-primary);outline:none;margin-bottom:8px;box-sizing:border-box;">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<button onclick="addKebAPI(${id})" id="kebAddBtn_${id}" class="btn-primary" style="padding:7px 18px;font-size:13px;">Simpan</button>
|
||||
<span id="kebAddMsg_${id}" style="font-size:12px;color:#ef4444;"></span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('kebDetailBody').innerHTML = listHtml + addFormHtml;
|
||||
} catch(e) {
|
||||
document.getElementById('kebDetailBody').innerHTML = '<span style="color:#ef4444;">Gagal memuat kebutuhan.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function addKebAPI(id) {
|
||||
const kat = document.getElementById(`kebAddKat_${id}`).value;
|
||||
const desc = document.getElementById(`kebAddDesc_${id}`).value.trim();
|
||||
const btn = document.getElementById(`kebAddBtn_${id}`);
|
||||
const msg = document.getElementById(`kebAddMsg_${id}`);
|
||||
btn.disabled = true; btn.textContent = 'Menyimpan...'; msg.textContent = '';
|
||||
const fd = new FormData();
|
||||
fd.append('penduduk_id', id); fd.append('kategori', kat); fd.append('deskripsi', desc);
|
||||
try {
|
||||
const r = await fetch('../api/kebutuhan/simpan.php', { method:'POST', body: appendCsrf(fd) });
|
||||
const j = await r.json();
|
||||
if (j.status === 'success') {
|
||||
loadKeb();
|
||||
selectPenduduk(id, document.getElementById('kebDetailTitle').textContent);
|
||||
} else {
|
||||
msg.textContent = j.message || 'Gagal menyimpan.';
|
||||
btn.disabled = false; btn.textContent = 'Simpan';
|
||||
}
|
||||
} catch(e) {
|
||||
msg.textContent = 'Gagal terhubung ke server.';
|
||||
btn.disabled = false; btn.textContent = 'Simpan';
|
||||
}
|
||||
}
|
||||
|
||||
async function updateKeb(sel) {
|
||||
const id = sel.dataset.id;
|
||||
const orig = sel.dataset.orig;
|
||||
const catatan = prompt('Catatan perubahan kebutuhan (opsional):', '');
|
||||
if (catatan === null) {
|
||||
sel.value = orig;
|
||||
return;
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append('id', id); fd.append('status', sel.value); fd.append('catatan', catatan);
|
||||
try {
|
||||
const r = await fetch('../api/kebutuhan/update_status.php', {method:'POST', body: appendCsrf(fd)});
|
||||
const j = await r.json();
|
||||
if (j.status === 'success') {
|
||||
sel.dataset.orig = sel.value;
|
||||
const clsMap = {'Belum Terpenuhi':'belum','Dalam Proses':'proses','Terpenuhi':'terpenuhi'};
|
||||
sel.className = 'keb-status-sel ' + (clsMap[sel.value]||'');
|
||||
loadKeb();
|
||||
} else { alert(j.message||'Gagal update.'); }
|
||||
} catch(e) { alert('Gagal terhubung ke server.'); }
|
||||
}
|
||||
|
||||
loadKeb();
|
||||
if (window._IS_ADMIN) loadDonorInbox();
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────
|
||||
let _kebToastTimer = null;
|
||||
function showKebToast(msg, type) {
|
||||
const el = document.getElementById('kebToast');
|
||||
if (!el) return;
|
||||
clearTimeout(_kebToastTimer);
|
||||
el.className = 'keb-toast ' + (type || 'success');
|
||||
el.textContent = msg;
|
||||
el.classList.add('show');
|
||||
_kebToastTimer = setTimeout(function() { el.classList.remove('show'); }, 3500);
|
||||
}
|
||||
|
||||
// ── Confirm dialog ────────────────────────────────────────────────────
|
||||
function showKebConfirm(title, message) {
|
||||
return new Promise(function(resolve) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'confirm-overlay';
|
||||
el.innerHTML = '<div class="confirm-box">'
|
||||
+ '<div class="confirm-title">' + esc(title) + '</div>'
|
||||
+ '<p class="confirm-msg">' + esc(message) + '</p>'
|
||||
+ '<div class="confirm-actions">'
|
||||
+ '<button class="btn-secondary" id="_kc_cancel">Batal</button>'
|
||||
+ '<button class="btn-danger" id="_kc_ok">Ya, Hapus</button>'
|
||||
+ '</div></div>';
|
||||
document.body.appendChild(el);
|
||||
const done = function(val) { el.remove(); resolve(val); };
|
||||
el.querySelector('#_kc_ok').onclick = function() { done(true); };
|
||||
el.querySelector('#_kc_cancel').onclick = function() { done(false); };
|
||||
el.addEventListener('click', function(e) { if (e.target === el) done(false); });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tab system ────────────────────────────────────────────────────────
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab-btn').forEach(function(b) {
|
||||
b.classList.toggle('active', b.dataset.tab === tab);
|
||||
});
|
||||
document.getElementById('tabKebutuhan').style.display = tab === 'kebutuhan' ? '' : 'none';
|
||||
document.getElementById('tabDonatur').style.display = tab === 'donatur' ? '' : 'none';
|
||||
if (tab === 'donatur' && window._IS_ADMIN && !_donorLoaded) loadDonorInbox();
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
|
||||
// ── Donor inbox ───────────────────────────────────────────────────────
|
||||
function copyKontak(el) {
|
||||
const text = el.dataset.copy || '';
|
||||
if (!text) return;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
showKebToast('Tersalin: ' + text, 'success');
|
||||
}).catch(function() { _fallbackCopy(text); });
|
||||
} else {
|
||||
_fallbackCopy(text);
|
||||
}
|
||||
}
|
||||
function _fallbackCopy(text) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0;';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); showKebToast('Tersalin: ' + text, 'success'); }
|
||||
catch(e) { showKebToast('Gagal menyalin.', 'error'); }
|
||||
ta.remove();
|
||||
}
|
||||
|
||||
function buildKontakUrl(kontak) {
|
||||
const trimmed = (kontak || '').trim();
|
||||
if (trimmed.includes('@')) return 'mailto:' + trimmed;
|
||||
const digits = trimmed.replace(/\D/g, '');
|
||||
if (digits.length >= 8) {
|
||||
const normalized = digits.startsWith('0') ? '62' + digits.slice(1) : digits;
|
||||
return 'https://wa.me/' + normalized;
|
||||
}
|
||||
return 'mailto:' + trimmed;
|
||||
}
|
||||
|
||||
let _donorData = [], _donorLoaded = false;
|
||||
|
||||
async function loadDonorInbox() {
|
||||
const list = document.getElementById('donaturList');
|
||||
if (!list) return;
|
||||
list.innerHTML = '<span class="spinner"></span> Memuat...';
|
||||
try {
|
||||
const r = await fetch('../api/papan/donatur.php?_=' + Date.now());
|
||||
const j = await r.json();
|
||||
if (j.status !== 'success') throw new Error(j.message || 'Gagal memuat.');
|
||||
_donorData = j.data || [];
|
||||
_donorLoaded = true;
|
||||
renderDonorInbox(_donorData);
|
||||
} catch (e) {
|
||||
list.innerHTML = '<div style="color:#ef4444;padding:8px;">Gagal memuat pesan: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDonorInbox(data) {
|
||||
const list = document.getElementById('donaturList');
|
||||
const badge = document.getElementById('donaturBadge');
|
||||
const counter = document.getElementById('donaturCount');
|
||||
if (!list) return;
|
||||
|
||||
const unread = data.filter(function(d) { return !parseInt(d.is_read); }).length;
|
||||
if (badge) { badge.textContent = unread; badge.style.display = unread > 0 ? '' : 'none'; }
|
||||
if (counter) counter.textContent = data.length + ' pesan' + (unread > 0 ? ', ' + unread + ' belum dibaca' : '');
|
||||
|
||||
if (!data.length) {
|
||||
list.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-muted);font-size:13px;">Belum ada pesan masuk dari calon donatur.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = data.map(function(d) {
|
||||
const isUnread = !parseInt(d.is_read);
|
||||
const kontakUrl = buildKontakUrl(d.kontak || '');
|
||||
const isWa = kontakUrl.startsWith('https://wa.me');
|
||||
const safeId = parseInt(d.id, 10) || 0;
|
||||
return '<div class="donor-card' + (isUnread ? ' unread' : '') + '" id="dc-' + safeId + '">'
|
||||
+ '<div class="donor-card-header">'
|
||||
+ '<div style="display:flex;align-items:center;gap:8px;">'
|
||||
+ '<span class="donor-nama">' + esc(d.nama) + '</span>'
|
||||
+ (isUnread ? '<span style="display:inline-block;width:7px;height:7px;background:#0d7490;border-radius:50%;"></span>' : '')
|
||||
+ (d.kategori_minat ? '<span class="badge-pill" style="background:#eff6ff;color:#2563eb;">' + esc(d.kategori_minat) + '</span>' : '')
|
||||
+ '</div>'
|
||||
+ '<span class="donor-meta">' + esc((d.created_at || '').slice(0, 10)) + '</span>'
|
||||
+ '</div>'
|
||||
+ '<div class="donor-kontak keb-copyable" data-copy="' + attr(d.kontak || '') + '" onclick="copyKontak(this)" title="Klik untuk salin">'
|
||||
+ '<i data-lucide="copy" style="width:11px;height:11px;opacity:.5;flex-shrink:0;"></i> '
|
||||
+ esc(d.kontak)
|
||||
+ '</div>'
|
||||
+ (d.pesan ? '<div class="donor-pesan">' + esc(d.pesan) + '</div>' : '')
|
||||
+ '<div class="donor-actions">'
|
||||
+ '<a href="' + attr(kontakUrl) + '" target="_blank" rel="noopener" class="btn-hubungi">'
|
||||
+ (isWa ? 'WhatsApp' : 'Email')
|
||||
+ '</a>'
|
||||
+ '<button class="btn-hapus" onclick="hapusDonatur(' + safeId + ')">Hapus</button>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
const btn = document.getElementById('btnMarkAll');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Memproses...'; }
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'mark_all');
|
||||
try {
|
||||
const r = await fetch('../api/papan/donatur.php', { method: 'POST', body: appendCsrf(fd) });
|
||||
const j = await r.json();
|
||||
if (j.status !== 'success') throw new Error(j.message || 'Gagal.');
|
||||
_donorData = _donorData.map(function(d) { return Object.assign({}, d, { is_read: '1' }); });
|
||||
renderDonorInbox(_donorData);
|
||||
showKebToast('Semua pesan ditandai sudah dibaca.', 'success');
|
||||
} catch (e) {
|
||||
showKebToast('Gagal menandai: ' + e.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Tandai Semua Sudah Dibaca'; }
|
||||
}
|
||||
}
|
||||
|
||||
async function hapusDonatur(id) {
|
||||
const ok = await showKebConfirm('Hapus Pesan', 'Hapus pesan donatur ini secara permanen?');
|
||||
if (!ok) return;
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'hapus');
|
||||
fd.append('id', id);
|
||||
try {
|
||||
const r = await fetch('../api/papan/donatur.php', { method: 'POST', body: appendCsrf(fd) });
|
||||
const j = await r.json();
|
||||
if (j.status !== 'success') throw new Error(j.message || 'Gagal hapus.');
|
||||
_donorData = _donorData.filter(function(d) { return parseInt(d.id) !== id; });
|
||||
renderDonorInbox(_donorData);
|
||||
showKebToast('Pesan dihapus.', 'success');
|
||||
} catch (e) {
|
||||
showKebToast('Gagal menghapus: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php include '../includes/page-end.php'; ?>
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
require_once '../config.php';
|
||||
require_once '../auth/helper.php';
|
||||
require_once '../koneksi.php';
|
||||
if (!is_logged_in() || !has_role('administrator')) { header('Location: ../auth/login.php'); exit; }
|
||||
require_password_changed('../auth/change_password.php');
|
||||
$page_title = 'Laporan & Export';
|
||||
$page_subtitle = 'Export data binaan per rumah ibadah';
|
||||
$active_nav = 'laporan';
|
||||
include '../includes/page-start.php';
|
||||
?>
|
||||
|
||||
<div style="max-width:640px;">
|
||||
<div class="dash-card" style="margin-bottom:20px;">
|
||||
<div class="dash-card-title">Pilih Rumah Ibadah</div>
|
||||
<select id="lapIbadah" style="width:100%;padding:10px 12px;border:1px solid var(--card-border);border-radius:8px;font-family:var(--font);font-size:14px;color:var(--text-primary);background:#fafaf9;cursor:pointer;margin-bottom:12px;outline:none;" onchange="loadPreview()">
|
||||
<option value="">-- Pilih Rumah Ibadah --</option>
|
||||
</select>
|
||||
<div id="lapPreview" style="display:none;">
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:16px;" id="lapCards"></div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||
<a id="btnCsv" href="" aria-disabled="true" onclick="return this.getAttribute('aria-disabled') !== 'true';" class="btn-primary disabled" style="text-decoration:none;padding:10px 20px;display:inline-flex;align-items:center;gap:6px;"><i data-lucide="download"></i> Download CSV</a>
|
||||
<a id="btnPdf" href="" aria-disabled="true" onclick="return this.getAttribute('aria-disabled') !== 'true';" target="_blank" class="btn-secondary disabled" style="text-decoration:none;padding:10px 20px;display:inline-flex;align-items:center;gap:6px;"><i data-lucide="printer"></i> Cetak PDF</a>
|
||||
</div>
|
||||
<p style="font-size:11px;color:var(--text-muted);margin-top:10px;">CSV tidak menyertakan NIK untuk meminimalkan risiko penyebaran data sensitif.</p>
|
||||
</div>
|
||||
<div id="lapMsg" style="font-size:13px;margin-top:8px;color:var(--text-muted);"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function esc(s){const d=document.createElement('div');d.appendChild(document.createTextNode(s??''));return d.innerHTML;}
|
||||
function fmt(n){return Number(n||0).toLocaleString('id-ID');}
|
||||
|
||||
async function loadIbadahList() {
|
||||
try {
|
||||
const r = await fetch('../api/ibadah/ambil.php?_='+Date.now());
|
||||
const j = await r.json();
|
||||
if (j.status !== 'success') return;
|
||||
const sel = document.getElementById('lapIbadah');
|
||||
j.data.forEach(ib => {
|
||||
const o = document.createElement('option');
|
||||
o.value = ib.id;
|
||||
o.textContent = '['+ib.jenis+'] '+ib.nama+' ('+fmt(ib.total_kk||0)+' KK)';
|
||||
o.dataset.kk = ib.total_kk || 0;
|
||||
o.dataset.jiwa = ib.total_jiwa || 0;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
} catch(e) {
|
||||
document.getElementById('lapMsg').textContent = 'Gagal memuat daftar ibadah.';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
const sel = document.getElementById('lapIbadah');
|
||||
const id = sel.value;
|
||||
const preview = document.getElementById('lapPreview');
|
||||
if (!id) { preview.style.display = 'none'; return; }
|
||||
|
||||
const opt = sel.selectedOptions[0];
|
||||
const kk = opt.dataset.kk || 0;
|
||||
const jiwa = opt.dataset.jiwa || 0;
|
||||
|
||||
document.getElementById('lapMsg').innerHTML = '<span class="spinner"></span> Memuat statistik...';
|
||||
preview.style.display = 'none';
|
||||
|
||||
try {
|
||||
const r = await fetch('../api/stats/ambil.php?_='+Date.now());
|
||||
const j = await r.json();
|
||||
document.getElementById('lapMsg').textContent = '';
|
||||
let pct = 0;
|
||||
if (j.status === 'success') {
|
||||
const stat = (j.data.rekap_ibadah || []).find(x => String(x.id) === String(id));
|
||||
if (stat && parseInt(stat.jml_kk) > 0) {
|
||||
pct = Math.round(parseInt(stat.jml_ditangani) / parseInt(stat.jml_kk) * 100);
|
||||
}
|
||||
}
|
||||
document.getElementById('lapCards').innerHTML =
|
||||
`<div class="need-stat"><div class="need-stat-val blue">${fmt(kk)}</div><div class="need-stat-lbl">KK Binaan</div></div>
|
||||
<div class="need-stat"><div class="need-stat-val">${fmt(jiwa)}</div><div class="need-stat-lbl">Jiwa Binaan</div></div>
|
||||
<div class="need-stat"><div class="need-stat-val green">${esc(String(pct))}%</div><div class="need-stat-lbl">Sudah Ditangani</div></div>`;
|
||||
document.getElementById('btnCsv').href = '../api/export/csv.php?ibadah_id=' + encodeURIComponent(id);
|
||||
document.getElementById('btnPdf').href = '../api/export/pdf.php?ibadah_id=' + encodeURIComponent(id);
|
||||
document.getElementById('btnCsv').setAttribute('aria-disabled', 'false');
|
||||
document.getElementById('btnPdf').setAttribute('aria-disabled', 'false');
|
||||
document.getElementById('btnCsv').classList.remove('disabled');
|
||||
document.getElementById('btnPdf').classList.remove('disabled');
|
||||
preview.style.display = '';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
} catch(e) {
|
||||
document.getElementById('lapMsg').textContent = 'Gagal memuat statistik.';
|
||||
}
|
||||
}
|
||||
|
||||
loadIbadahList();
|
||||
</script>
|
||||
|
||||
<?php include '../includes/page-end.php'; ?>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
require_once '../config.php';
|
||||
require_once '../auth/helper.php';
|
||||
require_once '../koneksi.php';
|
||||
if (!is_logged_in() || !has_role('operator')) {
|
||||
header('Location: ../auth/login.php');
|
||||
exit;
|
||||
}
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
require_password_changed('../auth/change_password.php');
|
||||
$page_title = 'Peta Interaktif';
|
||||
$page_subtitle = 'Peta sebaran rumah ibadah dan penduduk miskin';
|
||||
$active_nav = 'map';
|
||||
include '../includes/page-start.php';
|
||||
?>
|
||||
|
||||
<style>
|
||||
.al-content {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.map-frame-wrap {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: #f5f0eb;
|
||||
}
|
||||
.map-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #f5f0eb;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="map-frame-wrap">
|
||||
<iframe
|
||||
class="map-frame"
|
||||
src="../index.php?embedded=1&v=20260608-ui6"
|
||||
title="Peta Interaktif"
|
||||
loading="eager"
|
||||
referrerpolicy="same-origin">
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
<?php include '../includes/page-end.php'; ?>
|
||||
@@ -0,0 +1,836 @@
|
||||
<?php
|
||||
require_once '../config.php';
|
||||
require_once '../auth/helper.php';
|
||||
require_once '../koneksi.php';
|
||||
if (!is_logged_in() || !has_role('operator')) { header('Location: ../auth/login.php'); exit; }
|
||||
require_password_changed('../auth/change_password.php');
|
||||
$is_admin = has_role('administrator');
|
||||
$ibadah_id = get_ibadah_id();
|
||||
$page_title = 'Penduduk Miskin';
|
||||
$page_subtitle = 'Daftar dan manajemen data penduduk miskin';
|
||||
$active_nav = 'penduduk';
|
||||
include '../includes/page-start.php';
|
||||
?>
|
||||
<script>
|
||||
window._IBADAH_ID = <?= $ibadah_id ? (int)$ibadah_id : 'null' ?>;
|
||||
window._IS_ADMIN = <?= $is_admin ? 'true' : 'false' ?>;
|
||||
</script>
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
||||
|
||||
<style>
|
||||
.pd-filter-bar{display:flex;flex-direction:column;gap:8px;margin-bottom:16px;}
|
||||
.pd-input{width:100%;box-sizing:border-box;padding:8px 12px;border:1px solid var(--card-border);border-radius:8px;font-family:var(--font);font-size:13px;background:#fafaf9;color:var(--text-primary);outline:none;}
|
||||
.pd-input:focus{border-color:#0d7490;}
|
||||
.pd-filter-chips{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}
|
||||
.pd-chip{width:auto;height:auto;padding:7px 11px;border:1px solid var(--card-border);border-radius:8px;font-family:var(--font);font-size:13px;background:#fafaf9;color:var(--text-primary);cursor:pointer;white-space:nowrap;}
|
||||
.pd-chip:focus{outline:none;border-color:#0d7490;}
|
||||
.pd-chip-spacer{flex:1;}
|
||||
.kat-badge{display:inline-block;padding:2px 9px;border-radius:9999px;font-size:11px;font-weight:500;}
|
||||
.kat-sm{background:#fef2f2;color:#ef4444;}
|
||||
.kat-m{background:#fffbeb;color:#f59e0b;}
|
||||
.kat-hm{background:#eff6ff;color:#3b82f6;}
|
||||
.bs-badge{display:inline-block;padding:2px 9px;border-radius:9999px;font-size:11px;font-weight:500;}
|
||||
.bs-blank{background:#fef2f2;color:#ef4444;}
|
||||
.bs-cover{background:#f0fdf4;color:#16a34a;}
|
||||
.sv-badge{display:inline-flex;align-items:center;gap:3px;padding:2px 9px;border-radius:9999px;font-size:11px;font-weight:500;}
|
||||
.sv-pending{background:#fef3c7;color:#d97706;}
|
||||
.sv-ok{background:#d1fae5;color:#16a34a;}
|
||||
.sv-tolak{background:#fee2e2;color:#dc2626;}
|
||||
.stat-sel{padding:5px 10px;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid var(--card-border);background:#fafaf9;font-family:var(--font);color:var(--text-secondary);}
|
||||
.stat-sel.belum{background:#f5f0eb;color:#7a7067;border-color:#c5bdb5;}
|
||||
.stat-sel.proses{background:#fffbeb;color:#d97706;border-color:#fcd34d;}
|
||||
.stat-sel.sudah{background:#f0fdf4;color:#16a34a;border-color:#86efac;}
|
||||
.modal-dark{position:fixed;inset:0;background:rgba(32,21,21,.55);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;padding:20px;z-index:9999;opacity:0;pointer-events:none;transition:opacity .2s;}
|
||||
.modal-dark.show{opacity:1;pointer-events:auto;}
|
||||
.modal-box{width:min(620px,100%);background:#fafaf9;border:1px solid var(--card-border);border-radius:12px;padding:24px;box-shadow:0 4px 16px rgba(32,21,21,.1);max-height:90vh;display:flex;flex-direction:column;}
|
||||
.modal-box h3{font-size:15px;font-weight:600;margin-bottom:14px;color:var(--text-primary);flex-shrink:0;}
|
||||
.modal-scroll{overflow-y:auto;flex:1;padding-right:4px;}
|
||||
.mfg{margin-bottom:14px;}
|
||||
.mfg label{display:block;font-size:11px;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:.5px;margin-bottom:5px;}
|
||||
.mfg input,.mfg select,.mfg textarea{width:100%;padding:9px 12px;border:1px solid var(--card-border);border-radius:8px;font-family:var(--font);font-size:13px;color:var(--text-primary);background:#fafaf9;outline:none;transition:border-color .2s;}
|
||||
.mfg input:focus,.mfg select:focus,.mfg textarea:focus{border-color:#0d7490;}
|
||||
.mfg textarea{resize:vertical;min-height:70px;}
|
||||
.mfg small{font-size:11px;color:var(--text-muted);margin-top:4px;display:block;}
|
||||
.modal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:16px;flex-shrink:0;padding-top:14px;border-top:1px solid var(--card-border);}
|
||||
.btn-cancel{padding:9px 16px;background:transparent;border:1px solid var(--card-border);border-radius:8px;font-family:var(--font);font-size:13px;font-weight:600;color:var(--text-secondary);cursor:pointer;transition:background .15s;}
|
||||
.btn-cancel:hover{background:#ede8e2;}
|
||||
.btn-primary{padding:9px 18px;background:#0d7490;border:none;border-radius:8px;font-family:var(--font);font-size:14px;font-weight:600;color:#fff;cursor:pointer;transition:background .15s;}
|
||||
.btn-primary:hover{background:#0a5f7a;}
|
||||
.btn-primary:disabled{opacity:.5;cursor:not-allowed;}
|
||||
.btn-approve{padding:4px 10px;background:#f0fdf4;border:1px solid #86efac;border-radius:9999px;font-size:11px;font-weight:500;color:#16a34a;cursor:pointer;white-space:nowrap;}
|
||||
.btn-approve:hover{background:#dcfce7;}
|
||||
.btn-reject{padding:4px 10px;background:#fef2f2;border:1px solid #fca5a5;border-radius:9999px;font-size:11px;font-weight:500;color:#dc2626;cursor:pointer;white-space:nowrap;}
|
||||
.btn-reject:hover{background:#fee2e2;}
|
||||
.msg-err{font-size:12px;color:#ef4444;margin-top:6px;min-height:16px;}
|
||||
.map-picker-wrap{border:1px solid var(--card-border);border-radius:10px;overflow:hidden;}
|
||||
.map-picker-wrap #pdMapPicker{height:240px;cursor:crosshair;}
|
||||
.map-picker-hint{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text-muted);margin-top:6px;}
|
||||
.map-geocoding{font-size:11px;color:var(--text-muted);margin-top:4px;min-height:16px;font-style:italic;}
|
||||
</style>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="pd-filter-bar">
|
||||
<input type="text" class="pd-input" id="pdSearch" placeholder="Cari nama KK..." oninput="applyFilter()">
|
||||
<div class="pd-filter-chips">
|
||||
<select class="pd-chip" id="pdKat" onchange="applyFilter()">
|
||||
<option value="">Semua Kategori</option>
|
||||
<option value="Sangat Miskin">Sangat Miskin</option>
|
||||
<option value="Miskin">Miskin</option>
|
||||
<option value="Hampir Miskin">Hampir Miskin</option>
|
||||
</select>
|
||||
<select class="pd-chip" id="pdStatus" onchange="applyFilter()">
|
||||
<option value="">Semua Status</option>
|
||||
<option value="Belum Ditangani">Belum Ditangani</option>
|
||||
<option value="Dalam Proses">Dalam Proses</option>
|
||||
<option value="Sudah Ditangani">Sudah Ditangani</option>
|
||||
</select>
|
||||
<select class="pd-chip" id="pdBlank" onchange="applyFilter()">
|
||||
<option value="">Semua Spot</option>
|
||||
<option value="1">Blank Spot</option>
|
||||
<option value="0">Terjangkau</option>
|
||||
</select>
|
||||
<select class="pd-chip" id="pdVerif" onchange="applyFilter()">
|
||||
<option value="">Semua Verifikasi</option>
|
||||
<option value="Pending">Pending</option>
|
||||
<option value="Terverifikasi">Terverifikasi</option>
|
||||
<option value="Ditolak">Ditolak</option>
|
||||
</select>
|
||||
<div style="width:1px;height:20px;background:var(--card-border);margin:0 2px;align-self:center;flex-shrink:0;"></div>
|
||||
<select class="pd-chip" id="pdSort" onchange="applyFilter()">
|
||||
<option value="">Urutkan</option>
|
||||
<option value="nama_asc">Nama A→Z</option>
|
||||
<option value="nama_desc">Nama Z→A</option>
|
||||
<option value="jiwa_desc">Jiwa Terbanyak</option>
|
||||
<option value="jiwa_asc">Jiwa Tersedikit</option>
|
||||
<option value="jarak_asc">Jarak Terdekat</option>
|
||||
<option value="jarak_desc">Jarak Terjauh</option>
|
||||
</select>
|
||||
<div class="pd-chip-spacer"></div>
|
||||
<a href="map.php" class="btn-see-all" style="text-decoration:none;padding:6px 12px;border-radius:8px;font-size:12px;font-weight:600;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;"><i data-lucide="map"></i> Peta</a>
|
||||
<button class="btn-primary" onclick="openAddPd()" style="white-space:nowrap;">+ Tambah Penduduk</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="table-card-header">
|
||||
<div>
|
||||
<div class="table-card-title">Daftar Penduduk Miskin</div>
|
||||
<div class="table-card-count" id="pdCount"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px;">No</th>
|
||||
<th>Nama KK</th>
|
||||
<th>Kategori</th>
|
||||
<th style="text-align:right;">Jiwa</th>
|
||||
<th>Rumah Ibadah</th>
|
||||
<th>Status Bantuan</th>
|
||||
<th>Keterjangkauan</th>
|
||||
<th>Verifikasi</th>
|
||||
<th style="width:100px;text-align:center;">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pdTbody">
|
||||
<tr><td colspan="8" style="text-align:center;padding:28px;color:var(--text-muted);"><span class="spinner"></span> Memuat...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<span id="pd-info"></span>
|
||||
<div class="page-btns" id="pd-pages"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Riwayat Modal -->
|
||||
<div class="modal-dark" id="riwayatModal">
|
||||
<div class="modal-box">
|
||||
<h3 id="riwayatTitle">Riwayat Bantuan</h3>
|
||||
<div class="modal-scroll" id="riwayatBody" style="font-size:13px;"></div>
|
||||
<div style="margin-top:14px;flex-shrink:0;">
|
||||
<button style="padding:8px 16px;background:transparent;border:1px solid var(--card-border);border-radius:8px;font-family:var(--font);font-size:13px;font-weight:600;color:var(--text-secondary);cursor:pointer;" onclick="document.getElementById('riwayatModal').classList.remove('show')">Tutup</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const PD_PER = 15;
|
||||
let _pdAll = [], _pdFiltered = [], _pdPage = 1;
|
||||
|
||||
function esc(s) { const d = document.createElement('div'); d.appendChild(document.createTextNode(s ?? '')); return d.innerHTML; }
|
||||
function attr(s) { return String(s ?? '').replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
function fmt(n) { return Number(n || 0).toLocaleString('id-ID'); }
|
||||
|
||||
async function loadPd() {
|
||||
try {
|
||||
const r = await fetch('../api/penduduk/ambil.php?_=' + Date.now());
|
||||
const j = await r.json();
|
||||
if (j.status !== 'success') return;
|
||||
// Operator: filter to own ibadah only
|
||||
_pdAll = (window._IBADAH_ID !== null && !window._IS_ADMIN)
|
||||
? j.data.filter(p => parseInt(p.ibadah_id) === window._IBADAH_ID)
|
||||
: j.data;
|
||||
document.getElementById('pdCount').textContent = _pdAll.length + ' penduduk';
|
||||
applyFilter();
|
||||
} catch(e) {
|
||||
document.getElementById('pdTbody').innerHTML =
|
||||
'<tr><td colspan="8" style="text-align:center;padding:24px;color:#ef4444;">Gagal memuat data.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
const q = document.getElementById('pdSearch').value.toLowerCase();
|
||||
const kat = document.getElementById('pdKat').value;
|
||||
const stat = document.getElementById('pdStatus').value;
|
||||
const blank = document.getElementById('pdBlank').value;
|
||||
const verif = document.getElementById('pdVerif').value;
|
||||
const sort = document.getElementById('pdSort').value;
|
||||
_pdFiltered = _pdAll.filter(p =>
|
||||
(!q || (p.nama_kk || '').toLowerCase().includes(q)) &&
|
||||
(!kat || p.kategori === kat) &&
|
||||
(!stat || p.status_bantuan === stat) &&
|
||||
(blank === '' || String(p.is_blank_spot) === blank) &&
|
||||
(!verif || (p.status_verifikasi || 'Pending') === verif)
|
||||
);
|
||||
if (sort) {
|
||||
_pdFiltered.sort((a, b) => {
|
||||
switch (sort) {
|
||||
case 'nama_asc': return (a.nama_kk||'').localeCompare(b.nama_kk||'', 'id');
|
||||
case 'nama_desc': return (b.nama_kk||'').localeCompare(a.nama_kk||'', 'id');
|
||||
case 'jiwa_desc': return (b.jumlah_jiwa||0) - (a.jumlah_jiwa||0);
|
||||
case 'jiwa_asc': return (a.jumlah_jiwa||0) - (b.jumlah_jiwa||0);
|
||||
case 'jarak_asc': return (a.jarak_m||0) - (b.jarak_m||0);
|
||||
case 'jarak_desc': return (b.jarak_m||0) - (a.jarak_m||0);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
_pdPage = 1;
|
||||
renderPage();
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const start = (_pdPage - 1) * PD_PER;
|
||||
const slice = _pdFiltered.slice(start, start + PD_PER);
|
||||
const pages = Math.ceil(_pdFiltered.length / PD_PER) || 1;
|
||||
const tbody = document.getElementById('pdTbody');
|
||||
|
||||
const katCls = { 'Sangat Miskin': 'kat-sm', 'Miskin': 'kat-m', 'Hampir Miskin': 'kat-hm' };
|
||||
const statCls = { 'Belum Ditangani': 'belum', 'Dalam Proses': 'proses', 'Sudah Ditangani': 'sudah' };
|
||||
|
||||
if (!slice.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:24px;color:var(--text-muted);">Tidak ada data.</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = slice.map((p, i) => {
|
||||
const ibadahText = p.nama_ibadah
|
||||
? `${esc(p.nama_ibadah)}<br><small style="color:var(--text-muted);">${fmt(Math.round(p.jarak_m || 0))} m</small>`
|
||||
: '<span style="color:var(--text-muted);">—</span>';
|
||||
const nonaktifBtn = window._IS_ADMIN
|
||||
? `<button class="tbl-action-btn" title="Nonaktifkan" style="color:#d97706;" data-id="${attr(parseInt(p.id, 10) || 0)}" onclick="doNonaktifkan(this.dataset.id)"><i data-lucide="ban"></i></button>`
|
||||
: '';
|
||||
const sc = statCls[p.status_bantuan] || '';
|
||||
const sv = p.status_verifikasi || 'Pending';
|
||||
const isVerified = sv === 'Terverifikasi';
|
||||
const svCls = { Terverifikasi: 'sv-ok', Pending: 'sv-pending', Ditolak: 'sv-tolak' }[sv] || 'sv-pending';
|
||||
const svIcon = {
|
||||
Terverifikasi: '<i data-lucide="check-circle-2" style="width:12px;height:12px;vertical-align:middle;margin-right:2px;"></i>',
|
||||
Pending: '<i data-lucide="clock" style="width:12px;height:12px;vertical-align:middle;margin-right:2px;"></i>',
|
||||
Ditolak: '<i data-lucide="x-circle" style="width:12px;height:12px;vertical-align:middle;margin-right:2px;"></i>'
|
||||
}[sv] || '';
|
||||
const verifBtns = (window._IS_ADMIN && sv === 'Pending')
|
||||
? `<div style="display:flex;gap:4px;margin-top:4px;">
|
||||
<button class="btn-approve" data-id="${attr(parseInt(p.id,10)||0)}" onclick="doVerifRow(this,'approve')"><i data-lucide="check" style="width:10px;height:10px;"></i></button>
|
||||
<button class="btn-reject" data-id="${attr(parseInt(p.id,10)||0)}" onclick="doVerifRow(this,'reject')"><i data-lucide="x" style="width:10px;height:10px;"></i></button>
|
||||
</div>` : '';
|
||||
const statusControl = isVerified
|
||||
? `<select class="stat-sel ${esc(sc)}"
|
||||
onchange="updateStatus(${p.id}, this)"
|
||||
data-orig="${esc(p.status_bantuan || '')}">
|
||||
<option ${p.status_bantuan === 'Belum Ditangani' ? 'selected' : ''}>Belum Ditangani</option>
|
||||
<option ${p.status_bantuan === 'Dalam Proses' ? 'selected' : ''}>Dalam Proses</option>
|
||||
<option ${p.status_bantuan === 'Sudah Ditangani' ? 'selected' : ''}>Sudah Ditangani</option>
|
||||
</select>`
|
||||
: `<div>
|
||||
<select class="stat-sel ${esc(sc)}" disabled data-orig="${esc(p.status_bantuan || '')}">
|
||||
<option selected>${esc(p.status_bantuan || 'Belum Ditangani')}</option>
|
||||
</select>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">Menunggu verifikasi admin</div>
|
||||
</div>`;
|
||||
return `<tr>
|
||||
<td style="color:var(--text-muted);font-size:12px;">${start + i + 1}</td>
|
||||
<td style="font-weight:600;">${esc(p.nama_kk || '—')}</td>
|
||||
<td><span class="kat-badge ${esc(katCls[p.kategori] || '')}">${esc(p.kategori || '—')}</span></td>
|
||||
<td style="text-align:right;font-family:var(--font-mono);">${esc(String(p.jumlah_jiwa || 0))}</td>
|
||||
<td style="font-size:12px;">${ibadahText}</td>
|
||||
<td>
|
||||
${statusControl}
|
||||
</td>
|
||||
<td><span class="bs-badge ${p.is_blank_spot ? 'bs-blank' : 'bs-cover'}">${p.is_blank_spot ? 'Blank Spot' : 'Terjangkau'}</span></td>
|
||||
<td>
|
||||
<span class="sv-badge ${svCls}">${svIcon}${sv}</span>
|
||||
${verifBtns}
|
||||
</td>
|
||||
<td style="text-align:center;display:flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<button class="tbl-action-btn" title="Edit Data" style="color:#d97706;" data-id="${attr(parseInt(p.id,10)||0)}" onclick="openEditPd(parseInt(this.dataset.id,10))"><i data-lucide="pencil"></i></button>
|
||||
<button class="tbl-action-btn" title="Riwayat Bantuan" data-id="${attr(parseInt(p.id, 10) || 0)}" data-nama="${attr(p.nama_kk || '?')}" onclick="loadRiwayat(this.dataset.id, this.dataset.nama)"><i data-lucide="clipboard-list"></i></button>
|
||||
${isVerified ? `<button class="tbl-action-btn" title="Kelola Kebutuhan" style="color:#0d7490;" data-id="${attr(parseInt(p.id,10)||0)}" data-nama="${attr(p.nama_kk||'?')}" onclick="openKebPd(parseInt(this.dataset.id,10), this.dataset.nama)"><i data-lucide="package"></i></button>` : ''}
|
||||
${nonaktifBtn}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const end = Math.min(start + PD_PER, _pdFiltered.length);
|
||||
document.getElementById('pd-info').textContent = _pdFiltered.length > 0
|
||||
? `Menampilkan ${start + 1}–${end} dari ${_pdFiltered.length}`
|
||||
: '';
|
||||
|
||||
let btns = `<button class="page-btn" onclick="goPage(${_pdPage - 1})" ${_pdPage === 1 ? 'disabled' : ''}><i data-lucide="chevron-left"></i></button>`;
|
||||
for (let p = 1; p <= pages; p++) {
|
||||
btns += `<button class="page-btn ${p === _pdPage ? 'active' : ''}" onclick="goPage(${p})">${p}</button>`;
|
||||
}
|
||||
btns += `<button class="page-btn" onclick="goPage(${_pdPage + 1})" ${_pdPage === pages ? 'disabled' : ''}><i data-lucide="chevron-right"></i></button>`;
|
||||
document.getElementById('pd-pages').innerHTML = btns;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
|
||||
function goPage(p) {
|
||||
const pages = Math.ceil(_pdFiltered.length / PD_PER) || 1;
|
||||
if (p < 1 || p > pages) return;
|
||||
_pdPage = p;
|
||||
renderPage();
|
||||
}
|
||||
|
||||
async function updateStatus(id, sel) {
|
||||
const newStatus = sel.value;
|
||||
const orig = sel.dataset.orig;
|
||||
const item = _pdAll.find(p => String(p.id) === String(id));
|
||||
if (item && (item.status_verifikasi || 'Pending') !== 'Terverifikasi') {
|
||||
alert('Menunggu verifikasi admin');
|
||||
sel.value = orig;
|
||||
return;
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('status', newStatus);
|
||||
try {
|
||||
const r = await fetch('../api/penduduk/update_status.php', { method: 'POST', body: appendCsrf(fd) });
|
||||
const j = await r.json();
|
||||
if (j.status === 'success') {
|
||||
sel.dataset.orig = newStatus;
|
||||
const statCls = { 'Belum Ditangani': 'belum', 'Dalam Proses': 'proses', 'Sudah Ditangani': 'sudah' };
|
||||
sel.className = 'stat-sel ' + (statCls[newStatus] || '');
|
||||
// Update local data so filter stays consistent
|
||||
if (item) item.status_bantuan = newStatus;
|
||||
} else {
|
||||
alert(j.message || 'Gagal update status.');
|
||||
sel.value = orig;
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Gagal terhubung ke server.');
|
||||
sel.value = orig;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRiwayat(id, nama) {
|
||||
document.getElementById('riwayatTitle').textContent = 'Riwayat Bantuan — ' + nama;
|
||||
document.getElementById('riwayatBody').innerHTML = '<span class="spinner"></span> Memuat...';
|
||||
document.getElementById('riwayatModal').classList.add('show');
|
||||
try {
|
||||
const r = await fetch('../api/penduduk/riwayat.php?id=' + id + '&_=' + Date.now());
|
||||
const j = await r.json();
|
||||
if (!j.data || !j.data.length) {
|
||||
document.getElementById('riwayatBody').innerHTML = '<p style="color:var(--text-muted);display:inline-flex;align-items:center;gap:6px;"><i data-lucide="info"></i> Belum ada riwayat perubahan status.</p>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
document.getElementById('riwayatBody').innerHTML =
|
||||
'<table class="admin-table" style="font-size:12px;">' +
|
||||
'<thead><tr><th>Waktu</th><th>Oleh</th><th>Perubahan</th><th>Catatan</th></tr></thead>' +
|
||||
'<tbody>' + j.data.map(rv => `<tr>
|
||||
<td style="white-space:nowrap;color:var(--text-muted);">${esc(rv.created_at ? rv.created_at.slice(0, 16) : '—')}</td>
|
||||
<td>${esc(rv.user_nama || '—')}</td>
|
||||
<td style="white-space:nowrap;"><span style="color:var(--text-muted);">${esc(rv.status_lama || '—')}</span> → <strong>${esc(rv.status_baru || '—')}</strong></td>
|
||||
<td style="color:var(--text-muted);">${esc(rv.catatan || '—')}</td>
|
||||
</tr>`).join('') + '</tbody></table>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
} catch(e) {
|
||||
document.getElementById('riwayatBody').innerHTML = '<span style="color:#ef4444;display:inline-flex;align-items:center;gap:6px;"><i data-lucide="x-circle"></i> Gagal memuat riwayat.</span>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
function showConfirm({ title, message, confirmLabel = 'Ya, Lanjutkan', cancelLabel = 'Batal', danger = false }) {
|
||||
return new Promise(resolve => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'confirm-overlay';
|
||||
el.innerHTML = `<div class="confirm-box">
|
||||
<div class="confirm-title">${title}</div>
|
||||
<p class="confirm-msg">${message}</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="btn-secondary" id="_sc_cancel">${cancelLabel}</button>
|
||||
<button class="${danger ? 'btn-danger' : 'btn-primary'}" id="_sc_ok">${confirmLabel}</button>
|
||||
</div></div>`;
|
||||
document.body.appendChild(el);
|
||||
const done = val => { el.remove(); resolve(val); };
|
||||
el.querySelector('#_sc_ok').onclick = () => done(true);
|
||||
el.querySelector('#_sc_cancel').onclick = () => done(false);
|
||||
el.addEventListener('click', e => { if (e.target === el) done(false); });
|
||||
});
|
||||
}
|
||||
|
||||
async function doNonaktifkan(id) {
|
||||
const ok = await showConfirm({
|
||||
title: 'Nonaktifkan Warga',
|
||||
message: 'Tandai warga ini sebagai tidak aktif (meninggal/pindah)? Data akan diarsipkan.',
|
||||
confirmLabel: 'Ya, Nonaktifkan', danger: true
|
||||
});
|
||||
if (!ok) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
try {
|
||||
const r = await fetch('../api/penduduk/nonaktifkan.php', { method: 'POST', body: appendCsrf(fd) });
|
||||
const j = await r.json();
|
||||
if (j.status === 'success') {
|
||||
loadPd();
|
||||
} else {
|
||||
alert(j.message || 'Gagal menonaktifkan warga.');
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Gagal terhubung ke server.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Verifikasi langsung dari tabel ────────────────────────────
|
||||
async function doVerifRow(btn, aksi) {
|
||||
const id = btn.dataset.id;
|
||||
const item = _pdAll.find(p => String(p.id) === String(id));
|
||||
const label = aksi === 'approve' ? 'Terverifikasi' : 'Ditolak';
|
||||
const nama = item?.nama_kk ? `"${item.nama_kk}"` : `#${id}`;
|
||||
const ok = await showConfirm({
|
||||
title: aksi === 'approve' ? 'Konfirmasi Verifikasi' : 'Konfirmasi Tolak',
|
||||
message: `Ubah status verifikasi ${nama} menjadi <strong>${label}</strong>?`,
|
||||
confirmLabel: aksi === 'approve' ? 'Ya, Verifikasi' : 'Ya, Tolak',
|
||||
danger: aksi === 'reject'
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
btn.disabled = true;
|
||||
const fd = new FormData();
|
||||
fd.append('id', id); fd.append('aksi', aksi);
|
||||
try {
|
||||
const r = await fetch('../api/penduduk/verifikasi.php', { method:'POST', body: appendCsrf(fd) });
|
||||
const j = await r.json();
|
||||
if (j.status === 'success') {
|
||||
if (item) item.status_verifikasi = j.status_verifikasi;
|
||||
applyFilter();
|
||||
} else { alert(j.message || 'Gagal.'); btn.disabled = false; }
|
||||
} catch { alert('Gagal terhubung.'); btn.disabled = false; }
|
||||
}
|
||||
|
||||
// ── Map Picker untuk form tambah ──────────────────────────────
|
||||
const PD_DEF_LAT = <?= CENTER_LAT ?>;
|
||||
const PD_DEF_LNG = <?= CENTER_LNG ?>;
|
||||
const PD_DEF_ZOOM = <?= ZOOM_LEVEL ?>;
|
||||
let _pdMap = null, _pdMarker = null, _pdGeoTimer = null;
|
||||
|
||||
function initPdMap() {
|
||||
if (_pdMap) { _pdMap.remove(); _pdMap = null; _pdMarker = null; }
|
||||
_pdMap = L.map('pdMapPicker').setView([PD_DEF_LAT, PD_DEF_LNG], PD_DEF_ZOOM);
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom:19, attribution:'© OpenStreetMap'
|
||||
}).addTo(_pdMap);
|
||||
_pdMap.on('click', e => { pdPlaceMarker(e.latlng.lat, e.latlng.lng); pdFillCoords(e.latlng.lat, e.latlng.lng); pdGeocode(e.latlng.lat, e.latlng.lng); });
|
||||
setTimeout(() => _pdMap && _pdMap.invalidateSize(), 80);
|
||||
}
|
||||
|
||||
function pdPlaceMarker(lat, lng) {
|
||||
if (_pdMarker) { _pdMarker.setLatLng([lat, lng]); }
|
||||
else {
|
||||
_pdMarker = L.marker([lat, lng], { draggable: true }).addTo(_pdMap);
|
||||
_pdMarker.on('dragend', e => {
|
||||
const ll = e.target.getLatLng();
|
||||
pdFillCoords(ll.lat, ll.lng);
|
||||
pdGeocode(ll.lat, ll.lng);
|
||||
});
|
||||
}
|
||||
_pdMap.panTo([lat, lng]);
|
||||
}
|
||||
|
||||
function pdFillCoords(lat, lng) {
|
||||
document.getElementById('pdLat').value = lat.toFixed(8);
|
||||
document.getElementById('pdLng').value = lng.toFixed(8);
|
||||
}
|
||||
|
||||
function pdCoordsManual() {
|
||||
const lat = parseFloat(document.getElementById('pdLat').value);
|
||||
const lng = parseFloat(document.getElementById('pdLng').value);
|
||||
if (!isNaN(lat) && !isNaN(lng) && _pdMap) { pdPlaceMarker(lat, lng); pdGeocode(lat, lng); }
|
||||
}
|
||||
|
||||
async function pdGeocode(lat, lng) {
|
||||
clearTimeout(_pdGeoTimer);
|
||||
document.getElementById('pdGeoStatus').innerHTML = '<span class="spinner"></span> Mencari alamat...';
|
||||
_pdGeoTimer = setTimeout(async () => {
|
||||
try {
|
||||
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`, { headers:{'Accept-Language':'id'} });
|
||||
const j = await r.json();
|
||||
const alamatEl = document.getElementById('pdAlamat');
|
||||
if (j && j.display_name && !alamatEl.dataset.userEdited) {
|
||||
alamatEl.value = j.display_name;
|
||||
}
|
||||
document.getElementById('pdGeoStatus').innerHTML = j.display_name
|
||||
? '<i data-lucide="check" style="color:var(--text-success);margin-right:4px;"></i> ' + esc(j.address?.road || j.address?.suburb || j.display_name.split(',')[0])
|
||||
: '';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
} catch { document.getElementById('pdGeoStatus').textContent = ''; }
|
||||
}, 600);
|
||||
}
|
||||
|
||||
function openAddPd() {
|
||||
['pdNama','pdNik','pdAlamat','pdCatatan','pdLat','pdLng'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
el.value = '';
|
||||
delete el.dataset.userEdited;
|
||||
});
|
||||
document.getElementById('pdJiwa').value = 1;
|
||||
document.getElementById('pdKategori').value = 'Miskin';
|
||||
document.getElementById('pdMsg').textContent = '';
|
||||
document.getElementById('pdGeoStatus').textContent = '';
|
||||
document.getElementById('pdSaveBtn').disabled = false;
|
||||
document.getElementById('pdSaveBtn').textContent = 'Simpan';
|
||||
document.getElementById('pdModal').classList.add('show');
|
||||
initPdMap();
|
||||
}
|
||||
|
||||
function closeAddPd() {
|
||||
document.getElementById('pdModal').classList.remove('show');
|
||||
clearTimeout(_pdGeoTimer);
|
||||
if (_pdMap) { _pdMap.remove(); _pdMap = null; _pdMarker = null; }
|
||||
}
|
||||
|
||||
async function savePd() {
|
||||
const btn = document.getElementById('pdSaveBtn');
|
||||
const msg = document.getElementById('pdMsg');
|
||||
const lat = document.getElementById('pdLat').value.trim();
|
||||
const lng = document.getElementById('pdLng').value.trim();
|
||||
const nama = document.getElementById('pdNama').value.trim();
|
||||
|
||||
if (!nama) { msg.textContent = 'Nama Kepala Keluarga wajib diisi.'; return; }
|
||||
if (!lat || !lng) { msg.textContent = 'Klik peta untuk menentukan lokasi.'; return; }
|
||||
|
||||
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Menyimpan...'; msg.textContent = '';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('nama_kk', nama);
|
||||
fd.append('nik', document.getElementById('pdNik').value.trim());
|
||||
fd.append('jumlah_jiwa',document.getElementById('pdJiwa').value);
|
||||
fd.append('kategori', document.getElementById('pdKategori').value);
|
||||
fd.append('alamat', document.getElementById('pdAlamat').value.trim());
|
||||
fd.append('catatan', document.getElementById('pdCatatan').value.trim());
|
||||
fd.append('lat', lat); fd.append('lng', lng);
|
||||
|
||||
try {
|
||||
const r = await fetch('../api/penduduk/simpan.php', { method:'POST', body: appendCsrf(fd) });
|
||||
const j = await r.json();
|
||||
btn.disabled = false; btn.innerHTML = 'Simpan';
|
||||
if (j.status === 'success') {
|
||||
closeAddPd();
|
||||
loadPd();
|
||||
} else { msg.textContent = j.message || 'Terjadi kesalahan.'; }
|
||||
} catch { btn.disabled = false; btn.innerHTML = 'Simpan'; msg.textContent = 'Gagal terhubung ke server.'; }
|
||||
}
|
||||
|
||||
// ── Edit data penduduk ────────────────────────────────────────
|
||||
function openEditPd(id) {
|
||||
const p = _pdAll.find(x => String(x.id) === String(id));
|
||||
if (!p) return;
|
||||
document.getElementById('editPdId').value = p.id;
|
||||
document.getElementById('editPdNama').value = p.nama_kk || '';
|
||||
document.getElementById('editPdNik').value = p.nik || '';
|
||||
document.getElementById('editPdJiwa').value = p.jumlah_jiwa || 1;
|
||||
document.getElementById('editPdKategori').value = p.kategori || 'Miskin';
|
||||
document.getElementById('editPdAlamat').value = p.alamat || '';
|
||||
document.getElementById('editPdCatatan').value = p.catatan || '';
|
||||
document.getElementById('editPdMsg').textContent = '';
|
||||
document.getElementById('editPdSaveBtn').disabled = false;
|
||||
document.getElementById('editPdSaveBtn').textContent = 'Simpan Perubahan';
|
||||
document.getElementById('editPdModal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeEditPd() {
|
||||
document.getElementById('editPdModal').classList.remove('show');
|
||||
}
|
||||
|
||||
async function saveEditPd() {
|
||||
const btn = document.getElementById('editPdSaveBtn');
|
||||
const msg = document.getElementById('editPdMsg');
|
||||
const nama = document.getElementById('editPdNama').value.trim();
|
||||
if (!nama) { msg.textContent = 'Nama Kepala Keluarga wajib diisi.'; return; }
|
||||
|
||||
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Menyimpan...'; msg.textContent = '';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', document.getElementById('editPdId').value);
|
||||
fd.append('nama_kk', nama);
|
||||
fd.append('nik', document.getElementById('editPdNik').value.trim());
|
||||
fd.append('jumlah_jiwa', document.getElementById('editPdJiwa').value);
|
||||
fd.append('kategori', document.getElementById('editPdKategori').value);
|
||||
fd.append('alamat', document.getElementById('editPdAlamat').value.trim());
|
||||
fd.append('catatan', document.getElementById('editPdCatatan').value.trim());
|
||||
|
||||
try {
|
||||
const r = await fetch('../api/penduduk/update.php', { method: 'POST', body: appendCsrf(fd) });
|
||||
const j = await r.json();
|
||||
btn.disabled = false; btn.textContent = 'Simpan Perubahan';
|
||||
if (j.status === 'success') {
|
||||
closeEditPd();
|
||||
loadPd();
|
||||
} else {
|
||||
msg.textContent = j.message || 'Terjadi kesalahan.';
|
||||
}
|
||||
} catch {
|
||||
btn.disabled = false; btn.textContent = 'Simpan Perubahan';
|
||||
msg.textContent = 'Gagal terhubung ke server.';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Kelola Kebutuhan dari tabel ───────────────────────────────
|
||||
const KEB_KAT_LIST = ['Sembako','Biaya Sekolah','Biaya Kesehatan','Modal Usaha','Renovasi Rumah','Perlengkapan Rumah','Pakaian','Lainnya'];
|
||||
|
||||
async function openKebPd(id, nama) {
|
||||
document.getElementById('kebPdTitle').textContent = 'Kebutuhan — ' + nama;
|
||||
document.getElementById('kebPdBody').innerHTML = '<span class="spinner"></span> Memuat...';
|
||||
document.getElementById('kebPdModal').classList.add('show');
|
||||
await renderKebPd(id);
|
||||
}
|
||||
|
||||
async function renderKebPd(id) {
|
||||
const clsMap = {'Belum Terpenuhi':'belum','Dalam Proses':'proses','Terpenuhi':'terpenuhi'};
|
||||
try {
|
||||
const r = await fetch('../api/kebutuhan/ambil.php?penduduk_id='+id+'&_='+Date.now());
|
||||
const j = await r.json();
|
||||
|
||||
const listHtml = (!j.data || !j.data.length)
|
||||
? '<p style="color:var(--text-muted);margin-bottom:4px;">Belum ada kebutuhan tercatat.</p>'
|
||||
: j.data.map(k => `
|
||||
<div style="padding:10px 0;border-bottom:1px solid var(--card-border);">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;">
|
||||
<span style="font-weight:600;">${esc(k.kategori)}</span>
|
||||
<select class="stat-sel ${clsMap[k.status]||''}"
|
||||
data-id="${attr(parseInt(k.id,10)||0)}" data-orig="${attr(k.status||'')}" onchange="updateKebFromPd(this)">
|
||||
<option ${k.status==='Belum Terpenuhi'?'selected':''}>Belum Terpenuhi</option>
|
||||
<option ${k.status==='Dalam Proses'?'selected':''}>Dalam Proses</option>
|
||||
<option ${k.status==='Terpenuhi'?'selected':''}>Terpenuhi</option>
|
||||
</select>
|
||||
</div>
|
||||
${k.deskripsi?`<div style="font-size:12px;color:var(--text-muted);">${esc(k.deskripsi)}</div>`:''}
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:3px;">Oleh ${esc(k.created_by_nama||'—')} · ${esc((k.created_at||'').slice(0,10))}</div>
|
||||
</div>`).join('');
|
||||
|
||||
const addFormHtml = `
|
||||
<div style="margin-top:14px;padding-top:14px;border-top:2px dashed var(--card-border);">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;">Tambah Kebutuhan</div>
|
||||
<select id="kebPdKat_${id}" style="width:100%;padding:8px 10px;border:1px solid var(--card-border);border-radius:7px;font-family:var(--font);font-size:13px;background:#fafaf9;color:var(--text-primary);outline:none;margin-bottom:8px;">
|
||||
${KEB_KAT_LIST.map(k=>`<option value="${esc(k)}">${esc(k)}</option>`).join('')}
|
||||
</select>
|
||||
<input type="text" id="kebPdDesc_${id}" placeholder="Deskripsi (opsional, maks 300 karakter)" maxlength="300"
|
||||
style="width:100%;padding:8px 10px;border:1px solid var(--card-border);border-radius:7px;font-family:var(--font);font-size:13px;background:#fafaf9;color:var(--text-primary);outline:none;margin-bottom:8px;box-sizing:border-box;">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<button onclick="addKebFromPd(${id})" id="kebPdAddBtn_${id}" class="btn-primary" style="padding:7px 18px;font-size:13px;">Simpan</button>
|
||||
<span id="kebPdAddMsg_${id}" style="font-size:12px;color:#ef4444;"></span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('kebPdBody').innerHTML = listHtml + addFormHtml;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
} catch(e) {
|
||||
document.getElementById('kebPdBody').innerHTML = '<span style="color:#ef4444;">Gagal memuat kebutuhan.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function updateKebFromPd(sel) {
|
||||
const id = sel.dataset.id;
|
||||
const orig = sel.dataset.orig;
|
||||
const catatan = prompt('Catatan perubahan kebutuhan (opsional):', '');
|
||||
if (catatan === null) { sel.value = orig; return; }
|
||||
const fd = new FormData();
|
||||
fd.append('id', id); fd.append('status', sel.value); fd.append('catatan', catatan);
|
||||
try {
|
||||
const r = await fetch('../api/kebutuhan/update_status.php', {method:'POST', body: appendCsrf(fd)});
|
||||
const j = await r.json();
|
||||
if (j.status === 'success') {
|
||||
sel.dataset.orig = sel.value;
|
||||
const clsMap = {'Belum Terpenuhi':'belum','Dalam Proses':'proses','Terpenuhi':'terpenuhi'};
|
||||
sel.className = 'stat-sel ' + (clsMap[sel.value]||'');
|
||||
} else { alert(j.message||'Gagal update.'); sel.value = orig; }
|
||||
} catch(e) { alert('Gagal terhubung ke server.'); sel.value = orig; }
|
||||
}
|
||||
|
||||
async function addKebFromPd(id) {
|
||||
const kat = document.getElementById(`kebPdKat_${id}`).value;
|
||||
const desc = document.getElementById(`kebPdDesc_${id}`).value.trim();
|
||||
const btn = document.getElementById(`kebPdAddBtn_${id}`);
|
||||
const msg = document.getElementById(`kebPdAddMsg_${id}`);
|
||||
btn.disabled = true; btn.textContent = 'Menyimpan...'; msg.textContent = '';
|
||||
const fd = new FormData();
|
||||
fd.append('penduduk_id', id); fd.append('kategori', kat); fd.append('deskripsi', desc);
|
||||
try {
|
||||
const r = await fetch('../api/kebutuhan/simpan.php', {method:'POST', body: appendCsrf(fd)});
|
||||
const j = await r.json();
|
||||
if (j.status === 'success') {
|
||||
await renderKebPd(id);
|
||||
} else {
|
||||
msg.textContent = j.message || 'Gagal menyimpan.';
|
||||
btn.disabled = false; btn.textContent = 'Simpan';
|
||||
}
|
||||
} catch(e) {
|
||||
msg.textContent = 'Gagal terhubung ke server.';
|
||||
btn.disabled = false; btn.textContent = 'Simpan';
|
||||
}
|
||||
}
|
||||
|
||||
loadPd();
|
||||
</script>
|
||||
|
||||
<!-- Modal Edit Penduduk -->
|
||||
<div class="modal-dark" id="editPdModal">
|
||||
<div class="modal-box">
|
||||
<h3>Edit Data Penduduk</h3>
|
||||
<div class="modal-scroll">
|
||||
<input type="hidden" id="editPdId">
|
||||
<div class="mfg">
|
||||
<label>Nama Kepala Keluarga <span style="color:#ef4444;">*</span></label>
|
||||
<input type="text" id="editPdNama" placeholder="Nama lengkap KK">
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||
<div class="mfg">
|
||||
<label>NIK <small style="text-transform:none;">(opsional)</small></label>
|
||||
<input type="text" id="editPdNik" placeholder="16 digit" maxlength="16">
|
||||
</div>
|
||||
<div class="mfg">
|
||||
<label>Jumlah Jiwa <small style="text-transform:none;">(orang)</small></label>
|
||||
<input type="number" id="editPdJiwa" value="1" min="1" max="30">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mfg">
|
||||
<label>Kategori Kemiskinan</label>
|
||||
<select id="editPdKategori">
|
||||
<option value="Sangat Miskin">Sangat Miskin</option>
|
||||
<option value="Miskin" selected>Miskin</option>
|
||||
<option value="Hampir Miskin">Hampir Miskin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mfg">
|
||||
<label>Alamat</label>
|
||||
<input type="text" id="editPdAlamat" placeholder="Alamat lengkap">
|
||||
</div>
|
||||
<div class="mfg">
|
||||
<label>Catatan Kondisi <small style="text-transform:none;">(opsional)</small></label>
|
||||
<textarea id="editPdCatatan" placeholder="Kondisi rumah, kebutuhan khusus, dll..."></textarea>
|
||||
</div>
|
||||
<div class="msg-err" id="editPdMsg"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" onclick="closeEditPd()">Batal</button>
|
||||
<button class="btn-primary" id="editPdSaveBtn" onclick="saveEditPd()">Simpan Perubahan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Kelola Kebutuhan -->
|
||||
<div class="modal-dark" id="kebPdModal">
|
||||
<div class="modal-box">
|
||||
<h3 id="kebPdTitle">Kebutuhan</h3>
|
||||
<div class="modal-scroll" id="kebPdBody" style="font-size:13px;"></div>
|
||||
<div style="margin-top:14px;flex-shrink:0;display:flex;gap:8px;">
|
||||
<button style="padding:8px 16px;background:transparent;border:1px solid var(--card-border);border-radius:8px;font-family:var(--font);font-size:13px;font-weight:600;color:var(--text-secondary);cursor:pointer;" onclick="document.getElementById('kebPdModal').classList.remove('show')">Tutup</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Tambah Penduduk -->
|
||||
<div class="modal-dark" id="pdModal">
|
||||
<div class="modal-box">
|
||||
<h3>Tambah Penduduk Miskin</h3>
|
||||
<div class="modal-scroll">
|
||||
|
||||
<!-- Peta -->
|
||||
<div class="mfg">
|
||||
<label>Lokasi di Peta <span style="font-weight:400;color:var(--text-muted);text-transform:none;letter-spacing:0;">— klik untuk set titik</span></label>
|
||||
<div class="map-picker-wrap"><div id="pdMapPicker"></div></div>
|
||||
<div class="map-picker-hint" style="display:flex;align-items:center;gap:12px;margin-top:6px;">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;"><i data-lucide="mouse-pointer"></i> Klik peta</span> · <span style="display:inline-flex;align-items:center;gap:4px;"><i data-lucide="map-pin"></i> Drag marker</span> · <span style="display:inline-flex;align-items:center;gap:4px;"><i data-lucide="zoom-in"></i> Scroll zoom</span>
|
||||
</div>
|
||||
<div class="map-geocoding" id="pdGeoStatus"></div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||
<div class="mfg">
|
||||
<label>Latitude</label>
|
||||
<input type="number" id="pdLat" step="any" placeholder="-0.0557" oninput="pdCoordsManual()">
|
||||
</div>
|
||||
<div class="mfg">
|
||||
<label>Longitude</label>
|
||||
<input type="number" id="pdLng" step="any" placeholder="109.3487" oninput="pdCoordsManual()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Identitas -->
|
||||
<div class="mfg">
|
||||
<label>Nama Kepala Keluarga <span style="color:#ef4444;">*</span></label>
|
||||
<input type="text" id="pdNama" placeholder="Nama lengkap KK">
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||
<div class="mfg">
|
||||
<label>NIK <small style="text-transform:none;">(opsional)</small></label>
|
||||
<input type="text" id="pdNik" placeholder="16 digit" maxlength="16">
|
||||
</div>
|
||||
<div class="mfg">
|
||||
<label>Jumlah Jiwa</label>
|
||||
<input type="number" id="pdJiwa" value="1" min="1" max="30">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mfg">
|
||||
<label>Kategori Kemiskinan</label>
|
||||
<select id="pdKategori">
|
||||
<option value="Sangat Miskin">Sangat Miskin</option>
|
||||
<option value="Miskin" selected>Miskin</option>
|
||||
<option value="Hampir Miskin">Hampir Miskin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mfg">
|
||||
<label>Alamat</label>
|
||||
<input type="text" id="pdAlamat" placeholder="Alamat lengkap"
|
||||
oninput="this.dataset.userEdited='1'">
|
||||
</div>
|
||||
<div class="mfg">
|
||||
<label>Catatan Kondisi <small style="text-transform:none;">(opsional)</small></label>
|
||||
<textarea id="pdCatatan" placeholder="Kondisi rumah, kebutuhan khusus, dll..."></textarea>
|
||||
</div>
|
||||
|
||||
<?php if ($is_admin): ?>
|
||||
<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:10px 14px;font-size:12px;color:#16a34a;margin-bottom:4px;display:inline-flex;align-items:center;gap:6px;width:100%;box-sizing:border-box;">
|
||||
<i data-lucide="check-circle" style="color:#16a34a;flex-shrink:0;"></i> <span>Sebagai Admin, data yang disimpan langsung berstatus <strong>Terverifikasi</strong>.</span>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:10px 14px;font-size:12px;color:#d97706;margin-bottom:4px;display:inline-flex;align-items:center;gap:6px;width:100%;box-sizing:border-box;">
|
||||
<i data-lucide="clock" style="color:#d97706;flex-shrink:0;"></i> <span>Data akan berstatus <strong>Pending</strong> hingga diverifikasi oleh Administrator.</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="msg-err" id="pdMsg"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" onclick="closeAddPd()">Batal</button>
|
||||
<button class="btn-primary" id="pdSaveBtn" onclick="savePd()">Simpan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include '../includes/page-end.php'; ?>
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
require_once '../config.php';
|
||||
require_once '../auth/helper.php';
|
||||
require_once '../koneksi.php';
|
||||
if (!is_logged_in() || !has_role('operator')) { header('Location: ../auth/login.php'); exit; }
|
||||
require_password_changed('../auth/change_password.php');
|
||||
$ibadah_id = get_ibadah_id();
|
||||
$page_title = 'Status Bantuan';
|
||||
$page_subtitle = 'Monitor dan update status penyaluran bantuan per warga';
|
||||
$active_nav = 'status';
|
||||
include '../includes/page-start.php';
|
||||
?>
|
||||
<script>
|
||||
window._IBADAH_ID = <?= $ibadah_id ? (int)$ibadah_id : 'null' ?>;
|
||||
window._IS_ADMIN = <?= has_role('administrator') ? 'true' : 'false' ?>;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.sb-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:24px;}
|
||||
@media(max-width:600px){.sb-cards{grid-template-columns:1fr;}}
|
||||
.stat-sel-sb{padding:5px 10px;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid var(--card-border);background:#fafaf9;font-family:var(--font);color:var(--text-secondary);}
|
||||
.stat-sel-sb.belum{background:#f5f0eb;color:#7a7067;border-color:#c5bdb5;}
|
||||
.stat-sel-sb.proses{background:#fffbeb;color:#d97706;border-color:#fcd34d;}
|
||||
.stat-sel-sb.sudah{background:#f0fdf4;color:#16a34a;border-color:#86efac;}
|
||||
.text-danger{color:#dc2626;}
|
||||
.text-success{color:#16a34a;}
|
||||
.sb-filter-bar{display:flex;flex-direction:column;gap:8px;margin-bottom:16px;}
|
||||
.sb-input{width:100%;box-sizing:border-box;padding:8px 12px;border:1px solid var(--card-border);border-radius:8px;font-family:var(--font);font-size:13px;background:#fafaf9;color:var(--text-primary);outline:none;}
|
||||
.sb-input:focus{border-color:#0d7490;}
|
||||
.sb-filter-chips{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}
|
||||
.sb-chip{width:auto;height:auto;padding:7px 11px;border:1px solid var(--card-border);border-radius:8px;font-family:var(--font);font-size:13px;background:#fafaf9;color:var(--text-primary);cursor:pointer;white-space:nowrap;}
|
||||
.sb-chip:focus{outline:none;border-color:#0d7490;}
|
||||
</style>
|
||||
|
||||
<div class="sb-cards">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top"><div class="stat-icon amber"><i data-lucide="clipboard-list"></i></div><div class="stat-label">Belum Ditangani</div></div>
|
||||
<div class="stat-value text-danger" id="sb-belum">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top"><div class="stat-icon blue"><i data-lucide="clock"></i></div><div class="stat-label">Dalam Proses</div></div>
|
||||
<div class="stat-value" style="color:#d97706;" id="sb-proses">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-top"><div class="stat-icon green"><i data-lucide="check-circle-2"></i></div><div class="stat-label">Sudah Ditangani</div></div>
|
||||
<div class="stat-value text-success" id="sb-sudah">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sb-filter-bar">
|
||||
<input type="text" class="sb-input" id="sbSearch" placeholder="Cari nama KK..." oninput="filterSb()">
|
||||
<div class="sb-filter-chips">
|
||||
<select class="sb-chip" id="sbFilter" onchange="filterSb()">
|
||||
<option value="">Semua Status</option>
|
||||
<option value="Belum Ditangani">Belum Ditangani</option>
|
||||
<option value="Dalam Proses">Dalam Proses</option>
|
||||
<option value="Sudah Ditangani">Sudah Ditangani</option>
|
||||
</select>
|
||||
<div style="width:1px;height:20px;background:var(--card-border);margin:0 2px;align-self:center;flex-shrink:0;"></div>
|
||||
<select class="sb-chip" id="sbSort" onchange="filterSb()">
|
||||
<option value="">Urutkan</option>
|
||||
<option value="nama_asc">Nama A→Z</option>
|
||||
<option value="nama_desc">Nama Z→A</option>
|
||||
<option value="jiwa_desc">Jiwa Terbanyak</option>
|
||||
<option value="jiwa_asc">Jiwa Tersedikit</option>
|
||||
<option value="updated_desc">Diupdate Terbaru</option>
|
||||
<option value="updated_asc">Diupdate Terlama</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="table-card-header">
|
||||
<div class="table-card-title">Daftar Penyaluran Bantuan</div>
|
||||
<span class="table-card-count" id="sb-count"></span>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:44px;">No</th>
|
||||
<th>Nama KK</th>
|
||||
<th style="text-align:right;">Jiwa</th>
|
||||
<th>Ibadah</th>
|
||||
<th>Status</th>
|
||||
<th>Terakhir Diupdate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sbTbody">
|
||||
<tr><td colspan="6" style="text-align:center;padding:28px;color:var(--text-muted);"><span class="spinner"></span> Memuat...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _sbAll = [];
|
||||
function esc(s){const d=document.createElement('div');d.appendChild(document.createTextNode(s??''));return d.innerHTML;}
|
||||
function attr(s){return String(s??'').replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');}
|
||||
function fmt(n){return Number(n||0).toLocaleString('id-ID');}
|
||||
|
||||
async function loadSb() {
|
||||
try {
|
||||
const r = await fetch('../api/penduduk/ambil.php?_='+Date.now());
|
||||
const j = await r.json();
|
||||
if (j.status !== 'success') {
|
||||
document.getElementById('sbTbody').innerHTML =
|
||||
'<tr><td colspan="6" style="text-align:center;padding:24px;color:#ef4444;">'+esc(j.message || 'Gagal memuat data.')+'</td></tr>';
|
||||
return;
|
||||
}
|
||||
const verifiedRows = j.data.filter(p => p.status_verifikasi === 'Terverifikasi');
|
||||
_sbAll = (window._IBADAH_ID && !window._IS_ADMIN)
|
||||
? verifiedRows.filter(p => parseInt(p.ibadah_id) === window._IBADAH_ID)
|
||||
: verifiedRows;
|
||||
updateCards();
|
||||
filterSb();
|
||||
} catch(e) {
|
||||
document.getElementById('sbTbody').innerHTML =
|
||||
'<tr><td colspan="6" style="text-align:center;padding:24px;color:#ef4444;">Gagal memuat data.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function updateCards() {
|
||||
const b = _sbAll.filter(p => p.status_bantuan === 'Belum Ditangani').length;
|
||||
const pr = _sbAll.filter(p => p.status_bantuan === 'Dalam Proses').length;
|
||||
const s = _sbAll.filter(p => p.status_bantuan === 'Sudah Ditangani').length;
|
||||
document.getElementById('sb-belum').textContent = fmt(b);
|
||||
document.getElementById('sb-proses').textContent = fmt(pr);
|
||||
document.getElementById('sb-sudah').textContent = fmt(s);
|
||||
}
|
||||
|
||||
function filterSb() {
|
||||
const q = document.getElementById('sbSearch').value.toLowerCase();
|
||||
const f = document.getElementById('sbFilter').value;
|
||||
const sort = document.getElementById('sbSort').value;
|
||||
const rows = _sbAll.filter(p =>
|
||||
(!q || (p.nama_kk||'').toLowerCase().includes(q)) &&
|
||||
(!f || p.status_bantuan === f)
|
||||
);
|
||||
if (sort) {
|
||||
rows.sort((a, b) => {
|
||||
switch (sort) {
|
||||
case 'nama_asc': return (a.nama_kk||'').localeCompare(b.nama_kk||'', 'id');
|
||||
case 'nama_desc': return (b.nama_kk||'').localeCompare(a.nama_kk||'', 'id');
|
||||
case 'jiwa_desc': return (b.jumlah_jiwa||0) - (a.jumlah_jiwa||0);
|
||||
case 'jiwa_asc': return (a.jumlah_jiwa||0) - (b.jumlah_jiwa||0);
|
||||
case 'updated_desc': return new Date(b.updated_at||b.created_at||0) - new Date(a.updated_at||a.created_at||0);
|
||||
case 'updated_asc': return new Date(a.updated_at||a.created_at||0) - new Date(b.updated_at||b.created_at||0);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
document.getElementById('sb-count').textContent = rows.length + ' warga';
|
||||
const clsMap = {'Belum Ditangani':'belum','Dalam Proses':'proses','Sudah Ditangani':'sudah'};
|
||||
document.getElementById('sbTbody').innerHTML = rows.length
|
||||
? rows.map((p, i) => `<tr>
|
||||
<td style="color:var(--text-muted);font-size:12px;">${i+1}</td>
|
||||
<td style="font-weight:600;">${esc(p.nama_kk||'—')}</td>
|
||||
<td style="text-align:right;font-family:var(--font-mono);">${esc(String(p.jumlah_jiwa||0))}</td>
|
||||
<td style="font-size:12px;color:var(--text-muted);">${esc(p.nama_ibadah||'—')}</td>
|
||||
<td>
|
||||
<select class="stat-sel-sb ${esc(clsMap[p.status_bantuan]||'')}"
|
||||
data-id="${attr(parseInt(p.id, 10) || 0)}" data-orig="${attr(p.status_bantuan||'')}"
|
||||
onchange="updateStatusSb(this)">
|
||||
<option ${p.status_bantuan==='Belum Ditangani'?'selected':''}>Belum Ditangani</option>
|
||||
<option ${p.status_bantuan==='Dalam Proses'?'selected':''}>Dalam Proses</option>
|
||||
<option ${p.status_bantuan==='Sudah Ditangani'?'selected':''}>Sudah Ditangani</option>
|
||||
</select>
|
||||
</td>
|
||||
<td style="font-size:12px;color:var(--text-muted);">${esc((p.updated_at||p.created_at||'').slice(0,10))}</td>
|
||||
</tr>`).join('')
|
||||
: '<tr><td colspan="6" style="text-align:center;padding:24px;color:var(--text-muted);">Tidak ada data.</td></tr>';
|
||||
}
|
||||
|
||||
async function updateStatusSb(sel) {
|
||||
const id = sel.dataset.id;
|
||||
const orig = sel.dataset.orig;
|
||||
const catatan = prompt('Catatan perubahan status (opsional):', '');
|
||||
if (catatan === null) {
|
||||
sel.value = orig;
|
||||
return;
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append('id', id); fd.append('status', sel.value); fd.append('catatan', catatan);
|
||||
try {
|
||||
const r = await fetch('../api/penduduk/update_status.php', {method:'POST', body: appendCsrf(fd)});
|
||||
const j = await r.json();
|
||||
if (j.status === 'success') {
|
||||
sel.dataset.orig = sel.value;
|
||||
const clsMap = {'Belum Ditangani':'belum','Dalam Proses':'proses','Sudah Ditangani':'sudah'};
|
||||
sel.className = 'stat-sel-sb ' + (clsMap[sel.value]||'');
|
||||
const item = _sbAll.find(p => String(p.id) === String(id));
|
||||
if (item) item.status_bantuan = sel.value;
|
||||
updateCards();
|
||||
} else {
|
||||
alert(j.message||'Gagal update status.');
|
||||
sel.value = orig;
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Gagal terhubung ke server.');
|
||||
sel.value = orig;
|
||||
}
|
||||
}
|
||||
|
||||
loadSb();
|
||||
</script>
|
||||
|
||||
<?php include '../includes/page-end.php'; ?>
|
||||
@@ -0,0 +1,318 @@
|
||||
<?php
|
||||
require_once '../config.php';
|
||||
require_once '../auth/helper.php';
|
||||
if (!is_logged_in() || !has_role('administrator')) {
|
||||
header('Location: ../auth/login.php'); exit;
|
||||
}
|
||||
require_password_changed('../auth/change_password.php');
|
||||
require_once '../koneksi.php';
|
||||
|
||||
$ibadah_list = [];
|
||||
$res = $conn->query("SELECT id, nama, jenis FROM rumah_ibadah WHERE deleted_at IS NULL ORDER BY nama");
|
||||
while ($row = $res->fetch_assoc()) $ibadah_list[] = $row;
|
||||
|
||||
$page_title = 'Pengguna & Akun';
|
||||
$page_subtitle = 'Kelola akun pengguna sistem';
|
||||
$active_nav = 'users';
|
||||
include '../includes/page-start.php';
|
||||
?>
|
||||
|
||||
<style>
|
||||
.users-content {
|
||||
--c-bg:#f5f0eb; --c-surface:#fafaf9; --c-surface2:#ede8e2;
|
||||
--c-border:#ddd8d2; --c-text:#201515; --c-muted:#7a7067;
|
||||
--c-accent:#0d7490; --c-accent-h:#0a5f7a; --c-danger:#ef4444;
|
||||
--c-info:#3b82f6; --c-warn:#f59e0b;
|
||||
--font-body:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
color: var(--c-text);
|
||||
}
|
||||
.users-content .section-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; }
|
||||
.users-content .section-title { font-size:15px; font-weight:600; letter-spacing:-0.3px; color:var(--c-text); }
|
||||
.users-content .btn { padding:8px 16px; border-radius:8px; font-family:var(--font-body); font-size:13px; font-weight:600; cursor:pointer; border:none; transition:background .15s; }
|
||||
.users-content .btn-primary { background:#0d7490; color:#fff; }
|
||||
.users-content .btn-primary:hover { background:#0a5f7a; }
|
||||
.users-content .btn-sm { padding:5px 10px; font-size:12px; border-radius:6px; }
|
||||
.users-content .btn-danger { background:#fef2f2; color:#ef4444; border:1px solid rgba(239,68,68,.3); }
|
||||
.users-content .btn-danger:hover { background:#fee2e2; }
|
||||
.users-content .btn-secondary { background:#ede8e2; color:#3d3530; border:1px solid #ddd8d2; }
|
||||
.users-content .btn-secondary:hover { background:#ddd8d2; }
|
||||
.users-content table { width:100%; border-collapse:collapse; background:var(--c-surface); border:1px solid var(--c-border); border-radius:10px; overflow:hidden; }
|
||||
.users-content th, .users-content td { padding:12px 14px; text-align:left; border-bottom:1px solid var(--c-border); font-size:13px; color:var(--c-text); }
|
||||
.users-content th { font-size:11px; font-weight:500; color:var(--c-muted); text-transform:uppercase; letter-spacing:.5px; background:var(--c-surface2); }
|
||||
.users-content tr:last-child td { border-bottom:none; }
|
||||
.users-content tr:hover td { background:#f5f0eb; }
|
||||
.users-content .badge { display:inline-block; padding:3px 10px; border-radius:9999px; font-size:11px; font-weight:500; }
|
||||
.users-content .badge-admin { background:#f0fdf4; color:#16a34a; }
|
||||
.users-content .badge-op { background:#eff6ff; color:#2563eb; }
|
||||
.users-content .badge-view { background:#ede8e2; color:#7a7067; }
|
||||
.users-content .badge-on { background:#f0fdf4; color:#16a34a; }
|
||||
.users-content .badge-off { background:#fef2f2; color:#ef4444; }
|
||||
.users-content .modal-overlay { position:fixed; inset:0; background:rgba(32,21,21,.45); backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center; padding:20px; z-index:9999; opacity:0; pointer-events:none; transition:opacity .2s; }
|
||||
.users-content .modal-overlay.show { opacity:1; pointer-events:auto; }
|
||||
.users-content .modal { width:min(440px,100%); background:#fafaf9; border:1px solid #ddd8d2; border-radius:12px; padding:24px; box-shadow:0 4px 16px rgba(32,21,21,.1); }
|
||||
.users-content .modal h3 { font-size:15px; font-weight:600; letter-spacing:-0.3px; margin-bottom:18px; color:var(--c-text); }
|
||||
.users-content .form-group { margin-bottom:14px; }
|
||||
.users-content .form-group label { display:block; font-size:13px; font-weight:500; color:#3d3530; margin-bottom:5px; }
|
||||
.users-content .form-group input,
|
||||
.users-content .form-group select { width:100%; padding:9px 12px; background:#fafaf9; border:1px solid #ddd8d2; border-radius:8px; color:#201515; font-family:var(--font-body); font-size:13px; outline:none; transition:border-color .15s; }
|
||||
.users-content .form-group input:focus,
|
||||
.users-content .form-group select:focus { border-color:#0d7490; }
|
||||
.users-content .modal-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:18px; }
|
||||
.users-content .msg-box { margin-top:10px; font-size:12px; min-height:16px; }
|
||||
.users-content .msg-box.error { color:#ef4444; }
|
||||
.users-content .msg-box.success { color:#16a34a; }
|
||||
.users-content .temp-pw-box { background:#ede8e2; border:1px solid #ddd8d2; border-radius:8px; padding:12px 16px; text-align:center; font-size:20px; font-weight:600; color:#201515; letter-spacing:4px; margin:12px 0; }
|
||||
</style>
|
||||
|
||||
<div class="users-content">
|
||||
|
||||
<div class="section-header">
|
||||
<div class="section-title">Daftar Akun Pengguna</div>
|
||||
<button class="btn btn-primary" onclick="openModal('create')">+ Akun Baru</button>
|
||||
</div>
|
||||
|
||||
<table id="userTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th><th>Nama</th><th>Role</th>
|
||||
<th>Rumah Ibadah</th><th>Status</th><th>Dibuat</th><th>Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="userTbody">
|
||||
<tr><td colspan="7" style="text-align:center;color:var(--c-muted);">Memuat...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Modal Create/Edit -->
|
||||
<div class="modal-overlay" id="modalForm">
|
||||
<div class="modal">
|
||||
<h3 id="modalTitle">Akun Baru</h3>
|
||||
<input type="hidden" id="editId">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" id="fUsername" placeholder="username" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group" id="fgPassword">
|
||||
<label>Password Awal</label>
|
||||
<input type="text" id="fPassword" placeholder="Min 8 karakter, huruf+angka" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nama Lengkap</label>
|
||||
<input type="text" id="fNama" placeholder="Nama lengkap">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Role</label>
|
||||
<select id="fRole" onchange="toggleIbadahField()">
|
||||
<option value="administrator">Administrator</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="viewer" disabled>Viewer (legacy)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="fgIbadah" style="display:none;">
|
||||
<label>Rumah Ibadah</label>
|
||||
<select id="fIbadah">
|
||||
<option value="">-- Pilih Rumah Ibadah --</option>
|
||||
<?php foreach ($ibadah_list as $ib): ?>
|
||||
<option value="<?= $ib['id'] ?>"><?= htmlspecialchars("[{$ib['jenis']}] {$ib['nama']}") ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="msg-box" id="modalMsg"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Batal</button>
|
||||
<button class="btn btn-primary" id="btnModalSave" onclick="saveUser()">Simpan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Reset Password -->
|
||||
<div class="modal-overlay" id="modalReset">
|
||||
<div class="modal">
|
||||
<h3>Reset Password</h3>
|
||||
<p style="font-size:13px;color:var(--c-muted);line-height:1.6;">
|
||||
Password sementara untuk akun ini. Sampaikan ke pengguna secara langsung —
|
||||
mereka akan diminta mengganti saat login pertama.
|
||||
</p>
|
||||
<div class="temp-pw-box" id="tempPwDisplay">—</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" onclick="document.getElementById('modalReset').classList.remove('show')">Tutup</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- .users-content -->
|
||||
|
||||
<script>
|
||||
const SELF_ID = <?= get_user_id() ?>;
|
||||
|
||||
async function loadUsers() {
|
||||
const res = await fetch('../api/users/ambil.php');
|
||||
const j = await res.json();
|
||||
if (j.status !== 'success') return;
|
||||
|
||||
const tbody = document.getElementById('userTbody');
|
||||
if (!j.data.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#8b949e;">Belum ada akun.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = j.data.map(u => {
|
||||
const roleBadge = u.role === 'administrator'
|
||||
? `<span class="badge badge-admin">Admin</span>`
|
||||
: u.role === 'operator'
|
||||
? `<span class="badge badge-op">Operator</span>`
|
||||
: `<span class="badge badge-view">Viewer</span>`;
|
||||
const statusBadge = u.is_active == 1
|
||||
? `<span class="badge badge-on">Aktif</span>`
|
||||
: `<span class="badge badge-off">Nonaktif</span>`;
|
||||
const ibadah = u.nama_ibadah ? escapeHTML(u.nama_ibadah) : '<span style="color:#8b949e">—</span>';
|
||||
const isSelf = u.id == SELF_ID;
|
||||
const toggleBtn = isSelf ? '' :
|
||||
`<button class="btn btn-sm ${u.is_active==1 ? 'btn-danger' : 'btn-secondary'}"
|
||||
onclick="toggleActive(${u.id})">
|
||||
${u.is_active==1 ? 'Nonaktifkan' : 'Aktifkan'}
|
||||
</button>`;
|
||||
return `<tr>
|
||||
<td style="font-weight:500">${escapeHTML(u.username)}</td>
|
||||
<td>${escapeHTML(u.nama_lengkap)}</td>
|
||||
<td>${roleBadge}</td>
|
||||
<td>${ibadah}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td style="color:#8b949e;font-size:12px">${u.created_at.slice(0,10)}</td>
|
||||
<td style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
<button class="btn btn-sm btn-secondary" onclick='openModal("edit", ${JSON.stringify(u)})'>Edit</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="resetPw(${u.id})">Reset PW</button>
|
||||
${toggleBtn}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function escapeHTML(s) {
|
||||
const d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(s ?? ''));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function toggleIbadahField() {
|
||||
const role = document.getElementById('fRole').value;
|
||||
document.getElementById('fgIbadah').style.display = role === 'operator' ? '' : 'none';
|
||||
}
|
||||
|
||||
function openModal(mode, user = null) {
|
||||
const isEdit = mode === 'edit';
|
||||
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Akun' : 'Akun Baru';
|
||||
document.getElementById('editId').value = isEdit ? user.id : '';
|
||||
document.getElementById('fUsername').value = isEdit ? user.username : '';
|
||||
document.getElementById('fUsername').disabled = isEdit;
|
||||
document.getElementById('fNama').value = isEdit ? user.nama_lengkap : '';
|
||||
document.getElementById('fRole').value = isEdit ? user.role : 'operator';
|
||||
document.getElementById('fIbadah').value = isEdit ? (user.ibadah_id ?? '') : '';
|
||||
document.getElementById('fgPassword').style.display = isEdit ? 'none' : '';
|
||||
document.getElementById('modalMsg').textContent = '';
|
||||
toggleIbadahField();
|
||||
document.getElementById('modalForm').classList.add('show');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modalForm').classList.remove('show');
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
const id = document.getElementById('editId').value;
|
||||
const isEdit = !!id;
|
||||
const msg = document.getElementById('modalMsg');
|
||||
const btn = document.getElementById('btnModalSave');
|
||||
btn.disabled = true; btn.textContent = '⏳';
|
||||
msg.className = 'msg-box'; msg.textContent = '';
|
||||
|
||||
const fd = new FormData();
|
||||
if (isEdit) {
|
||||
fd.append('id', id);
|
||||
fd.append('nama_lengkap', document.getElementById('fNama').value.trim());
|
||||
fd.append('role', document.getElementById('fRole').value);
|
||||
fd.append('ibadah_id', document.getElementById('fIbadah').value);
|
||||
} else {
|
||||
fd.append('username', document.getElementById('fUsername').value.trim());
|
||||
fd.append('password', document.getElementById('fPassword').value);
|
||||
fd.append('nama_lengkap', document.getElementById('fNama').value.trim());
|
||||
fd.append('role', document.getElementById('fRole').value);
|
||||
fd.append('ibadah_id', document.getElementById('fIbadah').value);
|
||||
}
|
||||
|
||||
const url = isEdit ? '../api/users/update.php' : '../api/users/simpan.php';
|
||||
const res = await fetch(url, { method: 'POST', body: appendCsrf(fd) });
|
||||
const j = await res.json();
|
||||
btn.disabled = false; btn.textContent = 'Simpan';
|
||||
|
||||
if (j.status === 'success') {
|
||||
closeModal();
|
||||
loadUsers();
|
||||
} else {
|
||||
msg.className = 'msg-box error';
|
||||
msg.textContent = j.message;
|
||||
}
|
||||
}
|
||||
|
||||
function showConfirm({ title, message, confirmLabel = 'Ya, Lanjutkan', cancelLabel = 'Batal', danger = false }) {
|
||||
return new Promise(resolve => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'confirm-overlay';
|
||||
el.innerHTML = `<div class="confirm-box">
|
||||
<div class="confirm-title">${title}</div>
|
||||
<p class="confirm-msg">${message}</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="btn-secondary" id="_sc_cancel">${cancelLabel}</button>
|
||||
<button class="${danger ? 'btn-danger' : 'btn-primary'}" id="_sc_ok">${confirmLabel}</button>
|
||||
</div></div>`;
|
||||
document.body.appendChild(el);
|
||||
const done = val => { el.remove(); resolve(val); };
|
||||
el.querySelector('#_sc_ok').onclick = () => done(true);
|
||||
el.querySelector('#_sc_cancel').onclick = () => done(false);
|
||||
el.addEventListener('click', e => { if (e.target === el) done(false); });
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleActive(id) {
|
||||
const user = [...document.querySelectorAll('#userTbody tr')].find(tr =>
|
||||
tr.querySelector(`[onclick*="toggleActive(${id})"]`)
|
||||
);
|
||||
const isActive = user?.querySelector('.badge-on') !== null;
|
||||
const ok = await showConfirm({
|
||||
title: isActive ? 'Nonaktifkan Akun' : 'Aktifkan Akun',
|
||||
message: isActive
|
||||
? 'Akun ini akan dinonaktifkan dan tidak bisa login.'
|
||||
: 'Akun ini akan diaktifkan kembali.',
|
||||
confirmLabel: isActive ? 'Ya, Nonaktifkan' : 'Ya, Aktifkan',
|
||||
danger: isActive
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
const fd = new FormData(); fd.append('id', id);
|
||||
const res = await fetch('../api/users/toggle_active.php', { method:'POST', body: appendCsrf(fd) });
|
||||
const j = await res.json();
|
||||
if (j.status === 'success') loadUsers();
|
||||
else alert(j.message);
|
||||
}
|
||||
|
||||
async function resetPw(id) {
|
||||
const ok = await showConfirm({
|
||||
title: 'Reset Password',
|
||||
message: 'Password akun ini akan direset. Password sementara akan ditampilkan setelah proses selesai.',
|
||||
confirmLabel: 'Ya, Reset'
|
||||
});
|
||||
if (!ok) return;
|
||||
const fd = new FormData(); fd.append('id', id);
|
||||
const res = await fetch('../api/users/reset_password.php', { method:'POST', body: appendCsrf(fd) });
|
||||
const j = await res.json();
|
||||
if (j.status === 'success') {
|
||||
document.getElementById('tempPwDisplay').textContent = j.temp_password;
|
||||
document.getElementById('modalReset').classList.add('show');
|
||||
} else {
|
||||
alert(j.message);
|
||||
}
|
||||
}
|
||||
|
||||
loadUsers();
|
||||
</script>
|
||||
|
||||
<?php include '../includes/page-end.php'; ?>
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once 'auth/helper.php';
|
||||
$is_admin = is_logged_in() && has_role('administrator');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Papan Kebutuhan Publik — <?= APP_NAME ?></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--c-bg:#f5f0eb;--c-surface:#fafaf9;--c-surface2:#ede8e2;
|
||||
--c-border:#ddd8d2;--c-text:#201515;--c-muted:#7a7067;
|
||||
--c-accent:#0d7490;--c-accent-h:#0a5f7a;--c-warn:#f59e0b;
|
||||
--c-danger:#ef4444;--c-info:#3b82f6;
|
||||
--font-body:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
}
|
||||
body{background:var(--c-bg);color:var(--c-text);font-family:var(--font-body);min-height:100vh;padding:24px 16px;}
|
||||
.container{max-width:860px;margin:0 auto;}
|
||||
header{margin-bottom:32px;}
|
||||
header h1{font-size:22px;font-weight:600;letter-spacing:-0.5px;color:var(--c-text);margin-bottom:6px;display:flex;align-items:center;gap:8px;}
|
||||
header p{font-size:13px;color:var(--c-muted);line-height:1.6;max-width:600px;}
|
||||
.back-link{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--c-muted);
|
||||
text-decoration:none;margin-bottom:20px;transition:color .15s;}
|
||||
.back-link:hover{color:var(--c-text);}
|
||||
.board-grid{display:flex;flex-direction:column;gap:8px;margin-bottom:32px;}
|
||||
.board-row{background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;
|
||||
padding:14px 16px;display:flex;align-items:center;gap:14px;cursor:pointer;transition:border-color .15s,box-shadow .15s;}
|
||||
.board-row:hover{border-color:#0d7490;box-shadow:0 2px 8px rgba(32,21,21,.06);}
|
||||
.board-icon{flex-shrink:0;width:40px;height:40px;display:flex;align-items:center;justify-content:center;background:var(--c-surface2);border-radius:8px;}
|
||||
.board-info{flex:1;}
|
||||
.board-title{font-size:14px;font-weight:600;color:var(--c-text);}
|
||||
.board-sub{font-size:12px;color:var(--c-muted);margin-top:3px;}
|
||||
.board-count{font-size:22px;font-weight:600;letter-spacing:-0.5px;color:#ef4444;flex-shrink:0;}
|
||||
.board-count-label{font-size:10px;color:var(--c-muted);text-align:right;}
|
||||
.btn-help{background:#0d7490;color:#fff;border:none;border-radius:8px;padding:8px 16px;
|
||||
font-size:12px;font-weight:600;cursor:pointer;transition:background .15s;white-space:nowrap;}
|
||||
.btn-help:hover{background:#0a5f7a;}
|
||||
.empty-state{text-align:center;padding:48px 24px;color:var(--c-muted);font-size:14px;}
|
||||
.section-title{font-size:11px;font-weight:600;color:var(--c-muted);text-transform:uppercase;
|
||||
letter-spacing:.6px;margin-bottom:12px;}
|
||||
|
||||
/* Form donatur */
|
||||
.form-panel{background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;
|
||||
padding:20px;margin-top:24px;display:none;box-shadow:0 4px 12px rgba(32,21,21,.08);}
|
||||
.form-panel.show{display:block;}
|
||||
.form-title{font-size:15px;font-weight:600;letter-spacing:-0.3px;margin-bottom:4px;display:flex;align-items:center;gap:6px;}
|
||||
.form-sub{font-size:12px;color:var(--c-muted);margin-bottom:16px;}
|
||||
.form-group{margin-bottom:12px;}
|
||||
.form-group label{display:block;font-size:13px;font-weight:500;color:#3d3530;margin-bottom:5px;}
|
||||
.form-group input,.form-group select,.form-group textarea{
|
||||
width:100%;padding:9px 12px;background:#fafaf9;border:1px solid #ddd8d2;
|
||||
border-radius:8px;color:#201515;font-family:var(--font-body);font-size:13px;outline:none;
|
||||
transition:border-color .15s;}
|
||||
.form-group input:focus,.form-group select:focus,.form-group textarea:focus{border-color:#0d7490;}
|
||||
.form-group textarea{resize:vertical;min-height:80px;}
|
||||
.form-actions{display:flex;gap:10px;margin-top:16px;}
|
||||
.btn-submit{background:#0d7490;color:#fff;border:none;border-radius:8px;padding:9px 20px;
|
||||
font-size:13px;font-weight:600;cursor:pointer;flex:1;transition:background .15s;display:inline-flex;align-items:center;justify-content:center;gap:6px;}
|
||||
.btn-submit:hover:not(:disabled){background:#0a5f7a;}
|
||||
.btn-submit:disabled{opacity:.6;cursor:not-allowed;}
|
||||
.btn-cancel{background:transparent;color:var(--c-muted);border:1px solid var(--c-border);
|
||||
border-radius:8px;padding:9px 16px;font-size:13px;cursor:pointer;}
|
||||
.form-status{font-size:12px;margin-top:8px;padding:6px 10px;border-radius:6px;display:inline-flex;align-items:center;gap:6px;}
|
||||
.form-status.success{background:#f0fdf4;color:#16a34a;}
|
||||
.form-status.error{background:#fef2f2;color:#ef4444;}
|
||||
|
||||
footer{background:#1c1612;color:#a89f96;padding:20px 24px;margin-top:48px;
|
||||
border-radius:12px;font-size:12px;text-align:center;}
|
||||
footer a{color:#a89f96;text-decoration:none;}
|
||||
footer a:hover{color:#ffffff;}
|
||||
|
||||
/* Lucide Icons */
|
||||
.lucide { width: 16px; height: 16px; display: inline-block; vertical-align: middle; stroke-width: 2px; }
|
||||
.board-icon .lucide { width: 20px; height: 20px; color: var(--c-accent); }
|
||||
.spinner {
|
||||
display: inline-block; width: 14px; height: 14px;
|
||||
border: 2px solid var(--c-border); border-top-color: var(--c-accent);
|
||||
border-radius: 50%; animation: spin .7s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="index.php" class="back-link"><i data-lucide="arrow-left"></i> Kembali ke Peta</a>
|
||||
|
||||
<header>
|
||||
<h1><i data-lucide="clipboard-list" style="width:24px;height:24px;color:var(--c-accent);"></i> Papan Kebutuhan Masyarakat</h1>
|
||||
<p>Daftar kebutuhan warga miskin yang belum terpenuhi — diagregasi per kategori dan wilayah. Data ditampilkan secara anonim untuk menjaga privasi warga.</p>
|
||||
</header>
|
||||
|
||||
<div class="section-title">Kebutuhan yang Belum Terpenuhi</div>
|
||||
<div id="boardContainer" class="board-grid">
|
||||
<div class="empty-state"><span class="spinner"></span> Memuat data...</div>
|
||||
</div>
|
||||
|
||||
<!-- Form hubungi admin -->
|
||||
<div id="formPanel" class="form-panel">
|
||||
<div class="form-title"><i data-lucide="handshake" style="color:var(--c-accent);"></i> Saya Ingin Membantu</div>
|
||||
<div class="form-sub" id="formSubtitle">Isi form ini untuk menghubungi kami. Kami akan segera menghubungi Anda.</div>
|
||||
<form id="donaturForm" onsubmit="submitDonatur(event)">
|
||||
<input type="hidden" id="f_kategori_minat" name="kategori_minat">
|
||||
<div class="form-group">
|
||||
<label>Nama Organisasi / Individu *</label>
|
||||
<input type="text" id="f_nama" name="nama" placeholder="cth. Yayasan Peduli, Bapak Ahmad" maxlength="150" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>WhatsApp / Email *</label>
|
||||
<input type="text" id="f_kontak" name="kontak" placeholder="cth. 0812-3456-7890 atau email@gmail.com" maxlength="150" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Pesan (opsional)</label>
|
||||
<textarea id="f_pesan" name="pesan" placeholder="Ceritakan bentuk bantuan yang ingin Anda berikan..." maxlength="1000"></textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-submit" id="btnSubmit"><i data-lucide="send"></i> Kirim Pesan</button>
|
||||
<button type="button" class="btn-cancel" onclick="document.getElementById('formPanel').classList.remove('show')">Batal</button>
|
||||
</div>
|
||||
<div id="formStatus" class="form-status" style="display:none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Data kebutuhan ini dikelola oleh <?= htmlspecialchars(APP_NAME, ENT_QUOTES) ?>.
|
||||
<?php if ($is_admin): ?>
|
||||
<a href="index.php">Buka Peta Admin</a> ·
|
||||
<?php endif; ?>
|
||||
Untuk informasi lebih lanjut, hubungi pengelola sistem.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const KATEGORI_ICON = {
|
||||
'Sembako':'shopping-cart','Biaya Sekolah':'book-open','Biaya Kesehatan':'heart-pulse','Modal Usaha':'briefcase',
|
||||
'Renovasi Rumah':'home','Perlengkapan Rumah':'sofa','Pakaian':'shirt','Lainnya':'clipboard-list'
|
||||
};
|
||||
|
||||
function esc(str) {
|
||||
const d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(str ?? ''));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
fetch('api/papan/ambil.php?_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
const container = document.getElementById('boardContainer');
|
||||
if (j.status !== 'success' || !j.data.length) {
|
||||
container.innerHTML = '<div class="empty-state"><i data-lucide="check-circle" style="color:var(--c-info);"></i> Saat ini tidak ada kebutuhan yang belum terpenuhi.</div>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
container.innerHTML = j.data.map(row => {
|
||||
const icon = KATEGORI_ICON[row.kategori] || 'clipboard-list';
|
||||
return `<div class="board-row" data-kategori="${esc(row.kategori)}">
|
||||
<div class="board-icon"><i data-lucide="${icon}"></i></div>
|
||||
<div class="board-info">
|
||||
<div class="board-title">${esc(row.kategori)}</div>
|
||||
<div class="board-sub">di area ${esc(row.area)}</div>
|
||||
</div>
|
||||
<div style="text-align:right;flex-shrink:0;">
|
||||
<div class="board-count">${row.jumlah_kk}</div>
|
||||
<div class="board-count-label">KK</div>
|
||||
</div>
|
||||
<button class="btn-help" type="button" data-kategori="${esc(row.kategori)}">
|
||||
Saya Ingin Membantu
|
||||
</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('boardContainer').innerHTML =
|
||||
'<div class="empty-state" style="color:#ef4444;"><i data-lucide="x-circle"></i> Gagal memuat data. Coba muat ulang halaman.</div>';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
});
|
||||
|
||||
document.getElementById('boardContainer').addEventListener('click', function (event) {
|
||||
const trigger = event.target.closest('[data-kategori]');
|
||||
if (!trigger || !this.contains(trigger)) return;
|
||||
openForm(trigger.dataset.kategori || '');
|
||||
});
|
||||
|
||||
function openForm(kategori) {
|
||||
document.getElementById('f_kategori_minat').value = kategori || '';
|
||||
document.getElementById('formSubtitle').textContent =
|
||||
kategori
|
||||
? `Anda ingin membantu kebutuhan: ${kategori}. Isi form ini untuk dihubungi oleh pengelola.`
|
||||
: 'Isi form ini untuk menghubungi kami. Kami akan segera menghubungi Anda.';
|
||||
document.getElementById('formStatus').style.display = 'none';
|
||||
document.getElementById('donaturForm').reset();
|
||||
document.getElementById('f_kategori_minat').value = kategori || '';
|
||||
document.getElementById('formPanel').classList.add('show');
|
||||
document.getElementById('formPanel').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function submitDonatur(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('btnSubmit');
|
||||
const status = document.getElementById('formStatus');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Mengirim...';
|
||||
status.style.display = 'none';
|
||||
|
||||
const fd = new FormData(e.target);
|
||||
fetch('api/papan/kontak_donatur.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message || 'Gagal mengirim.');
|
||||
status.innerHTML = '<i data-lucide="check-circle" style="color:var(--c-accent);"></i> Pesan berhasil dikirim! Pengelola akan menghubungi Anda.';
|
||||
status.className = 'form-status success';
|
||||
status.style.display = '';
|
||||
btn.innerHTML = '<i data-lucide="check"></i> Terkirim';
|
||||
e.target.reset();
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
})
|
||||
.catch(err => {
|
||||
status.innerHTML = '<i data-lucide="x-circle" style="color:var(--c-danger);"></i> ' + (err.message || 'Gagal mengirim, coba lagi.');
|
||||
status.className = 'form-status error';
|
||||
status.style.display = '';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i data-lucide="send"></i> Kirim Pesan';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
});
|
||||
}
|
||||
// Boot
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,226 @@
|
||||
-- setup_database.sql — WebGIS Poverty Mapping v1.0
|
||||
-- Jalankan via phpMyAdmin (SQL tab) atau:
|
||||
-- mysql -u root -P 3307 < setup_database.sql
|
||||
-- Aman dijalankan berulang (CREATE IF NOT EXISTS + ALTER ADD COLUMN IF NOT EXISTS)
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS db_webgis
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE db_webgis;
|
||||
|
||||
-- NOTE: data_jalan and data_parsil were removed from setup because those features are obsolete.
|
||||
-- Existing production databases should only DROP those tables after backup and explicit approval.
|
||||
|
||||
-- ─── Core v1.0: rumah_ibadah ───────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rumah_ibadah (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
nama VARCHAR(150) NOT NULL,
|
||||
jenis ENUM('Masjid','Mushola','Gereja','Pura','Vihara','Klenteng') NOT NULL DEFAULT 'Masjid',
|
||||
alamat TEXT DEFAULT NULL,
|
||||
lat DECIMAL(10,8) NOT NULL,
|
||||
lng DECIMAL(11,8) NOT NULL,
|
||||
radius_m INT NOT NULL DEFAULT 500,
|
||||
kontak VARCHAR(100) DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_ri_deleted (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Upgrade kolom jika tabel sudah ada dari prototype
|
||||
ALTER TABLE rumah_ibadah
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at,
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP NULL DEFAULT NULL AFTER updated_at;
|
||||
|
||||
-- ─── Core v1.0: penduduk_miskin ───────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS penduduk_miskin (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
nama_kk VARCHAR(150) NOT NULL,
|
||||
nik VARCHAR(16) DEFAULT NULL,
|
||||
jumlah_jiwa INT NOT NULL DEFAULT 1,
|
||||
kategori ENUM('Sangat Miskin','Miskin','Hampir Miskin') DEFAULT 'Miskin',
|
||||
alamat TEXT DEFAULT NULL,
|
||||
catatan TEXT DEFAULT NULL,
|
||||
lat DECIMAL(10,8) NOT NULL,
|
||||
lng DECIMAL(11,8) NOT NULL,
|
||||
ibadah_id INT DEFAULT NULL,
|
||||
jarak_m FLOAT DEFAULT NULL,
|
||||
is_blank_spot TINYINT(1) DEFAULT 0,
|
||||
status_bantuan ENUM('Belum Ditangani','Dalam Proses','Sudah Ditangani') DEFAULT 'Belum Ditangani',
|
||||
status_verifikasi ENUM('Pending','Terverifikasi','Ditolak') NOT NULL DEFAULT 'Pending',
|
||||
verified_by INT DEFAULT NULL,
|
||||
verified_at TIMESTAMP NULL DEFAULT NULL,
|
||||
catatan_verifikasi TEXT DEFAULT NULL,
|
||||
is_active TINYINT(1) DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_nik (nik),
|
||||
INDEX idx_pm_blank (is_blank_spot),
|
||||
INDEX idx_pm_active (is_active),
|
||||
INDEX idx_pm_verifikasi (status_verifikasi),
|
||||
INDEX idx_pm_deleted(deleted_at),
|
||||
FOREIGN KEY (ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Upgrade kolom jika tabel sudah ada dari prototype
|
||||
ALTER TABLE penduduk_miskin
|
||||
ADD COLUMN IF NOT EXISTS catatan TEXT DEFAULT NULL AFTER alamat,
|
||||
ADD COLUMN IF NOT EXISTS is_blank_spot TINYINT(1) DEFAULT 0 AFTER jarak_m,
|
||||
ADD COLUMN IF NOT EXISTS status_bantuan ENUM('Belum Ditangani','Dalam Proses','Sudah Ditangani') DEFAULT 'Belum Ditangani' AFTER is_blank_spot,
|
||||
ADD COLUMN IF NOT EXISTS status_verifikasi ENUM('Pending','Terverifikasi','Ditolak') NOT NULL DEFAULT 'Pending' AFTER status_bantuan,
|
||||
ADD COLUMN IF NOT EXISTS verified_by INT DEFAULT NULL AFTER status_verifikasi,
|
||||
ADD COLUMN IF NOT EXISTS verified_at TIMESTAMP NULL DEFAULT NULL AFTER verified_by,
|
||||
ADD COLUMN IF NOT EXISTS catatan_verifikasi TEXT DEFAULT NULL AFTER verified_at,
|
||||
ADD COLUMN IF NOT EXISTS is_active TINYINT(1) DEFAULT 1 AFTER catatan_verifikasi,
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at,
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP NULL DEFAULT NULL AFTER updated_at;
|
||||
|
||||
ALTER TABLE penduduk_miskin
|
||||
ADD INDEX IF NOT EXISTS idx_pm_verifikasi (status_verifikasi);
|
||||
|
||||
ALTER TABLE penduduk_miskin
|
||||
ADD UNIQUE INDEX IF NOT EXISTS uq_nik (nik);
|
||||
|
||||
-- ─── Core v1.0: users ─────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
nama_lengkap VARCHAR(150) NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
role ENUM('administrator','operator','viewer') NOT NULL DEFAULT 'viewer',
|
||||
ibadah_id INT NULL DEFAULT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
must_change_password TINYINT(1) NOT NULL DEFAULT 0,
|
||||
login_attempts INT NOT NULL DEFAULT 0,
|
||||
locked_until TIMESTAMP NULL DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_username (username),
|
||||
FOREIGN KEY (ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ─── Core v1.0: riwayat_bantuan (append-only) ─────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS riwayat_bantuan (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
penduduk_id INT NOT NULL,
|
||||
operator_id INT NOT NULL,
|
||||
status_lama VARCHAR(50) DEFAULT NULL,
|
||||
status_baru VARCHAR(50) NOT NULL,
|
||||
catatan TEXT DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id),
|
||||
FOREIGN KEY (operator_id) REFERENCES users(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ─── F1.1: kebutuhan ──────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kebutuhan (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
penduduk_id INT NOT NULL,
|
||||
kategori ENUM('Sembako','Biaya Sekolah','Biaya Kesehatan','Modal Usaha',
|
||||
'Renovasi Rumah','Perlengkapan Rumah','Pakaian','Lainnya') NOT NULL,
|
||||
deskripsi VARCHAR(300) DEFAULT NULL,
|
||||
status ENUM('Belum Terpenuhi','Dalam Proses','Terpenuhi') NOT NULL DEFAULT 'Belum Terpenuhi',
|
||||
created_by INT NOT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_k_penduduk (penduduk_id),
|
||||
INDEX idx_k_status (status),
|
||||
INDEX idx_k_kategori (kategori),
|
||||
FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id),
|
||||
FOREIGN KEY (created_by) REFERENCES users(id),
|
||||
FOREIGN KEY (updated_by) REFERENCES users(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ─── F1.1: riwayat_kebutuhan (append-only) ───────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS riwayat_kebutuhan (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
kebutuhan_id INT NOT NULL,
|
||||
operator_id INT NOT NULL,
|
||||
status_lama VARCHAR(50) DEFAULT NULL,
|
||||
status_baru VARCHAR(50) NOT NULL,
|
||||
catatan TEXT DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (kebutuhan_id) REFERENCES kebutuhan(id),
|
||||
FOREIGN KEY (operator_id) REFERENCES users(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ─── F1.1: kontak_donatur ────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kontak_donatur (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
nama VARCHAR(150) NOT NULL,
|
||||
kontak VARCHAR(150) NOT NULL,
|
||||
kategori_minat ENUM('Sembako','Biaya Sekolah','Biaya Kesehatan','Modal Usaha',
|
||||
'Renovasi Rumah','Perlengkapan Rumah','Pakaian','Lainnya') DEFAULT NULL,
|
||||
pesan TEXT DEFAULT NULL,
|
||||
is_read TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_kd_read (is_read)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ─── Demo seed untuk penilaian — password semua akun: password ───────────
|
||||
-- Rumah ibadah demo hanya dibuat kalau belum ada data rumah ibadah aktif.
|
||||
INSERT INTO rumah_ibadah (nama, jenis, alamat, lat, lng, radius_m, kontak)
|
||||
SELECT
|
||||
'Masjid Demo Operator',
|
||||
'Masjid',
|
||||
'Pontianak, Kalimantan Barat',
|
||||
-0.05570000,
|
||||
109.34870000,
|
||||
1000,
|
||||
'081234567890'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM rumah_ibadah WHERE deleted_at IS NULL
|
||||
);
|
||||
|
||||
INSERT INTO users (username, nama_lengkap, password, role, ibadah_id, is_active, must_change_password)
|
||||
VALUES
|
||||
(
|
||||
'admin',
|
||||
'Administrator',
|
||||
'$2y$10$SBXp6R9CuUxfD3gai9cVN.iPrHzFLqVi33bqbCAnuWBj7v0uTuYMK',
|
||||
'administrator',
|
||||
NULL,
|
||||
1,
|
||||
0
|
||||
),
|
||||
(
|
||||
'operator',
|
||||
'Operator Demo',
|
||||
'$2y$10$SBXp6R9CuUxfD3gai9cVN.iPrHzFLqVi33bqbCAnuWBj7v0uTuYMK',
|
||||
'operator',
|
||||
(SELECT id FROM rumah_ibadah WHERE deleted_at IS NULL ORDER BY id LIMIT 1),
|
||||
1,
|
||||
0
|
||||
),
|
||||
(
|
||||
'viewer',
|
||||
'Viewer Demo',
|
||||
'$2y$10$SBXp6R9CuUxfD3gai9cVN.iPrHzFLqVi33bqbCAnuWBj7v0uTuYMK',
|
||||
'viewer',
|
||||
NULL,
|
||||
1,
|
||||
0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
nama_lengkap = VALUES(nama_lengkap),
|
||||
password = VALUES(password),
|
||||
role = VALUES(role),
|
||||
ibadah_id = VALUES(ibadah_id),
|
||||
is_active = 1,
|
||||
must_change_password = 0,
|
||||
login_attempts = 0,
|
||||
locked_until = NULL;
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$tests = [
|
||||
'test_csrf_enforcement.php',
|
||||
'test_session_cookie_security.php',
|
||||
'test_operator_scope_endpoints.php',
|
||||
'test_operator_scope_strict.php',
|
||||
'test_viewer_public_filters.php',
|
||||
'test_user_management_guards.php',
|
||||
'test_frontend_reliability_guards.php',
|
||||
'test_deployment_docs.php',
|
||||
'test_webroot_test_access_guard.php',
|
||||
'test_navigation_role_guards.php',
|
||||
'test_public_donor_rate_limit.php',
|
||||
'test_csv_hardening.php',
|
||||
'test_safe_error_responses.php',
|
||||
'test_audit_note_ui.php',
|
||||
'test_documentation_sop.php',
|
||||
'test_recalculate_transaction_guard.php',
|
||||
'test_donor_inbox_api.php',
|
||||
'test_donor_inbox_ui.php',
|
||||
];
|
||||
|
||||
$php = PHP_BINARY;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($tests as $test) {
|
||||
$path = __DIR__ . DIRECTORY_SEPARATOR . $test;
|
||||
if (!file_exists($path)) {
|
||||
echo "SKIP: {$test} not found\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n=== {$test} ===\n";
|
||||
passthru('"' . $php . '" "' . $path . '"', $code);
|
||||
if ($code !== 0) {
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n=== SUMMARY ===\n";
|
||||
echo $failed === 0 ? "ALL PASSED\n" : "{$failed} test file(s) failed\n";
|
||||
exit($failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) {
|
||||
echo "PASS: {$label}\n";
|
||||
$pass++;
|
||||
} else {
|
||||
echo "FAIL: {$label}\n";
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
$statusPage = file_get_contents($root . '/pages/status-bantuan.php');
|
||||
$kebutuhanPage = file_get_contents($root . '/pages/kebutuhan.php');
|
||||
$pendudukJs = file_get_contents($root . '/modules/penduduk.js');
|
||||
$kebutuhanJs = file_get_contents($root . '/modules/kebutuhan.js');
|
||||
|
||||
check('status page sends catatan', strpos($statusPage, "fd.append('catatan'") !== false);
|
||||
check('kebutuhan page sends catatan', strpos($kebutuhanPage, "fd.append('catatan'") !== false);
|
||||
check('map penduduk status sends catatan', strpos($pendudukJs, "fd.append('catatan'") !== false);
|
||||
check('map kebutuhan status sends catatan', strpos($kebutuhanJs, "fd.append('catatan'") !== false);
|
||||
check('status UI asks for note', strpos($statusPage, 'prompt(') !== false || strpos($statusPage, 'catatan') !== false);
|
||||
check('status page cancels update when note prompt is cancelled', strpos($statusPage, 'catatan === null') !== false);
|
||||
check('kebutuhan page cancels update when note prompt is cancelled', strpos($kebutuhanPage, 'catatan === null') !== false);
|
||||
check('map penduduk cancels update when note prompt is cancelled', strpos($pendudukJs, 'catatan === null') !== false);
|
||||
check('map kebutuhan cancels update when note prompt is cancelled', strpos($kebutuhanJs, 'catatan === null') !== false);
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
// tests/test_auth_helper.php — Test fungsi di auth/helper.php
|
||||
// Jalankan via browser: http://localhost/webgis/WebgisPovertyMapping/tests/test_auth_helper.php
|
||||
if (PHP_SAPI === 'cli') {
|
||||
$session_path = __DIR__ . '/../tmp/test-sessions';
|
||||
if (!is_dir($session_path)) {
|
||||
mkdir($session_path, 0777, true);
|
||||
}
|
||||
session_save_path($session_path);
|
||||
}
|
||||
require_once '../config.php';
|
||||
|
||||
$pass = 0; $fail = 0;
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) { echo "✅ $label\n"; $pass++; }
|
||||
else { echo "❌ $label\n"; $fail++; }
|
||||
}
|
||||
|
||||
require_once '../auth/helper.php';
|
||||
|
||||
// Test 1: tanpa session → is_logged_in() return false
|
||||
$_SESSION = [];
|
||||
check('is_logged_in() false tanpa session', is_logged_in() === false);
|
||||
|
||||
// Test 2: session ada → is_logged_in() return true
|
||||
$_SESSION['user_id'] = 1;
|
||||
$_SESSION['role'] = 'administrator';
|
||||
$_SESSION['last_activity'] = time();
|
||||
check('is_logged_in() true dengan session', is_logged_in() === true);
|
||||
check('get_role() returns administrator', get_role() === 'administrator');
|
||||
check('has_role(administrator) true', has_role('administrator') === true);
|
||||
check('administrator satisfies operator role', has_role('operator') === true);
|
||||
|
||||
// Test 3: operator hanya bisa akses role operator dan viewer
|
||||
$_SESSION['role'] = 'operator';
|
||||
check('operator: has_role(operator) true', has_role('operator') === true);
|
||||
check('operator: has_role(administrator) false', has_role('administrator') === false);
|
||||
|
||||
// Test 4: get_ibadah_id
|
||||
$_SESSION['ibadah_id'] = 5;
|
||||
check('get_ibadah_id() returns 5', get_ibadah_id() === 5);
|
||||
|
||||
// Test 5: viewer has lowest access
|
||||
$_SESSION['role'] = 'viewer';
|
||||
check('viewer: has_role(viewer) true', has_role('viewer') === true);
|
||||
check('viewer: has_role(operator) false', has_role('operator') === false);
|
||||
|
||||
echo "\n--- $pass passed, $fail failed ---\n";
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) {
|
||||
echo "PASS: {$label}\n";
|
||||
$pass++;
|
||||
} else {
|
||||
echo "FAIL: {$label}\n";
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
$config = file_get_contents($root . '/config.php');
|
||||
check('config defines map minimum latitude', strpos($config, 'MAP_MIN_LAT') !== false);
|
||||
check('config defines map maximum latitude', strpos($config, 'MAP_MAX_LAT') !== false);
|
||||
check('config defines map minimum longitude', strpos($config, 'MAP_MIN_LNG') !== false);
|
||||
check('config defines map maximum longitude', strpos($config, 'MAP_MAX_LNG') !== false);
|
||||
|
||||
$helper = $root . '/includes/validation.php';
|
||||
check('shared validation helper exists', file_exists($helper));
|
||||
|
||||
if (file_exists($helper)) {
|
||||
require_once $root . '/config.php';
|
||||
require_once $helper;
|
||||
|
||||
check('validate_lat_lng function exists', function_exists('validate_lat_lng'));
|
||||
if (function_exists('validate_lat_lng')) {
|
||||
check('valid Pontianak coordinate accepted', validate_lat_lng('-0.0557', '109.3487')['ok'] === true);
|
||||
check('blank coordinate rejected', validate_lat_lng('', '109.3487')['ok'] === false);
|
||||
check('non numeric coordinate rejected', validate_lat_lng('abc', '109.3487')['ok'] === false);
|
||||
check('zero-zero coordinate rejected by study bounds', validate_lat_lng('0', '0')['ok'] === false);
|
||||
check('latitude outside earth range rejected', validate_lat_lng('91', '109.3487')['ok'] === false);
|
||||
check('longitude outside earth range rejected', validate_lat_lng('-0.0557', '181')['ok'] === false);
|
||||
}
|
||||
}
|
||||
|
||||
$mutationFiles = [
|
||||
'api/penduduk/simpan.php',
|
||||
'api/penduduk/update_posisi.php',
|
||||
'api/ibadah/simpan.php',
|
||||
'api/ibadah/update.php',
|
||||
'api/ibadah/update_posisi.php',
|
||||
'api/import/csv.php',
|
||||
];
|
||||
|
||||
foreach ($mutationFiles as $relative) {
|
||||
$src = file_get_contents($root . '/' . $relative);
|
||||
check("{$relative} loads validation helper", strpos($src, 'includes/validation.php') !== false);
|
||||
check("{$relative} calls validate_lat_lng", strpos($src, 'validate_lat_lng(') !== false);
|
||||
}
|
||||
|
||||
$ibadahUpdate = file_get_contents($root . '/api/ibadah/update.php');
|
||||
check(
|
||||
'ibadah update validates submitted coordinates directly',
|
||||
strpos($ibadahUpdate, "validate_lat_lng(\$_POST['lat'] ?? null, \$_POST['lng'] ?? null)") !== false
|
||||
);
|
||||
check(
|
||||
'ibadah update does not substitute existing coordinates for blank input',
|
||||
strpos($ibadahUpdate, 'SELECT lat, lng FROM rumah_ibadah') === false
|
||||
);
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) {
|
||||
echo "PASS: {$label}\n";
|
||||
$pass++;
|
||||
} else {
|
||||
echo "FAIL: {$label}\n";
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
$helper = file_get_contents($root . '/auth/helper.php');
|
||||
check('csrf_token function exists', strpos($helper, 'function csrf_token(): string') !== false);
|
||||
check('require_csrf function exists', strpos($helper, 'function require_csrf(): void') !== false);
|
||||
check('require_csrf uses hash_equals', strpos($helper, 'hash_equals') !== false);
|
||||
|
||||
$must_have = [
|
||||
'api/ibadah/simpan.php',
|
||||
'api/ibadah/hapus.php',
|
||||
'api/ibadah/update.php',
|
||||
'api/ibadah/update_posisi.php',
|
||||
'api/ibadah/update_radius.php',
|
||||
'api/ibadah/update_kontak.php',
|
||||
'api/ibadah/recalculate.php',
|
||||
'api/penduduk/simpan.php',
|
||||
'api/penduduk/update_posisi.php',
|
||||
'api/penduduk/update_status.php',
|
||||
'api/penduduk/hapus.php',
|
||||
'api/penduduk/nonaktifkan.php',
|
||||
'api/penduduk/verifikasi.php',
|
||||
'api/kebutuhan/simpan.php',
|
||||
'api/kebutuhan/update_status.php',
|
||||
'api/users/simpan.php',
|
||||
'api/users/update.php',
|
||||
'api/users/reset_password.php',
|
||||
'api/users/toggle_active.php',
|
||||
'api/import/csv.php',
|
||||
'api/auth/change_password.php',
|
||||
'api/papan/donatur.php',
|
||||
];
|
||||
|
||||
foreach ($must_have as $file) {
|
||||
$content = file_get_contents($root . '/' . $file);
|
||||
check("{$file} calls require_csrf", strpos($content, 'require_csrf();') !== false);
|
||||
}
|
||||
|
||||
$index = file_get_contents($root . '/index.php');
|
||||
$pageStart = file_get_contents($root . '/includes/page-start.php');
|
||||
$changePassword = file_get_contents($root . '/auth/change_password.php');
|
||||
|
||||
check('index exposes csrf token on APP_USER', strpos($index, 'csrfToken: <?= json_encode($logged_in ? csrf_token() : null) ?>') !== false);
|
||||
check('index defines appendCsrf helper', strpos($index, 'function appendCsrf(fd)') !== false);
|
||||
check('dashboard pages expose APP_CSRF_TOKEN', strpos($pageStart, 'window.APP_CSRF_TOKEN = <?= json_encode(is_logged_in() ? csrf_token() : null) ?>;') !== false);
|
||||
check('change password appends csrf token', strpos($changePassword, "fd.append('csrf_token',") !== false);
|
||||
check('donor mark-read uses POST with csrf', strpos($index, "fetch('api/papan/donatur.php', { method: 'POST', body: appendCsrf(fd) })") !== false);
|
||||
$donorEndpoint = file_get_contents($root . '/api/papan/donatur.php');
|
||||
check('donor mark-read is POST-only', strpos($donorEndpoint, "\$_SERVER['REQUEST_METHOD'] === 'POST'") !== false);
|
||||
check('donor mark-read requires csrf before update', strpos($donorEndpoint, 'require_csrf()') !== false && strpos($donorEndpoint, 'UPDATE kontak_donatur SET is_read=1') !== false);
|
||||
|
||||
$frontend_files = [
|
||||
'index.php',
|
||||
'modules/ibadah.js',
|
||||
'modules/penduduk.js',
|
||||
'modules/kebutuhan.js',
|
||||
'pages/analisis.php',
|
||||
'pages/ibadah.php',
|
||||
'pages/import.php',
|
||||
'pages/kebutuhan.php',
|
||||
'pages/penduduk.php',
|
||||
'pages/status-bantuan.php',
|
||||
'pages/users.php',
|
||||
];
|
||||
|
||||
foreach ($frontend_files as $file) {
|
||||
$content = file_get_contents($root . '/' . $file);
|
||||
check("{$file} uses appendCsrf", strpos($content, 'appendCsrf(') !== false);
|
||||
}
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) {
|
||||
echo "PASS: {$label}\n";
|
||||
$pass++;
|
||||
} else {
|
||||
echo "FAIL: {$label}\n";
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
$import = file_get_contents($root . '/api/import/csv.php');
|
||||
$export = file_get_contents($root . '/api/export/csv.php');
|
||||
|
||||
check('import checks upload error', strpos($import, 'UPLOAD_ERR_OK') !== false);
|
||||
check('import checks csv extension', strpos($import, 'PATHINFO_EXTENSION') !== false || strpos($import, 'pathinfo(') !== false);
|
||||
check('import checks file size', strpos($import, "csv_file']['size']") !== false || (strpos($import, '$file[\'size\']') !== false && strpos($import, '1024') !== false));
|
||||
check('export defines csv safe cell helper', strpos($export, 'function csv_safe_cell') !== false);
|
||||
check('export neutralizes spreadsheet formulas', strpos($export, "['=', '+', '-', '@']") !== false || strpos($export, 'str_starts_with') !== false);
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
// tests/test_db.php — Verifikasi schema database PRD v1.0
|
||||
// Jalankan via browser: http://localhost/webgis/WebgisPovertyMapping/tests/test_db.php
|
||||
require_once '../koneksi.php';
|
||||
|
||||
$pass = 0; $fail = 0;
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) { echo "✅ $label\n"; $pass++; }
|
||||
else { echo "❌ $label\n"; $fail++; }
|
||||
}
|
||||
|
||||
// --- rumah_ibadah ---
|
||||
$cols = [];
|
||||
$r = $conn->query("SHOW COLUMNS FROM rumah_ibadah");
|
||||
while ($row = $r->fetch_assoc()) $cols[] = $row['Field'];
|
||||
|
||||
check('rumah_ibadah.updated_at exists', in_array('updated_at', $cols));
|
||||
check('rumah_ibadah.deleted_at exists', in_array('deleted_at', $cols));
|
||||
|
||||
// --- penduduk_miskin ---
|
||||
$cols = [];
|
||||
$r = $conn->query("SHOW COLUMNS FROM penduduk_miskin");
|
||||
while ($row = $r->fetch_assoc()) $cols[] = $row['Field'];
|
||||
|
||||
check('penduduk_miskin.catatan exists', in_array('catatan', $cols));
|
||||
check('penduduk_miskin.status_bantuan exists', in_array('status_bantuan', $cols));
|
||||
check('penduduk_miskin.is_active exists', in_array('is_active', $cols));
|
||||
check('penduduk_miskin.updated_at exists', in_array('updated_at', $cols));
|
||||
check('penduduk_miskin.deleted_at exists', in_array('deleted_at', $cols));
|
||||
|
||||
// --- tabel users ---
|
||||
$t = $conn->query("SHOW TABLES LIKE 'users'");
|
||||
check('table users exists', $t->num_rows > 0);
|
||||
|
||||
// --- tabel riwayat_bantuan ---
|
||||
$t = $conn->query("SHOW TABLES LIKE 'riwayat_bantuan'");
|
||||
check('table riwayat_bantuan exists', $t->num_rows > 0);
|
||||
|
||||
// --- users columns ---
|
||||
if ($conn->query("SHOW TABLES LIKE 'users'")->num_rows > 0) {
|
||||
$cols = [];
|
||||
$r = $conn->query("SHOW COLUMNS FROM users");
|
||||
while ($row = $r->fetch_assoc()) $cols[] = $row['Field'];
|
||||
check('users.username exists', in_array('username', $cols));
|
||||
check('users.role exists', in_array('role', $cols));
|
||||
check('users.must_change_password exists', in_array('must_change_password', $cols));
|
||||
check('users.login_attempts exists', in_array('login_attempts', $cols));
|
||||
check('users.locked_until exists', in_array('locked_until', $cols));
|
||||
check('users.ibadah_id exists', in_array('ibadah_id', $cols));
|
||||
}
|
||||
|
||||
echo "\n--- $pass passed, $fail failed ---\n";
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) {
|
||||
echo "PASS: {$label}\n";
|
||||
$pass++;
|
||||
} else {
|
||||
echo "FAIL: {$label}\n";
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
$docPath = $root . '/docs/deployment-xampp.md';
|
||||
check('deployment XAMPP doc exists', file_exists($docPath));
|
||||
|
||||
if (file_exists($docPath)) {
|
||||
$doc = file_get_contents($docPath);
|
||||
foreach ([
|
||||
'Apache',
|
||||
'PHP',
|
||||
'MySQL',
|
||||
'MariaDB',
|
||||
'DB_PORT',
|
||||
'3307',
|
||||
'setup_database.sql',
|
||||
'admin',
|
||||
'Admin1234',
|
||||
'Leaflet',
|
||||
'Chart.js',
|
||||
'leaflet.heat',
|
||||
'Google Fonts',
|
||||
'OpenStreetMap',
|
||||
'Nominatim',
|
||||
'session timeout',
|
||||
'mirror lokal',
|
||||
'reverse proxy',
|
||||
'Secure',
|
||||
'Lucide',
|
||||
] as $needle) {
|
||||
check("deployment doc mentions {$needle}", strpos($doc, $needle) !== false);
|
||||
}
|
||||
}
|
||||
|
||||
$readme = file_get_contents($root . '/README.md');
|
||||
$nav = file_get_contents($root . '/docs/codebase-navigation.md');
|
||||
check('README links deployment doc', strpos($readme, 'docs/deployment-xampp.md') !== false);
|
||||
check('navigation links deployment doc', strpos($nav, 'deployment-xampp.md') !== false);
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) {
|
||||
echo "PASS: {$label}\n";
|
||||
$pass++;
|
||||
} else {
|
||||
echo "FAIL: {$label}\n";
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
$sopPath = $root . '/docs/business-process-sop.md';
|
||||
check('business SOP exists', file_exists($sopPath));
|
||||
|
||||
$sop = file_exists($sopPath) ? file_get_contents($sopPath) : '';
|
||||
foreach (['Administrator', 'Operator', 'Publik', 'Donatur', 'Verifikasi', 'Proximity', 'Recalculate', 'Catatan'] as $needle) {
|
||||
check("SOP mentions {$needle}", strpos($sop, $needle) !== false);
|
||||
}
|
||||
|
||||
$readme = file_get_contents($root . '/README.md');
|
||||
check('README links business SOP', strpos($readme, 'docs/business-process-sop.md') !== false);
|
||||
|
||||
$design = file_get_contents($root . '/DESIGN.md');
|
||||
check('DESIGN status is not ambiguous', strpos($design, 'Status dokumen') !== false && strpos($design, 'artifact') !== false);
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0; $fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) { echo "PASS: {$label}\n"; $pass++; }
|
||||
else { echo "FAIL: {$label}\n"; $fail++; }
|
||||
}
|
||||
|
||||
$api = file_get_contents($root . '/api/papan/donatur.php');
|
||||
$index = file_get_contents($root . '/index.php');
|
||||
|
||||
check('donatur API reads $action from POST', strpos($api, '$action') !== false);
|
||||
check('donatur API handles action=hapus', strpos($api, "'hapus'") !== false && strpos($api, 'DELETE FROM kontak_donatur') !== false);
|
||||
check('donatur API handles action=mark_one', strpos($api, "'mark_one'") !== false && strpos($api, 'UPDATE kontak_donatur SET is_read=1 WHERE id') !== false);
|
||||
check('donatur API uses prepared statement (bind_param)', strpos($api, 'bind_param') !== false);
|
||||
check('donatur API casts id to int', strpos($api, '(int)') !== false);
|
||||
check('donatur API requires CSRF on all POST', strpos($api, 'require_csrf') !== false);
|
||||
check('markDonorRead in index.php sends action=mark_all', strpos($index, "append('action', 'mark_all')") !== false);
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0; $fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) { echo "PASS: {$label}\n"; $pass++; }
|
||||
else { echo "FAIL: {$label}\n"; $fail++; }
|
||||
}
|
||||
|
||||
$src = file_get_contents($root . '/pages/kebutuhan.php');
|
||||
|
||||
check('kebutuhan page has tab-nav element', strpos($src, 'class="tab-nav"') !== false);
|
||||
check('kebutuhan page has Pesan Donatur tab button', strpos($src, 'Pesan Donatur') !== false);
|
||||
check('kebutuhan page has donaturBadge element', strpos($src, 'donaturBadge') !== false);
|
||||
check('kebutuhan page has tabKebutuhan panel', strpos($src, 'tabKebutuhan') !== false);
|
||||
check('kebutuhan page has tabDonatur panel', strpos($src, 'tabDonatur') !== false);
|
||||
check('kebutuhan page has switchTab function', strpos($src, 'function switchTab') !== false);
|
||||
check('kebutuhan page has loadDonorInbox function', strpos($src, 'function loadDonorInbox') !== false);
|
||||
check('kebutuhan page has renderDonorInbox function', strpos($src, 'function renderDonorInbox') !== false);
|
||||
check('kebutuhan page has buildKontakUrl function', strpos($src, 'function buildKontakUrl') !== false);
|
||||
check('kebutuhan page has hapusDonatur function', strpos($src, 'function hapusDonatur') !== false);
|
||||
check('kebutuhan page has markAllRead function', strpos($src, 'function markAllRead') !== false);
|
||||
check('kebutuhan page fetches donatur API', strpos($src, 'api/papan/donatur.php') !== false);
|
||||
check('kebutuhan page has admin-only guard', strpos($src, '_IS_ADMIN') !== false || strpos($src, 'is_admin') !== false);
|
||||
check('kebutuhan page has wa.me contact URL logic', strpos($src, 'wa.me') !== false);
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$file = __DIR__ . '/../index.php';
|
||||
$src = file_get_contents($file);
|
||||
|
||||
if ($src === false) {
|
||||
echo "FAIL: cannot read index.php\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$must_not_contain = [
|
||||
'const TOTAL_MODULES = 4',
|
||||
'loaded === TOTAL_MODULES',
|
||||
"filemtime(__FILE__)",
|
||||
];
|
||||
|
||||
foreach ($must_not_contain as $needle) {
|
||||
if (strpos($src, $needle) !== false) {
|
||||
echo "FAIL: index.php still contains stale loader marker {$needle}\n";
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$must_contain = [
|
||||
'$map_modules',
|
||||
'modules/kebutuhan.js',
|
||||
'window.MAP_MODULES',
|
||||
'window.MAP_MODULES.length',
|
||||
"filemtime(__DIR__ . '/' . \$module)",
|
||||
];
|
||||
|
||||
foreach ($must_contain as $needle) {
|
||||
if (strpos($src, $needle) === false) {
|
||||
echo "FAIL: index.php missing module loader marker {$needle}\n";
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
echo "PASS: frontend module loader uses dynamic module count and per-file cache busting\n";
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) {
|
||||
echo "PASS: {$label}\n";
|
||||
$pass++;
|
||||
} else {
|
||||
echo "FAIL: {$label}\n";
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
$index = file_get_contents($root . '/index.php');
|
||||
$heatmap = file_get_contents($root . '/modules/heatmap.js');
|
||||
$ibadah = file_get_contents($root . '/modules/ibadah.js');
|
||||
$penduduk = file_get_contents($root . '/modules/penduduk.js');
|
||||
$stats = file_get_contents($root . '/modules/stats.js');
|
||||
$kebutuhan = file_get_contents($root . '/modules/kebutuhan.js');
|
||||
$pagePenduduk = file_get_contents($root . '/pages/penduduk.php');
|
||||
$pageStatusBantuan = file_get_contents($root . '/pages/status-bantuan.php');
|
||||
$pageKebutuhan = file_get_contents($root . '/pages/kebutuhan.php');
|
||||
$dashboard = file_get_contents($root . '/dashboard.php');
|
||||
$pageUsers = file_get_contents($root . '/pages/users.php');
|
||||
$pageAnalisis = file_get_contents($root . '/pages/analisis.php');
|
||||
|
||||
check('module loader has onerror handler', strpos($index, 's.onerror = function ()') !== false);
|
||||
check('module loader reports failed script source', strpos($index, 'Gagal memuat modul') !== false);
|
||||
check('heatmap filters numeric coordinates', strpos($heatmap, 'Number.isFinite(lat) && Number.isFinite(lng)') !== false);
|
||||
check('heatmap rejects null coordinate values', strpos($heatmap, 'rawLat !== null') !== false && strpos($heatmap, 'rawLng !== null') !== false);
|
||||
check('heatmap rejects blank coordinate values', strpos($heatmap, "rawLat !== ''") !== false && strpos($heatmap, "rawLng !== ''") !== false);
|
||||
check('recalc UI uses tb-icon selector', strpos($ibadah, "querySelector('.tb-icon')") !== false);
|
||||
check('recalc UI no longer uses ft-icon selector', strpos($ibadah, "querySelector('.ft-icon')") === false);
|
||||
check('global data changed dispatcher is defined', strpos($index, 'window.dispatchDataChanged') !== false && strpos($index, 'webgis:data-changed') !== false);
|
||||
check('global refreshAllData is defined', strpos($index, 'window.refreshAllData') !== false);
|
||||
check('ibadah exposes reload hook', strpos($ibadah, 'window._ibadahReload') !== false);
|
||||
check('stats update guards missing modalStats', strpos($stats, 'const modal = document.getElementById(\'modalStats\')') !== false && strpos($stats, 'if (!modal) return;') !== false);
|
||||
check('stats show/hide guards missing modalStats', strpos($stats, 'if (!modal) return;') !== false && strpos($stats, 'modal.classList.add(\'show\')') !== false && strpos($stats, 'modal.classList.remove(\'show\')') !== false);
|
||||
check('penduduk exposes all dataset', strpos($penduduk, 'window._pendudukAll') !== false);
|
||||
check('penduduk exposes visible dataset', strpos($penduduk, 'window._pendudukVisible') !== false);
|
||||
check('heatmap uses all penduduk dataset by default', strpos($heatmap, 'window._pendudukAll') !== false);
|
||||
check('heatmap blocks public viewer use', strpos($heatmap, '!window.APP_USER?.isOp') !== false && strpos($heatmap, '!window.APP_USER?.isAdmin') !== false);
|
||||
check('penduduk popup passes verification status to kebutuhan section', strpos($penduduk, '_kebutuhanBuildSection(data.id, data.kebutuhan_open, sv)') !== false);
|
||||
check('penduduk popup shows waiting message for unverified workflow actions', strpos($penduduk, 'Menunggu verifikasi admin') !== false);
|
||||
check('kebutuhan popup section accepts verification status', strpos($kebutuhan, 'function (pendudukId, kebutuhanOpen, statusVerifikasi') !== false);
|
||||
check('kebutuhan popup disables mutation controls for unverified warga', strpos($kebutuhan, "statusVerifikasi === 'Terverifikasi'") !== false && strpos($kebutuhan, 'Menunggu verifikasi admin') !== false);
|
||||
check('penduduk page disables status bantuan for unverified warga', strpos($pagePenduduk, 'Menunggu verifikasi admin') !== false && strpos($pagePenduduk, 'disabled') !== false);
|
||||
check('status bantuan page filters only verified warga', strpos($pageStatusBantuan, "status_verifikasi === 'Terverifikasi'") !== false);
|
||||
check('kebutuhan page filters only verified warga', strpos($pageKebutuhan, "status_verifikasi === 'Terverifikasi'") !== false);
|
||||
check('dashboard notification panel uses unique id', strpos($dashboard, 'id="dash-notif-list"') !== false && strpos($dashboard, "getElementById('dash-notif-list')") !== false);
|
||||
check('dashboard boot waits for external scripts', strpos($dashboard, "window.addEventListener('load'") !== false && strpos($dashboard, 'loadStats();') !== false && strpos($dashboard, 'loadTren();') !== false);
|
||||
check('dashboard notifications use canonical endpoint', strpos($dashboard, "fetch('api/notif/ambil.php") !== false && strpos($dashboard, 'loadDashboardNotifs();') !== false);
|
||||
check('dashboard no longer derives notification badge from donor unread only', strpos($dashboard, 'const unread = parseInt(d.donatur_unread') === false);
|
||||
check('dashboard notification rows are clickable links', strpos($dashboard, '<a href="${esc(it.page || \'#\')}" class="notif-item">') !== false);
|
||||
check('penduduk table verification asks confirmation', strpos($pagePenduduk, 'Konfirmasi Verifikasi') !== false && strpos($pagePenduduk, 'await showConfirm') !== false && strpos($pagePenduduk, 'if (!ok) return;') !== false);
|
||||
check('penduduk map verification asks confirmation', strpos($penduduk, 'Konfirmasi Verifikasi') !== false && strpos($penduduk, 'showDeleteConfirm') !== false && strpos($penduduk, 'if (!confirmed) return;') !== false);
|
||||
check('user active toggle asks confirmation', strpos($pageUsers, 'await showConfirm') !== false && strpos($pageUsers, 'Nonaktifkan Akun') !== false && strpos($pageUsers, 'Aktifkan Akun') !== false && strpos($pageUsers, 'if (!ok) return;') !== false);
|
||||
check('analysis recalculate asks confirmation', strpos($pageAnalisis, 'Hitung Ulang Proximity') !== false && strpos($pageAnalisis, 'await showConfirm') !== false && strpos($pageAnalisis, 'if (!ok) return;') !== false);
|
||||
check('map recalculate asks confirmation', strpos($ibadah, 'Konfirmasi Hitung Ulang Proximity') !== false && strpos($ibadah, "btnLabel: 'Ya, Hitung Ulang'") !== false && strpos($ibadah, 'showDeleteConfirm') !== false && strpos($ibadah, 'if (!confirmed) return;') !== false);
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
// tests/test_ibadah.php — Smoke test API ibadah
|
||||
header('Content-Type: text/plain');
|
||||
if (PHP_SAPI === 'cli') {
|
||||
$session_path = __DIR__ . '/../tmp/test-sessions';
|
||||
if (!is_dir($session_path)) {
|
||||
mkdir($session_path, 0777, true);
|
||||
}
|
||||
session_save_path($session_path);
|
||||
}
|
||||
require_once '../config.php';
|
||||
require_once '../auth/helper.php';
|
||||
require_once '../koneksi.php';
|
||||
|
||||
$pass = 0; $fail = 0;
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) { echo "PASS $label\n"; $pass++; }
|
||||
else { echo "FAIL $label\n"; $fail++; }
|
||||
}
|
||||
|
||||
// Test 1: schema punya deleted_at
|
||||
$cols = [];
|
||||
$r = $conn->query("SHOW COLUMNS FROM rumah_ibadah");
|
||||
while ($row = $r->fetch_assoc()) $cols[] = $row['Field'];
|
||||
check('rumah_ibadah.deleted_at ada', in_array('deleted_at', $cols));
|
||||
|
||||
// Test 2: query dengan filter deleted_at IS NULL bekerja
|
||||
$r2 = $conn->query("SELECT COUNT(*) AS n FROM rumah_ibadah WHERE deleted_at IS NULL");
|
||||
$active = (int)$r2->fetch_assoc()['n'];
|
||||
$r3 = $conn->query("SELECT COUNT(*) AS n FROM rumah_ibadah");
|
||||
$all = (int)$r3->fetch_assoc()['n'];
|
||||
check('query deleted_at IS NULL tidak error', $active <= $all);
|
||||
|
||||
// Test 3: validasi radius 100-5000
|
||||
$clamp_low = max(100, min(5000, 50));
|
||||
$clamp_high = max(100, min(5000, 6000));
|
||||
check('radius < 100 di-clamp ke 100', $clamp_low === 100);
|
||||
check('radius > 5000 di-clamp ke 5000', $clamp_high === 5000);
|
||||
|
||||
$hapus = file_get_contents(__DIR__ . '/../api/ibadah/hapus.php');
|
||||
$kontak = file_get_contents(__DIR__ . '/../api/ibadah/update_kontak.php');
|
||||
$update = file_get_contents(__DIR__ . '/../api/ibadah/update.php');
|
||||
$posisi = file_get_contents(__DIR__ . '/../api/ibadah/update_posisi.php');
|
||||
$radius = file_get_contents(__DIR__ . '/../api/ibadah/update_radius.php');
|
||||
check('hapus filters deleted rows', strpos($hapus, 'AND deleted_at IS NULL') !== false);
|
||||
check('hapus checks affected rows before recalc', strpos($hapus, 'affected_rows') !== false);
|
||||
check('hapus returns deleted/not-found message', strpos($hapus, 'Data tidak ditemukan atau sudah dihapus') !== false);
|
||||
check('hapus checks active ibadah before operator warning', strpos($hapus, 'SELECT id FROM rumah_ibadah') !== false && strpos($hapus, 'SELECT id FROM rumah_ibadah') < strpos($hapus, 'SELECT nama_lengkap FROM users'));
|
||||
check('update_kontak filters deleted rows', strpos($kontak, 'AND deleted_at IS NULL') !== false);
|
||||
check('update_kontak checks active row before update', strpos($kontak, 'SELECT id FROM rumah_ibadah') !== false && strpos($kontak, 'SELECT id FROM rumah_ibadah') < strpos($kontak, 'UPDATE rumah_ibadah'));
|
||||
check('update_kontak returns deleted/not-found message', strpos($kontak, 'Data tidak ditemukan atau sudah dihapus') !== false);
|
||||
foreach ([
|
||||
'update.php' => $update,
|
||||
'update_posisi.php' => $posisi,
|
||||
'update_radius.php' => $radius,
|
||||
] as $file => $src) {
|
||||
check("{$file} checks active row before update", strpos($src, 'SELECT id FROM rumah_ibadah') !== false && strpos($src, 'SELECT id FROM rumah_ibadah') < strpos($src, 'UPDATE rumah_ibadah'));
|
||||
check("{$file} does not treat no-op affected_rows as not found", strpos($src, 'affected_rows === 0') === false);
|
||||
}
|
||||
|
||||
echo "\n--- $pass passed, $fail failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
require_once '../config.php';
|
||||
require_once '../koneksi.php';
|
||||
|
||||
$pass = 0; $fail = 0;
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) { echo "✅ $label\n"; $pass++; }
|
||||
else { echo "❌ $label\n"; $fail++; }
|
||||
}
|
||||
|
||||
// ── Tabel kebutuhan ────────────────────────────────────────────────────────
|
||||
$cols = [];
|
||||
$r = $conn->query("SHOW COLUMNS FROM kebutuhan");
|
||||
while ($row = $r->fetch_assoc()) $cols[] = $row['Field'];
|
||||
check('kebutuhan.id exists', in_array('id', $cols));
|
||||
check('kebutuhan.penduduk_id exists', in_array('penduduk_id', $cols));
|
||||
check('kebutuhan.kategori exists', in_array('kategori', $cols));
|
||||
check('kebutuhan.deskripsi exists', in_array('deskripsi', $cols));
|
||||
check('kebutuhan.status exists', in_array('status', $cols));
|
||||
check('kebutuhan.created_by exists', in_array('created_by', $cols));
|
||||
|
||||
// ── Tabel riwayat_kebutuhan ────────────────────────────────────────────────
|
||||
$cols2 = [];
|
||||
$r2 = $conn->query("SHOW COLUMNS FROM riwayat_kebutuhan");
|
||||
while ($row = $r2->fetch_assoc()) $cols2[] = $row['Field'];
|
||||
check('riwayat_kebutuhan.kebutuhan_id exists', in_array('kebutuhan_id', $cols2));
|
||||
check('riwayat_kebutuhan.operator_id exists', in_array('operator_id', $cols2));
|
||||
check('riwayat_kebutuhan.status_baru exists', in_array('status_baru', $cols2));
|
||||
|
||||
// ── Tabel kontak_donatur ───────────────────────────────────────────────────
|
||||
$cols3 = [];
|
||||
$r3 = $conn->query("SHOW COLUMNS FROM kontak_donatur");
|
||||
while ($row = $r3->fetch_assoc()) $cols3[] = $row['Field'];
|
||||
check('kontak_donatur.nama exists', in_array('nama', $cols3));
|
||||
check('kontak_donatur.kontak exists', in_array('kontak', $cols3));
|
||||
check('kontak_donatur.is_read exists', in_array('is_read', $cols3));
|
||||
|
||||
// ── Index kebutuhan ────────────────────────────────────────────────────────
|
||||
$idx = $conn->query("SHOW INDEX FROM kebutuhan");
|
||||
$idx_names = [];
|
||||
while ($row = $idx->fetch_assoc()) $idx_names[] = $row['Key_name'];
|
||||
check('kebutuhan penduduk index exists', in_array('idx_k_penduduk', $idx_names) || in_array('idx_penduduk', $idx_names));
|
||||
check('kebutuhan status index exists', in_array('idx_k_status', $idx_names) || in_array('idx_status', $idx_names));
|
||||
|
||||
// ── Default status = Belum Terpenuhi ─────────────────────────────────────
|
||||
$default_status = $conn->query(
|
||||
"SELECT COLUMN_DEFAULT FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='kebutuhan' AND COLUMN_NAME='status'"
|
||||
)->fetch_assoc()['COLUMN_DEFAULT'];
|
||||
$default_status = trim((string)$default_status, "'");
|
||||
check("kebutuhan.status default = 'Belum Terpenuhi'", $default_status === 'Belum Terpenuhi');
|
||||
|
||||
// ── kontak_donatur.is_read default = 0 ───────────────────────────────────
|
||||
$default_read = $conn->query(
|
||||
"SELECT COLUMN_DEFAULT FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='kontak_donatur' AND COLUMN_NAME='is_read'"
|
||||
)->fetch_assoc()['COLUMN_DEFAULT'];
|
||||
check("kontak_donatur.is_read default = 0", $default_read === '0');
|
||||
|
||||
// ── ambil.php LEFT JOIN returns kebutuhan_open column ─────────────────────
|
||||
$test_sql = "
|
||||
SELECT pm.id, COALESCE(kstat.kebutuhan_open, 0) AS kebutuhan_open
|
||||
FROM penduduk_miskin pm
|
||||
LEFT JOIN (
|
||||
SELECT penduduk_id, SUM(CASE WHEN status='Belum Terpenuhi' THEN 1 ELSE 0 END) AS kebutuhan_open
|
||||
FROM kebutuhan GROUP BY penduduk_id
|
||||
) kstat ON kstat.penduduk_id = pm.id
|
||||
WHERE pm.is_active = 1 AND pm.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
";
|
||||
$q = $conn->query($test_sql);
|
||||
check('kebutuhan LEFT JOIN query executes without error', $q !== false);
|
||||
|
||||
echo "\n--- $pass passed, $fail failed ---\n";
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) {
|
||||
echo "PASS: {$label}\n";
|
||||
$pass++;
|
||||
} else {
|
||||
echo "FAIL: {$label}\n";
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
$map = file_get_contents($root . '/pages/map.php');
|
||||
$pageStart = file_get_contents($root . '/includes/page-start.php');
|
||||
$index = file_get_contents($root . '/index.php');
|
||||
$dashboard = file_get_contents($root . '/dashboard.php');
|
||||
$laporan = file_get_contents($root . '/pages/laporan.php');
|
||||
|
||||
check(
|
||||
'pages/map.php requires login before admin layout',
|
||||
strpos($map, "if (!is_logged_in()") !== false
|
||||
&& strpos($map, "header('Location: ../auth/login.php')") !== false
|
||||
);
|
||||
check('sidebar has administrator branch', strpos($pageStart, "\$_nav_role === 'administrator'") !== false);
|
||||
check('sidebar has operator branch', strpos($pageStart, "\$_nav_role === 'operator'") !== false);
|
||||
check('admin-only import is inside administrator branch', strpos($pageStart, "pages/import.php") !== false && strpos($pageStart, "\$_nav_role === 'administrator'") !== false);
|
||||
check('operator map dropdown avoids administrator dashboard', strpos($index, "has_role('administrator')") !== false && strpos($index, 'pages/status-bantuan.php') !== false);
|
||||
check('dashboard has no href dead link', strpos($dashboard, 'href="#"') === false);
|
||||
check('laporan export buttons are disabled before selection', strpos($laporan, 'aria-disabled="true"') !== false);
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) {
|
||||
echo "PASS: {$label}\n";
|
||||
$pass++;
|
||||
} else {
|
||||
echo "FAIL: {$label}\n";
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
$removed_paths = [
|
||||
'modules/point.js',
|
||||
'api/point',
|
||||
'ambil_data.php',
|
||||
'simpan.php',
|
||||
'hapus.php',
|
||||
'update_posisi.php',
|
||||
];
|
||||
|
||||
foreach ($removed_paths as $path) {
|
||||
check("{$path} removed", !file_exists($root . '/' . $path));
|
||||
}
|
||||
|
||||
$scan_files = [
|
||||
'index.php',
|
||||
'modules/penduduk.js',
|
||||
'includes/page-start.php',
|
||||
'setup_database.sql',
|
||||
'README.md',
|
||||
'docs/codebase-navigation.md',
|
||||
];
|
||||
|
||||
$forbidden = [
|
||||
'lokasi_usaha',
|
||||
'modules/point.js',
|
||||
'api/point',
|
||||
'initPoint',
|
||||
'_point',
|
||||
'ambil_data.php',
|
||||
'nama_tempat',
|
||||
'searchPoi',
|
||||
'Data POI',
|
||||
'Pengaturan Sistem',
|
||||
'Aktivitas & Audit Log',
|
||||
'POI/business',
|
||||
];
|
||||
|
||||
foreach ($scan_files as $file) {
|
||||
$content = file_get_contents($root . '/' . $file);
|
||||
$content = str_replace('test_no_legacy_poi.php', 'test_no_legacy_regression.php', $content);
|
||||
foreach ($forbidden as $needle) {
|
||||
check("{$file} has no {$needle}", strpos($content, $needle) === false);
|
||||
}
|
||||
}
|
||||
|
||||
$nav = file_get_contents($root . '/includes/page-start.php');
|
||||
check('sidebar has no visible placeholder href menu item', strpos($nav, "_sb_item('#'") === false);
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) {
|
||||
echo "PASS: {$label}\n";
|
||||
$pass++;
|
||||
} else {
|
||||
echo "FAIL: {$label}\n";
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
$index = file_get_contents($root . '/index.php');
|
||||
$ibadah = file_get_contents($root . '/modules/ibadah.js');
|
||||
|
||||
check('APP_USER exposes operator ibadah id', strpos($index, 'ibadahId: <?= json_encode($is_op ? get_ibadah_id() : null) ?>') !== false);
|
||||
check('ibadah module can detect current operator marker', strpos($ibadah, 'function isOwnOperatorIbadah(id)') !== false);
|
||||
check('operator highlight uses gold accent', strpos($ibadah, 'OPERATOR_HIGHLIGHT') !== false && strpos($ibadah, '#f59e0b') !== false);
|
||||
check('operator marker icon receives own marker flag', strpos($ibadah, 'createIbadahIcon(jenis, ownOperatorIbadah)') !== false);
|
||||
check('operator circle uses highlighted style', strpos($ibadah, 'ownOperatorIbadah ? OPERATOR_HIGHLIGHT.stroke : clr.stroke') !== false);
|
||||
check('operator marker gets higher z-index', strpos($ibadah, 'ownOperatorIbadah ? 1000 : 100') !== false);
|
||||
check('operator popup shows own ibadah badge', strpos($ibadah, 'Rumah ibadah Anda') !== false);
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$files = [
|
||||
'api/penduduk/update_status.php',
|
||||
'api/penduduk/riwayat.php',
|
||||
'api/kebutuhan/ambil.php',
|
||||
'api/kebutuhan/simpan.php',
|
||||
'api/kebutuhan/update_status.php',
|
||||
];
|
||||
|
||||
foreach ($files as $relative) {
|
||||
$path = __DIR__ . '/../' . $relative;
|
||||
$src = file_get_contents($path);
|
||||
|
||||
if ($src === false) {
|
||||
echo "FAIL: cannot read {$relative}\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$checks = [
|
||||
'operator auth' => "require_auth('operator')",
|
||||
'admin bypass' => "has_role('administrator')",
|
||||
'operator ibadah lookup' => 'get_ibadah_id()',
|
||||
'forbidden status code' => 'http_response_code(403)',
|
||||
];
|
||||
|
||||
foreach ($checks as $label => $needle) {
|
||||
if (strpos($src, $needle) === false) {
|
||||
echo "FAIL: {$relative} missing {$label}\n";
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'api/kebutuhan/simpan.php',
|
||||
'api/kebutuhan/update_status.php',
|
||||
'api/penduduk/update_status.php',
|
||||
] as $relative) {
|
||||
$src = file_get_contents(__DIR__ . '/../' . $relative);
|
||||
if (strpos($src, 'status_verifikasi') === false) {
|
||||
echo "FAIL: {$relative} missing verification status guard\n";
|
||||
exit(1);
|
||||
}
|
||||
if (strpos($src, 'Terverifikasi') === false) {
|
||||
echo "FAIL: {$relative} missing Terverifikasi comparison\n";
|
||||
exit(1);
|
||||
}
|
||||
if (strpos($src, 'Data warga belum terverifikasi') === false) {
|
||||
echo "FAIL: {$relative} missing unverified warga error message\n";
|
||||
exit(1);
|
||||
}
|
||||
$guardPos = strpos($src, "!== 'Terverifikasi'");
|
||||
$writeNeedle = str_contains($relative, 'simpan.php')
|
||||
? 'INSERT INTO kebutuhan'
|
||||
: (str_contains($relative, 'kebutuhan') ? 'UPDATE kebutuhan' : 'UPDATE penduduk_miskin');
|
||||
$writePos = strpos($src, $writeNeedle);
|
||||
if ($guardPos === false || $writePos === false || $guardPos > $writePos) {
|
||||
echo "FAIL: {$relative} verification guard must run before {$writeNeedle}\n";
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
echo "PASS: operator scoped endpoints include auth, scope checks, and 403 responses\n";
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) {
|
||||
echo "PASS: {$label}\n";
|
||||
$pass++;
|
||||
} else {
|
||||
echo "FAIL: {$label}\n";
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
$simpan = file_get_contents($root . '/api/penduduk/simpan.php');
|
||||
$move = file_get_contents($root . '/api/penduduk/update_posisi.php');
|
||||
$stats = file_get_contents($root . '/api/stats/ambil.php');
|
||||
|
||||
check(
|
||||
'simpan rejects operator without linked ibadah',
|
||||
strpos($simpan, 'Operator belum dikaitkan dengan rumah ibadah') !== false
|
||||
);
|
||||
|
||||
check(
|
||||
'simpan rejects calculated ibadah outside operator scope',
|
||||
strpos($simpan, 'Lokasi warga berada di luar wilayah rumah ibadah operator') !== false
|
||||
);
|
||||
|
||||
check(
|
||||
'simpan checks proximity result against operator ibadah',
|
||||
strpos($simpan, '(int)$p[\'ibadah_id\'] !== $op_ibadah_id') !== false
|
||||
);
|
||||
|
||||
check(
|
||||
'update_posisi checks new proximity result against operator ibadah',
|
||||
strpos($move, '(int)$p[\'ibadah_id\'] !== $linked_ibadah_id') !== false
|
||||
);
|
||||
|
||||
check(
|
||||
'update_posisi rejects move outside operator scope',
|
||||
strpos($move, 'Posisi baru berada di luar wilayah rumah ibadah operator') !== false
|
||||
);
|
||||
|
||||
check(
|
||||
'stats defines operator scope sql',
|
||||
strpos($stats, '$operator_scope_pm_sql') !== false && strpos($stats, 'get_ibadah_id()') !== false
|
||||
);
|
||||
|
||||
check(
|
||||
'stats applies operator scope to penduduk aliases',
|
||||
strpos($stats, 'pm.ibadah_id') !== false && strpos($stats, 'AND 1 = 0') !== false
|
||||
);
|
||||
|
||||
check(
|
||||
'stats scopes total ibadah for operators',
|
||||
strpos($stats, 'id = ' . '$operator_ibadah_id') !== false || strpos($stats, 'id = {$operator_ibadah_id}') !== false
|
||||
);
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
require_once '../config.php';
|
||||
require_once '../koneksi.php';
|
||||
|
||||
$pass = 0; $fail = 0;
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) { echo "✅ $label\n"; $pass++; }
|
||||
else { echo "❌ $label\n"; $fail++; }
|
||||
}
|
||||
|
||||
// Test 1: kolom baru ada
|
||||
$cols = [];
|
||||
$r = $conn->query("SHOW COLUMNS FROM penduduk_miskin");
|
||||
while ($row = $r->fetch_assoc()) $cols[] = $row['Field'];
|
||||
check('penduduk_miskin.catatan exists', in_array('catatan', $cols));
|
||||
check('penduduk_miskin.status_bantuan exists', in_array('status_bantuan', $cols));
|
||||
check('penduduk_miskin.is_active exists', in_array('is_active', $cols));
|
||||
check('penduduk_miskin.deleted_at exists', in_array('deleted_at', $cols));
|
||||
|
||||
// Test 2: NIK UNIQUE constraint ada
|
||||
$r = $conn->query("SHOW INDEX FROM penduduk_miskin WHERE Key_name = 'uq_nik'");
|
||||
check('NIK unique index ada', $r->num_rows > 0);
|
||||
|
||||
// Test 3: ambil query hanya mengembalikan is_active=1 dan deleted_at IS NULL
|
||||
$conn->query(
|
||||
"INSERT IGNORE INTO penduduk_miskin (nama_kk, jumlah_jiwa, kategori, lat, lng, is_active)
|
||||
VALUES ('__test_inactive__', 1, 'Miskin', 0, 0, 0)"
|
||||
);
|
||||
$test_id = $conn->insert_id;
|
||||
$n_active = (int)$conn->query(
|
||||
"SELECT COUNT(*) AS n FROM penduduk_miskin WHERE is_active=1 AND deleted_at IS NULL"
|
||||
)->fetch_assoc()['n'];
|
||||
$n_all = (int)$conn->query(
|
||||
"SELECT COUNT(*) AS n FROM penduduk_miskin"
|
||||
)->fetch_assoc()['n'];
|
||||
check('inactive warga tidak tampil di query aktif', $n_all > $n_active);
|
||||
|
||||
// Cleanup
|
||||
if ($test_id) $conn->query("DELETE FROM penduduk_miskin WHERE id = $test_id");
|
||||
|
||||
echo "\n--- $pass passed, $fail failed ---\n";
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) {
|
||||
echo "PASS: {$label}\n";
|
||||
$pass++;
|
||||
} else {
|
||||
echo "FAIL: {$label}\n";
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
$simpan = file_get_contents($root . '/api/penduduk/simpan.php');
|
||||
$import = file_get_contents($root . '/api/import/csv.php');
|
||||
|
||||
check(
|
||||
'penduduk simpan bind types keep jarak_m double and is_blank_spot integer',
|
||||
strpos($simpan, "bind_param(\n 'ssisssddidisis'") !== false
|
||||
);
|
||||
|
||||
check(
|
||||
'import csv bind types include verification fields after proximity fields',
|
||||
strpos($import, "bind_param(\n 'ssisssddidisis'") !== false
|
||||
);
|
||||
|
||||
check(
|
||||
'import csv inserts verification metadata',
|
||||
strpos($import, 'status_verifikasi, verified_by, verified_at') !== false
|
||||
&& strpos($import, "\$status_verif = 'Terverifikasi'") !== false
|
||||
&& strpos($import, '$verified_by = get_user_id()') !== false
|
||||
);
|
||||
|
||||
check(
|
||||
'penduduk simpan duplicate NIK checks archived records too',
|
||||
preg_match('/SELECT\s+id,\s+nama_kk\s+FROM\s+penduduk_miskin\s+WHERE\s+nik\s+=\s+\?\s+LIMIT\s+1/s', $simpan) === 1
|
||||
);
|
||||
|
||||
check(
|
||||
'penduduk simpan duplicate NIK message points admin to archive',
|
||||
strpos($simpan, 'pernah terdaftar') !== false && strpos($simpan, 'arsip') !== false
|
||||
);
|
||||
|
||||
check(
|
||||
'import csv duplicate NIK checks archived records too',
|
||||
preg_match('/SELECT\s+id\s+FROM\s+penduduk_miskin\s+WHERE\s+nik=\?\s+LIMIT\s+1/s', $import) === 1
|
||||
);
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
require_once '../config.php';
|
||||
require_once '../koneksi.php';
|
||||
|
||||
$pass = 0; $fail = 0;
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) { echo "✅ $label\n"; $pass++; }
|
||||
else { echo "❌ $label\n"; $fail++; }
|
||||
}
|
||||
|
||||
// Test 1: haversine helper ada
|
||||
check('haversine_distance() ada', function_exists('haversine_distance'));
|
||||
|
||||
// Test 2: jarak titik ke dirinya sendiri = 0
|
||||
$d = haversine_distance(-0.0557, 109.3487, -0.0557, 109.3487);
|
||||
check('haversine jarak sama = 0', $d < 0.001);
|
||||
|
||||
// Test 3: ~1 derajat latitude ≈ 111 km
|
||||
$d2 = haversine_distance(0, 0, 1, 0);
|
||||
check('haversine ~111km per derajat', $d2 > 110000 && $d2 < 112000);
|
||||
|
||||
// Test 4: tie-breaking — dua ibadah di titik identik → id lebih kecil menang
|
||||
$conn->query("INSERT INTO rumah_ibadah (nama, jenis, lat, lng, radius_m)
|
||||
VALUES ('__test_A__','Masjid', -0.05, 109.34, 5000)");
|
||||
$id_a = $conn->insert_id;
|
||||
$conn->query("INSERT INTO rumah_ibadah (nama, jenis, lat, lng, radius_m)
|
||||
VALUES ('__test_B__','Masjid', -0.05, 109.34, 5000)");
|
||||
$id_b = $conn->insert_id;
|
||||
|
||||
$p = calc_proximity($conn, -0.05, 109.34);
|
||||
check('tie-breaking: ibadah id terkecil menang', (int)$p['ibadah_id'] === (int)$id_a);
|
||||
check('tie-breaking: is_blank_spot=0', $p['is_blank_spot'] === 0);
|
||||
|
||||
// Cleanup
|
||||
$conn->query("DELETE FROM rumah_ibadah WHERE nama IN ('__test_A__','__test_B__')");
|
||||
|
||||
echo "\n--- $pass passed, $fail failed ---\n";
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) {
|
||||
echo "PASS: {$label}\n";
|
||||
$pass++;
|
||||
} else {
|
||||
echo "FAIL: {$label}\n";
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
$endpoint = file_get_contents($root . '/api/papan/kontak_donatur.php');
|
||||
|
||||
check('donor endpoint starts session for rate limit', strpos($endpoint, 'session_start') !== false || strpos($endpoint, 'auth/helper.php') !== false);
|
||||
check('donor endpoint records last submission time', strpos($endpoint, 'donor_last_submit') !== false);
|
||||
check('donor endpoint returns 429 on too many submissions', strpos($endpoint, 'http_response_code(429)') !== false);
|
||||
check('donor endpoint has simple time window', strpos($endpoint, 'time()') !== false && strpos($endpoint, '60') !== false);
|
||||
check('donor endpoint uses client IP for server-side rate limit', strpos($endpoint, 'REMOTE_ADDR') !== false && strpos($endpoint, 'hash(') !== false);
|
||||
check('donor endpoint stores server-side rate limit file', strpos($endpoint, 'file_put_contents') !== false && strpos($endpoint, 'donor-rate-limit') !== false);
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$files = [
|
||||
'api/stats/ambil.php' => [
|
||||
'pm.status_verifikasi',
|
||||
'$public_verif_sql',
|
||||
'$public_verif_pm_sql',
|
||||
],
|
||||
'api/papan/ambil.php' => [
|
||||
"pm.status_verifikasi = 'Terverifikasi'",
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($files as $relative => $needles) {
|
||||
$path = __DIR__ . '/../' . $relative;
|
||||
$src = file_get_contents($path);
|
||||
|
||||
if ($src === false) {
|
||||
echo "FAIL: cannot read {$relative}\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
foreach ($needles as $needle) {
|
||||
if (strpos($src, $needle) === false) {
|
||||
echo "FAIL: {$relative} missing public verification filter marker {$needle}\n";
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$board = file_get_contents(__DIR__ . '/../papan-kebutuhan.php');
|
||||
if (strpos($board, 'data-kategori') === false) {
|
||||
echo "FAIL: papan-kebutuhan.php missing data-kategori handler\n";
|
||||
exit(1);
|
||||
}
|
||||
if (strpos($board, 'addEventListener') === false) {
|
||||
echo "FAIL: papan-kebutuhan.php missing delegated event listener\n";
|
||||
exit(1);
|
||||
}
|
||||
if (strpos($board, "openForm('\${esc(row.kategori)}')") !== false) {
|
||||
echo "FAIL: papan-kebutuhan.php still uses inline JS string category handler\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "PASS: public stats and board endpoints filter unverified penduduk\n";
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
|
||||
function check($label, $ok) {
|
||||
global $pass, $fail;
|
||||
if ($ok) {
|
||||
echo "PASS: {$label}\n";
|
||||
$pass++;
|
||||
} else {
|
||||
echo "FAIL: {$label}\n";
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'api/ibadah/update.php',
|
||||
'api/ibadah/update_posisi.php',
|
||||
'api/ibadah/update_radius.php',
|
||||
'api/ibadah/hapus.php',
|
||||
'api/ibadah/recalculate.php',
|
||||
] as $file) {
|
||||
$content = file_get_contents($root . '/' . $file);
|
||||
check("{$file} starts transaction", strpos($content, 'begin_transaction') !== false);
|
||||
check("{$file} commits transaction", strpos($content, 'commit()') !== false);
|
||||
check("{$file} rolls back transaction", strpos($content, 'rollback()') !== false);
|
||||
}
|
||||
|
||||
echo "\n--- {$pass} passed, {$fail} failed ---\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user