diff --git a/03PovertyMapping b/03PovertyMapping deleted file mode 160000 index 7f555a2..0000000 --- a/03PovertyMapping +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7f555a2494376e7c5149bd57d4954e90c39890d7 diff --git a/03PovertyMapping/.gitignore b/03PovertyMapping/.gitignore new file mode 100644 index 0000000..f513539 --- /dev/null +++ b/03PovertyMapping/.gitignore @@ -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 diff --git a/03PovertyMapping/Dockerfile b/03PovertyMapping/Dockerfile new file mode 100644 index 0000000..36962dc --- /dev/null +++ b/03PovertyMapping/Dockerfile @@ -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 \ No newline at end of file diff --git a/03PovertyMapping/LICENSE b/03PovertyMapping/LICENSE new file mode 100644 index 0000000..efb911f --- /dev/null +++ b/03PovertyMapping/LICENSE @@ -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. diff --git a/03PovertyMapping/README.md b/03PovertyMapping/README.md new file mode 100644 index 0000000..d353923 --- /dev/null +++ b/03PovertyMapping/README.md @@ -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. \ No newline at end of file diff --git a/03PovertyMapping/api/aid/index.php b/03PovertyMapping/api/aid/index.php new file mode 100644 index 0000000..e86a663 --- /dev/null +++ b/03PovertyMapping/api/aid/index.php @@ -0,0 +1,216 @@ += ?'; $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(); +} \ No newline at end of file diff --git a/03PovertyMapping/api/auth/check.php b/03PovertyMapping/api/auth/check.php new file mode 100644 index 0000000..aaea1e0 --- /dev/null +++ b/03PovertyMapping/api/auth/check.php @@ -0,0 +1,95 @@ + 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(); \ No newline at end of file diff --git a/03PovertyMapping/api/centers/index.php b/03PovertyMapping/api/centers/index.php new file mode 100644 index 0000000..bfa8981 --- /dev/null +++ b/03PovertyMapping/api/centers/index.php @@ -0,0 +1,334 @@ +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; +} \ No newline at end of file diff --git a/03PovertyMapping/api/houses/index.php b/03PovertyMapping/api/houses/index.php new file mode 100644 index 0000000..bfa5043 --- /dev/null +++ b/03PovertyMapping/api/houses/index.php @@ -0,0 +1,558 @@ +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()); + } +} \ No newline at end of file diff --git a/03PovertyMapping/api/public/report.php b/03PovertyMapping/api/public/report.php new file mode 100644 index 0000000..227701f --- /dev/null +++ b/03PovertyMapping/api/public/report.php @@ -0,0 +1,461 @@ + 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; +} \ No newline at end of file diff --git a/03PovertyMapping/api/public/upload.php b/03PovertyMapping/api/public/upload.php new file mode 100644 index 0000000..0c22738 --- /dev/null +++ b/03PovertyMapping/api/public/upload.php @@ -0,0 +1,145 @@ + 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); +} \ No newline at end of file diff --git a/03PovertyMapping/api/reports/index.php b/03PovertyMapping/api/reports/index.php new file mode 100644 index 0000000..76a6927 --- /dev/null +++ b/03PovertyMapping/api/reports/index.php @@ -0,0 +1,175 @@ +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', + }; +} diff --git a/03PovertyMapping/api/stats/index.php b/03PovertyMapping/api/stats/index.php new file mode 100644 index 0000000..64c1360 --- /dev/null +++ b/03PovertyMapping/api/stats/index.php @@ -0,0 +1,217 @@ +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); +} + diff --git a/03PovertyMapping/assets/css/style.css b/03PovertyMapping/assets/css/style.css new file mode 100644 index 0000000..26341e3 --- /dev/null +++ b/03PovertyMapping/assets/css/style.css @@ -0,0 +1,1039 @@ +/* ============================================================ + WebGIS v2 β€” style.css + Fixed: layer-control top-right positioning, legend overlap, + typography scale restored, mobile media queries cleaned + ============================================================ */ +:root { + --bg: #eef0f5; --surface: #ffffff; + --surface-2: #f5f6f9; --surface-3: #edf0f7; + --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; --warning: #d97706; + --stat-blue: #3a56d4; --stat-emerald: #0b9e73; + --stat-amber: #d97706; --stat-violet: #7c3aed; + --shadow-xs: 0 1px 2px rgba(15,22,35,0.04); + --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-xs: 4px; --r-sm: 7px; --r: 11px; --r-lg: 15px; --r-xl: 20px; +} + +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg); overflow: hidden; font-size: 13px; + line-height: 1.55; color: var(--text-primary); + -webkit-font-smoothing: antialiased; +} + +/* ===== MAP ===== */ +#map { + height: 100vh; width: 100%; position: relative; z-index: 1; + /* iOS 100vh fix β€” set by JS */ + height: calc(var(--vh, 1vh) * 100); +} + +/* ===== SIDEBAR ===== */ +.sidebar { + position: absolute; top: 16px; left: 16px; width: 328px; + max-height: calc(100vh - 32px); + background: var(--surface); border-radius: var(--r-xl); + box-shadow: var(--shadow-lg); z-index: 1000; + overflow: hidden; display: flex; flex-direction: column; + border: 1px solid var(--border); + transition: transform 0.28s cubic-bezier(0.4,0,0.2,1), opacity 0.28s; +} + +.sidebar-header { + padding: 14px 16px 12px; + border-bottom: 1px solid var(--border-subtle); + display: flex; align-items: center; gap: 10px; flex-shrink: 0; + background: linear-gradient(160deg,#f9fafe 0%,#fff 100%); +} +.sidebar-logo { + width: 32px; height: 32px; background: var(--accent); border-radius: 9px; + display: flex; align-items: center; justify-content: center; + color: white; font-size: 14px; flex-shrink: 0; + box-shadow: 0 3px 10px rgba(58,86,212,0.30); +} +.sidebar-title { flex: 1; min-width: 0; } +.sidebar-title h1 { font-size: 13.5px; font-weight: 700; color: var(--text-primary); line-height: 1.2; } +.sidebar-title p { font-size: 10.5px; color: var(--text-muted); } + +/* User menu */ +.sidebar-user { position: relative; } +.user-btn { + display: flex; align-items: center; gap: 5px; + padding: 5px 8px; border: 1px solid var(--border); + border-radius: var(--r-sm); background: var(--surface-2); + cursor: pointer; font-size: 11px; font-weight: 600; + color: var(--text-secondary); font-family: 'DM Sans', sans-serif; + transition: all 0.14s; white-space: nowrap; +} +.user-btn:hover { background: var(--accent-light); border-color: var(--accent); color: var(--accent); } +.user-dropdown { + position: absolute; right: 0; top: calc(100% + 6px); + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--r); box-shadow: var(--shadow-md); + min-width: 190px; z-index: 2000; display: none; padding: 6px 0; +} +.user-dropdown.open { display: block; } +.user-info { padding: 10px 14px 8px; } +.user-info strong { display: block; font-size: 13px; color: var(--text-primary); } +.user-info small { font-size: 10.5px; color: var(--text-muted); } +.user-dropdown hr { border: none; border-top: 1px solid var(--border-subtle); margin: 4px 0; } +.user-dropdown a { + display: flex; align-items: center; gap: 8px; + padding: 8px 14px; font-size: 12px; color: var(--text-secondary); + text-decoration: none; transition: background 0.12s; +} +.user-dropdown a:hover { background: var(--surface-2); color: var(--text-primary); } + +.sidebar-content { + padding: 12px 13px; overflow-y: auto; flex: 1; + display: flex; flex-direction: column; gap: 9px; + scrollbar-width: thin; scrollbar-color: var(--border) transparent; + -webkit-overflow-scrolling: touch; +} +.sidebar-content::-webkit-scrollbar { width: 3px; } +.sidebar-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } + +/* ===== MOBILE SIDEBAR TOGGLE ===== */ +.sidebar-toggle { + display: none; + position: absolute; top: 14px; left: 14px; z-index: 1001; + width: 40px; height: 40px; border-radius: var(--r); + background: var(--surface); border: 1px solid var(--border); + box-shadow: var(--shadow-md); cursor: pointer; + align-items: center; justify-content: center; + color: var(--accent); font-size: 16px; + transition: all 0.15s; +} + +/* Backdrop for mobile sidebar */ +.sidebar-backdrop { + display: none; + position: fixed; inset: 0; + background: rgba(10,15,28,0.40); + z-index: 999; +} +.sidebar-backdrop.active { display: block; } + +/* ===== NAV TABS ===== */ +.nav-tabs { + display: flex; gap: 3px; background: var(--surface-2); + border-radius: var(--r-sm); padding: 3px; + border: 1px solid var(--border-subtle); +} +.nav-tab { + flex: 1; padding: 6px 4px; text-align: center; + background: transparent; border-radius: 5px; cursor: pointer; + transition: all 0.15s; font-size: 10.5px; font-weight: 500; + color: var(--text-secondary); + display: flex; align-items: center; justify-content: center; + gap: 4px; user-select: none; white-space: nowrap; +} +.nav-tab:hover { background: var(--surface); color: var(--text-primary); } +.nav-tab.active { background: var(--surface); color: var(--accent); box-shadow: var(--shadow-sm); font-weight: 600; } +.nav-tab i { font-size: 10px; } + +/* ===== STAT CARDS ===== */ +.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; width: 100%; } +.stat-card { + position: relative; overflow: hidden; padding: 11px 12px 9px; + border-radius: var(--r); border: 1px solid transparent; + transition: transform 0.18s, box-shadow 0.18s; cursor: default; +} +.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); } +.stat-card::before { + content: ''; position: absolute; right: -10px; bottom: -10px; + width: 58px; height: 58px; border-radius: 50%; opacity: 0.12; + background: currentColor; transition: transform 0.25s; +} +.stat-card:hover::before { transform: scale(1.3); } +.stat-card.purple { background: linear-gradient(135deg,#eaedfc,#dde3fa); border-color: #c8d0f5; color: var(--stat-blue); } +.stat-card.green { background: linear-gradient(135deg,#e0faf3,#c8f2e6); border-color: #a8e8d4; color: var(--stat-emerald); } +.stat-card.orange { background: linear-gradient(135deg,#fef6e4,#fde9c0); border-color: #f7d796; color: var(--stat-amber); } +.stat-card.teal { background: linear-gradient(135deg,#f0ebff,#e4d9fe); border-color: #c9b8fb; color: var(--stat-violet); } +.stat-card.red { background: linear-gradient(135deg,#fff0f0,#fddede); border-color: #f9b8b8; color: var(--danger); } +.stat-icon { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; } +.stat-icon i { font-size: 14px; opacity: 0.75; } +.stat-icon::after { content: ''; display: block; width: 5px; height: 5px; border-radius: 50%; background: currentColor; opacity: 0.4; } +.stat-value { font-size: 22px; font-weight: 700; font-family: 'DM Mono', monospace; line-height: 1; letter-spacing: -0.03em; margin-bottom: 3px; } +.stat-label { font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.65; } + +/* ===== SEARCH ===== */ +.search-box { position: relative; } +.search-box input { + width: 100%; padding: 8px 10px 8px 32px; + border: 1px solid var(--border); border-radius: var(--r-sm); + font-size: 12px; font-family: 'DM Sans', sans-serif; + background: var(--surface-2); color: var(--text-primary); + transition: all 0.15s; outline: none; +} +.search-box input:focus { border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px var(--accent-subtle); } +.search-box i { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-muted); font-size: 11px; pointer-events: none; } + +/* ===== FILTER ===== */ +.filter-section { padding: 9px 11px; background: var(--surface-2); border-radius: var(--r-sm); border: 1px solid var(--border-subtle); } +.filter-title { font-size: 9.5px; font-weight: 700; color: var(--text-muted); margin-bottom: 7px; text-transform: uppercase; letter-spacing: 0.08em; } +.filter-buttons { display: flex; gap: 4px; } +.filter-btn { + flex: 1; padding: 5px 6px; border: 1px solid var(--border); + background: var(--surface); border-radius: var(--r-xs); + cursor: pointer; font-size: 10px; font-weight: 600; + font-family: 'DM Sans', sans-serif; transition: all 0.14s; color: var(--text-secondary); +} +.filter-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-light); } +.filter-btn.active { background: var(--accent); color: white; border-color: var(--accent); box-shadow: 0 2px 8px rgba(58,86,212,0.22); } + +/* Sub filters */ +.subfilter-section { display: flex; flex-direction: column; gap: 5px; } +.filter-row { display: flex; gap: 5px; } +.subfilter-select { + flex: 1; padding: 5px 7px; border: 1px solid var(--border); + border-radius: var(--r-xs); font-size: 10.5px; font-family: 'DM Sans', sans-serif; + background: var(--surface-2); color: var(--text-secondary); + outline: none; transition: all 0.14s; -webkit-appearance: none; +} +.subfilter-select:focus { border-color: var(--accent); } + +/* ===== DATA SECTION ===== */ +.data-section { display: flex; flex-direction: column; gap: 5px; } +.section-title { + display: flex; align-items: center; justify-content: space-between; + font-size: 9.5px; font-weight: 700; color: var(--text-secondary); + text-transform: uppercase; letter-spacing: 0.07em; +} +.count-badge { + background: var(--surface-3); border: 1px solid var(--border); + color: var(--text-muted); font-size: 10px; font-weight: 600; + padding: 1px 6px; border-radius: 20px; font-family: 'DM Mono', monospace; +} +.data-list { + border: 1px solid var(--border); border-radius: var(--r); + overflow: hidden; background: var(--surface); + max-height: 220px; overflow-y: auto; + scrollbar-width: thin; scrollbar-color: var(--border) transparent; + -webkit-overflow-scrolling: touch; +} +.data-list::-webkit-scrollbar { width: 3px; } +.data-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +.data-item { + padding: 8px 11px; border-bottom: 1px solid var(--border-subtle); + cursor: pointer; transition: background 0.12s; + display: flex; align-items: center; gap: 9px; + min-height: 40px; +} +.data-item:hover { background: var(--surface-2); } +.data-item:last-child { border-bottom: none; } +.data-item-icon { width: 28px; height: 28px; border-radius: 7px; display: flex; align-items: center; justify-content: center; font-size: 11px; flex-shrink: 0; } +.data-item-icon.center { background: var(--accent-light); color: var(--accent); } +.data-item-icon.house { background: #e6faf4; color: var(--success); } +.data-item-info { flex: 1; min-width: 0; } +.data-item-title { font-weight: 600; font-size: 12px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 1px; } +.data-item-subtitle { font-size: 10px; color: var(--text-secondary); } +.data-item-actions { display: flex; gap: 3px; flex-shrink: 0; } +.data-item-actions button { width: 24px; height: 24px; border: 1px solid var(--border); border-radius: var(--r-sm); cursor: pointer; font-size: 9px; display: flex; align-items: center; justify-content: center; background: var(--surface); transition: all 0.14s; } +.btn-edit { color: var(--warning); } .btn-edit:hover { background: #fffbf0; border-color: var(--warning); } +.btn-delete { color: var(--danger); } .btn-delete:hover { background: #fff4f4; border-color: var(--danger); } + +/* Admin panel button */ +.btn-admin-panel { + width: 100%; padding: 8px; border: 1px dashed var(--accent); + border-radius: var(--r-sm); background: var(--accent-subtle); + color: var(--accent); font-size: 12px; font-weight: 600; + font-family: 'DM Sans', sans-serif; cursor: pointer; + display: flex; align-items: center; justify-content: center; gap: 7px; + transition: all 0.14s; +} +.btn-admin-panel:hover { background: var(--accent-light); } + +/* Logout button */ +.logout-btn { + background: none; border: none; color: var(--danger); font-size: 15px; + cursor: pointer; padding: 4px 6px; border-radius: 6px; + transition: all 0.14s; display: flex; align-items: center; +} +.logout-btn:hover { background: rgba(214,50,48,0.08); } + +/* ===== GENERIC BUTTONS ===== */ +.btn { + padding: 9px 14px; border: none; border-radius: var(--r-sm); + font-size: 12.5px; font-weight: 600; font-family: 'DM Sans', sans-serif; + cursor: pointer; display: inline-flex; align-items: center; justify-content: center; + gap: 6px; transition: all 0.15s; width: 100%; margin-bottom: 6px; +} +.btn:last-child { margin-bottom: 0; } +.btn-sm { padding: 6px 10px; font-size: 11.5px; width: auto; margin-bottom: 0; } +.btn-primary { background: var(--accent); color: white; } +.btn-primary:hover { background: var(--accent-hover); box-shadow: 0 4px 12px rgba(58,86,212,0.28); } +.btn-success { background: var(--success); color: white; } +.btn-success:hover { filter: brightness(0.93); } +.btn-secondary { background: var(--surface-2); color: var(--text-secondary); border: 1px solid var(--border); } +.btn-secondary:hover { background: var(--surface-3); color: var(--text-primary); } +.btn-danger { background: var(--danger); color: white; } +.btn-danger:hover { filter: brightness(0.92); } + +/* ===== MODALS ===== */ +.modal-overlay { + position: fixed; inset: 0; background: rgba(10,15,28,0.45); + backdrop-filter: blur(4px); z-index: 2000; + display: none; align-items: center; justify-content: center; +} +.modal { + background: var(--surface); border-radius: var(--r-xl); + padding: 24px; max-width: 430px; width: 92%; + max-height: 88vh; overflow-y: auto; + box-shadow: var(--shadow-lg); border: 1px solid var(--border); + scrollbar-width: thin; + -webkit-overflow-scrolling: touch; +} +.modal-header { + display: flex; justify-content: space-between; align-items: flex-start; + margin-bottom: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--border); +} +.modal-header h2 { font-size: 15px; font-weight: 700; color: var(--text-primary); } +.modal-close { + cursor: pointer; font-size: 18px; color: var(--text-muted); + line-height: 1; padding: 2px; transition: color 0.14s; + min-width: 28px; text-align: right; +} +.modal-close:hover { color: var(--text-primary); } + +/* Form inside modals */ +.form-group { margin-bottom: 13px; } +.form-group label { + display: block; font-size: 10.5px; font-weight: 700; + color: var(--text-secondary); margin-bottom: 4px; + letter-spacing: 0.03em; text-transform: uppercase; +} +.form-group input, .form-group textarea, .form-group select { + width: 100%; padding: 8px 10px; + border: 1px solid var(--border); border-radius: var(--r-sm); + font-size: 12.5px; font-family: 'DM Sans', sans-serif; + color: var(--text-primary); background: var(--surface-2); + transition: all 0.15s; outline: none; -webkit-appearance: none; +} +.form-group input:focus, .form-group textarea:focus, .form-group select:focus { + border-color: var(--accent); background: var(--surface); + box-shadow: 0 0 0 3px var(--accent-subtle); +} +.form-group input[readonly] { background: var(--surface-2); color: var(--text-muted); cursor: default; } +.form-group textarea { resize: vertical; min-height: 68px; line-height: 1.5; } +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; } +.modal-actions { display: flex; gap: 7px; margin-top: 18px; } +.modal-actions .btn { flex: 1; margin-bottom: 0; } + +/* Slider */ +.slider-container { display: flex; align-items: center; gap: 10px; } +.slider-container input[type="range"] { + flex: 1; height: 4px; -webkit-appearance: none; background: var(--border); + border-radius: 2px; outline: none; border: none; box-shadow: none; padding: 0; +} +.slider-container input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; + background: var(--accent); cursor: pointer; + box-shadow: 0 1px 5px rgba(58,86,212,0.35); border: 2px solid white; +} +.slider-value { min-width: 44px; text-align: right; color: var(--accent); font-weight: 700; font-size: 11.5px; font-family: 'DM Mono', monospace; } + +/* Form sub-tabs */ +.form-tabs { display: flex; gap: 3px; margin-bottom: 14px; background: var(--surface-2); border-radius: var(--r-sm); padding: 3px; } +.form-tab { flex: 1; padding: 5px 4px; border: none; border-radius: 5px; cursor: pointer; font-size: 10.5px; font-weight: 600; font-family: 'DM Sans', sans-serif; color: var(--text-secondary); background: transparent; transition: all 0.14s; } +.form-tab.active { background: var(--surface); color: var(--accent); box-shadow: var(--shadow-sm); } +.form-tab-panel { display: none; } +.form-tab-panel.active { display: block; animation: fadeInPanel 0.18s ease; } +@keyframes fadeInPanel { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } + +/* Poverty preview widget */ +.poverty-preview { + display: flex; align-items: flex-start; gap: 12px; + padding: 12px 14px; background: var(--surface-2); + border-radius: var(--r); border: 1px solid var(--border-subtle); margin-bottom: 14px; + transition: border-color 0.3s; +} +.poverty-score-ring { + width: 44px; height: 44px; min-width: 44px; + border-radius: 50%; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; + border: 3px solid #9ba4b5; background: #fff; + font-weight: 700; font-size: 15px; font-family: 'DM Mono', monospace; + transition: all 0.3s; +} +.poverty-status-label { font-weight: 700; font-size: 13px; line-height: 1.2; } +.poverty-status-badge { + display: inline-block; padding: 2px 8px; border-radius: 10px; + font-size: 9px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.5px; margin-left: 6px; vertical-align: middle; +} +.poverty-indicators { margin-top: 6px; padding: 0; list-style: none; } +.poverty-indicators li { + font-size: 9px; color: #5a6478; margin-bottom: 2px; line-height: 1.3; + padding-left: 10px; position: relative; +} +.poverty-indicators li::before { content: 'β€’'; position: absolute; left: 0; color: currentColor; } + +/* Aid history list */ +.aid-history-list { max-height: 160px; overflow-y: auto; -webkit-overflow-scrolling: touch; } +.aid-history-item { + display: flex; align-items: center; gap: 8px; + padding: 6px 10px; border-bottom: 1px solid var(--border-subtle); + font-size: 11.5px; +} +.aid-history-item:last-child { border-bottom: none; } +.aid-badge { padding: 2px 7px; border-radius: 20px; font-size: 10px; font-weight: 600; } + +/* ===== POPUP OVERRIDES ===== */ +.leaflet-popup-content-wrapper { + border-radius: var(--r-lg) !important; box-shadow: var(--shadow-lg) !important; + border: 1px solid var(--border); font-family: 'DM Sans', sans-serif !important; + padding: 0 !important; +} +.leaflet-popup-content { min-width: 240px; margin: 0 !important; } +.leaflet-popup-tip-container { display: none; } +.leaflet-popup-close-button { top: 10px !important; right: 10px !important; z-index: 10; } +.popup-info { padding: 12px 14px; } +.popup-info p { margin: 5px 0; font-size: 12px; color: var(--text-secondary); display: flex; align-items: baseline; gap: 6px; } +.popup-info p strong { font-size: 13.5px; font-weight: 700; color: var(--text-primary); display: block; margin-bottom: 2px; } +.popup-info p i { font-size: 10px; color: var(--text-muted); flex-shrink: 0; } +.popup-actions { display: flex; gap: 5px; margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--border-subtle); flex-wrap: wrap; } +.popup-actions .btn { margin: 0; font-size: 11px; padding: 6px 9px; } +.radius-control { margin: 8px 0; padding: 9px 11px; background: var(--surface-2); border-radius: var(--r-sm); border: 1px solid var(--border-subtle); } +.radius-control label { font-size: 10.5px; color: var(--text-secondary); font-weight: 500; display: block; margin-bottom: 5px; } +.radius-ticks { display: flex; justify-content: space-between; font-size: 9px; color: var(--text-muted); margin-top: 3px; } +.popup-score-badge { + display: inline-flex; align-items: center; gap: 5px; + padding: 3px 9px; border-radius: 20px; font-size: 11px; font-weight: 700; +} + +/* Legacy popup classes */ +.popup-name { font-size: 14px; font-weight: 700; color: var(--text-primary); margin-bottom: 2px; line-height: 1.3; } +.popup-hint { font-size: 9.5px; color: var(--text-muted); margin-bottom: 10px; } +.popup-badges { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border-subtle); } +.popup-badge { padding: 3px 9px; border-radius: 20px; font-size: 9.5px; font-weight: 700; white-space: nowrap; } +.popup-row { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 6px; font-size: 12px; color: var(--text-secondary); line-height: 1.45; } +.popup-row i { font-size: 10px; color: var(--text-muted); margin-top: 2px; flex-shrink: 0; width: 12px; text-align: center; } +.popup-row strong { color: var(--text-primary); font-weight: 600; } +.popup-section { margin-top: 10px; padding: 9px 11px; background: var(--surface-2); border-radius: var(--r-sm); border: 1px solid var(--border-subtle); } +.popup-section-label { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 6px; } + +.legend { + position: absolute; + top: 130px; /* ganti dari bottom:300px ke top:70px */ + right: 16px; + background: var(--surface); + padding: 12px 14px; + border-radius: var(--r-lg); + box-shadow: var(--shadow-md); + z-index: 900; + border: 1px solid var(--border); + min-width: 160px; + max-width: 180px; + transition: all 0.2s; +} +.legend h4 { + font-size: 9.5px; font-weight: 700; letter-spacing: 0.08em; + text-transform: uppercase; color: var(--text-secondary); + margin-bottom: 9px; padding-bottom: 7px; + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; user-select: none; + display: flex; align-items: center; justify-content: space-between; +} +.legend .legend-content { display: block; } +.legend.collapsed .legend-content { display: none; } +.legend.collapsed h4 { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } +.legend-section-label { font-size: 8.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.09em; color: var(--text-muted); margin: 8px 0 4px; } +.legend-item { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; font-size: 11px; color: var(--text-secondary); } +.legend-icon { width: 16px; height: 16px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white; flex-shrink: 0; } +.legend-poverty-item { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; font-size: 11px; color: var(--text-secondary); } +.legend-poverty-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; border: 1.5px solid rgba(0,0,0,0.1); } + +/* LAYER CONTROL β€” ALWAYS top-right, compact height, never tall */ +.custom-layer-control { + position: absolute; + top: 16px; + right: 16px; + background: var(--surface); + padding: 10px 12px; + border-radius: var(--r-lg); + box-shadow: var(--shadow-md); + z-index: 1000; + border: 1px solid var(--border); + min-width: 0; + width: auto; +} +.custom-layer-control h4 { + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 5px; + white-space: nowrap; +} +.custom-layer-item { + display: flex; + align-items: center; + padding: 5px 0; + cursor: pointer; + border-bottom: 1px solid var(--border-subtle); + gap: 8px; + transition: opacity 0.12s; + white-space: nowrap; +} + +.custom-layer-item:hover { + opacity: 0.78; +} + +.custom-layer-item:last-of-type { + border-bottom: none; + padding-bottom: 0; +} + +.custom-layer-item input[type="checkbox"] { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: var(--accent); + flex-shrink: 0; +} +.layer-icon { + font-size: 13px; + width: 18px; + text-align: center; + flex-shrink: 0; +} + +.layer-icon-center { + color: #3a56d4; /* Biru untuk tempat ibadah */ +} + +.layer-icon-house { + color: #0b9e73; /* Hijau untuk rumah */ +} + +.custom-layer-item span { + flex: 1; + font-size: 11.5px; + font-weight: 500; + color: var(--text-primary); +} + +.custom-layer-item small { + color: var(--text-muted); + font-size: 10px; + font-family: 'DM Mono', monospace; + margin-left: 4px; +} + +/* ===== LOADING ===== */ +.loading { + position: fixed; top: 16px; left: 50%; transform: translateX(-50%); + background: var(--surface); padding: 9px 15px; + border-radius: var(--r-sm); box-shadow: var(--shadow-md); + z-index: 2000; display: none; align-items: center; gap: 7px; + font-size: 12px; font-weight: 500; color: var(--text-secondary); + border: 1px solid var(--border); white-space: nowrap; +} +.loading i { color: var(--accent); } + +/* ===== TOAST ===== */ +.toast { + position: fixed; bottom: 26px; left: 50%; transform: translateX(-50%); + background: var(--text-primary); color: white; padding: 9px 18px; + border-radius: var(--r-sm); z-index: 3000; display: none; + font-size: 12px; font-weight: 500; box-shadow: var(--shadow-md); + white-space: nowrap; animation: toastIn 0.22s cubic-bezier(0.34,1.2,0.64,1); + max-width: calc(100vw - 32px); text-align: center; +} +.toast.success { background: var(--success); } +.toast.error { background: var(--danger); } +.toast.warning { background: var(--warning); } +@keyframes toastIn { from{transform:translate(-50%,16px);opacity:0} to{transform:translate(-50%,0);opacity:1} } + +/* ===== EMPTY STATE ===== */ +.empty-state { text-align: center; padding: 24px 14px; color: var(--text-muted); } +.empty-state i { font-size: 20px; display: block; margin-bottom: 7px; opacity: 0.3; } +.empty-state p { font-size: 11.5px; } + +/* ===== CURSORS ===== */ +.cursor-add-center { cursor: crosshair !important; } +.cursor-add-house { cursor: cell !important; } + +/* ===== HELP BUTTON ===== */ +.help-btn { + position: absolute; bottom: 100px; right: 16px; z-index: 1000; + width: 38px; height: 38px; border-radius: 50%; + background: var(--surface); border: 1px solid var(--border); + box-shadow: var(--shadow-md); cursor: pointer; + display: flex; align-items: center; justify-content: center; + color: var(--accent); font-size: 16px; transition: all 0.14s; +} +.help-btn:hover { background: var(--accent-light); transform: scale(1.08); } + +/* Help modal */ +.help-steps { min-height: 180px; } +.help-step { display: none; text-align: center; padding: 10px 0; animation: fadeIn 0.2s; } +.help-step.active { display: block; } +.help-step-icon { width: 56px; height: 56px; border-radius: 50%; background: var(--accent-light); color: var(--accent); display: flex; align-items: center; justify-content: center; font-size: 22px; margin: 0 auto 14px; } +.help-step h3 { font-size: 15px; font-weight: 700; margin-bottom: 8px; color: var(--text-primary); } +.help-step p { font-size: 13px; color: var(--text-secondary); line-height: 1.6; } +.help-nav { display: flex; align-items: center; justify-content: space-between; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border-subtle); } +#helpProgress { font-size: 11.5px; color: var(--text-muted); font-family: 'DM Mono', monospace; } +@keyframes fadeIn { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} } + +/* ===== DASHBOARD CHARTS ===== */ +#dashboardModal .modal { + max-width: 1100px; width: 96%; max-height: 90vh; + display: flex; flex-direction: column; overflow: hidden; padding: 0; +} +#dashboardModal .modal-header { + flex-shrink: 0; padding: 16px 24px; + border-bottom: 1px solid var(--border); + background: linear-gradient(160deg,#f9fafe 0%,#fff 100%); + margin-bottom: 0; border-radius: var(--r-xl) var(--r-xl) 0 0; +} +#dashboardModal .modal-header h2 { + font-size: 15px; font-weight: 700; + display: flex; align-items: center; gap: 8px; +} +#dashboardModal .modal-header h2 i { color: var(--accent); font-size: 16px; } + +.dashboard-scroll { + flex: 1; overflow-y: auto; padding: 20px 24px; + scrollbar-width: thin; scrollbar-color: var(--border) transparent; + -webkit-overflow-scrolling: touch; +} +.dashboard-scroll::-webkit-scrollbar { width: 4px; } +.dashboard-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } + +.dashboard-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 14px; +} + +.chart-card, .chart-card-sm { + background: var(--surface); border: 1px solid var(--border-subtle); + border-radius: var(--r-lg); padding: 14px 18px 12px; + box-shadow: var(--shadow-xs); transition: box-shadow 0.2s; + display: flex; flex-direction: column; +} +.chart-card:hover, .chart-card-sm:hover { box-shadow: var(--shadow-sm); } + +.chart-card-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 10px; padding-bottom: 8px; + border-bottom: 1px solid var(--border-subtle); flex-shrink: 0; +} +.chart-card-header h3 { + font-size: 11px; font-weight: 700; color: var(--text-secondary); + text-transform: uppercase; letter-spacing: 0.06em; + display: flex; align-items: center; gap: 7px; +} +.chart-card-header h3 i { color: var(--accent); font-size: 11px; opacity: 0.7; } +.chart-card-badge { + font-size: 9px; font-weight: 600; color: var(--text-muted); + background: var(--surface-2); padding: 3px 8px; + border-radius: 12px; font-family: 'DM Mono', monospace; +} + +.chart-canvas-wrap { + flex: 1; position: relative; min-height: 230px; +} +.chart-canvas-wrap canvas { width: 100% !important; height: 100% !important; } + +.chart-card-full { grid-column: 1 / -1; } +.chart-card-full .chart-canvas-wrap { min-height: 260px; } + +/* ===== ADMIN PANEL ===== */ +.admin-tabs { display: none; } /* single tab β€” hide bar */ +.admin-tab-panel { display: none; } +.admin-tab-panel.active { display: block; } +.admin-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; } +.admin-search { + flex: 1; padding: 7px 10px; border: 1px solid var(--border); + border-radius: var(--r-sm); font-size: 12px; + font-family: 'DM Sans', sans-serif; background: var(--surface-2); + outline: none; transition: border-color 0.15s; +} +.admin-search:focus { border-color: var(--accent); } +.admin-table-wrap { + max-height: 360px; overflow: auto; + border-radius: var(--r); border: 1px solid var(--border); + -webkit-overflow-scrolling: touch; +} +.admin-table-wrap::-webkit-scrollbar { width: 4px; height: 4px; } +.admin-table-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +.admin-table { width: 100%; border-collapse: collapse; font-size: 11.5px; min-width: 520px; } +.admin-table th { + padding: 8px 10px; background: var(--surface-2); font-weight: 700; + font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; + color: var(--text-secondary); text-align: left; + position: sticky; top: 0; z-index: 2; border-bottom: 1px solid var(--border); +} +.admin-table td { padding: 7px 10px; border-bottom: 1px solid var(--border-subtle); color: var(--text-primary); vertical-align: middle; } +.admin-table tr:hover td { background: var(--surface-2); } +.admin-table tr:last-child td { border-bottom: none; } +.text-center { text-align: center; color: var(--text-muted); padding: 20px !important; } +.role-badge { padding: 2px 8px; border-radius: 20px; font-size: 9.5px; font-weight: 700; display: inline-block; } +.role-badge.super_admin { background: #fee2e2; color: #991b1b; } +.role-badge.admin { background: #dbeafe; color: #1e40af; } +.role-badge.field_officer { background: #d1fae5; color: #065f46; } +.status-badge { padding: 2px 7px; border-radius: 20px; font-size: 9.5px; font-weight: 600; display: inline-block; } +.status-badge.active { background: #d1fae5; color: #065f46; } +.status-badge.inactive { background: #fee2e2; color: #991b1b; } +.action-btn { padding: 3px 8px; border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 10px; background: var(--surface); font-family: 'DM Sans', sans-serif; transition: all 0.12s; } +.action-btn:hover { border-color: var(--accent); color: var(--accent); } +.action-btn.danger { color: var(--danger); } .action-btn.danger:hover { border-color: var(--danger); background: #fff4f4; } +.btn-approve { background: var(--success); color: white; border: none; } +.btn-approve:hover { filter: brightness(0.92); } +.btn-reject { background: var(--danger); color: white; border: none; } +.btn-reject:hover { filter: brightness(0.92); } +.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; min-width: 14px; text-align: center; +} +.pending-badge:empty { display: none; } +#adminModal .modal { max-width: 800px; } + +/* ===== LEAFLET NATIVE ===== */ +.leaflet-control-zoom { border-radius: var(--r-sm) !important; border: 1px solid var(--border) !important; box-shadow: var(--shadow-sm) !important; } +.leaflet-control-zoom a { color: var(--text-secondary) !important; border-radius: inherit !important; font-weight: 600 !important; } +.leaflet-control-zoom a:hover { background: var(--accent-light) !important; color: var(--accent) !important; } + +/* ===== CUSTOM MAP MARKERS ===== */ +.custom-marker-center { + width: 32px; height: 32px; border-radius: 50% 50% 50% 0; + transform: rotate(-45deg); display: flex; align-items: center; + justify-content: center; box-shadow: 0 3px 10px rgba(0,0,0,0.28); + border: 2px solid rgba(255,255,255,0.85); transition: transform 0.15s; +} +.custom-marker-center i { transform: rotate(45deg); font-size: 12px; color: white; } +.custom-marker-center:hover { transform: rotate(-45deg) scale(1.12); } + +.custom-marker-house { + width: 26px; height: 26px; border-radius: 50% 50% 50% 0; + transform: rotate(-45deg); display: flex; align-items: center; + justify-content: center; box-shadow: 0 2px 8px rgba(0,0,0,0.25); + border: 2px solid rgba(255,255,255,0.85); position: relative; +} +.custom-marker-house i { transform: rotate(45deg); font-size: 10px; color: white; } +.marker-aid-dot { + position: absolute; top: -2px; right: -2px; + width: 8px; height: 8px; border-radius: 50%; + background: #ffe066; border: 1.5px solid #b8860b; transform: rotate(45deg); +} + +/* ===== HOUSE POPUP (.hp-*) ===== */ +.hp-leaflet-popup .leaflet-popup-content-wrapper { + padding: 0; border-radius: var(--r-lg); + box-shadow: var(--shadow-lg); border: 1px solid var(--border); overflow: hidden; +} +.hp-leaflet-popup .leaflet-popup-content { + margin: 0 !important; + min-width: 300px !important; max-width: 360px !important; width: 100% !important; +} +.hp-leaflet-popup .leaflet-popup-tip-container { margin-top: -1px; } + +.hp-popup { + display: flex; flex-direction: column; + font-family: 'DM Sans', -apple-system, sans-serif; + font-size: 12px; color: var(--text-primary); + background: var(--surface); border-radius: var(--r-lg); + overflow: hidden; max-height: 82vh; +} +.hp-header { + display: flex; align-items: center; gap: 8px; + padding: 12px 14px 10px; + background: linear-gradient(160deg,#f9fafe 0%,#fff 100%); + border-bottom: 1px solid var(--border-subtle); flex-shrink: 0; +} +.hp-avatar { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 15px; flex-shrink: 0; } +.hp-header-info { flex: 1; min-width: 0; } +.hp-head-name { font-size: 13.5px; font-weight: 700; color: var(--text-primary); line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.hp-nik { font-size: 10px; color: var(--text-muted); margin-top: 2px; font-family: 'DM Mono', monospace; } +.hp-drag-hint { width: 28px; height: 28px; border-radius: 8px; display: flex; align-items: center; justify-content: center; background: var(--accent-light); border: 1px solid var(--border); color: var(--accent); font-size: 11px; flex-shrink: 0; } +.hp-status-strip { display: flex; gap: 6px; padding: 9px 14px; background: var(--surface-2); border-bottom: 1px solid var(--border-subtle); flex-shrink: 0; flex-wrap: wrap; } +.hp-status-chip { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 20px; border: 1px solid transparent; font-size: 10px; font-weight: 700; white-space: nowrap; } +.hp-status-chip i { font-size: 9px; } +.hp-chip-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } +.hp-body { overflow-y: auto; flex: 1; padding: 0 0 4px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; -webkit-overflow-scrolling: touch; } +.hp-body::-webkit-scrollbar { width: 3px; } +.hp-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +.hp-section { padding: 11px 14px; border-bottom: 1px solid var(--border-subtle); } +.hp-section:last-child { border-bottom: none; } +.hp-section-header { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 8px; } +.hp-section-header i { font-size: 9px; width: 12px; text-align: center; } +.hp-count { margin-left: auto; background: var(--surface-3); border: 1px solid var(--border); color: var(--text-muted); font-size: 9px; font-weight: 700; padding: 0 5px; border-radius: 10px; font-family: 'DM Mono', monospace; } +.hp-address { font-size: 11.5px; color: var(--text-secondary); line-height: 1.5; margin-bottom: 7px; word-break: break-word; } +.hp-pills { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 7px; } +.hp-pill { padding: 2px 8px; border-radius: 20px; background: var(--accent-subtle); color: var(--accent); font-size: 10px; font-weight: 600; white-space: nowrap; } +.hp-center-row { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-secondary); margin-bottom: 7px; } +.hp-center-row i { color: var(--accent); font-size: 10px; flex-shrink: 0; } +.hp-coords { display: flex; align-items: center; gap: 5px; font-size: 10px; color: var(--text-muted); font-family: 'DM Mono', monospace; } +.hp-coords i { font-size: 9px; } +.hp-kv-grid { display: flex; flex-direction: column; gap: 5px; } +.hp-kv-row { display: flex; align-items: baseline; gap: 6px; font-size: 11.5px; line-height: 1.4; } +.hp-kv-label { flex-shrink: 0; width: 90px; font-size: 10px; font-weight: 600; color: var(--text-muted); } +.hp-kv-val { color: var(--text-primary); flex: 1; word-break: break-word; } +.hp-members-list { display: flex; flex-direction: column; gap: 6px; } +.hp-member-row { display: flex; align-items: center; gap: 8px; } +.hp-member-avatar { width: 24px; height: 24px; border-radius: 7px; background: var(--surface-3); border: 1px solid var(--border); display: flex; align-items: center; justify-content: center; color: var(--text-muted); font-size: 9px; flex-shrink: 0; } +.hp-member-info { flex: 1; min-width: 0; } +.hp-member-name { font-size: 11.5px; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.hp-member-meta { font-size: 10px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.hp-member-more { font-size: 10px; color: var(--text-muted); text-align: center; padding: 4px 0 0; border-top: 1px dashed var(--border); margin-top: 4px; display: flex; align-items: center; justify-content: center; gap: 5px; } +.hp-aid-list { display: flex; flex-direction: column; gap: 8px; } +.hp-aid-row { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; align-items: start; padding-bottom: 8px; border-bottom: 1px solid var(--border-subtle); } +.hp-aid-row:last-child { border-bottom: none; padding-bottom: 0; } +.hp-aid-left { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; grid-column: 1; } +.hp-aid-type { display: inline-block; padding: 2px 8px; border-radius: 20px; background: #e0faf3; color: #0b9e73; font-size: 9.5px; font-weight: 700; } +.hp-aid-amount { font-size: 11px; font-weight: 700; color: var(--text-primary); } +.hp-aid-date { font-size: 10px; color: var(--text-muted); text-align: right; grid-column: 2; grid-row: 1; white-space: nowrap; } +.hp-aid-note { grid-column: 1 / -1; font-size: 10px; color: var(--text-muted); line-height: 1.4; } +.hp-empty-hint { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--text-muted); font-style: italic; } +.hp-empty-hint i { font-size: 12px; } +.hp-actions { + display: flex; gap: 6px; padding: 10px 14px 12px; + border-top: 1px solid var(--border-subtle); + background: var(--surface); flex-shrink: 0; flex-wrap: wrap; +} +.hp-btn { display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px; border-radius: var(--r-sm); border: none; font-size: 11.5px; font-weight: 600; font-family: 'DM Sans', sans-serif; cursor: pointer; transition: all 0.14s; white-space: nowrap; } +.hp-btn i { font-size: 10px; } +.hp-btn-primary { background: var(--accent); color: white; } +.hp-btn-primary:hover { background: var(--accent-hover); } +.hp-btn-success { background: #e0faf3; color: var(--success); flex: 1; justify-content: center; } +.hp-btn-success:hover { background: #c8f2e6; } +.hp-btn-danger { background: #fff0f0; color: var(--danger); padding: 6px 10px; } +.hp-btn-danger:hover { background: #fddede; } + +/* Admin top bar */ +.admin-top-bar { position: absolute; top: 10px; left: 50%; transform: translateX(-50%); z-index: 1001; display: flex; gap: 6px; align-items: center; pointer-events: none; } +.admin-top-bar a { pointer-events: all; } + +/* ===== PRINT ===== */ +@media print { + .sidebar, .legend, .custom-layer-control, .loading, .toast, .help-btn { display: none !important; } + #map { height: 100vh !important; } +} + +/* ===== FOCUS ===== */ +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* MOBILE RESPONSIVE */ + +/* ── Tablet (≀900px) ── */ +@media (max-width: 900px) { + #dashboardModal .modal { max-width: 96vw; } + .dashboard-grid { gap: 12px; } +} + +/* ── Mobile (≀768px) ── */ +@media (max-width: 768px) { + /* Show hamburger toggle */ + .sidebar-toggle { display: flex; } + + /* Sidebar becomes a slide-in drawer from the left */ + .sidebar { + top: 0; left: 0; + width: 300px; max-width: 88vw; + max-height: 100vh; + border-radius: 0 var(--r-xl) var(--r-xl) 0; + transform: translateX(-100%); + opacity: 0; pointer-events: none; + box-shadow: none; + } + .sidebar.mobile-open { + transform: translateX(0); + opacity: 1; pointer-events: all; + box-shadow: var(--shadow-lg); + } + + /* LAYER CONTROL: stays top-right, just slightly smaller */ + .custom-layer-control { + top: 14px; + right: 14px; + padding: 9px 11px; + /* hide count labels to save space */ + } + .custom-layer-control small { display: none; } + .custom-layer-control h4 { margin-bottom: 6px; font-size: 9px; } + .custom-layer-item { padding: 4px 0; } + + /* LEGEND: stays bottom-right, collapse by default on mobile */ + .legend { + top: 100px; + right: 14px; + padding: 9px 12px; + min-width: 0; + } + .legend .legend-content { display: none; } + .legend.collapsed .legend-content { display: none; } + .legend:not(.collapsed) .legend-content { display: block; margin-top: 8px; } + .legend:not(.collapsed) h4 { margin-bottom: 7px; padding-bottom: 6px; border-bottom: 1px solid var(--border-subtle); } + .legend.collapsed h4 { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } + + /* Help button β€” move left so it doesn't sit on top of legend */ + .help-btn { bottom: 100px; right: 10px; } + + /* Stats */ + .stat-value { font-size: 19px; } + .stats-grid { gap: 5px; } + + /* Popup */ + .hp-leaflet-popup .leaflet-popup-content { + min-width: 270px !important; + max-width: calc(100vw - 32px) !important; + } + .hp-popup { max-height: 68vh; } + + /* Dashboard full-screen */ + #dashboardModal .modal { + max-width: 100%; width: 100%; + max-height: 100vh; border-radius: 0; + } + .dashboard-grid { grid-template-columns: 1fr; gap: 10px; } + .dashboard-scroll { padding: 12px 14px; } + .chart-canvas-wrap { min-height: 200px; } + .chart-card-full .chart-canvas-wrap { min-height: 220px; } + + /* Admin modal */ + #adminModal .modal { max-width: 100%; border-radius: var(--r-lg); } + + /* Loading: center top */ + .loading { left: 50%; right: auto; transform: translateX(-50%); } + + /* Toast: allow wrapping */ + .toast { white-space: normal; max-width: calc(100vw - 28px); } + + /* Modals: slightly tighter on mobile */ + .modal { padding: 20px 18px; max-height: 92vh; } +} + +/* ── Small phones (≀480px) ── */ +@media (max-width: 480px) { + .sidebar { max-width: 92vw; border-radius: 0 var(--r-lg) var(--r-lg) 0; } + + /* Stat cards compact */ + .stat-value { font-size: 17px; } + .stat-card { padding: 9px 10px 7px; } + .stat-label { font-size: 8.5px; } + + /* Nav tabs: smaller */ + .nav-tab { font-size: 9.5px; padding: 5px 3px; } + + /* Popup width */ + .hp-leaflet-popup .leaflet-popup-content { + min-width: 250px !important; + max-width: calc(100vw - 24px) !important; + } + + /* Layer control: icon + checkboxes only, no label text */ + .custom-layer-item span { display: none; } + .custom-layer-control h4 span { display: none; } + .custom-layer-control { padding: 8px 9px; } + + /* Admin toolbar wraps */ + .admin-toolbar { flex-wrap: wrap; } + .admin-top-bar a span { display: none; } + + /* Modal padding tight */ + .modal { padding: 16px 14px; } +} + +/* ── Very small (≀360px) ── */ +@media (max-width: 360px) { + .sidebar-title p { display: none; } + .stat-value { font-size: 15px; } + .nav-tab i + * { display: none; } /* icon only */ +} + +/* ── Landscape phone ── */ +@media (max-height: 480px) and (orientation: landscape) { + .sidebar { max-height: 100vh; } + .sidebar-content { gap: 6px; padding: 8px 10px; } + .stats-grid { grid-template-columns: repeat(5,1fr); } + .data-list { max-height: 100px; } + .legend { display: none; } + .chart-canvas-wrap { min-height: 160px; } +} + +/* ── Photo upload widget ──────────────────────────────────────── */ +.photo-upload-zone { + border: 2px dashed var(--border, #e2e6ef); + border-radius: 10px; + padding: 14px 12px 10px; + text-align: center; + cursor: pointer; + transition: border-color .2s, background .2s; + background: var(--bg-secondary, #f8f9fc); + margin-top: 2px; +} +.photo-upload-zone:hover, +.photo-upload-zone.dragover { + border-color: var(--accent, #5c72f5); + background: rgba(92,114,245,.05); +} +.photo-upload-zone input[type="file"] { + display: none; +} +.photo-upload-zone .puz-label { + font-size: 12px; + color: var(--text-muted, #9ba4b5); + margin-top: 6px; + display: block; +} +.photo-upload-zone .puz-icon { + font-size: 22px; + color: var(--accent, #5c72f5); + opacity: .7; +} +.photo-strip { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; + min-height: 0; +} +.photo-thumb { + width: 72px; + height: 72px; + object-fit: cover; + border-radius: 8px; + border: 1.5px solid var(--border, #e2e6ef); + transition: transform .15s; +} +.photo-thumb:hover { transform: scale(1.06); } +.photo-error-msg { + font-size: 11px; + color: var(--danger, #d63230); + margin-top: 5px; + display: none; +} +.photo-error-msg.show { display: block; } +/* Popup photo strip */ +.popup-photo-strip { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 8px; +} +.popup-photo-strip img { + width: 60px; + height: 60px; + object-fit: cover; + border-radius: 6px; + border: 1.5px solid #e2e6ef; + cursor: zoom-in; + transition: transform .15s; +} +.popup-photo-strip img:hover { transform: scale(1.08); } \ No newline at end of file diff --git a/03PovertyMapping/assets/js/admin.js b/03PovertyMapping/assets/js/admin.js new file mode 100644 index 0000000..e057d9b --- /dev/null +++ b/03PovertyMapping/assets/js/admin.js @@ -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); +}); \ No newline at end of file diff --git a/03PovertyMapping/assets/js/api.js b/03PovertyMapping/assets/js/api.js new file mode 100644 index 0000000..addf140 --- /dev/null +++ b/03PovertyMapping/assets/js/api.js @@ -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' }); + }, +}; \ No newline at end of file diff --git a/03PovertyMapping/assets/js/app.js b/03PovertyMapping/assets/js/app.js new file mode 100644 index 0000000..85331f7 --- /dev/null +++ b/03PovertyMapping/assets/js/app.js @@ -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')); + } +}); \ No newline at end of file diff --git a/03PovertyMapping/assets/js/auth.js b/03PovertyMapping/assets/js/auth.js new file mode 100644 index 0000000..44382ef --- /dev/null +++ b/03PovertyMapping/assets/js/auth.js @@ -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 = ''; + 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 +}; \ No newline at end of file diff --git a/03PovertyMapping/assets/js/config.js b/03PovertyMapping/assets/js/config.js new file mode 100644 index 0000000..60a332e --- /dev/null +++ b/03PovertyMapping/assets/js/config.js @@ -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 = ''; + } +}); \ No newline at end of file diff --git a/03PovertyMapping/assets/js/dashboard.js b/03PovertyMapping/assets/js/dashboard.js new file mode 100644 index 0000000..4532c4e --- /dev/null +++ b/03PovertyMapping/assets/js/dashboard.js @@ -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 = '
Belum ada data pendataan 12 bulan terakhir
'; + } + 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 }, + }, + }, + }, + }); +} \ No newline at end of file diff --git a/03PovertyMapping/assets/js/forms.js b/03PovertyMapping/assets/js/forms.js new file mode 100644 index 0000000..4ae4257 --- /dev/null +++ b/03PovertyMapping/assets/js/forms.js @@ -0,0 +1,1004 @@ +/* ============================================================ + forms.js β€” All modal form logic (FULLY UPDATED) + ============================================================ */ +'use strict'; + +function clientCalcPoverty(income, dependents, condition, education, landOwnership) { + let severityPoints = 0; + const indicators = []; + const members = Math.max(1, dependents); + const perCapita = income / members; + if (perCapita < 400000) { indicators.push('Pendapatan per kapita sangat rendah (< Rp 400.000)'); severityPoints += 3; } + else if (perCapita < 700000) { indicators.push('Pendapatan per kapita rendah (< Rp 700.000)'); severityPoints += 2; } + else if (perCapita < 1200000) { indicators.push('Pendapatan per kapita di bawah UMP'); severityPoints += 1; } + if (dependents >= 7) { indicators.push('Tanggungan sangat besar (β‰₯ 7 orang)'); severityPoints += 3; } + else if (dependents >= 5) { indicators.push('Tanggungan besar (5-6 orang)'); severityPoints += 2; } + else if (dependents >= 4) { indicators.push('Tanggungan cukup besar (4 orang)'); severityPoints += 1; } + if (condition === 'tidak_layak') { indicators.push('Kondisi rumah tidak layak huni'); severityPoints += 3; } + const eduMap = { 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 (eduMap[education] && eduMap[education][1] > 0) { indicators.push(eduMap[education][0]); severityPoints += eduMap[education][1]; } + if (landOwnership === 'numpang') { indicators.push('Tidak memiliki lahan (numpang)'); severityPoints += 2; } + else if (landOwnership === 'sewa') { indicators.push('Lahan menyewa'); severityPoints += 1; } + let status, label; + if (severityPoints >= 7) { status = 'sangat_miskin'; label = 'Sangat Miskin'; } + else if (severityPoints >= 4) { status = 'miskin'; label = 'Miskin'; } + else if (severityPoints >= 1) { status = 'rentan_miskin'; label = 'Rentan Miskin'; } + else { status = 'terdata'; label = 'Terdata'; } + return { status, label, indicators, severity: severityPoints }; +} + +function recalcPoverty() { + const income = parseInt(document.getElementById('houseIncome')?.value || 0); + const dependents = parseInt(document.getElementById('houseDependents')?.value || 1); + const condition = document.getElementById('houseCondition')?.value || 'layak'; + const education = document.getElementById('houseEducation')?.value || 'sd'; + const landOwnership = document.getElementById('houseLandOwnership')?.value || 'milik'; + const { status, label, indicators } = clientCalcPoverty(income, dependents, condition, education, landOwnership); + const color = POVERTY_COLORS[status] || '#9ba4b5'; + const ring = document.getElementById('povertyRing'); + const icon = document.getElementById('povertyIcon'); + const labelEl = document.getElementById('povertyStatusLabel'); + const indicatorList = document.getElementById('povertyIndicators'); + if (ring) { ring.style.borderColor = color; ring.style.boxShadow = `0 0 12px ${color}30`; } + if (icon) icon.style.color = color; + if (labelEl) { const shortLabels = { sangat_miskin: 'Sangat Miskin', miskin: 'Miskin', rentan_miskin: 'Rentan Miskin', terdata: 'Terdata' }; labelEl.textContent = shortLabels[status] || label; labelEl.style.color = color; } + if (indicatorList && indicators.length > 0) indicatorList.innerHTML = indicators.map(i => `
  • ${i}
  • `).join(''); + else if (indicatorList) indicatorList.innerHTML = '
  • Tidak ada indikator kemiskinan signifikan
  • '; +} + +function initFormTabs() { + document.querySelectorAll('.form-tab').forEach(tab => { + tab.addEventListener('click', () => { + const panel = tab.dataset.ftab; + document.querySelectorAll('.form-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.form-tab-panel').forEach(p => p.classList.remove('active')); + tab.classList.add('active'); + document.getElementById('ftab-' + panel)?.classList.add('active'); + }); + }); +} + +async function reverseGeocodeDetailed(lat, lng) { + try { + const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&accept-language=id&addressdetails=1&zoom=18`; + const response = await fetch(url, { headers: { 'User-Agent': 'WebGIS-PovertyMapping/2.0' } }); + const data = await response.json(); + let rt = '', rw = '', kelurahan = '', kecamatan = '', fullAddress = ''; + if (data && data.address) { + const addr = data.address; + rt = addr.quarter || addr.suburb || ''; + rw = addr.neighbourhood || ''; + kelurahan = addr.village || addr.suburb || addr.city_district || ''; + kecamatan = addr.city || addr.town || addr.municipality || ''; + const parts = []; + if (addr.road) parts.push(addr.road); + if (addr.house_number) parts.push('No. ' + addr.house_number); + if (rt) parts.push('RT ' + rt); + if (rw) parts.push('RW ' + rw); + if (kelurahan) parts.push('Kel. ' + kelurahan); + if (kecamatan) parts.push('Kec. ' + kecamatan); + if (addr.city) parts.push(addr.city); + if (addr.state) parts.push(addr.state); + fullAddress = parts.join(', '); + } + return { rt, rw, kelurahan, kecamatan, fullAddress: fullAddress || `${lat.toFixed(6)}, ${lng.toFixed(6)}` }; + } catch (err) { return { rt: '', rw: '', kelurahan: '', kecamatan: '', fullAddress: `${lat.toFixed(6)}, ${lng.toFixed(6)}` }; } +} + +async function autoAssignReligiousCenter(lat, lng) { + try { + const r = await ApiCenters.nearby(lat, lng, 2); + if (r.ok && r.data?.success && r.data.data.centers.length) { + const nearest = r.data.data.centers[0]; + document.getElementById('managingCenterId').value = nearest.id; + document.getElementById('managingCenterName').value = nearest.name; + return nearest.id; + } + } catch (err) { console.warn('Failed to auto-assign center:', err); } + return null; +} + +function openCenterModal(id = null, lat = null, lng = null, address = '') { + const isEdit = !!id; + document.getElementById('centerModalTitle').textContent = isEdit ? 'Edit Tempat Ibadah' : 'Tambah Tempat Ibadah'; + document.getElementById('centerId').value = id || ''; + document.getElementById('centerLat').value = lat || ''; + document.getElementById('centerLng').value = lng || ''; + if (!isEdit) { + document.getElementById('centerName').value = ''; + document.getElementById('centerWorshipType').value = 'masjid'; + document.getElementById('centerRadius').value = '300'; + document.getElementById('centerRadiusSlider').value = '300'; + document.getElementById('centerRadiusValue').textContent = '300m'; + document.getElementById('centerContactPerson').value = ''; + document.getElementById('centerContactPhone').value = ''; + document.getElementById('centerNotes').value = ''; + document.getElementById('centerAddress').value = address; + document.getElementById('centerLatDisplay').value = lat ? lat.toFixed(6) : ''; + document.getElementById('centerLngDisplay').value = lng ? lng.toFixed(6) : ''; + cancelPlacementMode(); + openModal('centerModal'); + return; + } + showLoading(true); + ApiCenters.show(id).then(r => { + showLoading(false); + if (!r.ok) { showToast('Gagal memuat data.', 'error'); return; } + const c = r.data.data; + document.getElementById('centerName').value = c.name; + document.getElementById('centerWorshipType').value = c.worship_type; + document.getElementById('centerRadius').value = c.radius; + document.getElementById('centerRadiusSlider').value = c.radius; + document.getElementById('centerRadiusValue').textContent = c.radius + 'm'; + document.getElementById('centerContactPerson').value = c.contact_person || ''; + document.getElementById('centerContactPhone').value = c.contact_phone || ''; + document.getElementById('centerNotes').value = c.notes || ''; + document.getElementById('centerAddress').value = c.address; + document.getElementById('centerLat').value = c.latitude; + document.getElementById('centerLng').value = c.longitude; + document.getElementById('centerLatDisplay').value = parseFloat(c.latitude).toFixed(6); + document.getElementById('centerLngDisplay').value = parseFloat(c.longitude).toFixed(6); + openModal('centerModal'); + }); +} + +document.getElementById('centerRadiusSlider')?.addEventListener('input', function () { document.getElementById('centerRadius').value = this.value; document.getElementById('centerRadiusValue').textContent = this.value + 'm'; }); +document.getElementById('centerRadius')?.addEventListener('input', function () { document.getElementById('centerRadiusSlider').value = this.value; document.getElementById('centerRadiusValue').textContent = this.value + 'm'; }); + +document.getElementById('centerForm')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const id = document.getElementById('centerId').value; + const lat = parseFloat(document.getElementById('centerLat').value); + const lng = parseFloat(document.getElementById('centerLng').value); + if (!lat || !lng) { showToast('Koordinat tidak valid.', 'error'); return; } + const body = { name: document.getElementById('centerName').value.trim(), worship_type: document.getElementById('centerWorshipType').value, radius: parseInt(document.getElementById('centerRadius').value), address: document.getElementById('centerAddress').value, latitude: lat, longitude: lng, contact_person: document.getElementById('centerContactPerson').value.trim(), contact_phone: document.getElementById('centerContactPhone').value.trim(), notes: document.getElementById('centerNotes').value.trim() }; + if (!body.name) { showToast('Nama tempat ibadah wajib diisi.', 'error'); return; } + showLoading(true); + const r = id ? await ApiCenters.update(id, body) : await ApiCenters.create(body); + showLoading(false); + if (r.ok && r.data?.success) { + closeModal('centerModal'); + cancelPlacementMode(); + showToast(id ? 'Tempat ibadah diperbarui.' : 'Tempat ibadah ditambahkan.', 'success'); + await loadAllData(); + loadStats(); + } else showToast(r.data?.message || 'Gagal menyimpan.', 'error'); + return false; +}); + +async function editCenter(id) { if (MAP) MAP.closePopup(); openCenterModal(id); } +async function deleteCenter(id) { + if (!window.canDelete) { showToast('Anda tidak memiliki izin untuk menghapus data.', 'error'); return; } + if (!confirm('Hapus tempat ibadah ini? Semua data terkait akan dihapus.')) return; + showLoading(true); + const r = await ApiCenters.delete(id); + showLoading(false); + if (MAP) MAP.closePopup(); + if (r.ok && r.data?.success) { showToast('Tempat ibadah dihapus.', 'success'); await loadAllData(); recountCenterHouseholds(); renderCenterList(); loadStats(); } + else showToast(r.data?.message || 'Gagal menghapus.', 'error'); +} + +async function openHouseModal(id = null, lat = null, lng = null, address = '') { + const isEdit = !!id; + document.getElementById('houseModalTitle').textContent = isEdit ? 'Edit Data Rumah Tangga' : 'Tambah Rumah Tangga'; + document.getElementById('houseId').value = id || ''; + document.getElementById('houseLat').value = lat || ''; + document.getElementById('houseLng').value = lng || ''; + document.getElementById('familyMembersData').value = '[]'; + + // Mode TAMBAH BARU (dari klik peta) + if (!isEdit && lat && lng) { + const addr = await reverseGeocodeDetailed(lat, lng); + document.getElementById('houseRt').value = addr.rt || ''; + document.getElementById('houseRw').value = addr.rw || ''; + document.getElementById('houseKelurahan').value = addr.kelurahan || ''; + document.getElementById('houseKecamatan').value = addr.kecamatan || ''; + document.getElementById('houseFullAddress').value = addr.fullAddress || address || ''; + document.getElementById('houseLatDisplay').value = lat.toFixed(6); + document.getElementById('houseLngDisplay').value = lng.toFixed(6); + await autoAssignReligiousCenter(lat, lng); + + // Reset form fields + document.getElementById('houseHeadName').value = ''; + document.getElementById('houseNIK').value = ''; + document.getElementById('houseGender').value = 'male'; + document.getElementById('houseDOB').value = ''; + document.getElementById('houseEducation').value = 'sd'; + document.getElementById('houseIncome').value = '0'; + document.getElementById('houseJob').value = ''; + document.getElementById('houseCondition').value = 'layak'; + document.getElementById('houseLandOwnership').value = 'milik'; + document.getElementById('houseDescription').value = ''; + document.getElementById('houseAidStatus').value = 'not_yet'; + document.getElementById('houseEmploymentStatus').value = 'unemployed'; + document.getElementById('houseJobGroup').style.display = 'none'; + document.getElementById('houseInstitutionGroup').style.display = 'none'; + document.getElementById('houseJob').value = ''; + document.getElementById('houseInstitutionName').value = ''; + + // Reset family members display + const familyContainer = document.getElementById('familyMembersList'); + if (familyContainer) { + familyContainer.innerHTML = '

    Belum ada anggota keluarga

    '; + } + + // Reset aid history display + renderAidHistory([]); + + // Sembunyikan tombol add aid (karena household belum tersimpan) + const addAidBtn = document.getElementById('addAidBtn'); + if (addAidBtn) addAidBtn.style.display = 'none'; + + const savedStrip = document.getElementById('houseSavedPhotoStrip'); + const newStrip = document.getElementById('housePhotoStrip'); + const photoInput = document.getElementById('housePhotos'); + if (savedStrip) { + savedStrip.innerHTML = ''; + newStrip.innerHTML = ''; + if (photoInput) photoInput.value = ''; + } + + cancelPlacementMode(); + openModal('houseModal'); + recalcPoverty(); // Hitung ulang poverty preview + return; + } + + // Mode TAMBAH BARU (tanpa koordinat) + if (!isEdit && !lat && !lng) { + // Reset semua form + document.getElementById('houseRt').value = ''; + document.getElementById('houseRw').value = ''; + document.getElementById('houseKelurahan').value = ''; + document.getElementById('houseKecamatan').value = ''; + document.getElementById('houseFullAddress').value = ''; + document.getElementById('houseLatDisplay').value = ''; + document.getElementById('houseLngDisplay').value = ''; + document.getElementById('houseSavedPhotoStrip').innerHTML = ''; + document.getElementById('housePhotoStrip').innerHTML = ''; + document.getElementById('managingCenterId').value = ''; + document.getElementById('managingCenterName').value = ''; + document.getElementById('houseHeadName').value = ''; + document.getElementById('houseNIK').value = ''; + document.getElementById('houseGender').value = 'male'; + document.getElementById('houseDOB').value = ''; + document.getElementById('houseEducation').value = 'sd'; + document.getElementById('houseIncome').value = '0'; + document.getElementById('houseJob').value = ''; + document.getElementById('houseCondition').value = 'layak'; + document.getElementById('houseLandOwnership').value = 'milik'; + document.getElementById('houseDescription').value = ''; + document.getElementById('houseAidStatus').value = 'not_yet'; + + const photoInput = document.getElementById('housePhotos'); + if (photoInput) photoInput.value = ''; + + const familyContainer = document.getElementById('familyMembersList'); + if (familyContainer) { + familyContainer.innerHTML = '

    Belum ada anggota keluarga

    '; + } + renderAidHistory([]); + + const addAidBtn = document.getElementById('addAidBtn'); + if (addAidBtn) addAidBtn.style.display = 'none'; + + cancelPlacementMode(); + openModal('houseModal'); + recalcPoverty(); + return; + } + + // ================================================================ + // EDIT MODE: Load data dari API + // ================================================================ + if (isEdit) { + showLoading(true); + const r = await ApiHouses.show(id); + showLoading(false); + + if (!r.ok || !r.data?.success) { + showToast('Gagal memuat data rumah tangga.', 'error'); + return; + } + + const h = r.data.data; + + // Informasi Alamat + if (document.getElementById('houseRt')) + document.getElementById('houseRt').value = h.rt || ''; + if (document.getElementById('houseRw')) + document.getElementById('houseRw').value = h.rw || ''; + if (document.getElementById('houseKelurahan')) + document.getElementById('houseKelurahan').value = h.kelurahan || ''; + if (document.getElementById('houseKecamatan')) + document.getElementById('houseKecamatan').value = h.kecamatan || ''; + if (document.getElementById('houseFullAddress')) { + document.getElementById('houseFullAddress').value = h.full_address || h.address || ''; + // Hapus readonly attribute jika ada + document.getElementById('houseFullAddress').removeAttribute('readonly'); + // Set background ke surface (tidak readonly) + document.getElementById('houseFullAddress').style.background = 'var(--surface)'; + } + // Koordinat + if (document.getElementById('houseLat')) + document.getElementById('houseLat').value = h.latitude; + if (document.getElementById('houseLng')) + document.getElementById('houseLng').value = h.longitude; + if (document.getElementById('houseLatDisplay')) + document.getElementById('houseLatDisplay').value = parseFloat(h.latitude).toFixed(6); + if (document.getElementById('houseLngDisplay')) + document.getElementById('houseLngDisplay').value = parseFloat(h.longitude).toFixed(6); + + // Pusat Pengelola + if (document.getElementById('managingCenterId')) + document.getElementById('managingCenterId').value = h.managing_center_id || ''; + if (document.getElementById('managingCenterName')) + document.getElementById('managingCenterName').value = h.center_name || ''; + + // Kondisi Rumah + if (document.getElementById('houseCondition')) + document.getElementById('houseCondition').value = h.house_condition || 'layak'; + + // Kepemilikan Lahan + if (document.getElementById('houseLandOwnership')) + document.getElementById('houseLandOwnership').value = h.land_ownership || 'milik'; + + // Keterangan + if (document.getElementById('houseDescription')) + document.getElementById('houseDescription').value = h.notes || h.description || ''; + + // ⭐ KEPALA KELUARGA (Head of Household) + if (document.getElementById('houseHeadName')) + document.getElementById('houseHeadName').value = h.head_name || ''; + if (document.getElementById('houseNIK')) + document.getElementById('houseNIK').value = h.head_nik || h.nik || ''; + if (document.getElementById('houseGender')) + document.getElementById('houseGender').value = h.head_gender || h.gender || 'male'; + if (document.getElementById('houseDOB')) + document.getElementById('houseDOB').value = h.head_date_of_birth || h.date_of_birth || ''; + if (document.getElementById('houseEducation')) + document.getElementById('houseEducation').value = h.head_education || h.education || 'sd'; + + // Pekerjaan dan Pendapatan + if (document.getElementById('houseJob')) + document.getElementById('houseJob').value = h.head_job_name || h.job || ''; + if (document.getElementById('houseIncome')) + document.getElementById('houseIncome').value = h.head_monthly_income || h.income || 0; + if (document.getElementById('houseEmploymentStatus')) { + const empStatus = h.head_employment_status || 'unemployed'; + document.getElementById('houseEmploymentStatus').value = empStatus; + toggleHouseEmploymentFields(); + + if (empStatus === 'working') { + document.getElementById('houseJob').value = h.head_job_name || ''; + } else if (empStatus === 'studying') { + document.getElementById('houseInstitutionName').value = h.head_institution_name || ''; + } + } + + // Status Bantuan + if (document.getElementById('houseAidStatus')) { + // Jika ada aid_history, set status ke 'received' + const hasAidHistory = (h.aid_history && h.aid_history.length > 0); + document.getElementById('houseAidStatus').value = hasAidHistory ? 'received' : (h.aid_status || 'not_yet'); + } + + // ⭐ ANGGOTA KELUARGA (Household Members) + if (h.household_members && h.household_members.length) { + document.getElementById('familyMembersData').value = JSON.stringify(h.household_members); + renderFamilyMembers(h.household_members); + } else if (h.family_members && h.family_members.length) { + document.getElementById('familyMembersData').value = JSON.stringify(h.family_members); + renderFamilyMembers(h.family_members); + } else { + document.getElementById('familyMembersData').value = '[]'; + const familyContainer = document.getElementById('familyMembersList'); + if (familyContainer) { + familyContainer.innerHTML = '

    Belum ada anggota keluarga

    '; + } + } + + // ⭐ RIWAYAT BANTUAN (Aid History) + if (h.aid_history && h.aid_history.length) { + renderAidHistory(h.aid_history); + } else { + renderAidHistory([]); + } + + const savedStrip = document.getElementById('houseSavedPhotoStrip'); + const newStrip = document.getElementById('housePhotoStrip'); + const photoInput = document.getElementById('housePhotos'); + if (savedStrip) { + savedStrip.innerHTML = ''; + newStrip.innerHTML = ''; + if (photoInput) photoInput.value = ''; + + const photos = JSON.parse(h.house_photos || '[]'); + if (photos.length) { + const hId = id; // capture for closure + savedStrip.appendChild( + PhotoUpload.buildSavedStrip( + photos, + 'uploads/houses/', + window.canDelete ?? false, // removable only for admin/officer + (filename, wrap) => deleteHousePhoto(hId, filename, wrap) + ) + ); + } + } + + // Tampilkan tombol add aid jika household sudah ada di database + const addAidBtn = document.getElementById('addAidBtn'); + if (addAidBtn) addAidBtn.style.display = 'inline-flex'; + + // Hitung ulang poverty preview berdasarkan data yang diisi + setTimeout(() => recalcPoverty(), 100); + + openModal('houseModal'); + } +} + +function renderFamilyMembers(members) { + const container = document.getElementById('familyMembersList'); + if (!container) return; + if (!members || members.length === 0) { container.innerHTML = '

    Belum ada anggota keluarga

    '; return; } + container.innerHTML = ''; + members.forEach((member, index) => { + const row = document.createElement('div'); + row.className = 'family-member-item'; + row.innerHTML = `
    ${escapeHtml(member.name)}${member.relationship || 'lainnya'}${member.employment_status === 'working' ? `πŸ’Ό ${escapeHtml(member.job_name || 'Bekerja')}` : ''}${member.employment_status === 'studying' ? `πŸ“š ${escapeHtml(member.institution_name || 'Sekolah')}` : ''}
    `; + container.appendChild(row); + }); +} + +let currentEditingMemberIndex = -1; +function openFamilyMemberModal(index = -1) { + currentEditingMemberIndex = index; + const title = document.getElementById('familyMemberModalTitle'); + if (index >= 0) { + title.textContent = 'Edit Anggota Keluarga'; + const members = JSON.parse(document.getElementById('familyMembersData').value || '[]'); + const member = members[index]; + document.getElementById('memberName').value = member.name || ''; + document.getElementById('memberNik').value = member.nik || ''; + document.getElementById('memberGender').value = member.gender || 'male'; + document.getElementById('memberDob').value = member.date_of_birth || ''; + document.getElementById('memberEducation').value = member.education || 'sd'; + document.getElementById('memberRelationship').value = member.relationship || 'lainnya'; + document.getElementById('memberEmploymentStatus').value = member.employment_status || 'unemployed'; + const jobGroup = document.getElementById('memberJobGroup'); + const institutionGroup = document.getElementById('memberInstitutionGroup'); + if (member.employment_status === 'working') { if (jobGroup) jobGroup.style.display = 'block'; if (institutionGroup) institutionGroup.style.display = 'none'; document.getElementById('memberJobName').value = member.job_name || ''; } + else if (member.employment_status === 'studying') { if (jobGroup) jobGroup.style.display = 'none'; if (institutionGroup) institutionGroup.style.display = 'block'; document.getElementById('memberInstitutionName').value = member.institution_name || ''; } + else { if (jobGroup) jobGroup.style.display = 'none'; if (institutionGroup) institutionGroup.style.display = 'none'; } + document.getElementById('memberIncome').value = member.monthly_income || 0; + } else { + title.textContent = 'Tambah Anggota Keluarga'; + document.getElementById('memberForm').reset(); + document.getElementById('memberGender').value = 'male'; + document.getElementById('memberEducation').value = 'sd'; + document.getElementById('memberRelationship').value = 'lainnya'; + document.getElementById('memberEmploymentStatus').value = 'unemployed'; + if (document.getElementById('memberJobGroup')) document.getElementById('memberJobGroup').style.display = 'none'; + if (document.getElementById('memberInstitutionGroup')) document.getElementById('memberInstitutionGroup').style.display = 'none'; + document.getElementById('memberIncome').value = '0'; + } + openModal('familyMemberModal'); +} + +function saveFamilyMember() { + const member = { name: document.getElementById('memberName').value.trim(), nik: document.getElementById('memberNik').value.trim(), gender: document.getElementById('memberGender').value, date_of_birth: document.getElementById('memberDob').value, education: document.getElementById('memberEducation').value, relationship: document.getElementById('memberRelationship').value, employment_status: document.getElementById('memberEmploymentStatus').value, job_name: document.getElementById('memberJobName').value.trim(), institution_name: document.getElementById('memberInstitutionName').value.trim(), monthly_income: parseInt(document.getElementById('memberIncome').value) || 0 }; + if (!member.name) { showToast('Nama anggota keluarga harus diisi.', 'error'); return; } + let members = JSON.parse(document.getElementById('familyMembersData').value || '[]'); + if (currentEditingMemberIndex >= 0) members[currentEditingMemberIndex] = member; + else members.push(member); + document.getElementById('familyMembersData').value = JSON.stringify(members); + renderFamilyMembers(members); + closeModal('familyMemberModal'); +} + +function editFamilyMember(index) { openFamilyMemberModal(index); } +function deleteFamilyMember(index) { + if (!confirm('Hapus anggota keluarga ini?')) return; + let members = JSON.parse(document.getElementById('familyMembersData').value || '[]'); + members.splice(index, 1); + document.getElementById('familyMembersData').value = JSON.stringify(members); + renderFamilyMembers(members); +} + +function toggleMemberEmploymentFields() { + const status = document.getElementById('memberEmploymentStatus')?.value; + const jobGroup = document.getElementById('memberJobGroup'); + const institutionGroup = document.getElementById('memberInstitutionGroup'); + if (status === 'working') { if (jobGroup) jobGroup.style.display = 'block'; if (institutionGroup) institutionGroup.style.display = 'none'; } + else if (status === 'studying') { if (jobGroup) jobGroup.style.display = 'none'; if (institutionGroup) institutionGroup.style.display = 'block'; } + else { if (jobGroup) jobGroup.style.display = 'none'; if (institutionGroup) institutionGroup.style.display = 'none'; } +} + +function toggleHouseEmploymentFields() { + const status = document.getElementById('houseEmploymentStatus')?.value; + const jobGroup = document.getElementById('houseJobGroup'); + const institutionGroup = document.getElementById('houseInstitutionGroup'); + const jobField = document.getElementById('houseJob'); + const institutionField = document.getElementById('houseInstitutionName'); + + if (status === 'working') { + if (jobGroup) jobGroup.style.display = 'block'; + if (institutionGroup) institutionGroup.style.display = 'none'; + if (jobField) jobField.required = false; + if (institutionField) institutionField.required = false; + } else if (status === 'studying') { + if (jobGroup) jobGroup.style.display = 'none'; + if (institutionGroup) institutionGroup.style.display = 'block'; + if (jobField) jobField.required = false; + if (institutionField) institutionField.required = false; + } else { + if (jobGroup) jobGroup.style.display = 'none'; + if (institutionGroup) institutionGroup.style.display = 'none'; + if (jobField) jobField.required = false; + if (institutionField) institutionField.required = false; + } +} + +async function editHouse(id) { if (MAP) MAP.closePopup(); await openHouseModal(id); } +async function deleteHouse(id) { + if (!window.canDelete) { showToast('Anda tidak memiliki izin untuk menghapus data.', 'error'); return; } + if (!confirm('Hapus data rumah ini? Semua data terkait akan dihapus.')) return; + showLoading(true); + const r = await ApiHouses.delete(id); + showLoading(false); + if (MAP) MAP.closePopup(); + if (r.ok && r.data?.success) { + showToast('Data rumah dihapus.', 'success'); + await loadAllData(); + + // ⭐ HITUNG ULANG CENTER HOUSEHOLDS + recountCenterHouseholds(); + renderCenterList(); + renderHouseList(); + updateLayerCounts(); + loadStats(); + } else { + showToast(r.data?.message || 'Gagal menghapus.', 'error'); + } +} + +function openAidModalForHouse(householdId) { if (MAP) MAP.closePopup(); document.getElementById('aidHouseholdId').value = householdId; document.getElementById('aidDate').value = new Date().toISOString().slice(0, 10); document.getElementById('aidType').value = 'sembako'; document.getElementById('aidAmount').value = '0'; document.getElementById('aidNotes').value = ''; openModal('aidModal'); } + +// assets/js/forms.js - Update event listener aidForm +document.getElementById('aidForm')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const editId = document.getElementById('aidForm').dataset.editId; + const body = { + household_id: parseInt(document.getElementById('aidHouseholdId').value), + aid_type: document.getElementById('aidType').value, + aid_date: document.getElementById('aidDate').value, + amount: parseInt(document.getElementById('aidAmount').value || 0), + notes: document.getElementById('aidNotes').value.trim() + }; + showLoading(true); + const r = editId ? await ApiAid.update(editId, body) : await ApiAid.create(body); + showLoading(false); + + if (r.ok && r.data?.success) { + closeModal('aidModal'); + document.getElementById('aidForm').dataset.editId = ''; + document.querySelector('#aidModal .modal-header h2').textContent = 'Catat Bantuan'; + showToast(editId ? 'Bantuan berhasil diperbarui.' : 'Bantuan berhasil dicatat.', 'success'); + + const houseId = document.getElementById('houseId').value; + if (houseId) { + // ⭐ REFRESH DATA RUMAH SETELAH TAMBAH BANTUAN + showLoading(true); + const houseData = await ApiHouses.show(houseId); + showLoading(false); + if (houseData.ok && houseData.data?.data) { + const freshData = houseData.data.data; + + // Update aid history display in modal + renderAidHistory(freshData.aid_history || []); + document.getElementById('houseAidStatus').value = 'received'; + + // ⭐ UPDATE POPUP DI PETA - REFRESH MARKER + const existingHouse = State.houses.find(h => h.id == houseId); + if (existingHouse && existingHouse._marker) { + // Update data rumah di State + existingHouse.aid_history = freshData.aid_history || []; + existingHouse.aid_status = 'received'; + existingHouse._marker._houseData = freshData; + + // Jika popup sedang terbuka, refresh dengan data baru + if (existingHouse._marker.isPopupOpen()) { + await showHousePopup(existingHouse._marker, freshData); + } + } + + // Reload stats untuk update counter + await loadStats(); + } + } + } else { + showToast(r.data?.message || 'Gagal menyimpan.', 'error'); + } + return false; +}); + +async function refreshHousePopup(householdId) { + try { + const r = await ApiHouses.show(householdId); + if (r.ok && r.data?.success) { + const freshData = r.data.data; + + // Update data di State + const index = State.houses.findIndex(h => h.id == householdId); + if (index !== -1) { + State.houses[index] = freshData; + + // Update marker popup + if (State.houses[index]._marker) { + showHousePopup(State.houses[index]._marker, freshData); + } + } + + return freshData; + } + } catch (err) { + console.error('Failed to refresh house popup:', err); + } + return null; +} + +// Export fungsi untuk digunakan di file lain +window.refreshHousePopup = refreshHousePopup; + +document.getElementById('addAidBtn')?.addEventListener('click', () => { const hhId = document.getElementById('houseId').value; if (hhId) openAidModalForHouse(hhId); }); + +function renderAidHistory(list) { + const el = document.getElementById('aidHistoryList'); + if (!el) return; + + if (!list || !list.length) { + el.innerHTML = '

    Belum ada riwayat bantuan

    '; + return; + } + + el.innerHTML = list.map((a, idx) => { + // Gunakan label yang sudah ada atau mapping dari AID_LABELS + const typeLabel = a.aid_type_label || AID_LABELS[a.aid_type] || a.aid_type || 'Bantuan'; + const amount = a.amount || 0; + const notes = a.description || a.notes || ''; + + return ` +
    + ${escapeHtml(typeLabel)} + ${formatDate(a.aid_date)} + ${amount ? formatRp(amount) : 'β€”'} + + +
    + `; + }).join(''); +} + +async function editAidRecord(aidId) { + showLoading(true); + const r = await ApiAid.show(aidId); + showLoading(false); + if (!r.ok || !r.data?.data) { showToast('Gagal memuat data bantuan.', 'error'); return; } + const a = r.data.data; + document.getElementById('aidHouseholdId').value = a.household_id; + document.getElementById('aidDate').value = a.aid_date; + document.getElementById('aidType').value = a.aid_type; + document.getElementById('aidAmount').value = a.amount || 0; + document.getElementById('aidNotes').value = a.notes || ''; + document.getElementById('aidForm').dataset.editId = aidId; + document.querySelector('#aidModal .modal-header h2').textContent = 'Edit Bantuan'; + openModal('aidModal'); +} + +async function deleteAidRecord(aidId) { + if (!confirm('Hapus riwayat bantuan ini?')) return; + showLoading(true); + const r = await ApiAid.delete(aidId); + showLoading(false); + if (r.ok && r.data?.success) { showToast('Riwayat bantuan dihapus.', 'success'); const item = document.getElementById('aid-item-' + aidId); if (item) item.remove(); const houseId = document.getElementById('houseId').value; if (houseId) { const houseData = await ApiHouses.show(houseId); if (houseData.ok && houseData.data?.data) { renderAidHistory(houseData.data.data.aid_history || []); document.getElementById('houseAidStatus').value = houseData.data.data.aid_status; } } } + else showToast(r.data?.message || 'Gagal menghapus.', 'error'); +} + +function openReportModal(householdId) { showToast('Gunakan halaman lapor.html untuk melaporkan warga.', 'success', 3000); } + +// Helper: show error message below field +function showFieldError(fieldId, errorId, message) { + const errorEl = document.getElementById(errorId); + const fieldEl = document.getElementById(fieldId); + if (errorEl) { + errorEl.innerHTML = ` ${message}`; + errorEl.classList.add('show'); + } + if (fieldEl) fieldEl.classList.add('error'); +} + +// Helper: clear error for a field +function clearFieldError(fieldId, errorId) { + const errorEl = document.getElementById(errorId); + const fieldEl = document.getElementById(fieldId); + if (errorEl) errorEl.classList.remove('show'); + if (fieldEl) fieldEl.classList.remove('error'); +} + +// Validate house form before submit +function validateHouseForm() { + let isValid = true; + + const employmentStatus = document.getElementById('houseEmploymentStatus')?.value; + + // Required fields + const requiredFields = [ + { id: 'houseHeadName', errId: 'errHouseHeadName', label: 'Nama Kepala Keluarga' }, + { id: 'houseNIK', errId: 'errHouseNIK', label: 'NIK KK', validate: (val) => /^\d{16}$/.test(val) }, + { id: 'houseGender', errId: 'errHouseGender', label: 'Jenis Kelamin' }, + { id: 'houseDOB', errId: 'errHouseDOB', label: 'Tanggal Lahir' }, + { id: 'houseEducation', errId: 'errHouseEducation', label: 'Pendidikan' }, + { id: 'houseIncome', errId: 'errHouseIncome', label: 'Pendapatan' }, + { id: 'houseCondition', errId: 'errHouseCondition', label: 'Kondisi Rumah' }, + { id: 'houseLandOwnership', errId: 'errHouseLandOwnership', label: 'Kepemilikan Lahan' }, + { id: 'houseRt', errId: 'errHouseRt', label: 'RT' }, + { id: 'houseRw', errId: 'errHouseRw', label: 'RW' }, + { id: 'houseKelurahan', errId: 'errHouseKelurahan', label: 'Kelurahan' }, + { id: 'houseKecamatan', errId: 'errHouseKecamatan', label: 'Kecamatan' }, + { id: 'houseFullAddress', errId: 'errHouseAddress', label: 'Alamat Lengkap' } + ]; + + requiredFields.forEach(field => { + const el = document.getElementById(field.id); + let value = el ? el.value.trim() : ''; + + if (!value || value === '') { + showFieldError(field.id, field.errId, `${field.label} wajib diisi.`); + isValid = false; + } else if (field.validate && !field.validate(value)) { + showFieldError(field.id, field.errId, `${field.label} tidak valid.`); + isValid = false; + } else { + clearFieldError(field.id, field.errId); + } + }); + + if (employmentStatus === 'working') { + const jobValue = document.getElementById('houseJob')?.value.trim(); + if (!jobValue || jobValue === '') { + showFieldError('houseJob', 'errHouseJob', 'Nama pekerjaan wajib diisi.'); + isValid = false; + } else { + clearFieldError('houseJob', 'errHouseJob'); + } + } else if (employmentStatus === 'studying') { + const institutionValue = document.getElementById('houseInstitutionName')?.value.trim(); + if (!institutionValue || institutionValue === '') { + showFieldError('houseInstitutionName', 'errHouseInstitution', 'Nama sekolah/universitas wajib diisi.'); + isValid = false; + } else { + clearFieldError('houseInstitutionName', 'errHouseInstitution'); + } + } else { + // Status unemployed: tidak perlu validasi pekerjaan/institusi + clearFieldError('houseJob', 'errHouseJob'); + clearFieldError('houseInstitutionName', 'errHouseInstitution'); + } + + // Koordinat validation + const lat = document.getElementById('houseLat')?.value; + const lng = document.getElementById('houseLng')?.value; + if (!lat || !lng || lat === '' || lng === '') { + showFieldError('houseLatDisplay', 'errHouseCoord', 'Pilih lokasi pada peta terlebih dahulu.'); + isValid = false; + } else { + clearFieldError('houseLatDisplay', 'errHouseCoord'); + } + + // Family members validation (at least 1 member? optional) + const familyData = document.getElementById('familyMembersData')?.value; + // Tidak wajib, hanya peringatan jika kosong + if (familyData && familyData !== '[]') { + const members = JSON.parse(familyData); + if (members.length === 0) { + // Optional warning + } + } + + return isValid; +} + +// Tambahkan event listener untuk real-time validation clearing +function initHouseFormValidation() { + const fields = ['houseHeadName', 'houseNIK', 'houseGender', 'houseDOB', 'houseEducation', + 'houseIncome', 'houseJob', 'houseCondition', 'houseLandOwnership', + 'houseRt', 'houseRw', 'houseKelurahan', 'houseKecamatan', 'houseFullAddress']; + + fields.forEach(fieldId => { + const el = document.getElementById(fieldId); + if (el) { + el.addEventListener('input', () => { + const errId = 'err' + fieldId.charAt(0).toUpperCase() + fieldId.slice(1); + clearFieldError(fieldId, errId); + }); + el.addEventListener('change', () => { + const errId = 'err' + fieldId.charAt(0).toUpperCase() + fieldId.slice(1); + clearFieldError(fieldId, errId); + }); + } + }); + + // Coordinate clear on map click + const coordFields = ['houseLat', 'houseLng']; + coordFields.forEach(fieldId => { + const el = document.getElementById(fieldId); + if (el) { + el.addEventListener('change', () => { + clearFieldError('houseLatDisplay', 'errHouseCoord'); + }); + } + }); +} + +/* ================================================================ + Photo upload β€” houseModal wiring + ================================================================ */ + +/* ================================================================ + deleteHousePhoto β€” called from the Γ— button on saved photo thumbs + ================================================================ */ +async function deleteHousePhoto(householdId, filename, thumbWrap) { + if (!confirm('Hapus foto ini secara permanen?')) return; + + // Optimistic UI: dim the thumb immediately + if (thumbWrap) thumbWrap.style.opacity = '0.4'; + + const r = await PhotoUpload.deletePhoto('house', householdId, filename); + + if (r.ok && r.data?.success) { + // Remove thumb from DOM + thumbWrap?.remove(); + showToast('Foto dihapus.', 'success'); + } else { + // Restore opacity on failure + if (thumbWrap) thumbWrap.style.opacity = '1'; + showToast(r.data?.message || 'Gagal menghapus foto.', 'error'); + } +} + +(function() { + const zone = document.getElementById('housePhotoZone'); + const input = document.getElementById('housePhotos'); + if (!zone || !input) return; + + const strip = document.getElementById('housePhotoStrip'); + const errEl = document.getElementById('housePhotoError'); + const savedEl = document.getElementById('houseSavedPhotoStrip'); + + function showErr(msg) { errEl.textContent = msg; errEl.classList.add('show'); } + function clearErr() { errEl.classList.remove('show'); } + + function currentSavedCount() { + return savedEl ? savedEl.querySelectorAll('div').length : 0; + } + + function handleFiles(fileList) { + clearErr(); + const v = PhotoUpload.validate(fileList, currentSavedCount()); + if (!v.valid) { showErr(v.message); input.value = ''; return; } + strip.innerHTML = ''; + strip.appendChild(PhotoUpload.buildPreviewStrip(fileList)); + } + + input.addEventListener('change', () => handleFiles(input.files)); + + zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('dragover'); }); + zone.addEventListener('dragleave', () => zone.classList.remove('dragover')); + zone.addEventListener('drop', (e) => { + e.preventDefault(); + zone.classList.remove('dragover'); + try { + const dt = new DataTransfer(); + Array.from(e.dataTransfer.files).forEach(f => dt.items.add(f)); + input.files = dt.files; + handleFiles(input.files); + } catch(_) { + showErr('Drag & drop tidak didukung. Klik untuk memilih file.'); + } + }); +})(); + +// Modify the houseForm submit handler +document.getElementById('houseForm')?.addEventListener('submit', async (e) => { + e.preventDefault(); + + // VALIDATE FORM FIRST + if (!validateHouseForm()) { + const firstError = document.querySelector('.field-error.show'); + if (firstError) { + firstError.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + showToast('Periksa kembali isian yang belum lengkap.', 'error'); + return; + } + + const id = document.getElementById('houseId').value; + const lat = parseFloat(document.getElementById('houseLat').value); + const lng = parseFloat(document.getElementById('houseLng').value); + + if (!lat || !lng || isNaN(lat) || isNaN(lng)) { + showToast('Koordinat tidak valid. Klik peta terlebih dahulu.', 'error'); + return; + } + + const members = JSON.parse(document.getElementById('familyMembersData').value || '[]'); + const body = { + rt: document.getElementById('houseRt')?.value || '', + rw: document.getElementById('houseRw')?.value || '', + kelurahan: document.getElementById('houseKelurahan')?.value || '', + kecamatan: document.getElementById('houseKecamatan')?.value || '', + full_address: document.getElementById('houseFullAddress')?.value || '', + latitude: lat, + longitude: lng, + house_condition: document.getElementById('houseCondition').value, + managing_center_id: document.getElementById('managingCenterId')?.value || null, + head_name: document.getElementById('houseHeadName').value.trim(), + head_nik: document.getElementById('houseNIK').value.trim(), + head_gender: document.getElementById('houseGender').value, + head_date_of_birth: document.getElementById('houseDOB').value, + head_education: document.getElementById('houseEducation').value, + head_employment_status: document.getElementById('houseEmploymentStatus').value, + head_job_name: document.getElementById('houseEmploymentStatus').value === 'working' ? document.getElementById('houseJob').value.trim() || null : null, + head_institution_name: document.getElementById('houseEmploymentStatus').value === 'studying' ? document.getElementById('houseInstitutionName').value.trim() || null : null, + head_monthly_income: parseInt(document.getElementById('houseIncome').value) || 0, + land_ownership: document.getElementById('houseLandOwnership').value, + description: document.getElementById('houseDescription').value.trim(), + aid_status: document.getElementById('houseAidStatus').value, + household_members: members, + notes: document.getElementById('houseDescription').value.trim() + }; + + if (!body.head_name) { showToast('Nama Kepala Keluarga wajib diisi.', 'error'); return; } + if (!body.head_nik || body.head_nik.length !== 16) { showToast('NIK KK harus 16 digit.', 'error'); return; } + if (!body.head_date_of_birth) { showToast('Tanggal Lahir KK wajib diisi.', 'error'); return; } + + showLoading(true); + try { + const r = id ? await ApiHouses.update(id, body) : await ApiHouses.create(body); + if (r.ok && r.data?.success) { + // ── Upload photos ───────────────────────────────────── + const savedId = id || r.data.data?.id; + const photoInput = document.getElementById('housePhotos'); + if (savedId && photoInput?.files?.length) { + const upRes = await PhotoUpload.upload('house', savedId, photoInput.files); + if (!upRes.ok || !upRes.data?.success) { + showToast('Data disimpan, tapi foto gagal diunggah: ' + (upRes.data?.message || ''), 'error', 5000); + } + photoInput.value = ''; + } + // ── End upload ──────────────────────────────────────── + + closeModal('houseModal'); + cancelPlacementMode(); + showToast(id ? 'Data rumah diperbarui.' : 'Rumah berhasil ditambahkan.', 'success'); + await loadAllData(); + + recountCenterHouseholds(); + renderCenterList(); + renderHouseList(); + updateLayerCounts(); + loadStats(); + } else { + showToast(r.data?.message || 'Gagal menyimpan.', 'error'); + } + } catch (err) { + showToast('Terjadi kesalahan: ' + err.message, 'error'); + } finally { + showLoading(false); + } +}); \ No newline at end of file diff --git a/03PovertyMapping/assets/js/map.js b/03PovertyMapping/assets/js/map.js new file mode 100644 index 0000000..f5c8546 --- /dev/null +++ b/03PovertyMapping/assets/js/map.js @@ -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: `
    `, + 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; +} \ No newline at end of file diff --git a/03PovertyMapping/assets/js/markers.js b/03PovertyMapping/assets/js/markers.js new file mode 100644 index 0000000..69ba924 --- /dev/null +++ b/03PovertyMapping/assets/js/markers.js @@ -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, '''); +} + +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: `
    `, + 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 = ` + + `; + } + + const popup = L.popup({ maxWidth: 320, closeButton: true }) + .setLatLng(marker.getLatLng()) + .setContent(` + `); + + 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: `
    `, + 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: `
    `, + 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 => + `` + ).join(''); + + return ` +
    +
    + + Foto Rumah + ${photosArr.length} +
    + +
    `; +} + +// 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' + ? ` Layak` + : houseData.house_condition === 'tidak_layak' + ? ` Tidak Layak` + : 'β€”'; + + // ── 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 + ? `
    ${locationPills.map(p => `${p}`).join('')}
    ` + : ''; + + // ── 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 = ` +
    +
    + + Anggota Keluarga + ${members.length} +
    +
    + ${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 `
    +
    +
    +
    ${escapeHtml(m.name)}
    +
    ${escapeHtml(m.relationship || 'β€”')}${statusLine ? ' Β· ' + statusLine : ''}
    +
    +
    `; + }).join('')} + ${extra > 0 ? `
    +${extra} anggota lainnya
    ` : ''} +
    +
    `; + } + + // ── Aid history ─────────────────────────────────────────────────── + let aidHistoryHtml = ''; + if (hasAid) { + const latestAids = houseData.aid_history.slice(0, 5); + const extraAids = houseData.aid_history.length - latestAids.length; + aidHistoryHtml = ` +
    +
    + + Riwayat Bantuan + ${houseData.aid_history.length} +
    +
    + ${latestAids.map(aid => ` +
    +
    + ${escapeHtml(aid.aid_type_label || (typeof AID_LABELS !== 'undefined' && AID_LABELS[aid.aid_type]) || aid.aid_type || 'Bantuan')} + ${aid.amount ? `${formatRp(aid.amount)}` : ''} +
    +
    ${formatDate(aid.aid_date)}
    + ${aid.description || aid.notes ? `
    ${escapeHtml((aid.description || aid.notes || '').substring(0, 55))}${(aid.description || aid.notes || '').length > 55 ? '…' : ''}
    ` : ''} +
    `).join('')} + ${extraAids > 0 ? `
    +${extraAids} bantuan lainnya
    ` : ''} +
    +
    `; + } else { + aidHistoryHtml = ` +
    +
    + + Riwayat Bantuan +
    +
    Belum ada riwayat bantuan
    +
    `; + } + + // ── Assigned center ─────────────────────────────────────────────── + const centerHtml = houseData.center_name + ? `
    + + ${escapeHtml(houseData.center_name)} +
    ` + : ''; + + // ── 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(` +
    + + +
    +
    + +
    +
    + +
    +
    +
    ${escapeHtml(houseData.head_name)}
    +
    NIK: ${escapeHtml(houseData.head_nik || houseData.nik || 'β€”')}
    +
    +
    + + +
    +
    + ${povLabel} +
    +
    + ${aidStatusText} +
    +
    + + +
    + + +
    +
    + + Lokasi +
    +
    ${escapeHtml(fullAddress)}
    + ${locationPillsHtml} + ${centerHtml} +
    ${lat}, ${lng}
    +
    + + + ${photosHtml} + + +
    +
    + + Kepala Keluarga +
    +
    +
    + Usia + ${age} tahun +
    +
    + Pendidikan + ${educationLabel(houseData.head_education) || 'β€”'} +
    +
    + Pekerjaan + ${jobLine} +
    +
    + Kondisi Rumah + ${conditionIcon} +
    +
    +
    + + + ${membersHtml} + + + ${aidHistoryHtml} + +
    + + +
    + + + ${window.canDelete ? `` : ''} +
    + +
    `); + + 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: `
    `, + 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: `
    `, + 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: `
    `, + 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('
    Memuat data...
    ') + .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('
    Gagal memuat data
    '); + setTimeout(() => marker.closePopup(), 1500); + } + } catch (err) { + loadingPopup.setContent('
    Error memuat data
    '); + 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 = '

    Tidak ada data

    '; 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 `
    +
    +
    ${truncate(c.name, 24)}
    ${CENTER_LABELS[c.worship_type] || ''} Β· ${c.household_count ?? 0} rumah
    +
    + + ${window.canDelete ? `` : ''} +
    +
    `; + }).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 = '

    Tidak ada data

    '; 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 `
    +
    +
    ${truncate(h.head_name, 22)}
    ${status}
    +
    + + ${window.canDelete ? `` : ''} +
    +
    `; + }).join(''); + if (list.length > 80) el.innerHTML += `

    +${list.length - 80} lainnya β€” gunakan filter

    `; +} + +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; +} \ No newline at end of file diff --git a/03PovertyMapping/assets/js/photo-upload.js b/03PovertyMapping/assets/js/photo-upload.js new file mode 100644 index 0000000..57d19f3 --- /dev/null +++ b/03PovertyMapping/assets/js/photo-upload.js @@ -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 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 } }; + } + }, +}; \ No newline at end of file diff --git a/03PovertyMapping/assets/js/public-reports.js b/03PovertyMapping/assets/js/public-reports.js new file mode 100644 index 0000000..0964ee1 --- /dev/null +++ b/03PovertyMapping/assets/js/public-reports.js @@ -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 = 'Memuat...'; + + const r = await ApiPublicReports.list(params); + + if (!r.ok || !r.data?.success) { + tbody.innerHTML = 'Gagal memuat laporan publik.'; + 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 = ` + + ${emptyMsg} + `; + 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 ` + + ${dateStr} + +
    ${escapeHtml(rep.head_name || 'β€”')}
    + ${rep.reporter_name + ? `
    ${escapeHtml(rep.reporter_name)}${rep.reporter_phone ? ' Β· ' + escapeHtml(rep.reporter_phone) : ''}
    ` + : ''} + + ${escapeHtml(truncate(rep.address || 'β€”', 38))} + + ${escapeHtml(truncate(rep.description || 'β€”', 60))} + ${buildPhotoMiniStrip(rep.proof_photos, 'uploads/reports/')} + + + ${st.label} + ${rep.admin_notes + ? `
    ${escapeHtml(truncate(rep.admin_notes, 25))}
    ` + : ''} + ${rep.converted_household_id + ? `
    ID: ${rep.converted_household_id}
    ` + : ''} + + + ${canAct ? ` + + + ` : ''} + + + + `; + }).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 ``; + }).join(''); + + const extra = arr.length > 3 ? `+${arr.length - 3}` : ''; + return `
    ${imgs}${extra}
    `; +} + +/** Safely JSON-encode report object for inline onclick attribute */ +function safeJson(obj) { + return JSON.stringify(obj) + .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 = ` +
    +
    + +
    +
    +
    ${data.head_name || 'β€”'}
    +
    ${data.address || 'β€”'}
    +
    "${truncate(data.description || '', 100)}"
    + ${data.reporter_name ? `
    ${data.reporter_name}
    ` : ''} +
    +
    `; + + // ── 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 = '
    Tidak ada foto bukti
    '; + } + } + } + + 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; \ No newline at end of file diff --git a/03PovertyMapping/config/bootstrap.php b/03PovertyMapping/config/bootstrap.php new file mode 100644 index 0000000..3851668 --- /dev/null +++ b/03PovertyMapping/config/bootstrap.php @@ -0,0 +1,94 @@ +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; +} \ No newline at end of file diff --git a/03PovertyMapping/config/config.php b/03PovertyMapping/config/config.php new file mode 100644 index 0000000..9a0ac35 --- /dev/null +++ b/03PovertyMapping/config/config.php @@ -0,0 +1,28 @@ + 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.'); } +} \ No newline at end of file diff --git a/03PovertyMapping/database/migrate.php b/03PovertyMapping/database/migrate.php new file mode 100644 index 0000000..aafef39 --- /dev/null +++ b/03PovertyMapping/database/migrate.php @@ -0,0 +1,381 @@ + $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'; + ?> + + + + + +DB Migration β€” poverty_mapping + + + +
    +
    +

    +

    Database: poverty_mapping  | 

    +
    +
    + +
    + +
    + + +
    + +
    +
    + +
    + +
    + + += 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; \ No newline at end of file diff --git a/03PovertyMapping/database/structure.sql b/03PovertyMapping/database/structure.sql new file mode 100644 index 0000000..0af42d0 --- /dev/null +++ b/03PovertyMapping/database/structure.sql @@ -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; diff --git a/03PovertyMapping/index.html b/03PovertyMapping/index.html new file mode 100644 index 0000000..a1a1a72 --- /dev/null +++ b/03PovertyMapping/index.html @@ -0,0 +1,778 @@ + + + + + + + + +WebGIS Dashboard β€” Manajemen Data + + + + + + + + + + +
    + + + + + + + + + + + +
    +

    + Keterangan + +

    +
    + +
    Masjid
    +
    Gereja
    +
    Klenteng
    +
    Pura
    +
    Vihara
    + +
    Sangat Miskin
    +
    Miskin
    +
    Rentan Miskin
    +
    Terdata
    +
    +
    + + +
    +

    Layer

    +
    + + + Tempat Ibadah + 0 +
    +
    + + + Rumah + 0 +
    +
    + + +
    Memproses...
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/03PovertyMapping/lapor.html b/03PovertyMapping/lapor.html new file mode 100644 index 0000000..23c22c0 --- /dev/null +++ b/03PovertyMapping/lapor.html @@ -0,0 +1,1557 @@ + + + + + + + +Laporkan Warga β€” WebGIS Kemiskinan + + + + + + + + +
    + + + + + +
    +
    +

    Laporan Terkirim!

    +

    Terima kasih. Laporan Anda telah diterima dan akan segera diverifikasi oleh petugas kami. Data warga akan ditambahkan ke peta setelah verifikasi.

    +
    + + ID Laporan: β€” +
    + +
    + + +
    + +
    + +
    + Petunjuk pengisian: Semua kolom bertanda + * wajib diisi. + Klik peta di sebelah kanan untuk menentukan lokasi β€” alamat, kelurahan, + dan kecamatan akan terisi otomatis. +
    +
    + +
    + + +
    + + +
    +
    + Data Pelapor +
    + +
    + + +
    + + Nama pelapor wajib diisi. +
    +
    + +
    + + +
    + + Nomor telepon wajib diisi (8–15 digit). +
    +
    +
    + + +
    +
    + Data Warga yang Dilaporkan +
    + +
    + + +
    + + Nama kepala keluarga wajib diisi. +
    +
    + + +
    +
    + + +
    + + RT wajib diisi. +
    +
    +
    + + +
    + + RW wajib diisi. +
    +
    +
    + + +
    + +
    + + + + + + +
    +
    + + Alamat lengkap wajib diisi. +
    +
    + + +
    +
    + + +
    + + Kelurahan wajib diisi. +
    +
    +
    + + +
    + + Kecamatan wajib diisi. +
    +
    +
    + + +
    + + +
    + + Alasan wajib diisi (minimal 20 karakter). +
    +
    + + +
    + + +
    +
    + 🟒 + Ringan +
    +
    + 🟑 + Sedang +
    +
    + 🟠 + Berat +
    +
    + πŸ”΄ + Kritis +
    +
    +
    + + Pilih tingkat urgensi. +
    +
    + + +
    + + +
    +
    + + +
    +
    + Koordinat Lokasi +
    + +
    + +
    +
    Belum dipilih
    +
    Klik peta untuk menentukan lokasi
    +
    +
    + +
    + + Klik peta di sebelah kanan untuk menentukan lokasi rumah +
    + + +
    + +
    + + Klik atau seret foto ke sini
    JPG / PNG, maksimal 5 MB per foto
    + +
    +
    +
    +
    + + Foto bukti wajib diunggah minimal 1 foto. +
    +
    + + + + + +
    + + Pilih lokasi pada peta terlebih dahulu. +
    + + + +
    + +
    + + +
    +
    +
    + Pilih Lokasi Rumah +
    +
    +
    + + Klik pada peta untuk menempatkan pin. Pin dapat digeser. +
    +
    +
    + +
    + + + +
    +
    + + +
    +
    + + Mengirim laporan... +
    +
    + +
    + + + + + + + + \ No newline at end of file diff --git a/03PovertyMapping/login.html b/03PovertyMapping/login.html new file mode 100644 index 0000000..6692f4e --- /dev/null +++ b/03PovertyMapping/login.html @@ -0,0 +1,315 @@ + + + + + + + + +Login β€” WebGIS Poverty Mapping + + + + + + + +
    + + +
    +
    + + Username atau password salah. +
    + +
    +
    + +
    + + +
    +
    +
    + +
    + + + +
    +
    + +
    +
    + + +
    + + + + \ No newline at end of file diff --git a/03PovertyMapping/middleware/Response.php b/03PovertyMapping/middleware/Response.php new file mode 100644 index 0000000..d8deba4 --- /dev/null +++ b/03PovertyMapping/middleware/Response.php @@ -0,0 +1,47 @@ + 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); + } +} \ No newline at end of file diff --git a/03PovertyMapping/middleware/Validator.php b/03PovertyMapping/middleware/Validator.php new file mode 100644 index 0000000..5dd2b95 --- /dev/null +++ b/03PovertyMapping/middleware/Validator.php @@ -0,0 +1,137 @@ +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')); + } +} \ No newline at end of file diff --git a/03PovertyMapping/models/AuditLog.php b/03PovertyMapping/models/AuditLog.php new file mode 100644 index 0000000..60216bf --- /dev/null +++ b/03PovertyMapping/models/AuditLog.php @@ -0,0 +1,37 @@ +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 + } + } +} \ No newline at end of file diff --git a/03PovertyMapping/models/PovertyCalculator.php b/03PovertyMapping/models/PovertyCalculator.php new file mode 100644 index 0000000..7c28109 --- /dev/null +++ b/03PovertyMapping/models/PovertyCalculator.php @@ -0,0 +1,149 @@ += 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 => '-', + }; + } +} \ No newline at end of file diff --git a/03PovertyMapping/update_users.php b/03PovertyMapping/update_users.php new file mode 100644 index 0000000..ceecf54 --- /dev/null +++ b/03PovertyMapping/update_users.php @@ -0,0 +1,33 @@ +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"; +} diff --git a/03PovertyMapping/uploads/.htaccess b/03PovertyMapping/uploads/.htaccess new file mode 100644 index 0000000..4c4832c --- /dev/null +++ b/03PovertyMapping/uploads/.htaccess @@ -0,0 +1,13 @@ +# Mencegah eksekusi file PHP di folder uploads + + Order allow,deny + Deny from all + + +# Menonaktifkan daftar direktori +Options -Indexes + +# Mencegah MIME sniffing + + Header set X-Content-Type-Options "nosniff" + \ No newline at end of file diff --git a/03PovertyMapping/uploads/reports/reports_4_378e0629b95b01d6f5538657.png b/03PovertyMapping/uploads/reports/reports_4_378e0629b95b01d6f5538657.png new file mode 100644 index 0000000..54f317e Binary files /dev/null and b/03PovertyMapping/uploads/reports/reports_4_378e0629b95b01d6f5538657.png differ diff --git a/03PovertyMapping/uploads/reports/reports_5_43fea4b2601780d8d1ade422.png b/03PovertyMapping/uploads/reports/reports_5_43fea4b2601780d8d1ade422.png new file mode 100644 index 0000000..54f317e Binary files /dev/null and b/03PovertyMapping/uploads/reports/reports_5_43fea4b2601780d8d1ade422.png differ