Fix: Convert 03PovertyMapping from submodule to regular folder
This commit is contained in:
Submodule 03PovertyMapping deleted from 7f555a2494
@@ -0,0 +1,20 @@
|
|||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Config sensitive
|
||||||
|
config/config.local.php
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.cache
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
FROM php:8.2-apache
|
||||||
|
|
||||||
|
RUN docker-php-ext-install mysqli pdo pdo_mysql
|
||||||
|
|
||||||
|
COPY . /var/www/html/
|
||||||
|
|
||||||
|
RUN chown -R www-data:www-data /var/www/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 cindyrasl
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# WebGIS Poverty Mapping
|
||||||
|
|
||||||
|
Sistem Informasi Geografis (GIS) berbasis web untuk memetakan, menganalisis, dan mengelola data kesejahteraan sosial (kemiskinan) pada tingkat rumah tangga. Aplikasi ini membantu petugas di lapangan dan administrator dalam melakukan pendataan secara visual melalui peta interaktif.
|
||||||
|
|
||||||
|
## Fitur Utama
|
||||||
|
|
||||||
|
- 🗺️ **Peta Geografis Interaktif:** Visualisasi data rumah tangga dan fasilitas ibadah menggunakan Leaflet.js.
|
||||||
|
- 👥 **Manajemen Data Rumah Tangga:** Pengelolaan data keluarga, pekerjaan, hingga perhitungan status kemiskinan secara otomatis.
|
||||||
|
- 🎁 **Riwayat Bantuan:** Pencatatan distribusi bantuan sosial per rumah tangga.
|
||||||
|
- 📢 **Laporan Publik:** Masyarakat umum dapat melapor kasus kemiskinan dengan menyertakan bukti foto (tanpa perlu login).
|
||||||
|
- 📷 **Unggah Foto:** Mendukung unggah foto rumah dan foto bukti laporan.
|
||||||
|
- 🔐 **Hak Akses Berjenjang:** Sistem memiliki hak akses untuk Admin dan Petugas Lapangan.
|
||||||
|
- 📈 **Dasbor Analitik:** Grafik statistik dan Key Performance Indicator (KPI) terkait data kemiskinan (khusus Admin).
|
||||||
|
- 📱 **Desain Responsif:** Tampilan yang menyesuaikan dengan baik di perangkat desktop maupun seluler (mobile-friendly).
|
||||||
|
|
||||||
|
## Teknologi yang Digunakan
|
||||||
|
|
||||||
|
- **Frontend:** HTML5, Vanilla CSS, Vanilla JavaScript
|
||||||
|
- **Peta & Grafik:** Leaflet.js, Chart.js
|
||||||
|
- **Backend:** PHP 8.1+ (Native)
|
||||||
|
- **Database:** MySQL 8.0+ / MariaDB 10.6+
|
||||||
|
|
||||||
|
## Cara Menjalankan Aplikasi
|
||||||
|
|
||||||
|
1. **Persiapan:** Pastikan Anda telah menginstal server lokal seperti XAMPP atau Laragon yang mendukung PHP 8.1+ dan MySQL.
|
||||||
|
2. **Salin Folder:** Tempatkan folder proyek ini ke dalam direktori server web Anda (misalnya `htdocs` untuk XAMPP).
|
||||||
|
3. **Database:**
|
||||||
|
- Buat database baru di phpMyAdmin.
|
||||||
|
- Impor file SQL yang tersedia (berisi struktur tabel dan data awal).
|
||||||
|
4. **Konfigurasi:**
|
||||||
|
- Sesuaikan konfigurasi koneksi database Anda di dalam file `config/config.php`.
|
||||||
|
5. **Jalankan:**
|
||||||
|
- Akses aplikasi melalui browser dengan mengunjungi URL lokal Anda (misal: `http://localhost/PovertyMapping/login.html`).
|
||||||
|
|
||||||
|
## Penggunaan & Hak Akses
|
||||||
|
|
||||||
|
Sistem menggunakan login berupa **Username** dan **Password** (tidak menggunakan format email). Terdapat tiga jenis interaksi utama:
|
||||||
|
1. **Admin:** Memiliki akses penuh termasuk dasbor statistik, kelola pengguna, dan persetujuan laporan publik.
|
||||||
|
2. **Petugas Lapangan:** Dapat menambah dan mengedit data rumah tangga serta mencatat penyaluran bantuan di lapangan.
|
||||||
|
3. **Masyarakat (Publik):** Hanya dapat mengakses halaman Laporan Publik untuk mengirim laporan bantuan darurat.
|
||||||
|
|
||||||
|
> **Catatan Keamanan:** Sistem sudah dilengkapi dengan perlindungan dasar terhadap SQL Injection (menggunakan PDO Prepared Statements), XSS, dan sistem pembatasan laju (Rate Limiting) untuk laporan publik.
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// api/aid/index.php — Aid History (requireAuth on all)
|
||||||
|
// ============================================================
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../config/bootstrap.php';
|
||||||
|
|
||||||
|
requireAuth();
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? 'list';
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||||
|
|
||||||
|
switch ("$method:$action") {
|
||||||
|
|
||||||
|
case 'GET:list':
|
||||||
|
case 'GET:': {
|
||||||
|
$pdo = Database::get();
|
||||||
|
$where = ['1=1'];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (!empty($_GET['household_id'])) { $where[] = 'ah.household_id = ?'; $params[] = (int)$_GET['household_id']; }
|
||||||
|
if (!empty($_GET['center_id'])) { $where[] = 'ah.center_id = ?'; $params[] = (int)$_GET['center_id']; }
|
||||||
|
if (!empty($_GET['aid_type'])) { $where[] = 'ah.aid_type = ?'; $params[] = $_GET['aid_type']; }
|
||||||
|
if (!empty($_GET['from'])) { $where[] = 'ah.aid_date >= ?'; $params[] = $_GET['from']; }
|
||||||
|
if (!empty($_GET['to'])) { $where[] = 'ah.aid_date <= ?'; $params[] = $_GET['to']; }
|
||||||
|
|
||||||
|
$whereSQL = implode(' AND ', $where);
|
||||||
|
$limit = min((int)($_GET['limit'] ?? 100), 500);
|
||||||
|
$offset = max(0, (int)($_GET['offset'] ?? 0));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT ah.*, h.head_name, h.poverty_status, rc.name AS center_name
|
||||||
|
FROM aid_history ah
|
||||||
|
LEFT JOIN households h ON h.id = ah.household_id
|
||||||
|
LEFT JOIN religious_centers rc ON rc.id = ah.center_id
|
||||||
|
WHERE $whereSQL
|
||||||
|
ORDER BY ah.aid_date DESC, ah.created_at DESC
|
||||||
|
LIMIT $limit OFFSET $offset
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
$cntStmt = $pdo->prepare("SELECT COUNT(*) FROM aid_history ah WHERE $whereSQL");
|
||||||
|
$cntStmt->execute($params);
|
||||||
|
|
||||||
|
Response::success(['aid_history' => $stmt->fetchAll(), 'total' => (int)$cntStmt->fetchColumn()]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'GET:show': {
|
||||||
|
if (!$id) Response::error('ID is required.', 400);
|
||||||
|
$pdo = Database::get();
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT ah.*, h.head_name, rc.name AS center_name
|
||||||
|
FROM aid_history ah
|
||||||
|
LEFT JOIN households h ON h.id = ah.household_id
|
||||||
|
LEFT JOIN religious_centers rc ON rc.id = ah.center_id
|
||||||
|
WHERE ah.id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!$row) Response::notFound('Aid record not found.');
|
||||||
|
Response::success($row);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'POST:create': {
|
||||||
|
$data = Validator::json();
|
||||||
|
Validator::make($data, [
|
||||||
|
'household_id' => 'required|integer',
|
||||||
|
'aid_type' => 'required|in:sembako,pendanaan,pelatihan,sembako_pendanaan,sembako_pelatihan,pendanaan_pelatihan,lengkap',
|
||||||
|
'aid_date' => 'required|date',
|
||||||
|
'amount' => 'integer|min:0',
|
||||||
|
])->validate_or_fail();
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
|
||||||
|
// Check if household exists
|
||||||
|
$hh = $pdo->prepare('SELECT id FROM households WHERE id = ? AND is_active = 1');
|
||||||
|
$hh->execute([(int)$data['household_id']]);
|
||||||
|
if (!$hh->fetch()) Response::notFound('Household not found.');
|
||||||
|
|
||||||
|
// Insert menggunakan kolom description (bukan notes)
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO aid_history
|
||||||
|
(household_id, center_id, aid_type, aid_date, amount, description, given_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
(int)$data['household_id'],
|
||||||
|
!empty($data['center_id']) ? (int)$data['center_id'] : null,
|
||||||
|
$data['aid_type'],
|
||||||
|
$data['aid_date'],
|
||||||
|
!empty($data['amount']) ? (int)$data['amount'] : null,
|
||||||
|
!empty($data['notes']) ? Validator::sanitizeString($data['notes']) : null,
|
||||||
|
(int)($_SESSION['user_id'] ?? 1) // current user ID
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
// Update household aid_status to 'received'
|
||||||
|
$pdo->prepare("UPDATE households SET aid_status = 'received' WHERE id = ?")
|
||||||
|
->execute([(int)$data['household_id']]);
|
||||||
|
|
||||||
|
AuditLog::record('Catat Bantuan', 'aid_history', $newId, null, $data);
|
||||||
|
Response::created(['id' => $newId], 'Bantuan berhasil dicatat.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'POST:update': {
|
||||||
|
if (!$id) Response::error('ID is required.', 400);
|
||||||
|
|
||||||
|
$data = Validator::json();
|
||||||
|
Validator::make($data, [
|
||||||
|
'aid_type' => 'required|in:sembako,pendanaan,pelatihan,sembako_pendanaan,sembako_pelatihan,pendanaan_pelatihan,lengkap',
|
||||||
|
'aid_date' => 'required|date',
|
||||||
|
'amount' => 'integer|min:0',
|
||||||
|
])->validate_or_fail();
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
|
||||||
|
// Get old data for audit
|
||||||
|
$old = $pdo->prepare('SELECT * FROM aid_history WHERE id = ?');
|
||||||
|
$old->execute([$id]);
|
||||||
|
$oldRow = $old->fetch();
|
||||||
|
if (!$oldRow) Response::notFound('Aid record not found.');
|
||||||
|
|
||||||
|
// Update menggunakan kolom description
|
||||||
|
$pdo->prepare("
|
||||||
|
UPDATE aid_history SET
|
||||||
|
aid_type = ?, aid_date = ?, amount = ?, description = ?, center_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
")->execute([
|
||||||
|
$data['aid_type'],
|
||||||
|
$data['aid_date'],
|
||||||
|
!empty($data['amount']) ? (int)$data['amount'] : null,
|
||||||
|
!empty($data['notes']) ? Validator::sanitizeString($data['notes']) : null,
|
||||||
|
!empty($data['center_id']) ? (int)$data['center_id'] : null,
|
||||||
|
$id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLog::record('Update Bantuan', 'aid_history', $id, $oldRow, $data);
|
||||||
|
Response::success(null, 'Bantuan diperbarui.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'POST:delete': {
|
||||||
|
requireAdmin(); // only admin can delete aid records
|
||||||
|
|
||||||
|
if (!$id) Response::error('ID is required.', 400);
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
|
||||||
|
// Get old data for audit
|
||||||
|
$old = $pdo->prepare('SELECT * FROM aid_history WHERE id = ?');
|
||||||
|
$old->execute([$id]);
|
||||||
|
$row = $old->fetch();
|
||||||
|
if (!$row) Response::notFound('Aid record not found.');
|
||||||
|
|
||||||
|
// Delete record
|
||||||
|
$pdo->prepare('DELETE FROM aid_history WHERE id = ?')->execute([$id]);
|
||||||
|
|
||||||
|
// Check if household still has any aid records
|
||||||
|
$checkStmt = $pdo->prepare('SELECT COUNT(*) FROM aid_history WHERE household_id = ?');
|
||||||
|
$checkStmt->execute([$row['household_id']]);
|
||||||
|
$remaining = (int)$checkStmt->fetchColumn();
|
||||||
|
|
||||||
|
// If no more aid records, update aid_status back to 'not_yet'
|
||||||
|
if ($remaining === 0) {
|
||||||
|
$pdo->prepare("UPDATE households SET aid_status = 'not_yet' WHERE id = ?")
|
||||||
|
->execute([$row['household_id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLog::record('Hapus Bantuan', 'aid_history', $id, $row);
|
||||||
|
Response::success(null, 'Bantuan dihapus.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'GET:stats': {
|
||||||
|
$pdo = Database::get();
|
||||||
|
|
||||||
|
$byType = $pdo->query("
|
||||||
|
SELECT aid_type, COUNT(*) AS cnt, COALESCE(SUM(amount), 0) AS total_amount
|
||||||
|
FROM aid_history
|
||||||
|
GROUP BY aid_type
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
$monthly = $pdo->query("
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(aid_date, '%Y-%m') AS month,
|
||||||
|
COUNT(*) AS distributions,
|
||||||
|
COALESCE(SUM(amount), 0) AS total_amount
|
||||||
|
FROM aid_history
|
||||||
|
GROUP BY month
|
||||||
|
ORDER BY month DESC
|
||||||
|
LIMIT 12
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
$total = (int)$pdo->query("SELECT COUNT(DISTINCT household_id) FROM aid_history")->fetchColumn();
|
||||||
|
$totalDist = (int)$pdo->query("SELECT COUNT(*) FROM aid_history")->fetchColumn();
|
||||||
|
|
||||||
|
Response::success([
|
||||||
|
'by_type' => $byType,
|
||||||
|
'monthly' => array_reverse($monthly),
|
||||||
|
'summary' => [
|
||||||
|
'total_distributions' => $totalDist,
|
||||||
|
'total_households_aided' => $total
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
Response::methodNotAllowed();
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// api/auth/check.php — Session check & login/logout
|
||||||
|
//
|
||||||
|
// GET /api/auth/check.php → check session
|
||||||
|
// POST /api/auth/check.php?action=login → login
|
||||||
|
// POST /api/auth/check.php?action=logout→ logout
|
||||||
|
// ============================================================
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../config/bootstrap.php';
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'CLI';
|
||||||
|
$action = $_GET['action'] ?? 'check';
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// CHECK — return current session state
|
||||||
|
// ================================================================
|
||||||
|
if ($method === 'GET' || $action === 'check') {
|
||||||
|
if (!empty($_SESSION['user_id']) && !empty($_SESSION['role'])) {
|
||||||
|
Response::success([
|
||||||
|
'logged_in' => true,
|
||||||
|
'user_id' => (int)$_SESSION['user_id'],
|
||||||
|
'name' => $_SESSION['name'] ?? '',
|
||||||
|
'email' => $_SESSION['email'] ?? '',
|
||||||
|
'role' => $_SESSION['role'],
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
Response::success(['logged_in' => false]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// LOGIN
|
||||||
|
// ================================================================
|
||||||
|
if ($method === 'POST' && $action === 'login') {
|
||||||
|
$data = Validator::json();
|
||||||
|
$v = Validator::make($data, [
|
||||||
|
'email' => 'required',
|
||||||
|
'password' => 'required',
|
||||||
|
]);
|
||||||
|
$v->validate_or_fail();
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT id, name, email, password_hash, role, is_active FROM users WHERE email = ? LIMIT 1'
|
||||||
|
);
|
||||||
|
$stmt->execute([strtolower(trim($data['email']))]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$user || !$user['is_active']) {
|
||||||
|
AuditLog::record('auth.login_failed', 'users', null, null, ['email' => $data['email']]);
|
||||||
|
Response::error('Username atau password salah.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password_verify($data['password'], $user['password_hash'])) {
|
||||||
|
AuditLog::record('auth.login_failed', 'users', null, null, ['email' => $data['email']]);
|
||||||
|
Response::error('Username atau password salah.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate session ID to prevent fixation
|
||||||
|
session_regenerate_id(true);
|
||||||
|
|
||||||
|
$_SESSION['user_id'] = $user['id'];
|
||||||
|
$_SESSION['name'] = $user['name'];
|
||||||
|
$_SESSION['email'] = $user['email'];
|
||||||
|
$_SESSION['role'] = $user['role'];
|
||||||
|
|
||||||
|
// Update last_login_at
|
||||||
|
try {
|
||||||
|
$pdo->prepare('UPDATE users SET last_login_at = NOW() WHERE id = ?')
|
||||||
|
->execute([$user['id']]);
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
|
||||||
|
AuditLog::record('auth.login', 'users', (int)$user['id']);
|
||||||
|
|
||||||
|
Response::success([
|
||||||
|
'user_id' => (int)$user['id'],
|
||||||
|
'name' => $user['name'],
|
||||||
|
'email' => $user['email'],
|
||||||
|
'role' => $user['role'],
|
||||||
|
], 'Login berhasil.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// LOGOUT
|
||||||
|
// ================================================================
|
||||||
|
if ($method === 'POST' && $action === 'logout') {
|
||||||
|
$userId = $_SESSION['user_id'] ?? null;
|
||||||
|
AuditLog::record('auth.logout', 'users', $userId ? (int)$userId : null);
|
||||||
|
$_SESSION = [];
|
||||||
|
session_destroy();
|
||||||
|
Response::success(null, 'Logout berhasil.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::methodNotAllowed();
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
<?php
|
||||||
|
// api/centers/index.php - FINAL VERSION
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../config/bootstrap.php';
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? 'list';
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||||
|
|
||||||
|
// sendJSON function removed in favor of Response::success and Response::error
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch ("$method:$action") {
|
||||||
|
|
||||||
|
case 'GET:list':
|
||||||
|
case 'GET:': {
|
||||||
|
$pdo = Database::get();
|
||||||
|
|
||||||
|
$where = ['rc.is_active = 1'];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (!empty($_GET['type'])) {
|
||||||
|
$allowed = ['masjid','gereja','klenteng','pura','vihara'];
|
||||||
|
if (in_array($_GET['type'], $allowed, true)) {
|
||||||
|
$where[] = 'rc.worship_type = ?';
|
||||||
|
$params[] = $_GET['type'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($_GET['q'])) {
|
||||||
|
$where[] = '(rc.name LIKE ? OR rc.address LIKE ?)';
|
||||||
|
$q = '%' . $_GET['q'] . '%';
|
||||||
|
$params[] = $q; $params[] = $q;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereSQL = implode(' AND ', $where);
|
||||||
|
|
||||||
|
// Query tanpa subquery yang bermasalah
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT
|
||||||
|
rc.id, rc.name, rc.worship_type, rc.address,
|
||||||
|
rc.kelurahan, rc.kecamatan,
|
||||||
|
rc.latitude, rc.longitude, rc.radius,
|
||||||
|
rc.contact_person, rc.contact_phone, rc.notes,
|
||||||
|
rc.is_active, rc.created_at, rc.updated_at
|
||||||
|
FROM religious_centers rc
|
||||||
|
WHERE $whereSQL
|
||||||
|
ORDER BY rc.name
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Hitung household_count dan pending_aid_count secara terpisah
|
||||||
|
foreach ($rows as &$r) {
|
||||||
|
$r['latitude'] = (float)$r['latitude'];
|
||||||
|
$r['longitude'] = (float)$r['longitude'];
|
||||||
|
$r['radius'] = (int)$r['radius'];
|
||||||
|
|
||||||
|
// Hitung jumlah rumah yang dikelola center ini
|
||||||
|
$countStmt = $pdo->prepare("
|
||||||
|
SELECT COUNT(*) FROM households
|
||||||
|
WHERE managing_center_id = ? AND is_active = 1
|
||||||
|
");
|
||||||
|
$countStmt->execute([$r['id']]);
|
||||||
|
$r['household_count'] = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
// Hitung jumlah rumah yang belum terima bantuan
|
||||||
|
$pendingStmt = $pdo->prepare("
|
||||||
|
SELECT COUNT(*) FROM households
|
||||||
|
WHERE managing_center_id = ? AND is_active = 1 AND aid_status = 'not_yet'
|
||||||
|
");
|
||||||
|
$pendingStmt->execute([$r['id']]);
|
||||||
|
$r['pending_aid_count'] = (int)$pendingStmt->fetchColumn();
|
||||||
|
}
|
||||||
|
unset($r);
|
||||||
|
|
||||||
|
Response::success(['centers' => $rows, 'total' => count($rows)]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'GET:show': {
|
||||||
|
if (!$id) Response::error('ID required', 400);
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT * FROM religious_centers
|
||||||
|
WHERE id = ? AND is_active = 1
|
||||||
|
");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
Response::notFound('Not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$row['latitude'] = (float)$row['latitude'];
|
||||||
|
$row['longitude'] = (float)$row['longitude'];
|
||||||
|
$row['radius'] = (int)$row['radius'];
|
||||||
|
|
||||||
|
Response::success($row);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'GET:nearby': {
|
||||||
|
$lat = (float)($_GET['lat'] ?? 0);
|
||||||
|
$lng = (float)($_GET['lng'] ?? 0);
|
||||||
|
$km = min(50, (float)($_GET['km'] ?? 5));
|
||||||
|
|
||||||
|
if ($lat === 0.0 && $lng === 0.0) {
|
||||||
|
Response::error('lat and lng required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT
|
||||||
|
id, name, worship_type, address,
|
||||||
|
latitude, longitude, radius,
|
||||||
|
(6371 * ACOS(
|
||||||
|
COS(RADIANS(?)) * COS(RADIANS(latitude)) *
|
||||||
|
COS(RADIANS(longitude) - RADIANS(?)) +
|
||||||
|
SIN(RADIANS(?)) * SIN(RADIANS(latitude))
|
||||||
|
)) AS distance_km
|
||||||
|
FROM religious_centers
|
||||||
|
WHERE is_active = 1
|
||||||
|
HAVING distance_km <= ?
|
||||||
|
ORDER BY distance_km
|
||||||
|
LIMIT 10
|
||||||
|
");
|
||||||
|
$stmt->execute([$lat, $lng, $lat, $km]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($rows as &$r) {
|
||||||
|
$r['latitude'] = (float)$r['latitude'];
|
||||||
|
$r['longitude'] = (float)$r['longitude'];
|
||||||
|
$r['radius'] = (int)$r['radius'];
|
||||||
|
$r['distance_km'] = round((float)$r['distance_km'], 3);
|
||||||
|
}
|
||||||
|
unset($r);
|
||||||
|
|
||||||
|
Response::success(['centers' => $rows]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'GET:coverage': {
|
||||||
|
if (!$id) Response::error('ID required', 400);
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
$center = $pdo->prepare("SELECT * FROM religious_centers WHERE id = ? AND is_active = 1");
|
||||||
|
$center->execute([$id]);
|
||||||
|
$c = $center->fetch();
|
||||||
|
|
||||||
|
if (!$c) {
|
||||||
|
Response::notFound('Center not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT
|
||||||
|
id, head_name, full_address, latitude, longitude,
|
||||||
|
poverty_status, aid_status
|
||||||
|
FROM households
|
||||||
|
WHERE is_active = 1
|
||||||
|
");
|
||||||
|
$stmt->execute();
|
||||||
|
$allHouseholds = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$households = [];
|
||||||
|
foreach ($allHouseholds as $h) {
|
||||||
|
$distance = haversineDistance(
|
||||||
|
(float)$c['latitude'], (float)$c['longitude'],
|
||||||
|
(float)$h['latitude'], (float)$h['longitude']
|
||||||
|
);
|
||||||
|
if ($distance <= (float)$c['radius']) {
|
||||||
|
$h['distance_m'] = round($distance, 1);
|
||||||
|
$h['latitude'] = (float)$h['latitude'];
|
||||||
|
$h['longitude'] = (float)$h['longitude'];
|
||||||
|
$households[] = $h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($households, function($a, $b) {
|
||||||
|
return $a['distance_m'] <=> $b['distance_m'];
|
||||||
|
});
|
||||||
|
|
||||||
|
Response::success([
|
||||||
|
'center' => $c,
|
||||||
|
'households' => $households,
|
||||||
|
'count' => count($households)
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'POST:create': {
|
||||||
|
requireAuth();
|
||||||
|
$data = Validator::json();
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO religious_centers
|
||||||
|
(name, worship_type, address, latitude, longitude, radius,
|
||||||
|
contact_person, contact_phone, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$data['name'] ?? '',
|
||||||
|
$data['worship_type'] ?? 'masjid',
|
||||||
|
$data['address'] ?? '',
|
||||||
|
(float)($data['latitude'] ?? 0),
|
||||||
|
(float)($data['longitude'] ?? 0),
|
||||||
|
(int)($data['radius'] ?? 300),
|
||||||
|
$data['contact_person'] ?? null,
|
||||||
|
$data['contact_phone'] ?? null,
|
||||||
|
$data['notes'] ?? null
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newId = (int)$pdo->lastInsertId();
|
||||||
|
AuditLog::record('Tambah Tempat Ibadah', 'religious_centers', $newId, null, $data);
|
||||||
|
Response::created(['id' => $newId], 'Center created');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'POST:update': {
|
||||||
|
requireAuth();
|
||||||
|
if (!$id) Response::error('ID required', 400);
|
||||||
|
|
||||||
|
$data = Validator::json();
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
$old = $pdo->prepare("SELECT * FROM religious_centers WHERE id = ? AND is_active = 1");
|
||||||
|
$old->execute([$id]);
|
||||||
|
$oldRow = $old->fetch();
|
||||||
|
|
||||||
|
if (!$oldRow) {
|
||||||
|
Response::notFound('Not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
UPDATE religious_centers SET
|
||||||
|
name = ?, worship_type = ?, address = ?,
|
||||||
|
latitude = ?, longitude = ?, radius = ?,
|
||||||
|
contact_person = ?, contact_phone = ?, notes = ?
|
||||||
|
WHERE id = ? AND is_active = 1
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$data['name'] ?? $oldRow['name'],
|
||||||
|
$data['worship_type'] ?? $oldRow['worship_type'],
|
||||||
|
$data['address'] ?? $oldRow['address'],
|
||||||
|
(float)($data['latitude'] ?? $oldRow['latitude']),
|
||||||
|
(float)($data['longitude'] ?? $oldRow['longitude']),
|
||||||
|
(int)($data['radius'] ?? $oldRow['radius']),
|
||||||
|
$data['contact_person'] ?? $oldRow['contact_person'],
|
||||||
|
$data['contact_phone'] ?? $oldRow['contact_phone'],
|
||||||
|
$data['notes'] ?? $oldRow['notes'],
|
||||||
|
$id
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLog::record('Update Tempat Ibadah', 'religious_centers', $id, $oldRow, $data);
|
||||||
|
Response::success(null, 'Updated');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'POST:patch': {
|
||||||
|
requireAuth();
|
||||||
|
if (!$id) Response::error('ID required', 400);
|
||||||
|
|
||||||
|
$data = Validator::json();
|
||||||
|
$fields = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (isset($data['radius'])) {
|
||||||
|
$fields[] = 'radius = ?';
|
||||||
|
$params[] = (int)$data['radius'];
|
||||||
|
}
|
||||||
|
if (isset($data['latitude']) && isset($data['longitude'])) {
|
||||||
|
$fields[] = 'latitude = ?';
|
||||||
|
$params[] = (float)$data['latitude'];
|
||||||
|
$fields[] = 'longitude = ?';
|
||||||
|
$params[] = (float)$data['longitude'];
|
||||||
|
if (isset($data['address'])) {
|
||||||
|
$fields[] = 'address = ?';
|
||||||
|
$params[] = $data['address'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($fields)) {
|
||||||
|
Response::error('No fields to update', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$params[] = $id;
|
||||||
|
$sql = "UPDATE religious_centers SET " . implode(', ', $fields) . " WHERE id = ? AND is_active = 1";
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
$pdo->prepare($sql)->execute($params);
|
||||||
|
|
||||||
|
AuditLog::record('Geser/Resize Tempat Ibadah', 'religious_centers', $id, null, $data);
|
||||||
|
Response::success(null, 'Patched');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'POST:delete': {
|
||||||
|
requireAdmin();
|
||||||
|
if (!$id) Response::error('ID required', 400);
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
$old = $pdo->prepare("SELECT * FROM religious_centers WHERE id = ? AND is_active = 1");
|
||||||
|
$old->execute([$id]);
|
||||||
|
$oldRow = $old->fetch();
|
||||||
|
|
||||||
|
if (!$oldRow) {
|
||||||
|
Response::notFound('Not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->prepare("UPDATE religious_centers SET is_active = 0 WHERE id = ?")->execute([$id]);
|
||||||
|
AuditLog::record('Hapus Tempat Ibadah', 'religious_centers', $id, $oldRow);
|
||||||
|
Response::success(null, 'Deleted');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
Response::methodNotAllowed();
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$message = APP_DEBUG ? $e->getMessage() : 'Internal Server Error';
|
||||||
|
Response::error($message, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineDistance($lat1, $lon1, $lat2, $lon2) {
|
||||||
|
$R = 6371000;
|
||||||
|
$dLat = deg2rad($lat2 - $lat1);
|
||||||
|
$dLon = deg2rad($lon2 - $lon1);
|
||||||
|
$a = sin($dLat/2) * sin($dLat/2) +
|
||||||
|
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
|
||||||
|
sin($dLon/2) * sin($dLon/2);
|
||||||
|
$c = 2 * atan2(sqrt($a), sqrt(1-$a));
|
||||||
|
return $R * $c;
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// api/houses/index.php — Households CRUD (Adapted for new schema)
|
||||||
|
// ============================================================
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../config/bootstrap.php';
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? 'list';
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||||
|
|
||||||
|
switch ("$method:$action") {
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// LIST — untuk map markers
|
||||||
|
// ================================================================
|
||||||
|
case 'GET:list':
|
||||||
|
case 'GET:': {
|
||||||
|
requireAuth();
|
||||||
|
$pdo = Database::get();
|
||||||
|
$where = ['h.is_active = 1'];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (!empty($_GET['poverty_status'])) {
|
||||||
|
$allowed = ['terdata','rentan_miskin','miskin','sangat_miskin'];
|
||||||
|
if (in_array($_GET['poverty_status'], $allowed, true)) {
|
||||||
|
$where[] = 'h.poverty_status = ?';
|
||||||
|
$params[] = $_GET['poverty_status'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($_GET['aid_status'])) {
|
||||||
|
if (in_array($_GET['aid_status'], ['not_yet','received'], true)) {
|
||||||
|
$where[] = 'h.aid_status = ?';
|
||||||
|
$params[] = $_GET['aid_status'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($_GET['house_condition'])) {
|
||||||
|
if (in_array($_GET['house_condition'], ['layak','tidak_layak'], true)) {
|
||||||
|
$where[] = 'h.house_condition = ?';
|
||||||
|
$params[] = $_GET['house_condition'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($_GET['center_id'])) {
|
||||||
|
$where[] = 'h.managing_center_id = ?';
|
||||||
|
$params[] = (int)$_GET['center_id'];
|
||||||
|
}
|
||||||
|
if (!empty($_GET['q'])) {
|
||||||
|
$where[] = '(h.head_name LIKE ? OR h.full_address LIKE ? OR h.head_nik LIKE ?)';
|
||||||
|
$q = '%' . $_GET['q'] . '%';
|
||||||
|
$params[] = $q; $params[] = $q; $params[] = $q;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereSQL = implode(' AND ', $where);
|
||||||
|
$limit = min((int)($_GET['limit'] ?? PAGE_SIZE), PAGE_SIZE);
|
||||||
|
$offset = max(0, (int)($_GET['offset'] ?? 0));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT
|
||||||
|
h.*,
|
||||||
|
rc.name AS center_name,
|
||||||
|
(SELECT COUNT(*) FROM household_members hm WHERE hm.household_id = h.id) AS member_count,
|
||||||
|
(SELECT COUNT(*) FROM aid_history ah WHERE ah.household_id = h.id) AS aid_count
|
||||||
|
FROM households h
|
||||||
|
LEFT JOIN religious_centers rc ON rc.id = h.managing_center_id
|
||||||
|
WHERE $whereSQL
|
||||||
|
ORDER BY h.created_at DESC
|
||||||
|
LIMIT $limit OFFSET $offset
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$cntStmt = $pdo->prepare("SELECT COUNT(*) FROM households h WHERE $whereSQL");
|
||||||
|
$cntStmt->execute($params);
|
||||||
|
$total = (int)$cntStmt->fetchColumn();
|
||||||
|
|
||||||
|
foreach ($rows as &$r) {
|
||||||
|
castHousehold($r);
|
||||||
|
}
|
||||||
|
unset($r);
|
||||||
|
|
||||||
|
Response::success(['households' => $rows, 'total' => $total]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// SHOW — detail with aid history and family members
|
||||||
|
// ================================================================
|
||||||
|
case 'GET:show': {
|
||||||
|
requireAuth();
|
||||||
|
if (!$id) Response::error('ID is required.', 400);
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT
|
||||||
|
h.*,
|
||||||
|
TIMESTAMPDIFF(YEAR, h.head_date_of_birth, CURDATE()) AS age,
|
||||||
|
rc.name AS center_name,
|
||||||
|
rc.worship_type AS center_type,
|
||||||
|
rc.latitude AS center_lat,
|
||||||
|
rc.longitude AS center_lng,
|
||||||
|
rc.radius AS center_radius
|
||||||
|
FROM households h
|
||||||
|
LEFT JOIN religious_centers rc ON rc.id = h.managing_center_id
|
||||||
|
WHERE h.id = ? AND h.is_active = 1
|
||||||
|
");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!$row) Response::notFound('Household not found.');
|
||||||
|
|
||||||
|
castHousehold($row);
|
||||||
|
|
||||||
|
// ⭐ FIX: Pastikan aid_history selalu diambil dengan lengkap
|
||||||
|
$aidStmt = $pdo->prepare("
|
||||||
|
SELECT
|
||||||
|
ah.*,
|
||||||
|
rc.name AS center_name,
|
||||||
|
DATE_FORMAT(ah.aid_date, '%Y-%m-%d') AS aid_date_formatted
|
||||||
|
FROM aid_history ah
|
||||||
|
LEFT JOIN religious_centers rc ON rc.id = ah.center_id
|
||||||
|
WHERE ah.household_id = ?
|
||||||
|
ORDER BY ah.aid_date DESC
|
||||||
|
LIMIT 20
|
||||||
|
");
|
||||||
|
$aidStmt->execute([$id]);
|
||||||
|
$aidHistory = $aidStmt->fetchAll();
|
||||||
|
|
||||||
|
// ⭐ Pastikan format data bantuan konsisten
|
||||||
|
foreach ($aidHistory as &$aid) {
|
||||||
|
$aid['aid_type_label'] = match($aid['aid_type'] ?? '') {
|
||||||
|
'sembako' => 'Sembako',
|
||||||
|
'pendanaan' => 'Pendanaan',
|
||||||
|
'pelatihan' => 'Pelatihan',
|
||||||
|
'sembako_pendanaan' => 'Sembako + Pendanaan',
|
||||||
|
'sembako_pelatihan' => 'Sembako + Pelatihan',
|
||||||
|
'pendanaan_pelatihan' => 'Pendanaan + Pelatihan',
|
||||||
|
'lengkap' => 'Lengkap (Semua)',
|
||||||
|
default => $aid['aid_type'] ?? 'Bantuan'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
unset($aid);
|
||||||
|
|
||||||
|
$row['aid_history'] = $aidHistory;
|
||||||
|
|
||||||
|
// Family members
|
||||||
|
$depStmt = $pdo->prepare("
|
||||||
|
SELECT * FROM household_members
|
||||||
|
WHERE household_id = ?
|
||||||
|
ORDER BY id
|
||||||
|
");
|
||||||
|
$depStmt->execute([$id]);
|
||||||
|
$row['household_members'] = $depStmt->fetchAll();
|
||||||
|
|
||||||
|
Response::success($row);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// CREATE
|
||||||
|
// ================================================================
|
||||||
|
case 'POST:create': {
|
||||||
|
requireAuth();
|
||||||
|
$data = Validator::json();
|
||||||
|
|
||||||
|
$v = Validator::make($data, [
|
||||||
|
'head_name' => 'required|string|maxlen:150',
|
||||||
|
'head_nik' => 'required|string|maxlen:16',
|
||||||
|
'full_address' => 'required|string',
|
||||||
|
'latitude' => 'required|latitude',
|
||||||
|
'longitude' => 'required|longitude',
|
||||||
|
'house_condition' => 'in:layak,tidak_layak',
|
||||||
|
'head_education' => 'in:tidak_sekolah,sd,smp,sma,diploma,sarjana,pascasarjana',
|
||||||
|
'aid_status' => 'in:not_yet,received',
|
||||||
|
'head_gender' => 'in:male,female',
|
||||||
|
'head_date_of_birth' => 'date',
|
||||||
|
'land_ownership' => 'in:milik,sewa,numpang,lainnya',
|
||||||
|
]);
|
||||||
|
$v->validate_or_fail();
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
|
||||||
|
$calc = PovertyCalculator::calculate(
|
||||||
|
(int)($data['head_monthly_income'] ?? $data['income'] ?? 0),
|
||||||
|
(int)($data['dependents'] ?? 1),
|
||||||
|
$data['house_condition'] ?? 'layak',
|
||||||
|
$data['head_education'] ?? 'sd',
|
||||||
|
$data['land_ownership'] ?? 'milik'
|
||||||
|
);
|
||||||
|
|
||||||
|
$managingId = resolveManagingCenter($pdo, (float)$data['latitude'], (float)$data['longitude']);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO households (
|
||||||
|
rt, rw, kelurahan, kecamatan, full_address,
|
||||||
|
latitude, longitude, house_condition, managing_center_id,
|
||||||
|
head_name, head_nik, head_gender, head_date_of_birth, head_education,
|
||||||
|
head_employment_status, head_job_name, head_institution_name, head_monthly_income,
|
||||||
|
poverty_score, poverty_status, aid_status, notes
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$data['rt'] ?? null,
|
||||||
|
$data['rw'] ?? null,
|
||||||
|
$data['kelurahan'] ?? null,
|
||||||
|
$data['kecamatan'] ?? null,
|
||||||
|
$data['full_address'],
|
||||||
|
(float)$data['latitude'],
|
||||||
|
(float)$data['longitude'],
|
||||||
|
$data['house_condition'] ?? 'layak',
|
||||||
|
$managingId,
|
||||||
|
$data['head_name'],
|
||||||
|
$data['head_nik'],
|
||||||
|
$data['head_gender'] ?? 'male',
|
||||||
|
$data['head_date_of_birth'],
|
||||||
|
$data['head_education'] ?? 'sd',
|
||||||
|
$data['head_employment_status'] ?? 'unemployed',
|
||||||
|
$data['head_job_name'] ?? null,
|
||||||
|
$data['head_institution_name'] ?? null,
|
||||||
|
(int)($data['head_monthly_income'] ?? $data['income'] ?? 0),
|
||||||
|
$calc['score'] ?? 0,
|
||||||
|
$calc['status'],
|
||||||
|
$data['aid_status'] ?? 'not_yet',
|
||||||
|
$data['notes'] ?? $data['description'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
// Save family members
|
||||||
|
if (!empty($data['household_members']) && is_array($data['household_members'])) {
|
||||||
|
saveMembers($pdo, $newId, $data['household_members']);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLog::record('Tambah Rumah', 'households', $newId, null, $data);
|
||||||
|
Response::created([
|
||||||
|
'id' => $newId,
|
||||||
|
'poverty_status' => $calc['status'],
|
||||||
|
'poverty_label' => $calc['label'],
|
||||||
|
'marker_color' => PovertyCalculator::markerColor($calc['status']),
|
||||||
|
'managing_center_id' => $managingId,
|
||||||
|
], 'Rumah berhasil ditambahkan.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// UPDATE
|
||||||
|
// ================================================================
|
||||||
|
case 'POST:update': {
|
||||||
|
requireAuth();
|
||||||
|
if (!$id) Response::error('ID is required.', 400);
|
||||||
|
|
||||||
|
$data = Validator::json();
|
||||||
|
$v = Validator::make($data, [
|
||||||
|
'head_name' => 'required|string|maxlen:150',
|
||||||
|
'head_nik' => 'required|string|maxlen:16',
|
||||||
|
'full_address' => 'required|string',
|
||||||
|
'latitude' => 'required|latitude',
|
||||||
|
'longitude' => 'required|longitude',
|
||||||
|
'house_condition' => 'in:layak,tidak_layak',
|
||||||
|
'head_education' => 'in:tidak_sekolah,sd,smp,sma,diploma,sarjana,pascasarjana',
|
||||||
|
'aid_status' => 'in:not_yet,received',
|
||||||
|
'head_gender' => 'in:male,female',
|
||||||
|
'head_date_of_birth' => 'date',
|
||||||
|
'land_ownership' => 'in:milik,sewa,numpang,lainnya',
|
||||||
|
]);
|
||||||
|
$v->validate_or_fail();
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
$old = $pdo->prepare("SELECT * FROM households WHERE id = ? AND is_active = 1");
|
||||||
|
$old->execute([$id]);
|
||||||
|
$oldRow = $old->fetch();
|
||||||
|
if (!$oldRow) Response::notFound('Household not found.');
|
||||||
|
|
||||||
|
$calc = PovertyCalculator::calculate(
|
||||||
|
(int)($data['head_monthly_income'] ?? $data['income'] ?? 0),
|
||||||
|
(int)($data['dependents'] ?? 1),
|
||||||
|
$data['house_condition'] ?? 'layak',
|
||||||
|
$data['head_education'] ?? 'sd',
|
||||||
|
$data['land_ownership'] ?? 'milik'
|
||||||
|
);
|
||||||
|
|
||||||
|
$managingId = resolveManagingCenter($pdo, (float)$data['latitude'], (float)$data['longitude']);
|
||||||
|
|
||||||
|
$pdo->prepare("
|
||||||
|
UPDATE households SET
|
||||||
|
rt = ?, rw = ?, kelurahan = ?, kecamatan = ?, full_address = ?,
|
||||||
|
latitude = ?, longitude = ?, house_condition = ?, managing_center_id = ?,
|
||||||
|
head_name = ?, head_nik = ?, head_gender = ?, head_date_of_birth = ?, head_education = ?,
|
||||||
|
head_employment_status = ?, head_job_name = ?, head_institution_name = ?, head_monthly_income = ?,
|
||||||
|
poverty_score = ?, poverty_status = ?, aid_status = ?, notes = ?
|
||||||
|
WHERE id = ? AND is_active = 1
|
||||||
|
")->execute([
|
||||||
|
$data['rt'] ?? $oldRow['rt'],
|
||||||
|
$data['rw'] ?? $oldRow['rw'],
|
||||||
|
$data['kelurahan'] ?? $oldRow['kelurahan'],
|
||||||
|
$data['kecamatan'] ?? $oldRow['kecamatan'],
|
||||||
|
$data['full_address'],
|
||||||
|
(float)$data['latitude'],
|
||||||
|
(float)$data['longitude'],
|
||||||
|
$data['house_condition'] ?? 'layak',
|
||||||
|
$managingId,
|
||||||
|
$data['head_name'],
|
||||||
|
$data['head_nik'],
|
||||||
|
$data['head_gender'] ?? 'male',
|
||||||
|
$data['head_date_of_birth'],
|
||||||
|
$data['head_education'] ?? 'sd',
|
||||||
|
$data['head_employment_status'] ?? 'unemployed',
|
||||||
|
$data['head_job_name'] ?? null,
|
||||||
|
$data['head_institution_name'] ?? null,
|
||||||
|
(int)($data['head_monthly_income'] ?? $data['income'] ?? 0),
|
||||||
|
$calc['score'] ?? 0,
|
||||||
|
$calc['status'],
|
||||||
|
$data['aid_status'] ?? 'not_yet',
|
||||||
|
$data['notes'] ?? $data['description'] ?? null,
|
||||||
|
$id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isset($data['household_members']) && is_array($data['household_members'])) {
|
||||||
|
saveMembers($pdo, $id, $data['household_members']);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLog::record('Update Rumah', 'households', $id, $oldRow, $data);
|
||||||
|
Response::success([
|
||||||
|
'id' => $id,
|
||||||
|
'poverty_status' => $calc['status'],
|
||||||
|
'poverty_label' => $calc['label'],
|
||||||
|
'marker_color' => PovertyCalculator::markerColor($calc['status']),
|
||||||
|
'managing_center_id' => $managingId,
|
||||||
|
], 'Data rumah diperbarui.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// PATCH — untuk drag marker
|
||||||
|
// ================================================================
|
||||||
|
case 'POST:patch': {
|
||||||
|
requireAuth();
|
||||||
|
if (!$id) Response::error('ID is required.', 400);
|
||||||
|
|
||||||
|
$data = Validator::json();
|
||||||
|
|
||||||
|
// Validasi: minimal latitude dan longitude harus ada
|
||||||
|
if (!isset($data['latitude']) || !isset($data['longitude'])) {
|
||||||
|
Response::error('Latitude dan longitude diperlukan.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
Validator::make($data, [
|
||||||
|
'latitude' => 'required|latitude',
|
||||||
|
'longitude' => 'required|longitude',
|
||||||
|
])->validate_or_fail();
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
|
||||||
|
// Cek apakah household ada
|
||||||
|
$check = $pdo->prepare("SELECT id, managing_center_id FROM households WHERE id = ? AND is_active = 1");
|
||||||
|
$check->execute([$id]);
|
||||||
|
$existing = $check->fetch();
|
||||||
|
if (!$existing) Response::notFound('Household tidak ditemukan.');
|
||||||
|
|
||||||
|
// Hitung ulang managing_center_id berdasarkan posisi baru
|
||||||
|
$managingId = resolveManagingCenter($pdo, (float)$data['latitude'], (float)$data['longitude']);
|
||||||
|
|
||||||
|
// Update query
|
||||||
|
$sql = "UPDATE households SET latitude = ?, longitude = ?, managing_center_id = ?";
|
||||||
|
$params = [(float)$data['latitude'], (float)$data['longitude'], $managingId];
|
||||||
|
|
||||||
|
if (!empty($data['full_address'])) {
|
||||||
|
$sql .= ", full_address = ?";
|
||||||
|
$params[] = Validator::sanitizeString($data['full_address']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " WHERE id = ? AND is_active = 1";
|
||||||
|
$params[] = $id;
|
||||||
|
|
||||||
|
$pdo->prepare($sql)->execute($params);
|
||||||
|
|
||||||
|
// Ambil data center untuk response
|
||||||
|
$centerName = null;
|
||||||
|
if ($managingId) {
|
||||||
|
$cStmt = $pdo->prepare("SELECT name FROM religious_centers WHERE id = ?");
|
||||||
|
$cStmt->execute([$managingId]);
|
||||||
|
$center = $cStmt->fetch();
|
||||||
|
$centerName = $center ? $center['name'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLog::record('Pindah Posisi Rumah', 'households', $id, null, $data);
|
||||||
|
Response::success([
|
||||||
|
'managing_center_id' => $managingId,
|
||||||
|
'center_name' => $centerName
|
||||||
|
], 'Posisi rumah berhasil diperbarui.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// DELETE (soft delete)
|
||||||
|
// ================================================================
|
||||||
|
case 'POST:delete': {
|
||||||
|
requireAdmin();
|
||||||
|
if (!$id) Response::error('ID is required.', 400);
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
$old = $pdo->prepare("SELECT * FROM households WHERE id = ? AND is_active = 1");
|
||||||
|
$old->execute([$id]);
|
||||||
|
$oldRow = $old->fetch();
|
||||||
|
if (!$oldRow) Response::notFound('Household not found.');
|
||||||
|
|
||||||
|
$pdo->prepare("UPDATE households SET is_active = 0 WHERE id = ?")->execute([$id]);
|
||||||
|
AuditLog::record('Hapus Rumah', 'households', $id, $oldRow);
|
||||||
|
Response::success(null, 'Data rumah dihapus.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// DELETE PHOTO — hapus foto rumah tangga satu per satu
|
||||||
|
// ================================================================
|
||||||
|
case 'POST:delete_photo': {
|
||||||
|
requireAuth();
|
||||||
|
if (!$id) Response::error('ID is required.', 400);
|
||||||
|
|
||||||
|
$data = Validator::json();
|
||||||
|
$filename = trim($data['filename'] ?? '');
|
||||||
|
|
||||||
|
if ($filename === '' || preg_match('/[\/\\\\]/', $filename) ||
|
||||||
|
!preg_match('/^[a-zA-Z0-9_\-\.]+$/', $filename)) {
|
||||||
|
Response::error('Nama file tidak valid.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, ['jpg', 'jpeg', 'png'], true)) {
|
||||||
|
Response::error('Ekstensi file tidak diizinkan.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT house_photos FROM households WHERE id = ? AND is_active = 1");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
Response::notFound('Data rumah tangga tidak ditemukan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$photos = json_decode($row['house_photos'] ?? '[]', true) ?: [];
|
||||||
|
|
||||||
|
if (!in_array($filename, $photos, true)) {
|
||||||
|
Response::error('File tidak ditemukan dalam data ini.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$photos = array_values(array_filter($photos, fn($p) => $p !== $filename));
|
||||||
|
|
||||||
|
$upd = $pdo->prepare("UPDATE households SET house_photos = ? WHERE id = ?");
|
||||||
|
$upd->execute([json_encode($photos, JSON_UNESCAPED_UNICODE), $id]);
|
||||||
|
|
||||||
|
$dir = __DIR__ . '/../../uploads/houses/';
|
||||||
|
$fullPath = realpath($dir . $filename);
|
||||||
|
$realDir = realpath($dir);
|
||||||
|
if ($fullPath && $realDir && str_starts_with($fullPath, $realDir)) {
|
||||||
|
@unlink($fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLog::record('Hapus Foto Rumah', 'households', $id, null, ['filename' => $filename]);
|
||||||
|
Response::success(['remaining' => $photos], 'Foto berhasil dihapus.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
Response::methodNotAllowed();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// HELPERS
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function castHousehold(array &$r): void
|
||||||
|
{
|
||||||
|
$r['latitude'] = (float)$r['latitude'];
|
||||||
|
$r['longitude'] = (float)$r['longitude'];
|
||||||
|
$r['age'] = isset($r['age']) ? (int)$r['age'] : null;
|
||||||
|
$r['is_active'] = (bool)$r['is_active'];
|
||||||
|
$r['marker_color'] = PovertyCalculator::markerColor($r['poverty_status'] ?? '');
|
||||||
|
$r['poverty_label'] = PovertyCalculator::label($r['poverty_status'] ?? '');
|
||||||
|
|
||||||
|
// Backward compatibility untuk frontend lama
|
||||||
|
$r['address'] = $r['full_address'] ?? '';
|
||||||
|
$r['nik'] = $r['head_nik'] ?? '';
|
||||||
|
$r['head_name'] = $r['head_name'] ?? '';
|
||||||
|
$r['head_nik'] = $r['head_nik'] ?? '';
|
||||||
|
$r['head_education'] = $r['head_education'] ?? 'sd';
|
||||||
|
$r['head_employment_status'] = $r['head_employment_status'] ?? 'unemployed';
|
||||||
|
$r['head_job_name'] = $r['head_job_name'] ?? '';
|
||||||
|
$r['head_monthly_income'] = (int)($r['head_monthly_income'] ?? 0);
|
||||||
|
$r['house_condition'] = $r['house_condition'] ?? 'layak';
|
||||||
|
$r['aid_status'] = $r['aid_status'] ?? 'not_yet';
|
||||||
|
$r['notes'] = $r['notes'] ?? '';
|
||||||
|
$r['dependents'] = (int)($r['family_members_count'] ?? 0) + 1;
|
||||||
|
$r['income'] = $r['head_monthly_income'] ?? 0;
|
||||||
|
$r['job'] = $r['head_job_name'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveManagingCenter(\PDO $pdo, float $lat, float $lng): ?int
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT sub.id FROM (
|
||||||
|
SELECT
|
||||||
|
rc.id,
|
||||||
|
rc.radius,
|
||||||
|
(6371000 * ACOS(
|
||||||
|
COS(RADIANS(:lat1)) * COS(RADIANS(rc.latitude)) *
|
||||||
|
COS(RADIANS(rc.longitude) - RADIANS(:lng)) +
|
||||||
|
SIN(RADIANS(:lat2)) * SIN(RADIANS(rc.latitude))
|
||||||
|
)) AS distance_m,
|
||||||
|
(SELECT COUNT(*) FROM households hh
|
||||||
|
WHERE hh.managing_center_id = rc.id AND hh.is_active = 1) AS load_count
|
||||||
|
FROM religious_centers rc
|
||||||
|
WHERE rc.is_active = 1
|
||||||
|
) sub
|
||||||
|
WHERE sub.distance_m <= sub.radius
|
||||||
|
ORDER BY sub.load_count ASC, sub.distance_m ASC
|
||||||
|
LIMIT 1
|
||||||
|
");
|
||||||
|
$stmt->execute([':lat1' => $lat, ':lng' => $lng, ':lat2' => $lat]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row ? (int)$row['id'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMembers(\PDO $pdo, int $householdId, array $data): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Delete existing members
|
||||||
|
$pdo->prepare("DELETE FROM household_members WHERE household_id = ?")->execute([$householdId]);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO household_members
|
||||||
|
(household_id, name, nik, gender, date_of_birth, education,
|
||||||
|
relationship, employment_status, job_name, institution_name, monthly_income)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
");
|
||||||
|
|
||||||
|
foreach ($data as $member) {
|
||||||
|
$name = trim($member['name'] ?? '');
|
||||||
|
if (!$name) continue;
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$householdId,
|
||||||
|
$name,
|
||||||
|
$member['nik'] ?? null,
|
||||||
|
$member['gender'] ?? 'male',
|
||||||
|
$member['date_of_birth'] ?? null,
|
||||||
|
$member['education'] ?? 'sd',
|
||||||
|
$member['relationship'] ?? 'lainnya',
|
||||||
|
$member['employment_status'] ?? 'unemployed',
|
||||||
|
$member['job_name'] ?? null,
|
||||||
|
$member['institution_name'] ?? null,
|
||||||
|
(int)($member['monthly_income'] ?? 0),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('Save members error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* api/public/report.php
|
||||||
|
* Public poverty reports — create (lapor.html) + admin CRUD.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Matikan semua error reporting ke output
|
||||||
|
error_reporting(0);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
|
||||||
|
// Bersihkan buffer
|
||||||
|
while (ob_get_level()) ob_end_clean();
|
||||||
|
ob_start();
|
||||||
|
|
||||||
|
// Load bootstrap (sudah include requireAuth() dan requireAdmin())
|
||||||
|
if (!file_exists(__DIR__ . '/../../config/bootstrap.php')) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Konfigurasi server tidak lengkap.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../config/bootstrap.php';
|
||||||
|
|
||||||
|
// Pastikan tidak ada output sebelum JSON
|
||||||
|
if (ob_get_length()) ob_clean();
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// POST tanpa action = create (dari lapor.html)
|
||||||
|
if ($method === 'POST' && ($action === '' || $action === 'create')) {
|
||||||
|
handleCreate();
|
||||||
|
}
|
||||||
|
// GET list
|
||||||
|
elseif ($method === 'GET' && $action === 'list') {
|
||||||
|
handleList();
|
||||||
|
}
|
||||||
|
// GET show
|
||||||
|
elseif ($method === 'GET' && $action === 'show' && $id) {
|
||||||
|
handleShow($id);
|
||||||
|
}
|
||||||
|
// POST approve (admin) - requireAdmin() dari bootstrap.php
|
||||||
|
elseif ($method === 'POST' && $action === 'approve' && $id) {
|
||||||
|
requireAdmin();
|
||||||
|
handleApprove($id);
|
||||||
|
}
|
||||||
|
// POST reject (admin)
|
||||||
|
elseif ($method === 'POST' && $action === 'reject' && $id) {
|
||||||
|
requireAdmin();
|
||||||
|
handleReject($id);
|
||||||
|
}
|
||||||
|
// POST delete (admin)
|
||||||
|
elseif ($method === 'POST' && $action === 'delete' && $id) {
|
||||||
|
requireAdmin();
|
||||||
|
handleDelete($id);
|
||||||
|
}
|
||||||
|
elseif ($method === 'POST' && $action === 'delete_photo' && $id) {
|
||||||
|
requireAuth(); // at minimum logged in
|
||||||
|
handleDeletePhoto($id, 'public_reports', 'proof_photos',
|
||||||
|
__DIR__ . '/../../uploads/reports/');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
jsonError('Method or action not allowed', 405);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('[public/report.php] PDO: ' . $e->getMessage());
|
||||||
|
jsonError('Terjadi kesalahan database. Silakan coba lagi.', 500);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('[public/report.php] ' . $e->getMessage());
|
||||||
|
jsonError('Terjadi kesalahan server.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CREATE — public submission, all fields required
|
||||||
|
// ============================================================
|
||||||
|
function handleCreate(): void
|
||||||
|
{
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
|
||||||
|
if (!is_array($data)) {
|
||||||
|
jsonError('Data tidak valid.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
$required = [
|
||||||
|
'reporter_name' => 'Nama pelapor',
|
||||||
|
'reporter_phone' => 'Nomor telepon',
|
||||||
|
'head_name' => 'Nama kepala keluarga',
|
||||||
|
'address' => 'Alamat',
|
||||||
|
'kelurahan' => 'Kelurahan',
|
||||||
|
'kecamatan' => 'Kecamatan',
|
||||||
|
'description' => 'Deskripsi',
|
||||||
|
'latitude' => 'Latitude',
|
||||||
|
'longitude' => 'Longitude',
|
||||||
|
'severity' => 'Tingkat urgensi'
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($required as $field => $label) {
|
||||||
|
$value = trim($data[$field] ?? '');
|
||||||
|
if ($value === '') {
|
||||||
|
$errors[$field] = "$label wajib diisi.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RT/RW juga required
|
||||||
|
if (trim($data['rt'] ?? '') === '') {
|
||||||
|
$errors['rt'] = 'RT wajib diisi.';
|
||||||
|
}
|
||||||
|
if (trim($data['rw'] ?? '') === '') {
|
||||||
|
$errors['rw'] = 'RW wajib diisi.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($errors)) {
|
||||||
|
$firstError = reset($errors);
|
||||||
|
jsonError($firstError, 422, ['validation_errors' => $errors]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone validation
|
||||||
|
$phone = preg_replace('/[\s\-\(\)\+]/', '', $data['reporter_phone']);
|
||||||
|
if (!preg_match('/^\d{8,15}$/', $phone)) {
|
||||||
|
jsonError('Nomor telepon tidak valid (8-15 digit).', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description min 20 chars
|
||||||
|
if (mb_strlen($data['description']) < 20) {
|
||||||
|
jsonError('Deskripsi minimal 20 karakter.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coordinates
|
||||||
|
$lat = (float)$data['latitude'];
|
||||||
|
$lng = (float)$data['longitude'];
|
||||||
|
if ($lat < -90 || $lat > 90 || $lat == 0) {
|
||||||
|
jsonError('Latitude tidak valid.', 422);
|
||||||
|
}
|
||||||
|
if ($lng < -180 || $lng > 180 || $lng == 0) {
|
||||||
|
jsonError('Longitude tidak valid.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Severity validation
|
||||||
|
$allowedSeverity = ['ringan', 'sedang', 'berat', 'kritis'];
|
||||||
|
if (!in_array($data['severity'], $allowedSeverity)) {
|
||||||
|
jsonError('Tingkat urgensi tidak valid.', 422);
|
||||||
|
}
|
||||||
|
// Photo requirement
|
||||||
|
$photoCount = (int)($data['proof_photo_count'] ?? -1);
|
||||||
|
if ($photoCount === 0) {
|
||||||
|
jsonError('Foto bukti wajib diunggah minimal 1 foto.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit
|
||||||
|
$pdo = Database::get();
|
||||||
|
$ip = getClientIP();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT COUNT(*) FROM public_reports WHERE ip_address = ? AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)");
|
||||||
|
$stmt->execute([$ip]);
|
||||||
|
if ((int)$stmt->fetchColumn() >= 5) {
|
||||||
|
jsonError('Terlalu banyak laporan. Coba lagi besok.', 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize
|
||||||
|
$sanitize = fn($s) => htmlspecialchars(strip_tags(trim($s)), ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
|
// Insert
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO public_reports
|
||||||
|
(reporter_name, reporter_phone, rt, rw, head_name, address, kelurahan, kecamatan,
|
||||||
|
latitude, longitude, description, severity, urgent_need, status, ip_address, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, NOW())
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$sanitize($data['reporter_name']),
|
||||||
|
$sanitize($data['reporter_phone']),
|
||||||
|
$sanitize($data['rt'] ?? ''),
|
||||||
|
$sanitize($data['rw'] ?? ''),
|
||||||
|
$sanitize($data['head_name']),
|
||||||
|
$sanitize($data['address']),
|
||||||
|
$sanitize($data['kelurahan']),
|
||||||
|
$sanitize($data['kecamatan']),
|
||||||
|
round($lat, 7),
|
||||||
|
round($lng, 7),
|
||||||
|
$sanitize($data['description']),
|
||||||
|
$data['severity'],
|
||||||
|
$sanitize($data['urgent_need'] ?? ''),
|
||||||
|
$ip
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
jsonSuccess(['id' => $newId], 'Laporan berhasil dikirim. Petugas akan segera memverifikasi.', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LIST
|
||||||
|
// ============================================================
|
||||||
|
function handleList(): void
|
||||||
|
{
|
||||||
|
$pdo = Database::get();
|
||||||
|
$where = ['1=1'];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (!empty($_GET['status'])) {
|
||||||
|
$where[] = 'status = ?';
|
||||||
|
$params[] = $_GET['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereSQL = implode(' AND ', $where);
|
||||||
|
$limit = min((int)($_GET['limit'] ?? 100), 500);
|
||||||
|
$offset = max(0, (int)($_GET['offset'] ?? 0));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT * FROM public_reports
|
||||||
|
WHERE $whereSQL
|
||||||
|
ORDER BY FIELD(status, 'pending', 'approved', 'rejected'), created_at DESC
|
||||||
|
LIMIT $limit OFFSET $offset
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$cnt = $pdo->prepare("SELECT COUNT(*) FROM public_reports WHERE $whereSQL");
|
||||||
|
$cnt->execute($params);
|
||||||
|
|
||||||
|
jsonSuccess(['reports' => $rows, 'total' => (int)$cnt->fetchColumn()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SHOW
|
||||||
|
// ============================================================
|
||||||
|
function handleShow(int $id): void
|
||||||
|
{
|
||||||
|
$pdo = Database::get();
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM public_reports WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
jsonError('Laporan tidak ditemukan.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSuccess($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// APPROVE
|
||||||
|
// ============================================================
|
||||||
|
function handleApprove(int $id): void
|
||||||
|
{
|
||||||
|
$pdo = Database::get();
|
||||||
|
|
||||||
|
// Get report
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM public_reports WHERE id = ? AND status = 'pending'");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$report = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$report) {
|
||||||
|
jsonError('Laporan tidak ditemukan atau sudah diproses.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create household
|
||||||
|
$income = (int)($data['income'] ?? 0);
|
||||||
|
$condition = $data['house_condition'] ?? 'tidak_layak';
|
||||||
|
$education = $data['education'] ?? 'sd';
|
||||||
|
|
||||||
|
// Simple poverty score
|
||||||
|
$score = 0;
|
||||||
|
if ($income < 500000) $score += 30;
|
||||||
|
elseif ($income < 1500000) $score += 20;
|
||||||
|
elseif ($income < 3000000) $score += 10;
|
||||||
|
if ($condition === 'tidak_layak') $score += 20;
|
||||||
|
|
||||||
|
$povertyStatus = match(true) {
|
||||||
|
$score >= 60 => 'sangat_miskin',
|
||||||
|
$score >= 40 => 'miskin',
|
||||||
|
$score >= 20 => 'rentan_miskin',
|
||||||
|
default => 'terdata'
|
||||||
|
};
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO households
|
||||||
|
(full_address, kelurahan, kecamatan, latitude, longitude, head_name,
|
||||||
|
head_education, head_monthly_income, house_condition, land_ownership,
|
||||||
|
poverty_score, poverty_status, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'numpang', ?, ?, ?)
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$report['address'],
|
||||||
|
$report['kelurahan'] ?? '',
|
||||||
|
$report['kecamatan'] ?? '',
|
||||||
|
$report['latitude'],
|
||||||
|
$report['longitude'],
|
||||||
|
$report['head_name'],
|
||||||
|
$education,
|
||||||
|
$income,
|
||||||
|
$condition,
|
||||||
|
$score,
|
||||||
|
$povertyStatus,
|
||||||
|
'Dari laporan publik #' . $id
|
||||||
|
]);
|
||||||
|
|
||||||
|
$householdId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
// Update report
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
UPDATE public_reports
|
||||||
|
SET status = 'approved', converted_household_id = ?, admin_notes = ?, reviewed_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$householdId, $data['admin_notes'] ?? null, $id]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
jsonSuccess(['household_id' => $householdId], 'Laporan disetujui.');
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// REJECT
|
||||||
|
// ============================================================
|
||||||
|
function handleReject(int $id): void
|
||||||
|
{
|
||||||
|
$pdo = Database::get();
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("UPDATE public_reports SET status = 'rejected', admin_notes = ?, reviewed_at = NOW() WHERE id = ? AND status = 'pending'");
|
||||||
|
$stmt->execute([$data['admin_notes'] ?? null, $id]);
|
||||||
|
|
||||||
|
if ($stmt->rowCount() === 0) {
|
||||||
|
jsonError('Laporan tidak ditemukan atau sudah diproses.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSuccess(null, 'Laporan ditolak.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DELETE SINGLE PHOTO (shared by report & house endpoints)
|
||||||
|
// ============================================================
|
||||||
|
function handleDeletePhoto(int $id, string $table, string $col, string $dir): void
|
||||||
|
{
|
||||||
|
// Role guard: admin or field_officer only
|
||||||
|
$user = requireAuth();
|
||||||
|
$role = $user['role'] ?? '';
|
||||||
|
if (!in_array($role, ['admin', 'field_officer'], true)) {
|
||||||
|
jsonError('Akses ditolak. Hanya admin atau petugas lapangan.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
$filename = trim($data['filename'] ?? '');
|
||||||
|
|
||||||
|
// Security: filename must be a plain filename with no path separators
|
||||||
|
if ($filename === '' || preg_match('/[\/\\\\]/', $filename) ||
|
||||||
|
!preg_match('/^[a-zA-Z0-9_\-\.]+$/', $filename)) {
|
||||||
|
jsonError('Nama file tidak valid.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must end with allowed extension
|
||||||
|
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, ['jpg', 'jpeg', 'png'], true)) {
|
||||||
|
jsonError('Ekstensi file tidak diizinkan.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
$stmt = $pdo->prepare("SELECT $col FROM $table WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
jsonError('Data tidak ditemukan.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$photos = json_decode($row[$col] ?? '[]', true) ?: [];
|
||||||
|
|
||||||
|
// Check file is actually in this record's photo list (prevents arbitrary deletion)
|
||||||
|
if (!in_array($filename, $photos, true)) {
|
||||||
|
jsonError('File tidak ditemukan dalam data ini.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from array
|
||||||
|
$photos = array_values(array_filter($photos, fn($p) => $p !== $filename));
|
||||||
|
|
||||||
|
// Update DB
|
||||||
|
$upd = $pdo->prepare("UPDATE $table SET $col = ? WHERE id = ?");
|
||||||
|
$upd->execute([json_encode($photos, JSON_UNESCAPED_UNICODE), $id]);
|
||||||
|
|
||||||
|
// Delete physical file — realpath check prevents traversal
|
||||||
|
$fullPath = realpath($dir . $filename);
|
||||||
|
$realDir = realpath($dir);
|
||||||
|
if ($fullPath && $realDir && str_starts_with($fullPath, $realDir)) {
|
||||||
|
@unlink($fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSuccess(['remaining' => $photos], 'Foto berhasil dihapus.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DELETE
|
||||||
|
// ============================================================
|
||||||
|
function handleDelete(int $id): void
|
||||||
|
{
|
||||||
|
$pdo = Database::get();
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM public_reports WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
if ($stmt->rowCount() === 0) {
|
||||||
|
jsonError('Laporan tidak ditemukan.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonSuccess(null, 'Laporan dihapus.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// HELPERS (Hanya fungsi yang tidak ada di bootstrap.php)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// getClientIP - tidak ada di bootstrap, aman didefinisikan
|
||||||
|
function getClientIP(): string
|
||||||
|
{
|
||||||
|
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||||
|
return trim(explode(',', $ip)[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonSuccess dan jsonError - fungsi custom untuk response JSON
|
||||||
|
function jsonSuccess($data, string $message = 'OK', int $code = 200): void
|
||||||
|
{
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'message' => $message,
|
||||||
|
'data' => $data
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonError(string $message, int $code = 400, array $extra = []): void
|
||||||
|
{
|
||||||
|
http_response_code($code);
|
||||||
|
$response = ['success' => false, 'message' => $message];
|
||||||
|
if (!empty($extra)) {
|
||||||
|
$response = array_merge($response, $extra);
|
||||||
|
}
|
||||||
|
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
// ===== DEBUG MODE - FULL ERROR REPORTING =====
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$debug = [
|
||||||
|
'step' => 1,
|
||||||
|
'timestamp' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 2: Cek method
|
||||||
|
$debug['step'] = 2;
|
||||||
|
$debug['method'] = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
throw new Exception('Method not allowed. Use POST.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Cek parameters
|
||||||
|
$debug['step'] = 3;
|
||||||
|
$debug['get'] = $_GET;
|
||||||
|
$target = $_GET['target'] ?? '';
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
$debug['target'] = $target;
|
||||||
|
$debug['id'] = $id;
|
||||||
|
|
||||||
|
if (!in_array($target, ['report', 'house'], true) || $id <= 0) {
|
||||||
|
throw new Exception('Invalid target or id. target=' . $target . ', id=' . $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Load bootstrap
|
||||||
|
$debug['step'] = 4;
|
||||||
|
$bootstrap_path = __DIR__ . '/../../config/bootstrap.php';
|
||||||
|
$debug['bootstrap_path'] = $bootstrap_path;
|
||||||
|
$debug['bootstrap_exists'] = file_exists($bootstrap_path);
|
||||||
|
|
||||||
|
if (!file_exists($bootstrap_path)) {
|
||||||
|
throw new Exception('Bootstrap not found at: ' . $bootstrap_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once $bootstrap_path;
|
||||||
|
$debug['step'] = 5;
|
||||||
|
$debug['bootstrap_loaded'] = true;
|
||||||
|
|
||||||
|
// Step 5: Authentication for house target
|
||||||
|
$debug['step'] = 6;
|
||||||
|
if ($target === 'house') {
|
||||||
|
$user = requireAuth();
|
||||||
|
$debug['auth_user'] = $user['name'] ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Setup directories
|
||||||
|
$debug['step'] = 7;
|
||||||
|
$dir = $target === 'report' ? 'reports' : 'houses';
|
||||||
|
$table = $target === 'report' ? 'public_reports' : 'households';
|
||||||
|
$dest = __DIR__ . '/../../uploads/' . $dir . '/';
|
||||||
|
$debug['dest'] = $dest;
|
||||||
|
$debug['dest_exists'] = is_dir($dest);
|
||||||
|
$debug['dest_writable'] = is_writable($dest);
|
||||||
|
|
||||||
|
if (!is_dir($dest)) {
|
||||||
|
mkdir($dest, 0777, true);
|
||||||
|
$debug['dest_created'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Check record exists
|
||||||
|
$debug['step'] = 8;
|
||||||
|
$pdo = Database::get();
|
||||||
|
$colName = $target === 'report' ? 'proof_photos' : 'house_photos';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT id, $colName FROM $table WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$record = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$debug['record_exists'] = !empty($record);
|
||||||
|
|
||||||
|
if (!$record) {
|
||||||
|
throw new Exception("Record with id $id not found in $table");
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = json_decode($record[$colName] ?? '[]', true) ?: [];
|
||||||
|
$debug['existing_photos'] = count($existing);
|
||||||
|
|
||||||
|
// Step 8: Check uploaded files
|
||||||
|
$debug['step'] = 9;
|
||||||
|
$files = $_FILES['photos'] ?? null;
|
||||||
|
$debug['has_files'] = !empty($files);
|
||||||
|
$debug['files_count'] = $files ? count($files['name']) : 0;
|
||||||
|
|
||||||
|
if (!$files || empty($files['name'][0])) {
|
||||||
|
throw new Exception('No files uploaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 9: Process files
|
||||||
|
$debug['step'] = 10;
|
||||||
|
$saved = [];
|
||||||
|
|
||||||
|
foreach ($files['name'] as $i => $originalName) {
|
||||||
|
if ($files['error'][$i] !== UPLOAD_ERR_OK) {
|
||||||
|
throw new Exception('Upload error for file: ' . $originalName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||||
|
$unique = bin2hex(random_bytes(12));
|
||||||
|
$filename = "{$dir}_{$id}_{$unique}.{$ext}";
|
||||||
|
$fullPath = $dest . $filename;
|
||||||
|
|
||||||
|
if (move_uploaded_file($files['tmp_name'][$i], $fullPath)) {
|
||||||
|
$saved[] = $filename;
|
||||||
|
$debug['saved'][] = $filename;
|
||||||
|
} else {
|
||||||
|
throw new Exception('Failed to move uploaded file: ' . $originalName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 10: Update database
|
||||||
|
$debug['step'] = 11;
|
||||||
|
$all = array_merge($existing, $saved);
|
||||||
|
$upd = $pdo->prepare("UPDATE $table SET $colName = ? WHERE id = ?");
|
||||||
|
$upd->execute([json_encode($all, JSON_UNESCAPED_UNICODE), $id]);
|
||||||
|
$debug['db_updated'] = true;
|
||||||
|
|
||||||
|
// Success response
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Foto berhasil diunggah',
|
||||||
|
'data' => [
|
||||||
|
'filenames' => $saved,
|
||||||
|
'all_photos' => $all
|
||||||
|
],
|
||||||
|
'debug' => $debug
|
||||||
|
], JSON_PRETTY_PRINT);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$debug['error'] = $e->getMessage();
|
||||||
|
$debug['trace'] = $e->getTraceAsString();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
'debug' => $debug
|
||||||
|
], JSON_PRETTY_PRINT);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// api/reports/index.php — Emergency Reports CRUD (no auth)
|
||||||
|
// UI tabs removed; API kept for data integrity & popup access
|
||||||
|
// ============================================================
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../config/bootstrap.php';
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? 'list';
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||||
|
|
||||||
|
switch ("$method:$action") {
|
||||||
|
|
||||||
|
case 'GET:list':
|
||||||
|
case 'GET:': {
|
||||||
|
$pdo = Database::get();
|
||||||
|
$where = ['1=1'];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (!empty($_GET['status'])) { $where[] = 'er.status = ?'; $params[] = $_GET['status']; }
|
||||||
|
if (!empty($_GET['severity'])) { $where[] = 'er.severity = ?'; $params[] = $_GET['severity']; }
|
||||||
|
if (!empty($_GET['household_id'])) { $where[] = 'er.household_id = ?'; $params[] = (int)$_GET['household_id']; }
|
||||||
|
if (!empty($_GET['type'])) { $where[] = 'er.type = ?'; $params[] = $_GET['type']; }
|
||||||
|
|
||||||
|
$whereSQL = implode(' AND ', $where);
|
||||||
|
$limit = min((int)($_GET['limit'] ?? 50), 200);
|
||||||
|
$offset = max(0, (int)($_GET['offset'] ?? 0));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT er.*, h.head_name, h.address, h.latitude, h.longitude, h.nik
|
||||||
|
FROM emergency_reports er
|
||||||
|
LEFT JOIN households h ON h.id = er.household_id
|
||||||
|
WHERE $whereSQL
|
||||||
|
ORDER BY FIELD(er.severity,'kritis','berat','sedang','ringan'),
|
||||||
|
FIELD(er.status,'open','in_progress','resolved','closed'),
|
||||||
|
er.created_at DESC
|
||||||
|
LIMIT $limit OFFSET $offset
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$cntStmt = $pdo->prepare("SELECT COUNT(*) FROM emergency_reports er WHERE $whereSQL");
|
||||||
|
$cntStmt->execute($params);
|
||||||
|
|
||||||
|
foreach ($rows as &$r) { $r['severity_color'] = severityColor($r['severity']); }
|
||||||
|
unset($r);
|
||||||
|
|
||||||
|
Response::success(['reports' => $rows, 'total' => (int)$cntStmt->fetchColumn()]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'GET:show': {
|
||||||
|
if (!$id) Response::error('ID is required.', 400);
|
||||||
|
$pdo = Database::get();
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT er.*, h.head_name, h.address, h.latitude, h.longitude, h.nik,
|
||||||
|
h.poverty_status, h.dependents, h.income, h.house_condition
|
||||||
|
FROM emergency_reports er
|
||||||
|
LEFT JOIN households h ON h.id = er.household_id
|
||||||
|
WHERE er.id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!$row) Response::notFound('Report not found.');
|
||||||
|
$row['severity_color'] = severityColor($row['severity']);
|
||||||
|
Response::success($row);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'POST:create': {
|
||||||
|
$data = Validator::json();
|
||||||
|
$v = Validator::make($data, [
|
||||||
|
'household_id' => 'required|integer',
|
||||||
|
'type' => 'required|in:sakit,kecelakaan,bencana,kehilangan_pekerjaan,kematian,lainnya',
|
||||||
|
'severity' => 'required|in:ringan,sedang,berat,kritis',
|
||||||
|
'description' => 'required|string',
|
||||||
|
]);
|
||||||
|
$v->validate_or_fail();
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
$hh = $pdo->prepare('SELECT id FROM households WHERE id=? AND is_active=1');
|
||||||
|
$hh->execute([(int)$data['household_id']]);
|
||||||
|
if (!$hh->fetch()) Response::notFound('Household not found.');
|
||||||
|
|
||||||
|
$existing = $pdo->prepare("
|
||||||
|
SELECT id, type, status FROM emergency_reports
|
||||||
|
WHERE household_id = ? AND status IN ('open','in_progress') LIMIT 1
|
||||||
|
");
|
||||||
|
$existing->execute([(int)$data['household_id']]);
|
||||||
|
$active = $existing->fetch();
|
||||||
|
if ($active) {
|
||||||
|
$lbl = ['sakit'=>'Sakit','kecelakaan'=>'Kecelakaan','bencana'=>'Bencana',
|
||||||
|
'kehilangan_pekerjaan'=>'Kehilangan Pekerjaan','kematian'=>'Kematian','lainnya'=>'Lainnya'];
|
||||||
|
Response::error(
|
||||||
|
'Sudah ada laporan aktif: ' . ($lbl[$active['type']] ?? $active['type']) . '. Selesaikan dulu.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->prepare("INSERT INTO emergency_reports (household_id, type, severity, description, status) VALUES (?,?,?,?,'open')")
|
||||||
|
->execute([(int)$data['household_id'], $data['type'], $data['severity'], Validator::sanitizeString($data['description'])]);
|
||||||
|
|
||||||
|
$newId = (int)$pdo->lastInsertId();
|
||||||
|
AuditLog::record('Tambah Laporan Darurat', 'emergency_reports', $newId, null, $data);
|
||||||
|
Response::created(['id' => $newId], 'Laporan darurat berhasil dibuat.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'POST:update': {
|
||||||
|
requireAuth();
|
||||||
|
if (!$id) Response::error('ID is required.', 400);
|
||||||
|
$data = Validator::json();
|
||||||
|
Validator::make($data, [
|
||||||
|
'status' => 'required|in:open,in_progress,resolved,closed',
|
||||||
|
'severity' => 'in:ringan,sedang,berat,kritis',
|
||||||
|
])->validate_or_fail();
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
$old = $pdo->prepare('SELECT * FROM emergency_reports WHERE id=?');
|
||||||
|
$old->execute([$id]);
|
||||||
|
$oldRow = $old->fetch();
|
||||||
|
if (!$oldRow) Response::notFound('Report not found.');
|
||||||
|
|
||||||
|
$fields = ['status = ?'];
|
||||||
|
$params = [$data['status']];
|
||||||
|
if (!empty($data['severity'])) { $fields[] = 'severity = ?'; $params[] = $data['severity']; }
|
||||||
|
if (!empty($data['description'])) { $fields[] = 'description = ?'; $params[] = Validator::sanitizeString($data['description']); }
|
||||||
|
if (in_array($data['status'], ['resolved','closed']) && !$oldRow['resolved_at']) { $fields[] = 'resolved_at = NOW()'; }
|
||||||
|
$params[] = $id;
|
||||||
|
|
||||||
|
$pdo->prepare('UPDATE emergency_reports SET ' . implode(', ', $fields) . ' WHERE id=?')->execute($params);
|
||||||
|
AuditLog::record('Update Laporan Darurat', 'emergency_reports', $id, $oldRow, $data);
|
||||||
|
Response::success(['id' => $id], 'Laporan diperbarui.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'POST:resolve': {
|
||||||
|
requireAuth();
|
||||||
|
if (!$id) Response::error('ID is required.', 400);
|
||||||
|
$pdo = Database::get();
|
||||||
|
$stmt = $pdo->prepare("UPDATE emergency_reports SET status='resolved', resolved_at=NOW() WHERE id=? AND status NOT IN ('resolved','closed')");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
if ($stmt->rowCount() === 0) Response::error('Report not found or already resolved.', 409);
|
||||||
|
AuditLog::record('Selesaikan Laporan', 'emergency_reports', $id);
|
||||||
|
Response::success(null, 'Laporan diselesaikan.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'POST:delete': {
|
||||||
|
requireAdmin();
|
||||||
|
if (!$id) Response::error('ID is required.', 400);
|
||||||
|
$pdo = Database::get();
|
||||||
|
$old = $pdo->prepare('SELECT * FROM emergency_reports WHERE id=?');
|
||||||
|
$old->execute([$id]);
|
||||||
|
$row = $old->fetch();
|
||||||
|
if (!$row) Response::notFound('Report not found.');
|
||||||
|
$pdo->prepare('DELETE FROM emergency_reports WHERE id=?')->execute([$id]);
|
||||||
|
AuditLog::record('Hapus Laporan Darurat', 'emergency_reports', $id, $row);
|
||||||
|
Response::success(null, 'Laporan dihapus.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
Response::methodNotAllowed();
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityColor(string $s): string
|
||||||
|
{
|
||||||
|
return match($s) {
|
||||||
|
'kritis' => '#d63230', 'berat' => '#f76707',
|
||||||
|
'sedang' => '#f59e0b', 'ringan' => '#0b9e73',
|
||||||
|
default => '#9ba4b5',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
// api/stats/index.php — Dashboard Statistics
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../config/bootstrap.php';
|
||||||
|
|
||||||
|
requireAuth();
|
||||||
|
$pdo = Database::get();
|
||||||
|
$action = $_GET['action'] ?? 'overview';
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
|
||||||
|
case 'overview': {
|
||||||
|
$centers = (int)$pdo->query("SELECT COUNT(*) FROM religious_centers WHERE is_active = 1")->fetchColumn();
|
||||||
|
$households = (int)$pdo->query("SELECT COUNT(*) FROM households WHERE is_active = 1")->fetchColumn();
|
||||||
|
|
||||||
|
// Total population (head + members)
|
||||||
|
$members = (int)$pdo->query("SELECT COUNT(*) FROM household_members")->fetchColumn();
|
||||||
|
$population = $households + $members;
|
||||||
|
|
||||||
|
// Aid received (based on aid_history)
|
||||||
|
$aidReceived = (int)$pdo->query("SELECT COUNT(DISTINCT household_id) FROM aid_history")->fetchColumn();
|
||||||
|
|
||||||
|
// Open emergency reports
|
||||||
|
$openReports = 0;
|
||||||
|
try {
|
||||||
|
$openReports = (int)$pdo->query("SELECT COUNT(*) FROM emergency_reports WHERE status IN ('open', 'in_progress')")->fetchColumn();
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
|
||||||
|
// Pending public reports
|
||||||
|
$pendingPublic = 0;
|
||||||
|
try {
|
||||||
|
$pendingPublic = (int)$pdo->query("SELECT COUNT(*) FROM public_reports WHERE status = 'pending'")->fetchColumn();
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
|
||||||
|
// Poverty breakdown
|
||||||
|
$povertyBreakdown = $pdo->query("
|
||||||
|
SELECT poverty_status, COUNT(*) AS cnt
|
||||||
|
FROM households WHERE is_active = 1
|
||||||
|
GROUP BY poverty_status
|
||||||
|
")->fetchAll(\PDO::FETCH_KEY_PAIR);
|
||||||
|
|
||||||
|
// Condition breakdown
|
||||||
|
$conditionBreakdown = $pdo->query("
|
||||||
|
SELECT house_condition, COUNT(*) AS cnt
|
||||||
|
FROM households WHERE is_active = 1
|
||||||
|
GROUP BY house_condition
|
||||||
|
")->fetchAll(\PDO::FETCH_KEY_PAIR);
|
||||||
|
|
||||||
|
Response::success([
|
||||||
|
'centers' => $centers,
|
||||||
|
'households' => $households,
|
||||||
|
'population' => $population,
|
||||||
|
'aid_received' => $aidReceived,
|
||||||
|
'aid_not_yet' => max(0, $households - $aidReceived),
|
||||||
|
'open_reports' => $openReports,
|
||||||
|
'pending_public' => $pendingPublic,
|
||||||
|
'poverty_breakdown' => [
|
||||||
|
'sangat_miskin' => (int)($povertyBreakdown['sangat_miskin'] ?? 0),
|
||||||
|
'miskin' => (int)($povertyBreakdown['miskin'] ?? 0),
|
||||||
|
'rentan_miskin' => (int)($povertyBreakdown['rentan_miskin'] ?? 0),
|
||||||
|
'terdata' => (int)($povertyBreakdown['terdata'] ?? 0),
|
||||||
|
],
|
||||||
|
'condition_breakdown' => [
|
||||||
|
'layak' => (int)($conditionBreakdown['layak'] ?? 0),
|
||||||
|
'tidak_layak' => (int)($conditionBreakdown['tidak_layak'] ?? 0),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'trend': {
|
||||||
|
$rows = $pdo->query("
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(created_at, '%Y-%m') AS month,
|
||||||
|
COUNT(*) AS new_households,
|
||||||
|
SUM(CASE WHEN aid_status = 'received' THEN 1 ELSE 0 END) AS aided,
|
||||||
|
ROUND(AVG(poverty_score), 1) AS avg_score
|
||||||
|
FROM households
|
||||||
|
WHERE is_active = 1
|
||||||
|
AND created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH)
|
||||||
|
GROUP BY month
|
||||||
|
ORDER BY month
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
foreach ($rows as &$r) {
|
||||||
|
$r['new_households'] = (int)$r['new_households'];
|
||||||
|
$r['aided'] = (int)$r['aided'];
|
||||||
|
$r['avg_score'] = (float)$r['avg_score'];
|
||||||
|
}
|
||||||
|
unset($r);
|
||||||
|
|
||||||
|
Response::success(['trend' => $rows]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'poverty_chart': {
|
||||||
|
$breakdown = $pdo->query("
|
||||||
|
SELECT poverty_status, COUNT(*) AS count,
|
||||||
|
ROUND(AVG(poverty_score), 1) AS avg_score
|
||||||
|
FROM households WHERE is_active = 1
|
||||||
|
GROUP BY poverty_status
|
||||||
|
ORDER BY FIELD(poverty_status, 'sangat_miskin', 'miskin', 'rentan_miskin', 'terdata')
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
Response::success(['breakdown' => $breakdown]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'aid_chart': {
|
||||||
|
$byType = $pdo->query("
|
||||||
|
SELECT aid_type, COUNT(*) AS cnt, COALESCE(SUM(amount), 0) AS total_amount
|
||||||
|
FROM aid_history
|
||||||
|
GROUP BY aid_type
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
$total = (int)$pdo->query("SELECT COUNT(*) FROM aid_history")->fetchColumn();
|
||||||
|
|
||||||
|
Response::success([
|
||||||
|
'by_type' => $byType,
|
||||||
|
'summary' => ['total_distributions' => $total],
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'age_distribution': {
|
||||||
|
$headQuery = "
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN TIMESTAMPDIFF(YEAR, head_date_of_birth, CURDATE()) < 12 THEN 'anak'
|
||||||
|
WHEN TIMESTAMPDIFF(YEAR, head_date_of_birth, CURDATE()) BETWEEN 12 AND 17 THEN 'remaja'
|
||||||
|
WHEN TIMESTAMPDIFF(YEAR, head_date_of_birth, CURDATE()) BETWEEN 18 AND 30 THEN 'pemuda'
|
||||||
|
WHEN TIMESTAMPDIFF(YEAR, head_date_of_birth, CURDATE()) BETWEEN 31 AND 59 THEN 'dewasa'
|
||||||
|
WHEN TIMESTAMPDIFF(YEAR, head_date_of_birth, CURDATE()) >= 60 THEN 'lansia'
|
||||||
|
ELSE NULL
|
||||||
|
END AS age_group
|
||||||
|
FROM households
|
||||||
|
WHERE is_active = 1 AND head_date_of_birth IS NOT NULL
|
||||||
|
";
|
||||||
|
|
||||||
|
$memberQuery = "
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN TIMESTAMPDIFF(YEAR, hm.date_of_birth, CURDATE()) < 12 THEN 'anak'
|
||||||
|
WHEN TIMESTAMPDIFF(YEAR, hm.date_of_birth, CURDATE()) BETWEEN 12 AND 17 THEN 'remaja'
|
||||||
|
WHEN TIMESTAMPDIFF(YEAR, hm.date_of_birth, CURDATE()) BETWEEN 18 AND 30 THEN 'pemuda'
|
||||||
|
WHEN TIMESTAMPDIFF(YEAR, hm.date_of_birth, CURDATE()) BETWEEN 31 AND 59 THEN 'dewasa'
|
||||||
|
WHEN TIMESTAMPDIFF(YEAR, hm.date_of_birth, CURDATE()) >= 60 THEN 'lansia'
|
||||||
|
ELSE NULL
|
||||||
|
END AS age_group
|
||||||
|
FROM household_members hm
|
||||||
|
INNER JOIN households h ON h.id = hm.household_id
|
||||||
|
WHERE h.is_active = 1 AND hm.date_of_birth IS NOT NULL
|
||||||
|
";
|
||||||
|
|
||||||
|
$combinedQuery = "
|
||||||
|
SELECT age_group, COUNT(*) as total
|
||||||
|
FROM (
|
||||||
|
$headQuery
|
||||||
|
UNION ALL
|
||||||
|
$memberQuery
|
||||||
|
) AS all_persons
|
||||||
|
WHERE age_group IS NOT NULL
|
||||||
|
GROUP BY age_group
|
||||||
|
";
|
||||||
|
|
||||||
|
$result = $pdo->query($combinedQuery);
|
||||||
|
$rows = $result->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Inisialisasi array dengan nilai default 0
|
||||||
|
$distribution = [
|
||||||
|
'anak' => 0, // < 12 tahun
|
||||||
|
'remaja' => 0, // 12-17 tahun
|
||||||
|
'pemuda' => 0, // 18-30 tahun
|
||||||
|
'dewasa' => 0, // 31-59 tahun
|
||||||
|
'lansia' => 0, // >= 60 tahun
|
||||||
|
'unknown' => 0, // data tanggal lahir tidak tersedia
|
||||||
|
];
|
||||||
|
|
||||||
|
// Hitung jumlah orang tanpa tanggal lahir
|
||||||
|
$unknownCount = 0;
|
||||||
|
|
||||||
|
// Hitung kepala keluarga tanpa tanggal lahir
|
||||||
|
$stmt = $pdo->query("SELECT COUNT(*) FROM households WHERE is_active = 1 AND head_date_of_birth IS NULL");
|
||||||
|
$unknownCount += (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
|
// Hitung anggota keluarga tanpa tanggal lahir
|
||||||
|
$stmt = $pdo->query("
|
||||||
|
SELECT COUNT(*) FROM household_members hm
|
||||||
|
INNER JOIN households h ON h.id = hm.household_id
|
||||||
|
WHERE h.is_active = 1 AND hm.date_of_birth IS NULL
|
||||||
|
");
|
||||||
|
$unknownCount += (int)$stmt->fetchColumn();
|
||||||
|
$distribution['unknown'] = $unknownCount;
|
||||||
|
|
||||||
|
// Isi hasil query ke array distribution
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (isset($distribution[$row['age_group']])) {
|
||||||
|
$distribution[$row['age_group']] = (int)$row['total'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hitung total keseluruhan untuk debugging (opsional)
|
||||||
|
$totalPeople = array_sum($distribution) - $distribution['unknown'];
|
||||||
|
|
||||||
|
Response::success([
|
||||||
|
'age_distribution' => $distribution,
|
||||||
|
'total_people' => $totalPeople,
|
||||||
|
'includes_members' => true // Indikator bahwa sudah termasuk anggota keluarga
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
Response::error('Unknown stats action.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
/* ============================================================
|
||||||
|
admin.js — Admin panel: public reports (now accessible to Field Officers)
|
||||||
|
============================================================ */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
async function openAdminPanel() {
|
||||||
|
openModal('adminModal');
|
||||||
|
loadPendingReports();
|
||||||
|
updatePendingBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePendingBadge() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('api/public/report.php?action=list&status=pending&limit=1');
|
||||||
|
const d = await r.json();
|
||||||
|
const cnt = d?.data?.total ?? 0;
|
||||||
|
const el = document.getElementById('pendingBadge');
|
||||||
|
if (el) el.textContent = cnt > 0 ? cnt : '';
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Status filter change - tetap tersedia untuk semua role
|
||||||
|
document.getElementById('pendingStatusFilter')
|
||||||
|
?.addEventListener('change', loadPendingReports);
|
||||||
|
|
||||||
|
// Refresh badge every 90 seconds
|
||||||
|
setInterval(updatePendingBadge, 90_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/* ============================================================
|
||||||
|
api.js — Centralized fetch wrapper
|
||||||
|
============================================================ */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Http = {
|
||||||
|
async request(url, options = {}) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { ...options, headers });
|
||||||
|
const data = await res.json();
|
||||||
|
return { ok: res.ok, status: res.status, data };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[API] Network error:', err);
|
||||||
|
return { ok: false, status: 0, data: { success: false, message: 'Koneksi gagal.' } };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async post(endpoint, body = {}) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApiHouses = {
|
||||||
|
async list(params = {}) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return Http.request(API.houses + (qs ? '?' + qs : ''), { method: 'GET' });
|
||||||
|
},
|
||||||
|
async show(id) {
|
||||||
|
return Http.request(API.houses + '?action=show&id=' + id, { method: 'GET' });
|
||||||
|
},
|
||||||
|
async create(body) {
|
||||||
|
return Http.post(API.houses + '?action=create', body);
|
||||||
|
},
|
||||||
|
async update(id, body) {
|
||||||
|
return Http.post(API.houses + '?action=update&id=' + id, body);
|
||||||
|
},
|
||||||
|
async patch(id, body) {
|
||||||
|
return Http.post(API.houses + '?action=patch&id=' + id, body);
|
||||||
|
},
|
||||||
|
async delete(id) {
|
||||||
|
return Http.post(API.houses + '?action=delete&id=' + id);
|
||||||
|
},
|
||||||
|
async verify(id) {
|
||||||
|
return Http.post(API.houses + '?action=verify&id=' + id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApiCenters = {
|
||||||
|
async list(params = {}) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return Http.request(API.centers + (qs ? '?' + qs : ''), { method: 'GET' });
|
||||||
|
},
|
||||||
|
async show(id) {
|
||||||
|
return Http.request(API.centers + '?action=show&id=' + id, { method: 'GET' });
|
||||||
|
},
|
||||||
|
async create(body) {
|
||||||
|
return Http.post(API.centers + '?action=create', body);
|
||||||
|
},
|
||||||
|
async update(id, body) {
|
||||||
|
return Http.post(API.centers + '?action=update&id=' + id, body);
|
||||||
|
},
|
||||||
|
async patch(id, body) {
|
||||||
|
return Http.post(API.centers + '?action=patch&id=' + id, body);
|
||||||
|
},
|
||||||
|
async delete(id) {
|
||||||
|
return Http.post(API.centers + '?action=delete&id=' + id);
|
||||||
|
},
|
||||||
|
async coverage(id) {
|
||||||
|
return Http.request(API.centers + '?action=coverage&id=' + id, { method: 'GET' });
|
||||||
|
},
|
||||||
|
async nearby(lat, lng, km = 5) {
|
||||||
|
return Http.request(API.centers + `?action=nearby&lat=${lat}&lng=${lng}&km=${km}`, { method: 'GET' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApiAid = {
|
||||||
|
async list(params = {}) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return Http.request(API.aid + (qs ? '?' + qs : ''), { method: 'GET' });
|
||||||
|
},
|
||||||
|
async show(id) {
|
||||||
|
return Http.request(API.aid + '?action=show&id=' + id, { method: 'GET' });
|
||||||
|
},
|
||||||
|
async create(body) {
|
||||||
|
return Http.post(API.aid + '?action=create', body);
|
||||||
|
},
|
||||||
|
async update(id, body) {
|
||||||
|
return Http.post(API.aid + '?action=update&id=' + id, body);
|
||||||
|
},
|
||||||
|
async delete(id) {
|
||||||
|
return Http.post(API.aid + '?action=delete&id=' + id);
|
||||||
|
},
|
||||||
|
async stats() {
|
||||||
|
return Http.request(API.aid + '?action=stats', { method: 'GET' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApiReports = {
|
||||||
|
async list(params = {}) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return Http.request(API.reports + (qs ? '?' + qs : ''), { method: 'GET' });
|
||||||
|
},
|
||||||
|
async show(id) {
|
||||||
|
return Http.request(API.reports + '?action=show&id=' + id, { method: 'GET' });
|
||||||
|
},
|
||||||
|
async create(body) {
|
||||||
|
return Http.post(API.reports + '?action=create', body);
|
||||||
|
},
|
||||||
|
async update(id, body) {
|
||||||
|
return Http.post(API.reports + '?action=update&id=' + id, body);
|
||||||
|
},
|
||||||
|
async resolve(id) {
|
||||||
|
return Http.post(API.reports + '?action=resolve&id=' + id);
|
||||||
|
},
|
||||||
|
async delete(id) {
|
||||||
|
return Http.post(API.reports + '?action=delete&id=' + id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApiStats = {
|
||||||
|
async overview() {
|
||||||
|
return Http.request(API.stats + '?action=overview', { method: 'GET' });
|
||||||
|
},
|
||||||
|
async trend() {
|
||||||
|
return Http.request(API.stats + '?action=trend', { method: 'GET' });
|
||||||
|
},
|
||||||
|
async povertyChart() {
|
||||||
|
return Http.request(API.stats + '?action=poverty_chart', { method: 'GET' });
|
||||||
|
},
|
||||||
|
async aidChart() {
|
||||||
|
return Http.request(API.stats + '?action=aid_chart', { method: 'GET' });
|
||||||
|
},
|
||||||
|
async centerStats() {
|
||||||
|
return Http.request(API.stats + '?action=center_stats', { method: 'GET' });
|
||||||
|
},
|
||||||
|
async ageDistribution() {
|
||||||
|
return Http.request(API.stats + '?action=age_distribution', { method: 'GET' });
|
||||||
|
},
|
||||||
|
async education() {
|
||||||
|
return Http.request(API.stats + '?action=education', { method: 'GET' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApiUsers = {
|
||||||
|
async list(params = {}) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return Http.request(API.users + (qs ? '?' + qs : ''), { method: 'GET' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApiLogs = {
|
||||||
|
async list(params = {}) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return Http.request(API.logs + (qs ? '?' + qs : ''), { method: 'GET' });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/* ============================================================
|
||||||
|
app.js — Main app orchestrator with authentication
|
||||||
|
============================================================ */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Flag to prevent duplicate initialization
|
||||||
|
let isAppInitialized = false;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
// Prevent duplicate initialization
|
||||||
|
if (isAppInitialized) {
|
||||||
|
console.warn('App already initialized, skipping duplicate call');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check authentication
|
||||||
|
const isAuthed = await checkAuth();
|
||||||
|
|
||||||
|
if (!isAuthed) {
|
||||||
|
// Not logged in, redirect to login
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize UI based on role
|
||||||
|
initUIByRole();
|
||||||
|
|
||||||
|
// Add logout button to sidebar
|
||||||
|
addLogoutButton();
|
||||||
|
|
||||||
|
// Init map
|
||||||
|
initMap();
|
||||||
|
|
||||||
|
// Init UI
|
||||||
|
initNavTabs();
|
||||||
|
initFilters();
|
||||||
|
initFormTabs();
|
||||||
|
initHelpModal();
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
await loadAllData();
|
||||||
|
|
||||||
|
// Nav tab: activate placement modes
|
||||||
|
hookTabPlacementModes();
|
||||||
|
|
||||||
|
isAppInitialized = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshCenters() {
|
||||||
|
const r = await ApiCenters.list();
|
||||||
|
if (r.ok && r.data?.success) {
|
||||||
|
State.centers = r.data.data.centers || [];
|
||||||
|
recountCenterHouseholds(); // Hitung ulang setelah dapat data baru
|
||||||
|
renderCenters();
|
||||||
|
updateLayerCounts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override loadAllData untuk memastikan recount
|
||||||
|
async function loadAllData() {
|
||||||
|
showLoading(true);
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
if (State.povertyFilter) params.poverty_status = State.povertyFilter;
|
||||||
|
if (State.aidFilter) params.aid_status = State.aidFilter;
|
||||||
|
if (State.searchQuery) params.q = State.searchQuery;
|
||||||
|
if (State.conditionFilter) params.house_condition = State.conditionFilter;
|
||||||
|
params.limit = 500;
|
||||||
|
|
||||||
|
const [centersRes, housesRes] = await Promise.all([
|
||||||
|
ApiCenters.list(),
|
||||||
|
ApiHouses.list(params),
|
||||||
|
loadStats()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (centersRes.ok && centersRes.data?.success) {
|
||||||
|
State.centers = centersRes.data.data.centers || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (housesRes.ok && housesRes.data?.success) {
|
||||||
|
State.houses = housesRes.data.data.households || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
recountCenterHouseholds(); // ⭐ PASTIKAN HITUNG ULANG
|
||||||
|
renderCenters();
|
||||||
|
renderHouses();
|
||||||
|
|
||||||
|
showLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export fungsi refresh
|
||||||
|
window.refreshCenters = refreshCenters;
|
||||||
|
|
||||||
|
async function loadCenters() {
|
||||||
|
const r = await ApiCenters.list();
|
||||||
|
if (r.ok && r.data?.success) {
|
||||||
|
State.centers = r.data.data.centers || [];
|
||||||
|
}
|
||||||
|
renderCenters();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHouses() {
|
||||||
|
const params = {};
|
||||||
|
if (State.povertyFilter) params.poverty_status = State.povertyFilter;
|
||||||
|
if (State.aidFilter) params.aid_status = State.aidFilter;
|
||||||
|
if (State.searchQuery) params.q = State.searchQuery;
|
||||||
|
if (State.conditionFilter) params.house_condition = State.conditionFilter;
|
||||||
|
|
||||||
|
const r = await ApiHouses.list({ ...params, limit: 500 });
|
||||||
|
if (r.ok && r.data?.success) {
|
||||||
|
State.houses = r.data.data.households || [];
|
||||||
|
}
|
||||||
|
renderHouses();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
const r = await ApiStats.overview();
|
||||||
|
if (!r.ok || !r.data?.success) return;
|
||||||
|
|
||||||
|
const d = r.data.data;
|
||||||
|
State.stats = d;
|
||||||
|
|
||||||
|
animateCount('statCenters', d.centers);
|
||||||
|
animateCount('statHouses', d.households);
|
||||||
|
animateCount('statPopulation', d.population);
|
||||||
|
animateCount('statAided', d.aid_received);
|
||||||
|
animateCount('statPending', d.pending_public ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for public-reports.js
|
||||||
|
window.loadAllData = loadAllData;
|
||||||
|
window.loadStats = loadStats;
|
||||||
|
|
||||||
|
function animateCount(id, target) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
const start = parseInt(el.textContent.replace(/\D/g,'')) || 0;
|
||||||
|
const diff = target - start;
|
||||||
|
const steps = 20;
|
||||||
|
let step = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
step++;
|
||||||
|
el.textContent = Math.round(start + (diff * step / steps)).toLocaleString('id-ID');
|
||||||
|
if (step >= steps) clearInterval(interval);
|
||||||
|
}, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initNavTabs() {
|
||||||
|
document.querySelectorAll('.nav-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
const key = tab.dataset.tab;
|
||||||
|
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
|
||||||
|
if (key === 'dashboard') {
|
||||||
|
openDashboard();
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelector('[data-tab="overview"]').classList.add('active');
|
||||||
|
}, 200);
|
||||||
|
} else if (key === 'overview') {
|
||||||
|
cancelPlacementMode();
|
||||||
|
State.activeFilter = 'all';
|
||||||
|
renderAllLayers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hookTabPlacementModes() {
|
||||||
|
document.querySelector('[data-tab="centers"]')?.addEventListener('click', () => {
|
||||||
|
setPlacementMode('center');
|
||||||
|
showToast('Klik peta untuk menambah tempat ibadah.', 'success', 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('[data-tab="houses"]')?.addEventListener('click', () => {
|
||||||
|
setPlacementMode('house');
|
||||||
|
showToast('Klik peta untuk menambah rumah tangga.', 'success', 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFilters() {
|
||||||
|
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
State.activeFilter = btn.dataset.filter;
|
||||||
|
renderAllLayers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', debounce(async (e) => {
|
||||||
|
State.searchQuery = e.target.value.trim();
|
||||||
|
renderAllLayers();
|
||||||
|
}, 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let helpStep = 1;
|
||||||
|
const helpTotal = 5;
|
||||||
|
|
||||||
|
function initHelpModal() {
|
||||||
|
document.getElementById('helpBtn')?.addEventListener('click', () => {
|
||||||
|
helpStep = 1;
|
||||||
|
updateHelpStep();
|
||||||
|
openModal('helpModal');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function helpNav(dir) {
|
||||||
|
helpStep = Math.min(helpTotal, Math.max(1, helpStep + dir));
|
||||||
|
updateHelpStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHelpStep() {
|
||||||
|
document.querySelectorAll('.help-step').forEach(s => s.classList.remove('active'));
|
||||||
|
document.querySelector(`.help-step[data-step="${helpStep}"]`)?.classList.add('active');
|
||||||
|
document.getElementById('helpProgress').textContent = helpStep + ' / ' + helpTotal;
|
||||||
|
document.getElementById('helpPrev').disabled = helpStep === 1;
|
||||||
|
document.getElementById('helpNext').disabled = helpStep === helpTotal;
|
||||||
|
if (helpStep === helpTotal) {
|
||||||
|
document.getElementById('helpNext').textContent = 'Selesai';
|
||||||
|
document.getElementById('helpNext').onclick = () => closeModal('helpModal');
|
||||||
|
} else {
|
||||||
|
document.getElementById('helpNext').textContent = 'Berikutnya →';
|
||||||
|
document.getElementById('helpNext').onclick = () => helpNav(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
cancelPlacementMode();
|
||||||
|
document.querySelectorAll('.modal-overlay').forEach(m => {
|
||||||
|
if (m.style.display === 'flex') {
|
||||||
|
m.style.display = 'none';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.key === 'r') {
|
||||||
|
e.preventDefault();
|
||||||
|
loadAllData().then(() => showToast('Data diperbarui.', 'success'));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/* ============================================================
|
||||||
|
auth.js — Authentication and role-based UI management
|
||||||
|
============================================================ */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Global user state
|
||||||
|
let currentUser = null;
|
||||||
|
let isLoggedIn = false;
|
||||||
|
let userRole = null;
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Session check on page load
|
||||||
|
// ================================================================
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('api/auth/check.php');
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.data && result.data.logged_in === true) {
|
||||||
|
isLoggedIn = true;
|
||||||
|
currentUser = {
|
||||||
|
id: result.data.user_id,
|
||||||
|
name: result.data.name,
|
||||||
|
email: result.data.email,
|
||||||
|
role: result.data.role
|
||||||
|
};
|
||||||
|
userRole = result.data.role;
|
||||||
|
|
||||||
|
window.userRole = userRole;
|
||||||
|
window.currentUser = currentUser;
|
||||||
|
window.currentUserName = currentUser.name;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
isLoggedIn = false;
|
||||||
|
currentUser = null;
|
||||||
|
userRole = null;
|
||||||
|
window.userRole = null;
|
||||||
|
window.currentUser = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check failed:', error);
|
||||||
|
isLoggedIn = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Initialize UI based on user role
|
||||||
|
// ================================================================
|
||||||
|
function initUIByRole() {
|
||||||
|
if (!isLoggedIn || !userRole) {
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = (userRole === 'admin');
|
||||||
|
|
||||||
|
window.canDelete = isAdmin;
|
||||||
|
|
||||||
|
const chartTab = document.querySelector('.nav-tab[data-tab="dashboard"]');
|
||||||
|
if (chartTab) {
|
||||||
|
chartTab.style.display = isAdmin ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminPanelBtn = document.querySelector('.btn-admin-panel');
|
||||||
|
if (adminPanelBtn) {
|
||||||
|
adminPanelBtn.style.display = ''; // SEMUA USER BISA LIHAT (Field Officer juga)
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminTopBar = document.querySelector('.admin-top-bar');
|
||||||
|
if (adminTopBar) {
|
||||||
|
adminTopBar.style.display = isAdmin ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const userNameEl = document.getElementById('userName');
|
||||||
|
const userRoleEl = document.getElementById('userRoleDisplay');
|
||||||
|
if (userNameEl && currentUser) {
|
||||||
|
userNameEl.textContent = currentUser.name;
|
||||||
|
}
|
||||||
|
if (userRoleEl && currentUser) {
|
||||||
|
const roleLabel = userRole === 'admin' ? 'Admin' : 'Petugas Lapangan';
|
||||||
|
userRoleEl.textContent = roleLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`UI initialized for role: ${userRole} (Admin: ${isAdmin})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Logout function
|
||||||
|
// ================================================================
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await fetch('api/auth/check.php?action=logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Add logout button to sidebar (only if not already exists)
|
||||||
|
// ================================================================
|
||||||
|
function addLogoutButton() {
|
||||||
|
// CEK: apakah tombol logout sudah ada di HTML (dari index.html)
|
||||||
|
const existingLogoutBtn = document.querySelector('.sidebar-header .logout-btn, #logoutBtn');
|
||||||
|
if (existingLogoutBtn) {
|
||||||
|
console.log('Logout button already exists, skipping dynamic addition');
|
||||||
|
return; // JANGAN tambahkan tombol baru jika sudah ada
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: jika belum ada, baru tambahkan
|
||||||
|
const sidebarHeader = document.querySelector('.sidebar-header');
|
||||||
|
if (!sidebarHeader) return;
|
||||||
|
|
||||||
|
const logoutBtn = document.createElement('button');
|
||||||
|
logoutBtn.id = 'logoutBtn';
|
||||||
|
logoutBtn.className = 'logout-btn';
|
||||||
|
logoutBtn.innerHTML = '<i class="fas fa-sign-out-alt"></i>';
|
||||||
|
logoutBtn.title = 'Logout';
|
||||||
|
logoutBtn.onclick = logout;
|
||||||
|
logoutBtn.style.cssText = `
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
margin-left: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.14s;
|
||||||
|
`;
|
||||||
|
logoutBtn.onmouseenter = () => logoutBtn.style.backgroundColor = 'rgba(214,50,48,0.1)';
|
||||||
|
logoutBtn.onmouseleave = () => logoutBtn.style.backgroundColor = 'transparent';
|
||||||
|
|
||||||
|
const headerRight = sidebarHeader.querySelector('div[style*="margin-left:auto"]');
|
||||||
|
if (headerRight) {
|
||||||
|
headerRight.appendChild(logoutBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDelete() {
|
||||||
|
return userRole === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserRole() {
|
||||||
|
return userRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserName() {
|
||||||
|
return currentUser ? currentUser.name : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.auth = {
|
||||||
|
checkAuth,
|
||||||
|
initUIByRole,
|
||||||
|
logout,
|
||||||
|
addLogoutButton,
|
||||||
|
canDelete,
|
||||||
|
getUserRole,
|
||||||
|
getUserName,
|
||||||
|
currentUser: () => currentUser,
|
||||||
|
isLoggedIn: () => isLoggedIn,
|
||||||
|
userRole: () => userRole
|
||||||
|
};
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/* ============================================================
|
||||||
|
config.js — Global constants, utilities, shared state
|
||||||
|
Single-admin simplified version
|
||||||
|
============================================================ */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const API = {
|
||||||
|
houses: 'api/houses/index.php',
|
||||||
|
centers: 'api/centers/index.php',
|
||||||
|
aid: 'api/aid/index.php',
|
||||||
|
reports: 'api/reports/index.php',
|
||||||
|
stats: 'api/stats/index.php',
|
||||||
|
users: 'api/users/index.php',
|
||||||
|
logs: 'api/logs/index.php',
|
||||||
|
};
|
||||||
|
|
||||||
|
const POVERTY_COLORS = {
|
||||||
|
sangat_miskin: '#d63230',
|
||||||
|
miskin: '#f76707',
|
||||||
|
rentan_miskin: '#f59e0b',
|
||||||
|
terdata: '#0b9e73',
|
||||||
|
};
|
||||||
|
|
||||||
|
const POVERTY_LABELS = {
|
||||||
|
sangat_miskin: 'Sangat Miskin',
|
||||||
|
miskin: 'Miskin',
|
||||||
|
rentan_miskin: 'Rentan Miskin',
|
||||||
|
terdata: 'Terdata',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Short labels for compact display
|
||||||
|
const POVERTY_SHORT = {
|
||||||
|
sangat_miskin: 'Sgt Miskin',
|
||||||
|
miskin: 'Miskin',
|
||||||
|
rentan_miskin: 'Rentan',
|
||||||
|
terdata: 'Terdata',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CENTER_COLORS = {
|
||||||
|
masjid: '#1d6fa4',
|
||||||
|
gereja: '#7c3aed',
|
||||||
|
klenteng: '#b45309',
|
||||||
|
pura: '#0e7f6e',
|
||||||
|
vihara: '#a16207',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CENTER_ICONS = {
|
||||||
|
masjid: 'fa-mosque',
|
||||||
|
gereja: 'fa-church',
|
||||||
|
klenteng: 'fa-torii-gate',
|
||||||
|
pura: 'fa-om',
|
||||||
|
vihara: 'fa-dharmachakra',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CENTER_LABELS = {
|
||||||
|
masjid: 'Masjid',
|
||||||
|
gereja: 'Gereja',
|
||||||
|
klenteng: 'Klenteng',
|
||||||
|
pura: 'Pura',
|
||||||
|
vihara: 'Vihara',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AID_LABELS = {
|
||||||
|
sembako: 'Sembako',
|
||||||
|
pendanaan: 'Pendanaan',
|
||||||
|
pelatihan: 'Pelatihan',
|
||||||
|
sembako_pendanaan: 'Sembako + Pendanaan',
|
||||||
|
sembako_pelatihan: 'Sembako + Pelatihan',
|
||||||
|
pendanaan_pelatihan: 'Pendanaan + Pelatihan',
|
||||||
|
lengkap: 'Lengkap',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEVERITY_COLORS = {
|
||||||
|
kritis: '#d63230',
|
||||||
|
berat: '#f76707',
|
||||||
|
sedang: '#f59e0b',
|
||||||
|
ringan: '#0b9e73',
|
||||||
|
};
|
||||||
|
|
||||||
|
const State = {
|
||||||
|
activeFilter: 'all',
|
||||||
|
povertyFilter: '',
|
||||||
|
aidFilter: '',
|
||||||
|
conditionFilter: '',
|
||||||
|
searchQuery: '',
|
||||||
|
centers: [],
|
||||||
|
houses: [],
|
||||||
|
stats: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function debounce(fn, delay = 300) {
|
||||||
|
let t;
|
||||||
|
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), delay); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRp(n) {
|
||||||
|
if (!n) return 'Rp 0';
|
||||||
|
return 'Rp ' + Number(n).toLocaleString('id-ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(str) {
|
||||||
|
if (!str) return '—';
|
||||||
|
return new Date(str).toLocaleDateString('id-ID', { day:'2-digit', month:'short', year:'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(str) {
|
||||||
|
if (!str) return '—';
|
||||||
|
return new Date(str).toLocaleString('id-ID', { day:'2-digit', month:'short', year:'numeric', hour:'2-digit', minute:'2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(str, n = 28) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.length > n ? str.slice(0, n) + '…' : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg, type = 'success', duration = 2800) {
|
||||||
|
const el = document.getElementById('toast');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = 'toast ' + type;
|
||||||
|
el.style.display = 'block';
|
||||||
|
setTimeout(() => { el.style.display = 'none'; }, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoading(show = true) {
|
||||||
|
document.getElementById('loading').style.display = show ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) { el.style.display = 'flex'; document.body.style.overflow = 'hidden'; }
|
||||||
|
}
|
||||||
|
function closeModal(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) { el.style.display = 'none'; document.body.style.overflow = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.classList.contains('modal-overlay')) {
|
||||||
|
e.target.style.display = 'none';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
/* ============================================================
|
||||||
|
dashboard.js — Chart.js analytics dashboard
|
||||||
|
============================================================ */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let charts = {};
|
||||||
|
|
||||||
|
async function openDashboard() {
|
||||||
|
openModal('dashboardModal');
|
||||||
|
// Always fetch fresh data when opening
|
||||||
|
await renderDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyChart(id) {
|
||||||
|
if (charts[id]) {
|
||||||
|
charts[id].destroy();
|
||||||
|
delete charts[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDashboard() {
|
||||||
|
showLoading(true);
|
||||||
|
|
||||||
|
// Destroy all existing charts before re-rendering
|
||||||
|
Object.keys(charts).forEach(key => destroyChart(key));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [overview, trend, poverty, aidStat, age] = await Promise.all([
|
||||||
|
ApiStats.overview(),
|
||||||
|
ApiStats.trend(),
|
||||||
|
ApiStats.povertyChart(),
|
||||||
|
ApiStats.aidChart(),
|
||||||
|
ApiStats.ageDistribution(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
showLoading(false);
|
||||||
|
|
||||||
|
if (poverty && poverty.ok) renderPovertyChart(poverty.data.data);
|
||||||
|
if (trend && trend.ok) renderTrendChart(trend.data.data);
|
||||||
|
if (age && age.ok) renderAgeChart(age.data.data);
|
||||||
|
if (aidStat && aidStat.ok) renderAidChart(aidStat.data.data);
|
||||||
|
} catch (err) {
|
||||||
|
showLoading(false);
|
||||||
|
console.error('Dashboard render error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartDefaults() {
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
font: { family: "'DM Sans', sans-serif", size: 10 },
|
||||||
|
color: '#5a6478',
|
||||||
|
padding: 12,
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyleWidth: 8,
|
||||||
|
boxHeight: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: '#0f1623',
|
||||||
|
titleFont: { family: "'DM Sans', sans-serif", size: 11, weight: '700' },
|
||||||
|
bodyFont: { family: "'DM Sans', sans-serif", size: 11 },
|
||||||
|
padding: 10,
|
||||||
|
cornerRadius: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: 'rgba(0,0,0,0.03)' },
|
||||||
|
ticks: { font: { family: "'DM Sans', sans-serif", size: 9 }, color: '#9ba4b5' },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: 'rgba(0,0,0,0.03)' },
|
||||||
|
ticks: { font: { family: "'DM Sans', sans-serif", size: 9 }, color: '#9ba4b5', precision: 0 },
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Poverty distribution (doughnut) -----------------------
|
||||||
|
function renderPovertyChart(data) {
|
||||||
|
destroyChart('poverty');
|
||||||
|
const bd = data?.breakdown || [];
|
||||||
|
if (!bd.length) return;
|
||||||
|
|
||||||
|
const labels = bd.map(r => POVERTY_LABELS[r.poverty_status] || r.poverty_status);
|
||||||
|
const values = bd.map(r => parseInt(r.count));
|
||||||
|
const colors = bd.map(r => POVERTY_COLORS[r.poverty_status] || '#9ba4b5');
|
||||||
|
|
||||||
|
const ctx = document.getElementById('chartPoverty');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
charts.poverty = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
data: values,
|
||||||
|
backgroundColor: colors,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#fff',
|
||||||
|
hoverOffset: 8,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...chartDefaults(),
|
||||||
|
cutout: '62%',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Trend line chart (12 months) --------------------------
|
||||||
|
function renderTrendChart(data) {
|
||||||
|
destroyChart('trend');
|
||||||
|
const rows = data?.trend || [];
|
||||||
|
if (!rows.length) {
|
||||||
|
const ctx = document.getElementById('chartTrend');
|
||||||
|
if (ctx && ctx.parentElement) {
|
||||||
|
ctx.parentElement.innerHTML = '<div style="text-align:center;padding:40px;color:#9ba4b5;"><i class="fas fa-chart-line" style="font-size:24px;margin-bottom:10px;display:block;"></i>Belum ada data pendataan 12 bulan terakhir</div>';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format bulan (contoh: "2025-01" -> "Jan 2025")
|
||||||
|
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
|
||||||
|
const labels = rows.map(r => {
|
||||||
|
const [year, month] = r.month.split('-');
|
||||||
|
return `${months[parseInt(month) - 1]} ${year}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = document.getElementById('chartTrend');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
charts.trend = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Rumah Baru',
|
||||||
|
data: rows.map(r => r.new_households),
|
||||||
|
backgroundColor: '#46b4f4',
|
||||||
|
borderWidth: 0,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Dibantu',
|
||||||
|
data: rows.map(r => r.aided),
|
||||||
|
backgroundColor: '#7d5ce8',
|
||||||
|
borderWidth: 0,
|
||||||
|
borderRadius: 4,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...chartDefaults(),
|
||||||
|
plugins: {
|
||||||
|
...chartDefaults().plugins,
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
font: { family: "'DM Sans', sans-serif", size: 10 },
|
||||||
|
color: '#5a6478',
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyleWidth: 8,
|
||||||
|
boxHeight: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: {
|
||||||
|
font: { size: 9 },
|
||||||
|
maxRotation: 35,
|
||||||
|
autoSkip: true,
|
||||||
|
maxTicksLimit: 8
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||||
|
ticks: {
|
||||||
|
stepSize: 1,
|
||||||
|
precision: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Age distribution of DEPENDENTS (bar) ------------------
|
||||||
|
function renderAgeChart(data) {
|
||||||
|
destroyChart('age');
|
||||||
|
const dist = data?.age_distribution || {};
|
||||||
|
|
||||||
|
// Only dependent age groups
|
||||||
|
const labels = ['Anak (<12)', 'Remaja (12-17)', 'Pemuda (18-30)', 'Dewasa (31-59)', 'Lansia (60+)'];
|
||||||
|
const values = [
|
||||||
|
dist.anak || 0,
|
||||||
|
dist.remaja || 0,
|
||||||
|
dist.pemuda || 0,
|
||||||
|
dist.dewasa || 0,
|
||||||
|
dist.lansia || 0
|
||||||
|
];
|
||||||
|
const colors = ['#7c3aed','#3a56d4','#0b9e73','#d97706','#d63230'];
|
||||||
|
|
||||||
|
// Skip if all zero
|
||||||
|
if (values.every(v => v === 0)) return;
|
||||||
|
|
||||||
|
const ctx = document.getElementById('chartAge');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
charts.age = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Jumlah Tanggungan',
|
||||||
|
data: values,
|
||||||
|
backgroundColor: colors.map(c => c + 'cc'),
|
||||||
|
borderColor: colors,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderRadius: 5,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...chartDefaults(),
|
||||||
|
plugins: {
|
||||||
|
...chartDefaults().plugins,
|
||||||
|
legend: { display: false },
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
font: { family: "'DM Sans', sans-serif", size: 11 },
|
||||||
|
color: '#5a6478',
|
||||||
|
padding: { bottom: 10 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Aid distribution (pie) --------------------------------
|
||||||
|
function renderAidChart(data) {
|
||||||
|
destroyChart('aid');
|
||||||
|
const byType = data?.by_type || [];
|
||||||
|
if (!byType.length) return;
|
||||||
|
|
||||||
|
const colors = ['#3a56d4','#0b9e73','#d97706','#7c3aed','#d63230','#0e7f6e','#b45309'];
|
||||||
|
|
||||||
|
const ctx = document.getElementById('chartAid');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
charts.aid = new Chart(ctx, {
|
||||||
|
type: 'pie',
|
||||||
|
data: {
|
||||||
|
labels: byType.map(r => AID_LABELS[r.aid_type] || r.aid_type),
|
||||||
|
datasets: [{
|
||||||
|
data: byType.map(r => parseInt(r.cnt)),
|
||||||
|
backgroundColor: byType.map((_, i) => colors[i % colors.length] + 'cc'),
|
||||||
|
borderColor: byType.map((_, i) => colors[i % colors.length]),
|
||||||
|
borderWidth: 1.5,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...chartDefaults(),
|
||||||
|
plugins: {
|
||||||
|
...chartDefaults().plugins,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Total: ' + (data?.summary?.total_distributions || 0) + ' distribusi',
|
||||||
|
font: { family: "'DM Sans', sans-serif", size: 10 },
|
||||||
|
color: '#9ba4b5',
|
||||||
|
padding: { bottom: 8 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
|||||||
|
/* ============================================================
|
||||||
|
map.js — Leaflet map initialisation + layer management
|
||||||
|
============================================================ */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---- Map instance + layer groups ---------------------------
|
||||||
|
let MAP;
|
||||||
|
let layerCenters, layerHouses, layerReports;
|
||||||
|
let layerVisible = { centers: true, houses: true, reports: true };
|
||||||
|
|
||||||
|
// ---- Placement mode: 'center' | 'house' | null -------------
|
||||||
|
let placementMode = null;
|
||||||
|
let tempMarker = null;
|
||||||
|
|
||||||
|
// ---- Init --------------------------------------------------
|
||||||
|
function initMap() {
|
||||||
|
MAP = L.map('map', {
|
||||||
|
center: [-0.0236, 109.3426], // Pontianak, Kalimantan Barat
|
||||||
|
zoom: 13,
|
||||||
|
zoomControl: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
L.control.zoom({ position: 'bottomright' }).addTo(MAP);
|
||||||
|
|
||||||
|
// Tile layers
|
||||||
|
const osmTile = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
maxZoom: 19,
|
||||||
|
});
|
||||||
|
const satelliteTile = L.tileLayer(
|
||||||
|
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||||||
|
attribution: 'Tiles © Esri',
|
||||||
|
maxZoom: 19,
|
||||||
|
});
|
||||||
|
|
||||||
|
osmTile.addTo(MAP);
|
||||||
|
|
||||||
|
// Expose for layer switcher
|
||||||
|
MAP._baseLayers = { osm: osmTile, satellite: satelliteTile };
|
||||||
|
MAP._activeBase = 'osm';
|
||||||
|
|
||||||
|
// Layer groups
|
||||||
|
layerCenters = L.layerGroup().addTo(MAP);
|
||||||
|
layerHouses = L.layerGroup().addTo(MAP);
|
||||||
|
layerReports = L.layerGroup().addTo(MAP);
|
||||||
|
|
||||||
|
// Map click → placement
|
||||||
|
MAP.on('click', onMapClick);
|
||||||
|
|
||||||
|
// Double-click suppression
|
||||||
|
MAP.on('dblclick', (e) => e.originalEvent.preventDefault());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Map click handler -------------------------------------
|
||||||
|
async function onMapClick(e) {
|
||||||
|
if (!placementMode) return;
|
||||||
|
|
||||||
|
const { lat, lng } = e.latlng;
|
||||||
|
|
||||||
|
// Remove temp marker
|
||||||
|
if (tempMarker) { MAP.removeLayer(tempMarker); tempMarker = null; }
|
||||||
|
|
||||||
|
// Place temp crosshair marker
|
||||||
|
tempMarker = L.marker([lat, lng], {
|
||||||
|
icon: L.divIcon({
|
||||||
|
html: `<div style="width:14px;height:14px;border-radius:50%;background:#3a56d4;border:3px solid white;box-shadow:0 2px 8px rgba(0,0,0,0.35);"></div>`,
|
||||||
|
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
|
||||||
|
}),
|
||||||
|
}).addTo(MAP);
|
||||||
|
|
||||||
|
// Reverse geocode
|
||||||
|
showLoading(true);
|
||||||
|
const address = await reverseGeocode(lat, lng);
|
||||||
|
showLoading(false);
|
||||||
|
|
||||||
|
if (placementMode === 'center') {
|
||||||
|
openCenterModal(null, lat, lng, address);
|
||||||
|
} else if (placementMode === 'house') {
|
||||||
|
openHouseModal(null, lat, lng, address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reverse Geocode --------------------------------
|
||||||
|
async function reverseGeocode(lat, lng) {
|
||||||
|
try {
|
||||||
|
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&accept-language=id&addressdetails=1&zoom=18`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Accept-Language': 'id',
|
||||||
|
'User-Agent': 'WebGIS-PovertyMapping/2.0'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data && data.display_name) {
|
||||||
|
return data.display_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: build address from components
|
||||||
|
if (data && data.address) {
|
||||||
|
const addr = data.address;
|
||||||
|
const parts = [];
|
||||||
|
if (addr.road) parts.push(addr.road);
|
||||||
|
if (addr.house_number) parts.push('No. ' + addr.house_number);
|
||||||
|
if (addr.neighbourhood) parts.push(addr.neighbourhood);
|
||||||
|
if (addr.suburb) parts.push(addr.suburb);
|
||||||
|
if (addr.village) parts.push(addr.village);
|
||||||
|
if (addr.city || addr.town || addr.municipality) parts.push(addr.city || addr.town || addr.municipality);
|
||||||
|
if (addr.state) parts.push(addr.state);
|
||||||
|
if (parts.length > 0) return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Reverse geocoding failed:', err.message);
|
||||||
|
return `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Placement mode control --------------------------------
|
||||||
|
function setPlacementMode(mode) {
|
||||||
|
placementMode = mode;
|
||||||
|
const map = document.getElementById('map');
|
||||||
|
map.classList.remove('cursor-add-center', 'cursor-add-house');
|
||||||
|
if (mode === 'center') map.classList.add('cursor-add-center');
|
||||||
|
if (mode === 'house') map.classList.add('cursor-add-house');
|
||||||
|
|
||||||
|
// Highlight nav tabs
|
||||||
|
document.querySelectorAll('.nav-tab').forEach(t => {
|
||||||
|
t.classList.toggle('active', t.dataset.tab === (mode === 'center' ? 'centers' : mode === 'house' ? 'houses' : 'overview'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPlacementMode() {
|
||||||
|
placementMode = null;
|
||||||
|
document.getElementById('map').classList.remove('cursor-add-center', 'cursor-add-house');
|
||||||
|
if (tempMarker) { MAP.removeLayer(tempMarker); tempMarker = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Layer toggle ------------------------------------------
|
||||||
|
function toggleLayer(name) {
|
||||||
|
layerVisible[name] = !layerVisible[name];
|
||||||
|
const checkbox = document.getElementById('layerCenters')?.parentElement?.querySelector('#layer' + capitalize(name));
|
||||||
|
const grp = name === 'centers' ? layerCenters : name === 'houses' ? layerHouses : layerReports;
|
||||||
|
|
||||||
|
if (layerVisible[name]) {
|
||||||
|
grp.addTo(MAP);
|
||||||
|
} else {
|
||||||
|
MAP.removeLayer(grp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cb = document.getElementById('layer' + capitalize(name));
|
||||||
|
if (cb) cb.checked = layerVisible[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
||||||
|
|
||||||
|
// ---- Fly to point ------------------------------------------
|
||||||
|
function flyTo(lat, lng, zoom = 17) {
|
||||||
|
MAP.flyTo([lat, lng], zoom, { duration: 0.8 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Clear + rebuild all layers ----------------------------
|
||||||
|
function renderAllLayers() {
|
||||||
|
renderCenters();
|
||||||
|
renderHouses();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Update layer count badges -----------------------------
|
||||||
|
function updateLayerCounts() {
|
||||||
|
const cc = document.getElementById('layerCenterCount');
|
||||||
|
const hc = document.getElementById('layerHouseCount');
|
||||||
|
const rc = document.getElementById('layerReportCount');
|
||||||
|
if (cc) cc.textContent = State.centers.length;
|
||||||
|
if (hc) hc.textContent = State.houses.length;
|
||||||
|
}
|
||||||
@@ -0,0 +1,961 @@
|
|||||||
|
/* ============================================================
|
||||||
|
markers.js — Draggable markers + inside/outside radius colors
|
||||||
|
============================================================ */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// UTILITY FUNCTIONS
|
||||||
|
// ====================================================================
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (str === null || str === undefined) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(str, n = 28) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.length > n ? str.slice(0, n) + '…' : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRp(n) {
|
||||||
|
if (!n) return 'Rp 0';
|
||||||
|
return 'Rp ' + Number(n).toLocaleString('id-ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(str) {
|
||||||
|
if (!str) return '—';
|
||||||
|
return new Date(str).toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function educationLabel(edu) {
|
||||||
|
const labels = {
|
||||||
|
'tidak_sekolah': 'Tidak Sekolah',
|
||||||
|
'sd': 'SD',
|
||||||
|
'smp': 'SMP',
|
||||||
|
'sma': 'SMA',
|
||||||
|
'diploma': 'Diploma',
|
||||||
|
'sarjana': 'Sarjana',
|
||||||
|
'pascasarjana': 'Pascasarjana'
|
||||||
|
};
|
||||||
|
return labels[edu] || edu;
|
||||||
|
}
|
||||||
|
|
||||||
|
const radiusCircles = {};
|
||||||
|
let dragInProgress = false;
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// CENTERS
|
||||||
|
// ====================================================================
|
||||||
|
function renderCenters() {
|
||||||
|
layerCenters.clearLayers();
|
||||||
|
Object.values(radiusCircles).forEach(c => { if (MAP.hasLayer(c)) MAP.removeLayer(c); });
|
||||||
|
|
||||||
|
const filtered = State.centers.filter(c => {
|
||||||
|
if (State.activeFilter === 'houses') return false;
|
||||||
|
if (State.searchQuery) {
|
||||||
|
const q = State.searchQuery.toLowerCase();
|
||||||
|
return c.name.toLowerCase().includes(q) || (c.address || '').toLowerCase().includes(q);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
filtered.forEach(center => {
|
||||||
|
if (!center._marker) {
|
||||||
|
addCenterMarker(center);
|
||||||
|
} else {
|
||||||
|
layerCenters.addLayer(center._marker);
|
||||||
|
if (radiusCircles[center.id]) {
|
||||||
|
radiusCircles[center.id].addTo(MAP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateLayerCounts();
|
||||||
|
renderCenterList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCenterMarker(center) {
|
||||||
|
const color = CENTER_COLORS[center.worship_type] || '#3a56d4';
|
||||||
|
const icon = CENTER_ICONS[center.worship_type] || 'fa-place-of-worship';
|
||||||
|
|
||||||
|
const circle = L.circle([center.latitude, center.longitude], {
|
||||||
|
radius: center.radius,
|
||||||
|
color: color,
|
||||||
|
fillColor: color,
|
||||||
|
fillOpacity: 0.07,
|
||||||
|
weight: 1.5,
|
||||||
|
dashArray: '4 3',
|
||||||
|
});
|
||||||
|
circle.addTo(MAP);
|
||||||
|
radiusCircles[center.id] = circle;
|
||||||
|
|
||||||
|
const marker = L.marker([center.latitude, center.longitude], {
|
||||||
|
icon: L.divIcon({
|
||||||
|
html: `<div class="custom-marker-center" style="background:${color};"><i class="fas ${icon}"></i></div>`,
|
||||||
|
iconSize: [32, 32],
|
||||||
|
iconAnchor: [16, 32],
|
||||||
|
popupAnchor: [0, -34],
|
||||||
|
className: '',
|
||||||
|
}),
|
||||||
|
title: center.name,
|
||||||
|
draggable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
marker.on('dragstart', function() { marker.closePopup(); marker.setZIndexOffset(1000); });
|
||||||
|
marker.on('drag', function(e) { if (radiusCircles[center.id]) radiusCircles[center.id].setLatLng(marker.getLatLng()); });
|
||||||
|
marker.on('dragend', async function(e) {
|
||||||
|
marker.setZIndexOffset(0);
|
||||||
|
const pos = marker.getLatLng();
|
||||||
|
center.latitude = pos.lat;
|
||||||
|
center.longitude = pos.lng;
|
||||||
|
if (radiusCircles[center.id]) radiusCircles[center.id].setLatLng(pos);
|
||||||
|
try {
|
||||||
|
const r = await ApiCenters.patch(center.id, { latitude: pos.lat, longitude: pos.lng });
|
||||||
|
if (r.ok && r.data?.success) {
|
||||||
|
showToast('Posisi tempat ibadah diperbarui.', 'success');
|
||||||
|
updateAllHouseColors();
|
||||||
|
recountCenterHouseholds();
|
||||||
|
loadStats();
|
||||||
|
renderCenterList();
|
||||||
|
renderHouseList();
|
||||||
|
updateLayerCounts();
|
||||||
|
} else showToast('Gagal menyimpan posisi.', 'error');
|
||||||
|
} catch (err) { showToast('Gagal menyimpan posisi.', 'error'); }
|
||||||
|
showCenterPopup(marker, center);
|
||||||
|
});
|
||||||
|
marker.on('click', () => showCenterPopup(marker, center));
|
||||||
|
layerCenters.addLayer(marker);
|
||||||
|
center._marker = marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCenterPopup(marker, center) {
|
||||||
|
const color = CENTER_COLORS[center.worship_type] || '#3a56d4';
|
||||||
|
const icon = CENTER_ICONS[center.worship_type] || 'fa-place-of-worship';
|
||||||
|
const label = CENTER_LABELS[center.worship_type] || center.worship_type;
|
||||||
|
|
||||||
|
// Hitung ulang jumlah rumah saat popup dibuka (memastikan data fresh)
|
||||||
|
let count = 0;
|
||||||
|
let nearbyHouses = [];
|
||||||
|
|
||||||
|
State.houses.forEach(house => {
|
||||||
|
if (house.is_active === false) return;
|
||||||
|
const distance = haversineMeters(
|
||||||
|
house.latitude, house.longitude,
|
||||||
|
center.latitude, center.longitude
|
||||||
|
);
|
||||||
|
if (distance <= center.radius) {
|
||||||
|
count++;
|
||||||
|
nearbyHouses.push({
|
||||||
|
id: house.id,
|
||||||
|
name: house.head_name,
|
||||||
|
distance: Math.round(distance)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update center household_count jika berbeda
|
||||||
|
if (center.household_count !== count) {
|
||||||
|
center.household_count = count;
|
||||||
|
renderCenterList(); // Refresh sidebar jika ada perubahan
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buat HTML daftar rumah dalam radius
|
||||||
|
let housesListHtml = '';
|
||||||
|
if (nearbyHouses.length > 0) {
|
||||||
|
const displayHouses = nearbyHouses.slice(0, 5);
|
||||||
|
housesListHtml = `
|
||||||
|
<div class="popup-section" style="margin-top: 6px;">
|
||||||
|
<div class="popup-section-label"><i class="fas fa-home"></i> Rumah dalam Radius (${nearbyHouses.length})</div>
|
||||||
|
<div style="max-height: 150px; overflow-y: auto;">
|
||||||
|
${displayHouses.map(h => `
|
||||||
|
<div class="popup-row" style="font-size: 10.5px; cursor: pointer;" onclick="flyToAndOpenHouse(${h.id}, ${center.latitude}, ${center.longitude})">
|
||||||
|
<i class="fas fa-home" style="font-size: 8px;"></i>
|
||||||
|
<span><strong>${escapeHtml(h.name)}</strong> · ${h.distance}m</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
${nearbyHouses.length > 5 ? `<div class="popup-row" style="font-size: 9px; color: var(--text-muted);">+${nearbyHouses.length - 5} rumah lainnya</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const popup = L.popup({ maxWidth: 320, closeButton: true })
|
||||||
|
.setLatLng(marker.getLatLng())
|
||||||
|
.setContent(`
|
||||||
|
<div class="popup-info">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid #edf0f6;">
|
||||||
|
<div style="width:34px;height:34px;border-radius:9px;background:${color};display:flex;align-items:center;justify-content:center;color:white;font-size:14px;flex-shrink:0;box-shadow:0 2px 8px ${color}44;">
|
||||||
|
<i class="fas ${icon}"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="popup-name" style="margin-bottom:0;">${escapeHtml(center.name)}</div>
|
||||||
|
<div style="font-size:10px;color:var(--text-muted);">${label} · <em style="font-size:9px;">seret untuk pindahkan</em></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="popup-row"><i class="fas fa-map-marker-alt"></i><span>${truncate(center.address || '—', 42)}</span></div>
|
||||||
|
|
||||||
|
<!-- Jumlah Rumah dalam Radius (Live) -->
|
||||||
|
<div class="popup-section" style="background: ${count > 0 ? color + '10' : 'var(--surface-2)'}">
|
||||||
|
<div class="popup-section-label"><i class="fas fa-home"></i> Cakupan Layanan</div>
|
||||||
|
<div class="popup-row" style="margin-bottom: 0;">
|
||||||
|
<i class="fas fa-chart-simple"></i>
|
||||||
|
<span><strong style="font-size: 16px; color: ${color};">${count}</strong> rumah dalam radius ${center.radius}m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${housesListHtml}
|
||||||
|
|
||||||
|
<div class="popup-section">
|
||||||
|
<div class="popup-section-label"><i class="fas fa-dot-circle"></i> Ubah Radius: <strong id="rcVal_${center.id}" style="color:${color};">${center.radius}m</strong></div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<input type="range" min="50" max="5000" step="10" value="${center.radius}"
|
||||||
|
oninput="liveUpdateRadius(${center.id}, this.value)"
|
||||||
|
onchange="saveRadius(${center.id}, this.value)"
|
||||||
|
style="flex:1;height:4px;-webkit-appearance:none;background:#e2e6ef;border-radius:2px;outline:none;border:none;padding:0;accent-color:${color};">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:9px;color:#9ba4b5;margin-top:3px;">
|
||||||
|
<span>50m</span><span>2.5km</span><span>5km</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="editCenter(${center.id})"><i class="fas fa-pen"></i> Edit</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="showCoverageHouseholds(${center.id})"><i class="fas fa-eye"></i> Lihat Semua</button>
|
||||||
|
${window.canDelete ? `<button class="btn btn-danger btn-sm" onclick="deleteCenter(${center.id})" title="Hapus"><i class="fas fa-trash"></i></button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
marker.unbindPopup();
|
||||||
|
marker.bindPopup(popup).openPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fungsi helper untuk terbang ke rumah dan membuka popupnya
|
||||||
|
function flyToAndOpenHouse(houseId, centerLat, centerLng) {
|
||||||
|
const house = State.houses.find(h => h.id == houseId);
|
||||||
|
if (house) {
|
||||||
|
flyTo(house.latitude, house.longitude, 17);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (house._marker) {
|
||||||
|
house._marker.openPopup();
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.flyToAndOpenHouse = flyToAndOpenHouse;
|
||||||
|
|
||||||
|
function liveUpdateRadius(centerId, value) {
|
||||||
|
const valSpan = document.getElementById('rcVal_' + centerId);
|
||||||
|
if (valSpan) valSpan.textContent = value + 'm';
|
||||||
|
|
||||||
|
if (radiusCircles[centerId]) {
|
||||||
|
radiusCircles[centerId].setRadius(parseInt(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live update: Hitung ulang jumlah rumah saat slider digeser
|
||||||
|
const center = State.centers.find(c => c.id == centerId);
|
||||||
|
if (center) {
|
||||||
|
let tempCount = 0;
|
||||||
|
State.houses.forEach(house => {
|
||||||
|
if (house.is_active === false) return;
|
||||||
|
const distance = haversineMeters(
|
||||||
|
house.latitude, house.longitude,
|
||||||
|
center.latitude, center.longitude
|
||||||
|
);
|
||||||
|
if (distance <= parseInt(value)) {
|
||||||
|
tempCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update tampilan jumlah rumah di popup secara live
|
||||||
|
const popupContent = document.querySelector('.leaflet-popup-content');
|
||||||
|
if (popupContent && center._marker && center._marker.isPopupOpen()) {
|
||||||
|
const countElement = popupContent.querySelector('.popup-section:first-child .popup-row strong');
|
||||||
|
if (countElement) {
|
||||||
|
countElement.textContent = tempCount;
|
||||||
|
countElement.style.color = '#d63230';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRadius(centerId, value) {
|
||||||
|
try {
|
||||||
|
const r = await ApiCenters.patch(centerId, { radius: parseInt(value) });
|
||||||
|
if (r.ok && r.data?.success) {
|
||||||
|
const center = State.centers.find(c => c.id == centerId);
|
||||||
|
if (center) {
|
||||||
|
center.radius = parseInt(value);
|
||||||
|
// Perbarui lingkaran di peta
|
||||||
|
if (radiusCircles[centerId]) {
|
||||||
|
radiusCircles[centerId].setRadius(parseInt(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast('Radius diperbarui.', 'success');
|
||||||
|
|
||||||
|
// Update semua data yang terpengaruh
|
||||||
|
updateAllHouseColors();
|
||||||
|
recountCenterHouseholds(); // Hitung ulang jumlah rumah per center
|
||||||
|
renderHouseList(); // Refresh daftar rumah
|
||||||
|
renderCenterList(); // Refresh daftar center dengan count baru
|
||||||
|
updateLayerCounts(); // Update badge layer
|
||||||
|
loadStats(); // Update statistik dashboard
|
||||||
|
|
||||||
|
// Refresh popup center jika terbuka
|
||||||
|
if (center._marker && center._marker.isPopupOpen()) {
|
||||||
|
showCenterPopup(center._marker, center);
|
||||||
|
}
|
||||||
|
} else showToast('Gagal menyimpan radius.', 'error');
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Gagal menyimpan radius.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showCoverageHouseholds(centerId) {
|
||||||
|
showLoading(true);
|
||||||
|
const r = await ApiCenters.coverage(centerId);
|
||||||
|
showLoading(false);
|
||||||
|
if (!r.ok) { showToast('Gagal memuat data.', 'error'); return; }
|
||||||
|
const { center, households, count } = r.data.data;
|
||||||
|
if (!households.length) { showToast(`Tidak ada rumah dalam radius ${center.radius}m.`, 'warning'); return; }
|
||||||
|
const tempLayer = L.layerGroup();
|
||||||
|
households.forEach(h => {
|
||||||
|
L.circleMarker([h.latitude, h.longitude], {
|
||||||
|
radius: 10,
|
||||||
|
color: '#3a56d4',
|
||||||
|
fillColor: '#3a56d4',
|
||||||
|
fillOpacity: 0.25,
|
||||||
|
weight: 2,
|
||||||
|
}).addTo(tempLayer).bindTooltip(h.head_name, { permanent: false });
|
||||||
|
});
|
||||||
|
tempLayer.addTo(MAP);
|
||||||
|
setTimeout(() => MAP.removeLayer(tempLayer), 10000);
|
||||||
|
flyTo(center.latitude, center.longitude, 15);
|
||||||
|
showToast(`${count} rumah dalam jangkauan ${center.name}.`, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// HOUSES
|
||||||
|
// ====================================================================
|
||||||
|
function renderHouses() {
|
||||||
|
layerHouses.clearLayers();
|
||||||
|
|
||||||
|
const filtered = State.houses.filter(h => {
|
||||||
|
if (State.activeFilter === 'centers') return false;
|
||||||
|
if (State.povertyFilter && h.poverty_status !== State.povertyFilter) return false;
|
||||||
|
if (State.aidFilter && h.aid_status !== State.aidFilter) return false;
|
||||||
|
if (State.conditionFilter && h.house_condition !== State.conditionFilter) return false;
|
||||||
|
if (State.searchQuery) {
|
||||||
|
const q = State.searchQuery.toLowerCase();
|
||||||
|
if (!h.head_name.toLowerCase().includes(q) &&
|
||||||
|
!(h.full_address || h.address || '').toLowerCase().includes(q) &&
|
||||||
|
!(h.head_nik || '').includes(q)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
filtered.forEach(h => {
|
||||||
|
if (!h._marker) {
|
||||||
|
addHouseMarker(h);
|
||||||
|
} else {
|
||||||
|
// Update color if radius status changed
|
||||||
|
const newColor = getHouseMarkerColor(h.latitude, h.longitude);
|
||||||
|
if (h._marker._houseColor !== newColor) {
|
||||||
|
h._marker._houseColor = newColor;
|
||||||
|
h._marker.setIcon(L.divIcon({
|
||||||
|
html: `<div class="custom-marker-house" style="background:${newColor};"><i class="fas fa-home"></i></div>`,
|
||||||
|
iconSize: [26, 26],
|
||||||
|
iconAnchor: [13, 26],
|
||||||
|
popupAnchor: [0, -28],
|
||||||
|
className: '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
layerHouses.addLayer(h._marker);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateLayerCounts();
|
||||||
|
renderHouseList(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHouseInsideAnyRadius(lat, lng) {
|
||||||
|
for (const center of State.centers) {
|
||||||
|
if (!center.is_active) continue;
|
||||||
|
const distance = haversineMeters(lat, lng, center.latitude, center.longitude);
|
||||||
|
if (distance <= center.radius) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineMeters(lat1, lng1, lat2, lng2) {
|
||||||
|
const R = 6371000;
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||||
|
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||||
|
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recountCenterHouseholds() {
|
||||||
|
console.log('Recounting center households...');
|
||||||
|
|
||||||
|
State.centers.forEach(center => {
|
||||||
|
let count = 0;
|
||||||
|
let householdsInRadius = [];
|
||||||
|
|
||||||
|
State.houses.forEach(house => {
|
||||||
|
if (house.is_active === false) return;
|
||||||
|
|
||||||
|
const distance = haversineMeters(
|
||||||
|
house.latitude, house.longitude,
|
||||||
|
center.latitude, center.longitude
|
||||||
|
);
|
||||||
|
|
||||||
|
if (distance <= center.radius) {
|
||||||
|
count++;
|
||||||
|
householdsInRadius.push({
|
||||||
|
id: house.id,
|
||||||
|
name: house.head_name,
|
||||||
|
distance: Math.round(distance)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
center.household_count = count;
|
||||||
|
center.households_in_radius = householdsInRadius.slice(0, 10); // Simpan 10 terdekat
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHouseMarkerColor(lat, lng) {
|
||||||
|
return isHouseInsideAnyRadius(lat, lng) ? '#d63230' : '#0b9e73';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAllHouseColors() {
|
||||||
|
State.houses.forEach(h => {
|
||||||
|
if (h._marker) {
|
||||||
|
const insideRadius = isHouseInsideAnyRadius(h.latitude, h.longitude);
|
||||||
|
const newColor = insideRadius ? '#d63230' : '#0b9e73';
|
||||||
|
if (h._marker._houseColor !== newColor) {
|
||||||
|
h._marker._houseColor = newColor;
|
||||||
|
h._marker.setIcon(L.divIcon({
|
||||||
|
html: `<div class="custom-marker-house" style="background:${newColor};"><i class="fas fa-home"></i></div>`,
|
||||||
|
iconSize: [26, 26],
|
||||||
|
iconAnchor: [13, 26],
|
||||||
|
popupAnchor: [0, -28],
|
||||||
|
className: '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fungsi untuk mendapatkan foto rumah (akan digunakan di popup)
|
||||||
|
function getHousePhotosHtml(houseData) {
|
||||||
|
if (!houseData.house_photos) return '';
|
||||||
|
let photosArr = [];
|
||||||
|
try { photosArr = JSON.parse(houseData.house_photos); } catch(e) { return ''; }
|
||||||
|
if (!photosArr.length) return '';
|
||||||
|
|
||||||
|
const thumbnails = photosArr.slice(0, 4).map(name =>
|
||||||
|
`<img src="uploads/houses/${encodeURIComponent(name)}"
|
||||||
|
style="width:60px;height:60px;object-fit:cover;border-radius:6px;border:1.5px solid #e2e6ef;cursor:zoom-in;"
|
||||||
|
onclick="event.stopPropagation(); PhotoUpload.lightbox('uploads/houses/${encodeURIComponent(name)}')"
|
||||||
|
title="Foto rumah">`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="hp-section">
|
||||||
|
<div class="hp-section-header">
|
||||||
|
<i class="fas fa-camera"></i>
|
||||||
|
<span>Foto Rumah</span>
|
||||||
|
<span class="hp-count">${photosArr.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="popup-photo-strip" style="display:flex;flex-wrap:wrap;gap:5px;margin-top:5px;">
|
||||||
|
${thumbnails}
|
||||||
|
${photosArr.length > 4 ? `<span style="font-size:10px;color:#9ba4b5;margin-left:4px;">+${photosArr.length - 4} foto</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// showHousePopup (redesigned popup)
|
||||||
|
async function showHousePopup(marker, h, forceRefresh = false) {
|
||||||
|
let houseData = h;
|
||||||
|
|
||||||
|
// Fetch fresh data if aid_history is missing or forceRefresh=true
|
||||||
|
if (forceRefresh || !houseData.aid_history || houseData.aid_history.length === undefined) {
|
||||||
|
try {
|
||||||
|
showLoading(true);
|
||||||
|
const r = await ApiHouses.show(houseData.id);
|
||||||
|
showLoading(false);
|
||||||
|
if (r.ok && r.data?.success) {
|
||||||
|
houseData = r.data.data;
|
||||||
|
const index = State.houses.findIndex(hh => hh.id === houseData.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
State.houses[index] = houseData;
|
||||||
|
if (State.houses[index]._marker) {
|
||||||
|
State.houses[index]._marker._houseData = houseData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showLoading(false);
|
||||||
|
console.error('Failed to load fresh household data:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Derived values ───────────────────────────────────────────────
|
||||||
|
const hasAid = (houseData.aid_history && houseData.aid_history.length > 0);
|
||||||
|
const aidStatusText = hasAid ? 'Penerima Bantuan' : 'Belum Ada Bantuan';
|
||||||
|
const aidStatusColor = hasAid ? '#0b9e73' : '#d97706';
|
||||||
|
|
||||||
|
let age = '—';
|
||||||
|
if (houseData.head_date_of_birth) {
|
||||||
|
const bd = new Date(houseData.head_date_of_birth), now = new Date();
|
||||||
|
let a = now.getFullYear() - bd.getFullYear();
|
||||||
|
if (now.getMonth() < bd.getMonth() || (now.getMonth() === bd.getMonth() && now.getDate() < bd.getDate())) a--;
|
||||||
|
age = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const povColor = POVERTY_COLORS[houseData.poverty_status] || '#9ba4b5';
|
||||||
|
const povLabel = POVERTY_LABELS[houseData.poverty_status] || houseData.poverty_status;
|
||||||
|
|
||||||
|
// Employment one-liner
|
||||||
|
let jobLine = '—';
|
||||||
|
if (houseData.head_employment_status === 'unemployed') {
|
||||||
|
jobLine = 'Tidak Bekerja';
|
||||||
|
} else if (houseData.head_employment_status === 'studying') {
|
||||||
|
jobLine = escapeHtml(houseData.head_institution_name || 'Pelajar/Mahasiswa');
|
||||||
|
} else if (houseData.head_employment_status === 'working') {
|
||||||
|
jobLine = escapeHtml(houseData.head_job_name || 'Bekerja');
|
||||||
|
if (houseData.head_monthly_income) jobLine += ` · ${formatRp(houseData.head_monthly_income)}/bln`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullAddress = houseData.full_address || houseData.address || '—';
|
||||||
|
const conditionIcon = houseData.house_condition === 'layak'
|
||||||
|
? `<span style="color:#0b9e73;"><i class="fas fa-check-circle"></i> Layak</span>`
|
||||||
|
: houseData.house_condition === 'tidak_layak'
|
||||||
|
? `<span style="color:#d63230;"><i class="fas fa-times-circle"></i> Tidak Layak</span>`
|
||||||
|
: '—';
|
||||||
|
|
||||||
|
// ── Location pills (RT/RW · Kelurahan · Kecamatan) ───────────────
|
||||||
|
const locationPills = [
|
||||||
|
houseData.rt ? `RT ${escapeHtml(houseData.rt)}/${escapeHtml(houseData.rw || '?')}` : null,
|
||||||
|
houseData.kelurahan ? escapeHtml(houseData.kelurahan) : null,
|
||||||
|
houseData.kecamatan ? escapeHtml(houseData.kecamatan) : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const locationPillsHtml = locationPills.length
|
||||||
|
? `<div class="hp-pills">${locationPills.map(p => `<span class="hp-pill">${p}</span>`).join('')}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// ── Family members ────────────────────────────────────────────────
|
||||||
|
let membersHtml = '';
|
||||||
|
if (houseData.household_members && houseData.household_members.length) {
|
||||||
|
const members = houseData.household_members;
|
||||||
|
const shown = members.slice(0, 5);
|
||||||
|
const extra = members.length - shown.length;
|
||||||
|
membersHtml = `
|
||||||
|
<div class="hp-section">
|
||||||
|
<div class="hp-section-header">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
<span>Anggota Keluarga</span>
|
||||||
|
<span class="hp-count">${members.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hp-members-list">
|
||||||
|
${shown.map(m => {
|
||||||
|
let statusLine = '';
|
||||||
|
if (m.employment_status === 'working') statusLine = escapeHtml(m.job_name || 'Bekerja');
|
||||||
|
else if (m.employment_status === 'studying') statusLine = escapeHtml(m.institution_name || 'Sekolah');
|
||||||
|
else if (m.employment_status === 'unemployed') statusLine = 'Tidak bekerja';
|
||||||
|
return `<div class="hp-member-row">
|
||||||
|
<div class="hp-member-avatar"><i class="fas fa-user"></i></div>
|
||||||
|
<div class="hp-member-info">
|
||||||
|
<div class="hp-member-name">${escapeHtml(m.name)}</div>
|
||||||
|
<div class="hp-member-meta">${escapeHtml(m.relationship || '—')}${statusLine ? ' · ' + statusLine : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('')}
|
||||||
|
${extra > 0 ? `<div class="hp-member-more"><i class="fas fa-ellipsis-h"></i> +${extra} anggota lainnya</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aid history ───────────────────────────────────────────────────
|
||||||
|
let aidHistoryHtml = '';
|
||||||
|
if (hasAid) {
|
||||||
|
const latestAids = houseData.aid_history.slice(0, 5);
|
||||||
|
const extraAids = houseData.aid_history.length - latestAids.length;
|
||||||
|
aidHistoryHtml = `
|
||||||
|
<div class="hp-section">
|
||||||
|
<div class="hp-section-header">
|
||||||
|
<i class="fas fa-hand-holding-heart"></i>
|
||||||
|
<span>Riwayat Bantuan</span>
|
||||||
|
<span class="hp-count">${houseData.aid_history.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hp-aid-list">
|
||||||
|
${latestAids.map(aid => `
|
||||||
|
<div class="hp-aid-row">
|
||||||
|
<div class="hp-aid-left">
|
||||||
|
<span class="hp-aid-type">${escapeHtml(aid.aid_type_label || (typeof AID_LABELS !== 'undefined' && AID_LABELS[aid.aid_type]) || aid.aid_type || 'Bantuan')}</span>
|
||||||
|
${aid.amount ? `<span class="hp-aid-amount">${formatRp(aid.amount)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="hp-aid-date">${formatDate(aid.aid_date)}</div>
|
||||||
|
${aid.description || aid.notes ? `<div class="hp-aid-note">${escapeHtml((aid.description || aid.notes || '').substring(0, 55))}${(aid.description || aid.notes || '').length > 55 ? '…' : ''}</div>` : ''}
|
||||||
|
</div>`).join('')}
|
||||||
|
${extraAids > 0 ? `<div class="hp-member-more"><i class="fas fa-ellipsis-h"></i> +${extraAids} bantuan lainnya</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
aidHistoryHtml = `
|
||||||
|
<div class="hp-section hp-aid-empty">
|
||||||
|
<div class="hp-section-header">
|
||||||
|
<i class="fas fa-hand-holding-heart"></i>
|
||||||
|
<span>Riwayat Bantuan</span>
|
||||||
|
</div>
|
||||||
|
<div class="hp-empty-hint"><i class="fas fa-inbox"></i> Belum ada riwayat bantuan</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Assigned center ───────────────────────────────────────────────
|
||||||
|
const centerHtml = houseData.center_name
|
||||||
|
? `<div class="hp-center-row">
|
||||||
|
<i class="fas fa-place-of-worship"></i>
|
||||||
|
<span>${escapeHtml(houseData.center_name)}</span>
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// ── Coordinates ──────────────────────────────────────────────────
|
||||||
|
const lat = (houseData.latitude || marker.getLatLng().lat).toFixed(6);
|
||||||
|
const lng = (houseData.longitude || marker.getLatLng().lng).toFixed(6);
|
||||||
|
|
||||||
|
// ── Photos Html ──────────────────────────────────────────────────
|
||||||
|
const photosHtml = getHousePhotosHtml(houseData);
|
||||||
|
|
||||||
|
// ── Build popup ──────────────────────────────────────────────────
|
||||||
|
const popup = L.popup({ maxWidth: 360, minWidth: 300, closeButton: true, className: 'hp-leaflet-popup' })
|
||||||
|
.setLatLng(marker.getLatLng())
|
||||||
|
.setContent(`
|
||||||
|
<div class="hp-popup">
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<div class="hp-header">
|
||||||
|
<div class="hp-drag-hint" title="Seret marker untuk memindahkan">
|
||||||
|
<i class="fas fa-up-down-left-right"></i>
|
||||||
|
</div>
|
||||||
|
<div class="hp-avatar" style="background:${povColor}18;color:${povColor};">
|
||||||
|
<i class="fas fa-home"></i>
|
||||||
|
</div>
|
||||||
|
<div class="hp-header-info">
|
||||||
|
<div class="hp-head-name">${escapeHtml(houseData.head_name)}</div>
|
||||||
|
<div class="hp-nik">NIK: ${escapeHtml(houseData.head_nik || houseData.nik || '—')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STATUS STRIP -->
|
||||||
|
<div class="hp-status-strip">
|
||||||
|
<div class="hp-status-chip" style="background:${povColor}14;color:${povColor};border-color:${povColor}30;">
|
||||||
|
<span class="hp-chip-dot" style="background:${povColor};"></span>${povLabel}
|
||||||
|
</div>
|
||||||
|
<div class="hp-status-chip" style="background:${aidStatusColor}12;color:${aidStatusColor};border-color:${aidStatusColor}28;">
|
||||||
|
<i class="fas ${hasAid ? 'fa-check-circle' : 'fa-clock'}"></i>${aidStatusText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SCROLLABLE BODY -->
|
||||||
|
<div class="hp-body">
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
|
<div class="hp-section">
|
||||||
|
<div class="hp-section-header">
|
||||||
|
<i class="fas fa-map-marker-alt"></i>
|
||||||
|
<span>Lokasi</span>
|
||||||
|
</div>
|
||||||
|
<div class="hp-address">${escapeHtml(fullAddress)}</div>
|
||||||
|
${locationPillsHtml}
|
||||||
|
${centerHtml}
|
||||||
|
<div class="hp-coords"><i class="fas fa-crosshairs"></i>${lat}, ${lng}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photos -->
|
||||||
|
${photosHtml}
|
||||||
|
|
||||||
|
<!-- Head of Household -->
|
||||||
|
<div class="hp-section">
|
||||||
|
<div class="hp-section-header">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
<span>Kepala Keluarga</span>
|
||||||
|
</div>
|
||||||
|
<div class="hp-kv-grid">
|
||||||
|
<div class="hp-kv-row">
|
||||||
|
<span class="hp-kv-label">Usia</span>
|
||||||
|
<span class="hp-kv-val">${age} tahun</span>
|
||||||
|
</div>
|
||||||
|
<div class="hp-kv-row">
|
||||||
|
<span class="hp-kv-label">Pendidikan</span>
|
||||||
|
<span class="hp-kv-val">${educationLabel(houseData.head_education) || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hp-kv-row">
|
||||||
|
<span class="hp-kv-label">Pekerjaan</span>
|
||||||
|
<span class="hp-kv-val">${jobLine}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hp-kv-row">
|
||||||
|
<span class="hp-kv-label">Kondisi Rumah</span>
|
||||||
|
<span class="hp-kv-val">${conditionIcon}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Family Members -->
|
||||||
|
${membersHtml}
|
||||||
|
|
||||||
|
<!-- Aid History -->
|
||||||
|
${aidHistoryHtml}
|
||||||
|
|
||||||
|
</div><!-- /.hp-body -->
|
||||||
|
|
||||||
|
<!-- ACTIONS -->
|
||||||
|
<div class="hp-actions">
|
||||||
|
<button class="hp-btn hp-btn-primary" onclick="editHouse(${houseData.id})">
|
||||||
|
<i class="fas fa-pen"></i> Edit
|
||||||
|
</button>
|
||||||
|
<button class="hp-btn hp-btn-success" onclick="openAidModalForHouse(${houseData.id})">
|
||||||
|
<i class="fas fa-gift"></i> Tambah Bantuan
|
||||||
|
</button>
|
||||||
|
${window.canDelete ? `<button class="hp-btn hp-btn-danger" onclick="deleteHouse(${houseData.id})" title="Hapus"><i class="fas fa-trash"></i></button>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
marker.unbindPopup();
|
||||||
|
marker.bindPopup(popup).openPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update click handler untuk memuat data fresh
|
||||||
|
function addHouseMarker(h) {
|
||||||
|
const insideRadius = isHouseInsideAnyRadius(h.latitude, h.longitude);
|
||||||
|
const color = insideRadius ? '#d63230' : '#0b9e73';
|
||||||
|
|
||||||
|
const marker = L.marker([h.latitude, h.longitude], {
|
||||||
|
icon: L.divIcon({
|
||||||
|
html: `<div class="custom-marker-house" style="background:${color};"><i class="fas fa-home"></i></div>`,
|
||||||
|
iconSize: [26, 26],
|
||||||
|
iconAnchor: [13, 26],
|
||||||
|
popupAnchor: [0, -28],
|
||||||
|
className: '',
|
||||||
|
}),
|
||||||
|
title: h.head_name,
|
||||||
|
draggable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
marker._houseColor = color;
|
||||||
|
marker._houseData = h;
|
||||||
|
marker._originalId = h.id;
|
||||||
|
|
||||||
|
// Dragstart
|
||||||
|
marker.on('dragstart', function() {
|
||||||
|
dragInProgress = true;
|
||||||
|
marker.closePopup();
|
||||||
|
marker.setZIndexOffset(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag - update color live
|
||||||
|
marker.on('drag', function(e) {
|
||||||
|
const pos = marker.getLatLng();
|
||||||
|
const newColor = getHouseMarkerColor(pos.lat, pos.lng);
|
||||||
|
if (marker._houseColor !== newColor) {
|
||||||
|
marker._houseColor = newColor;
|
||||||
|
marker.setIcon(L.divIcon({
|
||||||
|
html: `<div class="custom-marker-house" style="background:${newColor};"><i class="fas fa-home"></i></div>`,
|
||||||
|
iconSize: [26, 26],
|
||||||
|
iconAnchor: [13, 26],
|
||||||
|
popupAnchor: [0, -28],
|
||||||
|
className: '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
marker.on('dragend', async function(e) {
|
||||||
|
dragInProgress = false;
|
||||||
|
marker.setZIndexOffset(0);
|
||||||
|
|
||||||
|
const pos = marker.getLatLng();
|
||||||
|
const lat = pos.lat, lng = pos.lng;
|
||||||
|
|
||||||
|
// Update local data
|
||||||
|
h.latitude = lat;
|
||||||
|
h.longitude = lng;
|
||||||
|
|
||||||
|
// Update warna marker
|
||||||
|
const newColor = getHouseMarkerColor(lat, lng);
|
||||||
|
marker._houseColor = newColor;
|
||||||
|
marker.setIcon(L.divIcon({
|
||||||
|
html: `<div class="custom-marker-house" style="background:${newColor};"><i class="fas fa-home"></i></div>`,
|
||||||
|
iconSize: [26, 26],
|
||||||
|
iconAnchor: [13, 26],
|
||||||
|
popupAnchor: [0, -28],
|
||||||
|
className: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Reverse geocode untuk dapat alamat baru
|
||||||
|
let newAddress = h.full_address || h.address;
|
||||||
|
try {
|
||||||
|
newAddress = await reverseGeocode(lat, lng);
|
||||||
|
h.full_address = newAddress;
|
||||||
|
} catch (err) {}
|
||||||
|
|
||||||
|
// Save ke database via API
|
||||||
|
showLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await ApiHouses.patch(h.id, {
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lng,
|
||||||
|
full_address: newAddress
|
||||||
|
});
|
||||||
|
|
||||||
|
if (r.ok && r.data?.success) {
|
||||||
|
if (r.data.data?.managing_center_id) {
|
||||||
|
h.managing_center_id = r.data.data.managing_center_id;
|
||||||
|
const center = State.centers.find(c => c.id == r.data.data.managing_center_id);
|
||||||
|
if (center) h.center_name = center.name;
|
||||||
|
}
|
||||||
|
showToast('Posisi rumah berhasil diperbarui.', 'success');
|
||||||
|
|
||||||
|
// Urutan yang benar - update state dulu
|
||||||
|
|
||||||
|
// 1. Update data di State (Harus pertama)
|
||||||
|
const index = State.houses.findIndex(hi => hi.id === h.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
State.houses[index] = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Hitung ulang jumlah rumah per center (membaca dari State.houses yang sudah diupdate)
|
||||||
|
recountCenterHouseholds();
|
||||||
|
|
||||||
|
// 3. Update warna semua marker rumah (yang mungkin berubah status radius)
|
||||||
|
updateAllHouseColors();
|
||||||
|
|
||||||
|
// 4. Update tampilan sidebar
|
||||||
|
renderCenterList();
|
||||||
|
renderHouseList();
|
||||||
|
updateLayerCounts();
|
||||||
|
|
||||||
|
// 5. Update statistik dashboard (ambil data fresh dari server)
|
||||||
|
await loadStats();
|
||||||
|
|
||||||
|
// 6. Refresh popup jika terbuka
|
||||||
|
if (marker.isPopupOpen()) {
|
||||||
|
await showHousePopup(marker, h, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast(r.data?.message || 'Gagal menyimpan posisi.', 'error');
|
||||||
|
// Kembalikan ke posisi lama jika gagal
|
||||||
|
marker.setLatLng([h.latitude, h.longitude]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Gagal menyimpan posisi: ' + err.message, 'error');
|
||||||
|
marker.setLatLng([h.latitude, h.longitude]);
|
||||||
|
} finally {
|
||||||
|
showLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tampilkan popup dengan data terbaru (jika belum terbuka)
|
||||||
|
if (!marker.isPopupOpen()) {
|
||||||
|
await showHousePopup(marker, h, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click handler dengan loading state
|
||||||
|
marker.on('click', async () => {
|
||||||
|
const loadingPopup = L.popup()
|
||||||
|
.setLatLng(marker.getLatLng())
|
||||||
|
.setContent('<div style="padding: 20px; text-align: center;"><i class="fas fa-circle-notch fa-spin"></i> Memuat data...</div>')
|
||||||
|
.openPopup();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await ApiHouses.show(h.id);
|
||||||
|
if (r.ok && r.data?.success) {
|
||||||
|
const freshData = r.data.data;
|
||||||
|
marker._houseData = freshData;
|
||||||
|
const index = State.houses.findIndex(hh => hh.id === freshData.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
State.houses[index] = freshData;
|
||||||
|
State.houses[index]._marker = marker;
|
||||||
|
}
|
||||||
|
await showHousePopup(marker, freshData);
|
||||||
|
} else {
|
||||||
|
loadingPopup.setContent('<div style="padding: 20px; text-align: center; color: var(--danger);">Gagal memuat data</div>');
|
||||||
|
setTimeout(() => marker.closePopup(), 1500);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
loadingPopup.setContent('<div style="padding: 20px; text-align: center; color: var(--danger);">Error memuat data</div>');
|
||||||
|
setTimeout(() => marker.closePopup(), 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
layerHouses.addLayer(marker);
|
||||||
|
h._marker = marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// SIDEBAR LISTS
|
||||||
|
// ====================================================================
|
||||||
|
function renderCenterList() {
|
||||||
|
const el = document.getElementById('centersList');
|
||||||
|
const cnt = document.getElementById('centerCount');
|
||||||
|
const show = State.activeFilter !== 'houses';
|
||||||
|
document.getElementById('centersListSection').style.display = show ? '' : 'none';
|
||||||
|
if (!show) return;
|
||||||
|
const filtered = State.centers.filter(c => {
|
||||||
|
if (!State.searchQuery) return true;
|
||||||
|
const q = State.searchQuery.toLowerCase();
|
||||||
|
return c.name.toLowerCase().includes(q) || (c.address || '').toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
cnt.textContent = filtered.length;
|
||||||
|
if (!filtered.length) { el.innerHTML = '<div class="empty-state"><i class="fas fa-place-of-worship"></i><p>Tidak ada data</p></div>'; return; }
|
||||||
|
el.innerHTML = filtered.slice(0, 50).map(c => {
|
||||||
|
const color = CENTER_COLORS[c.worship_type] || '#3a56d4';
|
||||||
|
const icon = CENTER_ICONS[c.worship_type] || 'fa-place-of-worship';
|
||||||
|
return `<div class="data-item" onclick="flyTo(${c.latitude},${c.longitude})">
|
||||||
|
<div class="data-item-icon center" style="background:${color}20;color:${color};"><i class="fas ${icon}"></i></div>
|
||||||
|
<div class="data-item-info"><div class="data-item-title">${truncate(c.name, 24)}</div><div class="data-item-subtitle">${CENTER_LABELS[c.worship_type] || ''} · ${c.household_count ?? 0} rumah</div></div>
|
||||||
|
<div class="data-item-actions">
|
||||||
|
<button class="btn-edit" title="Edit" onclick="event.stopPropagation();editCenter(${c.id})"><i class="fas fa-pen"></i></button>
|
||||||
|
${window.canDelete ? `<button class="btn-delete" title="Hapus" onclick="event.stopPropagation();deleteCenter(${c.id})"><i class="fas fa-trash"></i></button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHouseList(filtered) {
|
||||||
|
const el = document.getElementById('housesList');
|
||||||
|
const cnt = document.getElementById('houseCount');
|
||||||
|
const show = State.activeFilter !== 'centers';
|
||||||
|
document.getElementById('housesListSection').style.display = show ? '' : 'none';
|
||||||
|
if (!show) return;
|
||||||
|
const list = filtered || State.houses;
|
||||||
|
cnt.textContent = list.length;
|
||||||
|
if (!list.length) { el.innerHTML = '<div class="empty-state"><i class="fas fa-home"></i><p>Tidak ada data</p></div>'; return; }
|
||||||
|
el.innerHTML = list.slice(0, 80).map(h => {
|
||||||
|
const insideRadius = isHouseInsideAnyRadius(h.latitude, h.longitude);
|
||||||
|
const color = insideRadius ? '#d63230' : '#0b9e73';
|
||||||
|
const status = insideRadius ? 'Dalam Radius' : 'Luar Radius';
|
||||||
|
return `<div class="data-item" onclick="flyTo(${h.latitude},${h.longitude})">
|
||||||
|
<div class="data-item-icon house" style="background:${color}18;color:${color};"><i class="fas fa-home"></i></div>
|
||||||
|
<div class="data-item-info"><div class="data-item-title">${truncate(h.head_name, 22)}</div><div class="data-item-subtitle" style="color:${color};">${status}</div></div>
|
||||||
|
<div class="data-item-actions">
|
||||||
|
<button class="btn-edit" title="Edit" onclick="event.stopPropagation();editHouse(${h.id})"><i class="fas fa-pen"></i></button>
|
||||||
|
${window.canDelete ? `<button class="btn-delete" title="Hapus" onclick="event.stopPropagation();deleteHouse(${h.id})"><i class="fas fa-trash"></i></button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
if (list.length > 80) el.innerHTML += `<div class="empty-state" style="padding:10px;"><p style="color:var(--text-muted);">+${list.length - 80} lainnya — gunakan filter</p></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLayerCounts() {
|
||||||
|
const cc = document.getElementById('layerCenterCount');
|
||||||
|
const hc = document.getElementById('layerHouseCount');
|
||||||
|
if (cc) cc.textContent = State.centers.length;
|
||||||
|
if (hc) hc.textContent = State.houses.length;
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/* ============================================================
|
||||||
|
photo-upload.js — Reusable photo upload widget
|
||||||
|
Shared by lapor.html (public reports) and index.html (households)
|
||||||
|
============================================================ */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const PhotoUpload = {
|
||||||
|
MAX_FILES: 5,
|
||||||
|
MAX_MB: 5,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a FileList against rules.
|
||||||
|
* Returns { valid: true } or { valid: false, message: '...' }
|
||||||
|
*/
|
||||||
|
validate(fileList, existingCount = 0) {
|
||||||
|
const allowed = ['image/jpeg', 'image/png'];
|
||||||
|
const allowedExt = ['jpg', 'jpeg', 'png'];
|
||||||
|
const files = Array.from(fileList);
|
||||||
|
|
||||||
|
if (files.length === 0) return { valid: true };
|
||||||
|
|
||||||
|
if (existingCount + files.length > this.MAX_FILES) {
|
||||||
|
return { valid: false, message: `Maksimal ${this.MAX_FILES} foto. Sudah ada ${existingCount}, pilih paling banyak ${this.MAX_FILES - existingCount} foto lagi.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
const ext = f.name.split('.').pop().toLowerCase();
|
||||||
|
if (!allowedExt.includes(ext)) {
|
||||||
|
return { valid: false, message: `File "${f.name}" tidak diizinkan. Gunakan JPG atau PNG.` };
|
||||||
|
}
|
||||||
|
if (!allowed.includes(f.type)) {
|
||||||
|
return { valid: false, message: `File "${f.name}" bukan gambar yang valid.` };
|
||||||
|
}
|
||||||
|
if (f.size > this.MAX_MB * 1024 * 1024) {
|
||||||
|
return { valid: false, message: `File "${f.name}" melebihi batas ${this.MAX_MB} MB.` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a preview strip of <img> thumbnails for a FileList.
|
||||||
|
* Returns a DocumentFragment.
|
||||||
|
*/
|
||||||
|
buildPreviewStrip(fileList) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
Array.from(fileList).forEach(file => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = url;
|
||||||
|
img.className = 'photo-thumb';
|
||||||
|
img.alt = file.name;
|
||||||
|
img.title = file.name;
|
||||||
|
img.onload = () => URL.revokeObjectURL(url);
|
||||||
|
frag.appendChild(img);
|
||||||
|
});
|
||||||
|
return frag;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a preview strip from saved filenames (already uploaded).
|
||||||
|
* baseUrl e.g. 'uploads/reports/' or 'uploads/houses/'
|
||||||
|
*/
|
||||||
|
buildSavedStrip(filenames, baseUrl, removable = false, onRemove = null) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
if (!Array.isArray(filenames)) return frag;
|
||||||
|
filenames.forEach(name => {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.style.cssText = 'position:relative;display:inline-block;';
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = baseUrl + name;
|
||||||
|
img.className = 'photo-thumb';
|
||||||
|
img.alt = name;
|
||||||
|
img.title = 'Klik untuk perbesar';
|
||||||
|
img.style.cursor = 'pointer';
|
||||||
|
img.addEventListener('click', () => PhotoUpload.lightbox(img.src));
|
||||||
|
wrap.appendChild(img);
|
||||||
|
if (removable && onRemove) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.innerHTML = '×';
|
||||||
|
btn.title = 'Hapus foto';
|
||||||
|
btn.style.cssText = 'position:absolute;top:2px;right:2px;width:18px;height:18px;border-radius:50%;border:none;background:rgba(0,0,0,0.6);color:#fff;font-size:12px;line-height:1;cursor:pointer;padding:0;';
|
||||||
|
btn.addEventListener('click', (e) => { e.stopPropagation(); onRemove(name, wrap); });
|
||||||
|
wrap.appendChild(btn);
|
||||||
|
}
|
||||||
|
frag.appendChild(wrap);
|
||||||
|
});
|
||||||
|
return frag;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Simple lightbox */
|
||||||
|
lightbox(src) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.88);z-index:99999;display:flex;align-items:center;justify-content:center;cursor:zoom-out;';
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = src;
|
||||||
|
img.style.cssText = 'max-width:92vw;max-height:88vh;border-radius:8px;box-shadow:0 8px 40px rgba(0,0,0,.6);';
|
||||||
|
overlay.appendChild(img);
|
||||||
|
overlay.addEventListener('click', () => document.body.removeChild(overlay));
|
||||||
|
document.addEventListener('keydown', function esc(e) {
|
||||||
|
if (e.key === 'Escape') { document.body.removeChild(overlay); document.removeEventListener('keydown', esc); }
|
||||||
|
});
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload photos to the upload endpoint after a record has been saved.
|
||||||
|
* @param {string} target 'report' | 'house'
|
||||||
|
* @param {number} id The record's database id
|
||||||
|
* @param {FileList} fileList
|
||||||
|
* @returns {Promise<{ok: boolean, data: object}>}
|
||||||
|
*/
|
||||||
|
async upload(target, id, fileList) {
|
||||||
|
if (!fileList || fileList.length === 0) return { ok: true, data: { data: { all_photos: [] } } };
|
||||||
|
const form = new FormData();
|
||||||
|
Array.from(fileList).forEach(f => form.append('photos[]', f));
|
||||||
|
try {
|
||||||
|
const res = await fetch(`api/public/upload.php?target=${target}&id=${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
// ⚠️ Do NOT set Content-Type header — browser sets it with boundary automatically
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return { ok: res.ok, data };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PhotoUpload] Upload error:', err);
|
||||||
|
return { ok: false, data: { success: false, message: 'Upload gagal: ' + err.message } };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Delete a single saved photo via API.
|
||||||
|
* @param {string} target 'report' | 'house'
|
||||||
|
* @param {number} id Record's database id
|
||||||
|
* @param {string} filename The filename to delete
|
||||||
|
* @returns {Promise<{ok: boolean, data: object}>}
|
||||||
|
*/
|
||||||
|
async deletePhoto(target, id, filename) {
|
||||||
|
const endpointMap = {
|
||||||
|
report: 'api/public/report.php',
|
||||||
|
house: 'api/houses/index.php',
|
||||||
|
};
|
||||||
|
const url = endpointMap[target];
|
||||||
|
if (!url) return { ok: false, data: { success: false, message: 'Target tidak valid.' } };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}?action=delete_photo&id=${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ filename }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return { ok: res.ok, data };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PhotoUpload] deletePhoto error:', err);
|
||||||
|
return { ok: false, data: { success: false, message: 'Koneksi gagal: ' + err.message } };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
/* ============================================================
|
||||||
|
public-reports.js — Admin panel: public report verification
|
||||||
|
Loaded after app.js — requires loadAllData() and loadStats()
|
||||||
|
on window (exported by app.js)
|
||||||
|
============================================================ */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// API wrapper for public reports
|
||||||
|
// ================================================================
|
||||||
|
const ApiPublicReports = {
|
||||||
|
async list(params = {}) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return Http.request('api/public/report.php?action=list' + (qs ? '&' + qs : ''), { method: 'GET' });
|
||||||
|
},
|
||||||
|
async approve(id, body = {}) {
|
||||||
|
// Menambahkan verified_by ke body untuk audit
|
||||||
|
if (window.currentUserName) {
|
||||||
|
body.verified_by = window.currentUserName;
|
||||||
|
}
|
||||||
|
return Http.post(`api/public/report.php?action=approve&id=${id}`, body);
|
||||||
|
},
|
||||||
|
async reject(id, body = {}) {
|
||||||
|
if (window.currentUserName) {
|
||||||
|
body.rejected_by = window.currentUserName;
|
||||||
|
}
|
||||||
|
return Http.post(`api/public/report.php?action=reject&id=${id}`, body);
|
||||||
|
},
|
||||||
|
async delete(id) {
|
||||||
|
return Http.post(`api/public/report.php?action=delete&id=${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Load pending reports into admin panel table
|
||||||
|
// ================================================================
|
||||||
|
async function loadPendingReports() {
|
||||||
|
const tbody = document.getElementById('pendingTbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
const status = document.getElementById('pendingStatusFilter')?.value ?? 'pending';
|
||||||
|
const params = { limit: 100 };
|
||||||
|
if (status) params.status = status;
|
||||||
|
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center" style="color:#9ba4b5;padding:18px;">Memuat...</td></tr>';
|
||||||
|
|
||||||
|
const r = await ApiPublicReports.list(params);
|
||||||
|
|
||||||
|
if (!r.ok || !r.data?.success) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center" style="color:var(--danger);padding:18px;">Gagal memuat laporan publik.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reports = r.data.data?.reports || [];
|
||||||
|
|
||||||
|
// Update badge count (always count actual pending)
|
||||||
|
const badge = document.getElementById('pendingBadge');
|
||||||
|
if (badge) {
|
||||||
|
const pendingCount = reports.filter(rep => rep.status === 'pending').length;
|
||||||
|
badge.textContent = pendingCount > 0 ? pendingCount : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reports.length) {
|
||||||
|
const emptyMsg = status === 'pending' ? 'Tidak ada laporan yang menunggu verifikasi.' : 'Tidak ada laporan.';
|
||||||
|
tbody.innerHTML = `<tr><td colspan="6" class="text-center" style="color:#9ba4b5;padding:24px;">
|
||||||
|
<i class="fas fa-check-circle" style="font-size:20px;display:block;margin-bottom:8px;opacity:0.3;"></i>
|
||||||
|
${emptyMsg}
|
||||||
|
</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMap = {
|
||||||
|
pending: { label: 'Menunggu', color: '#d97706', bg: '#fef6e4' },
|
||||||
|
approved: { label: 'Disetujui', color: '#0b9e73', bg: '#e0faf3' },
|
||||||
|
rejected: { label: 'Ditolak', color: '#d63230', bg: '#fff0f0' },
|
||||||
|
};
|
||||||
|
|
||||||
|
tbody.innerHTML = reports.map(rep => {
|
||||||
|
const st = statusMap[rep.status] || { label: rep.status, color: '#9ba4b5', bg: '#f5f6f9' };
|
||||||
|
const canAct = rep.status === 'pending';
|
||||||
|
const dateStr = formatDateTime(rep.created_at);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr style="vertical-align:top;">
|
||||||
|
<td style="font-size:9.5px;color:#9ba4b5;white-space:nowrap;padding-top:10px;">${dateStr}</td>
|
||||||
|
<td style="padding-top:8px;">
|
||||||
|
<div style="font-size:12px;font-weight:700;color:#0f1623;">${escapeHtml(rep.head_name || '—')}</div>
|
||||||
|
${rep.reporter_name
|
||||||
|
? `<div style="font-size:10px;color:#9ba4b5;margin-top:2px;"><i class="fas fa-user" style="font-size:9px;"></i> ${escapeHtml(rep.reporter_name)}${rep.reporter_phone ? ' · ' + escapeHtml(rep.reporter_phone) : ''}</div>`
|
||||||
|
: ''}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:10.5px;color:#5a6478;max-width:130px;padding-top:10px;">${escapeHtml(truncate(rep.address || '—', 38))}</td>
|
||||||
|
<td style="font-size:10.5px;color:#5a6478;max-width:160px;padding-top:10px;">
|
||||||
|
${escapeHtml(truncate(rep.description || '—', 60))}
|
||||||
|
${buildPhotoMiniStrip(rep.proof_photos, 'uploads/reports/')}
|
||||||
|
</td>
|
||||||
|
<td style="padding-top:10px;">
|
||||||
|
<span style="padding:3px 9px;border-radius:20px;font-size:9.5px;font-weight:700;
|
||||||
|
background:${st.bg};color:${st.color};white-space:nowrap;">${st.label}</span>
|
||||||
|
${rep.admin_notes
|
||||||
|
? `<div style="font-size:9px;color:#9ba4b5;margin-top:3px;">${escapeHtml(truncate(rep.admin_notes, 25))}</div>`
|
||||||
|
: ''}
|
||||||
|
${rep.converted_household_id
|
||||||
|
? `<div style="font-size:9px;color:#0b9e73;margin-top:2px;"><i class="fas fa-home"></i> ID: ${rep.converted_household_id}</div>`
|
||||||
|
: ''}
|
||||||
|
</td>
|
||||||
|
<td style="white-space:nowrap;padding-top:8px;">
|
||||||
|
${canAct ? `
|
||||||
|
<button class="action-btn" onclick="openApproveModal(${rep.id}, ${safeJson(rep)})"
|
||||||
|
style="color:#0b9e73;border-color:#a8e8d4;" title="Setujui & tambah ke peta">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" onclick="openRejectModal(${rep.id})"
|
||||||
|
style="color:var(--danger);border-color:#fcc;" title="Tolak laporan">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
<button class="action-btn" onclick="flyToPublicReport(${rep.latitude}, ${rep.longitude})"
|
||||||
|
title="Lihat di peta" style="color:var(--accent);border-color:#c8d0f5;">
|
||||||
|
<i class="fas fa-map-marker-alt"></i>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn danger" onclick="deletePublicReport(${rep.id})" title="Hapus permanen">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a tiny inline strip of photo thumbnails in the admin table.
|
||||||
|
* photos: JSON string or array of filenames; baseUrl: path prefix.
|
||||||
|
*/
|
||||||
|
function buildPhotoMiniStrip(photos, baseUrl) {
|
||||||
|
let arr = [];
|
||||||
|
try {
|
||||||
|
arr = typeof photos === 'string' ? JSON.parse(photos) : (photos || []);
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('buildPhotoMiniStrip parse error:', e);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (!arr.length) return '';
|
||||||
|
|
||||||
|
const imgs = arr.slice(0, 3).map(name => {
|
||||||
|
const safeName = encodeURIComponent(name);
|
||||||
|
const src = baseUrl + safeName;
|
||||||
|
return `<img src="${src}"
|
||||||
|
style="width:36px;height:36px;object-fit:cover;border-radius:5px;border:1.5px solid #e2e6ef;cursor:zoom-in;margin-right:3px;"
|
||||||
|
onclick="PhotoUpload.lightbox('${src}')"
|
||||||
|
title="${escapeHtml(name)}">`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const extra = arr.length > 3 ? `<span style="font-size:10px;color:#9ba4b5;">+${arr.length - 3}</span>` : '';
|
||||||
|
return `<div style="display:flex;align-items:center;flex-wrap:wrap;gap:3px;margin-top:5px;">${imgs}${extra}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safely JSON-encode report object for inline onclick attribute */
|
||||||
|
function safeJson(obj) {
|
||||||
|
return JSON.stringify(obj)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Approve modal
|
||||||
|
// ================================================================
|
||||||
|
function openApproveModal(reportId, reportData) {
|
||||||
|
document.getElementById('approveReportId').value = reportId;
|
||||||
|
document.getElementById('approveIncome').value = 0;
|
||||||
|
document.getElementById('approveDependents').value = 1;
|
||||||
|
document.getElementById('approveCondition').value = 'tidak_layak';
|
||||||
|
document.getElementById('approveEducation').value = 'sd';
|
||||||
|
document.getElementById('approveNotes').value = '';
|
||||||
|
|
||||||
|
const preview = document.getElementById('approveReportPreview');
|
||||||
|
const photoPreview = document.getElementById('approvePhotoPreview');
|
||||||
|
|
||||||
|
if (preview && reportData) {
|
||||||
|
const data = typeof reportData === 'string' ? JSON.parse(reportData) : reportData;
|
||||||
|
preview.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:flex-start;gap:10px;">
|
||||||
|
<div style="width:32px;height:32px;border-radius:8px;background:#fff0f0;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
|
||||||
|
<i class="fas fa-flag" style="color:var(--danger);font-size:14px;"></i>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="font-size:13px;font-weight:700;color:#0f1623;">${data.head_name || '—'}</div>
|
||||||
|
<div style="font-size:11px;color:#5a6478;margin-top:3px;">${data.address || '—'}</div>
|
||||||
|
<div style="font-size:11px;color:#5a6478;margin-top:5px;font-style:italic;border-left:2px solid #e2e6ef;padding-left:8px;">"${truncate(data.description || '', 100)}"</div>
|
||||||
|
${data.reporter_name ? `<div style="font-size:10.5px;color:#9ba4b5;margin-top:4px;"><i class="fas fa-user" style="font-size:9px;"></i> ${data.reporter_name}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// ── TAMPILKAN FOTO BUKTI ──────────────────────────────
|
||||||
|
if (photoPreview) {
|
||||||
|
let photosArr = [];
|
||||||
|
try {
|
||||||
|
photosArr = typeof data.proof_photos === 'string' ? JSON.parse(data.proof_photos) : (data.proof_photos || []);
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('Failed to parse proof_photos:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photosArr.length) {
|
||||||
|
photoPreview.innerHTML = '';
|
||||||
|
const rId = reportId; // capture for closure
|
||||||
|
const strip = PhotoUpload.buildSavedStrip(
|
||||||
|
photosArr,
|
||||||
|
'uploads/reports/',
|
||||||
|
true, // always removable in admin approve-modal
|
||||||
|
(filename, wrap) => deleteReportPhoto(rId, filename, wrap)
|
||||||
|
);
|
||||||
|
photoPreview.appendChild(strip);
|
||||||
|
} else {
|
||||||
|
photoPreview.innerHTML = '<div style="font-size:11px;color:#9ba4b5;padding:8px 0;"><i class="fas fa-image"></i> Tidak ada foto bukti</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openModal('approveModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('approveForm')?.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = parseInt(document.getElementById('approveReportId').value);
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
income: parseInt(document.getElementById('approveIncome').value) || 0,
|
||||||
|
dependents: parseInt(document.getElementById('approveDependents').value) || 1,
|
||||||
|
house_condition: document.getElementById('approveCondition').value,
|
||||||
|
education: document.getElementById('approveEducation').value,
|
||||||
|
land_ownership: 'numpang',
|
||||||
|
admin_notes: document.getElementById('approveNotes').value.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
showLoading(true);
|
||||||
|
const r = await ApiPublicReports.approve(id, body);
|
||||||
|
showLoading(false);
|
||||||
|
|
||||||
|
if (r.ok && r.data?.success) {
|
||||||
|
closeModal('approveModal');
|
||||||
|
showToast('Laporan disetujui. Data rumah ditambahkan ke peta.', 'success', 4000);
|
||||||
|
loadPendingReports();
|
||||||
|
// Refresh map and stats
|
||||||
|
if (typeof loadAllData === 'function') await loadAllData();
|
||||||
|
if (typeof loadStats === 'function') await loadStats();
|
||||||
|
updatePendingBadge();
|
||||||
|
} else {
|
||||||
|
showToast(r.data?.message || 'Gagal menyetujui laporan.', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Reject modal
|
||||||
|
// ================================================================
|
||||||
|
function openRejectModal(reportId) {
|
||||||
|
document.getElementById('rejectReportId').value = reportId;
|
||||||
|
document.getElementById('rejectNotes').value = '';
|
||||||
|
openModal('rejectModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('rejectForm')?.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = parseInt(document.getElementById('rejectReportId').value);
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
admin_notes: document.getElementById('rejectNotes').value.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
showLoading(true);
|
||||||
|
const r = await ApiPublicReports.reject(id, body);
|
||||||
|
showLoading(false);
|
||||||
|
|
||||||
|
if (r.ok && r.data?.success) {
|
||||||
|
closeModal('rejectModal');
|
||||||
|
showToast('Laporan ditolak.', 'success');
|
||||||
|
loadPendingReports();
|
||||||
|
if (typeof loadStats === 'function') loadStats();
|
||||||
|
updatePendingBadge();
|
||||||
|
} else {
|
||||||
|
showToast(r.data?.message || 'Gagal menolak laporan.', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Delete
|
||||||
|
// ================================================================
|
||||||
|
async function deletePublicReport(id) {
|
||||||
|
if (!confirm('Hapus laporan ini secara permanen? Tindakan ini tidak dapat dibatalkan.')) return;
|
||||||
|
showLoading(true);
|
||||||
|
const r = await ApiPublicReports.delete(id);
|
||||||
|
showLoading(false);
|
||||||
|
if (r.ok && r.data?.success) {
|
||||||
|
showToast('Laporan dihapus.', 'success');
|
||||||
|
loadPendingReports();
|
||||||
|
if (typeof loadStats === 'function') loadStats();
|
||||||
|
updatePendingBadge();
|
||||||
|
} else {
|
||||||
|
showToast(r.data?.message || 'Gagal menghapus laporan.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Fly to location on map
|
||||||
|
// ================================================================
|
||||||
|
function flyToPublicReport(lat, lng) {
|
||||||
|
closeModal('adminModal');
|
||||||
|
flyTo(parseFloat(lat), parseFloat(lng), 17);
|
||||||
|
|
||||||
|
// Temporary highlight pulse
|
||||||
|
const highlight = L.circleMarker([lat, lng], {
|
||||||
|
radius: 20, color: '#d63230', fillColor: '#d63230',
|
||||||
|
fillOpacity: 0.2, weight: 2.5,
|
||||||
|
}).addTo(MAP);
|
||||||
|
|
||||||
|
setTimeout(() => { if (MAP.hasLayer(highlight)) MAP.removeLayer(highlight); }, 5000);
|
||||||
|
showToast('Lokasi laporan ditampilkan di peta.', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Delete a single proof photo from a public report
|
||||||
|
// ================================================================
|
||||||
|
async function deleteReportPhoto(reportId, filename, thumbWrap) {
|
||||||
|
if (!confirm('Hapus foto bukti ini secara permanen?')) return;
|
||||||
|
|
||||||
|
if (thumbWrap) thumbWrap.style.opacity = '0.4';
|
||||||
|
|
||||||
|
const r = await PhotoUpload.deletePhoto('report', reportId, filename);
|
||||||
|
|
||||||
|
if (r.ok && r.data?.success) {
|
||||||
|
thumbWrap?.remove();
|
||||||
|
showToast('Foto bukti dihapus.', 'success');
|
||||||
|
} else {
|
||||||
|
if (thumbWrap) thumbWrap.style.opacity = '1';
|
||||||
|
showToast(r.data?.message || 'Gagal menghapus foto.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Expose to global scope
|
||||||
|
// ================================================================
|
||||||
|
window.loadPendingReports = loadPendingReports;
|
||||||
|
window.openApproveModal = openApproveModal;
|
||||||
|
window.openRejectModal = openRejectModal;
|
||||||
|
window.deletePublicReport = deletePublicReport;
|
||||||
|
window.flyToPublicReport = flyToPublicReport;
|
||||||
|
window.deleteReportPhoto = deleteReportPhoto;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// config/bootstrap.php — with session support
|
||||||
|
// ============================================================
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
require_once __DIR__ . '/database.php';
|
||||||
|
require_once __DIR__ . '/../middleware/Response.php';
|
||||||
|
require_once __DIR__ . '/../middleware/Validator.php';
|
||||||
|
require_once __DIR__ . '/../models/AuditLog.php';
|
||||||
|
require_once __DIR__ . '/../models/PovertyCalculator.php';
|
||||||
|
|
||||||
|
// ---- Error handling ----------------------------------------
|
||||||
|
if (APP_DEBUG) {
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
} else {
|
||||||
|
error_reporting(0);
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
set_exception_handler(function (Throwable $e) {
|
||||||
|
$message = APP_DEBUG ? $e->getMessage() : 'Internal server error';
|
||||||
|
Response::error($message, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- CORS & Security Headers --------------------------------
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('X-Frame-Options: DENY');
|
||||||
|
|
||||||
|
if (APP_ENV === 'development') {
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type');
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? '';
|
||||||
|
if ($requestMethod === 'OPTIONS') {
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
|
||||||
|
// ---- Session bootstrap (shared by all APIs) -----------------
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_name('webgis_sess');
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 0, // until browser closes
|
||||||
|
'path' => '/',
|
||||||
|
'secure' => false, // set true if using HTTPS
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Session helper functions --------------------------------
|
||||||
|
|
||||||
|
/** Return current logged-in user array or null */
|
||||||
|
function currentUser(): ?array
|
||||||
|
{
|
||||||
|
if (empty($_SESSION['user_id']) || empty($_SESSION['role'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'id' => (int)$_SESSION['user_id'],
|
||||||
|
'name' => $_SESSION['name'] ?? '',
|
||||||
|
'email' => $_SESSION['email'] ?? '',
|
||||||
|
'role' => $_SESSION['role'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Require authentication — returns user or sends 401 */
|
||||||
|
function requireAuth(): array
|
||||||
|
{
|
||||||
|
$user = currentUser();
|
||||||
|
if (!$user) {
|
||||||
|
Response::error('Silakan login terlebih dahulu.', 401);
|
||||||
|
}
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Require admin role — returns user or sends 403 */
|
||||||
|
function requireAdmin(): array
|
||||||
|
{
|
||||||
|
$user = requireAuth();
|
||||||
|
if ($user['role'] !== 'admin') {
|
||||||
|
Response::error('Akses ditolak. Hanya admin yang dapat melakukan tindakan ini.', 403);
|
||||||
|
}
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// config/config.php
|
||||||
|
// ============================================================
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// ---- Environment -------------------------------------------
|
||||||
|
// SET TO 'development' TEMPORARILY TO SEE ERROR DETAILS
|
||||||
|
define('APP_ENV', getenv('APP_ENV') ?: 'development');
|
||||||
|
define('APP_DEBUG', true); // <--- SET TRUE TO SEE ERRORS
|
||||||
|
define('APP_NAME', 'WebGIS Poverty Mapping v2');
|
||||||
|
define('BASE_URL', getenv('BASE_URL') ?: 'http://localhost/webgis-v2');
|
||||||
|
|
||||||
|
// ---- Database ----------------------------------------------
|
||||||
|
define('DB_HOST', getenv('DB_HOST') ?: 'localhost');
|
||||||
|
define('DB_PORT', getenv('DB_PORT') ?: '3306');
|
||||||
|
define('DB_NAME', getenv('DB_NAME') ?: 'poverty_mapping');
|
||||||
|
define('DB_USER', getenv('DB_USER') ?: 'root');
|
||||||
|
define('DB_PASS', getenv('DB_PASS') ?: '');
|
||||||
|
define('DB_CHARSET', 'utf8mb4');
|
||||||
|
|
||||||
|
// ---- Pagination --------------------------------------------
|
||||||
|
define('PAGE_SIZE', 500);
|
||||||
|
|
||||||
|
// ---- Poverty scoring thresholds ----------------------------
|
||||||
|
define('POVERTY_THRESHOLD_NEAR', 30);
|
||||||
|
define('POVERTY_THRESHOLD_POOR', 55);
|
||||||
|
define('POVERTY_THRESHOLD_SEVERE', 75);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// config/database.php — PDO singleton
|
||||||
|
// ============================================================
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
private static ?PDO $instance = null;
|
||||||
|
|
||||||
|
public static function get(): PDO
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
$dsn = sprintf(
|
||||||
|
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
|
||||||
|
DB_HOST, DB_PORT, DB_NAME, DB_CHARSET
|
||||||
|
);
|
||||||
|
self::$instance = new PDO($dsn, DB_USER, DB_PASS, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prevent cloning / serialisation */
|
||||||
|
private function __clone() {}
|
||||||
|
public function __wakeup(): never { throw new \Exception('Cannot unserialize singleton.'); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// database/migrate.php — Run via browser:
|
||||||
|
// http://localhost/webgis/03PovertyMapping/database/migrate.php
|
||||||
|
// ============================================================
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// ---- Configuration (mirror config/config.php) ---------------
|
||||||
|
$DB_HOST = getenv('DB_HOST') ?: 'localhost';
|
||||||
|
$DB_PORT = getenv('DB_PORT') ?: '3306';
|
||||||
|
$DB_NAME = 'poverty_mapping';
|
||||||
|
$DB_USER = getenv('DB_USER') ?: 'root';
|
||||||
|
$DB_PASS = getenv('DB_PASS') ?: '';
|
||||||
|
$DB_CHARSET = 'utf8mb4';
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
$hasError = false;
|
||||||
|
|
||||||
|
// ---- Helper -------------------------------------------------
|
||||||
|
function step(string $msg, bool $ok, string $detail = ''): void
|
||||||
|
{
|
||||||
|
global $results, $hasError;
|
||||||
|
$results[] = ['ok' => $ok, 'msg' => $msg, 'detail' => $detail];
|
||||||
|
if (!$ok) $hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Connect WITHOUT selecting a database first -------------
|
||||||
|
try {
|
||||||
|
$dsn = "mysql:host={$DB_HOST};port={$DB_PORT};charset={$DB_CHARSET}";
|
||||||
|
$pdo = new PDO($dsn, $DB_USER, $DB_PASS, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
step('Connect to MySQL server', true, "{$DB_HOST}:{$DB_PORT}");
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
step('Connect to MySQL server', false, $e->getMessage());
|
||||||
|
renderPage($results, true);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Create database ----------------------------------------
|
||||||
|
try {
|
||||||
|
$pdo->exec("CREATE DATABASE IF NOT EXISTS `{$DB_NAME}`
|
||||||
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci");
|
||||||
|
step("Create database `{$DB_NAME}`", true);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
step("Create database `{$DB_NAME}`", false, $e->getMessage());
|
||||||
|
renderPage($results, true);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Select database ----------------------------------------
|
||||||
|
$pdo->exec("USE `{$DB_NAME}`");
|
||||||
|
step("USE `{$DB_NAME}`", true);
|
||||||
|
|
||||||
|
// ---- Enable FK checks off for migration ---------------------
|
||||||
|
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
|
||||||
|
|
||||||
|
// ---- Table definitions (ordered: no FK before parent) -------
|
||||||
|
$tables = [];
|
||||||
|
|
||||||
|
// 1 — users
|
||||||
|
$tables['users'] = "
|
||||||
|
CREATE TABLE IF NOT EXISTS `users` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(150) NOT NULL,
|
||||||
|
`email` VARCHAR(255) NOT NULL,
|
||||||
|
`password_hash` VARCHAR(255) NOT NULL,
|
||||||
|
`role` ENUM('admin','field_officer','viewer') NOT NULL DEFAULT 'viewer',
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`last_login_at` TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_users_email` (`email`),
|
||||||
|
KEY `idx_users_role` (`role`),
|
||||||
|
KEY `idx_users_is_active` (`is_active`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
";
|
||||||
|
|
||||||
|
// 2 — religious_centers
|
||||||
|
$tables['religious_centers'] = "
|
||||||
|
CREATE TABLE IF NOT EXISTS `religious_centers` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`worship_type` ENUM('masjid','gereja','klenteng','pura','vihara') NOT NULL DEFAULT 'masjid',
|
||||||
|
`address` TEXT NOT NULL,
|
||||||
|
`kelurahan` VARCHAR(100) NULL DEFAULT NULL,
|
||||||
|
`kecamatan` VARCHAR(100) NULL DEFAULT NULL,
|
||||||
|
`latitude` DECIMAL(10,7) NOT NULL,
|
||||||
|
`longitude` DECIMAL(10,7) NOT NULL,
|
||||||
|
`radius` INT UNSIGNED NOT NULL DEFAULT 300,
|
||||||
|
`contact_person` VARCHAR(150) NULL DEFAULT NULL,
|
||||||
|
`contact_phone` VARCHAR(20) NULL DEFAULT NULL,
|
||||||
|
`notes` TEXT NULL DEFAULT NULL,
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_rc_worship_type` (`worship_type`),
|
||||||
|
KEY `idx_rc_is_active` (`is_active`),
|
||||||
|
KEY `idx_rc_location` (`latitude`, `longitude`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
";
|
||||||
|
|
||||||
|
// 3 — households
|
||||||
|
$tables['households'] = "
|
||||||
|
CREATE TABLE IF NOT EXISTS `households` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`rt` VARCHAR(10) NULL DEFAULT NULL,
|
||||||
|
`rw` VARCHAR(10) NULL DEFAULT NULL,
|
||||||
|
`kelurahan` VARCHAR(100) NULL DEFAULT NULL,
|
||||||
|
`kecamatan` VARCHAR(100) NULL DEFAULT NULL,
|
||||||
|
`full_address` TEXT NOT NULL,
|
||||||
|
`latitude` DECIMAL(10,7) NOT NULL,
|
||||||
|
`longitude` DECIMAL(10,7) NOT NULL,
|
||||||
|
`house_condition` ENUM('layak','tidak_layak') NOT NULL DEFAULT 'layak',
|
||||||
|
`land_ownership` ENUM('milik','sewa','numpang','lainnya') NOT NULL DEFAULT 'milik',
|
||||||
|
`house_photos` JSON NULL DEFAULT NULL,
|
||||||
|
`head_name` VARCHAR(150) NOT NULL,
|
||||||
|
`head_nik` VARCHAR(16) NULL DEFAULT NULL,
|
||||||
|
`head_gender` ENUM('male','female') NOT NULL DEFAULT 'male',
|
||||||
|
`head_date_of_birth` DATE NULL DEFAULT NULL,
|
||||||
|
`head_education` ENUM('tidak_sekolah','sd','smp','sma','diploma','sarjana','pascasarjana') NOT NULL DEFAULT 'sd',
|
||||||
|
`head_employment_status` VARCHAR(50) NOT NULL DEFAULT 'unemployed',
|
||||||
|
`head_job_name` VARCHAR(150) NULL DEFAULT NULL,
|
||||||
|
`head_institution_name` VARCHAR(200) NULL DEFAULT NULL,
|
||||||
|
`head_monthly_income` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`poverty_score` TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`poverty_status` ENUM('terdata','rentan_miskin','miskin','sangat_miskin') NOT NULL DEFAULT 'terdata',
|
||||||
|
`aid_status` ENUM('not_yet','received') NOT NULL DEFAULT 'not_yet',
|
||||||
|
`managing_center_id` INT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
`notes` TEXT NULL DEFAULT NULL,
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_hh_poverty_status` (`poverty_status`),
|
||||||
|
KEY `idx_hh_aid_status` (`aid_status`),
|
||||||
|
KEY `idx_hh_house_condition` (`house_condition`),
|
||||||
|
KEY `idx_hh_managing_center` (`managing_center_id`),
|
||||||
|
KEY `idx_hh_is_active` (`is_active`),
|
||||||
|
KEY `idx_hh_location` (`latitude`, `longitude`),
|
||||||
|
KEY `idx_hh_head_nik` (`head_nik`),
|
||||||
|
KEY `idx_hh_created_at` (`created_at`),
|
||||||
|
CONSTRAINT `fk_hh_managing_center`
|
||||||
|
FOREIGN KEY (`managing_center_id`) REFERENCES `religious_centers` (`id`)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
";
|
||||||
|
|
||||||
|
// 4 — household_members
|
||||||
|
$tables['household_members'] = "
|
||||||
|
CREATE TABLE IF NOT EXISTS `household_members` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`household_id` INT UNSIGNED NOT NULL,
|
||||||
|
`name` VARCHAR(150) NOT NULL,
|
||||||
|
`nik` VARCHAR(16) NULL DEFAULT NULL,
|
||||||
|
`gender` ENUM('male','female') NOT NULL DEFAULT 'male',
|
||||||
|
`date_of_birth` DATE NULL DEFAULT NULL,
|
||||||
|
`education` ENUM('tidak_sekolah','sd','smp','sma','diploma','sarjana','pascasarjana') NOT NULL DEFAULT 'sd',
|
||||||
|
`relationship` VARCHAR(50) NOT NULL DEFAULT 'lainnya',
|
||||||
|
`employment_status` VARCHAR(50) NOT NULL DEFAULT 'unemployed',
|
||||||
|
`job_name` VARCHAR(150) NULL DEFAULT NULL,
|
||||||
|
`institution_name` VARCHAR(200) NULL DEFAULT NULL,
|
||||||
|
`monthly_income` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_hm_household_id` (`household_id`),
|
||||||
|
KEY `idx_hm_nik` (`nik`),
|
||||||
|
CONSTRAINT `fk_hm_household`
|
||||||
|
FOREIGN KEY (`household_id`) REFERENCES `households` (`id`)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
";
|
||||||
|
|
||||||
|
// 5 — aid_history
|
||||||
|
$tables['aid_history'] = "
|
||||||
|
CREATE TABLE IF NOT EXISTS `aid_history` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`household_id` INT UNSIGNED NOT NULL,
|
||||||
|
`center_id` INT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
`aid_type` ENUM('sembako','pendanaan','pelatihan','sembako_pendanaan','sembako_pelatihan','pendanaan_pelatihan','lengkap') NOT NULL,
|
||||||
|
`aid_date` DATE NOT NULL,
|
||||||
|
`amount` INT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
`description` TEXT NULL DEFAULT NULL,
|
||||||
|
`given_by` INT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_ah_household_id` (`household_id`),
|
||||||
|
KEY `idx_ah_center_id` (`center_id`),
|
||||||
|
KEY `idx_ah_aid_date` (`aid_date`),
|
||||||
|
KEY `idx_ah_aid_type` (`aid_type`),
|
||||||
|
CONSTRAINT `fk_ah_household`
|
||||||
|
FOREIGN KEY (`household_id`) REFERENCES `households` (`id`)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT `fk_ah_center`
|
||||||
|
FOREIGN KEY (`center_id`) REFERENCES `religious_centers` (`id`)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT `fk_ah_given_by`
|
||||||
|
FOREIGN KEY (`given_by`) REFERENCES `users` (`id`)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
";
|
||||||
|
|
||||||
|
// 6 — emergency_reports
|
||||||
|
$tables['emergency_reports'] = "
|
||||||
|
CREATE TABLE IF NOT EXISTS `emergency_reports` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`household_id` INT UNSIGNED NOT NULL,
|
||||||
|
`type` ENUM('sakit','kecelakaan','bencana','kehilangan_pekerjaan','kematian','lainnya') NOT NULL,
|
||||||
|
`severity` ENUM('ringan','sedang','berat','kritis') NOT NULL DEFAULT 'ringan',
|
||||||
|
`description` TEXT NOT NULL,
|
||||||
|
`status` ENUM('open','in_progress','resolved','closed') NOT NULL DEFAULT 'open',
|
||||||
|
`resolved_at` TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_er_household_id` (`household_id`),
|
||||||
|
KEY `idx_er_status` (`status`),
|
||||||
|
KEY `idx_er_severity` (`severity`),
|
||||||
|
KEY `idx_er_created_at` (`created_at`),
|
||||||
|
CONSTRAINT `fk_er_household`
|
||||||
|
FOREIGN KEY (`household_id`) REFERENCES `households` (`id`)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
";
|
||||||
|
|
||||||
|
// 7 — public_reports
|
||||||
|
$tables['public_reports'] = "
|
||||||
|
CREATE TABLE IF NOT EXISTS `public_reports` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`reporter_name` VARCHAR(150) NOT NULL,
|
||||||
|
`reporter_phone` VARCHAR(20) NOT NULL,
|
||||||
|
`rt` VARCHAR(10) NOT NULL DEFAULT '',
|
||||||
|
`rw` VARCHAR(10) NOT NULL DEFAULT '',
|
||||||
|
`head_name` VARCHAR(150) NOT NULL,
|
||||||
|
`address` TEXT NOT NULL,
|
||||||
|
`kelurahan` VARCHAR(100) NOT NULL,
|
||||||
|
`kecamatan` VARCHAR(100) NOT NULL,
|
||||||
|
`latitude` DECIMAL(10,7) NOT NULL,
|
||||||
|
`longitude` DECIMAL(10,7) NOT NULL,
|
||||||
|
`description` TEXT NOT NULL,
|
||||||
|
`severity` ENUM('ringan','sedang','berat','kritis') NOT NULL DEFAULT 'ringan',
|
||||||
|
`urgent_need` TEXT NULL DEFAULT NULL,
|
||||||
|
`proof_photos` JSON NULL DEFAULT NULL,
|
||||||
|
`status` ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending',
|
||||||
|
`admin_notes` TEXT NULL DEFAULT NULL,
|
||||||
|
`reviewed_at` TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
`converted_household_id` INT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
`ip_address` VARCHAR(45) NOT NULL DEFAULT '',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_pr_status` (`status`),
|
||||||
|
KEY `idx_pr_severity` (`severity`),
|
||||||
|
KEY `idx_pr_ip_address` (`ip_address`),
|
||||||
|
KEY `idx_pr_created_at` (`created_at`),
|
||||||
|
KEY `idx_pr_converted` (`converted_household_id`),
|
||||||
|
CONSTRAINT `fk_pr_converted_household`
|
||||||
|
FOREIGN KEY (`converted_household_id`) REFERENCES `households` (`id`)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
";
|
||||||
|
|
||||||
|
// 8 — audit_logs
|
||||||
|
$tables['audit_logs'] = "
|
||||||
|
CREATE TABLE IF NOT EXISTS `audit_logs` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`action` VARCHAR(100) NOT NULL,
|
||||||
|
`table_name` VARCHAR(100) NOT NULL,
|
||||||
|
`record_id` INT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
`old_values` JSON NULL DEFAULT NULL,
|
||||||
|
`new_values` JSON NULL DEFAULT NULL,
|
||||||
|
`ip_address` VARCHAR(45) NOT NULL DEFAULT '',
|
||||||
|
`user_agent` TEXT NULL DEFAULT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_al_table_name` (`table_name`),
|
||||||
|
KEY `idx_al_record_id` (`record_id`),
|
||||||
|
KEY `idx_al_action` (`action`),
|
||||||
|
KEY `idx_al_created_at` (`created_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
";
|
||||||
|
|
||||||
|
// ---- Execute each table -------------------------------------
|
||||||
|
foreach ($tables as $tableName => $sql) {
|
||||||
|
try {
|
||||||
|
$pdo->exec($sql);
|
||||||
|
step("CREATE TABLE IF NOT EXISTS `{$tableName}`", true);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
step("CREATE TABLE IF NOT EXISTS `{$tableName}`", false, $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Re-enable FK checks ------------------------------------
|
||||||
|
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
|
||||||
|
step('Re-enable FOREIGN_KEY_CHECKS', true);
|
||||||
|
|
||||||
|
// ---- Verify: count created tables ---------------------------
|
||||||
|
try {
|
||||||
|
$count = (int)$pdo->query("SELECT COUNT(*) FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = '{$DB_NAME}'")->fetchColumn();
|
||||||
|
step("Verify: {$count} table(s) exist in `{$DB_NAME}`", $count >= 8,
|
||||||
|
$count < 8 ? "Expected at least 8 tables." : "");
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
step('Verify table count', false, $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPage($results, $hasError);
|
||||||
|
|
||||||
|
// ---- Render HTML --------------------------------------------
|
||||||
|
function renderPage(array $results, bool $hasError): void
|
||||||
|
{
|
||||||
|
$title = $hasError ? '⚠ Migration completed with errors' : '✅ Migration successful';
|
||||||
|
$color = $hasError ? '#c0392b' : '#27ae60';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>DB Migration — poverty_mapping</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f1117; color: #e2e8f0; min-height: 100vh; padding: 2rem; }
|
||||||
|
.card { max-width: 900px; margin: 0 auto; background: #1a1d2e; border-radius: 12px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,.5); }
|
||||||
|
.header { padding: 2rem; background: <?= $color ?>22; border-bottom: 2px solid <?= $color ?>; }
|
||||||
|
.header h1 { font-size: 1.6rem; color: <?= $color ?>; }
|
||||||
|
.header p { margin-top: .4rem; color: #94a3b8; font-size: .9rem; }
|
||||||
|
.steps { padding: 1.5rem; display: flex; flex-direction: column; gap: .6rem; }
|
||||||
|
.step { display: flex; gap: 1rem; align-items: flex-start; padding: .7rem 1rem; border-radius: 8px; background: #252a3e; }
|
||||||
|
.step.ok { border-left: 3px solid #27ae60; }
|
||||||
|
.step.err { border-left: 3px solid #c0392b; }
|
||||||
|
.icon { font-size: 1.1rem; flex-shrink: 0; margin-top: .05rem; }
|
||||||
|
.msg { font-size: .9rem; flex: 1; }
|
||||||
|
.detail { font-size: .78rem; color: #f87171; margin-top: .2rem; font-family: monospace; }
|
||||||
|
.footer { padding: 1.5rem; border-top: 1px solid #2d3348; color: #64748b; font-size: .8rem; }
|
||||||
|
.next { margin-top: .8rem; }
|
||||||
|
.next a { color: #60a5fa; text-decoration: none; }
|
||||||
|
.next a:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="header">
|
||||||
|
<h1><?= htmlspecialchars($title) ?></h1>
|
||||||
|
<p>Database: <strong>poverty_mapping</strong> | <?= date('Y-m-d H:i:s') ?></p>
|
||||||
|
</div>
|
||||||
|
<div class="steps">
|
||||||
|
<?php foreach ($results as $r): ?>
|
||||||
|
<div class="step <?= $r['ok'] ? 'ok' : 'err' ?>">
|
||||||
|
<span class="icon"><?= $r['ok'] ? '✅' : '❌' ?></span>
|
||||||
|
<div class="msg">
|
||||||
|
<?= htmlspecialchars($r['msg']) ?>
|
||||||
|
<?php if ($r['detail']): ?>
|
||||||
|
<div class="detail"><?= htmlspecialchars($r['detail']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
Migration script: <code>database/migrate.php</code>
|
||||||
|
<div class="next">
|
||||||
|
<?php if (!$hasError): ?>
|
||||||
|
✅ All done! You can now run the seeder:
|
||||||
|
<a href="seeder.php">database/seeder.php</a>
|
||||||
|
<?php else: ?>
|
||||||
|
⚠ Fix the errors above, then reload this page to re-run.
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- seeder.sql — Data Dummy untuk WebGIS Poverty Mapping
|
||||||
|
-- Lokasi: Kota Pontianak, Kalimantan Barat
|
||||||
|
-- ============================================================
|
||||||
|
USE `poverty_mapping`;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. USERS (admin + 2 field officers)
|
||||||
|
-- Password untuk semua: password (hash: $2y$10$E7A/QogdGQz9W4DB1YcYl.7KvET/Ru6mZ3k8s1.e700lkMnrnsn4i)
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO `users` (`name`, `email`, `password_hash`, `role`, `is_active`) VALUES
|
||||||
|
('Administrator', 'admin', '$2y$10$E7A/QogdGQz9W4DB1YcYl.7KvET/Ru6mZ3k8s1.e700lkMnrnsn4i', 'admin', 1),
|
||||||
|
('Budi Santoso', 'petugas', '$2y$10$E7A/QogdGQz9W4DB1YcYl.7KvET/Ru6mZ3k8s1.e700lkMnrnsn4i', 'field_officer', 1),
|
||||||
|
('Siti Rahayu', 'petugas2', '$2y$10$E7A/QogdGQz9W4DB1YcYl.7KvET/Ru6mZ3k8s1.e700lkMnrnsn4i', 'field_officer', 1),
|
||||||
|
('Viewer Demo', 'viewer@povertymapping.id', '$2y$10$E7A/QogdGQz9W4DB1YcYl.7KvET/Ru6mZ3k8s1.e700lkMnrnsn4i', 'viewer', 1);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2. RELIGIOUS CENTERS (Tempat Ibadah di Pontianak)
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO `religious_centers`
|
||||||
|
(`name`, `worship_type`, `address`, `kelurahan`, `kecamatan`, `latitude`, `longitude`, `radius`, `contact_person`, `contact_phone`, `notes`) VALUES
|
||||||
|
('Masjid Raya Pontianak', 'masjid', 'Jl. Khatulistiwa No. 1', 'Batu Layang', 'Pontianak Utara', -0.0300000, 109.3250000, 500, 'H. Ahmad Yani', '08112233445', 'Masjid utama Kota Pontianak'),
|
||||||
|
('Masjid Jami', 'masjid', 'Jl. Pahlawan No. 15', 'Tanjung Hulu', 'Pontianak Timur', -0.0350000, 109.3280000, 400, 'Ustadz Hamid', '08122334456', NULL),
|
||||||
|
('Gereja Katedral Pontianak', 'gereja', 'Jl. Katedral No. 2', 'Benuamelayu Darat','Pontianak Selatan', -0.0420000, 109.3300000, 350, 'Pdt. Samuel Runtu', '08133445567', 'Pelayanan sosial aktif'),
|
||||||
|
('Vihara Bodhisattva', 'vihara', 'Jl. Gusti Situt Mahmud No. 8', 'Siantan Hilir', 'Pontianak Utara', -0.0350000, 109.3200000, 300, 'Bhiksu Dharmavira', '08144556678', NULL),
|
||||||
|
('Klenteng Tri Dharma', 'klenteng', 'Jl. Tanjungpura No. 23', 'Tanjungpura', 'Pontianak Barat', -0.0450000, 109.3350000, 300, 'Ibu Meri', '08155667789', NULL),
|
||||||
|
('Pura Jagadnatha', 'pura', 'Jl. Perdana No. 5', 'Perdana', 'Pontianak Tenggara',-0.0400000, 109.3280000, 350, 'Mangku Wayan', '08166778890', NULL);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3. HOUSEHOLDS (15 records, varied poverty levels)
|
||||||
|
-- Koordinat tersebar di sekitar Pontianak
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO `households`
|
||||||
|
(`rt`,`rw`,`kelurahan`,`kecamatan`,`full_address`,`latitude`,`longitude`,
|
||||||
|
`house_condition`,`land_ownership`,`head_name`,`head_nik`,`head_gender`,
|
||||||
|
`head_date_of_birth`,`head_education`,`head_employment_status`,
|
||||||
|
`head_job_name`,`head_monthly_income`,`poverty_score`,`poverty_status`,
|
||||||
|
`aid_status`,`managing_center_id`,`notes`) VALUES
|
||||||
|
|
||||||
|
-- sangat_miskin (score >= 50)
|
||||||
|
('001','001','Batu Layang','Pontianak Utara','Jl. Khatulistiwa Gg. 3 No. 5', -0.0315000, 109.3260000, 'tidak_layak','numpang', 'Hasan Basri', '6171010101800001','male', '1980-01-15','sd', 'unemployed', NULL, 350000, 72, 'sangat_miskin','not_yet', 1, 'Tinggal menumpang di tanah orang lain'),
|
||||||
|
('002','001','Batu Layang','Pontianak Utara','Jl. Khatulistiwa Gg. 5 No. 2', -0.0320000, 109.3270000, 'tidak_layak','numpang', 'Amiruddin', '6171010202790002','male', '1979-02-20','tidak_sekolah','unemployed', NULL, 280000, 85, 'sangat_miskin','not_yet', 1, NULL),
|
||||||
|
('001','002','Tanjung Hulu','Pontianak Timur','Jl. Pahlawan Dalam No. 9', -0.0360000, 109.3290000, 'tidak_layak','sewa', 'Nurhayati', '6171020303820003','female','1982-03-03','sd', 'unemployed', NULL, 420000, 68, 'sangat_miskin','received', 2, 'Ibu tunggal, 5 anak'),
|
||||||
|
|
||||||
|
-- miskin (score 30-49)
|
||||||
|
('003','001','Batu Layang','Pontianak Utara','Jl. Khatulistiwa Gg. 2 No. 11', -0.0305000, 109.3255000, 'tidak_layak','milik', 'Ruslan Daeng', '6171010404780004','male', '1978-04-10','smp', 'daily_labor', 'Buruh harian', 850000, 45, 'miskin', 'not_yet', 1, NULL),
|
||||||
|
('002','002','Tanjung Hulu','Pontianak Timur','Jl. Pahlawan Raya No. 7', -0.0345000, 109.3275000, 'tidak_layak','sewa', 'Daeng Tawing', '6171020505750005','male', '1975-05-05','sd', 'daily_labor', 'Kuli bangunan',950000, 42, 'miskin', 'received', 2, NULL),
|
||||||
|
('001','001','Benuamelayu Darat','Pontianak Selatan','Jl. Katedral Gg. 3 No. 4', -0.0415000, 109.3310000, 'tidak_layak','numpang','Marlina Sari', '6171030606850006','female','1985-06-06','smp', 'unemployed', NULL, 600000, 50, 'miskin', 'not_yet', 3, 'Suami meninggal 2023'),
|
||||||
|
('002','003','Siantan Hilir','Pontianak Utara','Jl. Gusti Situt Mahmud No. 12', -0.0360000, 109.3190000, 'tidak_layak','sewa', 'Wahyu Prasetyo', '6171040707900007','male', '1990-07-07','smp', 'daily_labor', 'Tukang becak', 900000, 38, 'miskin', 'received', 4, NULL),
|
||||||
|
|
||||||
|
-- rentan_miskin (score 15-29)
|
||||||
|
('003','002','Siantan Hilir','Pontianak Utara','Jl. Bodhisattva Gg. 2 No. 8', -0.0370000, 109.3210000, 'layak', 'sewa', 'Syamsuddin', '6171030808880008','male', '1988-08-08','sma', 'self_employed','Pedagang kaki lima',1200000, 25,'rentan_miskin','not_yet', 4, NULL),
|
||||||
|
('002','001','Batu Layang','Pontianak Utara','Jl. Khatulistiwa Gg. 8 No. 6', -0.0310000, 109.3265000, 'layak', 'milik', 'Rahma Wati', '6171010909870009','female','1987-09-09','sma', 'self_employed','Warung kecil', 1500000, 20,'rentan_miskin','not_yet', 1, NULL),
|
||||||
|
('003','003','Tanjung Hulu','Pontianak Timur','Jl. Hertasning No. 22', -0.0355000, 109.3285000, 'layak', 'sewa', 'Arif Rahman', '6171021010850010','male', '1985-10-10','sma', 'employee', 'Satpam', 1800000, 18,'rentan_miskin','received', 2, NULL),
|
||||||
|
('001','004','Tanjungpura','Pontianak Barat','Jl. Tanjungpura Gg. 3 No. 9', -0.0440000, 109.3360000, 'layak', 'sewa', 'Dewi Sartika', '6171041111920011','female','1992-11-11','sma', 'employee', 'Kasir', 1600000, 22,'rentan_miskin','not_yet', 5, NULL),
|
||||||
|
|
||||||
|
-- terdata (score < 15)
|
||||||
|
('001','001','Benuamelayu Darat','Pontianak Selatan','Jl. Katedral Raya No. 3', -0.0425000, 109.3305000, 'layak', 'milik', 'Eko Setiawan', '6171031212800012','male', '1980-12-12','sma', 'employee', 'Pegawai swasta',2500000, 8, 'terdata', 'not_yet', 3, NULL),
|
||||||
|
('002','003','Tanjung Hulu','Pontianak Timur','Jl. Pahlawan Baru No. 15', -0.0340000, 109.3270000, 'layak', 'milik', 'Indah Permata', '6171020101930013','female','1993-01-01','diploma', 'employee', 'Perawat', 2800000, 5, 'terdata', 'not_yet', 2, NULL),
|
||||||
|
('001','002','Siantan Hilir','Pontianak Utara','Jl. Bodhisattva No. 4', -0.0380000, 109.3220000, 'layak', 'milik', 'Firmansyah', '6171030202820014','male', '1982-02-02','sarjana', 'employee', 'Guru SMP', 3200000, 3, 'terdata', 'not_yet', 4, NULL),
|
||||||
|
('003','001','Tanjungpura','Pontianak Barat','Jl. Tri Dharma No. 20', -0.0460000, 109.3340000, 'layak', 'milik', 'Sari Bulan', '6171040303850015','female','1985-03-03','sarjana', 'employee', 'Bidan', 3500000, 2, 'terdata', 'not_yet', 5, NULL);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 4. HOUSEHOLD MEMBERS (anggota keluarga)
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO `household_members`
|
||||||
|
(`household_id`,`name`,`nik`,`gender`,`date_of_birth`,`education`,`relationship`,`employment_status`,`monthly_income`) VALUES
|
||||||
|
-- Household 1 (Hasan Basri — sangat_miskin)
|
||||||
|
(1, 'Salmah', '6171010101820101', 'female', '1982-03-10', 'sd', 'istri', 'unemployed', 0),
|
||||||
|
(1, 'Muhammad Rizki', '6171010101050102', 'male', '2005-06-15', 'smp', 'anak', 'unemployed', 0),
|
||||||
|
(1, 'Nur Aisyah', '6171010101080103', 'female', '2008-09-20', 'sd', 'anak', 'unemployed', 0),
|
||||||
|
|
||||||
|
-- Household 2 (Amiruddin — sangat_miskin)
|
||||||
|
(2, 'Rohani', '6171010202810201', 'female', '1981-05-12', 'tidak_sekolah','istri', 'unemployed', 0),
|
||||||
|
(2, 'Akbar', '6171010202030202', 'male', '2003-08-25', 'sd', 'anak', 'daily_labor', 500000),
|
||||||
|
(2, 'Mariam', '6171010202060203', 'female', '2006-11-30', 'sd', 'anak', 'unemployed', 0),
|
||||||
|
(2, 'Sulastri', '6171010202100204', 'female', '2010-02-14', 'sd', 'anak', 'unemployed', 0),
|
||||||
|
(2, 'Hendra', '6171010202130205', 'male', '2013-07-07', 'sd', 'anak', 'unemployed', 0),
|
||||||
|
|
||||||
|
-- Household 3 (Nurhayati — sangat_miskin, single mother)
|
||||||
|
(3, 'Fitri', '6171020303050301', 'female', '2005-01-10', 'smp', 'anak', 'unemployed', 0),
|
||||||
|
(3, 'Fathur', '6171020303080302', 'male', '2008-04-22', 'sd', 'anak', 'unemployed', 0),
|
||||||
|
(3, 'Zulaikha', '6171020303110303', 'female', '2011-09-05', 'sd', 'anak', 'unemployed', 0),
|
||||||
|
(3, 'Ilham', '6171020303140304', 'male', '2014-12-18', 'sd', 'anak', 'unemployed', 0),
|
||||||
|
(3, 'Rania', '6171020303180305', 'female', '2018-03-28', 'sd', 'anak', 'unemployed', 0),
|
||||||
|
|
||||||
|
-- Household 4 (Ruslan Daeng — miskin)
|
||||||
|
(4, 'Hasnah', '6171010404810401', 'female', '1981-07-14', 'sd', 'istri', 'unemployed', 0),
|
||||||
|
(4, 'Bagas', '6171010404030402', 'male', '2003-10-25', 'smp', 'anak', 'daily_labor', 600000),
|
||||||
|
(4, 'Nabila', '6171010404060403', 'female', '2006-02-08', 'smp', 'anak', 'unemployed', 0),
|
||||||
|
|
||||||
|
-- Household 8 (Syamsuddin — rentan_miskin)
|
||||||
|
(8, 'Sri Wahyuni', '6171030808900801', 'female', '1990-04-11', 'sma', 'istri', 'self_employed',800000),
|
||||||
|
(8, 'Alif', '6171030808150802', 'male', '2015-08-19', 'sd', 'anak', 'unemployed', 0),
|
||||||
|
|
||||||
|
-- Household 12 (Eko Setiawan — terdata)
|
||||||
|
(12,'Lina Karlina', '6171031212820121', 'female', '1982-06-30', 'sma', 'istri', 'employee', 1800000),
|
||||||
|
(12,'Kevin Eko', '6171031212100122', 'male', '2010-11-11', 'sd', 'anak', 'unemployed', 0);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 5. AID HISTORY (bantuan untuk beberapa rumah tangga)
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO `aid_history`
|
||||||
|
(`household_id`,`center_id`,`aid_type`,`aid_date`,`amount`,`description`,`given_by`) VALUES
|
||||||
|
(3, 2, 'sembako', '2025-01-15', NULL, 'Paket sembako Ramadan 2025', 1),
|
||||||
|
(3, 2, 'pendanaan', '2025-03-20', 500000, 'Bantuan biaya sekolah anak', 2),
|
||||||
|
(5, 2, 'sembako', '2025-02-10', NULL, 'Paket sembako bulanan Februari', 2),
|
||||||
|
(7, 4, 'pelatihan', '2025-04-05', NULL, 'Pelatihan keterampilan menjahit', 3),
|
||||||
|
(10, 2, 'sembako_pendanaan', '2025-05-01', 750000, 'Bantuan kombinasi lebaran', 1),
|
||||||
|
(1, 1, 'sembako', '2025-06-01', NULL, 'Paket sembako Juni', 2),
|
||||||
|
(2, 1, 'lengkap', '2025-01-10', 1000000, 'Bantuan lengkap awal tahun', 1),
|
||||||
|
(4, 1, 'pendanaan', '2025-06-05', 300000, 'Subsidi biaya pengobatan', 3);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 6. EMERGENCY REPORTS (laporan darurat)
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO `emergency_reports`
|
||||||
|
(`household_id`,`type`,`severity`,`description`,`status`,`resolved_at`) VALUES
|
||||||
|
(2, 'sakit', 'kritis', 'Kepala keluarga menderita stroke, butuh bantuan biaya pengobatan segera.', 'open', NULL),
|
||||||
|
(3, 'kehilangan_pekerjaan', 'berat', 'Ibu tunggal kehilangan sumber pendapatan akibat warung tutup karena banjir.', 'in_progress', NULL),
|
||||||
|
(1, 'bencana', 'sedang', 'Atap rumah bocor parah akibat hujan deras, 3 anak tinggal di kondisi tidak layak.', 'resolved', '2025-05-20 10:00:00'),
|
||||||
|
(6, 'kematian', 'berat', 'Suami meninggal mendadak, istri tidak memiliki penghasilan.', 'resolved', '2025-03-15 14:30:00');
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 7. PUBLIC REPORTS (laporan dari masyarakat Pontianak)
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO `public_reports`
|
||||||
|
(`reporter_name`,`reporter_phone`,`rt`,`rw`,`head_name`,`address`,`kelurahan`,`kecamatan`,
|
||||||
|
`latitude`,`longitude`,`description`,`severity`,`urgent_need`,`proof_photos`,
|
||||||
|
`status`,`admin_notes`,`reviewed_at`,`converted_household_id`,`ip_address`) VALUES
|
||||||
|
|
||||||
|
('Ahmad Fauzi', '081234567890', '005','002',
|
||||||
|
'Pak Dullah',
|
||||||
|
'Jl. Khatulistiwa Gg. 3 No. 7', 'Batu Layang', 'Pontianak Utara',
|
||||||
|
-0.0325000, 109.3265000,
|
||||||
|
'Keluarga ini tinggal di gubuk reyot dengan 6 anak. Tidak ada pekerjaan tetap dan sering kelaparan. Butuh bantuan segera.',
|
||||||
|
'kritis', 'Sembako dan biaya renovasi rumah',
|
||||||
|
'["reports_1_abc123.jpg"]',
|
||||||
|
'approved', 'Data sudah diverifikasi dan diinput sebagai rumah tangga baru.',
|
||||||
|
'2025-04-10 09:30:00', NULL, '192.168.1.10'),
|
||||||
|
|
||||||
|
('Dewi Lestari', '082345678901', '001','003',
|
||||||
|
'Ibu Saodah',
|
||||||
|
'Jl. Tanjungpura Gg. 5 No. 2', 'Tanjungpura', 'Pontianak Barat',
|
||||||
|
-0.0445000, 109.3355000,
|
||||||
|
'Nenek tua hidup sebatang kara, tidak punya penghasilan. Rumah hampir roboh.',
|
||||||
|
'berat', 'Bantuan bahan makanan dan perbaikan rumah',
|
||||||
|
'["reports_2_def456.jpg","reports_2_ghi789.jpg"]',
|
||||||
|
'pending', NULL, NULL, NULL, '192.168.1.20'),
|
||||||
|
|
||||||
|
('Rudi Hermawan', '083456789012', '003','001',
|
||||||
|
'Keluarga Pak Samson',
|
||||||
|
'Jl. Pahlawan Gg. 2 No. 8', 'Tanjung Hulu', 'Pontianak Timur',
|
||||||
|
-0.0355000, 109.3280000,
|
||||||
|
'Keluarga dengan 4 anak kecil, penghasilan tidak mencukupi untuk makan sehari-hari.',
|
||||||
|
'sedang', 'Bantuan sembako rutin',
|
||||||
|
NULL,
|
||||||
|
'rejected', 'Data tidak dapat diverifikasi, koordinat tidak sesuai lokasi.',
|
||||||
|
'2025-05-01 14:00:00', NULL, '10.0.0.5');
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 8. AUDIT LOGS (sample entries)
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO `audit_logs`
|
||||||
|
(`action`,`table_name`,`record_id`,`old_values`,`new_values`,`ip_address`,`user_agent`) VALUES
|
||||||
|
('auth.login', 'users', 1, NULL, '{"email":"admin@povertymapping.id"}', '127.0.0.1', 'Mozilla/5.0'),
|
||||||
|
('Tambah Rumah', 'households', 1, NULL, '{"head_name":"Hasan Basri"}', '127.0.0.1', 'Mozilla/5.0'),
|
||||||
|
('Tambah Rumah', 'households', 2, NULL, '{"head_name":"Amiruddin"}', '127.0.0.1', 'Mozilla/5.0'),
|
||||||
|
('Catat Bantuan', 'aid_history', 1, NULL, '{"aid_type":"sembako","amount":null}', '127.0.0.1', 'Mozilla/5.0'),
|
||||||
|
('auth.login', 'users', 2, NULL, '{"email":"budi@povertymapping.id"}', '192.168.1.1', 'Mozilla/5.0'),
|
||||||
|
('Tambah Laporan Darurat','emergency_reports',1,NULL,'{"type":"sakit","severity":"kritis"}','192.168.1.1','Mozilla/5.0');
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- VERIFIKASI (Cek jumlah data)
|
||||||
|
-- ============================================================
|
||||||
|
SELECT 'users' AS `table`, COUNT(*) AS row_count FROM users
|
||||||
|
UNION ALL SELECT 'religious_centers', COUNT(*) FROM religious_centers
|
||||||
|
UNION ALL SELECT 'households', COUNT(*) FROM households
|
||||||
|
UNION ALL SELECT 'household_members', COUNT(*) FROM household_members
|
||||||
|
UNION ALL SELECT 'aid_history', COUNT(*) FROM aid_history
|
||||||
|
UNION ALL SELECT 'emergency_reports', COUNT(*) FROM emergency_reports
|
||||||
|
UNION ALL SELECT 'public_reports', COUNT(*) FROM public_reports
|
||||||
|
UNION ALL SELECT 'audit_logs', COUNT(*) FROM audit_logs;
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- structure.sql — Full Schema for poverty_mapping
|
||||||
|
-- Database: poverty_mapping
|
||||||
|
-- Charset: utf8mb4 / utf8mb4_unicode_ci
|
||||||
|
-- Generated by: DB Reconstruction from source code analysis
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||||
|
SET time_zone = "+07:00";
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- CREATE DATABASE
|
||||||
|
-- ============================================================
|
||||||
|
CREATE DATABASE IF NOT EXISTS `poverty_mapping`
|
||||||
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE `poverty_mapping`;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE 1: users
|
||||||
|
-- Source: api/auth/check.php — SELECT id, name, email,
|
||||||
|
-- password_hash, role, is_active, last_login_at
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `users` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(150) NOT NULL,
|
||||||
|
`email` VARCHAR(255) NOT NULL,
|
||||||
|
`password_hash` VARCHAR(255) NOT NULL,
|
||||||
|
`role` ENUM('admin','field_officer','viewer') NOT NULL DEFAULT 'viewer',
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`last_login_at` TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_users_email` (`email`),
|
||||||
|
KEY `idx_users_role` (`role`),
|
||||||
|
KEY `idx_users_is_active` (`is_active`)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
DEFAULT CHARSET=utf8mb4
|
||||||
|
COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE 2: religious_centers
|
||||||
|
-- Source: api/centers/index.php — worship_type, address,
|
||||||
|
-- kelurahan, kecamatan, latitude, longitude, radius,
|
||||||
|
-- contact_person, contact_phone, notes, is_active
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `religious_centers` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`worship_type` ENUM('masjid','gereja','klenteng','pura','vihara') NOT NULL DEFAULT 'masjid',
|
||||||
|
`address` TEXT NOT NULL,
|
||||||
|
`kelurahan` VARCHAR(100) NULL DEFAULT NULL,
|
||||||
|
`kecamatan` VARCHAR(100) NULL DEFAULT NULL,
|
||||||
|
`latitude` DECIMAL(10,7) NOT NULL,
|
||||||
|
`longitude` DECIMAL(10,7) NOT NULL,
|
||||||
|
`radius` INT UNSIGNED NOT NULL DEFAULT 300 COMMENT 'Coverage radius in meters',
|
||||||
|
`contact_person` VARCHAR(150) NULL DEFAULT NULL,
|
||||||
|
`contact_phone` VARCHAR(20) NULL DEFAULT NULL,
|
||||||
|
`notes` TEXT NULL DEFAULT NULL,
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_rc_worship_type` (`worship_type`),
|
||||||
|
KEY `idx_rc_is_active` (`is_active`),
|
||||||
|
KEY `idx_rc_location` (`latitude`, `longitude`)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
DEFAULT CHARSET=utf8mb4
|
||||||
|
COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE 3: households
|
||||||
|
-- Source: api/houses/index.php — the main data table.
|
||||||
|
-- Columns verified from INSERT, UPDATE, SELECT queries.
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `households` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
|
||||||
|
-- Location (administrative)
|
||||||
|
`rt` VARCHAR(10) NULL DEFAULT NULL,
|
||||||
|
`rw` VARCHAR(10) NULL DEFAULT NULL,
|
||||||
|
`kelurahan` VARCHAR(100) NULL DEFAULT NULL,
|
||||||
|
`kecamatan` VARCHAR(100) NULL DEFAULT NULL,
|
||||||
|
`full_address` TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Spatial coordinates
|
||||||
|
`latitude` DECIMAL(10,7) NOT NULL,
|
||||||
|
`longitude` DECIMAL(10,7) NOT NULL,
|
||||||
|
|
||||||
|
-- House characteristics
|
||||||
|
`house_condition` ENUM('layak','tidak_layak') NOT NULL DEFAULT 'layak',
|
||||||
|
`land_ownership` ENUM('milik','sewa','numpang','lainnya') NOT NULL DEFAULT 'milik',
|
||||||
|
`house_photos` JSON NULL DEFAULT NULL COMMENT 'JSON array of photo filenames',
|
||||||
|
|
||||||
|
-- Household head info
|
||||||
|
`head_name` VARCHAR(150) NOT NULL,
|
||||||
|
`head_nik` VARCHAR(16) NULL DEFAULT NULL,
|
||||||
|
`head_gender` ENUM('male','female') NOT NULL DEFAULT 'male',
|
||||||
|
`head_date_of_birth` DATE NULL DEFAULT NULL,
|
||||||
|
`head_education` ENUM('tidak_sekolah','sd','smp','sma','diploma','sarjana','pascasarjana') NOT NULL DEFAULT 'sd',
|
||||||
|
`head_employment_status` VARCHAR(50) NOT NULL DEFAULT 'unemployed',
|
||||||
|
`head_job_name` VARCHAR(150) NULL DEFAULT NULL,
|
||||||
|
`head_institution_name` VARCHAR(200) NULL DEFAULT NULL,
|
||||||
|
`head_monthly_income` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Poverty assessment
|
||||||
|
`poverty_score` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0-100 poverty score',
|
||||||
|
`poverty_status` ENUM('terdata','rentan_miskin','miskin','sangat_miskin') NOT NULL DEFAULT 'terdata',
|
||||||
|
|
||||||
|
-- Aid tracking
|
||||||
|
`aid_status` ENUM('not_yet','received') NOT NULL DEFAULT 'not_yet',
|
||||||
|
|
||||||
|
-- Relations
|
||||||
|
`managing_center_id` INT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
-- Meta
|
||||||
|
`notes` TEXT NULL DEFAULT NULL,
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_hh_poverty_status` (`poverty_status`),
|
||||||
|
KEY `idx_hh_aid_status` (`aid_status`),
|
||||||
|
KEY `idx_hh_house_condition` (`house_condition`),
|
||||||
|
KEY `idx_hh_managing_center` (`managing_center_id`),
|
||||||
|
KEY `idx_hh_is_active` (`is_active`),
|
||||||
|
KEY `idx_hh_location` (`latitude`, `longitude`),
|
||||||
|
KEY `idx_hh_head_nik` (`head_nik`),
|
||||||
|
KEY `idx_hh_created_at` (`created_at`),
|
||||||
|
CONSTRAINT `fk_hh_managing_center`
|
||||||
|
FOREIGN KEY (`managing_center_id`)
|
||||||
|
REFERENCES `religious_centers` (`id`)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
DEFAULT CHARSET=utf8mb4
|
||||||
|
COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE 4: household_members
|
||||||
|
-- Source: api/houses/index.php — saveMembers() function &
|
||||||
|
-- SELECT * FROM household_members in SHOW action.
|
||||||
|
-- Stats: SELECT COUNT(*) FROM household_members
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `household_members` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`household_id` INT UNSIGNED NOT NULL,
|
||||||
|
`name` VARCHAR(150) NOT NULL,
|
||||||
|
`nik` VARCHAR(16) NULL DEFAULT NULL,
|
||||||
|
`gender` ENUM('male','female') NOT NULL DEFAULT 'male',
|
||||||
|
`date_of_birth` DATE NULL DEFAULT NULL,
|
||||||
|
`education` ENUM('tidak_sekolah','sd','smp','sma','diploma','sarjana','pascasarjana') NOT NULL DEFAULT 'sd',
|
||||||
|
`relationship` VARCHAR(50) NOT NULL DEFAULT 'lainnya'
|
||||||
|
COMMENT 'e.g. suami,istri,anak,orang_tua,lainnya',
|
||||||
|
`employment_status` VARCHAR(50) NOT NULL DEFAULT 'unemployed',
|
||||||
|
`job_name` VARCHAR(150) NULL DEFAULT NULL,
|
||||||
|
`institution_name` VARCHAR(200) NULL DEFAULT NULL,
|
||||||
|
`monthly_income` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_hm_household_id` (`household_id`),
|
||||||
|
KEY `idx_hm_nik` (`nik`),
|
||||||
|
CONSTRAINT `fk_hm_household`
|
||||||
|
FOREIGN KEY (`household_id`)
|
||||||
|
REFERENCES `households` (`id`)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
DEFAULT CHARSET=utf8mb4
|
||||||
|
COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE 5: aid_history
|
||||||
|
-- Source: api/aid/index.php — INSERT/UPDATE/SELECT queries.
|
||||||
|
-- Columns: household_id, center_id, aid_type, aid_date,
|
||||||
|
-- amount, description, given_by
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `aid_history` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`household_id` INT UNSIGNED NOT NULL,
|
||||||
|
`center_id` INT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
`aid_type` ENUM(
|
||||||
|
'sembako',
|
||||||
|
'pendanaan',
|
||||||
|
'pelatihan',
|
||||||
|
'sembako_pendanaan',
|
||||||
|
'sembako_pelatihan',
|
||||||
|
'pendanaan_pelatihan',
|
||||||
|
'lengkap'
|
||||||
|
) NOT NULL,
|
||||||
|
`aid_date` DATE NOT NULL,
|
||||||
|
`amount` INT UNSIGNED NULL DEFAULT NULL COMMENT 'Monetary value in IDR (optional)',
|
||||||
|
`description` TEXT NULL DEFAULT NULL COMMENT 'Notes / keterangan bantuan',
|
||||||
|
`given_by` INT UNSIGNED NULL DEFAULT NULL COMMENT 'FK to users.id (who recorded the aid)',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_ah_household_id` (`household_id`),
|
||||||
|
KEY `idx_ah_center_id` (`center_id`),
|
||||||
|
KEY `idx_ah_aid_date` (`aid_date`),
|
||||||
|
KEY `idx_ah_aid_type` (`aid_type`),
|
||||||
|
CONSTRAINT `fk_ah_household`
|
||||||
|
FOREIGN KEY (`household_id`)
|
||||||
|
REFERENCES `households` (`id`)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT `fk_ah_center`
|
||||||
|
FOREIGN KEY (`center_id`)
|
||||||
|
REFERENCES `religious_centers` (`id`)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT `fk_ah_given_by`
|
||||||
|
FOREIGN KEY (`given_by`)
|
||||||
|
REFERENCES `users` (`id`)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
DEFAULT CHARSET=utf8mb4
|
||||||
|
COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE 6: emergency_reports
|
||||||
|
-- Source: api/reports/index.php — INSERT, UPDATE, SELECT.
|
||||||
|
-- Columns: household_id, type, severity, description,
|
||||||
|
-- status, resolved_at
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `emergency_reports` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`household_id` INT UNSIGNED NOT NULL,
|
||||||
|
`type` ENUM('sakit','kecelakaan','bencana','kehilangan_pekerjaan','kematian','lainnya') NOT NULL,
|
||||||
|
`severity` ENUM('ringan','sedang','berat','kritis') NOT NULL DEFAULT 'ringan',
|
||||||
|
`description` TEXT NOT NULL,
|
||||||
|
`status` ENUM('open','in_progress','resolved','closed') NOT NULL DEFAULT 'open',
|
||||||
|
`resolved_at` TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_er_household_id` (`household_id`),
|
||||||
|
KEY `idx_er_status` (`status`),
|
||||||
|
KEY `idx_er_severity` (`severity`),
|
||||||
|
KEY `idx_er_created_at` (`created_at`),
|
||||||
|
CONSTRAINT `fk_er_household`
|
||||||
|
FOREIGN KEY (`household_id`)
|
||||||
|
REFERENCES `households` (`id`)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
DEFAULT CHARSET=utf8mb4
|
||||||
|
COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE 7: public_reports
|
||||||
|
-- Source: api/public/report.php — INSERT, SELECT, UPDATE.
|
||||||
|
-- Columns: reporter_name, reporter_phone, rt, rw,
|
||||||
|
-- head_name, address, kelurahan, kecamatan,
|
||||||
|
-- latitude, longitude, description, severity,
|
||||||
|
-- urgent_need, status, proof_photos,
|
||||||
|
-- ip_address, converted_household_id,
|
||||||
|
-- admin_notes, reviewed_at
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `public_reports` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`reporter_name` VARCHAR(150) NOT NULL,
|
||||||
|
`reporter_phone` VARCHAR(20) NOT NULL,
|
||||||
|
`rt` VARCHAR(10) NOT NULL DEFAULT '',
|
||||||
|
`rw` VARCHAR(10) NOT NULL DEFAULT '',
|
||||||
|
`head_name` VARCHAR(150) NOT NULL,
|
||||||
|
`address` TEXT NOT NULL,
|
||||||
|
`kelurahan` VARCHAR(100) NOT NULL,
|
||||||
|
`kecamatan` VARCHAR(100) NOT NULL,
|
||||||
|
`latitude` DECIMAL(10,7) NOT NULL,
|
||||||
|
`longitude` DECIMAL(10,7) NOT NULL,
|
||||||
|
`description` TEXT NOT NULL,
|
||||||
|
`severity` ENUM('ringan','sedang','berat','kritis') NOT NULL DEFAULT 'ringan',
|
||||||
|
`urgent_need` TEXT NULL DEFAULT NULL,
|
||||||
|
`proof_photos` JSON NULL DEFAULT NULL COMMENT 'JSON array of photo filenames',
|
||||||
|
`status` ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending',
|
||||||
|
`admin_notes` TEXT NULL DEFAULT NULL,
|
||||||
|
`reviewed_at` TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
`converted_household_id` INT UNSIGNED NULL DEFAULT NULL
|
||||||
|
COMMENT 'Set when admin approves and creates household',
|
||||||
|
`ip_address` VARCHAR(45) NOT NULL DEFAULT '' COMMENT 'Supports IPv6',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_pr_status` (`status`),
|
||||||
|
KEY `idx_pr_severity` (`severity`),
|
||||||
|
KEY `idx_pr_ip_address` (`ip_address`),
|
||||||
|
KEY `idx_pr_created_at` (`created_at`),
|
||||||
|
KEY `idx_pr_converted` (`converted_household_id`),
|
||||||
|
CONSTRAINT `fk_pr_converted_household`
|
||||||
|
FOREIGN KEY (`converted_household_id`)
|
||||||
|
REFERENCES `households` (`id`)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
DEFAULT CHARSET=utf8mb4
|
||||||
|
COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE 8: audit_logs
|
||||||
|
-- Source: models/AuditLog.php — INSERT INTO audit_logs
|
||||||
|
-- (action, table_name, record_id, old_values,
|
||||||
|
-- new_values, ip_address, user_agent)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `audit_logs` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`action` VARCHAR(100) NOT NULL COMMENT 'e.g. auth.login, Tambah Rumah, Update Bantuan',
|
||||||
|
`table_name` VARCHAR(100) NOT NULL,
|
||||||
|
`record_id` INT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
`old_values` JSON NULL DEFAULT NULL,
|
||||||
|
`new_values` JSON NULL DEFAULT NULL,
|
||||||
|
`ip_address` VARCHAR(45) NOT NULL DEFAULT '',
|
||||||
|
`user_agent` TEXT NULL DEFAULT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_al_table_name` (`table_name`),
|
||||||
|
KEY `idx_al_record_id` (`record_id`),
|
||||||
|
KEY `idx_al_action` (`action`),
|
||||||
|
KEY `idx_al_created_at` (`created_at`)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
DEFAULT CHARSET=utf8mb4
|
||||||
|
COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,778 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<title>WebGIS Dashboard — Manajemen Data</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
|
<style>
|
||||||
|
/* ── Inline component styles not yet in style.css ── */
|
||||||
|
|
||||||
|
/* Dependent entry */
|
||||||
|
.dependent-entry {
|
||||||
|
background:var(--surface-2); border:1px solid var(--border-subtle);
|
||||||
|
border-radius:var(--r); padding:10px 12px; margin-bottom:8px; position:relative;
|
||||||
|
}
|
||||||
|
.dependent-entry .dependent-number {
|
||||||
|
font-size:9px; font-weight:700; text-transform:uppercase;
|
||||||
|
letter-spacing:0.05em; color:var(--accent); margin-bottom:8px;
|
||||||
|
}
|
||||||
|
.dependent-entry .btn-remove-dependent {
|
||||||
|
position:absolute; top:8px; right:8px;
|
||||||
|
width:26px; height:26px; border-radius:50%;
|
||||||
|
border:1px solid var(--danger); background:#fff;
|
||||||
|
color:var(--danger); cursor:pointer; font-size:10px;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
transition:all 0.15s; touch-action:manipulation;
|
||||||
|
}
|
||||||
|
.dependent-entry .btn-remove-dependent:hover { background:#fff4f4; }
|
||||||
|
.dependent-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||||
|
.dependent-grid .form-group { margin-bottom:6px; }
|
||||||
|
.dependent-grid .form-group label { font-size:9.5px; margin-bottom:2px; }
|
||||||
|
.dependent-grid .form-group input,
|
||||||
|
.dependent-grid .form-group select { padding:7px 8px; font-size:14px; min-height:38px; }
|
||||||
|
.dependent-grid .form-group-full { grid-column:1/-1; }
|
||||||
|
#dependentsContainer { max-height:260px; overflow-y:auto; padding-right:4px; -webkit-overflow-scrolling:touch; }
|
||||||
|
#dependentsContainer::-webkit-scrollbar { width:3px; }
|
||||||
|
#dependentsContainer::-webkit-scrollbar-thumb { background:var(--border); border-radius:4px; }
|
||||||
|
|
||||||
|
/* Family member list */
|
||||||
|
.family-members-section { margin-top:14px; border-top:1px solid var(--border-subtle); padding-top:12px; }
|
||||||
|
.family-members-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; }
|
||||||
|
.family-members-header h4 { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-secondary); }
|
||||||
|
.btn-add-member { padding:6px 12px; font-size:11px; background:var(--success); color:white; border:none; border-radius:20px; cursor:pointer; min-height:32px; touch-action:manipulation; }
|
||||||
|
.family-member-list { max-height:190px; overflow-y:auto; -webkit-overflow-scrolling:touch; }
|
||||||
|
.family-member-item { padding:9px 10px; border-bottom:1px solid var(--border-subtle); display:flex; justify-content:space-between; align-items:center; min-height:44px; }
|
||||||
|
.family-member-item:last-child { border-bottom:none; }
|
||||||
|
.family-member-info { flex:1; }
|
||||||
|
.family-member-name { font-weight:600; font-size:12px; }
|
||||||
|
.family-member-relation { font-size:10px; color:var(--text-muted); background:var(--surface-2); padding:2px 6px; border-radius:12px; margin-left:6px; }
|
||||||
|
.family-member-job, .family-member-study { font-size:9px; color:var(--text-muted); margin-left:6px; }
|
||||||
|
.family-member-actions { display:flex; gap:5px; }
|
||||||
|
.btn-edit-member, .btn-delete-member { background:none; border:none; cursor:pointer; font-size:12px; padding:5px 8px; border-radius:5px; min-height:32px; touch-action:manipulation; }
|
||||||
|
.btn-edit-member { color:var(--warning); }
|
||||||
|
.btn-delete-member { color:var(--danger); }
|
||||||
|
|
||||||
|
/* Pending badge */
|
||||||
|
.pending-badge { background:var(--danger); color:white; font-size:9px; font-weight:700; padding:1px 5px; border-radius:10px; margin-left:4px; display:inline-block; vertical-align:middle; }
|
||||||
|
.pending-badge:empty { display:none; }
|
||||||
|
|
||||||
|
/* Popup */
|
||||||
|
.popup-info { padding:14px 16px; }
|
||||||
|
.popup-info .popup-name { font-size:14px; font-weight:700; color:var(--text-primary); margin-bottom:2px; line-height:1.3; }
|
||||||
|
.popup-info .popup-hint { font-size:9.5px; color:var(--text-muted); margin-bottom:10px; }
|
||||||
|
.popup-info .popup-badges { display:flex; gap:5px; flex-wrap:wrap; margin-bottom:10px; padding-bottom:10px; border-bottom:1px solid var(--border-subtle); }
|
||||||
|
.popup-info .popup-badge { padding:3px 9px; border-radius:20px; font-size:9.5px; font-weight:700; white-space:nowrap; }
|
||||||
|
.popup-info .popup-row { display:flex; align-items:flex-start; gap:8px; margin-bottom:6px; font-size:12px; color:var(--text-secondary); }
|
||||||
|
.popup-info .popup-row i { font-size:10px; color:var(--text-muted); margin-top:2px; flex-shrink:0; width:12px; }
|
||||||
|
.popup-info .popup-row strong { color:var(--text-primary); font-weight:600; }
|
||||||
|
.popup-info .popup-section { margin-top:10px; padding:9px 11px; background:var(--surface-2); border-radius:var(--r-sm); border:1px solid var(--border-subtle); }
|
||||||
|
.popup-info .popup-section-label { font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-muted); margin-bottom:6px; }
|
||||||
|
.popup-actions { display:flex; gap:5px; margin-top:12px; padding-top:10px; border-top:1px solid var(--border-subtle); flex-wrap:wrap; }
|
||||||
|
.popup-actions .btn { margin:0; font-size:11px; padding:7px 10px; min-height:36px; }
|
||||||
|
|
||||||
|
/* Admin */
|
||||||
|
.admin-tab-panel.active { display:block; }
|
||||||
|
.admin-tab-panel { display:none; }
|
||||||
|
.btn-approve { background:var(--success); color:white; }
|
||||||
|
.btn-approve:hover { filter:brightness(0.92); }
|
||||||
|
.btn-reject { background:var(--danger); color:white; }
|
||||||
|
.btn-reject:hover { filter:brightness(0.92); }
|
||||||
|
|
||||||
|
/* Mobile sidebar toggle button */
|
||||||
|
.sidebar-toggle {
|
||||||
|
position:absolute; top:14px; left:14px; z-index:1001;
|
||||||
|
width:42px; height:42px; border-radius:var(--r);
|
||||||
|
background:var(--surface); border:1px solid var(--border);
|
||||||
|
box-shadow:var(--shadow-md); cursor:pointer;
|
||||||
|
display:none; align-items:center; justify-content:center;
|
||||||
|
color:var(--accent); font-size:17px;
|
||||||
|
transition:all 0.15s; touch-action:manipulation;
|
||||||
|
}
|
||||||
|
.sidebar-toggle:active { transform:scale(0.94); }
|
||||||
|
@media (max-width:768px) { .sidebar-toggle { display:flex; } }
|
||||||
|
|
||||||
|
/* Sidebar backdrop */
|
||||||
|
.sidebar-backdrop {
|
||||||
|
display:none; position:fixed; inset:0;
|
||||||
|
background:rgba(10,15,28,0.45); z-index:999;
|
||||||
|
backdrop-filter:blur(2px); -webkit-backdrop-filter:blur(2px);
|
||||||
|
}
|
||||||
|
.sidebar-backdrop.active { display:block; }
|
||||||
|
|
||||||
|
/* Dashboard modal mobile close area */
|
||||||
|
#dashboardModal .modal-close-mobile {
|
||||||
|
display:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legend expand toggle on mobile */
|
||||||
|
.legend-expand-btn {
|
||||||
|
background:none; border:none; cursor:pointer;
|
||||||
|
color:var(--text-muted); font-size:10px; padding:0;
|
||||||
|
margin-left:6px; touch-action:manipulation;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ===== MAP ===== -->
|
||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
<!-- ===== MOBILE SIDEBAR TOGGLE ===== -->
|
||||||
|
<button class="sidebar-toggle" id="sidebarToggle" onclick="toggleMobileSidebar()" aria-label="Menu">
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Sidebar backdrop for mobile -->
|
||||||
|
<div class="sidebar-backdrop" id="sidebarBackdrop" onclick="closeMobileSidebar()"></div>
|
||||||
|
|
||||||
|
<!-- ===== SIDEBAR ===== -->
|
||||||
|
<div class="sidebar" id="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="sidebar-logo"><i class="fas fa-map-marked-alt"></i></div>
|
||||||
|
<div class="sidebar-title">
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<p>Poverty Mapping</p>
|
||||||
|
</div>
|
||||||
|
<div style="margin-left:auto; display:flex; align-items:center; gap:2px;">
|
||||||
|
<button class="btn-admin-panel" onclick="openAdminPanel()" style="margin:0;padding:6px 10px;font-size:11px;min-height:36px;">
|
||||||
|
<i class="fas fa-cog"></i> <span class="hide-xs">Laporan</span>
|
||||||
|
</button>
|
||||||
|
<button class="logout-btn" onclick="logout()" title="Logout">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<div class="nav-tabs">
|
||||||
|
<div class="nav-tab active" data-tab="overview"><i class="fas fa-eye"></i> <span class="tab-label">Ringkasan</span></div>
|
||||||
|
<div class="nav-tab" data-tab="centers"><i class="fas fa-place-of-worship"></i> <span class="tab-label">Ibadah</span></div>
|
||||||
|
<div class="nav-tab" data-tab="houses"><i class="fas fa-home"></i> <span class="tab-label">Rumah</span></div>
|
||||||
|
<div class="nav-tab" data-tab="dashboard"><i class="fas fa-chart-bar"></i> <span class="tab-label">Chart</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card purple">
|
||||||
|
<div class="stat-icon"><i class="fas fa-place-of-worship"></i></div>
|
||||||
|
<div class="stat-value" id="statCenters">0</div>
|
||||||
|
<div class="stat-label">Tempat Ibadah</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card green">
|
||||||
|
<div class="stat-icon"><i class="fas fa-home"></i></div>
|
||||||
|
<div class="stat-value" id="statHouses">0</div>
|
||||||
|
<div class="stat-label">Rumah</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card orange">
|
||||||
|
<div class="stat-icon"><i class="fas fa-users"></i></div>
|
||||||
|
<div class="stat-value" id="statPopulation">0</div>
|
||||||
|
<div class="stat-label">Tanggungan</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card teal">
|
||||||
|
<div class="stat-icon"><i class="fas fa-hand-holding-heart"></i></div>
|
||||||
|
<div class="stat-value" id="statAided">0</div>
|
||||||
|
<div class="stat-label">Terima Bantuan</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card red" id="cardPending">
|
||||||
|
<div class="stat-icon"><i class="fas fa-flag"></i></div>
|
||||||
|
<div class="stat-value" id="statPending">0</div>
|
||||||
|
<div class="stat-label">Laporan Publik</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-box">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<input type="text" id="searchInput" placeholder="Cari nama, alamat, NIK...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-section">
|
||||||
|
<div class="filter-title">Filter Tampilan</div>
|
||||||
|
<div class="filter-buttons">
|
||||||
|
<button class="filter-btn active" data-filter="all">Semua</button>
|
||||||
|
<button class="filter-btn" data-filter="centers">Ibadah</button>
|
||||||
|
<button class="filter-btn" data-filter="houses">Rumah</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dataListsContainer">
|
||||||
|
<div class="data-section" id="centersListSection">
|
||||||
|
<div class="section-title">
|
||||||
|
<span><i class="fas fa-place-of-worship"></i> Tempat Ibadah</span>
|
||||||
|
<span class="count-badge" id="centerCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="data-list" id="centersList">
|
||||||
|
<div class="empty-state"><i class="fas fa-place-of-worship"></i><p>Belum ada data</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-section" id="housesListSection">
|
||||||
|
<div class="section-title">
|
||||||
|
<span><i class="fas fa-home"></i> Rumah</span>
|
||||||
|
<span class="count-badge" id="houseCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="data-list" id="housesList">
|
||||||
|
<div class="empty-state"><i class="fas fa-home"></i><p>Belum ada data</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== LEGEND (collapsible on mobile) ===== -->
|
||||||
|
<div class="legend" id="legend">
|
||||||
|
<h4 onclick="toggleLegend()">
|
||||||
|
Keterangan
|
||||||
|
<button class="legend-expand-btn" title="Toggle legend">
|
||||||
|
<i class="fas fa-chevron-up" id="legendChevron"></i>
|
||||||
|
</button>
|
||||||
|
</h4>
|
||||||
|
<div class="legend-content" id="legendContent">
|
||||||
|
<div class="legend-section-label">Tempat Ibadah</div>
|
||||||
|
<div class="legend-item"><div class="legend-icon" style="background:#1d6fa4;"><i class="fas fa-mosque"></i></div><span>Masjid</span></div>
|
||||||
|
<div class="legend-item"><div class="legend-icon" style="background:#7c3aed;"><i class="fas fa-church"></i></div><span>Gereja</span></div>
|
||||||
|
<div class="legend-item"><div class="legend-icon" style="background:#b45309;"><i class="fas fa-torii-gate"></i></div><span>Klenteng</span></div>
|
||||||
|
<div class="legend-item"><div class="legend-icon" style="background:#0e7f6e;"><i class="fas fa-om"></i></div><span>Pura</span></div>
|
||||||
|
<div class="legend-item"><div class="legend-icon" style="background:#a16207;"><i class="fas fa-dharmachakra"></i></div><span>Vihara</span></div>
|
||||||
|
<div class="legend-section-label" style="margin-top:8px;">Kategori Kemiskinan</div>
|
||||||
|
<div class="legend-poverty-item"><div class="legend-poverty-dot" style="background:#d63230;"></div><span>Sangat Miskin</span></div>
|
||||||
|
<div class="legend-poverty-item"><div class="legend-poverty-dot" style="background:#f76707;"></div><span>Miskin</span></div>
|
||||||
|
<div class="legend-poverty-item"><div class="legend-poverty-dot" style="background:#f59e0b;"></div><span>Rentan Miskin</span></div>
|
||||||
|
<div class="legend-poverty-item"><div class="legend-poverty-dot" style="background:#0b9e73;"></div><span>Terdata</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== LAYER CONTROL ===== -->
|
||||||
|
<div class="custom-layer-control">
|
||||||
|
<h4><i class="fas fa-layer-group"></i> <span>Layer</span></h4>
|
||||||
|
<div class="custom-layer-item" onclick="toggleLayer('centers')">
|
||||||
|
<input type="checkbox" id="layerCenters" checked>
|
||||||
|
<i class="fas fa-place-of-worship layer-icon layer-icon-center"></i>
|
||||||
|
<span>Tempat Ibadah</span>
|
||||||
|
<small id="layerCenterCount">0</small>
|
||||||
|
</div>
|
||||||
|
<div class="custom-layer-item" onclick="toggleLayer('houses')">
|
||||||
|
<input type="checkbox" id="layerHouses" checked>
|
||||||
|
<i class="fas fa-home layer-icon layer-icon-house"></i>
|
||||||
|
<span>Rumah</span>
|
||||||
|
<small id="layerHouseCount">0</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== LOADING ===== -->
|
||||||
|
<div class="loading" id="loading"><i class="fas fa-circle-notch fa-spin"></i><span>Memproses...</span></div>
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<button class="help-btn" id="helpBtn" title="Panduan Pengguna"><i class="fas fa-question-circle"></i></button>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
MODAL: Religious Center
|
||||||
|
================================================================ -->
|
||||||
|
<div class="modal-overlay" id="centerModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="centerModalTitle">Tambah Tempat Ibadah</h2>
|
||||||
|
<span class="modal-close" onclick="closeModal('centerModal')">×</span>
|
||||||
|
</div>
|
||||||
|
<form id="centerForm" novalidate>
|
||||||
|
<input type="hidden" id="centerId">
|
||||||
|
<input type="hidden" id="centerLat">
|
||||||
|
<input type="hidden" id="centerLng">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nama *</label>
|
||||||
|
<input type="text" id="centerName" required placeholder="Contoh: Masjid Al-Ikhlas">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Jenis Ibadah</label>
|
||||||
|
<select id="centerWorshipType">
|
||||||
|
<option value="masjid">Masjid</option><option value="gereja">Gereja</option>
|
||||||
|
<option value="klenteng">Klenteng</option><option value="pura">Pura</option><option value="vihara">Vihara</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Radius (m)</label>
|
||||||
|
<input type="number" id="centerRadius" min="50" max="5000" value="300" step="10">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Slider Radius</label>
|
||||||
|
<div class="slider-container">
|
||||||
|
<input type="range" id="centerRadiusSlider" min="50" max="5000" value="300" step="10">
|
||||||
|
<span class="slider-value" id="centerRadiusValue">300m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Kontak Person</label><input type="text" id="centerContactPerson" placeholder="Nama penanggung jawab"></div>
|
||||||
|
<div class="form-group"><label>Nomor Telepon</label><input type="text" id="centerContactPhone" placeholder="08xx-xxxx-xxxx"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Catatan</label><textarea id="centerNotes" rows="2" placeholder="Informasi tambahan..."></textarea></div>
|
||||||
|
<div class="form-group"><label>Alamat</label><input type="text" id="centerAddress" readonly placeholder="Terisi otomatis dari peta"></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Latitude</label><input type="text" id="centerLatDisplay" readonly></div>
|
||||||
|
<div class="form-group"><label>Longitude</label><input type="text" id="centerLngDisplay" readonly></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> Simpan</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeModal('centerModal')">Batal</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
MODAL: Household
|
||||||
|
================================================================ -->
|
||||||
|
<div class="modal-overlay" id="houseModal">
|
||||||
|
<div class="modal" style="max-width:560px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="houseModalTitle">Tambah Rumah Tangga</h2>
|
||||||
|
<span class="modal-close" onclick="closeModal('houseModal')">×</span>
|
||||||
|
</div>
|
||||||
|
<form id="houseForm" novalidate>
|
||||||
|
<input type="hidden" id="houseId">
|
||||||
|
<input type="hidden" id="houseLat">
|
||||||
|
<input type="hidden" id="houseLng">
|
||||||
|
<input type="hidden" id="familyMembersData" value="[]">
|
||||||
|
|
||||||
|
<div class="poverty-preview" id="povertyPreview">
|
||||||
|
<div class="poverty-score-ring" id="povertyRing"><i id="povertyIcon" class="fas fa-circle" style="font-size:16px;"></i></div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div class="poverty-status-label" id="povertyStatusLabel">Belum dihitung</div>
|
||||||
|
<div class="poverty-indicators" id="povertyIndicators" style="font-size:9px;color:#5a6478;margin-top:4px;padding-left:0;list-style:none;max-height:70px;overflow-y:auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alamat -->
|
||||||
|
<div class="form-section" style="margin-bottom:14px;">
|
||||||
|
<div class="section-title" style="margin-bottom:8px;">Informasi Alamat</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>RT</label><input type="text" id="houseRt" maxlength="5" placeholder="01"></div>
|
||||||
|
<div class="form-group"><label>RW</label><input type="text" id="houseRw" maxlength="5" placeholder="02"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Kelurahan</label><input type="text" id="houseKelurahan" readonly></div>
|
||||||
|
<div class="form-group"><label>Kecamatan</label><input type="text" id="houseKecamatan" readonly></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Alamat Lengkap</label><textarea id="houseFullAddress" rows="2" placeholder="Alamat lengkap (dapat diedit manual)" style="background: var(--surface);"></textarea></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Pusat Pengelola</label><input type="text" id="managingCenterName" readonly placeholder="Otomatis terdeteksi"><input type="hidden" id="managingCenterId"></div>
|
||||||
|
<div class="form-group"><label>Kondisi Rumah</label><select id="houseCondition"><option value="layak">Layak</option><option value="tidak_layak">Tidak Layak</option></select></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Anggota Keluarga -->
|
||||||
|
<div class="form-section" style="margin-bottom:14px;">
|
||||||
|
<div class="section-title" style="margin-bottom:8px;">
|
||||||
|
<span>Anggota Keluarga</span>
|
||||||
|
<button type="button" class="btn-add-member" onclick="openFamilyMemberModal()"><i class="fas fa-plus"></i> Tambah</button>
|
||||||
|
</div>
|
||||||
|
<div id="familyMembersList" class="family-member-list">
|
||||||
|
<div class="empty-state" style="padding:16px;text-align:center;"><i class="fas fa-users"></i><p>Belum ada anggota keluarga</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form tabs -->
|
||||||
|
<div class="form-tabs">
|
||||||
|
<button type="button" class="form-tab active" data-ftab="identity">Identitas KK</button>
|
||||||
|
<button type="button" class="form-tab" data-ftab="economic">Ekonomi</button>
|
||||||
|
<button type="button" class="form-tab" data-ftab="aid">Bantuan</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-tab-panel active" id="ftab-identity">
|
||||||
|
<div class="form-group"><label>Nama Kepala Keluarga <span style="color:red;">*</span></label><input type="text" id="houseHeadName" required placeholder="Nama lengkap"></div>
|
||||||
|
<div class="field-error" id="errHouseHeadName" style="display: none; font-size: 11px; color: var(--danger); margin-top: 4px;">
|
||||||
|
<i class="fas fa-exclamation-circle"></i> Nama Kepala Keluarga wajib diisi.
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>NIK Kepala Keluarga <span style="color:red;">*</span></label><input type="text" id="houseNIK" maxlength="16" placeholder="16 digit NIK"></div>
|
||||||
|
<div class="field-error" id="errHouseNIK" style="display: none; font-size: 11px; color: var(--danger); margin-top: 4px;">
|
||||||
|
<i class="fas fa-exclamation-circle"></i> NIK Kepala Keluarga wajib diisi.
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Jenis Kelamin <span style="color:red;">*</span></label><select id="houseGender" required><option value="male">Laki-laki</option><option value="female">Perempuan</option></select></div>
|
||||||
|
<div class="field-error" id="errHouseGender" style="display: none; font-size: 11px; color: var(--danger); margin-top: 4px;">
|
||||||
|
<i class="fas fa-exclamation-circle"></i> Jenis Kelamin wajib diisi.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Tanggal Lahir Kepala Keluarga <span style="color:red;">*</span></label><input type="date" id="houseDOB" required></div>
|
||||||
|
<div class="field-error" id="errHouseDOB" style="display: none; font-size: 11px; color: var(--danger); margin-top: 4px;">
|
||||||
|
<i class="fas fa-exclamation-circle"></i> Tanggal Lahir Kepala Keluarga wajib diisi.
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Pendidikan Kepala Keluarga <span style="color:red;">*</span></label>
|
||||||
|
<select id="houseEducation" onchange="recalcPoverty()">
|
||||||
|
<option value="tidak_sekolah">Tidak Sekolah</option><option value="sd" selected>SD</option><option value="smp">SMP</option>
|
||||||
|
<option value="sma">SMA</option><option value="diploma">Diploma</option><option value="sarjana">Sarjana</option><option value="pascasarjana">Pascasarjana</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field-error" id="errHouseEducation" style="display: none; font-size: 11px; color: var(--danger); margin-top: 4px;">
|
||||||
|
<i class="fas fa-exclamation-circle"></i> Pendidikan Kepala Keluarga wajib diisi.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Status Pekerjaan <span style="color:red;">*</span></label>
|
||||||
|
<select id="houseEmploymentStatus" onchange="toggleHouseEmploymentFields()">
|
||||||
|
<option value="unemployed">Tidak Bekerja / Menganggur</option>
|
||||||
|
<option value="working">Bekerja</option>
|
||||||
|
<option value="studying">Sekolah / Kuliah</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Field untuk bekerja -->
|
||||||
|
<div class="form-group" id="houseJobGroup" style="display:none;">
|
||||||
|
<label>Nama Pekerjaan</label>
|
||||||
|
<input type="text" id="houseJob" placeholder="Contoh: Buruh Harian">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Field untuk sekolah -->
|
||||||
|
<div class="form-group" id="houseInstitutionGroup" style="display:none;">
|
||||||
|
<label>Nama Sekolah / Universitas</label>
|
||||||
|
<input type="text" id="houseInstitutionName" placeholder="Contoh: SMA Negeri 1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-tab-panel" id="ftab-economic">
|
||||||
|
<div class="form-group"><label>Pendapatan/Bulan (Rp)</label><input type="number" id="houseIncome" min="0" value="0" onchange="recalcPoverty()"></div>
|
||||||
|
<div class="form-group"><label>Kepemilikan Lahan</label><select id="houseLandOwnership"><option value="milik">Milik Sendiri</option><option value="sewa">Sewa</option><option value="numpang">Numpang</option><option value="lainnya">Lainnya</option></select></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-tab-panel" id="ftab-aid">
|
||||||
|
<div class="form-group"><label>Status Bantuan</label><select id="houseAidStatus"><option value="not_yet">Belum Terima Bantuan</option><option value="received">Sudah Terima Bantuan</option></select></div>
|
||||||
|
<div id="aidHistoryPanel" style="margin-top:12px;">
|
||||||
|
<div class="section-title" style="margin-bottom:8px;">
|
||||||
|
<span>Riwayat Bantuan</span>
|
||||||
|
<button type="button" class="btn btn-success btn-sm" id="addAidBtn" style="display:none;padding:5px 9px;font-size:10px;"><i class="fas fa-plus"></i> Catat</button>
|
||||||
|
</div>
|
||||||
|
<div id="aidHistoryList"><div class="empty-state" style="padding:14px;"><i class="fas fa-gift"></i><p>Belum ada riwayat bantuan</p></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group"><label>Keterangan</label><textarea id="houseDescription" rows="2" placeholder="Informasi tambahan..."></textarea></div>
|
||||||
|
<!-- Foto Rumah -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Foto Rumah <span style="font-weight:400;font-size:10.5px;color:var(--text-muted);">(maks. 5 foto · 5 MB)</span></label>
|
||||||
|
<div class="photo-upload-zone" id="housePhotoZone" onclick="document.getElementById('housePhotos').click()">
|
||||||
|
<i class="fas fa-camera puz-icon"></i>
|
||||||
|
<span class="puz-label">Klik atau seret foto ke sini · JPG / PNG</span>
|
||||||
|
<input type="file" id="housePhotos" accept=".jpg,.jpeg,.png" multiple>
|
||||||
|
</div>
|
||||||
|
<div class="photo-strip" id="housePhotoStrip"></div>
|
||||||
|
<div class="photo-strip" id="houseSavedPhotoStrip"></div>
|
||||||
|
<div class="photo-error-msg" id="housePhotoError"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Latitude</label><input type="text" id="houseLatDisplay" readonly></div>
|
||||||
|
<div class="form-group"><label>Longitude</label><input type="text" id="houseLngDisplay" readonly></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> Simpan</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeModal('houseModal')">Batal</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Family Member -->
|
||||||
|
<div class="modal-overlay" id="familyMemberModal">
|
||||||
|
<div class="modal" style="max-width:500px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="familyMemberModalTitle">Tambah Anggota Keluarga</h2>
|
||||||
|
<span class="modal-close" onclick="closeModal('familyMemberModal')">×</span>
|
||||||
|
</div>
|
||||||
|
<form id="memberForm" novalidate>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Nama Lengkap <span style="color:red;">*</span></label><input type="text" id="memberName" required></div>
|
||||||
|
<div class="form-group"><label>NIK</label><input type="text" id="memberNik" maxlength="16" placeholder="16 digit NIK"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Jenis Kelamin</label><select id="memberGender"><option value="male">Laki-laki</option><option value="female">Perempuan</option></select></div>
|
||||||
|
<div class="form-group"><label>Tanggal Lahir</label><input type="date" id="memberDob"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Pendidikan</label><select id="memberEducation">
|
||||||
|
<option value="tidak_sekolah">Tidak Sekolah</option><option value="sd">SD</option><option value="smp">SMP</option>
|
||||||
|
<option value="sma">SMA</option><option value="diploma">Diploma</option><option value="sarjana">Sarjana</option><option value="pascasarjana">Pascasarjana</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="form-group"><label>Hubungan Keluarga</label><select id="memberRelationship">
|
||||||
|
<option value="ayah">Ayah</option><option value="ibu">Ibu</option><option value="istri">Istri</option><option value="anak">Anak</option>
|
||||||
|
<option value="kakek">Kakek</option><option value="nenek">Nenek</option><option value="paman">Paman</option>
|
||||||
|
<option value="bibi">Bibi</option><option value="sepupu">Sepupu</option><option value="keponakan">Keponakan</option><option value="lainnya">Lainnya</option>
|
||||||
|
</select></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Status Pekerjaan</label>
|
||||||
|
<select id="memberEmploymentStatus" onchange="toggleMemberEmploymentFields()">
|
||||||
|
<option value="unemployed">Tidak Bekerja / Menganggur</option>
|
||||||
|
<option value="studying">Sekolah / Kuliah</option>
|
||||||
|
<option value="working">Bekerja</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="memberJobGroup" style="display:none;"><label>Nama Pekerjaan</label><input type="text" id="memberJobName" placeholder="Contoh: Buruh Harian"></div>
|
||||||
|
<div class="form-group" id="memberInstitutionGroup" style="display:none;"><label>Nama Sekolah / Universitas</label><input type="text" id="memberInstitutionName" placeholder="Contoh: SMA Negeri 1"></div>
|
||||||
|
<div class="form-group"><label>Perkiraan Pendapatan (Rp)</label><input type="number" id="memberIncome" min="0" value="0"><small style="color:var(--text-muted);">Isi 0 jika tidak bekerja atau masih sekolah</small></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveFamilyMember()"><i class="fas fa-save"></i> Simpan</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeModal('familyMemberModal')">Batal</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Aid Record -->
|
||||||
|
<div class="modal-overlay" id="aidModal">
|
||||||
|
<div class="modal" style="max-width:380px;">
|
||||||
|
<div class="modal-header"><h2>Catat Bantuan</h2><span class="modal-close" onclick="closeModal('aidModal')">×</span></div>
|
||||||
|
<form id="aidForm" novalidate>
|
||||||
|
<input type="hidden" id="aidHouseholdId">
|
||||||
|
<div class="form-group"><label>Jenis Bantuan *</label><select id="aidType">
|
||||||
|
<option value="sembako">Sembako</option><option value="pendanaan">Pendanaan</option><option value="pelatihan">Pelatihan</option>
|
||||||
|
<option value="sembako_pendanaan">Sembako + Pendanaan</option><option value="sembako_pelatihan">Sembako + Pelatihan</option>
|
||||||
|
<option value="pendanaan_pelatihan">Pendanaan + Pelatihan</option><option value="lengkap">Lengkap (Semua)</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Tanggal *</label><input type="date" id="aidDate" required></div>
|
||||||
|
<div class="form-group"><label>Jumlah (Rp)</label><input type="number" id="aidAmount" min="0" value="0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Catatan</label><textarea id="aidNotes" rows="2" placeholder="Catatan tambahan..."></textarea></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> Simpan</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeModal('aidModal')">Batal</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Admin Panel -->
|
||||||
|
<div class="modal-overlay" id="adminModal">
|
||||||
|
<div class="modal" style="max-width:750px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2><i class="fas fa-cog"></i> Laporan Publik</h2>
|
||||||
|
<span class="modal-close" onclick="closeModal('adminModal')">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-tabs"><button class="admin-tab active" data-atab="pending"><i class="fas fa-flag"></i> Laporan Publik<span class="pending-badge" id="pendingBadge"></span></button></div>
|
||||||
|
<div class="admin-tab-panel active" id="atab-pending">
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<select id="pendingStatusFilter" class="admin-search" style="max-width:150px;">
|
||||||
|
<option value="pending">Menunggu</option>
|
||||||
|
<option value="approved">Disetujui</option>
|
||||||
|
<option value="rejected">Ditolak</option>
|
||||||
|
<option value="">Semua Status</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="loadPendingReports()"><i class="fas fa-sync"></i> Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table" id="pendingTable">
|
||||||
|
<thead><tr><th>Waktu</th><th>Nama Warga</th><th>Alamat</th><th>Deskripsi</th><th>Status</th><th>Aksi</th></tr></thead>
|
||||||
|
<tbody id="pendingTbody"><tr><td colspan="6" class="text-center">Klik Refresh untuk memuat.</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Approve -->
|
||||||
|
<div class="modal-overlay" id="approveModal">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2><i class="fas fa-check-circle" style="color:var(--success);"></i> Setujui Laporan</h2>
|
||||||
|
<span class="modal-close" onclick="closeModal('approveModal')">×</span>
|
||||||
|
</div>
|
||||||
|
<div id="approveReportPreview" style="background:var(--surface-2);border-radius:var(--r);padding:12px;margin-bottom:14px;font-size:12px;"></div>
|
||||||
|
<div class="photo-strip" id="approvePhotoPreview" style="margin-top:8px;"></div>
|
||||||
|
<form id="approveForm" novalidate>
|
||||||
|
<input type="hidden" id="approveReportId">
|
||||||
|
<p style="font-size:12px;color:var(--text-secondary);margin-bottom:12px;">Data rumah tangga akan dibuat dengan nilai default. Petugas dapat mengedit lengkap setelah data masuk ke peta.</p>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Perkiraan Pendapatan/Bln</label><input type="number" id="approveIncome" min="0" value="0"></div>
|
||||||
|
<div class="form-group"><label>Jumlah Anggota</label><input type="number" id="approveDependents" min="1" value="1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Kondisi Rumah</label><select id="approveCondition"><option value="tidak_layak" selected>Tidak Layak</option><option value="layak">Layak</option></select></div>
|
||||||
|
<div class="form-group"><label>Pendidikan KK</label><select id="approveEducation"><option value="tidak_sekolah">Tidak Sekolah</option><option value="sd" selected>SD</option><option value="smp">SMP</option><option value="sma">SMA</option><option value="sarjana">Sarjana</option></select></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Catatan Admin (opsional)</label><textarea id="approveNotes" rows="2" placeholder="Catatan untuk laporan ini..."></textarea></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="btn btn-approve"><i class="fas fa-check"></i> Setujui & Tambah ke Peta</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeModal('approveModal')">Batal</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Reject -->
|
||||||
|
<div class="modal-overlay" id="rejectModal">
|
||||||
|
<div class="modal" style="max-width:380px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2><i class="fas fa-times-circle" style="color:var(--danger);"></i> Tolak Laporan</h2>
|
||||||
|
<span class="modal-close" onclick="closeModal('rejectModal')">×</span>
|
||||||
|
</div>
|
||||||
|
<form id="rejectForm" novalidate>
|
||||||
|
<input type="hidden" id="rejectReportId">
|
||||||
|
<div class="form-group"><label>Alasan Penolakan (opsional)</label><textarea id="rejectNotes" rows="3" placeholder="Contoh: Data duplikat, alamat tidak valid..."></textarea></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="btn btn-reject"><i class="fas fa-times"></i> Tolak Laporan</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeModal('rejectModal')">Batal</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard Modal -->
|
||||||
|
<div class="modal-overlay" id="dashboardModal">
|
||||||
|
<div class="modal" style="max-width:820px;max-height:90vh;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2><i class="fas fa-chart-bar"></i> Dashboard Analitik</h2>
|
||||||
|
<span class="modal-close" onclick="closeModal('dashboardModal')">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-scroll">
|
||||||
|
<div class="dashboard-grid" id="dashboardGrid">
|
||||||
|
<div class="chart-card chart-card-sm"><div class="chart-card-header"><h3><i class="fas fa-chart-pie"></i> Distribusi Kemiskinan</h3></div><div class="chart-canvas-wrap"><canvas id="chartPoverty"></canvas></div></div>
|
||||||
|
<div class="chart-card chart-card-sm"><div class="chart-card-header"><h3><i class="fas fa-chart-line"></i> Tren Pendataan</h3><span class="chart-card-badge">12 Bulan</span></div><div class="chart-canvas-wrap"><canvas id="chartTrend"></canvas></div></div>
|
||||||
|
<div class="chart-card chart-card-sm"><div class="chart-card-header"><h3><i class="fas fa-user-clock"></i> Distribusi Usia</h3></div><div class="chart-canvas-wrap"><canvas id="chartAge"></canvas></div></div>
|
||||||
|
<div class="chart-card chart-card-sm"><div class="chart-card-header"><h3><i class="fas fa-hand-holding-heart"></i> Distribusi Bantuan</h3></div><div class="chart-canvas-wrap"><canvas id="chartAid"></canvas></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Modal -->
|
||||||
|
<div class="modal-overlay" id="helpModal">
|
||||||
|
<div class="modal" style="max-width:500px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2><i class="fas fa-question-circle"></i> Panduan Pengguna</h2>
|
||||||
|
<span class="modal-close" onclick="closeModal('helpModal')">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="help-steps" id="helpSteps">
|
||||||
|
<div class="help-step active" data-step="1"><div class="help-step-icon"><i class="fas fa-map-marked-alt"></i></div><h3>Selamat Datang di WebGIS Admin</h3><p>Halaman admin untuk mengelola data rumah tangga, tempat ibadah, dan memverifikasi laporan dari masyarakat.</p></div>
|
||||||
|
<div class="help-step" data-step="2"><div class="help-step-icon"><i class="fas fa-place-of-worship"></i></div><h3>Menambah Tempat Ibadah</h3><p>Klik tab <strong>"Ibadah"</strong> di sidebar, lalu klik lokasi pada peta. Form akan muncul untuk mengisi detail.</p></div>
|
||||||
|
<div class="help-step" data-step="3"><div class="help-step-icon"><i class="fas fa-home"></i></div><h3>Menambah Rumah Tangga</h3><p>Klik tab <strong>"Rumah"</strong>, lalu klik lokasi pada peta. Skor kemiskinan dihitung <em>otomatis</em> berdasarkan data yang diisi.</p></div>
|
||||||
|
<div class="help-step" data-step="4"><div class="help-step-icon"><i class="fas fa-flag"></i></div><h3>Verifikasi Laporan Publik</h3><p>Klik <strong>Panel Admin → Laporan Publik</strong> untuk melihat laporan dari masyarakat. Setujui untuk langsung menambah data ke peta.</p></div>
|
||||||
|
<div class="help-step" data-step="5"><div class="help-step-icon"><i class="fas fa-chart-bar"></i></div><h3>Dashboard & Laporan</h3><p>Klik tab <strong>"Chart"</strong> untuk grafik analitik. Marker dapat diseret untuk memindahkan posisi. Radius dapat diubah langsung dari popup.</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="help-nav">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="helpPrev" onclick="helpNav(-1)" disabled>← Sebelumnya</button>
|
||||||
|
<span id="helpProgress">1 / 5</span>
|
||||||
|
<button class="btn btn-primary btn-sm" id="helpNext" onclick="helpNav(1)">Berikutnya →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="assets/js/config.js"></script>
|
||||||
|
<script src="assets/js/api.js"></script>
|
||||||
|
<script src="assets/js/auth.js"></script>
|
||||||
|
<script src="assets/js/map.js"></script>
|
||||||
|
<script src="assets/js/markers.js"></script>
|
||||||
|
<script src="assets/js/forms.js"></script>
|
||||||
|
<script src="assets/js/dashboard.js"></script>
|
||||||
|
<script src="assets/js/admin.js"></script>
|
||||||
|
<script src="assets/js/public-reports.js"></script>
|
||||||
|
<script src="assets/js/photo-upload.js"></script>
|
||||||
|
<script src="assets/js/app.js"></script>
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// iOS 100vh fix
|
||||||
|
// ================================================================
|
||||||
|
function setVH() {
|
||||||
|
const vh = window.innerHeight * 0.01;
|
||||||
|
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||||
|
}
|
||||||
|
setVH();
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
setVH();
|
||||||
|
if (typeof MAP !== 'undefined' && MAP) setTimeout(() => MAP.invalidateSize(), 150);
|
||||||
|
});
|
||||||
|
window.addEventListener('orientationchange', () => {
|
||||||
|
setTimeout(() => { setVH(); if (typeof MAP !== 'undefined' && MAP) MAP.invalidateSize(); }, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Legend Toggle - SATU FUNGSI UNTUK SEMUA (desktop + mobile)
|
||||||
|
// ================================================================
|
||||||
|
function toggleLegend() {
|
||||||
|
const legend = document.getElementById('legend');
|
||||||
|
const chevron = document.getElementById('legendChevron');
|
||||||
|
if (!legend) return;
|
||||||
|
|
||||||
|
legend.classList.toggle('collapsed');
|
||||||
|
|
||||||
|
if (chevron) {
|
||||||
|
chevron.className = legend.classList.contains('collapsed')
|
||||||
|
? 'fas fa-chevron-down'
|
||||||
|
: 'fas fa-chevron-up';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set legend default: collapsed on mobile, EXPANDED on desktop
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const legend = document.getElementById('legend');
|
||||||
|
if (legend) {
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
legend.classList.add('collapsed'); // mobile: collapsed
|
||||||
|
} else {
|
||||||
|
legend.classList.remove('collapsed'); // desktop: expanded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Mobile sidebar toggle
|
||||||
|
// ================================================================
|
||||||
|
function toggleMobileSidebar() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const backdrop = document.getElementById('sidebarBackdrop');
|
||||||
|
const isOpen = sidebar.classList.contains('mobile-open');
|
||||||
|
if (isOpen) {
|
||||||
|
closeMobileSidebar();
|
||||||
|
} else {
|
||||||
|
sidebar.classList.add('mobile-open');
|
||||||
|
backdrop.classList.add('active');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMobileSidebar() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const backdrop = document.getElementById('sidebarBackdrop');
|
||||||
|
sidebar.classList.remove('mobile-open');
|
||||||
|
backdrop.classList.remove('active');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close sidebar on nav/data-item click (mobile)
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.nav-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
if (window.innerWidth <= 768) setTimeout(closeMobileSidebar, 120);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
if (sidebar) {
|
||||||
|
sidebar.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.data-item') && window.innerWidth <= 768) setTimeout(closeMobileSidebar, 80);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const backdrop = document.getElementById('sidebarBackdrop');
|
||||||
|
if (backdrop) backdrop.addEventListener('click', closeMobileSidebar);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override closeModal to refresh Leaflet map
|
||||||
|
const _origCloseModal = window.closeModal;
|
||||||
|
if (typeof _origCloseModal === 'function') {
|
||||||
|
window.closeModal = function(id) {
|
||||||
|
_origCloseModal(id);
|
||||||
|
if (id === 'dashboardModal' && typeof MAP !== 'undefined' && MAP) {
|
||||||
|
setTimeout(() => MAP.invalidateSize(), 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,315 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<title>Login — WebGIS Poverty Mapping</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg:#eef0f5; --surface:#fff; --border:#e2e6ef; --border-subtle:#edf0f6;
|
||||||
|
--text-primary:#0f1623; --text-secondary:#5a6478; --text-muted:#9ba4b5;
|
||||||
|
--accent:#3a56d4; --accent-hover:#2d45b8; --accent-light:#eaedfc;
|
||||||
|
--accent-subtle:rgba(58,86,212,0.09);
|
||||||
|
--success:#0b9e73; --danger:#d63230;
|
||||||
|
--shadow-sm:0 1px 4px rgba(15,22,35,0.07),0 1px 2px rgba(15,22,35,0.04);
|
||||||
|
--shadow-md:0 4px 16px rgba(15,22,35,0.08),0 2px 6px rgba(15,22,35,0.04);
|
||||||
|
--shadow-lg:0 12px 36px rgba(15,22,35,0.11),0 4px 12px rgba(15,22,35,0.06);
|
||||||
|
--r-sm:7px; --r:11px; --r-lg:15px; --r-xl:20px;
|
||||||
|
--touch-min:44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,*::before,*::after { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:'DM Sans',-apple-system,BlinkMacSystemFont,sans-serif;
|
||||||
|
background:var(--bg);
|
||||||
|
min-height:100vh;
|
||||||
|
min-height:calc(var(--vh,1vh)*100);
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 15% 50%, rgba(58,86,212,0.07) 0%,transparent 55%),
|
||||||
|
radial-gradient(circle at 85% 20%, rgba(11,158,115,0.05) 0%,transparent 50%);
|
||||||
|
/* Safe area for notched phones */
|
||||||
|
padding:env(safe-area-inset-top) env(safe-area-inset-right)
|
||||||
|
env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||||
|
overflow-y:auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== WRAP ===== */
|
||||||
|
.login-wrap {
|
||||||
|
width:100%; max-width:400px;
|
||||||
|
padding:20px 16px;
|
||||||
|
/* On small screens center vertically with flex */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== LOGO ===== */
|
||||||
|
.login-logo { text-align:center; margin-bottom:24px; }
|
||||||
|
.logo-icon {
|
||||||
|
width:52px; height:52px; background:var(--accent); border-radius:14px;
|
||||||
|
display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
color:white; font-size:21px; margin-bottom:12px;
|
||||||
|
box-shadow:0 6px 20px rgba(58,86,212,0.32);
|
||||||
|
}
|
||||||
|
.login-logo h1 {
|
||||||
|
font-size:19px; font-weight:700; color:var(--text-primary); letter-spacing:-0.01em;
|
||||||
|
}
|
||||||
|
.login-logo p { font-size:12px; color:var(--text-muted); margin-top:3px; }
|
||||||
|
|
||||||
|
/* ===== CARD ===== */
|
||||||
|
.card {
|
||||||
|
background:var(--surface); border-radius:var(--r-xl);
|
||||||
|
padding:24px 22px; box-shadow:var(--shadow-lg); border:1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== FORM ===== */
|
||||||
|
.form-group { margin-bottom:15px; }
|
||||||
|
label {
|
||||||
|
display:block; font-size:11px; font-weight:700; color:var(--text-secondary);
|
||||||
|
text-transform:uppercase; letter-spacing:0.04em; margin-bottom:6px;
|
||||||
|
}
|
||||||
|
.input-wrap { position:relative; }
|
||||||
|
.input-wrap i.icon {
|
||||||
|
position:absolute; left:13px; top:50%; transform:translateY(-50%);
|
||||||
|
color:var(--text-muted); font-size:13px; pointer-events:none; z-index:1;
|
||||||
|
}
|
||||||
|
.input-wrap input {
|
||||||
|
width:100%; padding:12px 12px 12px 40px;
|
||||||
|
border:1.5px solid var(--border); border-radius:var(--r-sm);
|
||||||
|
/* 16px prevents iOS auto-zoom */
|
||||||
|
font-size:16px; font-family:'DM Sans',sans-serif;
|
||||||
|
color:var(--text-primary); background:#f9fafe;
|
||||||
|
transition:all 0.15s; outline:none;
|
||||||
|
min-height:var(--touch-min);
|
||||||
|
-webkit-appearance:none;
|
||||||
|
}
|
||||||
|
.input-wrap input:focus {
|
||||||
|
border-color:var(--accent); background:var(--surface);
|
||||||
|
box-shadow:0 0 0 3px var(--accent-subtle);
|
||||||
|
}
|
||||||
|
.input-wrap input.error { border-color:var(--danger); background:#fff8f8; }
|
||||||
|
.input-wrap .toggle-pass {
|
||||||
|
position:absolute; right:0; top:0; bottom:0;
|
||||||
|
width:44px; display:flex; align-items:center; justify-content:center;
|
||||||
|
color:var(--text-muted); cursor:pointer; font-size:13px; border:none;
|
||||||
|
background:none; padding:0; transition:color 0.14s;
|
||||||
|
border-radius:0 var(--r-sm) var(--r-sm) 0;
|
||||||
|
touch-action:manipulation; -webkit-tap-highlight-color:transparent;
|
||||||
|
}
|
||||||
|
.input-wrap .toggle-pass:hover { color:var(--accent); }
|
||||||
|
|
||||||
|
/* ===== ERROR ALERT ===== */
|
||||||
|
.alert-error {
|
||||||
|
background:#fff0f0; border:1px solid #fcc; color:var(--danger);
|
||||||
|
border-radius:var(--r-sm); padding:10px 13px;
|
||||||
|
font-size:13px; margin-bottom:15px;
|
||||||
|
display:none; align-items:center; gap:8px;
|
||||||
|
animation:slideDown 0.18s ease;
|
||||||
|
}
|
||||||
|
.alert-error.show { display:flex; }
|
||||||
|
@keyframes slideDown { from{opacity:0;transform:translateY(-6px)} to{opacity:1;transform:none} }
|
||||||
|
|
||||||
|
/* ===== BUTTON ===== */
|
||||||
|
.btn-login {
|
||||||
|
width:100%; padding:13px; border:none; border-radius:var(--r-sm);
|
||||||
|
background:var(--accent); color:white; font-size:15px; font-weight:700;
|
||||||
|
font-family:'DM Sans',sans-serif; cursor:pointer;
|
||||||
|
display:flex; align-items:center; justify-content:center; gap:9px;
|
||||||
|
transition:all 0.15s; margin-top:4px;
|
||||||
|
box-shadow:0 4px 14px rgba(58,86,212,0.28);
|
||||||
|
min-height:var(--touch-min);
|
||||||
|
touch-action:manipulation; -webkit-tap-highlight-color:transparent;
|
||||||
|
}
|
||||||
|
.btn-login:hover:not(:disabled) {
|
||||||
|
background:var(--accent-hover);
|
||||||
|
box-shadow:0 6px 20px rgba(58,86,212,0.38);
|
||||||
|
transform:translateY(-1px);
|
||||||
|
}
|
||||||
|
.btn-login:active:not(:disabled) { transform:translateY(0); }
|
||||||
|
.btn-login:disabled { opacity:0.65; cursor:not-allowed; transform:none; box-shadow:none; }
|
||||||
|
@keyframes spin { to{transform:rotate(360deg)} }
|
||||||
|
.spinner { animation:spin 0.8s linear infinite; display:inline-block; }
|
||||||
|
|
||||||
|
/* ===== PUBLIC LINK ===== */
|
||||||
|
.public-link {
|
||||||
|
text-align:center; margin-top:14px; font-size:12.5px; color:var(--text-muted);
|
||||||
|
}
|
||||||
|
.public-link a {
|
||||||
|
color:var(--accent); text-decoration:none; font-weight:600;
|
||||||
|
padding:2px 4px; border-radius:4px;
|
||||||
|
}
|
||||||
|
.public-link a:hover { text-decoration:underline; }
|
||||||
|
|
||||||
|
/* ===== RESPONSIVE ===== */
|
||||||
|
|
||||||
|
/* Phones: full-width, tighter padding */
|
||||||
|
@media (max-width:440px) {
|
||||||
|
.login-wrap { padding:16px 12px; max-width:100%; }
|
||||||
|
.card { padding:20px 16px; border-radius:var(--r-lg); }
|
||||||
|
.login-logo { margin-bottom:18px; }
|
||||||
|
.login-logo h1 { font-size:17px; }
|
||||||
|
.logo-icon { width:46px; height:46px; font-size:19px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Very small phones */
|
||||||
|
@media (max-width:360px) {
|
||||||
|
.login-wrap { padding:12px 10px; }
|
||||||
|
.card { padding:16px 14px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Landscape phones: reduce vertical spacing */
|
||||||
|
@media (max-height:600px) and (orientation:landscape) {
|
||||||
|
.login-logo { margin-bottom:14px; }
|
||||||
|
.login-logo .logo-icon { margin-bottom:8px; }
|
||||||
|
.card { padding:18px 20px; }
|
||||||
|
.form-group { margin-bottom:11px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="login-wrap">
|
||||||
|
<div class="login-logo">
|
||||||
|
<div class="logo-icon"><i class="fas fa-map-marked-alt"></i></div>
|
||||||
|
<h1>WebGIS Poverty Mapping</h1>
|
||||||
|
<p>Sistem Pemetaan Kemiskinan v2.0</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="alert-error" id="alertError">
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
<span id="alertText">Username atau password salah.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="loginForm" novalidate>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Username</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<i class="fas fa-user icon"></i>
|
||||||
|
<input type="text" id="email" placeholder="admin / petugas"
|
||||||
|
autocomplete="username" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<i class="fas fa-lock icon"></i>
|
||||||
|
<input type="password" id="password" placeholder="••••••••"
|
||||||
|
autocomplete="current-password" required>
|
||||||
|
<button type="button" class="toggle-pass" id="togglePass" tabindex="-1" aria-label="Tampilkan password">
|
||||||
|
<i class="fas fa-eye" id="togglePassIcon"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-login" id="loginBtn">
|
||||||
|
<i class="fas fa-sign-in-alt"></i> Masuk
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="public-link">
|
||||||
|
Masyarakat umum? <a href="lapor.html"><i class="fas fa-flag"></i> Buat Laporan Publik</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Fix 100vh on iOS
|
||||||
|
(function() {
|
||||||
|
function setVH() {
|
||||||
|
document.documentElement.style.setProperty('--vh', (window.innerHeight * 0.01) + 'px');
|
||||||
|
}
|
||||||
|
setVH();
|
||||||
|
window.addEventListener('resize', setVH);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---- If already logged in, skip to index.html ----
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('api/auth/check.php');
|
||||||
|
const d = await r.json();
|
||||||
|
if (d?.data?.logged_in) window.location.replace('index.html');
|
||||||
|
} catch { /* not logged in */ }
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---- Password toggle ----
|
||||||
|
document.getElementById('togglePass').addEventListener('click', () => {
|
||||||
|
const inp = document.getElementById('password');
|
||||||
|
const icon = document.getElementById('togglePassIcon');
|
||||||
|
const show = inp.type === 'password';
|
||||||
|
inp.type = show ? 'text' : 'password';
|
||||||
|
icon.className = show ? 'fas fa-eye-slash' : 'fas fa-eye';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Submit ----
|
||||||
|
const form = document.getElementById('loginForm');
|
||||||
|
const btn = document.getElementById('loginBtn');
|
||||||
|
const alertEl = document.getElementById('alertError');
|
||||||
|
const alertTx = document.getElementById('alertText');
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
alertTx.textContent = msg;
|
||||||
|
alertEl.className = 'alert-error show';
|
||||||
|
document.getElementById('password').classList.add('error');
|
||||||
|
}
|
||||||
|
function hideError() {
|
||||||
|
alertEl.className = 'alert-error';
|
||||||
|
document.getElementById('email').classList.remove('error');
|
||||||
|
document.getElementById('password').classList.remove('error');
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideError();
|
||||||
|
|
||||||
|
const email = document.getElementById('email').value.trim();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
showError('Username dan password harus diisi.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-circle-notch spinner"></i> Masuk...';
|
||||||
|
|
||||||
|
// Blur to hide keyboard on mobile before navigation
|
||||||
|
document.activeElement?.blur();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/auth/check.php?action=login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
btn.innerHTML = '<i class="fas fa-check"></i> Berhasil!';
|
||||||
|
window.location.replace('index.html');
|
||||||
|
} else {
|
||||||
|
showError(data.message || 'Username atau password salah.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-sign-in-alt"></i> Masuk';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showError('Koneksi gagal. Periksa server PHP.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-sign-in-alt"></i> Masuk';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter on email → focus password
|
||||||
|
document.getElementById('email').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); document.getElementById('password').focus(); }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// middleware/Response.php
|
||||||
|
// ============================================================
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class Response
|
||||||
|
{
|
||||||
|
public static function json(mixed $data, int $status = 200): never
|
||||||
|
{
|
||||||
|
// Clear any output buffer first
|
||||||
|
if (ob_get_level()) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function success(mixed $data = null, string $message = 'OK', int $status = 200): never
|
||||||
|
{
|
||||||
|
self::json(['success' => true, 'message' => $message, 'data' => $data], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function created(mixed $data, string $message = 'Created'): never
|
||||||
|
{
|
||||||
|
self::success($data, $message, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function error(string $message, int $status = 400, mixed $errors = null): never
|
||||||
|
{
|
||||||
|
$body = ['success' => false, 'message' => $message];
|
||||||
|
if ($errors !== null) $body['errors'] = $errors;
|
||||||
|
self::json($body, $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function notFound(string $message = 'Resource not found'): never
|
||||||
|
{
|
||||||
|
self::error($message, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function methodNotAllowed(): never
|
||||||
|
{
|
||||||
|
self::error('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// middleware/Validator.php
|
||||||
|
// ============================================================
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class Validator
|
||||||
|
{
|
||||||
|
private array $errors = [];
|
||||||
|
private array $data;
|
||||||
|
|
||||||
|
public function __construct(array $data)
|
||||||
|
{
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function make(array $data, array $rules): self
|
||||||
|
{
|
||||||
|
$v = new self($data);
|
||||||
|
$v->validate($rules);
|
||||||
|
return $v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validate(array $rules): void
|
||||||
|
{
|
||||||
|
foreach ($rules as $field => $ruleStr) {
|
||||||
|
$parts = explode('|', $ruleStr);
|
||||||
|
$value = $this->data[$field] ?? null;
|
||||||
|
|
||||||
|
foreach ($parts as $rule) {
|
||||||
|
[$ruleName, $ruleParam] = array_pad(explode(':', $rule, 2), 2, null);
|
||||||
|
|
||||||
|
switch ($ruleName) {
|
||||||
|
case 'required':
|
||||||
|
if ($value === null || $value === '' || $value === []) {
|
||||||
|
$this->errors[$field][] = "Field '{$field}' is required.";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'string':
|
||||||
|
if ($value !== null && !is_string($value)) {
|
||||||
|
$this->errors[$field][] = "'{$field}' must be a string.";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'integer':
|
||||||
|
if ($value !== null && $value !== '' && filter_var($value, FILTER_VALIDATE_INT) === false) {
|
||||||
|
$this->errors[$field][] = "'{$field}' must be an integer.";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'min':
|
||||||
|
if ($value !== null && is_numeric($value) && (float)$value < (float)$ruleParam) {
|
||||||
|
$this->errors[$field][] = "'{$field}' must be at least {$ruleParam}.";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'max':
|
||||||
|
if ($value !== null && is_numeric($value) && (float)$value > (float)$ruleParam) {
|
||||||
|
$this->errors[$field][] = "'{$field}' must be at most {$ruleParam}.";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'maxlen':
|
||||||
|
if ($value !== null && is_string($value) && mb_strlen($value) > (int)$ruleParam) {
|
||||||
|
$this->errors[$field][] = "'{$field}' must not exceed {$ruleParam} characters.";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'in':
|
||||||
|
$allowed = explode(',', $ruleParam ?? '');
|
||||||
|
if ($value !== null && $value !== '' && !in_array((string)$value, $allowed, true)) {
|
||||||
|
$this->errors[$field][] = "'{$field}' must be one of: {$ruleParam}.";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'email':
|
||||||
|
if ($value !== null && $value !== '' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$this->errors[$field][] = "'{$field}' must be a valid email address.";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'latitude':
|
||||||
|
if ($value !== null && $value !== '' && (
|
||||||
|
!is_numeric($value) || (float)$value < -90 || (float)$value > 90
|
||||||
|
)) {
|
||||||
|
$this->errors[$field][] = "'{$field}' must be between -90 and 90.";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'longitude':
|
||||||
|
if ($value !== null && $value !== '' && (
|
||||||
|
!is_numeric($value) || (float)$value < -180 || (float)$value > 180
|
||||||
|
)) {
|
||||||
|
$this->errors[$field][] = "'{$field}' must be between -180 and 180.";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'date':
|
||||||
|
if ($value !== null && $value !== '') {
|
||||||
|
$d = \DateTime::createFromFormat('Y-m-d', $value);
|
||||||
|
if (!$d || $d->format('Y-m-d') !== $value) {
|
||||||
|
$this->errors[$field][] = "'{$field}' must be a valid date (YYYY-MM-DD).";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fails(): bool { return !empty($this->errors); }
|
||||||
|
public function passes(): bool { return empty($this->errors); }
|
||||||
|
public function errors(): array { return $this->errors; }
|
||||||
|
|
||||||
|
public function validate_or_fail(): void
|
||||||
|
{
|
||||||
|
if ($this->fails()) {
|
||||||
|
Response::error('Validation failed.', 422, $this->errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function json(): array
|
||||||
|
{
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$data = json_decode($raw ?: '{}', true);
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
Response::error('Invalid JSON payload: ' . json_last_error_msg(), 400);
|
||||||
|
}
|
||||||
|
return is_array($data) ? $data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function sanitizeString(?string $s): string
|
||||||
|
{
|
||||||
|
return trim(htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// models/AuditLog.php
|
||||||
|
// Simplified — no user_id
|
||||||
|
// ============================================================
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class AuditLog
|
||||||
|
{
|
||||||
|
public static function record(
|
||||||
|
string $action,
|
||||||
|
string $tableName,
|
||||||
|
?int $recordId = null,
|
||||||
|
?array $oldValues = null,
|
||||||
|
?array $newValues = null,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
$pdo = Database::get();
|
||||||
|
|
||||||
|
$pdo->prepare("
|
||||||
|
INSERT INTO audit_logs
|
||||||
|
(action, table_name, record_id, old_values, new_values, ip_address, user_agent)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
")->execute([
|
||||||
|
$action,
|
||||||
|
$tableName,
|
||||||
|
$recordId,
|
||||||
|
$oldValues ? json_encode($oldValues, JSON_UNESCAPED_UNICODE) : null,
|
||||||
|
$newValues ? json_encode($newValues, JSON_UNESCAPED_UNICODE) : null,
|
||||||
|
$_SERVER['REMOTE_ADDR'] ?? '',
|
||||||
|
$_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||||
|
]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Audit log failure must never break the main request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// models/PovertyCalculator.php
|
||||||
|
// Direct poverty category classification
|
||||||
|
// ============================================================
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class PovertyCalculator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine poverty level directly based on indicators
|
||||||
|
*
|
||||||
|
* Categories (Indonesian context):
|
||||||
|
* - Sangat Miskin (Severe): Multiple critical indicators
|
||||||
|
* - Miskin (Poor): Several concerning indicators
|
||||||
|
* - Rentan Miskin (Vulnerable): At risk of poverty
|
||||||
|
*
|
||||||
|
* Indicators considered:
|
||||||
|
* 1. Per-capita income (below regional minimum wage)
|
||||||
|
* 2. Number of dependents (high dependency ratio)
|
||||||
|
* 3. House condition (uninhabitable)
|
||||||
|
* 4. Education level (low educational attainment)
|
||||||
|
* 5. Land ownership (doesn't own land)
|
||||||
|
*/
|
||||||
|
public static function calculate(
|
||||||
|
int $income,
|
||||||
|
int $dependents,
|
||||||
|
string $condition,
|
||||||
|
string $education,
|
||||||
|
string $landOwnership = 'milik'
|
||||||
|
): array {
|
||||||
|
$indicators = [];
|
||||||
|
$severityPoints = 0;
|
||||||
|
|
||||||
|
$members = max(1, $dependents);
|
||||||
|
$perCapita = $income / $members;
|
||||||
|
|
||||||
|
// ---- Indicator 1: Per-capita income ----
|
||||||
|
// Indonesian poverty line approx Rp 550,000/capita/month
|
||||||
|
// Regional minimum wage (UMP) varies ~Rp 2,000,000-5,000,000
|
||||||
|
if ($perCapita < 400_000) {
|
||||||
|
$indicators[] = 'Pendapatan per kapita sangat rendah (< Rp 400.000)';
|
||||||
|
$severityPoints += 3;
|
||||||
|
} elseif ($perCapita < 700_000) {
|
||||||
|
$indicators[] = 'Pendapatan per kapita rendah (< Rp 700.000)';
|
||||||
|
$severityPoints += 2;
|
||||||
|
} elseif ($perCapita < 1_200_000) {
|
||||||
|
$indicators[] = 'Pendapatan per kapita di bawah UMP';
|
||||||
|
$severityPoints += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Indicator 2: Dependency ratio ----
|
||||||
|
if ($dependents >= 7) {
|
||||||
|
$indicators[] = 'Tanggungan sangat besar (≥ 7 orang)';
|
||||||
|
$severityPoints += 3;
|
||||||
|
} elseif ($dependents >= 5) {
|
||||||
|
$indicators[] = 'Tanggungan besar (5-6 orang)';
|
||||||
|
$severityPoints += 2;
|
||||||
|
} elseif ($dependents >= 4) {
|
||||||
|
$indicators[] = 'Tanggungan cukup besar (4 orang)';
|
||||||
|
$severityPoints += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Indicator 3: House condition ----
|
||||||
|
if ($condition === 'tidak_layak') {
|
||||||
|
$indicators[] = 'Kondisi rumah tidak layak huni';
|
||||||
|
$severityPoints += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Indicator 4: Education ----
|
||||||
|
$eduLevels = [
|
||||||
|
'tidak_sekolah' => ['Tidak pernah sekolah', 3],
|
||||||
|
'sd' => ['Pendidikan hanya SD', 2],
|
||||||
|
'smp' => ['Pendidikan hanya SMP', 1],
|
||||||
|
'sma' => ['Pendidikan SMA', 0],
|
||||||
|
'diploma' => ['Pendidikan Diploma', 0],
|
||||||
|
'sarjana' => ['Pendidikan Sarjana', 0],
|
||||||
|
'pascasarjana' => ['Pendidikan Pascasarjana', 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isset($eduLevels[$education])) {
|
||||||
|
[$eduDesc, $eduPts] = $eduLevels[$education];
|
||||||
|
if ($eduPts > 0) {
|
||||||
|
$indicators[] = $eduDesc;
|
||||||
|
$severityPoints += $eduPts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Indicator 5: Land ownership ----
|
||||||
|
if ($landOwnership === 'numpang') {
|
||||||
|
$indicators[] = 'Tidak memiliki lahan (numpang)';
|
||||||
|
$severityPoints += 2;
|
||||||
|
} elseif ($landOwnership === 'sewa') {
|
||||||
|
$indicators[] = 'Lahan menyewa';
|
||||||
|
$severityPoints += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Determine category based on accumulated severity ----
|
||||||
|
if ($severityPoints >= 7) {
|
||||||
|
$status = 'sangat_miskin';
|
||||||
|
$label = 'Sangat Miskin';
|
||||||
|
} elseif ($severityPoints >= 4) {
|
||||||
|
$status = 'miskin';
|
||||||
|
$label = 'Miskin';
|
||||||
|
} elseif ($severityPoints >= 1) {
|
||||||
|
$status = 'rentan_miskin';
|
||||||
|
$label = 'Rentan Miskin';
|
||||||
|
} else {
|
||||||
|
$status = 'terdata';
|
||||||
|
$label = 'Terdata';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $status,
|
||||||
|
'label' => $label,
|
||||||
|
'indicators' => $indicators,
|
||||||
|
'severity' => $severityPoints,
|
||||||
|
'score' => (int)min(100, round(($severityPoints / 14) * 100)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker color based on poverty status
|
||||||
|
*/
|
||||||
|
public static function markerColor(string $status): string
|
||||||
|
{
|
||||||
|
return match($status) {
|
||||||
|
'sangat_miskin' => '#d63230', // Red
|
||||||
|
'miskin' => '#f76707', // Orange
|
||||||
|
'rentan_miskin' => '#f59e0b', // Amber
|
||||||
|
'terdata' => '#0b9e73', // Green
|
||||||
|
default => '#9ba4b5',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label in Indonesian
|
||||||
|
*/
|
||||||
|
public static function label(string $status): string
|
||||||
|
{
|
||||||
|
return match($status) {
|
||||||
|
'sangat_miskin' => 'Sangat Miskin',
|
||||||
|
'miskin' => 'Miskin',
|
||||||
|
'rentan_miskin' => 'Rentan Miskin',
|
||||||
|
'terdata' => 'Terdata',
|
||||||
|
default => '-',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
// Silencing warnings for CLI run
|
||||||
|
error_reporting(E_ERROR);
|
||||||
|
require_once __DIR__ . '/config/bootstrap.php';
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
$hash = password_hash('password', PASSWORD_DEFAULT);
|
||||||
|
// Update all passwords
|
||||||
|
$pdo->exec("UPDATE users SET password_hash = '{$hash}'");
|
||||||
|
|
||||||
|
// Update admin
|
||||||
|
$pdo->exec("UPDATE users SET email = 'admin' WHERE role = 'admin'");
|
||||||
|
|
||||||
|
// Update petugas (handle multiple to avoid UNIQUE constraint violation)
|
||||||
|
$stmt = $pdo->query("SELECT id FROM users WHERE role IN ('field_officer', 'staff', 'petugas') ORDER BY id ASC");
|
||||||
|
$petugas = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
foreach ($petugas as $index => $p) {
|
||||||
|
// If there's only one, it will be 'petugas'. If more, 'petugas', 'petugas2', etc.
|
||||||
|
$username = $index === 0 ? 'petugas' : 'petugas' . ($index + 1);
|
||||||
|
$pdo->exec("UPDATE users SET email = '{$username}' WHERE id = " . $p['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
echo "Database updated successfully.\n";
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
echo "Error updating database: " . $e->getMessage() . "\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Mencegah eksekusi file PHP di folder uploads
|
||||||
|
<FilesMatch "\.(php|php3|php4|php5|phtml|shtml|cgi|pl|py|rb|sh|asp|aspx|cfm|htaccess)$">
|
||||||
|
Order allow,deny
|
||||||
|
Deny from all
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Menonaktifkan daftar direktori
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
# Mencegah MIME sniffing
|
||||||
|
<FilesMatch "\.(jpg|jpeg|png)$">
|
||||||
|
Header set X-Content-Type-Options "nosniff"
|
||||||
|
</FilesMatch>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 326 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 326 KiB |
Reference in New Issue
Block a user