commit 2676f3a61c60d090414608cc75e6800c0151e5f0 Author: cw Date: Thu Jun 11 12:30:23 2026 +0700 Initial commit diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..e21ee62 --- /dev/null +++ b/.htaccess @@ -0,0 +1,12 @@ +Options -Indexes +RewriteEngine On +RewriteBase /sinergi/ + +# Allow direct access to public assets +RewriteCond %{REQUEST_URI} ^/sinergi/public/ [NC] +RewriteRule ^ - [L] + +# Route everything else to index.php +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.*)$ index.php [QSA,L] diff --git a/app/controllers/AdminController.php b/app/controllers/AdminController.php new file mode 100644 index 0000000..e97e21f --- /dev/null +++ b/app/controllers/AdminController.php @@ -0,0 +1,418 @@ +db->fetchOne(" + SELECT + (SELECT COUNT(*) FROM warga_miskin WHERE status='aktif') as total_warga, + (SELECT COUNT(*) FROM warga_miskin WHERE status='aktif' AND status_kemiskinan='Miskin Ekstrem') as warga_ekstrem, + (SELECT COUNT(*) FROM rumah_ibadah WHERE is_active=1) as total_ri, + (SELECT COUNT(*) FROM laporan_masyarakat) as total_laporan, + (SELECT COUNT(*) FROM laporan_masyarakat WHERE status='menunggu') as laporan_pending, + (SELECT COUNT(*) FROM histori_bantuan WHERE status='disalurkan') as total_bantuan, + (SELECT COUNT(*) FROM warga_miskin WHERE is_blind_spot=1 AND status='aktif') as blind_spot, + (SELECT COUNT(*) FROM users WHERE is_active=1) as total_user + "); + + // Data per kecamatan + $perKecamatan = $this->db->fetchAll(" + SELECT kec.nama, + COUNT(w.id) as total, + SUM(w.status_kemiskinan='Miskin Ekstrem') as ekstrem, + SUM(w.status_kemiskinan='Miskin') as miskin, + SUM(w.status_kemiskinan='Rentan Miskin') as rentan, + SUM(w.is_blind_spot=1) as blind_spot + FROM kecamatan kec + LEFT JOIN kelurahan k ON k.id_kecamatan = kec.id + LEFT JOIN warga_miskin w ON w.id_kelurahan = k.id AND w.status='aktif' + GROUP BY kec.id, kec.nama + ORDER BY total DESC + "); + + // Tren bantuan 6 bulan terakhir + $trenBantuan = $this->db->fetchAll(" + SELECT DATE_FORMAT(tanggal_penyaluran,'%Y-%m') as bulan, COUNT(*) as jumlah + FROM histori_bantuan WHERE status='disalurkan' + AND tanggal_penyaluran >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH) + GROUP BY bulan ORDER BY bulan + "); + + $aktivitas = $this->db->fetchAll("SELECT * FROM aktivitas_sistem ORDER BY created_at DESC LIMIT 15"); + + $this->view('admin.dashboard', [ + 'title' => 'Dashboard Super Admin', + 'stats' => $stats, + 'perKecamatan'=> $perKecamatan, + 'trenBantuan' => $trenBantuan, + 'aktivitas' => $aktivitas, + 'user' => Auth::user() + ], 'admin'); + } + + public function users(): void { + $users = $this->db->fetchAll(" + SELECT u.*, r.nama as role_nama, r.slug as role_slug, + k.nama as kelurahan_nama, ri.nama as rumah_ibadah_nama + FROM users u + JOIN roles r ON u.id_role = r.id + LEFT JOIN kelurahan k ON u.id_kelurahan = k.id + LEFT JOIN rumah_ibadah ri ON ri.id_user = u.id + ORDER BY u.id_role, u.nama + "); + $roles = $this->db->fetchAll("SELECT * FROM roles"); + $kelurahan = $this->db->fetchAll("SELECT k.id, CONCAT(kec.nama,' - ',k.nama) as nama FROM kelurahan k JOIN kecamatan kec ON k.id_kecamatan=kec.id ORDER BY kec.nama, k.nama"); + + $this->view('admin.users', [ + 'title' => 'Manajemen User', + 'users' => $users, + 'roles' => $roles, + 'kelurahan' => $kelurahan, + 'user' => Auth::user() + ], 'admin'); + } + + public function storeUser(): void { + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/admin/users'); + + $data = [ + 'id_role' => (int)$_POST['id_role'], + 'id_kelurahan' => !empty($_POST['id_kelurahan']) ? (int)$_POST['id_kelurahan'] : null, + 'nama' => trim($_POST['nama']), + 'username' => trim($_POST['username']), + 'email' => trim($_POST['email']), + 'password' => password_hash($_POST['password'], PASSWORD_BCRYPT), + 'no_hp' => trim($_POST['no_hp'] ?? ''), + 'is_active' => 1 + ]; + + try { + $this->db->insert('users', $data); + Auth::logActivity(Auth::id(), Auth::user()['nama'], 'superadmin', 'Tambah User', 'users', 'Menambah user: ' . $data['username']); + $this->flash('success', 'User berhasil ditambahkan.'); + } catch (Exception $e) { + $this->flash('error', 'Gagal menambah user: username/email sudah digunakan.'); + } + $this->redirect(APP_URL . '/admin/users'); + } + + public function updateUser(int $id): void { + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/admin/users'); + + $data = [ + 'id_role' => (int)$_POST['id_role'], + 'id_kelurahan' => !empty($_POST['id_kelurahan']) ? (int)$_POST['id_kelurahan'] : null, + 'nama' => trim($_POST['nama']), + 'username' => trim($_POST['username']), + 'email' => trim($_POST['email']), + 'no_hp' => trim($_POST['no_hp'] ?? ''), + 'is_active' => (int)$_POST['is_active'] + ]; + if (!empty($_POST['password'])) { + $data['password'] = password_hash($_POST['password'], PASSWORD_BCRYPT); + } + + try { + $this->db->update('users', $data, 'id = ?', [$id]); + Auth::logActivity(Auth::id(), Auth::user()['nama'], 'superadmin', 'Update User', 'users', "Update user ID $id"); + $this->flash('success', 'User berhasil diperbarui.'); + } catch (Exception $e) { + $this->flash('error', 'Gagal memperbarui user: username atau email sudah digunakan.'); + } + $this->redirect(APP_URL . '/admin/users'); + } + + public function resetUserPassword(int $id): void { + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/admin/users'); + + if ($id === Auth::id()) { + $this->flash('error', 'Tidak bisa reset password akun sendiri dari halaman ini.'); + $this->redirect(APP_URL . '/admin/users'); + } + + $target = $this->db->fetchOne(" + SELECT u.id, u.nama, u.username, u.email + FROM users u + WHERE u.id = ? + ", [$id]); + + if (!$target) { + $this->flash('error', 'User tidak ditemukan.'); + $this->redirect(APP_URL . '/admin/users'); + } + + $password = generatePasswordAkun(); + $this->db->update('users', [ + 'password' => password_hash($password, PASSWORD_BCRYPT) + ], 'id = ?', [$id]); + + $emailSent = kirimEmailAkun($target['email'], $target['nama'], $target['username'], $password, 'direset passwordnya'); + Auth::logActivity(Auth::id(), Auth::user()['nama'], 'superadmin', 'Reset Password User', 'users', "Reset password user ID $id"); + + if ($emailSent) { + $this->flash('success', 'Password berhasil direset dan dikirim ke email pengguna.'); + } else { + $this->flash('warning', 'Password berhasil direset, tetapi email belum terkirim. Password baru: ' . $password . ''); + } + + $this->redirect(APP_URL . '/admin/users'); + } + + public function deleteUser(int $id): void { + if ($id === Auth::id()) { + $this->flash('error', 'Tidak bisa menghapus akun sendiri.'); + $this->redirect(APP_URL . '/admin/users'); + } + $this->db->update('users', ['is_active' => 0], 'id = ?', [$id]); + $this->db->update('rumah_ibadah', ['id_user' => null], 'id_user = ?', [$id]); + Auth::logActivity(Auth::id(), Auth::user()['nama'], 'superadmin', 'Nonaktifkan User', 'users', "Nonaktifkan user ID $id"); + $this->flash('success', 'User berhasil dinonaktifkan.'); + $this->redirect(APP_URL . '/admin/users'); + } + + public function rumahIbadah(): void { + $data = $this->db->fetchAll(" + SELECT ri.*, ji.nama as jenis, k.nama as kelurahan, kec.nama as kecamatan, + u.nama as pengurus_nama, + (SELECT COUNT(*) FROM warga_miskin w WHERE w.id_rumah_ibadah=ri.id AND w.status='aktif') as jml_warga + FROM rumah_ibadah ri + JOIN jenis_rumah_ibadah ji ON ri.id_jenis = ji.id + JOIN kelurahan k ON ri.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + LEFT JOIN users u ON ri.id_user = u.id + ORDER BY kec.nama, k.nama, ri.nama + "); + $jenis = $this->db->fetchAll("SELECT * FROM jenis_rumah_ibadah"); + $kelurahan = $this->db->fetchAll("SELECT k.id, CONCAT(kec.nama,' - ',k.nama) as nama FROM kelurahan k JOIN kecamatan kec ON k.id_kecamatan=kec.id ORDER BY kec.nama,k.nama"); + $pengurusAvail = $this->db->fetchAll("SELECT u.id, u.nama FROM users u LEFT JOIN rumah_ibadah ri ON ri.id_user=u.id WHERE u.id_role=3 AND ri.id IS NULL"); + + $this->view('admin.rumah_ibadah', [ + 'title' => 'Manajemen Rumah Ibadah', + 'data' => $data, + 'jenis' => $jenis, + 'kelurahan' => $kelurahan, + 'pengurusAvail' => $pengurusAvail, + 'user' => Auth::user() + ], 'admin'); + } + + public function storeRumahIbadah(): void { + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/admin/rumah-ibadah'); + + $foto = null; + if (!empty($_FILES['foto']['name'])) { + $foto = $this->uploadFile($_FILES['foto'], 'rumah_ibadah'); + } + + $data = [ + 'id_user' => !empty($_POST['id_user']) ? (int)$_POST['id_user'] : null, + 'id_kelurahan' => (int)$_POST['id_kelurahan'], + 'id_jenis' => (int)$_POST['id_jenis'], + 'nama' => trim($_POST['nama']), + 'alamat' => trim($_POST['alamat']), + 'lat' => (float)$_POST['lat'], + 'lng' => (float)$_POST['lng'], + 'radius_meter' => (int)($_POST['radius_meter'] ?? 500), + 'nama_ketua' => trim($_POST['nama_ketua'] ?? ''), + 'no_hp_pengurus'=> trim($_POST['no_hp_pengurus'] ?? ''), + 'foto' => $foto, + 'is_active' => 1 + ]; + + $this->db->insert('rumah_ibadah', $data); + Auth::logActivity(Auth::id(), Auth::user()['nama'], 'superadmin', 'Tambah Rumah Ibadah', 'rumah_ibadah', 'Menambah: ' . $data['nama']); + $this->flash('success', 'Rumah ibadah berhasil ditambahkan.'); + $this->redirect(APP_URL . '/admin/rumah-ibadah'); + } + + public function updateRumahIbadah(int $id): void { + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/admin/rumah-ibadah'); + + $data = [ + 'id_user' => !empty($_POST['id_user']) ? (int)$_POST['id_user'] : null, + 'id_kelurahan' => (int)$_POST['id_kelurahan'], + 'id_jenis' => (int)$_POST['id_jenis'], + 'nama' => trim($_POST['nama']), + 'alamat' => trim($_POST['alamat']), + 'lat' => (float)$_POST['lat'], + 'lng' => (float)$_POST['lng'], + 'radius_meter' => (int)($_POST['radius_meter'] ?? 500), + 'nama_ketua' => trim($_POST['nama_ketua'] ?? ''), + 'no_hp_pengurus'=> trim($_POST['no_hp_pengurus'] ?? ''), + 'is_active' => (int)$_POST['is_active'] + ]; + if (!empty($_FILES['foto']['name'])) { + $data['foto'] = $this->uploadFile($_FILES['foto'], 'rumah_ibadah'); + } + + $this->db->update('rumah_ibadah', $data, 'id = ?', [$id]); + $this->flash('success', 'Rumah ibadah berhasil diperbarui.'); + $this->redirect(APP_URL . '/admin/rumah-ibadah'); + } + + public function deleteRumahIbadah(int $id): void { + $this->db->update('rumah_ibadah', ['is_active' => 0], 'id = ?', [$id]); + $this->flash('success', 'Rumah ibadah berhasil dinonaktifkan.'); + $this->redirect(APP_URL . '/admin/rumah-ibadah'); + } + + public function wilayah(): void { + $kecamatan = $this->db->fetchAll("SELECT k.*, COUNT(kel.id) as jml_kel FROM kecamatan k LEFT JOIN kelurahan kel ON kel.id_kecamatan=k.id GROUP BY k.id ORDER BY k.nama"); + $kelurahan = $this->db->fetchAll("SELECT kel.*, kec.nama as kecamatan FROM kelurahan kel JOIN kecamatan kec ON kel.id_kecamatan=kec.id ORDER BY kec.nama, kel.nama"); + $this->view('admin.wilayah', [ + 'title' => 'Manajemen Wilayah', + 'kecamatan' => $kecamatan, + 'kelurahan' => $kelurahan, + 'user' => Auth::user() + ], 'admin'); + } + + public function storeKecamatan(): void { + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/admin/wilayah'); + $this->db->insert('kecamatan', [ + 'nama' => trim($_POST['nama']), + 'kode' => trim($_POST['kode'] ?? ''), + 'lat' => (float)($_POST['lat'] ?? 0), + 'lng' => (float)($_POST['lng'] ?? 0) + ]); + $this->flash('success', 'Kecamatan berhasil ditambahkan.'); + $this->redirect(APP_URL . '/admin/wilayah'); + } + + public function storeKelurahan(): void { + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/admin/wilayah'); + $this->db->insert('kelurahan', [ + 'id_kecamatan' => (int)$_POST['id_kecamatan'], + 'nama' => trim($_POST['nama']), + 'kode' => trim($_POST['kode'] ?? ''), + 'lat' => (float)($_POST['lat'] ?? 0), + 'lng' => (float)($_POST['lng'] ?? 0) + ]); + $this->flash('success', 'Kelurahan berhasil ditambahkan.'); + $this->redirect(APP_URL . '/admin/wilayah'); + } + + public function kategoriBantuan(): void { + $data = $this->db->fetchAll("SELECT * FROM kategori_bantuan ORDER BY nama"); + $this->view('admin.kategori_bantuan', [ + 'title' => 'Manajemen Kategori Bantuan', + 'data' => $data, + 'user' => Auth::user() + ], 'admin'); + } + + public function storeKategoriBantuan(): void { + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/admin/kategori-bantuan'); + $this->db->insert('kategori_bantuan', [ + 'nama' => trim($_POST['nama']), + 'deskripsi' => trim($_POST['deskripsi'] ?? ''), + 'satuan' => trim($_POST['satuan'] ?? ''), + 'is_active' => 1 + ]); + $this->flash('success', 'Kategori bantuan berhasil ditambahkan.'); + $this->redirect(APP_URL . '/admin/kategori-bantuan'); + } + + public function updateKategoriBantuan(int $id): void { + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/admin/kategori-bantuan'); + $this->db->update('kategori_bantuan', [ + 'nama' => trim($_POST['nama']), + 'deskripsi' => trim($_POST['deskripsi'] ?? ''), + 'satuan' => trim($_POST['satuan'] ?? ''), + 'is_active' => (int)$_POST['is_active'] + ], 'id = ?', [$id]); + $this->flash('success', 'Kategori bantuan berhasil diperbarui.'); + $this->redirect(APP_URL . '/admin/kategori-bantuan'); + } + + public function monitoring(): void { + $warga = $this->db->fetchAll(" + SELECT w.*, k.nama as kelurahan, kec.nama as kecamatan, + ri.nama as rumah_ibadah_nama + FROM warga_miskin w + JOIN kelurahan k ON w.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + LEFT JOIN rumah_ibadah ri ON w.id_rumah_ibadah = ri.id + WHERE w.status = 'aktif' + ORDER BY w.level_prioritas DESC, w.skor_prioritas DESC + "); + $this->view('admin.monitoring', [ + 'title' => 'Monitoring Data Kemiskinan', + 'warga' => $warga, + 'user' => Auth::user() + ], 'admin'); + } + + public function blindSpot(): void { + $data = $this->db->fetchAll(" + SELECT w.*, k.nama as kelurahan, kec.nama as kecamatan + FROM warga_miskin w + JOIN kelurahan k ON w.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + WHERE w.is_blind_spot = 1 AND w.status = 'aktif' + ORDER BY w.level_prioritas DESC + "); + $this->view('admin.blind_spot', [ + 'title' => 'Monitoring Blind Spot', + 'data' => $data, + 'user' => Auth::user() + ], 'admin'); + } + + public function aktivitas(): void { + $data = $this->db->fetchAll(" + SELECT * FROM aktivitas_sistem ORDER BY created_at DESC LIMIT 200 + "); + $this->view('admin.aktivitas', [ + 'title' => 'Aktivitas Sistem', + 'data' => $data, + 'user' => Auth::user() + ], 'admin'); + } + + // API: Stats untuk dashboard + public function apiStats(): void { + $data = $this->db->fetchOne(" + SELECT + (SELECT COUNT(*) FROM warga_miskin WHERE status='aktif') as total_warga, + (SELECT COUNT(*) FROM warga_miskin WHERE status='aktif' AND level_prioritas='Tinggi') as prioritas_tinggi, + (SELECT COUNT(*) FROM warga_miskin WHERE is_blind_spot=1 AND status='aktif') as blind_spot, + (SELECT COUNT(*) FROM rumah_ibadah WHERE is_active=1) as total_ri, + (SELECT COUNT(*) FROM histori_bantuan WHERE status='disalurkan' AND MONTH(tanggal_penyaluran)=MONTH(CURDATE())) as bantuan_bulan_ini, + (SELECT COUNT(*) FROM laporan_masyarakat WHERE status='menunggu') as laporan_pending + "); + $this->json(['success' => true, 'data' => $data]); + } + + // API: Data peta + public function apiPeta(): void { + $warga = $this->db->fetchAll(" + SELECT w.id, w.nama, w.lat, w.lng, w.status_kemiskinan, w.level_prioritas, + w.is_blind_spot, k.nama as kelurahan, ri.nama as rumah_ibadah + FROM warga_miskin w + JOIN kelurahan k ON w.id_kelurahan = k.id + LEFT JOIN rumah_ibadah ri ON w.id_rumah_ibadah = ri.id + WHERE w.status = 'aktif' + "); + $ri = $this->db->fetchAll(" + SELECT ri.id, ri.nama, ri.lat, ri.lng, ri.radius_meter, ri.alamat, + ji.nama as jenis, k.nama as kelurahan + FROM rumah_ibadah ri + JOIN jenis_rumah_ibadah ji ON ri.id_jenis = ji.id + JOIN kelurahan k ON ri.id_kelurahan = k.id + WHERE ri.is_active = 1 + "); + $laporan = $this->db->fetchAll(" + SELECT id, kode_laporan, nama_warga, lat, lng, status, created_at + FROM laporan_masyarakat WHERE lat != 0 AND lng != 0 + AND status IN ('menunggu','diproses') + "); + $this->json(['warga' => $warga, 'rumah_ibadah' => $ri, 'laporan' => $laporan]); + } +} \ No newline at end of file diff --git a/app/controllers/AuthController.php b/app/controllers/AuthController.php new file mode 100644 index 0000000..4622e89 --- /dev/null +++ b/app/controllers/AuthController.php @@ -0,0 +1,42 @@ +view('auth.login', ['title' => 'Login - SINERGI'], null); + } + + public function doLogin(): void { + if (!$this->verifyCsrf()) { + $this->flash('error', 'Token tidak valid.'); + $this->redirect(APP_URL . '/login'); + } + + $username = trim($_POST['username'] ?? ''); + $password = $_POST['password'] ?? ''; + + if (empty($username) || empty($password)) { + $this->flash('error', 'Username dan password wajib diisi.'); + $this->redirect(APP_URL . '/login'); + } + + if (Auth::attempt($username, $password)) { + $this->redirect(Auth::getDashboardUrl()); + } else { + $this->flash('error', 'Username atau password salah.'); + $this->redirect(APP_URL . '/login'); + } + } + + public function logout(): void { + Auth::logout(); + $this->redirect(APP_URL . '/login'); + } + + public function forbidden(): void { + http_response_code(403); + $this->view('public.403', ['title' => 'Akses Ditolak'], null); + } +} diff --git a/app/controllers/DinsosController.php b/app/controllers/DinsosController.php new file mode 100644 index 0000000..91a194d --- /dev/null +++ b/app/controllers/DinsosController.php @@ -0,0 +1,617 @@ +db->fetchOne(" + SELECT + (SELECT COUNT(*) FROM warga_miskin WHERE status='aktif') as total_warga, + (SELECT COUNT(*) FROM warga_miskin WHERE status='aktif' AND status_kemiskinan='Miskin Ekstrem') as ekstrem, + (SELECT COUNT(*) FROM warga_miskin WHERE status='aktif' AND status_kemiskinan='Miskin') as miskin, + (SELECT COUNT(*) FROM warga_miskin WHERE status='aktif' AND status_kemiskinan='Rentan Miskin') as rentan, + (SELECT COUNT(*) FROM warga_miskin WHERE is_blind_spot=1 AND status='aktif') as blind_spot, + (SELECT COUNT(*) FROM rumah_ibadah WHERE is_active=1) as total_ri, + (SELECT COUNT(*) FROM histori_bantuan WHERE status='disalurkan') as total_bantuan, + (SELECT COUNT(*) FROM histori_bantuan WHERE status='disalurkan' AND MONTH(tanggal_penyaluran)=MONTH(CURDATE())) as bantuan_bulan_ini, + (SELECT COUNT(*) FROM laporan_masyarakat WHERE status='menunggu') as laporan_pending + "); + + $perKecamatan = $this->db->fetchAll(" + SELECT kec.id, kec.nama, + COUNT(w.id) as total, + SUM(w.status_kemiskinan='Miskin Ekstrem') as ekstrem, + SUM(w.status_kemiskinan='Miskin') as miskin, + SUM(w.status_kemiskinan='Rentan Miskin') as rentan, + SUM(w.is_blind_spot) as blind_spot + FROM kecamatan kec + LEFT JOIN kelurahan k ON k.id_kecamatan = kec.id + LEFT JOIN warga_miskin w ON w.id_kelurahan = k.id AND w.status='aktif' + GROUP BY kec.id, kec.nama + ORDER BY total DESC + "); + + $trenBantuan = $this->db->fetchAll(" + SELECT DATE_FORMAT(tanggal_penyaluran,'%Y-%m') as bulan, + COUNT(*) as jumlah, + COUNT(DISTINCT id_warga) as warga_terbantu + FROM histori_bantuan WHERE status='disalurkan' + AND tanggal_penyaluran >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH) + GROUP BY bulan ORDER BY bulan + "); + + $topRi = $this->db->fetchAll(" + SELECT ri.nama, ji.nama as jenis, COUNT(hb.id) as jml_bantuan, + COUNT(DISTINCT hb.id_warga) as jml_warga + FROM rumah_ibadah ri + JOIN jenis_rumah_ibadah ji ON ri.id_jenis = ji.id + LEFT JOIN histori_bantuan hb ON hb.id_rumah_ibadah = ri.id AND hb.status='disalurkan' + WHERE ri.is_active = 1 + GROUP BY ri.id, ri.nama, ji.nama + ORDER BY jml_bantuan DESC LIMIT 5 + "); + + $this->view('dinsos.dashboard', [ + 'title' => 'Dashboard Dinas Sosial', + 'stats' => $stats, + 'perKecamatan' => $perKecamatan, + 'trenBantuan' => $trenBantuan, + 'topRi' => $topRi, + 'user' => Auth::user() + ], 'dinsos'); + } + + public function kemiskinan(): void { + $filter_kec = $_GET['kecamatan'] ?? ''; + $filter_kel = $_GET['kelurahan'] ?? ''; + $filter_status = $_GET['status'] ?? ''; + $filter_prioritas = $_GET['prioritas'] ?? ''; + + $sql = "SELECT w.*, k.nama as kelurahan, kec.nama as kecamatan, ri.nama as rumah_ibadah + FROM warga_miskin w + JOIN kelurahan k ON w.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + LEFT JOIN rumah_ibadah ri ON w.id_rumah_ibadah = ri.id + WHERE w.status = 'aktif'"; + $params = []; + + if ($filter_kec) { $sql .= " AND kec.id = ?"; $params[] = $filter_kec; } + if ($filter_kel) { $sql .= " AND k.id = ?"; $params[] = $filter_kel; } + if ($filter_status) { $sql .= " AND w.status_kemiskinan = ?"; $params[] = $filter_status; } + if ($filter_prioritas) { $sql .= " AND w.level_prioritas = ?"; $params[] = $filter_prioritas; } + $sql .= " ORDER BY w.skor_prioritas DESC"; + + $data = $this->db->fetchAll($sql, $params); + $kecamatan = $this->db->fetchAll("SELECT * FROM kecamatan ORDER BY nama"); + $kelurahan = !empty($filter_kec) + ? $this->db->fetchAll("SELECT * FROM kelurahan WHERE id_kecamatan = ? ORDER BY nama", [$filter_kec]) + : []; + + $this->view('dinsos.kemiskinan', [ + 'title' => 'Monitoring Kemiskinan', + 'data' => $data, + 'kecamatan' => $kecamatan, + 'kelurahan' => $kelurahan, + 'filter_kec' => $filter_kec, + 'filter_kel' => $filter_kel, + 'filter_status' => $filter_status, + 'filter_prioritas'=> $filter_prioritas, + 'user' => Auth::user() + ], 'dinsos'); + } + + public function bantuan(): void { + $bulan = $_GET['bulan'] ?? date('Y-m'); + [$tahun, $bln] = explode('-', $bulan); + + $data = $this->db->fetchAll(" + SELECT hb.*, kb.nama as kategori, wm.nama as warga, + ri.nama as rumah_ibadah, k.nama as kelurahan, kec.nama as kecamatan + FROM histori_bantuan hb + JOIN kategori_bantuan kb ON hb.id_kategori_bantuan = kb.id + JOIN warga_miskin wm ON hb.id_warga = wm.id + JOIN rumah_ibadah ri ON hb.id_rumah_ibadah = ri.id + JOIN kelurahan k ON wm.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + WHERE hb.status = 'disalurkan' + AND YEAR(hb.tanggal_penyaluran) = ? AND MONTH(hb.tanggal_penyaluran) = ? + ORDER BY hb.tanggal_penyaluran DESC + ", [$tahun, $bln]); + + $perKategori = $this->db->fetchAll(" + SELECT kb.nama, COUNT(hb.id) as jumlah + FROM histori_bantuan hb + JOIN kategori_bantuan kb ON hb.id_kategori_bantuan = kb.id + WHERE hb.status = 'disalurkan' + AND YEAR(hb.tanggal_penyaluran) = ? AND MONTH(hb.tanggal_penyaluran) = ? + GROUP BY kb.id, kb.nama ORDER BY jumlah DESC + ", [$tahun, $bln]); + + $this->view('dinsos.bantuan', [ + 'title' => 'Monitoring Bantuan', + 'data' => $data, + 'perKategori' => $perKategori, + 'bulan' => $bulan, + 'user' => Auth::user() + ], 'dinsos'); + } + + public function akun(): void { + $users = $this->db->fetchAll(" + SELECT u.*, r.nama as role_nama, r.slug as role_slug, + CONCAT(kec.nama, ' - ', k.nama) as kelurahan_nama, + ri.id as rumah_ibadah_id, ri.nama as rumah_ibadah_nama + FROM users u + JOIN roles r ON u.id_role = r.id + LEFT JOIN kelurahan k ON u.id_kelurahan = k.id + LEFT JOIN kecamatan kec ON k.id_kecamatan = kec.id + LEFT JOIN rumah_ibadah ri ON ri.id_user = u.id + WHERE r.slug IN ('verifikator', 'rumah_ibadah') + ORDER BY r.id, u.nama + "); + + $roles = $this->db->fetchAll(" + SELECT id, nama, slug + FROM roles + WHERE slug IN ('verifikator', 'rumah_ibadah') + ORDER BY id + "); + + $kelurahan = $this->db->fetchAll(" + SELECT k.id, CONCAT(kec.nama, ' - ', k.nama) as nama + FROM kelurahan k + JOIN kecamatan kec ON k.id_kecamatan = kec.id + ORDER BY kec.nama, k.nama + "); + + $rumahIbadah = $this->db->fetchAll(" + SELECT ri.id, ri.nama, ri.id_user, CONCAT(kec.nama, ' - ', k.nama) as wilayah, + ji.nama as jenis, u.nama as pengurus_nama + FROM rumah_ibadah ri + JOIN jenis_rumah_ibadah ji ON ri.id_jenis = ji.id + JOIN kelurahan k ON ri.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + LEFT JOIN users u ON ri.id_user = u.id + WHERE ri.is_active = 1 + ORDER BY kec.nama, k.nama, ri.nama + "); + + $this->view('dinsos.akun', [ + 'title' => 'Manajemen Akun Pengguna', + 'users' => $users, + 'roles' => $roles, + 'kelurahan' => $kelurahan, + 'rumahIbadah' => $rumahIbadah, + 'user' => Auth::user() + ], 'dinsos'); + } + + public function storeAkun(): void { + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/dinsos/akun'); + + $roleSlug = $_POST['role_slug'] ?? ''; + if (!in_array($roleSlug, ['verifikator', 'rumah_ibadah'], true)) { + $this->flash('error', 'Peran pengguna tidak valid.'); + $this->redirect(APP_URL . '/dinsos/akun'); + } + + $role = $this->db->fetchOne("SELECT id, nama FROM roles WHERE slug = ?", [$roleSlug]); + if (!$role) { + $this->flash('error', 'Data peran tidak ditemukan.'); + $this->redirect(APP_URL . '/dinsos/akun'); + } + + $idKelurahan = null; + $idRumahIbadah = null; + if ($roleSlug === 'verifikator') { + $idKelurahan = (int)($_POST['id_kelurahan'] ?? 0); + if ($idKelurahan <= 0) { + $this->flash('error', 'Kelurahan tanggung jawab verifikator wajib dipilih.'); + $this->redirect(APP_URL . '/dinsos/akun'); + } + } else { + $idRumahIbadah = (int)($_POST['id_rumah_ibadah'] ?? 0); + $ri = $this->db->fetchOne("SELECT id, id_kelurahan, id_user, nama FROM rumah_ibadah WHERE id = ? AND is_active = 1", [$idRumahIbadah]); + if (!$ri) { + $this->flash('error', 'Rumah ibadah tidak ditemukan atau tidak aktif.'); + $this->redirect(APP_URL . '/dinsos/akun'); + } + if (!empty($ri['id_user'])) { + $this->flash('error', 'Rumah ibadah tersebut sudah memiliki pengurus.'); + $this->redirect(APP_URL . '/dinsos/akun'); + } + $idKelurahan = (int)$ri['id_kelurahan']; + } + + $password = generatePasswordAkun(); + $data = [ + 'id_role' => (int)$role['id'], + 'id_kelurahan' => $idKelurahan, + 'nama' => trim($_POST['nama'] ?? ''), + 'username' => trim($_POST['username'] ?? ''), + 'email' => trim($_POST['email'] ?? ''), + 'password' => password_hash($password, PASSWORD_BCRYPT), + 'no_hp' => trim($_POST['no_hp'] ?? ''), + 'is_active' => 1 + ]; + + if (!$data['nama'] || !$data['username'] || !$data['email']) { + $this->flash('error', 'Nama lengkap, username, dan email wajib diisi.'); + $this->redirect(APP_URL . '/dinsos/akun'); + } + + try { + $idUser = $this->db->insert('users', $data); + if ($roleSlug === 'rumah_ibadah') { + $this->db->update('rumah_ibadah', ['id_user' => $idUser], 'id = ?', [$idRumahIbadah]); + } + + $emailSent = kirimEmailAkun($data['email'], $data['nama'], $data['username'], $password, 'dibuat'); + Auth::logActivity(Auth::id(), Auth::user()['nama'], 'dinsos', 'Tambah Akun Pengguna', 'users', 'Menambah user: ' . $data['username']); + + if ($emailSent) { + $this->flash('success', 'Akun berhasil disimpan dan kredensial dikirim ke email pengguna.'); + } else { + $this->flash('warning', 'Akun berhasil disimpan, tetapi email belum terkirim. Password awal: ' . $password . ''); + } + } catch (Exception $e) { + $this->flash('error', 'Gagal menyimpan akun. Username atau email mungkin sudah digunakan.'); + } + + $this->redirect(APP_URL . '/dinsos/akun'); + } + + public function updateAkun(string $id): void { + $id = (int)$id; + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/dinsos/akun'); + + $target = $this->managedUser($id); + if (!$target) { + $this->flash('error', 'Akun tidak ditemukan atau tidak dapat dikelola Dinsos.'); + $this->redirect(APP_URL . '/dinsos/akun'); + } + + $roleSlug = $_POST['role_slug'] ?? ''; + if (!in_array($roleSlug, ['verifikator', 'rumah_ibadah'], true)) { + $this->flash('error', 'Peran pengguna tidak valid.'); + $this->redirect(APP_URL . '/dinsos/akun'); + } + + $role = $this->db->fetchOne("SELECT id FROM roles WHERE slug = ?", [$roleSlug]); + $idKelurahan = null; + $idRumahIbadah = null; + + if ($roleSlug === 'verifikator') { + $idKelurahan = (int)($_POST['id_kelurahan'] ?? 0); + if ($idKelurahan <= 0) { + $this->flash('error', 'Kelurahan tanggung jawab verifikator wajib dipilih.'); + $this->redirect(APP_URL . '/dinsos/akun'); + } + } else { + $idRumahIbadah = (int)($_POST['id_rumah_ibadah'] ?? 0); + $ri = $this->db->fetchOne("SELECT id, id_kelurahan, id_user FROM rumah_ibadah WHERE id = ? AND is_active = 1", [$idRumahIbadah]); + if (!$ri) { + $this->flash('error', 'Rumah ibadah tidak ditemukan atau tidak aktif.'); + $this->redirect(APP_URL . '/dinsos/akun'); + } + if (!empty($ri['id_user']) && (int)$ri['id_user'] !== $id) { + $this->flash('error', 'Rumah ibadah tersebut sudah memiliki pengurus.'); + $this->redirect(APP_URL . '/dinsos/akun'); + } + $idKelurahan = (int)$ri['id_kelurahan']; + } + + $data = [ + 'id_role' => (int)$role['id'], + 'id_kelurahan' => $idKelurahan, + 'nama' => trim($_POST['nama'] ?? ''), + 'username' => trim($_POST['username'] ?? ''), + 'email' => trim($_POST['email'] ?? ''), + 'no_hp' => trim($_POST['no_hp'] ?? ''), + 'is_active' => (int)($_POST['is_active'] ?? 1) + ]; + + try { + $this->db->update('users', $data, 'id = ?', [$id]); + $this->db->update('rumah_ibadah', ['id_user' => null], 'id_user = ?', [$id]); + if ($roleSlug === 'rumah_ibadah' && $data['is_active']) { + $this->db->update('rumah_ibadah', ['id_user' => $id], 'id = ?', [$idRumahIbadah]); + } + + Auth::logActivity(Auth::id(), Auth::user()['nama'], 'dinsos', 'Update Akun Pengguna', 'users', "Update user ID $id"); + $this->flash('success', 'Akun pengguna berhasil diperbarui.'); + } catch (Exception $e) { + $this->flash('error', 'Gagal memperbarui akun. Username atau email mungkin sudah digunakan.'); + } + + $this->redirect(APP_URL . '/dinsos/akun'); + } + + public function resetAkunPassword(string $id): void { + $id = (int)$id; + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/dinsos/akun'); + + $target = $this->managedUser($id); + if (!$target) { + $this->flash('error', 'Akun tidak ditemukan atau tidak dapat dikelola Dinsos.'); + $this->redirect(APP_URL . '/dinsos/akun'); + } + + $password = generatePasswordAkun(); + $this->db->update('users', [ + 'password' => password_hash($password, PASSWORD_BCRYPT) + ], 'id = ?', [$id]); + + $emailSent = kirimEmailAkun($target['email'], $target['nama'], $target['username'], $password, 'direset passwordnya'); + Auth::logActivity(Auth::id(), Auth::user()['nama'], 'dinsos', 'Reset Password Akun', 'users', "Reset password user ID $id"); + + if ($emailSent) { + $this->flash('success', 'Password berhasil direset dan dikirim ke email pengguna.'); + } else { + $this->flash('warning', 'Password berhasil direset, tetapi email belum terkirim. Password baru: ' . $password . ''); + } + + $this->redirect(APP_URL . '/dinsos/akun'); + } + + public function deactivateAkun(string $id): void { + $id = (int)$id; + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/dinsos/akun'); + + $target = $this->managedUser($id); + if (!$target) { + $this->flash('error', 'Akun tidak ditemukan atau tidak dapat dikelola Dinsos.'); + $this->redirect(APP_URL . '/dinsos/akun'); + } + + $this->db->update('users', ['is_active' => 0], 'id = ?', [$id]); + $this->db->update('rumah_ibadah', ['id_user' => null], 'id_user = ?', [$id]); + Auth::logActivity(Auth::id(), Auth::user()['nama'], 'dinsos', 'Nonaktifkan Akun', 'users', "Nonaktifkan user ID $id"); + $this->flash('success', 'Akun pengguna berhasil dinonaktifkan.'); + $this->redirect(APP_URL . '/dinsos/akun'); + } + + private function managedUser(int $id): ?array { + return $this->db->fetchOne(" + SELECT u.*, r.slug as role_slug + FROM users u + JOIN roles r ON u.id_role = r.id + WHERE u.id = ? AND r.slug IN ('verifikator', 'rumah_ibadah') + ", [$id]); + } + + public function blindSpot(): void { + $data = $this->db->fetchAll(" + SELECT w.*, k.nama as kelurahan, kec.nama as kecamatan, + ri.id as assigned_ri_id, ri.nama as assigned_rumah_ibadah, + assigned_ji.nama as assigned_jenis + FROM warga_miskin w + JOIN kelurahan k ON w.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + LEFT JOIN rumah_ibadah ri ON w.id_rumah_ibadah = ri.id + LEFT JOIN jenis_rumah_ibadah assigned_ji ON ri.id_jenis = assigned_ji.id + WHERE w.is_blind_spot = 1 AND w.status = 'aktif' + ORDER BY CASE WHEN w.id_rumah_ibadah IS NULL THEN 0 ELSE 1 END, + CASE w.level_prioritas WHEN 'Tinggi' THEN 1 WHEN 'Sedang' THEN 2 ELSE 3 END, + w.skor_prioritas DESC + "); + + $rumahIbadah = $this->db->fetchAll(" + SELECT ri.id, ri.nama, ri.alamat, ri.lat, ri.lng, ri.radius_meter, + ji.nama as jenis, k.nama as kelurahan, kec.nama as kecamatan, + COUNT(wm.id) as jml_warga + FROM rumah_ibadah ri + JOIN jenis_rumah_ibadah ji ON ri.id_jenis = ji.id + JOIN kelurahan k ON ri.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + LEFT JOIN warga_miskin wm ON wm.id_rumah_ibadah = ri.id AND wm.status = 'aktif' + WHERE ri.is_active = 1 + GROUP BY ri.id, ri.nama, ri.alamat, ri.lat, ri.lng, ri.radius_meter, + ji.nama, k.nama, kec.nama + ORDER BY kec.nama, k.nama, ri.nama + "); + + $rekomendasi = $this->buildRekomendasiRumahIbadah($data, $rumahIbadah); + $sudahDitugaskan = count(array_filter($data, fn($w) => !empty($w['id_rumah_ibadah']))); + + $perKec = $this->db->fetchAll(" + SELECT kec.nama, COUNT(w.id) as jumlah + FROM kecamatan kec + JOIN kelurahan k ON k.id_kecamatan = kec.id + JOIN warga_miskin w ON w.id_kelurahan = k.id + WHERE w.is_blind_spot = 1 AND w.status = 'aktif' + GROUP BY kec.id, kec.nama ORDER BY jumlah DESC + "); + + $this->view('dinsos.blind_spot', [ + 'title' => 'Monitoring Blind Spot', + 'data' => $data, + 'perKec' => $perKec, + 'rumahIbadah' => $rumahIbadah, + 'rekomendasi' => $rekomendasi, + 'ringkasanPenugasan' => [ + 'sudah' => $sudahDitugaskan, + 'belum' => count($data) - $sudahDitugaskan, + 'total_ri' => count($rumahIbadah) + ], + 'user' => Auth::user() + ], 'dinsos'); + } + + public function assignBlindSpot(string $id): void { + $id = (int) $id; + + if (!$this->verifyCsrf()) { + $this->flash('error', 'Token tidak valid. Silakan coba lagi.'); + $this->redirect(APP_URL . '/dinsos/blind-spot'); + } + + $idRumahIbadah = (int)($_POST['id_rumah_ibadah'] ?? 0); + if ($id <= 0 || $idRumahIbadah <= 0) { + $this->flash('error', 'Pilih rumah ibadah penanggung jawab terlebih dahulu.'); + $this->redirect(APP_URL . '/dinsos/blind-spot'); + } + + $warga = $this->db->fetchOne(" + SELECT id, nama + FROM warga_miskin + WHERE id = ? AND is_blind_spot = 1 AND status = 'aktif' + ", [$id]); + + if (!$warga) { + $this->flash('error', 'Data warga blind spot tidak ditemukan.'); + $this->redirect(APP_URL . '/dinsos/blind-spot'); + } + + $rumahIbadah = $this->db->fetchOne(" + SELECT id, nama + FROM rumah_ibadah + WHERE id = ? AND is_active = 1 + ", [$idRumahIbadah]); + + if (!$rumahIbadah) { + $this->flash('error', 'Rumah ibadah tidak aktif atau tidak ditemukan.'); + $this->redirect(APP_URL . '/dinsos/blind-spot'); + } + + $this->db->update('warga_miskin', [ + 'id_rumah_ibadah' => $idRumahIbadah + ], 'id = ? AND is_blind_spot = 1 AND status = ?', [$id, 'aktif']); + + Auth::logActivity( + Auth::id(), + Auth::user()['nama'], + 'dinsos', + 'Atur Penugasan Blind Spot', + 'blind_spot', + 'Menugaskan ' . $warga['nama'] . ' ke ' . $rumahIbadah['nama'] + ); + + $this->flash('success', 'Penugasan warga blind spot berhasil disimpan.'); + $this->redirect(APP_URL . '/dinsos/blind-spot'); + } + + private function buildRekomendasiRumahIbadah(array $wargaList, array $rumahIbadahList): array { + $hasil = []; + + foreach ($wargaList as $warga) { + $lat = (float)($warga['lat'] ?? 0); + $lng = (float)($warga['lng'] ?? 0); + $idWarga = (int)($warga['id'] ?? 0); + $hasil[$idWarga] = []; + + if (!$lat || !$lng) { + continue; + } + + $kandidat = []; + foreach ($rumahIbadahList as $ri) { + if (empty($ri['lat']) || empty($ri['lng'])) { + continue; + } + + $jarak = haversine($lat, $lng, (float)$ri['lat'], (float)$ri['lng']); + $item = $ri; + $item['jarak_meter'] = (int)round($jarak); + $item['di_dalam_radius'] = $jarak <= (float)$ri['radius_meter']; + $kandidat[] = $item; + } + + usort($kandidat, fn($a, $b) => $a['jarak_meter'] <=> $b['jarak_meter']); + $hasil[$idWarga] = array_slice($kandidat, 0, 3); + } + + return $hasil; + } + + public function kinerja(): void { + $data = $this->db->fetchAll(" + SELECT ri.id, ri.nama, ji.nama as jenis, k.nama as kelurahan, kec.nama as kecamatan, + ri.radius_meter, + COUNT(DISTINCT wm.id) as jml_warga, + COUNT(DISTINCT hb.id) as jml_bantuan, + COUNT(DISTINCT CASE WHEN wm.status_kemiskinan='Miskin Ekstrem' THEN wm.id END) as jml_ekstrem, + MAX(hb.tanggal_penyaluran) as bantuan_terakhir + FROM rumah_ibadah ri + JOIN jenis_rumah_ibadah ji ON ri.id_jenis = ji.id + JOIN kelurahan k ON ri.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + LEFT JOIN warga_miskin wm ON wm.id_rumah_ibadah = ri.id AND wm.status='aktif' + LEFT JOIN histori_bantuan hb ON hb.id_rumah_ibadah = ri.id AND hb.status='disalurkan' + WHERE ri.is_active = 1 + GROUP BY ri.id, ri.nama, ji.nama, k.nama, kec.nama, ri.radius_meter + ORDER BY jml_bantuan DESC + "); + + $this->view('dinsos.kinerja', [ + 'title' => 'Kinerja Rumah Ibadah', + 'data' => $data, + 'user' => Auth::user() + ], 'dinsos'); + } + + public function laporan(): void { + $tahun = (int)($_GET['tahun'] ?? date('Y')); + + $perBulan = $this->db->fetchAll(" + SELECT MONTH(tanggal_penyaluran) as bulan, COUNT(*) as bantuan, + COUNT(DISTINCT id_warga) as warga_terbantu + FROM histori_bantuan WHERE status='disalurkan' AND YEAR(tanggal_penyaluran) = ? + GROUP BY MONTH(tanggal_penyaluran) ORDER BY bulan + ", [$tahun]); + + $ringkasan = $this->db->fetchOne(" + SELECT COUNT(*) as total_bantuan, + COUNT(DISTINCT id_warga) as total_warga_terbantu, + COUNT(DISTINCT id_rumah_ibadah) as total_ri_aktif + FROM histori_bantuan WHERE status='disalurkan' AND YEAR(tanggal_penyaluran) = ? + ", [$tahun]); + + $this->view('dinsos.laporan', [ + 'title' => 'Laporan Tahunan', + 'perBulan' => $perBulan, + 'ringkasan' => $ringkasan, + 'tahun' => $tahun, + 'user' => Auth::user() + ], 'dinsos'); + } + + public function apiHeatmap(): void { + $data = $this->db->fetchAll(" + SELECT lat, lng, status_kemiskinan, + CASE status_kemiskinan + WHEN 'Miskin Ekstrem' THEN 1.0 + WHEN 'Miskin' THEN 0.7 + WHEN 'Rentan Miskin' THEN 0.4 + ELSE 0.3 + END as intensity + FROM warga_miskin WHERE status = 'aktif' AND lat != 0 + "); + $this->json($data); + } + + public function apiStats(): void { + $perKec = $this->db->fetchAll(" + SELECT kec.nama, + COUNT(w.id) as total, + SUM(w.status_kemiskinan='Miskin Ekstrem') as ekstrem, + SUM(w.is_blind_spot) as blind_spot + FROM kecamatan kec + LEFT JOIN kelurahan k ON k.id_kecamatan = kec.id + LEFT JOIN warga_miskin w ON w.id_kelurahan = k.id AND w.status='aktif' + GROUP BY kec.id, kec.nama + "); + $trenBantuan = $this->db->fetchAll(" + SELECT DATE_FORMAT(tanggal_penyaluran,'%Y-%m') as bulan, COUNT(*) as jumlah + FROM histori_bantuan WHERE status='disalurkan' + AND tanggal_penyaluran >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH) + GROUP BY bulan ORDER BY bulan + "); + $this->json(['perKecamatan' => $perKec, 'trenBantuan' => $trenBantuan]); + } +} diff --git a/app/controllers/PublicController.php b/app/controllers/PublicController.php new file mode 100644 index 0000000..eef3bb9 --- /dev/null +++ b/app/controllers/PublicController.php @@ -0,0 +1,134 @@ +db->fetchOne(" + SELECT + (SELECT COUNT(*) FROM warga_miskin WHERE status = 'aktif') as total_warga, + (SELECT COUNT(*) FROM rumah_ibadah WHERE is_active = 1) as total_ri, + (SELECT COUNT(*) FROM laporan_masyarakat) as total_laporan, + (SELECT COUNT(*) FROM histori_bantuan WHERE status = 'disalurkan') as total_bantuan, + (SELECT COUNT(*) FROM warga_miskin WHERE is_blind_spot = 1) as total_blind_spot + "); + $this->view('public.beranda', ['title' => 'SINERGI - Sistem GIS Penanganan Kemiskinan', 'stats' => $stats], 'public'); + } + + public function tentang(): void { + $this->view('public.tentang', ['title' => 'Tentang Sistem - SINERGI'], 'public'); + } + + public function kontak(): void { + $this->view('public.kontak', ['title' => 'Kontak - SINERGI'], 'public'); + } + + public function lapor(): void { + $kecamatan = $this->db->fetchAll("SELECT * FROM kecamatan ORDER BY nama"); + $this->view('public.lapor', [ + 'title' => 'Lapor Cepat - SINERGI', + 'kecamatan' => $kecamatan + ], 'public'); + } + + public function kirimLaporan(): void { + if (!$this->verifyCsrf()) { + $this->flash('error', 'Token tidak valid.'); + $this->redirect(APP_URL . '/lapor'); + } + + $namaWarga = trim($_POST['nama_warga'] ?? ''); + $namaPelapor = trim($_POST['nama_pelapor'] ?? ''); + $noHp = trim($_POST['no_hp_pelapor'] ?? ''); + $alamat = trim($_POST['alamat_warga'] ?? ''); + $idKelurahan = (int)($_POST['id_kelurahan'] ?? 0); + $lat = (float)($_POST['lat'] ?? 0); + $lng = (float)($_POST['lng'] ?? 0); + $deskripsi = trim($_POST['deskripsi'] ?? ''); + + if (!$namaWarga || !$namaPelapor || !$alamat || !$idKelurahan || !$lat || !$lng) { + $this->flash('error', 'Mohon lengkapi semua data yang wajib diisi.'); + $this->redirect(APP_URL . '/lapor'); + } + + $foto = null; + if (!empty($_FILES['foto']['name'])) { + $foto = $this->uploadFile($_FILES['foto'], 'laporan'); + } + + $kode = generateKodeLaporan(); + + $this->db->insert('laporan_masyarakat', [ + 'kode_laporan' => $kode, + 'nama_pelapor' => $namaPelapor, + 'no_hp_pelapor' => $noHp, + 'nama_warga' => $namaWarga, + 'alamat_warga' => $alamat, + 'id_kelurahan' => $idKelurahan, + 'lat' => $lat, + 'lng' => $lng, + 'deskripsi' => $deskripsi, + 'foto' => $foto, + 'status' => 'menunggu' + ]); + + $this->flash('success', 'Laporan berhasil dikirim! Simpan kode laporan Anda.', ['kode_laporan' => $kode]); + $this->redirect(APP_URL . '/lapor'); + } + + public function cekLaporan(): void { + $this->view('public.cek_laporan', ['title' => 'Cek Status Laporan - SINERGI'], 'public'); + } + + public function proseCekLaporan(): void { + $kode = strtoupper(trim($_POST['kode_laporan'] ?? '')); + $laporan = null; + if ($kode) { + $laporan = $this->db->fetchOne( + "SELECT l.*, k.nama as kelurahan, kec.nama as kecamatan + FROM laporan_masyarakat l + JOIN kelurahan k ON l.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + WHERE l.kode_laporan = ?", + [$kode] + ); + } + $this->view('public.cek_laporan', [ + 'title' => 'Cek Status Laporan - SINERGI', + 'laporan' => $laporan, + 'kode' => $kode + ], 'public'); + } + + // API: Ambil kelurahan berdasarkan kecamatan + public function apiKelurahan(int $id): void { + $data = $this->db->fetchAll("SELECT id, nama FROM kelurahan WHERE id_kecamatan = ? ORDER BY nama", [$id]); + $this->json($data); + } + + // API: Data rumah ibadah untuk peta publik + public function apiRumahIbadah(): void { + $data = $this->db->fetchAll( + "SELECT ri.id, ri.nama, ri.lat, ri.lng, ri.radius_meter, ri.alamat, + ji.nama as jenis, k.nama as kelurahan + FROM rumah_ibadah ri + JOIN jenis_rumah_ibadah ji ON ri.id_jenis = ji.id + JOIN kelurahan k ON ri.id_kelurahan = k.id + WHERE ri.is_active = 1" + ); + $this->json($data); + } + + // API: Data warga untuk peta publik (tanpa data sensitif) + public function apiWargaPeta(): void { + $data = $this->db->fetchAll( + "SELECT w.lat, w.lng, w.status_kemiskinan, w.level_prioritas, w.is_blind_spot, + k.nama as kelurahan, kec.nama as kecamatan + FROM warga_miskin w + JOIN kelurahan k ON w.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + WHERE w.status = 'aktif'" + ); + $this->json($data); + } +} \ No newline at end of file diff --git a/app/controllers/RumahIbadahController.php b/app/controllers/RumahIbadahController.php new file mode 100644 index 0000000..1e06f38 --- /dev/null +++ b/app/controllers/RumahIbadahController.php @@ -0,0 +1,225 @@ +

Akun belum terhubung ke Rumah Ibadah

Hubungi Super Admin untuk menautkan akun Anda.

'); + } + $this->ri = $this->db->fetchOne( + "SELECT ri.*, ji.nama as jenis, k.nama as kelurahan, kec.nama as kecamatan + FROM rumah_ibadah ri + JOIN jenis_rumah_ibadah ji ON ri.id_jenis = ji.id + JOIN kelurahan k ON ri.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + WHERE ri.id = ?", + [$user['rumah_ibadah_id']] + ); + } + + public function dashboard(): void { + $riId = $this->ri['id']; + + $stats = $this->db->fetchOne(" + SELECT + (SELECT COUNT(*) FROM warga_miskin WHERE id_rumah_ibadah=? AND status='aktif') as total_warga, + (SELECT COUNT(*) FROM warga_miskin WHERE id_rumah_ibadah=? AND level_prioritas='Tinggi' AND status='aktif') as prioritas_tinggi, + (SELECT COUNT(*) FROM warga_miskin WHERE id_rumah_ibadah=? AND level_prioritas='Sedang' AND status='aktif') as prioritas_sedang, + (SELECT COUNT(*) FROM histori_bantuan WHERE id_rumah_ibadah=? AND status='disalurkan') as total_bantuan, + (SELECT COUNT(*) FROM histori_bantuan WHERE id_rumah_ibadah=? AND status='disalurkan' AND MONTH(tanggal_penyaluran)=MONTH(CURDATE())) as bantuan_bulan_ini, + (SELECT COUNT(*) FROM warga_miskin WHERE id_rumah_ibadah=? AND status_kemiskinan='Miskin Ekstrem' AND status='aktif') as ekstrem + ", array_fill(0, 6, $riId)); + + $wargaList = $this->db->fetchAll(" + SELECT w.id, w.nama, w.status_kemiskinan, w.level_prioritas, w.skor_prioritas, + w.penghasilan_bulanan, w.jumlah_tanggungan, w.lat, w.lng, + w.is_lansia, w.is_disabilitas, k.nama as kelurahan + FROM warga_miskin w + JOIN kelurahan k ON w.id_kelurahan = k.id + WHERE w.id_rumah_ibadah = ? AND w.status = 'aktif' + ORDER BY w.skor_prioritas DESC + LIMIT 20 + ", [$riId]); + + $bantuanTerakhir = $this->db->fetchAll(" + SELECT hb.*, kb.nama as kategori, wm.nama as warga + FROM histori_bantuan hb + JOIN kategori_bantuan kb ON hb.id_kategori_bantuan = kb.id + JOIN warga_miskin wm ON hb.id_warga = wm.id + WHERE hb.id_rumah_ibadah = ? + ORDER BY hb.tanggal_penyaluran DESC LIMIT 10 + ", [$riId]); + + $this->view('rumah_ibadah.dashboard', [ + 'title' => 'Dashboard Rumah Ibadah', + 'ri' => $this->ri, + 'stats' => $stats, + 'wargaList' => $wargaList, + 'bantuanTerakhir'=> $bantuanTerakhir, + 'user' => Auth::user() + ], 'rumah_ibadah'); + } + + public function warga(): void { + $riId = $this->ri['id']; + $level = $_GET['level'] ?? 'semua'; + $sql = "SELECT w.*, k.nama as kelurahan FROM warga_miskin w + JOIN kelurahan k ON w.id_kelurahan = k.id + WHERE w.id_rumah_ibadah = ? AND w.status = 'aktif'"; + $params = [$riId]; + if ($level !== 'semua') { + $sql .= " AND w.level_prioritas = ?"; + $params[] = $level; + } + $sql .= " ORDER BY w.skor_prioritas DESC"; + + $data = $this->db->fetchAll($sql, $params); + $this->view('rumah_ibadah.warga', [ + 'title' => 'Daftar Warga Binaan', + 'data' => $data, + 'ri' => $this->ri, + 'level' => $level, + 'user' => Auth::user() + ], 'rumah_ibadah'); + } + + public function detailWarga(int $id): void { + $warga = $this->db->fetchOne(" + SELECT w.*, k.nama as kelurahan, kec.nama as kecamatan + FROM warga_miskin w + JOIN kelurahan k ON w.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + WHERE w.id = ? AND w.id_rumah_ibadah = ? + ", [$id, $this->ri['id']]); + + if (!$warga) { + $this->flash('error', 'Warga tidak ditemukan dalam binaan Anda.'); + $this->redirect(APP_URL . '/rumah-ibadah/warga'); + } + + $bantuan = $this->db->fetchAll(" + SELECT hb.*, kb.nama as kategori + FROM histori_bantuan hb + JOIN kategori_bantuan kb ON hb.id_kategori_bantuan = kb.id + WHERE hb.id_warga = ? + ORDER BY hb.tanggal_penyaluran DESC + ", [$id]); + + $this->view('rumah_ibadah.detail_warga', [ + 'title' => 'Detail Warga', + 'warga' => $warga, + 'ri' => $this->ri, + 'bantuan' => $bantuan, + 'user' => Auth::user() + ], 'rumah_ibadah'); + } + + public function prioritas(): void { + $data = $this->db->fetchAll(" + SELECT w.*, k.nama as kelurahan, + (SELECT COUNT(*) FROM histori_bantuan WHERE id_warga=w.id AND status='disalurkan') as jml_bantuan, + (SELECT MAX(tanggal_penyaluran) FROM histori_bantuan WHERE id_warga=w.id AND status='disalurkan') as bantuan_terakhir + FROM warga_miskin w + JOIN kelurahan k ON w.id_kelurahan = k.id + WHERE w.id_rumah_ibadah = ? AND w.status = 'aktif' + ORDER BY w.skor_prioritas DESC, w.status_kemiskinan + ", [$this->ri['id']]); + + $this->view('rumah_ibadah.prioritas', [ + 'title' => 'Prioritas Bantuan', + 'data' => $data, + 'ri' => $this->ri, + 'user' => Auth::user() + ], 'rumah_ibadah'); + } + + public function bantuan(): void { + $data = $this->db->fetchAll(" + SELECT hb.*, kb.nama as kategori, wm.nama as warga + FROM histori_bantuan hb + JOIN kategori_bantuan kb ON hb.id_kategori_bantuan = kb.id + JOIN warga_miskin wm ON hb.id_warga = wm.id + WHERE hb.id_rumah_ibadah = ? + ORDER BY hb.tanggal_penyaluran DESC + ", [$this->ri['id']]); + + $this->view('rumah_ibadah.bantuan', [ + 'title' => 'Histori Bantuan', + 'data' => $data, + 'ri' => $this->ri, + 'user' => Auth::user() + ], 'rumah_ibadah'); + } + + public function formBantuan(): void { + $wargaList = $this->db->fetchAll(" + SELECT id, nama, status_kemiskinan, level_prioritas, skor_prioritas + FROM warga_miskin WHERE id_rumah_ibadah = ? AND status = 'aktif' + ORDER BY skor_prioritas DESC + ", [$this->ri['id']]); + + $kategori = $this->db->fetchAll("SELECT * FROM kategori_bantuan WHERE is_active = 1 ORDER BY nama"); + + $this->view('rumah_ibadah.form_bantuan', [ + 'title' => 'Tambah Bantuan', + 'wargaList' => $wargaList, + 'kategori' => $kategori, + 'ri' => $this->ri, + 'user' => Auth::user() + ], 'rumah_ibadah'); + } + + public function storeBantuan(): void { + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/rumah-ibadah/bantuan'); + + // Pastikan warga memang binaan rumah ibadah ini + $idWarga = (int)$_POST['id_warga']; + $warga = $this->db->fetchOne("SELECT id FROM warga_miskin WHERE id = ? AND id_rumah_ibadah = ?", [$idWarga, $this->ri['id']]); + if (!$warga) { + $this->flash('error', 'Warga tidak valid.'); + $this->redirect(APP_URL . '/rumah-ibadah/bantuan/tambah'); + } + + $foto = null; + if (!empty($_FILES['foto_bukti']['name'])) { + $foto = $this->uploadFile($_FILES['foto_bukti'], 'bantuan'); + } + + $this->db->insert('histori_bantuan', [ + 'id_warga' => $idWarga, + 'id_rumah_ibadah' => $this->ri['id'], + 'id_kategori_bantuan' => (int)$_POST['id_kategori_bantuan'], + 'id_user_pengurus' => Auth::id(), + 'kode_bantuan' => generateKodeBantuan(), + 'tanggal_penyaluran' => $_POST['tanggal_penyaluran'], + 'jumlah' => (float)($_POST['jumlah'] ?? 0), + 'satuan' => trim($_POST['satuan'] ?? ''), + 'deskripsi' => trim($_POST['deskripsi'] ?? ''), + 'foto_bukti' => $foto, + 'status' => 'disalurkan' + ]); + + Auth::logActivity(Auth::id(), Auth::user()['nama'], 'rumah_ibadah', 'Salurkan Bantuan', 'bantuan', "Bantuan untuk warga ID $idWarga"); + $this->flash('success', 'Bantuan berhasil dicatat.'); + $this->redirect(APP_URL . '/rumah-ibadah/bantuan'); + } + + public function apiPeta(): void { + $riId = $this->ri['id']; + $warga = $this->db->fetchAll(" + SELECT w.id, w.nama, w.lat, w.lng, w.status_kemiskinan, w.level_prioritas, w.skor_prioritas + FROM warga_miskin w WHERE w.id_rumah_ibadah = ? AND w.status = 'aktif' + ", [$riId]); + $this->json([ + 'rumah_ibadah' => $this->ri, + 'warga' => $warga + ]); + } +} \ No newline at end of file diff --git a/app/controllers/VerifikatorController.php b/app/controllers/VerifikatorController.php new file mode 100644 index 0000000..da3d027 --- /dev/null +++ b/app/controllers/VerifikatorController.php @@ -0,0 +1,388 @@ +idKelurahan = (int)($_SESSION['id_kelurahan'] ?? 0); + } + + public function dashboard(): void { + $stats = $this->db->fetchOne(" + SELECT + (SELECT COUNT(*) FROM laporan_masyarakat WHERE id_kelurahan=?) as total_laporan, + (SELECT COUNT(*) FROM laporan_masyarakat WHERE id_kelurahan=? AND status='menunggu') as laporan_menunggu, + (SELECT COUNT(*) FROM laporan_masyarakat WHERE id_kelurahan=? AND status='diproses') as laporan_diproses, + (SELECT COUNT(*) FROM warga_miskin WHERE id_kelurahan=? AND status='aktif') as total_warga, + (SELECT COUNT(*) FROM warga_miskin WHERE id_kelurahan=? AND level_prioritas='Tinggi' AND status='aktif') as prioritas_tinggi, + (SELECT COUNT(*) FROM warga_miskin WHERE id_kelurahan=? AND is_blind_spot=1 AND status='aktif') as blind_spot + ", array_fill(0, 6, $this->idKelurahan)); + + $laporanTerbaru = $this->db->fetchAll(" + SELECT l.*, k.nama as kelurahan FROM laporan_masyarakat l + JOIN kelurahan k ON l.id_kelurahan = k.id + WHERE l.id_kelurahan = ? + ORDER BY l.created_at DESC LIMIT 10 + ", [$this->idKelurahan]); + + $this->view('verifikator.dashboard', [ + 'title' => 'Dashboard Verifikator', + 'stats' => $stats, + 'laporanTerbaru'=> $laporanTerbaru, + 'user' => Auth::user() + ], 'verifikator'); + } + + public function laporan(): void { + $status = $_GET['status'] ?? 'semua'; + $sql = "SELECT l.*, k.nama as kelurahan, kec.nama as kecamatan + FROM laporan_masyarakat l + JOIN kelurahan k ON l.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + WHERE l.id_kelurahan = ?"; + $params = [$this->idKelurahan]; + if ($status !== 'semua') { + $sql .= " AND l.status = ?"; + $params[] = $status; + } + $sql .= " ORDER BY l.created_at DESC"; + + $data = $this->db->fetchAll($sql, $params); + $this->view('verifikator.laporan', [ + 'title' => 'Daftar Laporan', + 'data' => $data, + 'status' => $status, + 'user' => Auth::user() + ], 'verifikator'); + } + + // FIX: type hint string, cast to int internally (router always passes strings) + public function detailLaporan(string $id): void { + $id = (int) $id; + + $laporan = $this->db->fetchOne(" + SELECT l.*, k.nama as kelurahan, kec.nama as kecamatan + FROM laporan_masyarakat l + JOIN kelurahan k ON l.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + WHERE l.id = ? AND l.id_kelurahan = ? + ", [$id, $this->idKelurahan]); + + if (!$laporan) { + $this->flash('error', 'Laporan tidak ditemukan.'); + $this->redirect(APP_URL . '/verifikator/laporan'); + } + + $this->view('verifikator.detail_laporan', [ + 'title' => 'Detail Laporan', + 'laporan' => $laporan, + 'user' => Auth::user() + ], 'verifikator'); + } + + // FIX: type hint string, cast to int internally + public function prosesLaporan(string $id): void { + $id = (int) $id; + + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/verifikator/laporan'); + + $aksi = $_POST['aksi'] ?? ''; + $catatan = trim($_POST['catatan'] ?? ''); + + if ($aksi === 'proses') { + $this->db->update('laporan_masyarakat', [ + 'status' => 'diproses', + 'id_verifikator' => Auth::id(), + 'catatan_verifikator' => $catatan + ], 'id = ? AND id_kelurahan = ?', [$id, $this->idKelurahan]); + $this->flash('success', 'Laporan ditandai sebagai diproses.'); + + } elseif ($aksi === 'tolak') { + $this->db->update('laporan_masyarakat', [ + 'status' => 'ditolak', + 'id_verifikator' => Auth::id(), + 'catatan_verifikator'=> $catatan, + 'tanggal_verifikasi' => date('Y-m-d H:i:s') + ], 'id = ? AND id_kelurahan = ?', [$id, $this->idKelurahan]); + $this->flash('info', 'Laporan ditolak.'); + + } elseif ($aksi === 'verifikasi') { + // Verifikasi = buat warga miskin baru dari laporan + $laporan = $this->db->fetchOne("SELECT * FROM laporan_masyarakat WHERE id=?", [$id]); + if ($laporan) { + $this->db->update('laporan_masyarakat', [ + 'status' => 'terverifikasi', + 'id_verifikator' => Auth::id(), + 'catatan_verifikator'=> $catatan, + 'tanggal_verifikasi' => date('Y-m-d H:i:s') + ], 'id = ?', [$id]); + + // Cek apakah warga sudah ada + $existing = $this->db->fetchOne("SELECT id FROM warga_miskin WHERE id_laporan = ?", [$id]); + if (!$existing) { + // Buat entry warga miskin baru + $isBlindSpot = isInBlindSpot((float)$laporan['lat'], (float)$laporan['lng']) ? 1 : 0; + $ri = findRumahIbadahTerdekat((float)$laporan['lat'], (float)$laporan['lng']); + $idRi = ($ri && !$isBlindSpot) ? $ri['id'] : null; + + // Jika dalam radius, gunakan yang terdekat dan dalam radius + if ($ri && !$isBlindSpot) { + $dist = haversine((float)$laporan['lat'], (float)$laporan['lng'], (float)$ri['lat'], (float)$ri['lng']); + if ($dist > $ri['radius_meter']) { + $idRi = null; + $isBlindSpot = 1; + } + } + + $wargaData = [ + 'id_laporan' => $id, + 'id_kelurahan' => $laporan['id_kelurahan'], + 'id_rumah_ibadah' => $idRi, + 'id_verifikator' => Auth::id(), + 'nama' => $laporan['nama_warga'], + 'alamat' => $laporan['alamat_warga'], + 'lat' => $laporan['lat'], + 'lng' => $laporan['lng'], + 'is_blind_spot' => $isBlindSpot, + 'status' => 'aktif', + 'tanggal_verifikasi' => date('Y-m-d') + ]; + + // Hitung skor prioritas awal + $skor = hitungSkorPrioritas($wargaData); + $wargaData['skor_prioritas'] = $skor['skor']; + $wargaData['level_prioritas'] = $skor['level']; + + $this->db->insert('warga_miskin', $wargaData); + } + } + Auth::logActivity(Auth::id(), Auth::user()['nama'], 'verifikator', 'Verifikasi Laporan', 'verifikasi', "Verifikasi laporan ID $id"); + $this->flash('success', 'Laporan berhasil diverifikasi. Data warga telah ditambahkan.'); + } + + $this->redirect(APP_URL . '/verifikator/laporan/' . $id); + } + + public function warga(): void { + $level = $_GET['level'] ?? 'semua'; + $sql = "SELECT w.*, k.nama as kelurahan, ri.nama as rumah_ibadah + FROM warga_miskin w + JOIN kelurahan k ON w.id_kelurahan = k.id + LEFT JOIN rumah_ibadah ri ON w.id_rumah_ibadah = ri.id + WHERE w.id_kelurahan = ? AND w.status = 'aktif'"; + $params = [$this->idKelurahan]; + if ($level !== 'semua') { + $sql .= " AND w.level_prioritas = ?"; + $params[] = $level; + } + $sql .= " ORDER BY w.skor_prioritas DESC, w.nama"; + + $data = $this->db->fetchAll($sql, $params); + $this->view('verifikator.warga', [ + 'title' => 'Data Warga Miskin', + 'data' => $data, + 'level' => $level, + 'user' => Auth::user() + ], 'verifikator'); + } + + // FIX: type hint string, cast to int internally + public function detailWarga(string $id): void { + $id = (int) $id; + + $warga = $this->db->fetchOne(" + SELECT w.*, k.nama as kelurahan, kec.nama as kecamatan, + ri.nama as rumah_ibadah_nama, ri.lat as ri_lat, ri.lng as ri_lng + FROM warga_miskin w + JOIN kelurahan k ON w.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + LEFT JOIN rumah_ibadah ri ON w.id_rumah_ibadah = ri.id + WHERE w.id = ? AND w.id_kelurahan = ? + ", [$id, $this->idKelurahan]); + + if (!$warga) { + $this->flash('error', 'Data tidak ditemukan.'); + $this->redirect(APP_URL . '/verifikator/warga'); + } + + $bantuan = $this->db->fetchAll(" + SELECT hb.*, kb.nama as kategori, ri.nama as rumah_ibadah + FROM histori_bantuan hb + JOIN kategori_bantuan kb ON hb.id_kategori_bantuan = kb.id + JOIN rumah_ibadah ri ON hb.id_rumah_ibadah = ri.id + WHERE hb.id_warga = ? + ORDER BY hb.tanggal_penyaluran DESC + ", [$id]); + + $this->view('verifikator.detail_warga', [ + 'title' => 'Detail Warga', + 'warga' => $warga, + 'bantuan' => $bantuan, + 'user' => Auth::user() + ], 'verifikator'); + } + + public function formTambahWarga(): void { + $kelurahan = $this->db->fetchOne("SELECT k.id, k.nama, kec.nama as kecamatan FROM kelurahan k JOIN kecamatan kec ON k.id_kecamatan=kec.id WHERE k.id=?", [$this->idKelurahan]); + $this->view('verifikator.form_warga', [ + 'title' => 'Tambah Data Warga', + 'kelurahan' => $kelurahan, + 'user' => Auth::user() + ], 'verifikator'); + } + + public function storeWarga(): void { + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/verifikator/warga'); + + $lat = (float)$_POST['lat']; + $lng = (float)$_POST['lng']; + + // Blind spot check + $isBlindSpot = isInBlindSpot($lat, $lng) ? 1 : 0; + + // Assignment rumah ibadah + $ri = findRumahIbadahTerdekat($lat, $lng); + $idRi = null; + if ($ri && !$isBlindSpot) { + $dist = haversine($lat, $lng, (float)$ri['lat'], (float)$ri['lng']); + $idRi = ($dist <= $ri['radius_meter']) ? $ri['id'] : null; + if (!$idRi) $isBlindSpot = 1; + } + + $penghasilan = (int)$_POST['penghasilan_bulanan']; + $tanggungan = (int)$_POST['jumlah_tanggungan']; + $statusKemiskinan = tentukanStatusKemiskinan($penghasilan, $tanggungan); + + $tanggalLahir = $_POST['tanggal_lahir'] ?? ''; + $isLansia = 0; + if ($tanggalLahir) { + $umur = hitungUmur($tanggalLahir); + if ($umur >= 60) $isLansia = 1; + } + + $data = [ + 'id_kelurahan' => $this->idKelurahan, + 'id_rumah_ibadah' => $idRi, + 'id_verifikator' => Auth::id(), + 'nik' => trim($_POST['nik'] ?? '') ?: null, + 'nama' => trim($_POST['nama']), + 'tempat_lahir' => trim($_POST['tempat_lahir'] ?? ''), + 'tanggal_lahir' => $tanggalLahir ?: null, + 'jenis_kelamin' => $_POST['jenis_kelamin'] ?? 'L', + 'agama' => $_POST['agama'] ?? 'Islam', + 'alamat' => trim($_POST['alamat']), + 'lat' => $lat, + 'lng' => $lng, + 'no_hp' => trim($_POST['no_hp'] ?? ''), + 'pendidikan_terakhir'=> $_POST['pendidikan_terakhir'] ?? 'SD', + 'pekerjaan' => trim($_POST['pekerjaan'] ?? ''), + 'penghasilan_bulanan'=> $penghasilan, + 'jumlah_tanggungan' => $tanggungan, + 'kondisi_rumah' => $_POST['kondisi_rumah'] ?? 'Tidak Layak', + 'riwayat_penyakit' => trim($_POST['riwayat_penyakit'] ?? ''), + 'is_lansia' => $isLansia, + 'is_disabilitas' => (int)($_POST['is_disabilitas'] ?? 0), + 'disabilitas_jenis' => trim($_POST['disabilitas_jenis'] ?? ''), + 'status_kemiskinan' => $statusKemiskinan, + 'is_blind_spot' => $isBlindSpot, + 'status' => 'aktif', + 'tanggal_verifikasi' => date('Y-m-d') + ]; + + $foto = null; + if (!empty($_FILES['foto']['name'])) { + $foto = $this->uploadFile($_FILES['foto'], 'warga'); + } + $data['foto'] = $foto; + + $skor = hitungSkorPrioritas($data); + $data['skor_prioritas'] = $skor['skor']; + $data['level_prioritas'] = $skor['level']; + + try { + $this->db->insert('warga_miskin', $data); + Auth::logActivity(Auth::id(), Auth::user()['nama'], 'verifikator', 'Tambah Warga', 'warga_miskin', 'Tambah: ' . $data['nama']); + $this->flash('success', 'Data warga berhasil ditambahkan.' . ($isBlindSpot ? ' PERHATIAN: Warga ini terdeteksi sebagai Blind Spot.' : '')); + } catch (Exception $e) { + $this->flash('error', 'Gagal menyimpan. NIK mungkin sudah terdaftar.'); + } + + $this->redirect(APP_URL . '/verifikator/warga'); + } + + // FIX: type hint string, cast to int internally + public function updateWarga(string $id): void { + $id = (int) $id; + + if (!$this->verifyCsrf()) $this->redirect(APP_URL . '/verifikator/warga'); + + $lat = (float)$_POST['lat']; + $lng = (float)$_POST['lng']; + $isBlindSpot = isInBlindSpot($lat, $lng) ? 1 : 0; + $ri = findRumahIbadahTerdekat($lat, $lng); + $idRi = null; + if ($ri && !$isBlindSpot) { + $dist = haversine($lat, $lng, (float)$ri['lat'], (float)$ri['lng']); + $idRi = ($dist <= $ri['radius_meter']) ? $ri['id'] : null; + if (!$idRi) $isBlindSpot = 1; + } + + $penghasilan = (int)$_POST['penghasilan_bulanan']; + $tanggungan = (int)$_POST['jumlah_tanggungan']; + $tanggalLahir = $_POST['tanggal_lahir'] ?? ''; + $isLansia = ($tanggalLahir && hitungUmur($tanggalLahir) >= 60) ? 1 : 0; + + $data = [ + 'id_rumah_ibadah' => $idRi, + 'nik' => trim($_POST['nik'] ?? '') ?: null, + 'nama' => trim($_POST['nama']), + 'tempat_lahir' => trim($_POST['tempat_lahir'] ?? ''), + 'tanggal_lahir' => $tanggalLahir ?: null, + 'jenis_kelamin' => $_POST['jenis_kelamin'] ?? 'L', + 'agama' => $_POST['agama'] ?? 'Islam', + 'alamat' => trim($_POST['alamat']), + 'lat' => $lat, + 'lng' => $lng, + 'pendidikan_terakhir'=> $_POST['pendidikan_terakhir'] ?? 'SD', + 'pekerjaan' => trim($_POST['pekerjaan'] ?? ''), + 'penghasilan_bulanan'=> $penghasilan, + 'jumlah_tanggungan' => $tanggungan, + 'kondisi_rumah' => $_POST['kondisi_rumah'] ?? 'Tidak Layak', + 'riwayat_penyakit' => trim($_POST['riwayat_penyakit'] ?? ''), + 'is_lansia' => $isLansia, + 'is_disabilitas' => (int)($_POST['is_disabilitas'] ?? 0), + 'disabilitas_jenis' => trim($_POST['disabilitas_jenis'] ?? ''), + 'status_kemiskinan' => tentukanStatusKemiskinan($penghasilan, $tanggungan), + 'is_blind_spot' => $isBlindSpot + ]; + + $skor = hitungSkorPrioritas($data); + $data['skor_prioritas'] = $skor['skor']; + $data['level_prioritas'] = $skor['level']; + + if (!empty($_FILES['foto']['name'])) { + $data['foto'] = $this->uploadFile($_FILES['foto'], 'warga'); + } + + $this->db->update('warga_miskin', $data, 'id = ? AND id_kelurahan = ?', [$id, $this->idKelurahan]); + Auth::logActivity(Auth::id(), Auth::user()['nama'], 'verifikator', 'Update Warga', 'warga_miskin', "Update warga ID $id"); + $this->flash('success', 'Data warga berhasil diperbarui.'); + $this->redirect(APP_URL . '/verifikator/warga/' . $id); + } + + public function apiStats(): void { + $data = $this->db->fetchOne(" + SELECT + COUNT(CASE WHEN status='menunggu' THEN 1 END) as menunggu, + COUNT(CASE WHEN status='diproses' THEN 1 END) as diproses, + COUNT(CASE WHEN status='terverifikasi' THEN 1 END) as terverifikasi, + COUNT(CASE WHEN status='ditolak' THEN 1 END) as ditolak + FROM laporan_masyarakat WHERE id_kelurahan = ? + ", [$this->idKelurahan]); + $this->json(['success' => true, 'data' => $data]); + } +} \ No newline at end of file diff --git a/app/controllers/WalikotaController.php b/app/controllers/WalikotaController.php new file mode 100644 index 0000000..d851f87 --- /dev/null +++ b/app/controllers/WalikotaController.php @@ -0,0 +1,114 @@ +db->fetchOne(" + SELECT + (SELECT COUNT(*) FROM warga_miskin WHERE status='aktif') as total_warga, + (SELECT COUNT(*) FROM warga_miskin WHERE status='aktif' AND status_kemiskinan='Miskin Ekstrem') as ekstrem, + (SELECT COUNT(*) FROM warga_miskin WHERE status='aktif' AND status_kemiskinan='Miskin') as miskin, + (SELECT COUNT(*) FROM warga_miskin WHERE status='aktif' AND status_kemiskinan='Rentan Miskin') as rentan, + (SELECT COUNT(*) FROM warga_miskin WHERE is_blind_spot=1 AND status='aktif') as blind_spot, + (SELECT COUNT(*) FROM rumah_ibadah WHERE is_active=1) as total_ri, + (SELECT COUNT(*) FROM histori_bantuan WHERE status='disalurkan') as total_bantuan, + (SELECT COUNT(*) FROM histori_bantuan WHERE status='disalurkan' AND YEAR(tanggal_penyaluran)=YEAR(CURDATE())) as bantuan_tahun_ini, + (SELECT COUNT(*) FROM laporan_masyarakat) as total_laporan, + (SELECT COUNT(*) FROM laporan_masyarakat WHERE status='terverifikasi') as laporan_terverifikasi + "); + + $perKecamatan = $this->db->fetchAll(" + SELECT kec.id, kec.nama, + COUNT(w.id) as total, + SUM(w.status_kemiskinan='Miskin Ekstrem') as ekstrem, + SUM(w.status_kemiskinan='Miskin') as miskin, + SUM(w.status_kemiskinan='Rentan Miskin') as rentan, + SUM(w.is_blind_spot) as blind_spot, + COUNT(DISTINCT ri.id) as total_ri + FROM kecamatan kec + LEFT JOIN kelurahan k ON k.id_kecamatan = kec.id + LEFT JOIN warga_miskin w ON w.id_kelurahan = k.id AND w.status='aktif' + LEFT JOIN rumah_ibadah ri ON ri.id_kelurahan = k.id AND ri.is_active=1 + GROUP BY kec.id, kec.nama + ORDER BY total DESC + "); + + $topRi = $this->db->fetchAll(" + SELECT ri.nama, ji.nama as jenis, kec.nama as kecamatan, + COUNT(DISTINCT wm.id) as jml_warga, + COUNT(DISTINCT hb.id) as jml_bantuan + FROM rumah_ibadah ri + JOIN jenis_rumah_ibadah ji ON ri.id_jenis = ji.id + JOIN kelurahan k ON ri.id_kelurahan = k.id + JOIN kecamatan kec ON k.id_kecamatan = kec.id + LEFT JOIN warga_miskin wm ON wm.id_rumah_ibadah = ri.id AND wm.status='aktif' + LEFT JOIN histori_bantuan hb ON hb.id_rumah_ibadah = ri.id AND hb.status='disalurkan' + WHERE ri.is_active = 1 + GROUP BY ri.id, ri.nama, ji.nama, kec.nama + ORDER BY jml_bantuan DESC LIMIT 5 + "); + + $trenTahunan = $this->db->fetchAll(" + SELECT YEAR(tanggal_penyaluran) as tahun, COUNT(*) as bantuan, + COUNT(DISTINCT id_warga) as warga_terbantu + FROM histori_bantuan WHERE status='disalurkan' + GROUP BY tahun ORDER BY tahun + "); + + $trenBulanan = $this->db->fetchAll(" + SELECT DATE_FORMAT(tanggal_penyaluran,'%Y-%m') as bulan, COUNT(*) as jumlah + FROM histori_bantuan WHERE status='disalurkan' + AND tanggal_penyaluran >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH) + GROUP BY bulan ORDER BY bulan + "); + + $this->view('walikota.dashboard', [ + 'title' => 'Dashboard Eksekutif - Wali Kota', + 'stats' => $stats, + 'perKecamatan' => $perKecamatan, + 'topRi' => $topRi, + 'trenTahunan' => $trenTahunan, + 'trenBulanan' => $trenBulanan, + 'user' => Auth::user() + ], 'walikota'); + } + + public function apiStats(): void { + $perKec = $this->db->fetchAll(" + SELECT kec.nama, + COUNT(w.id) as total, + SUM(w.status_kemiskinan='Miskin Ekstrem') as ekstrem, + SUM(w.is_blind_spot) as blind_spot + FROM kecamatan kec + LEFT JOIN kelurahan k ON k.id_kecamatan = kec.id + LEFT JOIN warga_miskin w ON w.id_kelurahan = k.id AND w.status='aktif' + GROUP BY kec.id, kec.nama + "); + $this->json(['perKecamatan' => $perKec]); + } + + public function apiPeta(): void { + $warga = $this->db->fetchAll(" + SELECT lat, lng, status_kemiskinan, is_blind_spot, + CASE status_kemiskinan + WHEN 'Miskin Ekstrem' THEN 1.0 + WHEN 'Miskin' THEN 0.7 + ELSE 0.4 + END as intensity + FROM warga_miskin WHERE status='aktif' AND lat != 0 + "); + $ri = $this->db->fetchAll(" + SELECT ri.nama, ri.lat, ri.lng, ri.radius_meter, ji.nama as jenis + FROM rumah_ibadah ri + JOIN jenis_rumah_ibadah ji ON ri.id_jenis = ji.id + WHERE ri.is_active = 1 + "); + $this->json(['warga' => $warga, 'rumah_ibadah' => $ri]); + } +} diff --git a/app/views/admin/aktivitas.php b/app/views/admin/aktivitas.php new file mode 100644 index 0000000..f736dda --- /dev/null +++ b/app/views/admin/aktivitas.php @@ -0,0 +1,41 @@ +
+

📋 Log Aktivitas Sistem

+

200 aktivitas terbaru tercatat dalam sistem

+
+ +
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + + +
WaktuUserRoleAksiModulDeskripsiIP
+ 'badge-purple','verifikator'=>'badge-blue','rumah_ibadah'=>'badge-green','dinsos'=>'badge-teal','walikota'=>'badge-orange']; ?> + +
+
+ +
📋

Belum ada aktivitas

+ +
+
+ + diff --git a/app/views/admin/blind_spot.php b/app/views/admin/blind_spot.php new file mode 100644 index 0000000..83fdf24 --- /dev/null +++ b/app/views/admin/blind_spot.php @@ -0,0 +1,56 @@ + +
+

⚠️ Monitoring Blind Spot

+

warga tidak terjangkau radius rumah ibadah manapun

+
+ + +
+
🎉
+

Tidak Ada Blind Spot!

+

Seluruh warga miskin sudah berada dalam radius pelayanan rumah ibadah.

+
+ + +
+
+
+ +
+
⚠️

Daftar Warga Blind Spot

+
+
+ + + + $w): ?> + + + + + + + + + + +
#NamaKecamatanKelurahanStatus KemiskinanPrioritas
'badge-red','Miskin'=>'badge-orange','Rentan Miskin'=>'badge-yellow']; ?> + 'badge-red','Sedang'=>'badge-yellow','Rendah'=>'badge-green']; ?> +
+
+
+
+ + + diff --git a/app/views/admin/dashboard.php b/app/views/admin/dashboard.php new file mode 100644 index 0000000..14fb3a3 --- /dev/null +++ b/app/views/admin/dashboard.php @@ -0,0 +1,189 @@ + +document.addEventListener('DOMContentLoaded', async () => { + // Init peta + const map = GISMap.init('admin-map', {$mapLat}, {$mapLng}, 13); + + // Load data peta + const data = await App.fetchJSON('{$appUrl}/admin/api/peta'); + if (data) { + const wargaLayer = GISMap.addWargaLayer(data.warga || []); + const riLayer = GISMap.addRILayer(data.rumah_ibadah || []); + const lapLayer = GISMap.addLaporanLayer(data.laporan || []); + + const overlays = { + '👥 Warga Miskin': wargaLayer, + '⛪ Rumah Ibadah': riLayer, + '📋 Laporan Masuk': lapLayer + }; + L.control.layers(GISMap.layers, overlays, {position:'topright', collapsed:true}).addTo(map); + + GISMap.autoFitBounds(); + + const heat = await App.fetchJSON('{$appUrl}/dinsos/api/heatmap'); + if (heat) GISMap.addHeatmap(heat); + } else { + GISMap.autoFitBounds(true); + } + + // Charts — data sudah diekstrak bersih di PHP + const kec = {$jsonKec}; + const kecData = {$jsonKecData}; + Charts.bar('chart-kecamatan', kec, [{ label: 'Warga Miskin', data: kecData }]); + + const bulan = {$jsonBulan}; + const bantuanData = {$jsonBantuanData}; + Charts.line('chart-bantuan', bulan, [{ label: 'Bantuan Disalurkan', data: bantuanData }]); +}); + +JS; ?> + + +
+
+
👥
+
+
+
Total Warga Miskin
+
miskin ekstrem
+
+
+
+
+
+
+
Rumah Ibadah Aktif
+
+
+
+
📋
+
+
+
Total Laporan
+
menunggu
+
+
+
+
📦
+
+
+
Total Bantuan
+
+
+
+
⚠️
+
+
+
Blind Spot
+
Belum terjangkau rumah ibadah
+
+
+
+
👤
+
+
+
Pengguna Sistem
+
+
+
+ + +
+ +
+
+ 🗺 +

Peta Persebaran Kemiskinan Kota Pontianak

+
+ +
+
+
+
+
+

Legenda

+
Miskin Ekstrem
+
Miskin
+
Rentan Miskin
+
Rumah Ibadah
+
Blind Spot
+
Laporan Masuk
+
+
+
+ + +
+ +
+
🗾

Sebaran per Kecamatan

+
+
+ + + + + + + + + + + + +
KecamatanTotalEkstremBlind Spot
+
+
+
+ + +
+
📋

Aktivitas Terbaru

+
+ +
+
+
·
+
+
+ +
+
+
+
+ + +
+
+
📊

Warga Miskin per Kecamatan

+
+
+
+
📈

Tren Bantuan 6 Bulan Terakhir

+
+
+
\ No newline at end of file diff --git a/app/views/admin/kategori_bantuan.php b/app/views/admin/kategori_bantuan.php new file mode 100644 index 0000000..1148a01 --- /dev/null +++ b/app/views/admin/kategori_bantuan.php @@ -0,0 +1,89 @@ +
+

📦 Manajemen Kategori Bantuan

+ +
+ +
+
+
+ + + + $k): ?> + + + + + + + + + + +
#NamaDeskripsiSatuanStatusAksi
Aktif' : 'Nonaktif' ?> + +
+
+
+
+ + + + + + + + diff --git a/app/views/admin/monitoring.php b/app/views/admin/monitoring.php new file mode 100644 index 0000000..66344f2 --- /dev/null +++ b/app/views/admin/monitoring.php @@ -0,0 +1,75 @@ + +
+

👥 Monitoring Data Kemiskinan

+

warga aktif terdata dalam sistem

+
+ + +
+
🗺

Peta Persebaran Seluruh Warga Miskin

+
+
+
+

Legenda

+
Miskin Ekstrem
+
Miskin
+
Rentan Miskin
+
Blind Spot
+
+
+
+ + +
+ +
+
+
+ + + + $w): ?> + + + + + + + + + + + + + +
#NamaWilayahStatus KemiskinanPrioritasPenghasilanTgnRumah IbadahKet
+
+
+
'badge-red','Miskin'=>'badge-orange','Rentan Miskin'=>'badge-yellow']; ?> + 'badge-red','Sedang'=>'badge-yellow','Rendah'=>'badge-green']; ?> + +
/100
+ + 👴 + +
+
+
+
+ + diff --git a/app/views/admin/rumah_ibadah.php b/app/views/admin/rumah_ibadah.php new file mode 100644 index 0000000..7365894 --- /dev/null +++ b/app/views/admin/rumah_ibadah.php @@ -0,0 +1,309 @@ + +let riMap = null; +let riMarker = null; + +function openTambahModal() { + App.showModal('modal-tambah'); + setTimeout(() => { + if (!riMap) { + riMap = L.map('ri-map-tambah').setView([-0.0263, 109.3425], 13); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19}).addTo(riMap); + riMap.on('click', e => placeRIMarker(e.latlng.lat, e.latlng.lng, 'tambah')); + } + riMap.invalidateSize(); + }, 300); +} + +function placeRIMarker(lat, lng, mode) { + const m = mode === 'tambah' ? riMap : editMap; + const prefix = mode === 'tambah' ? '' : 'edit-'; + if (riMarker) m.removeLayer(riMarker); + riMarker = L.marker([lat, lng], { draggable: true }).addTo(m); + document.getElementById(prefix + 'lat').value = lat.toFixed(7); + document.getElementById(prefix + 'lng').value = lng.toFixed(7); + riMarker.on('dragend', e => { + const pos = e.target.getLatLng(); + document.getElementById(prefix + 'lat').value = pos.lat.toFixed(7); + document.getElementById(prefix + 'lng').value = pos.lng.toFixed(7); + }); +} + +function openEditModal(ri) { + document.getElementById('edit-nama').value = ri.nama; + document.getElementById('edit-alamat').value = ri.alamat || ''; + document.getElementById('edit-id_jenis').value = ri.id_jenis; + document.getElementById('edit-id_kelurahan').value = ri.id_kelurahan; + document.getElementById('edit-radius_meter').value = ri.radius_meter; + document.getElementById('edit-nama_ketua').value = ri.nama_ketua || ''; + document.getElementById('edit-no_hp_pengurus').value = ri.no_hp_pengurus || ''; + document.getElementById('edit-lat').value = ri.lat; + document.getElementById('edit-lng').value = ri.lng; + document.getElementById('edit-is_active').value = ri.is_active; + document.getElementById('edit-form').action = '/admin/rumah-ibadah/' + ri.id + '/update'; + App.showModal('modal-edit'); +} + +JS; ?> + +
+
+

Manajemen Rumah Ibadah

+

Total rumah ibadah terdaftar

+
+ +
+ + +
+
🗺

Peta Rumah Ibadah

+
+
+
+
+ + +
+ +
+ + +
+
+
+ + + + + + $r): ?> + + + + + + + + + + + + + +
#NamaJenisWilayahKetuaRadiusWarga BinaanStatusAksi
+
+
+
m wargaAktif' : 'Nonaktif' ?> +
+ +
+ + +
+
+
+
+
+
+ + + + + + + + diff --git a/app/views/admin/users.php b/app/views/admin/users.php new file mode 100644 index 0000000..1c2985a --- /dev/null +++ b/app/views/admin/users.php @@ -0,0 +1,221 @@ + +
+
+

Manajemen User

+

Kelola seluruh pengguna sistem SINERGI

+
+ +
+ + +
+ +
+ + +
+
+
+ + + + + + + + + $u): ?> + + + + + + + + + + + + + +
#NamaUsernameRoleWilayahNo HPStatusLogin TerakhirAksi
+
+
+ +
+
+
+
+
+
+
+ 'purple','verifikator'=>'blue','rumah_ibadah'=>'green','dinsos'=>'teal','walikota'=>'orange']; ?> + + Aktif' : 'Nonaktif' ?> +
+ + +
+ + +
+ + Akun Anda + +
+
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/app/views/admin/wilayah.php b/app/views/admin/wilayah.php new file mode 100644 index 0000000..772efa0 --- /dev/null +++ b/app/views/admin/wilayah.php @@ -0,0 +1,119 @@ + +
+
+

🗾 Manajemen Wilayah

+

Data kecamatan dan kelurahan Kota Pontianak

+
+
+ + +
+
+ +
+ +
+
🏙

Kecamatan ()

+
+
+ + + + $k): ?> + + + + + + + + +
#NamaKodeJumlah Kelurahan
+
+
+
+ + +
+
🏘

Kelurahan ()

+
+
+ + + + $k): ?> + + + + + + + +
#NamaKecamatan
+
+
+
+
+ + + + + + diff --git a/app/views/auth/login.php b/app/views/auth/login.php new file mode 100644 index 0000000..a7676f7 --- /dev/null +++ b/app/views/auth/login.php @@ -0,0 +1,96 @@ + + + + + + + Login — SINERGI + + + + + + +
+ + + + + +
+ + + + + diff --git a/app/views/dinsos/bantuan.php b/app/views/dinsos/bantuan.php new file mode 100644 index 0000000..494342a --- /dev/null +++ b/app/views/dinsos/bantuan.php @@ -0,0 +1,89 @@ +
+
+

📦 Monitoring Bantuan

+

penyaluran pada bulan

+
+
+ + +
+
+ + +
+
📦
+
Total Penyaluran
+
+
👥
+
Warga Terbantu
+
+
+
Rumah Ibadah Aktif
+
+
📊
+
Jenis Bantuan
+
+
+ + +
+
+
📋

Daftar Penyaluran Bantuan

+
+
+ +
+
+ + + + + + + + + + + + + + + + + +
KodeWargaKategoriJumlahRumah IbadahWilayahTanggal
+ +
+
+ +
📭

Tidak ada penyaluran

Belum ada bantuan yang disalurkan pada periode ini

+ +
+
+ + +
+
🥧

Per Kategori

+
+
+
+ +
+ + +
+ +
+
+
+
+ + diff --git a/app/views/dinsos/blind_spot.php b/app/views/dinsos/blind_spot.php new file mode 100644 index 0000000..52c8a71 --- /dev/null +++ b/app/views/dinsos/blind_spot.php @@ -0,0 +1,377 @@ += 1000) { + return number_format($meter / 1000, 1, ',', '.') . ' km'; + } + return number_format($meter, 0, ',', '.') . ' m'; +}; + +$jsonFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; +$totalBlind = count($data); +$sudahDitugaskan = (int)($ringkasanPenugasan['sudah'] ?? 0); +$belumDitugaskan = (int)($ringkasanPenugasan['belum'] ?? 0); +$totalRumahIbadah = (int)($ringkasanPenugasan['total_ri'] ?? 0); +?> + +
+
+

Monitoring Blind Spot

+

Atur penugasan rumah ibadah untuk warga yang berada di luar radius pelayanan.

+
+ +
+ +
+
+
+
+ +
+ +
+ +
+
+
BS
+
+
+
Total blind spot
+
+
+
+
!
+
+
+
Belum ditugaskan
+
+
+
+
OK
+
+
+
Sudah ditugaskan
+
+
+
+
RI
+
+
+
Rumah ibadah aktif
+
+
+
+ +
+
+ MAP +

Peta Blind Spot dan Rumah Ibadah

+
+
+
+
+
+ +
+
+ BS +

Daftar Warga Blind Spot ()

+
+ +
+
+
+
+ + + + + + + + + + + + + + $w): ?> + (int)$w['id'], + 'nama' => $w['nama'], + 'alamat' => $w['alamat'] ?? '', + 'lokasi' => trim(($w['kecamatan'] ?? '') . ' / ' . ($w['kelurahan'] ?? ''), ' /'), + 'current_ri_id' => $currentRiId + ]; + ?> + + + + + + + + + + + +
#WargaLokasiPrioritasRekomendasi TerdekatPenugasanAksi
+
+
+
+ , +
+
+
+
+
+ 'badge-red', 'Miskin' => 'badge-orange', 'Rentan Miskin' => 'badge-yellow']; ?> + 'badge-red', 'Sedang' => 'badge-yellow', 'Rendah' => 'badge-green']; ?> +
+ + Prioritas + Skor +
+
+ +
+
+ + +
+
+ · , +
+
+ Radius · warga binaan +
+ 1): ?> +
+ Alternatif: + + () + +
+ +
+ + Koordinat warga atau rumah ibadah belum lengkap. + +
+ +
+ Sudah ditugaskan +
+
+
+ +
+ Belum ditugaskan +
Gunakan rekomendasi atau pilih manual.
+
+ +
+
+ +
+ + + +
+ + Rekomendasi aktif + + + +
+
+
+ +
+
OK
+

Tidak Ada Blind Spot

+

Semua warga sudah berada dalam radius pelayanan rumah ibadah.

+
+ +
+
+ + + + diff --git a/app/views/dinsos/dashboard.php b/app/views/dinsos/dashboard.php new file mode 100644 index 0000000..a0eaca5 --- /dev/null +++ b/app/views/dinsos/dashboard.php @@ -0,0 +1,182 @@ + +document.addEventListener('DOMContentLoaded', async () => { + // Peta dengan heatmap + const map = GISMap.init('dinsos-map', {$mapLat}, {$mapLng}, 12); + + // Load semua data + const [heatData, petaData] = await Promise.all([ + App.fetchJSON('{$appUrl}/dinsos/api/heatmap'), + App.fetchJSON('{$appUrl}/admin/api/peta') + ]); + + if(petaData) { + const riLayer = GISMap.addRILayer(petaData.rumah_ibadah||[]); + const wLayer = GISMap.addWargaLayer(petaData.warga||[]); + L.control.layers(GISMap.layers, { + '⛪ Rumah Ibadah': riLayer, + '👥 Warga Miskin': wLayer + }, {position:'topright', collapsed:true}).addTo(map); + } + if(heatData) GISMap.addHeatmap(heatData); + // Auto zoom ke seluruh Kota Pontianak + GISMap.autoFitBounds(); + + // Chart per kecamatan + const kec = {$jsonKec}; + const totals = {$jsonTotals}; + const ekstrems = {$jsonEkstrems}; + Charts.bar('chart-kecamatan', kec, [ + {label:'Total Warga', data:totals}, + {label:'Miskin Ekstrem', data:ekstrems} + ]); + + // Chart tren bantuan + const bulan = {$jsonBulan}; + const jmlBantuan = {$jsonJmlBantuan}; + const wargaTerbantu = {$jsonWargaTerbantu}; + Charts.line('chart-tren', bulan, [ + {label:'Bantuan Disalurkan', data:jmlBantuan}, + {label:'Warga Terbantu', data:wargaTerbantu} + ]); +}); + +JS; ?> + + +
+
+
👥
+
+
+
Total Warga Miskin
+
+
+
+
💀
+
+
+
Miskin Ekstrem
+
+
+
+
+
+
+
Miskin
+
+
+
+
📊
+
+
+
Rentan Miskin
+
+
+
+
⚠️
+
+
+
Blind Spot
+ +
+
+
+
📦
+
+
+
Total Bantuan
+
bulan ini
+
+
+
+ + +
+
+
+ 🌡

Heatmap Kemiskinan Kota Pontianak

+ +
+
+
+
+

Intensitas Kemiskinan

+
+
+ RendahTinggi +
+
+
+
+ + +
+ +
+
🗾

Kemiskinan per Kecamatan

+
+ $k): ?> +
+
+ . + +
+
+ +
+
+
+ Ekstrem: · Blind: +
+
+ +
+
+ + +
+
🏆

Rumah Ibadah Paling Aktif

+
+ $r): ?> +
+
+
+
+
bantuan · warga
+
+
+ +
+
+
+
+ + +
+
+
📊

Perbandingan per Kecamatan

+
+
+
+
📈

Tren Bantuan 6 Bulan

+
+
+
diff --git a/app/views/dinsos/kemiskinan.php b/app/views/dinsos/kemiskinan.php new file mode 100644 index 0000000..52155d3 --- /dev/null +++ b/app/views/dinsos/kemiskinan.php @@ -0,0 +1,137 @@ +
+
+

🗺 Monitoring Kemiskinan

+

warga terfilter · Klik marker pada peta untuk detail

+
+
+ + +
+
+ + + + + + + + ✕ Reset + +
+
+ + +
+ +
+
+
+
+

Legenda

+
Miskin Ekstrem
+
Miskin
+
Rentan Miskin
+
Rumah Ibadah
+
Blind Spot
+
+
+
+ + +
+
+ 👥 +

Daftar Warga ()

+
+
+ + + + +
👥

Tidak ada data

+ +
+
+
+ + diff --git a/app/views/dinsos/kinerja.php b/app/views/dinsos/kinerja.php new file mode 100644 index 0000000..f4adfd5 --- /dev/null +++ b/app/views/dinsos/kinerja.php @@ -0,0 +1,123 @@ +
+
+

🏆 Kinerja Rumah Ibadah

+

Monitoring efektivitas penyaluran bantuan oleh seluruh rumah ibadah

+
+
+ + +
+ $d['jml_bantuan']>0)); + ?> +
+
Total Rumah Ibadah
+
+
+
Aktif Menyalurkan
+
+
📦
+
Total Bantuan
+
+
👥
+
Total Warga Binaan
+
+
+ + +
+ +
+
📊

Ranking Bantuan per Rumah Ibadah (Top 10)

+
+
+ + +
+
🥧

Status Partisipasi

+
+
+
+ aktif  |  + belum aktif +
+
+
+
+
+ + +
+
📋

Detail Kinerja Semua Rumah Ibadah

+
+
+ +
+
+ + + + + + $r): ?> + + + + + + + + + + + + + +
#NamaJenisWilayahWarga BinaanBantuan DisalurkanMiskin EkstremBantuan TerakhirStatus
+
+
Radius: m
+
+
+ +
+ +
+
+
+
+ 0): ?> + + + + 0): ?> + ✅ Aktif + 0): ?> + ⏳ Ada Binaan + + + +
+
+
+
+ + diff --git a/app/views/dinsos/laporan.php b/app/views/dinsos/laporan.php new file mode 100644 index 0000000..31863b7 --- /dev/null +++ b/app/views/dinsos/laporan.php @@ -0,0 +1,85 @@ +
+
+

📄 Laporan Tahunan

+

Rekap penyaluran bantuan tahun

+
+
+ + + +
+
+ + +
+
📦
+
Total Penyaluran Bantuan
+
+
👥
+
Warga Mendapat Bantuan
+
+
+
Rumah Ibadah Aktif
+
+
+ + +
+
📊

Grafik Penyaluran Bantuan Bulan per Bulan Tahun

+
+
+ + +
+
📋

Rekap per Bulan

+
+
+ + + + 0?round($b['bantuan']/$b['warga_terbantu'],1):0; + ?> + + + + + + + + + + + + + + + +
BulanBantuan DisalurkanWarga TerbantuRata-rata/Warga
warga kali/warga
TOTAL (unik)
+
+ +
📭

Tidak ada data

Belum ada penyaluran pada tahun

+ +
+
+ + diff --git a/app/views/layouts/admin.php b/app/views/layouts/admin.php new file mode 100644 index 0000000..c8125e7 --- /dev/null +++ b/app/views/layouts/admin.php @@ -0,0 +1,104 @@ + + + + + + + + <?= e($title ?? 'SINERGI') ?> — SINERGI + + + + + + + + +
+ + + + +
+
+
+ + 🌐 Publik +
+
+ + +
+ + +
+
+ + + + + + + + diff --git a/app/views/layouts/dinsos.php b/app/views/layouts/dinsos.php new file mode 100644 index 0000000..cf5dabf --- /dev/null +++ b/app/views/layouts/dinsos.php @@ -0,0 +1,67 @@ + + + + + + + + <?= e($title ?? 'SINERGI') ?> — Dinas Sosial + + + + + + +
+ + +
+
+
+ +
+
+ +
+ + +
+
+ + + + + + + diff --git a/app/views/layouts/public.php b/app/views/layouts/public.php new file mode 100644 index 0000000..6d18db5 --- /dev/null +++ b/app/views/layouts/public.php @@ -0,0 +1,63 @@ + + + + + + + + <?= e($title ?? 'SINERGI') ?> + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/views/layouts/rumah_ibadah.php b/app/views/layouts/rumah_ibadah.php new file mode 100644 index 0000000..26652f1 --- /dev/null +++ b/app/views/layouts/rumah_ibadah.php @@ -0,0 +1,75 @@ + + + + + + + + <?= e($title ?? 'SINERGI') ?> — SINERGI Rumah Ibadah + + + + + + +
+ + +
+
+
+ +
+
+ +
+ + +
+
+ + + + + + + diff --git a/app/views/layouts/verifikator.php b/app/views/layouts/verifikator.php new file mode 100644 index 0000000..9fe3b96 --- /dev/null +++ b/app/views/layouts/verifikator.php @@ -0,0 +1,71 @@ + + + + + + + + <?= e($title ?? 'SINERGI') ?> — SINERGI Verifikator + + + + + + +
+ + +
+
+
+ +
+
+ +
+ + +
+
+ + + + + + diff --git a/app/views/layouts/walikota.php b/app/views/layouts/walikota.php new file mode 100644 index 0000000..6d8b8ad --- /dev/null +++ b/app/views/layouts/walikota.php @@ -0,0 +1,76 @@ + + + + + + + <?= e($title ?? 'SINERGI') ?> — Dashboard Eksekutif + + + + + + + +
+ + +
+
+
+ 🏛 Wali Kota — Read Only + +
+
+ +
+ +
+
+ + + + + + + diff --git a/app/views/public/403.php b/app/views/public/403.php new file mode 100644 index 0000000..ac50594 --- /dev/null +++ b/app/views/public/403.php @@ -0,0 +1,20 @@ + + + + 403 Akses Ditolak — SINERGI + + + + +
+
🚫
+

403

+

Akses Ditolak

+

Anda tidak memiliki izin untuk mengakses halaman ini.

+
+ Login dengan Akun Lain + Ke Beranda +
+
+ + diff --git a/app/views/public/404.php b/app/views/public/404.php new file mode 100644 index 0000000..e1b0365 --- /dev/null +++ b/app/views/public/404.php @@ -0,0 +1,17 @@ + + + + 404 — SINERGI + + + + +
+
🗺
+

404

+

Halaman Tidak Ditemukan

+

Halaman yang Anda cari tidak ada atau sudah dipindahkan.

+ ← Kembali ke Beranda +
+ + diff --git a/app/views/public/beranda.php b/app/views/public/beranda.php new file mode 100644 index 0000000..3bb23a4 --- /dev/null +++ b/app/views/public/beranda.php @@ -0,0 +1,91 @@ + +
+
🗺
+

SINERGI

+

Sistem Informasi Geografis Penanganan Kemiskinan Berbasis Kolaborasi Rumah Ibadah, Masyarakat, dan Pemerintah Kota Pontianak

+
+ 📝 Lapor Warga Membutuhkan + 🔍 Cek Status Laporan +
+
+ + +
+
+ +
+
+
+
+
+ +
+
🏛
+
Kota Pontianak
+
6 Kecamatan
+
+
+
+ + +
+

Peta Persebaran Kemiskinan

+

Data real-time persebaran warga miskin dan rumah ibadah di Kota Pontianak

+
+
+
+

Keterangan

+
Miskin Ekstrem
+
Miskin
+
Rentan Miskin
+
Rumah Ibadah
+
+
+
+ + +
+
+

Cara Melapor

+

Mudah dan cepat, tanpa perlu daftar atau login

+
+ +
+
+ +
+
Langkah
+
+
+
+ +
+
+ 📝 Mulai Melapor Sekarang +
+
+
+ + diff --git a/app/views/public/cek_laporan.php b/app/views/public/cek_laporan.php new file mode 100644 index 0000000..b0bf70b --- /dev/null +++ b/app/views/public/cek_laporan.php @@ -0,0 +1,102 @@ + +
+

🔍 Cek Status Laporan

+

Masukkan kode laporan yang Anda terima saat mengirim laporan

+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ + +
+
+
+

Laporan Tidak Ditemukan

+

Kode laporan tidak ditemukan. Pastikan kode sudah benar.

+
+
+ +
+
📋

Hasil Pencarian

+
+ + 1,'diproses'=>2,'terverifikasi'=>3,'ditolak'=>-1]; + $cur = $steps[$laporan['status']] ?? 1; + $colors = ['menunggu'=>'#d97706','diproses'=>'#0284c7','terverifikasi'=>'#16a34a','ditolak'=>'#dc2626']; + $icons = ['menunggu'=>'⏳','diproses'=>'🔄','terverifikasi'=>'✅','ditolak'=>'❌']; + ?> +
+
+
+ +
+
+ Kode: +
+
+ + + +
+ + $sl): ?> +
+
+ +
+
+ +
+
+ +
+ + +
+ + + +
+ +
+
+
+
+ + +
+
Catatan Petugas:
+
+
+ +
+
+
+ + +
+ Buat laporan baru → +
+
diff --git a/app/views/public/kontak.php b/app/views/public/kontak.php new file mode 100644 index 0000000..556a3f4 --- /dev/null +++ b/app/views/public/kontak.php @@ -0,0 +1,21 @@ +
+

📞 Kontak

+

Hubungi kami untuk informasi lebih lanjut tentang SINERGI

+
+
+
+
+
🏛
+

Dinas Sosial Kota Pontianak

+

📍 Jl. Gusti Sulung Lelanang No. 1B Pontianak Selatan, Darat Sekip, Kec. Pontianak Kota, Kota Pontianak, Kalimantan Barat 78116

+

📞 (0561) 8108 969

+

✉ dinsos@pontianak.go.id

+
+
+
🌐
+

Pemerintah Kota Pontianak

+

📞 (0561) 8181771

+

🌐 www.pontianak.go.id

+
+
+
diff --git a/app/views/public/lapor.php b/app/views/public/lapor.php new file mode 100644 index 0000000..f83f9a4 --- /dev/null +++ b/app/views/public/lapor.php @@ -0,0 +1,175 @@ + +document.addEventListener('DOMContentLoaded', () => { + LocationPicker.init('lapor-map','lat','lng', {$_mapLat}, {$_mapLng}); + + // Load kecamatan → kelurahan + document.getElementById('kecamatan').addEventListener('change', async function() { + const id = this.value; + const sel = document.getElementById('id_kelurahan'); + sel.innerHTML = ''; + if (!id) { sel.innerHTML = ''; return; } + const data = await App.fetchJSON('{$_appUrl}/api/kelurahan/' + id); + sel.innerHTML = ''; + if (data) data.forEach(k => { + sel.innerHTML += ''; + }); + }); +}); + +JS; +?> + + +
+

📝 Lapor Cepat

+

Laporkan warga yang membutuhkan bantuan — gratis, mudah, tanpa login

+
+ +
+ + + + +
+
✅ Laporan Berhasil Dikirim!
+
Simpan kode berikut untuk memantau status laporan Anda
+
+
Kode Laporan
+
+
+
+ + 🔍 Cek Status +
+ + +
+ +
+
+ + +
+
👤

Data Pelapor

+
+
+
+ + +
+
+ + +
+
+
+
+ +
+
🏠

Data Warga yang Dilaporkan

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
Foto rumah atau kondisi warga membantu proses verifikasi
+
+
+
+ +
+
📍

Lokasi Rumah Warga

+
+
+
+
+ + +
+
+ + +
+
+
📌 Klik pada peta atau drag marker untuk menentukan lokasi rumah warga secara tepat
+
+
+ + +
+ Laporan akan diverifikasi oleh petugas. Simpan kode laporan untuk memantau status. +
+
+
+ + +
+
+
ℹ️

Informasi Penting

+
+
+
✅ Apa yang terjadi setelah laporan?
+
    +
  1. Laporan diterima dan diberi kode unik
  2. +
  3. Verifikator kelurahan melakukan survei
  4. +
  5. Data masuk sistem pemetaan GIS
  6. +
  7. Rumah ibadah terdekat mendapat notifikasi
  8. +
  9. Bantuan disalurkan sesuai prioritas
  10. +
+
+ +
+
📱 Cek Status Laporan
+
Simpan kode laporan yang diberikan setelah mengirim untuk memantau status verifikasi.
+ 🔍 Cek Status +
+ +
+
⚠️ Peringatan
+
Laporan yang tidak akurat atau laporan palsu akan ditolak. Isi data dengan benar dan jujur.
+
+
+
+
+
+
\ No newline at end of file diff --git a/app/views/public/tentang.php b/app/views/public/tentang.php new file mode 100644 index 0000000..3be4799 --- /dev/null +++ b/app/views/public/tentang.php @@ -0,0 +1,40 @@ + +
+

Tentang SINERGI

+

Sistem Informasi Geografis Penanganan Kemiskinan Berbasis Kolaborasi

+
+
+
+
+
+
🎯
+

Tujuan Sistem

+

SINERGI dirancang untuk memetakan, memverifikasi, dan menangani kemiskinan di Kota Pontianak melalui kolaborasi antara rumah ibadah, masyarakat, dan pemerintah.

+
+
+
+

Kolaborasi Rumah Ibadah

+

Setiap warga miskin yang terverifikasi akan ditugaskan ke rumah ibadah terdekat menggunakan Haversine Formula untuk memastikan penyaluran bantuan tepat sasaran.

+
+
+
+

Fitur Unggulan Sistem

+
+ +
+
+
+
+
+ +
+
+
+
diff --git a/app/views/rumah_ibadah/bantuan.php b/app/views/rumah_ibadah/bantuan.php new file mode 100644 index 0000000..4c1729d --- /dev/null +++ b/app/views/rumah_ibadah/bantuan.php @@ -0,0 +1,46 @@ +
+
+

📦 Histori Bantuan

+

· catatan penyaluran

+
+ ➕ Catat Bantuan Baru +
+ +
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + +
KodeWargaKategoriJumlahTanggalStatus
+ +
+
+ +
+
📭
+

Belum ada penyaluran

+

Mulai catat penyaluran bantuan pertama Anda

+ ➕ Catat Bantuan +
+ +
+
+ diff --git a/app/views/rumah_ibadah/dashboard.php b/app/views/rumah_ibadah/dashboard.php new file mode 100644 index 0000000..5958319 --- /dev/null +++ b/app/views/rumah_ibadah/dashboard.php @@ -0,0 +1,175 @@ + + + + + +
+
+
+
+
· ,
+
📍  |  Radius: m
+
+
+ + +
+
+
👥
+
+
+
Total Warga Binaan
+
+
+
+
🔥
+
+
+
Prioritas Tinggi
+ +
+
+
+
+
+
+
Miskin Ekstrem
+
+
+
+
📦
+
+
+
Total Bantuan Disalurkan
+
bulan ini
+
+
+
+ + +
+ +
+
+ 🗺 +

Peta Wilayah Pelayanan & Warga Binaan

+ +
+
+
+
+

Legenda

+
Rumah Ibadah Anda
+
Miskin Ekstrem
+
Miskin
+
Rentan Miskin
+
+
+
+ + +
+ +
+
📊

Distribusi Prioritas

+
+
+ + +
+
+ 🔥

Warga Prioritas Tinggi

+ +
+
+ $w['level_prioritas']==='Tinggi'); + foreach(array_slice($topWarga,0,8) as $w): + ?> + + + + + +

Tidak ada warga prioritas tinggi

+ +
+
+
+
+ + +
+
+ 📦

Bantuan Terakhir Disalurkan

+ +
+
+
+ + + + + + + + + + + + + +
KodeWargaKategoriJumlahTanggal
+
+ +
📭

Belum ada bantuan disalurkan

+ +
+
diff --git a/app/views/rumah_ibadah/detail_warga.php b/app/views/rumah_ibadah/detail_warga.php new file mode 100644 index 0000000..bb14d34 --- /dev/null +++ b/app/views/rumah_ibadah/detail_warga.php @@ -0,0 +1,127 @@ + + + + +
+ ← Kembali +

+ 'badge-red','Miskin'=>'badge-orange','Rentan Miskin'=>'badge-yellow']; ?> + +
+ 📦 Catat Bantuan +
+
+ +
+
+ +
+
+
+
+
+
Skor Prioritas
+
+
+
+
+
+
Prioritas
+
+
+
+
+
+ + +
+
👤

Data Pribadi

+
+
+ +
+
+
+
+ +
+
+ 👴 Lansia + ♿ Disabilitas +
+
+
+ + +
+
📦

Histori Bantuan Diterima ()

+
+ +
+ + + + + + + + + + + +
TanggalKategoriJumlah
+
+ +

Belum menerima bantuan

+ +
+
+
+ + +
+
+
📍

Lokasi Warga

+
+
+
+
+
Koordinat
+
,
+ +
Alamat
+
+ +
+
+
+
diff --git a/app/views/rumah_ibadah/form_bantuan.php b/app/views/rumah_ibadah/form_bantuan.php new file mode 100644 index 0000000..38e640b --- /dev/null +++ b/app/views/rumah_ibadah/form_bantuan.php @@ -0,0 +1,133 @@ +
+ ← Kembali +

Catat Penyaluran Bantuan

+
+ +
+
+
+
📦

Form Penyaluran Bantuan

+
+
+ + +
+ + +
+ + + + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
Upload foto saat penyerahan bantuan sebagai bukti dokumentasi
+
+ +
+ Batal + +
+
+
+
+
+ + +
+
+
🔥

Warga Prioritas Tinggi

+
+ $w['level_prioritas']==='Tinggi'); + foreach($topWarga as $w): + ?> + + + +

Tidak ada prioritas tinggi

+ +
+
+
+
+ + \ No newline at end of file diff --git a/app/views/rumah_ibadah/prioritas.php b/app/views/rumah_ibadah/prioritas.php new file mode 100644 index 0000000..4642574 --- /dev/null +++ b/app/views/rumah_ibadah/prioritas.php @@ -0,0 +1,45 @@ +
+

🔥 Daftar Prioritas Bantuan

+

· Diurutkan berdasarkan skor prioritas tertinggi

+
+
+
+
+ + + + $w): ?> + + + + + + + + + + + + + +
RankNamaSkorStatusPenghasilanTanggunganBantuan TerakhirTotalAksi
+
+
+
+ Lansia + Disabilitas +
+
+ 'badge-red','Sedang'=>'badge-yellow','Rendah'=>'badge-green']; ?> + +
'badge-red','Miskin'=>'badge-orange','Rentan Miskin'=>'badge-yellow']; ?> + /bln orangBelum pernah' ?>× + Detail + 📦 Bantu +
+
+ +
👥

Tidak ada warga binaan

+ +
+
diff --git a/app/views/rumah_ibadah/warga.php b/app/views/rumah_ibadah/warga.php new file mode 100644 index 0000000..b8c79a4 --- /dev/null +++ b/app/views/rumah_ibadah/warga.php @@ -0,0 +1,62 @@ +
+
+

👥 Daftar Warga Binaan

+

· warga

+
+
+ +
+ 'Semua','Tinggi'=>'🔥 Prioritas Tinggi','Sedang'=>'⚡ Sedang','Rendah'=>'✅ Rendah'] as $l=>$label): ?> + + +
+ +
+ +
+ +
+
+
+ + + + + + $w): ?> + + + + + + + + + + + +
#NamaStatus KemiskinanPrioritasPenghasilanTanggunganAksi
+
+
+
+ 'badge-red','Miskin'=>'badge-orange','Rentan Miskin'=>'badge-yellow']; ?> + + + 'badge-red','Sedang'=>'badge-yellow','Rendah'=>'badge-green']; ?> + +
/100
+
orang + Detail +
+
+ +
+
👥
+

Tidak ada warga binaan

+

Warga binaan akan otomatis ditugaskan berdasarkan radius pelayanan

+
+ +
+
+ + diff --git a/app/views/verifikator/dashboard.php b/app/views/verifikator/dashboard.php new file mode 100644 index 0000000..7ffd80c --- /dev/null +++ b/app/views/verifikator/dashboard.php @@ -0,0 +1,141 @@ + + + + + +
+
+
📋
+
+
+
Laporan Menunggu
+ +
+
+
+
🔄
+
+
+
Sedang Diproses
+
+
+
+
+
+
+
Warga Terverifikasi
+
+
+
+
🔥
+
+
+
Prioritas Tinggi
+
+
+
+
⚠️
+
+
+
Blind Spot
+
+
+
+ + +
+ +
+
🗺

Peta Laporan Kelurahan Saya

+
+
+
+
+ + +
+
+
📊

Status Laporan

+
+
+ + +
+
+ + +
+

Aksi Cepat

+ +
\ No newline at end of file diff --git a/app/views/verifikator/detail_laporan.php b/app/views/verifikator/detail_laporan.php new file mode 100644 index 0000000..f742a5f --- /dev/null +++ b/app/views/verifikator/detail_laporan.php @@ -0,0 +1,179 @@ + + + + +
+ ← Kembali +

Detail Laporan:

+ 'badge-yellow','diproses'=>'badge-blue','terverifikasi'=>'badge-green','ditolak'=>'badge-red']; ?> + +
+ +
+ +
+ + +
+
👤

Data Warga Dilaporkan

+
+
+
+
Nama Warga
+
+
+
+
Wilayah
+
,
+
+
+
+
+
Alamat Lengkap
+
+
+
+
+
Koordinat
+
,
+
+
+
Tanggal Laporan
+
+
+
+ +
+
+
Deskripsi Kondisi
+
+
+ +
+
+ + +
+
📝

Data Pelapor

+
+
+
+
Nama Pelapor
+
+
+
+
No HP
+
+
+
+
+
+ + + +
+
📷

Foto Bukti

+
+ +
+
+ + + + +
+
💬

Catatan Verifikator

+
+
+ +
Diverifikasi:
+ +
+
+ +
+ + +
+ +
+
📍

Lokasi Warga

+
+
+
+
+ + + +
+
+ +

Tindakan Verifikasi

+
+
+ + + +
+ + +
+ + +
+ +
+
+ + + +
+ + +
+ + +
+ +
Akan otomatis membuat data warga miskin dan menetapkan rumah ibadah penanggung jawab
+
+ + +
+ + +
+ + +
+ +
+
+
+ +
+
+ +
+
Laporan Terverifikasi
+
Data warga telah ditambahkan ke sistem
+ Lihat Data Warga + +
+
Laporan Ditolak
+ +
+
+ +
+
\ No newline at end of file diff --git a/app/views/verifikator/detail_warga.php b/app/views/verifikator/detail_warga.php new file mode 100644 index 0000000..98ab8c8 --- /dev/null +++ b/app/views/verifikator/detail_warga.php @@ -0,0 +1,277 @@ + + + + + +
+ ← Kembali +

+ 'badge-red','Miskin'=>'badge-orange','Rentan Miskin'=>'badge-yellow']; ?> + + ⚠ Blind Spot +
+ +
+
+ +
+ +
+ + +
+
+
+
+
+
Skor / 100
+
+
+
+
+
+
Prioritas
+
Diperbarui otomatis berdasarkan data sosial ekonomi
+
+
+
+
+ + +
+
👤

Data Pribadi

+
+
+ +
+
+
+
+ +
+
+
+
Alamat
+
+
,
+
+ +
+ + +
+
+ + +
+
💼

Data Sosial Ekonomi

+
+
+ +
+
+
+
+ +
+
+
+ + 👴 Lansia + + + ♿ Disabilitas: + + + ⚠ Blind Spot + +
+
+
+ + +
+
📦

Histori Bantuan ()

+
+ +
+ + + + + + + + + + + + +
TanggalKategoriJumlahRumah Ibadah
+
+ +
📭

Belum ada bantuan

+ +
+
+
+ + +
+
+
📍

Lokasi

+
+
+
+
+
Koordinat
+
,
+
+
+ + +
+
+

Rumah Ibadah Penanggung Jawab

+
+
+
+
Ditetapkan otomatis berdasarkan Haversine Formula
+
+
+ + +
+
ℹ️

Info Verifikasi

+
+
Tanggal Verifikasi
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/app/views/verifikator/form_warga.php b/app/views/verifikator/form_warga.php new file mode 100644 index 0000000..625db5a --- /dev/null +++ b/app/views/verifikator/form_warga.php @@ -0,0 +1,208 @@ + +document.addEventListener('DOMContentLoaded', () => { + LocationPicker.init('picker-map','lat','lng', {$_mapLat}, {$_mapLng}); + + // Auto-detect lansia + document.getElementById('tanggal_lahir').addEventListener('change', function() { + const d = new Date(this.value); + const age = Math.floor((Date.now() - d.getTime()) / (365.25*24*60*60*1000)); + document.getElementById('is_lansia').checked = age >= 60; + }); + + // Auto hitung skor + const calcBtn = document.getElementById('calc-skor'); + if (calcBtn) { + calcBtn.addEventListener('click', function() { + const p = parseInt(document.getElementById('penghasilan').value) || 0; + const t = parseInt(document.getElementById('tanggungan').value) || 0; + const kapita = t > 0 ? p/t : p; + const st = kapita <= 350000 ? 'Miskin Ekstrem' : kapita <= 550000 ? 'Miskin' : 'Rentan Miskin'; + document.getElementById('status_kem_preview').textContent = st; + }); + } +}); + +JS; ?> + +
+ ← Kembali +

Input Data Warga Baru

+
+ +
+ +
+ + +
+ +
+
👤

Data Pribadi

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
💼

Data Sosial Ekonomi

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + Status Kemiskinan: +
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+
+ + +
+
+
📍

Lokasi Rumah

+
+
+
+
+ + +
+
+ + +
+
+
Klik peta atau drag marker untuk menentukan lokasi rumah
+ + +
+ + +
+ +
+
ℹ️ Assignment Otomatis
+
Setelah disimpan, sistem akan otomatis:
+ ✓ Menghitung rumah ibadah terdekat (Haversine)
+ ✓ Mendeteksi blind spot
+ ✓ Menghitung skor prioritas bantuan +
+
+
+
+
+
+ + +
+ Batal + +
+
\ No newline at end of file diff --git a/app/views/verifikator/laporan.php b/app/views/verifikator/laporan.php new file mode 100644 index 0000000..7d62d2a --- /dev/null +++ b/app/views/verifikator/laporan.php @@ -0,0 +1,64 @@ +
+
+

Daftar Laporan Masyarakat

+

Kelurahan:

+
+ ➕ Input Manual Warga +
+ + +
+ 'Semua','menunggu'=>'⏳ Menunggu','diproses'=>'🔄 Diproses','terverifikasi'=>'✅ Terverifikasi','ditolak'=>'❌ Ditolak']; ?> + $l): ?> + + +
+ + +
+ +
+ + +
+
+
+ + + + + + + + + + + + + + + + + +
KodeNama WargaPelaporAlamatStatusTanggalAksi
+
+
+
+ + +
+ +
+ 'badge-yellow','diproses'=>'badge-blue','terverifikasi'=>'badge-green','ditolak'=>'badge-red']; ?> + + + Detail +
+
+ +
📭

Tidak ada laporan

Belum ada laporan dengan status ini

+ +
+
+ + diff --git a/app/views/verifikator/warga.php b/app/views/verifikator/warga.php new file mode 100644 index 0000000..eb0a31f --- /dev/null +++ b/app/views/verifikator/warga.php @@ -0,0 +1,111 @@ +
+
+

Data Warga Miskin

+

Kelurahan: · warga terdata

+
+ ➕ Tambah Warga +
+ + +
+ 'Semua','Tinggi'=>'🔥 Prioritas Tinggi','Sedang'=>'⚡ Sedang','Rendah'=>'✅ Rendah']; ?> + $label): ?> + + +
+ +
+ +
+ + +
+
🗺

Peta Warga Kelurahan

+
+
+
+
+ + +
+
+
+ + + + + + + + + $w): ?> + + + + + + + + + + + + + +
#Nama / NIKStatus KemiskinanPrioritasPenghasilanTanggunganRumah IbadahKet.Aksi
+
+
NIK belum diisi' ?>
+
+ 'badge-red','Miskin'=>'badge-orange','Rentan Miskin'=>'badge-yellow']; ?> + + + 'badge-red','Sedang'=>'badge-yellow','Rendah'=>'badge-green']; ?> + +
+
+
+
+
+
/100
+
orang + + ⚠ Blind + + + 👴 + + + + + + Detail +
+
+ +
👥

Belum ada data warga

+ +
+
+ + diff --git a/app/views/walikota/dashboard.php b/app/views/walikota/dashboard.php new file mode 100644 index 0000000..69af054 --- /dev/null +++ b/app/views/walikota/dashboard.php @@ -0,0 +1,205 @@ + + + + + +
+
🏛
+
+
Dashboard Eksekutif
+
Penanganan Kemiskinan Kota Pontianak
+
Data real-time sistem SINERGI ·
+
+
+
Status Sistem
+
● Aktif & Online
+
+
+ + +
+
+
👥
+
+
+
Total Warga Miskin
+
+
+
+
💀
+
+
+
Miskin Ekstrem
+
0?round($stats['ekstrem']/$stats['total_warga']*100,1):0 ?>% dari total
+
+
+
+
⚠️
+
+
+
Warga Blind Spot
+
Belum terjangkau
+
+
+
+
+
+
+
Rumah Ibadah Aktif
+
+
+
+
📦
+
+
+
Bantuan Tahun Ini
+
Total:
+
+
+
+ + +
+ +
+
+ 🌡 +

Heatmap Kemiskinan Kota Pontianak

+
+
+
+
+

Intensitas

+
+
RendahTinggi
+
Rumah Ibadah
+
+
+
+ + +
+ +
+
🥧

Komposisi Kemiskinan

+
+
+ + +
+
🏆

Ranking Kecamatan

+
+ $k): ?> +
+
+
+
+
+
+ +
+
+
+ +
+
+ +
+
+
+
+ + +
+
+
📊

Perbandingan Kemiskinan per Kecamatan

+
+
+
+
📈

Tren Bantuan Tersalurkan (12 Bulan)

+
+
+
+ + +
+
🏆

5 Rumah Ibadah Paling Aktif

+
+
+ + + + $r): ?> + + + + + + + + + + +
PeringkatNamaJenisKecamatanWarga BinaanBantuan Disalurkan
+
+
warga +
+ +
+
+ +
+
+
+
+
+
+
+
diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..a2d1d05 --- /dev/null +++ b/config/app.php @@ -0,0 +1,33 @@ + SESSION_LIFETIME, + 'path' => '/', + 'secure' => false, + 'httponly' => true, + 'samesite' => 'Lax' + ]); + session_start(); + } + } + + public static function attempt(string $username, string $password): bool { + $db = Database::getInstance(); + $user = $db->fetchOne( + "SELECT u.*, r.slug as role_slug, r.nama as role_nama + FROM users u + JOIN roles r ON u.id_role = r.id + WHERE (u.username = ? OR u.email = ?) AND u.is_active = 1", + [$username, $username] + ); + + if ($user && password_verify($password, $user['password'])) { + // Update last login + $db->update('users', ['last_login' => date('Y-m-d H:i:s')], 'id = ?', [$user['id']]); + + // Store in session + $_SESSION['user_id'] = $user['id']; + $_SESSION['user_name'] = $user['nama']; + $_SESSION['user_role'] = $user['role_slug']; + $_SESSION['user_email'] = $user['email']; + $_SESSION['id_kelurahan'] = $user['id_kelurahan']; + $_SESSION['login_time'] = time(); + + // Log activity + self::logActivity($user['id'], $user['nama'], $user['role_slug'], 'Login', 'auth', 'Login ke sistem'); + + return true; + } + return false; + } + + public static function check(): bool { + return isset($_SESSION['user_id']) && !empty($_SESSION['user_id']); + } + + public static function user(): ?array { + if (!self::check()) return null; + if (self::$user !== null) return self::$user; + + $db = Database::getInstance(); + self::$user = $db->fetchOne( + "SELECT u.*, r.slug as role_slug, r.nama as role_nama, + k.nama as kelurahan_nama, kec.nama as kecamatan_nama, + ri.id as rumah_ibadah_id, ri.nama as rumah_ibadah_nama, + ri.lat as ri_lat, ri.lng as ri_lng, ri.radius_meter as ri_radius + FROM users u + JOIN roles r ON u.id_role = r.id + LEFT JOIN kelurahan k ON u.id_kelurahan = k.id + LEFT JOIN kecamatan kec ON k.id_kecamatan = kec.id + LEFT JOIN rumah_ibadah ri ON ri.id_user = u.id + WHERE u.id = ?", + [$_SESSION['user_id']] + ); + return self::$user; + } + + public static function id(): ?int { + return self::check() ? (int)$_SESSION['user_id'] : null; + } + + public static function role(): ?string { + return $_SESSION['user_role'] ?? null; + } + + public static function is(string ...$roles): bool { + return in_array(self::role(), $roles); + } + + public static function logout(): void { + if (self::check()) { + self::logActivity( + $_SESSION['user_id'], + $_SESSION['user_name'], + $_SESSION['user_role'], + 'Logout', 'auth', 'Logout dari sistem' + ); + } + $_SESSION = []; + session_destroy(); + self::$user = null; + } + + public static function require(string ...$roles): void { + self::init(); + if (!self::check()) { + header('Location: ' . APP_URL . '/login'); + exit; + } + if (!empty($roles) && !self::is(...$roles)) { + header('Location: ' . APP_URL . '/forbidden'); + exit; + } + } + + public static function redirectIfLoggedIn(): void { + if (self::check()) { + header('Location: ' . self::getDashboardUrl()); + exit; + } + } + + public static function getDashboardUrl(): string { + return match(self::role()) { + 'superadmin' => APP_URL . '/admin/dashboard', + 'verifikator' => APP_URL . '/verifikator/dashboard', + 'rumah_ibadah' => APP_URL . '/rumah-ibadah/dashboard', + 'dinsos' => APP_URL . '/dinsos/dashboard', + 'walikota' => APP_URL . '/walikota/dashboard', + default => APP_URL . '/' + }; + } + + public static function logActivity(int $userId, string $namaUser, string $role, string $aksi, string $modul, string $deskripsi = ''): void { + try { + $db = Database::getInstance(); + $db->insert('aktivitas_sistem', [ + 'id_user' => $userId, + 'nama_user' => $namaUser, + 'role' => $role, + 'aksi' => $aksi, + 'modul' => $modul, + 'deskripsi' => $deskripsi, + 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '', + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '' + ]); + } catch (Exception $e) { + // Silent fail for logging + } + } +} diff --git a/core/Controller.php b/core/Controller.php new file mode 100644 index 0000000..0ef29d2 --- /dev/null +++ b/core/Controller.php @@ -0,0 +1,116 @@ +db = Database::getInstance(); + } + + protected function view(string $view, array $data = [], ?string $layout = 'main'): void { + extract($data); + $viewFile = APP_ROOT . '/app/views/' . str_replace('.', '/', $view) . '.php'; + + if (!file_exists($viewFile)) { + die("View not found: $viewFile"); + } + + if ($layout) { + $layoutFile = APP_ROOT . '/app/views/layouts/' . $layout . '.php'; + if (file_exists($layoutFile)) { + // Capture view content + ob_start(); + require $viewFile; + $content = ob_get_clean(); + require $layoutFile; + return; + } + } + + require $viewFile; + } + + protected function json(mixed $data, int $code = 200): void { + http_response_code($code); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + + protected function redirect(string $url): void { + // Flush session ke disk sebelum redirect agar data tidak hilang + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + header("Location: $url"); + exit; + } + + protected function back(): void { + $referer = $_SERVER['HTTP_REFERER'] ?? APP_URL; + $this->redirect($referer); + } + + protected function csrf(): string { + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf_token']; + } + + protected function verifyCsrf(): bool { + // Pastikan session aktif sebelum membaca token + Auth::init(); + $token = $_POST['_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + return hash_equals($_SESSION['csrf_token'] ?? '', $token); + } + + protected function input(string $key, mixed $default = null): mixed { + return $_POST[$key] ?? $_GET[$key] ?? $default; + } + + protected function sanitize(string $value): string { + return htmlspecialchars(trim($value), ENT_QUOTES, 'UTF-8'); + } + + protected function flash(string $type, string $message, array $extra = []): void { + $_SESSION['flash'] = ['type' => $type, 'message' => $message] + $extra; + } + + protected function getFlash(): ?array { + if (isset($_SESSION['flash'])) { + $flash = $_SESSION['flash']; + unset($_SESSION['flash']); + return $flash; + } + return null; + } + + protected function uploadFile(array $file, string $dir = 'uploads'): ?string { + if ($file['error'] !== UPLOAD_ERR_OK) return null; + + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + $allow = ['jpg', 'jpeg', 'png', 'webp']; + if (!in_array($ext, $allow)) return null; + + $maxSize = 5 * 1024 * 1024; // 5MB + if ($file['size'] > $maxSize) return null; + + $filename = uniqid('img_', true) . '.' . $ext; + $destDir = APP_ROOT . '/public/images/' . $dir . '/'; + if (!is_dir($destDir)) mkdir($destDir, 0755, true); + + if (move_uploaded_file($file['tmp_name'], $destDir . $filename)) { + return $filename; + } + return null; + } + + protected function paginate(int $total, int $perPage = 20): array { + $page = max(1, (int)($_GET['page'] ?? 1)); + $offset = ($page - 1) * $perPage; + $pages = (int)ceil($total / $perPage); + return compact('page', 'offset', 'perPage', 'pages', 'total'); + } +} \ No newline at end of file diff --git a/core/Database.php b/core/Database.php new file mode 100644 index 0000000..5f7ede1 --- /dev/null +++ b/core/Database.php @@ -0,0 +1,76 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4" + ]; + try { + $this->pdo = new PDO($dsn, DB_USER, DB_PASS, $options); + } catch (PDOException $e) { + die(json_encode(['error' => 'Database connection failed: ' . $e->getMessage()])); + } + } + + public static function getInstance(): Database { + if (self::$instance === null) { + self::$instance = new Database(); + } + return self::$instance; + } + + public function getConnection(): PDO { + return $this->pdo; + } + + // Shorthand query + public function query(string $sql, array $params = []): PDOStatement { + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + return $stmt; + } + + public function fetchAll(string $sql, array $params = []): array { + return $this->query($sql, $params)->fetchAll(); + } + + public function fetchOne(string $sql, array $params = []): ?array { + $row = $this->query($sql, $params)->fetch(); + return $row ?: null; + } + + public function insert(string $table, array $data): int { + $cols = implode(', ', array_keys($data)); + $placeholders = implode(', ', array_fill(0, count($data), '?')); + $sql = "INSERT INTO `$table` ($cols) VALUES ($placeholders)"; + $this->query($sql, array_values($data)); + return (int)$this->pdo->lastInsertId(); + } + + public function update(string $table, array $data, string $where, array $whereParams = []): int { + $sets = implode(', ', array_map(fn($k) => "`$k` = ?", array_keys($data))); + $sql = "UPDATE `$table` SET $sets WHERE $where"; + $stmt = $this->query($sql, array_merge(array_values($data), $whereParams)); + return $stmt->rowCount(); + } + + public function delete(string $table, string $where, array $params = []): int { + $stmt = $this->query("DELETE FROM `$table` WHERE $where", $params); + return $stmt->rowCount(); + } + + public function lastInsertId(): string { + return $this->pdo->lastInsertId(); + } + + // Prevent cloning + private function __clone() {} +} diff --git a/core/Router.php b/core/Router.php new file mode 100644 index 0000000..a11754c --- /dev/null +++ b/core/Router.php @@ -0,0 +1,64 @@ +routes['GET'][$path] = $handler; + } + + public function post(string $path, callable|array $handler): void { + $this->routes['POST'][$path] = $handler; + } + + public function dispatch(): void { + $method = $_SERVER['REQUEST_METHOD']; + $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + + // Strip base path + $basePath = parse_url(APP_URL, PHP_URL_PATH); + if ($basePath && str_starts_with($uri, $basePath)) { + $uri = substr($uri, strlen($basePath)); + } + $uri = '/' . ltrim($uri, '/'); + if ($uri !== '/' && str_ends_with($uri, '/')) { + $uri = rtrim($uri, '/'); + } + + $routes = $this->routes[$method] ?? []; + + // Two-pass: static routes (no {param}) matched first, dynamic routes second. + // This ensures /verifikator/warga/tambah is never swallowed by /verifikator/warga/{id}. + $staticRoutes = array_filter($routes, fn($p) => !str_contains($p, '{'), ARRAY_FILTER_USE_KEY); + $dynamicRoutes = array_filter($routes, fn($p) => str_contains($p, '{'), ARRAY_FILTER_USE_KEY); + + foreach ([$staticRoutes, $dynamicRoutes] as $group) { + foreach ($group as $pattern => $handler) { + $regex = $this->buildRegex($pattern); + if (preg_match($regex, $uri, $matches)) { + array_shift($matches); + $params = array_values($matches); + + if (is_array($handler)) { + [$controllerName, $action] = $handler; + $controller = new $controllerName(); + $controller->$action(...$params); + } else { + $handler(...$params); + } + return; + } + } + } + + // 404 + http_response_code(404); + require APP_ROOT . '/app/views/public/404.php'; + } + + private function buildRegex(string $pattern): string { + $pattern = preg_replace('/\{(\w+)\}/', '([^/]+)', $pattern); + return '#^' . $pattern . '$#'; + } +} \ No newline at end of file diff --git a/core/helpers.php b/core/helpers.php new file mode 100644 index 0000000..b0a865f --- /dev/null +++ b/core/helpers.php @@ -0,0 +1,356 @@ += 6) $skor += 20; + elseif ($tanggungan >= 4) $skor += 15; + elseif ($tanggungan >= 2) $skor += 10; + else $skor += 4; + + // 3. Kondisi rumah (15 poin) + $skor += match($warga['kondisi_rumah'] ?? '') { + 'Sangat Tidak Layak' => 15, + 'Tidak Layak' => 10, + 'Cukup Layak' => 5, + 'Layak' => 1, + default => 5 + }; + + // 4. Pendidikan (10 poin) + $skor += match($warga['pendidikan_terakhir'] ?? '') { + 'Tidak Sekolah' => 10, + 'SD' => 8, + 'SMP' => 6, + 'SMA' => 3, + default => 1 + }; + + // 5. Riwayat penyakit (10 poin) + if (!empty($warga['riwayat_penyakit'])) { + $penyakit = strtolower($warga['riwayat_penyakit']); + if (str_contains($penyakit, 'kanker') || str_contains($penyakit, 'gagal ginjal') || str_contains($penyakit, 'stroke')) { + $skor += 10; + } elseif (str_contains($penyakit, 'diabetes') || str_contains($penyakit, 'jantung') || str_contains($penyakit, 'tbc')) { + $skor += 8; + } else { + $skor += 5; + } + } + + // 6. Lansia (8 poin) + if (!empty($warga['is_lansia'])) $skor += 8; + + // 7. Disabilitas (7 poin) + if (!empty($warga['is_disabilitas'])) $skor += 7; + + $skor = min(100, $skor); + + $level = match(true) { + $skor >= 70 => 'Tinggi', + $skor >= 40 => 'Sedang', + default => 'Rendah' + }; + + return ['skor' => $skor, 'level' => $level]; +} + +/** + * Tentukan status kemiskinan berdasarkan penghasilan dan tanggungan + */ +function tentukanStatusKemiskinan(int $penghasilan, int $tanggungan): string { + $kapita = $tanggungan > 0 ? $penghasilan / $tanggungan : $penghasilan; + if ($kapita <= 350000) return 'Miskin Ekstrem'; + if ($kapita <= 550000) return 'Miskin'; + return 'Rentan Miskin'; +} + +/** + * Generate kode laporan: LPR-YYYYMMDD-XXXX + */ +function generateKodeLaporan(): string { + $db = Database::getInstance(); + $today = date('Ymd'); + $count = $db->fetchOne("SELECT COUNT(*)+1 as n FROM laporan_masyarakat WHERE DATE(created_at) = CURDATE()")['n']; + return 'LPR-' . $today . '-' . str_pad($count, 4, '0', STR_PAD_LEFT); +} + +/** + * Generate kode bantuan: BNT-YYYYMMDD-XXXX + */ +function generateKodeBantuan(): string { + $db = Database::getInstance(); + $today = date('Ymd'); + $count = $db->fetchOne("SELECT COUNT(*)+1 as n FROM histori_bantuan WHERE DATE(created_at) = CURDATE()")['n']; + return 'BNT-' . $today . '-' . str_pad($count, 4, '0', STR_PAD_LEFT); +} + +/** + * Generate password awal/reset yang mudah diketik tetapi tetap acak. + */ +function generatePasswordAkun(): string { + return 'Sinergi@' . random_int(100000, 999999); +} + +/** + * Kirim kredensial akun via email jika mail server tersedia. + * Mengembalikan false di instalasi lokal yang belum punya konfigurasi SMTP/sendmail. + */ +function kirimEmailAkun(string $email, string $nama, string $username, string $password, string $aksi = 'dibuat'): bool { + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return false; + } + + $subject = 'Informasi Akun SINERGI'; + $message = "Yth. {$nama},\n\n" + . "Akun SINERGI Anda telah {$aksi}.\n\n" + . "URL Login: " . APP_URL . "/login\n" + . "Username: {$username}\n" + . "Password: {$password}\n\n" + . "Silakan masuk ke sistem dan simpan kredensial ini dengan aman.\n\n" + . "Salam,\nAdmin SINERGI"; + + $headers = [ + 'From: SINERGI ', + 'Content-Type: text/plain; charset=UTF-8' + ]; + + return @mail($email, $subject, $message, implode("\r\n", $headers)); +} + +/** + * Temukan rumah ibadah terdekat berdasarkan Haversine + */ +function findRumahIbadahTerdekat(float $lat, float $lng): ?array { + $db = Database::getInstance(); + $rumahIbadahs = $db->fetchAll( + "SELECT ri.*, k.nama as kelurahan FROM rumah_ibadah ri + JOIN kelurahan k ON ri.id_kelurahan = k.id + WHERE ri.is_active = 1" + ); + + $nearest = null; + $minDist = PHP_FLOAT_MAX; + + foreach ($rumahIbadahs as $ri) { + $dist = haversine($lat, $lng, (float)$ri['lat'], (float)$ri['lng']); + if ($dist < $minDist) { + $minDist = $dist; + $nearest = $ri; + $nearest['jarak_meter'] = round($dist); + } + } + return $nearest; +} + +/** + * Cek apakah titik berada dalam radius salah satu rumah ibadah + */ +function isInBlindSpot(float $lat, float $lng): bool { + $db = Database::getInstance(); + $rumahIbadahs = $db->fetchAll("SELECT lat, lng, radius_meter FROM rumah_ibadah WHERE is_active = 1"); + foreach ($rumahIbadahs as $ri) { + $dist = haversine($lat, $lng, (float)$ri['lat'], (float)$ri['lng']); + if ($dist <= (float)$ri['radius_meter']) { + return false; + } + } + return true; +} + +/** + * Format rupiah + */ +function formatRupiah(int $amount): string { + return 'Rp ' . number_format($amount, 0, ',', '.'); +} + +/** + * Format tanggal Indonesia + */ +function formatTanggal(string $date, bool $withTime = false): string { + if (empty($date) || $date === '0000-00-00') return '-'; + $bulan = ['','Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember']; + $d = new DateTime($date); + $str = $d->format('j') . ' ' . $bulan[(int)$d->format('n')] . ' ' . $d->format('Y'); + if ($withTime) $str .= ' ' . $d->format('H:i'); + return $str; +} + +/** + * Hitung umur + */ +function hitungUmur(string $tanggalLahir): int { + if (empty($tanggalLahir)) return 0; + return (new DateTime($tanggalLahir))->diff(new DateTime())->y; +} + +/** + * Warna marker berdasarkan status kemiskinan + */ +function warnaMiskin(string $status): string { + return match($status) { + 'Miskin Ekstrem' => '#dc2626', + 'Miskin' => '#ea580c', + 'Rentan Miskin' => '#ca8a04', + default => '#6b7280' + }; +} + +/** + * Badge HTML status + */ +function statusBadge(string $status): string { + $map = [ + 'menunggu' => ['bg-yellow-100 text-yellow-800', 'Menunggu'], + 'diproses' => ['bg-blue-100 text-blue-800', 'Diproses'], + 'terverifikasi'=> ['bg-green-100 text-green-800', 'Terverifikasi'], + 'ditolak' => ['bg-red-100 text-red-800', 'Ditolak'], + 'aktif' => ['bg-green-100 text-green-800', 'Aktif'], + 'nonaktif' => ['bg-gray-100 text-gray-600', 'Nonaktif'], + 'Tinggi' => ['bg-red-100 text-red-800', 'Tinggi'], + 'Sedang' => ['bg-yellow-100 text-yellow-800', 'Sedang'], + 'Rendah' => ['bg-green-100 text-green-800', 'Rendah'], + 'disalurkan' => ['bg-green-100 text-green-800', 'Disalurkan'], + 'rencana' => ['bg-blue-100 text-blue-800', 'Rencana'], + 'dibatalkan' => ['bg-red-100 text-red-800', 'Dibatalkan'], + ]; + [$cls, $label] = $map[$status] ?? ['bg-gray-100 text-gray-600', $status]; + return "$label"; +} + +/** + * CSRF hidden input + */ +function csrfField(): string { + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return ''; +} + +/** + * Set flash message ke session. + * Gunakan fungsi ini (bukan tulis langsung ke $_SESSION['flash']) agar + * kode_laporan / kode_bantuan disimpan sebagai key terpisah — bukan + * ditempel ke dalam string pesan — sehingga tidak ikut terhapus saat + * flashMessage() dipanggil. + * + * Contoh penggunaan: + * setFlash('success', 'Laporan berhasil disimpan.', ['kode_laporan' => $kode]); + * setFlash('error', 'Terjadi kesalahan.'); + */ +function setFlash(string $type, string $message, array $extra = []): void { + $_SESSION['flash'] = array_merge(['type' => $type, 'message' => $message], $extra); +} + +/** + * Flash message display. + * + * FIX Bug 1 — unset dilakukan SETELAH semua data flash dibaca, bukan di + * tengah-tengah, sehingga kode_laporan/kode_bantuan yang + * mungkin dipakai lapor.php masih tersedia selama request ini. + * + * FIX Bug 2 — pesan flash boleh mengandung dan
(supaya kode + * laporan bisa ditebalkan), tapi semua tag lain tetap di-escape + * agar aman dari XSS. + * + * Jika flash mengandung 'kode_laporan' atau 'kode_bantuan', nilainya + * otomatis disisipkan ke baris kedua pesan dalam tag . + */ +function flashMessage(): string { + if (empty($_SESSION['flash'])) return ''; + + // Baca dulu seluruh data — JANGAN unset sebelum semua field diambil + $flash = $_SESSION['flash']; + unset($_SESSION['flash']); // hapus sekarang, setelah semua field tersalin ke $flash + + $type = $flash['type'] ?? 'info'; + $message = $flash['message'] ?? ''; + + // Sisipkan kode laporan / bantuan sebagai baris ke-2 jika ada + if (!empty($flash['kode_laporan'])) { + $kode = htmlspecialchars($flash['kode_laporan'], ENT_QUOTES, 'UTF-8'); + $message .= '
Kode Laporan: ' . $kode . ''; + } elseif (!empty($flash['kode_bantuan'])) { + $kode = htmlspecialchars($flash['kode_bantuan'], ENT_QUOTES, 'UTF-8'); + $message .= '
Kode Bantuan: ' . $kode . ''; + } + + // Escape pesan utama — lalu kembalikan dan
yang sudah kita sisipkan + // Alur: escape semua → unescape hanya tag yang kita izinkan + $safe = htmlspecialchars($message, ENT_QUOTES, 'UTF-8'); + $safe = str_replace( + ['<strong>', '</strong>', '<br>'], + ['', '', '
'], + $safe + ); + + $icon = match($type) { + 'success' => '✓', + 'error' => '✕', + 'warning' => '⚠', + default => 'ℹ', + }; + $cls = match($type) { + 'success' => 'flash-success', + 'error' => 'flash-error', + 'warning' => 'flash-warning', + default => 'flash-info', + }; + + return "
$icon$safe
"; +} + +/** + * Escape HTML + */ +function e(string $str): string { + return htmlspecialchars($str, ENT_QUOTES, 'UTF-8'); +} + +/** + * Asset URL + */ +function asset(string $path): string { + return APP_URL . '/public/' . ltrim($path, '/'); +} + +/** + * Upload URL + */ +function uploadUrl(string $filename): string { + if (empty($filename)) return asset('images/placeholder.png'); + return APP_URL . '/public/images/uploads/' . $filename; +} diff --git a/database/dummy_data.sql b/database/dummy_data.sql new file mode 100644 index 0000000..b7d3c0e --- /dev/null +++ b/database/dummy_data.sql @@ -0,0 +1,154 @@ +-- ============================================================ +-- SINERGI - Data Dummy untuk Pengujian +-- Jalankan SETELAH sinergi.sql +-- ============================================================ + +USE `sinergi_db`; + +-- ============================================================ +-- Rumah Ibadah (30 data) +-- ============================================================ +INSERT INTO `users` (`id_role`,`id_kelurahan`,`nama`,`username`,`email`,`password`,`no_hp`,`is_active`) VALUES +(3,1,'Ustadz Hamid Ismail','ri_masjid_darussalam','hamid@sinergi.id','$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi','081200000010',1), +(3,2,'Pdt. Yohannes Kalvin','ri_gereja_bethel','yohannes@sinergi.id','$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi','081200000011',1), +(3,5,'Ustadz Ridwan','ri_masjid_alfalah','ridwan@sinergi.id','$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi','081200000012',1), +(3,9,'Ustadz Mukhlis','ri_masjid_almuhajirin','mukhlis@sinergi.id','$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi','081200000013',1), +(3,13,'Bhikkhu Suriyavamsa','ri_vihara_bodhi','suriyavamsa@sinergi.id','$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi','081200000014',1), +(3,17,'Ustadz Zainul','ri_masjid_taqwa','zainul@sinergi.id','$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi','081200000015',1), +(3,21,'Ustadz Hasan','ri_masjid_siantan','hasan@sinergi.id','$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi','081200000016',1); + +INSERT INTO `rumah_ibadah` (`id_user`,`id_kelurahan`,`id_jenis`,`nama`,`alamat`,`lat`,`lng`,`radius_meter`,`nama_ketua`,`is_active`) VALUES +-- Pontianak Kota +(6,1,1,'Masjid Darussalam','Jl. Darussalam No.1, Darat Sekip',-0.0170,109.3220,600,'Ustadz Hamid Ismail',1), +(7,2,2,'Gereja Bethel Indonesia','Jl. Nusantara No.5, Sungai Bangkong',-0.0295,109.3315,500,'Pdt. Yohannes Kalvin',1), +(NULL,3,1,'Masjid Al-Ikhlas','Jl. Tengah No.10',-0.0215,109.3295,550,NULL,1), +(NULL,4,3,'Vihara Saddharma','Jl. Tanjung No.8, Mariana',-0.0158,109.3358,450,NULL,1), +-- Pontianak Barat +(8,5,1,'Masjid Al-Falah','Jl. Sungai Jawi No.22',-0.0462,109.3055,600,'Ustadz Ridwan',1), +(NULL,6,2,'Gereja HKBP Sungai Jawi','Jl. Pendidikan No.4, Sungai Jawi Luar',-0.0535,109.3010,500,NULL,1), +(NULL,7,1,'Masjid Nurul Huda','Jl. Pal Lima No.15',-0.0598,109.2908,550,NULL,1), +(NULL,8,5,'Klenteng Kwan Ti Miao','Jl. Gajah Mada No.100, Sungai Beliung',-0.0448,109.3158,400,NULL,1), +-- Pontianak Selatan +(9,9,1,'Masjid Al-Muhajirin','Jl. Akcaya No.30',-0.0648,109.3382,600,'Ustadz Mukhlis',1), +(NULL,10,2,'Gereja Katolik St. Yosep','Jl. Bangka No.12',-0.0752,109.3425,500,NULL,1), +(NULL,11,1,'Masjid Baitul Muttaqin','Jl. Parit No.7, Bangka Belitung Laut',-0.0818,109.3352,550,NULL,1), +(NULL,12,3,'Vihara Singa Raja','Jl. Tokaya No.3',-0.0702,109.3305,450,NULL,1), +-- Pontianak Tenggara +(10,13,3,'Vihara Bodhi Dharma','Jl. Bansir No.5',-0.0872,109.3552,500,'Bhikkhu Suriyavamsa',1), +(NULL,14,1,'Masjid Al-Muttaqin','Jl. Bansir Laut No.18',-0.0952,109.3602,600,NULL,1), +(NULL,15,2,'Gereja Pentakosta Tanjung Hulu','Jl. Tanjung No.20',-0.0918,109.3705,500,NULL,1), +(NULL,16,1,'Masjid Nurul Iman','Jl. Tanjung Hilir No.9',-0.0852,109.3652,550,NULL,1), +-- Pontianak Timur +(11,17,1,'Masjid At-Taqwa','Jl. Saigon No.11',-0.0382,109.3702,600,'Ustadz Zainul',1), +(NULL,18,2,'Gereja GKE Tambelan','Jl. Tambelan No.6',-0.0448,109.3752,500,NULL,1), +(NULL,19,1,'Masjid Nurul Hidayah','Jl. Tanjung Raya No.33',-0.0318,109.3802,550,NULL,1), +(NULL,20,5,'Klenteng Tri Dharma','Jl. Parit Mayor No.2',-0.0498,109.3682,400,NULL,1), +-- Pontianak Utara +(12,21,1,'Masjid Raya Siantan','Jl. Siantan No.1',-0.0198,109.3252,700,'Ustadz Hasan',1), +(NULL,22,2,'Gereja Sidang Jemaat Allah','Jl. Siantan Tengah No.8',0.0102,109.3205,500,NULL,1), +(NULL,23,1,'Masjid Al-Amin','Jl. Siantan Hilir No.15',0.0052,109.3302,600,NULL,1), +(NULL,24,3,'Vihara Metta Karuna','Jl. Batu Layang No.20',0.0298,109.3352,450,NULL,1); + +-- ============================================================ +-- Warga Miskin (50 data dummy) +-- ============================================================ +INSERT INTO `warga_miskin` +(`id_kelurahan`,`id_rumah_ibadah`,`id_verifikator`,`nik`,`nama`,`tempat_lahir`,`tanggal_lahir`,`jenis_kelamin`,`agama`,`alamat`,`lat`,`lng`,`pendidikan_terakhir`,`pekerjaan`,`penghasilan_bulanan`,`jumlah_tanggungan`,`kondisi_rumah`,`riwayat_penyakit`,`is_lansia`,`is_disabilitas`,`status_kemiskinan`,`skor_prioritas`,`level_prioritas`,`is_blind_spot`,`status`,`tanggal_verifikasi`) +VALUES +(1,1,2,'6271010101850001','Ahmad Firdaus','Pontianak','1985-01-15','L','Islam','Jl. Darussalam Gg. 3 No. 12',-0.0180,109.3225,'SD','Buruh Harian',400000,4,'Tidak Layak','Hipertensi',0,0,'Miskin',72,'Tinggi',0,'aktif','2024-01-10'), +(1,1,2,'6271010101900002','Siti Rahmah','Pontianak','1990-05-20','P','Islam','Jl. Darussalam Gg. 5 No. 8',-0.0175,109.3218,'SMP','Pembantu RT',500000,3,'Tidak Layak',NULL,0,0,'Miskin',58,'Sedang',0,'aktif','2024-01-12'), +(2,2,2,'6271010201750003','Maria Lestari','Pontianak','1975-08-30','P','Kristen','Jl. Nusantara No. 45',-0.0288,109.3320,'SD','Pedagang Kecil',350000,5,'Sangat Tidak Layak','Diabetes, Hipertensi',1,0,'Miskin Ekstrem',95,'Tinggi',0,'aktif','2024-01-08'), +(2,2,2,'6271010201550004','Yohanes Subur','Pontianak','1955-03-12','L','Kristen','Jl. Bangkong No. 7',-0.0298,109.3308,'Tidak Sekolah',NULL,0,6,'Sangat Tidak Layak','Stroke',1,1,'Miskin Ekstrem',98,'Tinggi',0,'aktif','2024-01-08'), +(3,3,2,'6271010301880005','Heri Susanto','Bandung','1988-11-22','L','Islam','Jl. Tengah Gg. 1 No. 3',-0.0220,109.3290,'SMP','Ojek Online',800000,3,'Cukup Layak',NULL,0,0,'Rentan Miskin',35,'Rendah',0,'aktif','2024-01-15'), +(5,5,3,'6271020501920006','Nurhalimah','Pontianak','1992-07-04','P','Islam','Jl. Sungai Jawi No. 66',-0.0468,109.3058,'SMA','Tidak Bekerja',200000,4,'Tidak Layak','TBC',0,0,'Miskin Ekstrem',88,'Tinggi',0,'aktif','2024-02-01'), +(5,5,3,'6271020501800007','Bambang Hartono','Pontianak','1980-09-15','L','Islam','Jl. Sungai Jawi No. 70',-0.0470,109.3060,'SD','Pemulung',300000,5,'Sangat Tidak Layak',NULL,0,1,'Miskin Ekstrem',92,'Tinggi',0,'aktif','2024-02-01'), +(6,6,3,'6271020601850008','Agustina Br Sirait','Medan','1985-04-20','P','Kristen','Jl. Pendidikan No. 22',-0.0538,109.3012,'SMP','Buruh Cuci',450000,3,'Tidak Layak',NULL,0,0,'Miskin',55,'Sedang',0,'aktif','2024-02-05'), +(7,7,3,'6271020701960009','Rohani','Pontianak','1996-12-01','P','Islam','Jl. Pal Lima No. 18',-0.0600,109.2912,'SMA','Tidak Bekerja',150000,2,'Tidak Layak','Gangguan Jiwa',0,1,'Miskin Ekstrem',90,'Tinggi',0,'aktif','2024-02-10'), +(9,9,2,'6271030901780010','Musa Kadir','Pontianak','1978-06-08','L','Islam','Jl. Akcaya No. 15',-0.0652,109.3378,'SD','Kuli Bangunan',600000,4,'Tidak Layak',NULL,0,0,'Miskin',62,'Sedang',0,'aktif','2024-02-12'), +(9,9,2,'6271030901720011','Fatimah Zahra','Pontianak','1972-02-14','P','Islam','Jl. Akcaya No. 17',-0.0655,109.3380,'Tidak Sekolah',NULL,0,3,'Sangat Tidak Layak','Jantung',1,0,'Miskin Ekstrem',93,'Tinggi',0,'aktif','2024-02-12'), +(10,10,2,'6271031001820012','Antonius Jaya','Pontianak','1982-10-30','L','Katolik','Jl. Bangka No. 28',-0.0755,109.3428,'SMP','Nelayan',500000,4,'Tidak Layak',NULL,0,0,'Miskin',60,'Sedang',0,'aktif','2024-02-15'), +(11,11,2,'6271031101900013','Rahma Wati','Pontianak','1990-03-25','P','Islam','Jl. Parit Laut No. 5',-0.0820,109.3355,'SD','Penjual Gorengan',350000,5,'Tidak Layak',NULL,0,0,'Miskin',70,'Tinggi',0,'aktif','2024-02-18'), +(13,13,2,'6271041301750014','Lim Ah Kow','Pontianak','1975-07-07','L','Buddha','Jl. Bansir No. 12',-0.0875,109.3555,'SD','Tukang Becak',400000,4,'Tidak Layak','Asma',0,0,'Miskin',68,'Sedang',0,'aktif','2024-03-01'), +(14,14,2,'6271041401850015','Abdullah Sani','Pontianak','1985-11-18','L','Islam','Jl. Bansir Laut No. 25',-0.0955,109.3605,'SMP','Tidak Bekerja',0,5,'Sangat Tidak Layak',NULL,0,1,'Miskin Ekstrem',95,'Tinggi',0,'aktif','2024-03-05'), +(17,17,2,'6271051701880016','Yuliana','Pontianak','1988-08-12','P','Islam','Jl. Saigon No. 20',-0.0385,109.3705,'SMP','Buruh Pabrik',600000,3,'Cukup Layak',NULL,0,0,'Rentan Miskin',42,'Rendah',0,'aktif','2024-03-08'), +(17,17,2,'6271051701600017','Pak Suwarno','Pontianak','1960-01-01','L','Islam','Jl. Saigon No. 25',-0.0388,109.3708,'SD','Tidak Bekerja',0,2,'Sangat Tidak Layak','Diabetes, Gagal Ginjal',1,0,'Miskin Ekstrem',97,'Tinggi',0,'aktif','2024-03-08'), +(21,21,2,'6271061501700018','Ibrahim','Pontianak','1970-05-05','L','Islam','Jl. Siantan No. 40',0.0202,109.3255,'SD','Tukang Kayu',500000,4,'Tidak Layak',NULL,0,0,'Miskin',58,'Sedang',0,'aktif','2024-03-10'), +(22,22,2,'6271062201800019','Dorkas Br Manurung','Medan','1980-12-25','P','Kristen','Jl. Siantan Tengah No. 15',0.0105,109.3208,'SMP','Pedagang',400000,3,'Tidak Layak',NULL,0,0,'Miskin',55,'Sedang',0,'aktif','2024-03-12'), +-- Blind Spot samples (tidak masuk dalam radius manapun) +(3,NULL,2,'6271010301850020','Surya Pratama','Pontianak','1985-07-17','L','Islam','Jl. Pinggiran Sungai No. 5',-0.0250,109.3400,'SMP','Nelayan',350000,4,'Tidak Layak',NULL,0,0,'Miskin',65,'Sedang',1,'aktif','2024-04-01'), +(8,NULL,3,'6271020801920021','Tan Siu Liang','Pontianak','1992-03-08','L','Buddha','Jl. Beliung Bawah No. 2',-0.0455,109.3200,'SD','Pemulung',250000,3,'Sangat Tidak Layak',NULL,0,0,'Miskin Ekstrem',82,'Tinggi',1,'aktif','2024-04-05'), +-- More regular entries +(4,4,2,'6271010401960022','Dewi Kartika','Pontianak','1996-06-14','P','Buddha','Jl. Mariana No. 5',-0.0160,109.3360,'SMA','Tidak Bekerja',0,2,'Tidak Layak',NULL,0,0,'Miskin',72,'Tinggi',0,'aktif','2024-04-08'), +(6,6,3,'6271020601900023','Agus Salim','Pontianak','1990-09-09','L','Islam','Jl. Jawi Luar No. 30',-0.0532,109.3005,'SMP','Buruh Harian',450000,3,'Tidak Layak',NULL,0,0,'Miskin',60,'Sedang',0,'aktif','2024-04-10'), +(12,12,2,'6271031201830024','Tan Ah Moi','Pontianak','1983-02-28','P','Buddha','Jl. Tokaya No. 10',-0.0705,109.3308,'SD','Pedagang Sayur',300000,4,'Tidak Layak','Reumatik',0,0,'Miskin',68,'Sedang',0,'aktif','2024-04-12'), +(15,15,2,'6271041501780025','Hendrikus','Pontianak','1978-11-11','L','Katolik','Jl. Tanjung No. 35',-0.0920,109.3708,'SD','Nelayan',400000,5,'Tidak Layak',NULL,0,0,'Miskin',65,'Sedang',0,'aktif','2024-04-15'), +(16,16,2,'6271041601920026','Aminah','Pontianak','1992-04-04','P','Islam','Jl. Hilir No. 12',-0.0855,109.3655,'SMP','Pembantu RT',400000,3,'Tidak Layak',NULL,0,0,'Miskin',55,'Sedang',0,'aktif','2024-04-18'), +(18,18,2,'6271051801850027','Tarigan Boru','Pontianak','1985-08-08','P','Kristen','Jl. Tambelan No. 18',-0.0450,109.3755,'SMP','Pembantu RT',350000,2,'Tidak Layak',NULL,0,0,'Miskin',52,'Sedang',0,'aktif','2024-04-20'), +(19,19,2,'6271051901770028','Joko Widodo','Pontianak','1977-06-21','L','Islam','Jl. Tanjung Raya No. 44',-0.0320,109.3805,'SD','Kuli Angkut',450000,4,'Tidak Layak',NULL,0,0,'Miskin',62,'Sedang',0,'aktif','2024-04-22'), +(20,20,2,'6271052001880029','Halim Wijaya','Pontianak','1988-03-15','L','Buddha','Jl. Mayor No. 8',-0.0500,109.3685,'SMP','Tukang Parkir',300000,3,'Tidak Layak',NULL,0,0,'Miskin',58,'Sedang',0,'aktif','2024-04-25'), +(23,23,2,'6271062301600030','Pak Hamdan','Pontianak','1960-07-07','L','Islam','Jl. Hilir Siantan No. 22',0.0055,109.3305,'Tidak Sekolah',NULL,0,3,'Sangat Tidak Layak','TBC, Hipertensi',1,0,'Miskin Ekstrem',96,'Tinggi',0,'aktif','2024-04-28'); + +-- ============================================================ +-- Histori Bantuan (30 data dummy) +-- ============================================================ +INSERT INTO `histori_bantuan` +(`id_warga`,`id_rumah_ibadah`,`id_kategori_bantuan`,`id_user_pengurus`,`kode_bantuan`,`tanggal_penyaluran`,`jumlah`,`satuan`,`deskripsi`,`status`) +VALUES +(1,1,1,6,'BNT-20240115-0001','2024-01-15',1,'paket','Sembako bulanan Jan 2024','disalurkan'), +(2,1,1,6,'BNT-20240115-0002','2024-01-15',1,'paket','Sembako bulanan Jan 2024','disalurkan'), +(3,2,1,7,'BNT-20240120-0003','2024-01-20',1,'paket','Bantuan sembako','disalurkan'), +(3,2,4,7,'BNT-20240120-0004','2024-01-20',1,'paket','Obat diabetes dan hipertensi','disalurkan'), +(4,2,3,7,'BNT-20240125-0005','2024-01-25',300000,'rupiah','Bantuan tunai bulanan','disalurkan'), +(6,5,1,8,'BNT-20240201-0006','2024-02-01',1,'paket','Sembako bulanan Feb 2024','disalurkan'), +(6,5,4,8,'BNT-20240201-0007','2024-02-01',1,'paket','Obat TBC','disalurkan'), +(7,5,1,8,'BNT-20240201-0008','2024-02-01',1,'paket','Sembako bulanan','disalurkan'), +(7,5,3,8,'BNT-20240210-0009','2024-02-10',500000,'rupiah','Bantuan tunai mendesak','disalurkan'), +(9,7,1,8,'BNT-20240215-0010','2024-02-15',1,'paket','Sembako untuk keluarga Rohani','disalurkan'), +(10,9,1,9,'BNT-20240220-0011','2024-02-20',1,'paket','Sembako Feb 2024','disalurkan'), +(11,9,3,9,'BNT-20240220-0012','2024-02-20',400000,'rupiah','Bantuan tunai lansia','disalurkan'), +(11,9,4,9,'BNT-20240225-0013','2024-02-25',1,'paket','Obat jantung','disalurkan'), +(14,13,1,10,'BNT-20240301-0014','2024-03-01',1,'paket','Sembako Maret 2024','disalurkan'), +(15,14,1,9,'BNT-20240305-0015','2024-03-05',1,'paket','Sembako penyandang disabilitas','disalurkan'), +(15,14,3,9,'BNT-20240305-0016','2024-03-05',500000,'rupiah','Bantuan tunai mendesak','disalurkan'), +(17,17,4,11,'BNT-20240310-0017','2024-03-10',1,'paket','Obat diabetes dan ginjal','disalurkan'), +(17,17,3,11,'BNT-20240310-0018','2024-03-10',500000,'rupiah','Bantuan tunai pak Suwarno','disalurkan'), +(1,1,1,6,'BNT-20240215-0019','2024-02-15',1,'paket','Sembako bulanan Feb 2024','disalurkan'), +(2,1,2,6,'BNT-20240215-0020','2024-02-15',1,'paket','Bantuan pakaian','disalurkan'), +(3,2,1,7,'BNT-20240220-0021','2024-02-20',1,'paket','Sembako bulanan Feb 2024','disalurkan'), +(6,5,1,8,'BNT-20240301-0022','2024-03-01',1,'paket','Sembako Maret 2024','disalurkan'), +(10,9,2,9,'BNT-20240310-0023','2024-03-10',1,'paket','Bantuan pakaian anak','disalurkan'), +(13,11,1,9,'BNT-20240315-0024','2024-03-15',1,'paket','Sembako untuk Rahma','disalurkan'), +(18,21,1,12,'BNT-20240315-0025','2024-03-15',1,'paket','Sembako Ibrahim','disalurkan'), +(30,23,3,12,'BNT-20240430-0026','2024-04-30',400000,'rupiah','Bantuan tunai pak Hamdan','disalurkan'), +(1,1,5,6,'BNT-20240401-0027','2024-04-01',1,'paket','Peralatan sekolah anak Ahmad','disalurkan'), +(4,2,1,7,'BNT-20240415-0028','2024-04-15',1,'paket','Sembako April 2024','disalurkan'), +(7,5,1,8,'BNT-20240401-0029','2024-04-01',1,'paket','Sembako April Bambang','disalurkan'), +(17,17,1,11,'BNT-20240401-0030','2024-04-01',1,'paket','Sembako April Pak Suwarno','disalurkan'); + +-- ============================================================ +-- Laporan Masyarakat (10 data dummy) +-- ============================================================ +INSERT INTO `laporan_masyarakat` +(`kode_laporan`,`nama_pelapor`,`no_hp_pelapor`,`nama_warga`,`alamat_warga`,`id_kelurahan`,`lat`,`lng`,`deskripsi`,`status`,`catatan_verifikator`,`id_verifikator`,`tanggal_verifikasi`) +VALUES +('LPR-20240501-0001','Rizky Pratama','08190001111','Pak Udin','Jl. Darussalam Gg. 7 No. 3',1,-0.0183,109.3228,'Bapak ini sudah lama tidak bekerja, memiliki 3 anak yang masih kecil dan tinggal di rumah yang hampir roboh.','terverifikasi','Data valid, sudah survei lapangan',2,'2024-05-03'), +('LPR-20240502-0002','Susi Lestari','08190002222','Ibu Sari','Jl. Bangkong No. 32',2,-0.0300,109.3312,'Ibu ini janda dengan 4 anak, hidup dari berjualan kue keliling. Penghasilan sangat minim.','terverifikasi','Terverifikasi, perlu prioritas',2,'2024-05-05'), +('LPR-20240503-0003','Andi Wijaya','08190003333','Pak Badu','Jl. Pal Lima No. 40',7,-0.0602,109.2915,'Lansia sebatang kara, tidak ada yang merawat.','diproses',NULL,NULL,NULL), +('LPR-20240504-0004','Tono Hartono','08190004444','Keluarga Rudi','Jl. Sungai Jawi No. 85',5,-0.0472,109.3062,'Keluarga ini sangat membutuhkan bantuan, rumah bocor dan tidak layak.','menunggu',NULL,NULL,NULL), +('LPR-20240505-0005','Linda Susanti','08190005555','Pak Wahid','Jl. Akcaya No. 45',9,-0.0660,109.3385,'Suami sakit stroke, istri harus menghidupi 3 anak sendiri.','terverifikasi','Valid, prioritas tinggi',2,'2024-05-08'), +('LPR-20240506-0006','Hendra','08190006666','Bu Aminah','Jl. Bansir No. 30',13,-0.0878,109.3558,'Keluarga miskin ekstrem, anak-anak putus sekolah.','menunggu',NULL,NULL,NULL), +('LPR-20240507-0007','Ratna Sari','08190007777','Pak Syarif','Jl. Saigon No. 40',17,-0.0390,109.3710,'Penyandang disabilitas, tidak bisa bekerja, tidak ada keluarga.','terverifikasi','Penyandang disabilitas berat',2,'2024-05-10'), +('LPR-20240508-0008','Irwan','08190008888','Bu Marsini','Jl. Siantan No. 55',21,0.0205,109.3260,'Ibu tua sebatang kara, hidup dari belas kasihan tetangga.','menunggu',NULL,NULL,NULL), +('LPR-20240509-0009','Yayuk','08190009999','Pak Karso','Jl. Pendidikan No. 30',6,-0.0540,109.3015,'Buruh harian yang terkena PHK, punya 5 anak.','ditolak','Data tidak lengkap, perlu verifikasi ulang',3,'2024-05-12'), +('LPR-20240510-0010','Bambang S','08190000000','Nenek Sari','Jl. Siantan Tengah No. 25',22,0.0108,109.3212,'Nenek berusia 80 tahun, hidup sendirian, tidak ada penghasilan.','diproses',NULL,NULL,NULL); + +-- ============================================================ +-- Aktivitas Sistem +-- ============================================================ +INSERT INTO `aktivitas_sistem` (`id_user`,`nama_user`,`role`,`aksi`,`modul`,`deskripsi`,`ip_address`) VALUES +(2,'Budi Santoso','verifikator','Verifikasi Laporan','verifikasi','Verifikasi laporan LPR-20240501-0001','192.168.1.10'), +(2,'Budi Santoso','verifikator','Tambah Warga','warga_miskin','Menambah data warga Ahmad Firdaus','192.168.1.10'), +(6,'Ustadz Hamid Ismail','rumah_ibadah','Salurkan Bantuan','bantuan','Menyalurkan sembako ke Ahmad Firdaus','192.168.1.20'), +(1,'Super Administrator','superadmin','Login','auth','Login ke sistem','192.168.1.1'), +(4,'Ahmad Fauzi','dinsos','Lihat Dashboard','dashboard','Akses dashboard monitoring','192.168.1.30'), +(5,'H. Edi Kamtono','walikota','Lihat Dashboard','dashboard','Akses dashboard eksekutif','192.168.1.40'); diff --git a/database/sinergi.sql b/database/sinergi.sql new file mode 100644 index 0000000..f9c2e7e --- /dev/null +++ b/database/sinergi.sql @@ -0,0 +1,357 @@ +-- ============================================================ +-- SINERGI - Sistem Informasi Geografis Penanganan Kemiskinan +-- Database Schema v1.0 +-- Kota Pontianak +-- ============================================================ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +CREATE DATABASE IF NOT EXISTS `sinergi_db` + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE `sinergi_db`; + +-- ============================================================ +-- TABLE: roles +-- ============================================================ +CREATE TABLE `roles` ( + `id` TINYINT UNSIGNED NOT NULL AUTO_INCREMENT, + `nama` VARCHAR(50) NOT NULL, + `slug` VARCHAR(30) NOT NULL, + `deskripsi` VARCHAR(255), + PRIMARY KEY (`id`), + UNIQUE KEY `uq_roles_slug` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO `roles` (`id`,`nama`,`slug`,`deskripsi`) VALUES +(1,'Super Admin','superadmin','Administrator sistem, mengelola seluruh data master'), +(2,'Verifikator Kelurahan','verifikator','Petugas verifikasi laporan di tingkat kelurahan'), +(3,'Pengurus Rumah Ibadah','rumah_ibadah','Pengurus rumah ibadah yang menangani warga binaan'), +(4,'Dinas Sosial','dinsos','Petugas Dinas Sosial untuk monitoring kemiskinan'), +(5,'Wali Kota','walikota','Wali Kota untuk dashboard eksekutif'); + +-- ============================================================ +-- TABLE: kecamatan +-- ============================================================ +CREATE TABLE `kecamatan` ( + `id` SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT, + `nama` VARCHAR(100) NOT NULL, + `kode` VARCHAR(20), + `lat` DECIMAL(10,7), + `lng` DECIMAL(10,7), + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 6 Kecamatan Kota Pontianak +INSERT INTO `kecamatan` (`id`,`nama`,`kode`,`lat`,`lng`) VALUES +(1,'Pontianak Kota','62.71.01',-0.0233,109.3294), +(2,'Pontianak Barat','62.71.02',-0.0500,109.3100), +(3,'Pontianak Selatan','62.71.03',-0.0700,109.3400), +(4,'Pontianak Tenggara','62.71.04',-0.0900,109.3600), +(5,'Pontianak Timur','62.71.05',-0.0400,109.3700), +(6,'Pontianak Utara','62.71.06',0.0100,109.3300); + +-- ============================================================ +-- TABLE: kelurahan +-- ============================================================ +CREATE TABLE `kelurahan` ( + `id` SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT, + `id_kecamatan` SMALLINT UNSIGNED NOT NULL, + `nama` VARCHAR(100) NOT NULL, + `kode` VARCHAR(20), + `lat` DECIMAL(10,7), + `lng` DECIMAL(10,7), + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `fk_kel_kec` (`id_kecamatan`), + CONSTRAINT `fk_kel_kec` FOREIGN KEY (`id_kecamatan`) REFERENCES `kecamatan` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Kelurahan Kota Pontianak (sample per kecamatan) +INSERT INTO `kelurahan` (`id`,`id_kecamatan`,`nama`,`kode`,`lat`,`lng`) VALUES +-- Pontianak Kota +(1,1,'Darat Sekip','62.71.01.1001',-0.0175,109.3230), +(2,1,'Sungai Bangkong','62.71.01.1002',-0.0290,109.3310), +(3,1,'Tengah','62.71.01.1003',-0.0210,109.3290), +(4,1,'Mariana','62.71.01.1004',-0.0155,109.3350), +-- Pontianak Barat +(5,2,'Sungai Jawi Dalam','62.71.02.1001',-0.0467,109.3050), +(6,2,'Sungai Jawi Luar','62.71.02.1002',-0.0530,109.3000), +(7,2,'Pal Lima','62.71.02.1003',-0.0600,109.2900), +(8,2,'Sungai Beliung','62.71.02.1004',-0.0450,109.3150), +-- Pontianak Selatan +(9,3,'Akcaya','62.71.03.1001',-0.0650,109.3380), +(10,3,'Bangka Belitung Darat','62.71.03.1002',-0.0750,109.3420), +(11,3,'Bangka Belitung Laut','62.71.03.1003',-0.0820,109.3350), +(12,3,'Parit Tokaya','62.71.03.1004',-0.0700,109.3300), +-- Pontianak Tenggara +(13,4,'Bansir Darat','62.71.04.1001',-0.0870,109.3550), +(14,4,'Bansir Laut','62.71.04.1002',-0.0950,109.3600), +(15,4,'Tanjung Hulu','62.71.04.1003',-0.0920,109.3700), +(16,4,'Tanjung Hilir','62.71.04.1004',-0.0850,109.3650), +-- Pontianak Timur +(17,5,'Saigon','62.71.05.1001',-0.0380,109.3700), +(18,5,'Tambelan Sampit','62.71.05.1002',-0.0450,109.3750), +(19,5,'Tanjung Raya','62.71.05.1003',-0.0320,109.3800), +(20,5,'Parit Mayor','62.71.05.1004',-0.0500,109.3680), +-- Pontianak Utara +(21,6,'Siantan Hulu','62.71.06.1001',0.0200,109.3250), +(22,6,'Siantan Tengah','62.71.06.1002',0.0100,109.3200), +(23,6,'Siantan Hilir','62.71.06.1003',0.0050,109.3300), +(24,6,'Batu Layang','62.71.06.1004',0.0300,109.3350); + +-- ============================================================ +-- TABLE: users +-- ============================================================ +CREATE TABLE `users` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `id_role` TINYINT UNSIGNED NOT NULL, + `id_kelurahan` SMALLINT UNSIGNED DEFAULT NULL, + `nama` VARCHAR(100) NOT NULL, + `username` VARCHAR(50) NOT NULL, + `email` VARCHAR(100) NOT NULL, + `password` VARCHAR(255) NOT NULL, + `no_hp` VARCHAR(20), + `foto` VARCHAR(255), + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `last_login` DATETIME, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_users_username` (`username`), + UNIQUE KEY `uq_users_email` (`email`), + KEY `fk_users_role` (`id_role`), + KEY `fk_users_kel` (`id_kelurahan`), + CONSTRAINT `fk_users_role` FOREIGN KEY (`id_role`) REFERENCES `roles` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_users_kel` FOREIGN KEY (`id_kelurahan`) REFERENCES `kelurahan` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Password: Admin@123 (bcrypt) +INSERT INTO `users` (`id`,`id_role`,`id_kelurahan`,`nama`,`username`,`email`,`password`,`no_hp`,`is_active`) VALUES +(1,1,NULL,'Super Administrator','superadmin','superadmin@sinergi.id','$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi','081200000001',1), +(2,2,1,'Budi Santoso','verif_darat','budi.santoso@pontianak.go.id','$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi','081200000002',1), +(3,2,5,'Sari Dewi','verif_jawi','sari.dewi@pontianak.go.id','$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi','081200000003',1), +(4,4,NULL,'Ahmad Fauzi','dinsos_ahmad','ahmad.fauzi@dinsos.id','$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi','081200000004',1), +(5,5,NULL,'H. Edi Kamtono','walikota','walikota@pontianak.go.id','$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi','081200000005',1); + +-- ============================================================ +-- TABLE: jenis_rumah_ibadah +-- ============================================================ +CREATE TABLE `jenis_rumah_ibadah` ( + `id` TINYINT UNSIGNED NOT NULL AUTO_INCREMENT, + `nama` VARCHAR(50) NOT NULL, + `ikon` VARCHAR(100), + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO `jenis_rumah_ibadah` VALUES +(1,'Masjid','masjid.png'), +(2,'Gereja','gereja.png'), +(3,'Vihara','vihara.png'), +(4,'Pura','pura.png'), +(5,'Klenteng','klenteng.png'); + +-- ============================================================ +-- TABLE: rumah_ibadah +-- ============================================================ +CREATE TABLE `rumah_ibadah` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `id_user` INT UNSIGNED DEFAULT NULL COMMENT 'User pengurus', + `id_kelurahan` SMALLINT UNSIGNED NOT NULL, + `id_jenis` TINYINT UNSIGNED NOT NULL, + `nama` VARCHAR(150) NOT NULL, + `alamat` TEXT, + `lat` DECIMAL(10,7) NOT NULL, + `lng` DECIMAL(10,7) NOT NULL, + `radius_meter` INT UNSIGNED NOT NULL DEFAULT 500 COMMENT 'Radius pelayanan dalam meter', + `no_hp_pengurus` VARCHAR(20), + `nama_ketua` VARCHAR(100), + `foto` VARCHAR(255), + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `fk_ri_user` (`id_user`), + KEY `fk_ri_kel` (`id_kelurahan`), + KEY `fk_ri_jenis` (`id_jenis`), + CONSTRAINT `fk_ri_user` FOREIGN KEY (`id_user`) REFERENCES `users` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_ri_kel` FOREIGN KEY (`id_kelurahan`) REFERENCES `kelurahan` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_ri_jenis` FOREIGN KEY (`id_jenis`) REFERENCES `jenis_rumah_ibadah` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- TABLE: kategori_bantuan +-- ============================================================ +CREATE TABLE `kategori_bantuan` ( + `id` TINYINT UNSIGNED NOT NULL AUTO_INCREMENT, + `nama` VARCHAR(100) NOT NULL, + `deskripsi` TEXT, + `satuan` VARCHAR(50) COMMENT 'kg, paket, rupiah, dll', + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO `kategori_bantuan` (`id`,`nama`,`deskripsi`,`satuan`) VALUES +(1,'Sembako','Bantuan bahan pokok makanan','paket'), +(2,'Sandang','Bantuan pakaian dan kebutuhan sandang','paket'), +(3,'Uang Tunai','Bantuan uang tunai langsung','rupiah'), +(4,'Obat-obatan','Bantuan obat dan layanan kesehatan','paket'), +(5,'Peralatan Sekolah','Bantuan perlengkapan pendidikan anak','paket'), +(6,'Renovasi Rumah','Bantuan material renovasi rumah tidak layak huni','paket'), +(7,'Modal Usaha','Bantuan modal untuk usaha kecil','rupiah'), +(8,'Gas & Listrik','Bantuan biaya gas dan listrik','paket'); + +-- ============================================================ +-- TABLE: laporan_masyarakat +-- ============================================================ +CREATE TABLE `laporan_masyarakat` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `kode_laporan` VARCHAR(20) NOT NULL UNIQUE COMMENT 'LPR-YYYYMMDD-XXXX', + `nama_pelapor` VARCHAR(100) NOT NULL, + `no_hp_pelapor` VARCHAR(20), + `nama_warga` VARCHAR(100) NOT NULL, + `alamat_warga` TEXT NOT NULL, + `id_kelurahan` SMALLINT UNSIGNED NOT NULL, + `lat` DECIMAL(10,7) NOT NULL, + `lng` DECIMAL(10,7) NOT NULL, + `deskripsi` TEXT, + `foto` VARCHAR(255), + `status` ENUM('menunggu','diproses','terverifikasi','ditolak') NOT NULL DEFAULT 'menunggu', + `catatan_verifikator` TEXT, + `id_verifikator` INT UNSIGNED DEFAULT NULL, + `tanggal_verifikasi` DATETIME, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `fk_lap_kel` (`id_kelurahan`), + KEY `fk_lap_verif` (`id_verifikator`), + CONSTRAINT `fk_lap_kel` FOREIGN KEY (`id_kelurahan`) REFERENCES `kelurahan` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_lap_verif` FOREIGN KEY (`id_verifikator`) REFERENCES `users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- TABLE: warga_miskin +-- ============================================================ +CREATE TABLE `warga_miskin` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `id_laporan` INT UNSIGNED DEFAULT NULL, + `id_kelurahan` SMALLINT UNSIGNED NOT NULL, + `id_rumah_ibadah` INT UNSIGNED DEFAULT NULL COMMENT 'Rumah ibadah penanggung jawab', + `id_verifikator` INT UNSIGNED DEFAULT NULL, + -- Data Pribadi + `nik` VARCHAR(16) UNIQUE, + `nama` VARCHAR(100) NOT NULL, + `tempat_lahir` VARCHAR(100), + `tanggal_lahir` DATE, + `jenis_kelamin` ENUM('L','P') DEFAULT 'L', + `agama` ENUM('Islam','Kristen','Katolik','Hindu','Buddha','Konghucu','Lainnya') DEFAULT 'Islam', + `alamat` TEXT NOT NULL, + `lat` DECIMAL(10,7) NOT NULL, + `lng` DECIMAL(10,7) NOT NULL, + `no_hp` VARCHAR(20), + `foto` VARCHAR(255), + -- Data Sosial Ekonomi + `pendidikan_terakhir` ENUM('Tidak Sekolah','SD','SMP','SMA','D3','S1','S2','S3') DEFAULT 'SD', + `pekerjaan` VARCHAR(100), + `penghasilan_bulanan` INT UNSIGNED DEFAULT 0 COMMENT 'Rupiah per bulan', + `jumlah_tanggungan` TINYINT UNSIGNED DEFAULT 0, + -- Kondisi Khusus + `kondisi_rumah` ENUM('Sangat Tidak Layak','Tidak Layak','Cukup Layak','Layak') DEFAULT 'Tidak Layak', + `riwayat_penyakit` TEXT COMMENT 'Daftar penyakit yang diderita', + `is_lansia` TINYINT(1) DEFAULT 0, + `is_disabilitas` TINYINT(1) DEFAULT 0, + `disabilitas_jenis` VARCHAR(100), + -- Status & Prioritas + `status_kemiskinan` ENUM('Miskin Ekstrem','Miskin','Rentan Miskin') DEFAULT 'Miskin', + `skor_prioritas` TINYINT UNSIGNED DEFAULT 0 COMMENT '0-100', + `level_prioritas` ENUM('Tinggi','Sedang','Rendah') DEFAULT 'Sedang', + `is_blind_spot` TINYINT(1) DEFAULT 0, + `status` ENUM('aktif','nonaktif','meninggal','pindah') DEFAULT 'aktif', + -- Timestamps + `tanggal_verifikasi` DATE, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `fk_wm_laporan` (`id_laporan`), + KEY `fk_wm_kel` (`id_kelurahan`), + KEY `fk_wm_ri` (`id_rumah_ibadah`), + KEY `fk_wm_verif` (`id_verifikator`), + KEY `idx_wm_blind_spot` (`is_blind_spot`), + KEY `idx_wm_prioritas` (`level_prioritas`), + KEY `idx_wm_status_kem` (`status_kemiskinan`), + CONSTRAINT `fk_wm_laporan` FOREIGN KEY (`id_laporan`) REFERENCES `laporan_masyarakat` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_wm_kel` FOREIGN KEY (`id_kelurahan`) REFERENCES `kelurahan` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_wm_ri` FOREIGN KEY (`id_rumah_ibadah`) REFERENCES `rumah_ibadah` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_wm_verif` FOREIGN KEY (`id_verifikator`) REFERENCES `users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- TABLE: histori_bantuan +-- ============================================================ +CREATE TABLE `histori_bantuan` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `id_warga` INT UNSIGNED NOT NULL, + `id_rumah_ibadah` INT UNSIGNED NOT NULL, + `id_kategori_bantuan` TINYINT UNSIGNED NOT NULL, + `id_user_pengurus` INT UNSIGNED NOT NULL, + `kode_bantuan` VARCHAR(20) NOT NULL UNIQUE COMMENT 'BNT-YYYYMMDD-XXXX', + `tanggal_penyaluran` DATE NOT NULL, + `jumlah` DECIMAL(12,2), + `satuan` VARCHAR(50), + `deskripsi` TEXT, + `foto_bukti` VARCHAR(255), + `status` ENUM('rencana','disalurkan','dibatalkan') DEFAULT 'disalurkan', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `fk_hb_warga` (`id_warga`), + KEY `fk_hb_ri` (`id_rumah_ibadah`), + KEY `fk_hb_kat` (`id_kategori_bantuan`), + KEY `fk_hb_user` (`id_user_pengurus`), + CONSTRAINT `fk_hb_warga` FOREIGN KEY (`id_warga`) REFERENCES `warga_miskin` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_hb_ri` FOREIGN KEY (`id_rumah_ibadah`) REFERENCES `rumah_ibadah` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_hb_kat` FOREIGN KEY (`id_kategori_bantuan`) REFERENCES `kategori_bantuan` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_hb_user` FOREIGN KEY (`id_user_pengurus`) REFERENCES `users` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- TABLE: aktivitas_sistem +-- ============================================================ +CREATE TABLE `aktivitas_sistem` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `id_user` INT UNSIGNED DEFAULT NULL, + `nama_user` VARCHAR(100) COMMENT 'Snapshot nama saat aksi', + `role` VARCHAR(30), + `aksi` VARCHAR(100) NOT NULL, + `modul` VARCHAR(50), + `deskripsi` TEXT, + `ip_address` VARCHAR(45), + `user_agent` VARCHAR(255), + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `fk_as_user` (`id_user`), + KEY `idx_as_modul` (`modul`), + KEY `idx_as_created` (`created_at`), + CONSTRAINT `fk_as_user` FOREIGN KEY (`id_user`) REFERENCES `users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- TABLE: sessions (untuk autentikasi manual) +-- ============================================================ +CREATE TABLE `sessions` ( + `id` VARCHAR(128) NOT NULL, + `id_user` INT UNSIGNED NOT NULL, + `ip_address` VARCHAR(45), + `user_agent` VARCHAR(255), + `payload` TEXT, + `last_activity` INT UNSIGNED NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_sess_user` (`id_user`), + CONSTRAINT `fk_sess_user` FOREIGN KEY (`id_user`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/docs/ERD_DOKUMENTASI.sql b/docs/ERD_DOKUMENTASI.sql new file mode 100644 index 0000000..33b8598 --- /dev/null +++ b/docs/ERD_DOKUMENTASI.sql @@ -0,0 +1,130 @@ +-- ============================================================ +-- SINERGI — Entity Relationship Diagram (ERD) Documentation +-- ============================================================ +-- +-- DIAGRAM RELASI TABEL: +-- +-- roles ─────────────────────────────┐ +-- id, nama, slug, deskripsi │ 1 +-- │ +-- users ────────────────────────────-┤ N (setiap user punya 1 role) +-- id, id_role(FK→roles), id_kelurahan(FK→kelurahan) +-- nama, username, email, password, no_hp, foto, is_active +-- last_login, created_at +-- +-- kecamatan ─────────────────────────┐ +-- id, nama, kode, lat, lng │ 1 +-- │ +-- kelurahan ─────────────────────────┤ N (banyak kelurahan per kecamatan) +-- id, id_kecamatan(FK→kecamatan) │ 1 +-- nama, kode, lat, lng │ +-- │ N +-- rumah_ibadah ──────────────────────┤ (banyak RI per kelurahan) +-- id, id_user(FK→users) │ +-- id_kelurahan(FK→kelurahan) │ +-- id_jenis(FK→jenis_rumah_ibadah) │ +-- nama, alamat, lat, lng │ +-- radius_meter, nama_ketua, foto │ 1 +-- │ +-- warga_miskin ──────────────────────┘ N (banyak warga per RI) +-- id, id_laporan(FK→laporan_masyarakat) +-- id_kelurahan(FK→kelurahan) +-- id_rumah_ibadah(FK→rumah_ibadah) +-- id_verifikator(FK→users) +-- nik, nama, tgl_lahir, jk, agama +-- alamat, lat, lng, foto +-- pendidikan_terakhir, pekerjaan +-- penghasilan_bulanan, jumlah_tanggungan +-- kondisi_rumah, riwayat_penyakit +-- is_lansia, is_disabilitas +-- status_kemiskinan, skor_prioritas +-- level_prioritas, is_blind_spot +-- status, tanggal_verifikasi +-- +-- laporan_masyarakat ────────────────┐ +-- id, kode_laporan │ 1 +-- nama_pelapor, no_hp_pelapor │ +-- nama_warga, alamat_warga │ +-- id_kelurahan(FK→kelurahan) │ +-- lat, lng, deskripsi, foto │ +-- status, catatan_verifikator │ +-- id_verifikator(FK→users) │ +-- tanggal_verifikasi │ +-- │ (1 laporan → 1 warga) +-- histori_bantuan ───────────────────────────────── +-- id, id_warga(FK→warga_miskin) +-- id_rumah_ibadah(FK→rumah_ibadah) +-- id_kategori_bantuan(FK→kategori_bantuan) +-- id_user_pengurus(FK→users) +-- kode_bantuan, tanggal_penyaluran +-- jumlah, satuan, deskripsi, foto_bukti +-- status +-- +-- kategori_bantuan ────────────────── +-- id, nama, deskripsi, satuan, is_active +-- +-- jenis_rumah_ibadah ──────────────── +-- id, nama, ikon +-- +-- aktivitas_sistem ────────────────── +-- id, id_user(FK→users), nama_user +-- role, aksi, modul, deskripsi +-- ip_address, user_agent, created_at +-- +-- sessions ────────────────────────── +-- id, id_user(FK→users) +-- ip_address, user_agent +-- payload, last_activity +-- +-- ============================================================ +-- RELASI KARDINALITAS: +-- ============================================================ +-- +-- roles 1 ──── N users +-- kecamatan 1 ──── N kelurahan +-- kelurahan 1 ──── N rumah_ibadah +-- kelurahan 1 ──── N users (verifikator) +-- kelurahan 1 ──── N warga_miskin +-- kelurahan 1 ──── N laporan_masyarakat +-- rumah_ibadah 1 ──── N warga_miskin (binaan) +-- rumah_ibadah 1 ──── N histori_bantuan +-- warga_miskin 1 ──── N histori_bantuan +-- laporan 1 ──── 1 warga_miskin (opsional) +-- kategori 1 ──── N histori_bantuan +-- users 1 ──── 1 rumah_ibadah (pengurus) +-- users 1 ──── N aktivitas_sistem +-- users 1 ──── N laporan (verifikator) +-- users 1 ──── N warga_miskin (verifikator) +-- jenis_ri 1 ──── N rumah_ibadah +-- +-- ============================================================ +-- PROSES BISNIS UTAMA: +-- ============================================================ +-- +-- [1] PELAPORAN: +-- Masyarakat → POST /lapor → laporan_masyarakat (status=menunggu) +-- +-- [2] VERIFIKASI: +-- Verifikator → verifikasi laporan → +-- UPDATE laporan status=terverifikasi + +-- INSERT warga_miskin +-- +-- [3] ASSIGNMENT RI (Haversine): +-- Saat INSERT warga_miskin: +-- - Loop semua rumah_ibadah aktif +-- - Hitung jarak Haversine(warga.lat,warga.lng, ri.lat,ri.lng) +-- - Jika jarak <= radius_meter → assign id_rumah_ibadah +-- - Jika tidak ada → is_blind_spot = 1 +-- +-- [4] SKOR PRIORITAS (0-100): +-- penghasilan(30) + tanggungan(20) + kondisi_rumah(15) + +-- pendidikan(10) + penyakit(10) + lansia(8) + disabilitas(7) +-- → level: Tinggi(≥70), Sedang(≥40), Rendah(<40) +-- +-- [5] PENYALURAN BANTUAN: +-- Pengurus RI → INSERT histori_bantuan +-- +-- [6] MONITORING: +-- Dinsos/Wali Kota → READ ONLY semua data +-- GIS Heatmap dari koordinat warga_miskin +-- ============================================================ diff --git a/docs/PANDUAN_INSTALASI.md b/docs/PANDUAN_INSTALASI.md new file mode 100644 index 0000000..b92b5d5 --- /dev/null +++ b/docs/PANDUAN_INSTALASI.md @@ -0,0 +1,298 @@ +# PANDUAN INSTALASI SINERGI +## Sistem Informasi Geografis Penanganan Kemiskinan — Kota Pontianak + +--- + +## PRASYARAT SISTEM + +Pastikan software berikut sudah terinstal: + +| Software | Versi | Keterangan | +|----------|-------|------------| +| Laragon | 6.0+ | Full / Lite | +| PHP | 8.1+ | Sudah termasuk dalam Laragon | +| MySQL | 8.0+ | Sudah termasuk dalam Laragon | +| Apache | 2.4+ | Sudah termasuk dalam Laragon | + +--- + +## LANGKAH INSTALASI + +### Step 1: Copy Folder Proyek + +``` +Salin folder `sinergi/` ke: +C:\laragon\www\sinergi\ +``` + +Pastikan struktur folder seperti ini: +``` +C:\laragon\www\sinergi\ +├── app\ +│ ├── controllers\ +│ ├── models\ +│ └── views\ +├── config\ +├── core\ +├── public\ +├── database\ +├── index.php +└── .htaccess +``` + +--- + +### Step 2: Aktifkan mod_rewrite Apache + +1. Buka **Laragon** → klik kanan tray icon +2. Pilih **Apache** → **httpd.conf** +3. Cari baris: `#LoadModule rewrite_module modules/mod_rewrite.so` +4. Hapus tanda `#` di depannya +5. Simpan file +6. Restart Laragon + +--- + +### Step 3: Import Database + +#### Cara A — Melalui phpMyAdmin (Disarankan) +1. Buka browser: `http://localhost/phpmyadmin` +2. Klik **"Import"** di menu atas +3. Pilih file `database/sinergi.sql` → klik **Go** +4. Setelah selesai, pilih file `database/dummy_data.sql` → klik **Go** + +#### Cara B — Melalui Command Line +```bash +# Buka Command Prompt di folder sinergi +cd C:\laragon\www\sinergi + +# Import schema +mysql -u root -p < database\sinergi.sql + +# Import dummy data +mysql -u root -p sinergi_db < database\dummy_data.sql +``` + +--- + +### Step 4: Konfigurasi Aplikasi + +Edit file `config/database.php`: +```php +define('DB_HOST', 'localhost'); +define('DB_NAME', 'sinergi_db'); +define('DB_USER', 'root'); +define('DB_PASS', ''); // Sesuaikan password MySQL Anda +define('DB_CHARSET', 'utf8mb4'); +``` + +Edit file `config/app.php` jika nama folder berbeda: +```php +define('APP_URL', 'http://localhost/sinergi'); // Sesuaikan +``` + +--- + +### Step 5: Buat Folder Upload + +```bash +# Pastikan folder ini ada dan bisa ditulis +C:\laragon\www\sinergi\public\images\uploads\ +C:\laragon\www\sinergi\public\images\laporan\ +C:\laragon\www\sinergi\public\images\warga\ +C:\laragon\www\sinergi\public\images\bantuan\ +C:\laragon\www\sinergi\public\images\rumah_ibadah\ +``` + +Atau jalankan script ini di Command Prompt: +```batch +mkdir C:\laragon\www\sinergi\public\images\uploads +mkdir C:\laragon\www\sinergi\public\images\laporan +mkdir C:\laragon\www\sinergi\public\images\warga +mkdir C:\laragon\www\sinergi\public\images\bantuan +mkdir C:\laragon\www\sinergi\public\images\rumah_ibadah +``` + +--- + +### Step 6: Jalankan Aplikasi + +1. **Start Laragon** (klik tombol Start All) +2. Buka browser: `http://localhost/sinergi` +3. Halaman beranda publik akan tampil + +--- + +## AKUN LOGIN DEMO + +Semua akun menggunakan password: **Admin@123** + +| Role | Username | Password | Akses | +|------|----------|----------|-------| +| Super Admin | `superadmin` | Admin@123 | Semua fitur admin | +| Verifikator (Kel. Darat Sekip) | `verif_darat` | Admin@123 | Verifikasi laporan | +| Verifikator (Kel. Sungai Jawi) | `verif_jawi` | Admin@123 | Verifikasi laporan | +| Pengurus Masjid Darussalam | `ri_masjid_darussalam` | Admin@123 | Penyaluran bantuan | +| Pengurus Gereja Bethel | `ri_gereja_bethel` | Admin@123 | Penyaluran bantuan | +| Pengurus Masjid Al-Falah | `ri_masjid_alfalah` | Admin@123 | Penyaluran bantuan | +| Dinas Sosial | `dinsos_ahmad` | Admin@123 | Monitoring | +| Wali Kota | `walikota` | Admin@123 | Dashboard Eksekutif | + +--- + +## URL AKSES + +| Halaman | URL | +|---------|-----| +| Beranda Publik | `http://localhost/sinergi/` | +| Form Laporan | `http://localhost/sinergi/lapor` | +| Cek Laporan | `http://localhost/sinergi/cek-laporan` | +| Login | `http://localhost/sinergi/login` | +| Dashboard Admin | `http://localhost/sinergi/admin/dashboard` | +| Dashboard Verifikator | `http://localhost/sinergi/verifikator/dashboard` | +| Dashboard Rumah Ibadah | `http://localhost/sinergi/rumah-ibadah/dashboard` | +| Dashboard Dinsos | `http://localhost/sinergi/dinsos/dashboard` | +| Dashboard Wali Kota | `http://localhost/sinergi/walikota/dashboard` | + +--- + +## TROUBLESHOOTING + +### Error 404 (Halaman tidak ditemukan) +- Pastikan `mod_rewrite` sudah aktif di Apache +- Cek file `.htaccess` ada di root folder `sinergi/` +- Pastikan `AllowOverride All` aktif di `httpd.conf`: + ``` + + AllowOverride All + + ``` + +### Error database connection +- Pastikan MySQL running di Laragon +- Cek konfigurasi di `config/database.php` +- Pastikan database `sinergi_db` sudah diimport + +### Peta Leaflet tidak muncul +- Pastikan koneksi internet aktif (Leaflet CDN) +- Cek console browser (F12) untuk error JavaScript + +### Upload foto gagal +- Pastikan folder `public/images/uploads/` ada dan writable +- Cek konfigurasi `upload_max_filesize` di `php.ini` (minimal 5M) + +### Halaman putih kosong +- Aktifkan error reporting: tambah di awal `index.php`: + ```php + ini_set('display_errors', 1); + error_reporting(E_ALL); + ``` + +--- + +## STRUKTUR FOLDER LENGKAP + +``` +sinergi/ +├── .htaccess # URL Rewriting +├── index.php # Entry point & Router +│ +├── config/ +│ ├── app.php # Konfigurasi aplikasi +│ └── database.php # Konfigurasi database +│ +├── core/ +│ ├── Auth.php # Autentikasi & session +│ ├── Controller.php # Base controller +│ ├── Database.php # Koneksi database (PDO) +│ ├── Router.php # URL Router +│ └── helpers.php # Helper functions (Haversine, skor, dll) +│ +├── app/ +│ ├── controllers/ +│ │ ├── AdminController.php +│ │ ├── AuthController.php +│ │ ├── DinsosController.php +│ │ ├── PublicController.php +│ │ ├── RumahIbadahController.php +│ │ ├── VerifikatorController.php +│ │ └── WalikotaController.php +│ │ +│ └── views/ +│ ├── layouts/ +│ │ ├── admin.php +│ │ ├── dinsos.php +│ │ ├── public.php +│ │ ├── rumah_ibadah.php +│ │ ├── verifikator.php +│ │ └── walikota.php +│ ├── admin/ # View Super Admin +│ ├── auth/ # Login +│ ├── dinsos/ # View Dinsos +│ ├── public/ # View Publik +│ ├── rumah_ibadah/ # View Pengurus RI +│ ├── verifikator/ # View Verifikator +│ └── walikota/ # View Wali Kota +│ +├── public/ +│ ├── css/app.css # Stylesheet utama +│ ├── js/app.js # JavaScript utama (GIS, Charts) +│ └── images/ # Gambar & upload +│ +└── database/ + ├── sinergi.sql # Schema + data roles/kecamatan + └── dummy_data.sql # Data dummy untuk testing +``` + +--- + +## TEKNOLOGI YANG DIGUNAKAN + +| Komponen | Teknologi | Versi | +|----------|-----------|-------| +| Frontend | HTML5, CSS3 Native, JavaScript | — | +| Peta GIS | Leaflet.js | 1.9.4 | +| Heatmap | Leaflet.heat | 0.2.0 | +| Grafik | Chart.js | 4.4.0 | +| Font | Inter (Google Fonts) | — | +| Backend | PHP Native (tanpa framework) | 8.1+ | +| Database | MySQL | 8.0+ | +| Arsitektur | MVC Sederhana + RBAC | — | +| Environment | Laragon | 6.0+ | + +--- + +## FITUR UTAMA + +### 🗺 Sistem GIS +- Peta interaktif Leaflet.js +- Marker warga miskin (merah=ekstrem, oranye=miskin, kuning=rentan) +- Marker & radius rumah ibadah (hijau) +- Heatmap persebaran kemiskinan +- Layer control (beralih antara tampilan) +- Popup informasi detail + +### 📊 Algoritma Prioritas Bantuan +Skor 0-100 berdasarkan 7 faktor: +1. **Penghasilan** (30 poin) — semakin rendah = skor lebih tinggi +2. **Jumlah tanggungan** (20 poin) +3. **Kondisi rumah** (15 poin) +4. **Pendidikan** (10 poin) +5. **Riwayat penyakit** (10 poin) +6. **Status lansia** (8 poin) +7. **Status disabilitas** (7 poin) + +### 🔍 Deteksi Blind Spot +- Menggunakan **Haversine Formula** untuk menghitung jarak +- Jika warga tidak berada dalam radius rumah ibadah manapun → **Blind Spot** +- Blind spot otomatis muncul di dashboard Dinsos & Admin + +### ⛪ Assignment Rumah Ibadah +- Otomatis dihitung saat verifikasi warga +- Menggunakan Haversine Formula mencari rumah ibadah dalam radius terdekat +- 1 warga = 1 rumah ibadah penanggung jawab + +--- + +*SINERGI v1.0.0 — Sistem Informasi Geografis Penanganan Kemiskinan* +*Kota Pontianak — © 2024 Dinas Sosial Kota Pontianak* diff --git a/index.php b/index.php new file mode 100644 index 0000000..ddf4e3b --- /dev/null +++ b/index.php @@ -0,0 +1,127 @@ +get('/', [PublicController::class, 'beranda']); +$router->get('/tentang', [PublicController::class, 'tentang']); +$router->get('/kontak', [PublicController::class, 'kontak']); +$router->get('/lapor', [PublicController::class, 'lapor']); +$router->post('/lapor', [PublicController::class, 'kirimLaporan']); +$router->get('/cek-laporan', [PublicController::class, 'cekLaporan']); +$router->post('/cek-laporan', [PublicController::class, 'proseCekLaporan']); + +// API publik (JSON) +$router->get('/api/kelurahan/{id}', [PublicController::class, 'apiKelurahan']); +$router->get('/api/rumah-ibadah', [PublicController::class, 'apiRumahIbadah']); +$router->get('/api/warga-peta', [PublicController::class, 'apiWargaPeta']); + +// ============================================================ +// AUTH ROUTES +// ============================================================ +$router->get('/login', [AuthController::class, 'showLogin']); +$router->post('/login', [AuthController::class, 'doLogin']); +$router->get('/logout', [AuthController::class, 'logout']); +$router->get('/forbidden', [AuthController::class, 'forbidden']); + +// ============================================================ +// SUPER ADMIN ROUTES +// ============================================================ +$router->get('/admin/dashboard', [AdminController::class, 'dashboard']); +$router->get('/admin/users', [AdminController::class, 'users']); +$router->post('/admin/users/store', [AdminController::class, 'storeUser']); +$router->post('/admin/users/{id}/update', [AdminController::class, 'updateUser']); +$router->post('/admin/users/{id}/reset-password', [AdminController::class, 'resetUserPassword']); +$router->post('/admin/users/{id}/delete', [AdminController::class, 'deleteUser']); +$router->get('/admin/rumah-ibadah', [AdminController::class, 'rumahIbadah']); +$router->post('/admin/rumah-ibadah/store', [AdminController::class, 'storeRumahIbadah']); +$router->post('/admin/rumah-ibadah/{id}/update', [AdminController::class, 'updateRumahIbadah']); +$router->post('/admin/rumah-ibadah/{id}/delete', [AdminController::class, 'deleteRumahIbadah']); +$router->get('/admin/wilayah', [AdminController::class, 'wilayah']); +$router->post('/admin/kecamatan/store', [AdminController::class, 'storeKecamatan']); +$router->post('/admin/kelurahan/store', [AdminController::class, 'storeKelurahan']); +$router->get('/admin/kategori-bantuan', [AdminController::class, 'kategoriBantuan']); +$router->post('/admin/kategori-bantuan/store', [AdminController::class, 'storeKategoriBantuan']); +$router->post('/admin/kategori-bantuan/{id}/update', [AdminController::class, 'updateKategoriBantuan']); +$router->get('/admin/monitoring', [AdminController::class, 'monitoring']); +$router->get('/admin/blind-spot', [AdminController::class, 'blindSpot']); +$router->get('/admin/aktivitas', [AdminController::class, 'aktivitas']); +$router->get('/admin/api/stats', [AdminController::class, 'apiStats']); +$router->get('/admin/api/peta', [AdminController::class, 'apiPeta']); + +// ============================================================ +// VERIFIKATOR ROUTES +// ============================================================ +$router->get('/verifikator/dashboard', [VerifikatorController::class, 'dashboard']); +$router->get('/verifikator/laporan', [VerifikatorController::class, 'laporan']); +$router->get('/verifikator/laporan/{id}', [VerifikatorController::class, 'detailLaporan']); +$router->post('/verifikator/laporan/{id}/proses', [VerifikatorController::class, 'prosesLaporan']); +$router->get('/verifikator/warga', [VerifikatorController::class, 'warga']); +$router->get('/verifikator/warga/tambah', [VerifikatorController::class, 'formTambahWarga']); +$router->get('/verifikator/warga/{id}', [VerifikatorController::class, 'detailWarga']); +$router->post('/verifikator/warga/store', [VerifikatorController::class, 'storeWarga']); +$router->post('/verifikator/warga/{id}/update',[VerifikatorController::class, 'updateWarga']); +$router->get('/verifikator/api/stats', [VerifikatorController::class, 'apiStats']); + +// ============================================================ +// RUMAH IBADAH ROUTES +// ============================================================ +$router->get('/rumah-ibadah/dashboard', [RumahIbadahController::class, 'dashboard']); +$router->get('/rumah-ibadah/warga', [RumahIbadahController::class, 'warga']); +$router->get('/rumah-ibadah/warga/{id}', [RumahIbadahController::class, 'detailWarga']); +$router->get('/rumah-ibadah/prioritas', [RumahIbadahController::class, 'prioritas']); +$router->get('/rumah-ibadah/bantuan', [RumahIbadahController::class, 'bantuan']); +$router->get('/rumah-ibadah/bantuan/tambah', [RumahIbadahController::class, 'formBantuan']); +$router->post('/rumah-ibadah/bantuan/store', [RumahIbadahController::class, 'storeBantuan']); +$router->get('/rumah-ibadah/api/peta', [RumahIbadahController::class, 'apiPeta']); + +// ============================================================ +// DINSOS ROUTES +// ============================================================ +$router->get('/dinsos/dashboard', [DinsosController::class, 'dashboard']); +$router->get('/dinsos/kemiskinan', [DinsosController::class, 'kemiskinan']); +$router->get('/dinsos/bantuan', [DinsosController::class, 'bantuan']); +$router->get('/dinsos/blind-spot', [DinsosController::class, 'blindSpot']); +$router->post('/dinsos/blind-spot/{id}/assign', [DinsosController::class, 'assignBlindSpot']); +$router->get('/dinsos/kinerja', [DinsosController::class, 'kinerja']); +$router->get('/dinsos/laporan', [DinsosController::class, 'laporan']); +$router->get('/dinsos/akun', [DinsosController::class, 'akun']); +$router->post('/dinsos/akun/store', [DinsosController::class, 'storeAkun']); +$router->post('/dinsos/akun/{id}/update', [DinsosController::class, 'updateAkun']); +$router->post('/dinsos/akun/{id}/reset-password', [DinsosController::class, 'resetAkunPassword']); +$router->post('/dinsos/akun/{id}/deactivate', [DinsosController::class, 'deactivateAkun']); +$router->get('/dinsos/api/heatmap', [DinsosController::class, 'apiHeatmap']); +$router->get('/dinsos/api/stats', [DinsosController::class, 'apiStats']); + +// ============================================================ +// WALIKOTA ROUTES +// ============================================================ +$router->get('/walikota/dashboard', [WalikotaController::class, 'dashboard']); +$router->get('/walikota/api/stats', [WalikotaController::class, 'apiStats']); +$router->get('/walikota/api/peta', [WalikotaController::class, 'apiPeta']); + +// Dispatch +$router->dispatch(); \ No newline at end of file diff --git a/public/css/app.css b/public/css/app.css new file mode 100644 index 0000000..c361e63 --- /dev/null +++ b/public/css/app.css @@ -0,0 +1,510 @@ +/* ============================================================ + SINERGI — Main Stylesheet + Sistem Informasi Geografis Penanganan Kemiskinan + Kota Pontianak + ============================================================ */ + +/* --- CSS Variables --- */ +:root { + --clr-primary: #1a3a5c; + --clr-primary-d: #112845; + --clr-primary-l: #2a5c8e; + --clr-accent: #e8a020; + --clr-accent-d: #c87d10; + --clr-success: #16a34a; + --clr-danger: #dc2626; + --clr-warning: #d97706; + --clr-info: #0284c7; + --clr-bg: #f0f4f8; + --clr-surface: #ffffff; + --clr-border: #d1dce8; + --clr-text: #1e2d3d; + --clr-text-muted: #5a6e82; + --clr-sidebar-bg: #112845; + --clr-sidebar-hover: rgba(255,255,255,0.08); + --clr-sidebar-active: rgba(232,160,32,0.18); + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --shadow-sm: 0 1px 4px rgba(0,0,0,0.08); + --shadow-md: 0 4px 16px rgba(0,0,0,0.10); + --shadow-lg: 0 8px 32px rgba(0,0,0,0.14); + --sidebar-w: 260px; + --topbar-h: 62px; + --font-main: 'Inter', system-ui, -apple-system, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + --transition: all 0.2s ease; +} + +/* --- Reset & Base --- */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { font-size: 15px; scroll-behavior: smooth; } +body { + font-family: var(--font-main); + background: var(--clr-bg); + color: var(--clr-text); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} +a { color: var(--clr-primary-l); text-decoration: none; } +a:hover { color: var(--clr-accent); } +img { max-width: 100%; display: block; } +button, input, select, textarea { font-family: inherit; font-size: inherit; } + +/* --- Layout: App Shell --- */ +.app-shell { + display: grid; + grid-template-columns: var(--sidebar-w) 1fr; + grid-template-rows: var(--topbar-h) 1fr; + min-height: 100vh; +} + +/* --- Sidebar --- */ +.sidebar { + grid-row: 1 / -1; + background: var(--clr-sidebar-bg); + color: #fff; + display: flex; + flex-direction: column; + overflow-y: auto; + position: sticky; + top: 0; + height: 100vh; + z-index: 100; + transition: var(--transition); +} +.sidebar-brand { + padding: 20px 20px 16px; + border-bottom: 1px solid rgba(255,255,255,0.08); + display: flex; + align-items: center; + gap: 12px; +} +.sidebar-brand-icon { + width: 40px; height: 40px; + background: var(--clr-accent); + border-radius: var(--radius-sm); + display: flex; align-items: center; justify-content: center; + font-size: 20px; flex-shrink: 0; +} +.sidebar-brand-text h1 { + font-size: 1.1rem; font-weight: 700; color: #fff; line-height: 1.2; +} +.sidebar-brand-text p { font-size: 0.68rem; color: rgba(255,255,255,0.5); line-height: 1.3; } + +.sidebar-section { padding: 20px 12px 4px; } +.sidebar-section-title { + font-size: 0.65rem; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.1em; + color: rgba(255,255,255,0.35); + padding: 0 8px; margin-bottom: 4px; +} +.sidebar-nav { list-style: none; } +.sidebar-nav li { margin-bottom: 2px; } +.sidebar-nav a { + display: flex; align-items: center; gap: 10px; + padding: 9px 12px; border-radius: var(--radius-sm); + color: rgba(255,255,255,0.78); font-size: 0.875rem; font-weight: 500; + transition: var(--transition); cursor: pointer; +} +.sidebar-nav a:hover { background: var(--clr-sidebar-hover); color: #fff; } +.sidebar-nav a.active { + background: var(--clr-sidebar-active); + color: var(--clr-accent); font-weight: 600; + border-left: 3px solid var(--clr-accent); +} +.sidebar-nav a .nav-icon { width: 18px; text-align: center; flex-shrink: 0; font-size: 1rem; } +.sidebar-nav a .nav-badge { + margin-left: auto; background: var(--clr-danger); + color: #fff; font-size: 0.65rem; font-weight: 700; + padding: 1px 6px; border-radius: 20px; min-width: 18px; text-align: center; +} + +.sidebar-footer { + margin-top: auto; padding: 16px 12px; + border-top: 1px solid rgba(255,255,255,0.08); +} +.sidebar-user { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; border-radius: var(--radius-sm); + background: rgba(255,255,255,0.06); +} +.sidebar-user-avatar { + width: 34px; height: 34px; border-radius: 50%; + background: var(--clr-primary-l); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 0.85rem; color: #fff; flex-shrink: 0; +} +.sidebar-user-info { flex: 1; min-width: 0; } +.sidebar-user-name { font-size: 0.8rem; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sidebar-user-role { font-size: 0.68rem; color: rgba(255,255,255,0.45); } +.sidebar-logout { color: rgba(255,255,255,0.5); font-size: 1rem; cursor: pointer; transition: var(--transition); } +.sidebar-logout:hover { color: var(--clr-danger); } + +/* --- Topbar --- */ +.topbar { + grid-column: 2; + background: var(--clr-surface); + border-bottom: 1px solid var(--clr-border); + display: flex; align-items: center; gap: 16px; + padding: 0 28px; + box-shadow: var(--shadow-sm); + position: sticky; top: 0; z-index: 50; +} +.topbar-title { font-size: 1rem; font-weight: 600; color: var(--clr-text); flex: 1; } +.topbar-breadcrumb { font-size: 0.8rem; color: var(--clr-text-muted); } +.topbar-actions { display: flex; align-items: center; gap: 10px; margin-left: auto; } +.topbar-time { font-size: 0.8rem; color: var(--clr-text-muted); font-family: var(--font-mono); } + +/* --- Main Content --- */ +.main-content { + grid-column: 2; + padding: 24px 28px; + overflow-x: hidden; +} + +/* --- Cards --- */ +.card { + background: var(--clr-surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + border: 1px solid var(--clr-border); + overflow: hidden; +} +.card-header { + padding: 16px 20px; + border-bottom: 1px solid var(--clr-border); + display: flex; align-items: center; gap: 10px; +} +.card-header h3 { font-size: 0.95rem; font-weight: 600; color: var(--clr-text); } +.card-header .card-icon { font-size: 1.1rem; } +.card-header .card-actions { margin-left: auto; display: flex; gap: 8px; } +.card-body { padding: 20px; } +.card-body.no-pad { padding: 0; } + +/* --- Stat Cards --- */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} +.stat-card { + background: var(--clr-surface); + border-radius: var(--radius-md); + padding: 18px 20px; + border: 1px solid var(--clr-border); + box-shadow: var(--shadow-sm); + display: flex; align-items: flex-start; gap: 14px; + transition: var(--transition); +} +.stat-card:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); } +.stat-card-icon { + width: 44px; height: 44px; border-radius: var(--radius-sm); + display: flex; align-items: center; justify-content: center; + font-size: 1.3rem; flex-shrink: 0; +} +.stat-card-icon.blue { background: #dbeafe; color: #1d4ed8; } +.stat-card-icon.red { background: #fee2e2; color: #dc2626; } +.stat-card-icon.orange { background: #ffedd5; color: #c2410c; } +.stat-card-icon.yellow { background: #fef9c3; color: #a16207; } +.stat-card-icon.green { background: #dcfce7; color: #15803d; } +.stat-card-icon.purple { background: #f3e8ff; color: #7e22ce; } +.stat-card-icon.teal { background: #ccfbf1; color: #0f766e; } +.stat-card-icon.gray { background: #f1f5f9; color: #475569; } +.stat-card-body { flex: 1; min-width: 0; } +.stat-card-value { font-size: 1.75rem; font-weight: 700; line-height: 1; color: var(--clr-text); } +.stat-card-label { font-size: 0.78rem; color: var(--clr-text-muted); margin-top: 3px; } +.stat-card-sub { font-size: 0.72rem; color: var(--clr-text-muted); margin-top: 6px; } + +/* --- Map Container --- */ +.map-container { + border-radius: var(--radius-md); + overflow: hidden; + border: 1px solid var(--clr-border); + box-shadow: var(--shadow-sm); + position: relative; +} +.map-container #map, .map-container .map-inner { + width: 100%; border-radius: 0; +} + +/* --- Dashboard Grid --- */ +.dashboard-grid { + display: grid; + grid-template-columns: 1fr 340px; + gap: 20px; +} +.dashboard-grid.full { grid-template-columns: 1fr; } +@media (max-width: 1100px) { + .dashboard-grid { grid-template-columns: 1fr; } +} + +/* --- Tables --- */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } +thead th { + background: #f8fafc; color: var(--clr-text-muted); + font-size: 0.72rem; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.05em; padding: 10px 14px; + border-bottom: 1px solid var(--clr-border); white-space: nowrap; +} +tbody td { padding: 11px 14px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; } +tbody tr:last-child td { border-bottom: none; } +tbody tr:hover { background: #f8fafc; } + +/* --- Badges --- */ +.badge { + display: inline-flex; align-items: center; justify-content: center; + padding: 2px 9px; border-radius: 20px; + font-size: 0.7rem; font-weight: 700; letter-spacing: 0.02em; white-space: nowrap; +} +.badge-red { background: #fee2e2; color: #dc2626; } +.badge-orange { background: #ffedd5; color: #c2410c; } +.badge-yellow { background: #fef9c3; color: #a16207; } +.badge-green { background: #dcfce7; color: #15803d; } +.badge-blue { background: #dbeafe; color: #1d4ed8; } +.badge-gray { background: #f1f5f9; color: #475569; } +.badge-purple { background: #f3e8ff; color: #7e22ce; } + +/* --- Buttons --- */ +.btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 8px 16px; border-radius: var(--radius-sm); + font-size: 0.875rem; font-weight: 600; cursor: pointer; + border: none; transition: var(--transition); white-space: nowrap; +} +.btn-primary { background: var(--clr-primary); color: #fff; } +.btn-primary:hover { background: var(--clr-primary-l); } +.btn-accent { background: var(--clr-accent); color: var(--clr-primary-d); } +.btn-accent:hover { background: var(--clr-accent-d); } +.btn-success { background: var(--clr-success); color: #fff; } +.btn-success:hover { background: #15803d; } +.btn-danger { background: var(--clr-danger); color: #fff; } +.btn-danger:hover { background: #b91c1c; } +.btn-ghost { background: transparent; color: var(--clr-text-muted); border: 1px solid var(--clr-border); } +.btn-ghost:hover { background: var(--clr-bg); color: var(--clr-text); } +.btn-sm { padding: 5px 11px; font-size: 0.78rem; } +.btn-icon { padding: 7px; } + +/* --- Forms --- */ +.form-group { margin-bottom: 16px; } +.form-label { display: block; font-size: 0.82rem; font-weight: 600; color: var(--clr-text); margin-bottom: 5px; } +.form-label .required { color: var(--clr-danger); margin-left: 2px; } +.form-control { + width: 100%; padding: 9px 13px; + border: 1px solid var(--clr-border); border-radius: var(--radius-sm); + background: var(--clr-surface); color: var(--clr-text); font-size: 0.875rem; + transition: var(--transition); +} +.form-control:focus { outline: none; border-color: var(--clr-primary-l); box-shadow: 0 0 0 3px rgba(42,92,142,0.12); } +.form-hint { font-size: 0.75rem; color: var(--clr-text-muted); margin-top: 4px; } +.form-row { display: grid; gap: 16px; } +.form-row.cols-2 { grid-template-columns: 1fr 1fr; } +.form-row.cols-3 { grid-template-columns: 1fr 1fr 1fr; } + +/* --- Flash Messages --- */ +.flash-message { + display: flex; align-items: flex-start; gap: 10px; + padding: 12px 16px; border-radius: var(--radius-sm); + margin-bottom: 20px; font-size: 0.875rem; font-weight: 500; +} +.flash-success { background: #dcfce7; color: #15803d; border-left: 4px solid #16a34a; } +.flash-error { background: #fee2e2; color: #dc2626; border-left: 4px solid #dc2626; } +.flash-warning { background: #fef9c3; color: #a16207; border-left: 4px solid #d97706; } +.flash-info { background: #dbeafe; color: #1d4ed8; border-left: 4px solid #0284c7; } + +/* --- Priority Score Bar --- */ +.priority-bar { position: relative; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; } +.priority-bar-fill { height: 100%; border-radius: 3px; transition: width 0.4s ease; } +.priority-bar-fill.high { background: var(--clr-danger); } +.priority-bar-fill.medium { background: var(--clr-warning); } +.priority-bar-fill.low { background: var(--clr-success); } + +/* --- Map Legend --- */ +.map-legend { + position: absolute; bottom: 28px; left: 12px; z-index: 1000; + background: var(--clr-surface); border-radius: var(--radius-sm); + box-shadow: var(--shadow-md); padding: 12px 14px; + border: 1px solid var(--clr-border); min-width: 160px; +} +.map-legend h4 { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--clr-text-muted); margin-bottom: 8px; } +.map-legend-item { display: flex; align-items: center; gap: 8px; font-size: 0.78rem; margin-bottom: 5px; } +.map-legend-dot { width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0; } +.legend-dot-ekstrem { background: #dc2626; } +.legend-dot-miskin { background: #ea580c; } +.legend-dot-rentan { background: #ca8a04; } +.legend-dot-ri { background: #16a34a; } +.legend-dot-blind { background: #7c3aed; } +.legend-dot-laporan { background: #0284c7; } + +/* --- Tabs --- */ +.tabs { display: flex; gap: 2px; border-bottom: 2px solid var(--clr-border); margin-bottom: 20px; } +.tab-btn { + padding: 9px 18px; font-size: 0.875rem; font-weight: 600; + color: var(--clr-text-muted); border: none; background: none; cursor: pointer; + border-bottom: 2px solid transparent; margin-bottom: -2px; transition: var(--transition); +} +.tab-btn.active { color: var(--clr-primary); border-bottom-color: var(--clr-primary); } +.tab-btn:hover:not(.active) { color: var(--clr-text); } + +/* --- Filters --- */ +.filters-bar { + display: flex; gap: 10px; flex-wrap: wrap; align-items: center; + margin-bottom: 18px; padding: 14px 16px; + background: var(--clr-surface); border: 1px solid var(--clr-border); + border-radius: var(--radius-sm); +} +.filters-bar select, .filters-bar input { + padding: 7px 11px; border: 1px solid var(--clr-border); border-radius: var(--radius-sm); + background: var(--clr-surface); font-size: 0.85rem; color: var(--clr-text); +} + +/* --- Modal --- */ +.modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.48); + z-index: 1000; display: flex; align-items: center; justify-content: center; + padding: 20px; opacity: 0; pointer-events: none; transition: opacity 0.2s; +} +.modal-overlay.active { opacity: 1; pointer-events: all; } +.modal { + background: var(--clr-surface); border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); width: 100%; max-width: 560px; + max-height: 90vh; overflow-y: auto; + transform: translateY(20px); transition: transform 0.2s; +} +.modal-overlay.active .modal { transform: translateY(0); } +.modal-header { + padding: 20px 24px; border-bottom: 1px solid var(--clr-border); + display: flex; align-items: center; justify-content: space-between; +} +.modal-header h3 { font-size: 1rem; font-weight: 700; } +.modal-close { cursor: pointer; color: var(--clr-text-muted); font-size: 1.3rem; border: none; background: none; } +.modal-body { padding: 24px; } +.modal-footer { padding: 16px 24px; border-top: 1px solid var(--clr-border); display: flex; justify-content: flex-end; gap: 10px; } + +/* --- Empty State --- */ +.empty-state { + text-align: center; padding: 48px 24px; color: var(--clr-text-muted); +} +.empty-state-icon { font-size: 3rem; margin-bottom: 12px; opacity: 0.4; } +.empty-state h3 { font-size: 1rem; font-weight: 600; margin-bottom: 6px; } +.empty-state p { font-size: 0.875rem; } + +/* --- Pagination --- */ +.pagination { display: flex; gap: 4px; align-items: center; justify-content: center; margin-top: 20px; } +.page-btn { + padding: 6px 11px; border-radius: var(--radius-sm); + font-size: 0.82rem; font-weight: 600; cursor: pointer; + border: 1px solid var(--clr-border); background: var(--clr-surface); color: var(--clr-text-muted); + transition: var(--transition); +} +.page-btn:hover, .page-btn.active { background: var(--clr-primary); color: #fff; border-color: var(--clr-primary); } + +/* --- Chart Container --- */ +.chart-wrap { position: relative; height: 240px; } + +/* --- Sidebar list panel --- */ +.sidebar-panel { + height: 100%; overflow-y: auto; + border-left: 1px solid var(--clr-border); + background: var(--clr-surface); +} +.sidebar-panel-header { + padding: 16px 20px; border-bottom: 1px solid var(--clr-border); + position: sticky; top: 0; background: var(--clr-surface); z-index: 10; +} +.sidebar-panel-item { + padding: 12px 20px; border-bottom: 1px solid #f1f5f9; cursor: pointer; + transition: var(--transition); +} +.sidebar-panel-item:hover { background: #f8fafc; } +.sidebar-panel-item.active { background: #eff6ff; border-left: 3px solid var(--clr-primary); } +.sidebar-panel-item h4 { font-size: 0.85rem; font-weight: 600; } +.sidebar-panel-item p { font-size: 0.75rem; color: var(--clr-text-muted); margin-top: 2px; } + +/* --- Public layout --- */ +.public-nav { + position: sticky; top: 0; z-index: 200; + background: var(--clr-primary-d); box-shadow: var(--shadow-md); + padding: 0 32px; height: 64px; + display: flex; align-items: center; gap: 24px; +} +.public-nav-brand { + display: flex; align-items: center; gap: 10px; color: #fff; + font-weight: 800; font-size: 1.15rem; text-decoration: none; +} +.public-nav-brand span { color: var(--clr-accent); } +.public-nav-links { display: flex; gap: 4px; margin-left: auto; } +.public-nav-links a { + color: rgba(255,255,255,0.78); padding: 7px 14px; border-radius: var(--radius-sm); + font-size: 0.875rem; font-weight: 500; transition: var(--transition); +} +.public-nav-links a:hover { background: rgba(255,255,255,0.1); color: #fff; } +.public-nav-links a.btn-login { + background: var(--clr-accent); color: var(--clr-primary-d); + font-weight: 700; padding: 7px 18px; +} +.public-footer { + background: var(--clr-primary-d); color: rgba(255,255,255,0.55); + padding: 32px; text-align: center; font-size: 0.82rem; margin-top: 60px; +} + +/* --- Login Page --- */ +.login-page { + min-height: 100vh; display: flex; + background: linear-gradient(135deg, var(--clr-primary-d) 0%, var(--clr-primary) 60%, var(--clr-primary-l) 100%); +} +.login-left { + flex: 1; display: flex; flex-direction: column; + align-items: center; justify-content: center; padding: 48px; + color: #fff; display: none; +} +@media (min-width: 900px) { .login-left { display: flex; } } +.login-right { + width: 100%; max-width: 440px; background: #fff; + display: flex; flex-direction: column; justify-content: center; + padding: 48px; box-shadow: var(--shadow-lg); +} +.login-logo { font-size: 3rem; font-weight: 900; letter-spacing: -0.02em; } +.login-logo span { color: var(--clr-accent); } +.login-tagline { font-size: 0.875rem; color: rgba(255,255,255,0.65); margin-top: 8px; max-width: 360px; text-align: center; line-height: 1.5; } +.login-form-title { font-size: 1.5rem; font-weight: 800; color: var(--clr-text); margin-bottom: 6px; } +.login-form-sub { font-size: 0.85rem; color: var(--clr-text-muted); margin-bottom: 28px; } + +/* --- Misc --- */ +.text-muted { color: var(--clr-text-muted); } +.text-sm { font-size: 0.82rem; } +.text-xs { font-size: 0.72rem; } +.font-bold { font-weight: 700; } +.text-right { text-align: right; } +.text-center { text-align: center; } +.mt-auto { margin-top: auto; } +.w-full { width: 100%; } +.flex { display: flex; } +.items-center { align-items: center; } +.gap-2 { gap: 8px; } +.gap-3 { gap: 12px; } +.section-title { font-size: 1.05rem; font-weight: 700; color: var(--clr-text); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; } +.divider { border: none; border-top: 1px solid var(--clr-border); margin: 20px 0; } + +/* --- Scrollbar --- */ +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 99px; } +::-webkit-scrollbar-thumb:hover { background: #94a3b8; } + +/* --- Responsive --- */ +@media (max-width: 900px) { + .app-shell { grid-template-columns: 1fr; } + .sidebar { display: none; } + .topbar, .main-content { grid-column: 1; } + .stats-grid { grid-template-columns: repeat(2, 1fr); } + .form-row.cols-3 { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 600px) { + .main-content { padding: 16px; } + .topbar { padding: 0 16px; } + .stats-grid { grid-template-columns: 1fr 1fr; } + .form-row.cols-2, .form-row.cols-3 { grid-template-columns: 1fr; } +} diff --git a/public/images/bantuan/.gitkeep b/public/images/bantuan/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/images/laporan/.gitkeep b/public/images/laporan/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/images/laporan/img_6a294ed32d7d84.48553144.jpg b/public/images/laporan/img_6a294ed32d7d84.48553144.jpg new file mode 100644 index 0000000..e923cac Binary files /dev/null and b/public/images/laporan/img_6a294ed32d7d84.48553144.jpg differ diff --git a/public/images/laporan/img_6a295274a80e21.38991515.jpg b/public/images/laporan/img_6a295274a80e21.38991515.jpg new file mode 100644 index 0000000..e923cac Binary files /dev/null and b/public/images/laporan/img_6a295274a80e21.38991515.jpg differ diff --git a/public/images/rumah_ibadah/.gitkeep b/public/images/rumah_ibadah/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/images/uploads/.gitkeep b/public/images/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/images/warga/.gitkeep b/public/images/warga/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..9c05022 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,656 @@ +// public/js/app.js — SINERGI Core JavaScript v1.1 +// Fix: Auto-zoom ke Kota Pontianak + auto-fitBounds setelah data dimuat + +/* ============================================================ + KONSTANTA KOTA PONTIANAK + ============================================================ */ +const PONTIANAK = { + lat: -0.0263, + lng: 109.3425, + zoom: 13, + // Bounding box seluruh Kota Pontianak (untuk fitBounds fallback) + bounds: [[-0.115, 109.280], [0.055, 109.420]] +}; + +/* ============================================================ + UTILITY FUNCTIONS + ============================================================ */ +const App = { + url: document.querySelector('meta[name="app-url"]')?.content || '', + + formatNumber(n) { + return Number(n || 0).toLocaleString('id-ID'); + }, + + formatRupiah(n) { + return 'Rp ' + Number(n || 0).toLocaleString('id-ID'); + }, + + formatTanggal(str) { + if (!str) return '-'; + const bulan = ['','Januari','Februari','Maret','April','Mei','Juni', + 'Juli','Agustus','September','Oktober','November','Desember']; + const d = new Date(str); + return `${d.getDate()} ${bulan[d.getMonth() + 1]} ${d.getFullYear()}`; + }, + + async fetchJSON(url) { + try { + const r = await fetch(url); + return await r.json(); + } catch (e) { + console.error('Fetch error:', url, e); + return null; + } + }, + + showModal(id) { + const el = document.getElementById(id); + if (el) { el.classList.add('active'); document.body.style.overflow = 'hidden'; } + }, + hideModal(id) { + const el = document.getElementById(id); + if (el) { el.classList.remove('active'); document.body.style.overflow = ''; } + }, + + confirmDelete(url, msg = 'Apakah Anda yakin ingin menghapus data ini?') { + if (!confirm(msg)) return false; + const form = document.createElement('form'); + form.method = 'POST'; + form.action = url; + const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ''; + form.innerHTML = ``; + document.body.appendChild(form); + form.submit(); + }, + + startClock() { + const el = document.getElementById('live-clock'); + if (!el) return; + const update = () => { + const now = new Date(); + el.textContent = now.toLocaleTimeString('id-ID', { + hour: '2-digit', minute: '2-digit', second: '2-digit' + }); + }; + update(); + setInterval(update, 1000); + }, + + initFlash() { + document.querySelectorAll('.flash-message').forEach(el => { + setTimeout(() => { + el.style.transition = 'opacity 0.5s, max-height 0.5s'; + el.style.opacity = '0'; + el.style.maxHeight = '0'; + el.style.overflow = 'hidden'; + setTimeout(() => el.remove(), 500); + }, 4500); + }); + }, + + initTableSearch(inputId, tableId) { + const input = document.getElementById(inputId); + const table = document.getElementById(tableId); + if (!input || !table) return; + input.addEventListener('input', () => { + const q = input.value.toLowerCase(); + table.querySelectorAll('tbody tr').forEach(row => { + row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none'; + }); + }); + }, + + init() { + this.startClock(); + this.initFlash(); + + // Close modal on overlay click + document.querySelectorAll('.modal-overlay').forEach(overlay => { + overlay.addEventListener('click', e => { + if (e.target === overlay) overlay.classList.remove('active'); + }); + }); + + // Close modal on ESC + document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + document.querySelectorAll('.modal-overlay.active') + .forEach(m => m.classList.remove('active')); + document.body.style.overflow = ''; + } + }); + + // Sidebar active link highlight + const path = window.location.pathname; + document.querySelectorAll('.sidebar-nav a').forEach(a => { + const href = a.getAttribute('href'); + if (href && href !== '/' && path.startsWith(href)) { + a.classList.add('active'); + } + }); + } +}; + +/* ============================================================ + GIS MAP MODULE — dengan auto-zoom ke Pontianak + ============================================================ */ +const GISMap = { + map: null, + layers: {}, + overlayLayers: {}, + heatLayer: null, + _allMarkerBounds: [], // Kumpulkan semua koordinat untuk fitBounds akhir + + warnaMiskin: { + 'Miskin Ekstrem': '#dc2626', + 'Miskin': '#ea580c', + 'Rentan Miskin': '#ca8a04', + }, + + createMarker(lat, lng, color, size = 10) { + return L.circleMarker([lat, lng], { + radius: size, + fillColor: color, + color: '#fff', + weight: 2, + opacity: 1, + fillOpacity: 0.88 + }); + }, + + createRIMarker(ri) { + const icon = L.divIcon({ + className: '', + html: `
`, + iconSize: [32, 32], + iconAnchor: [16, 32], + popupAnchor: [0, -34] + }); + return L.marker([parseFloat(ri.lat), parseFloat(ri.lng)], { icon }); + }, + + // ─── INISIALISASI PETA ───────────────────────────────────── + // Selalu mulai dari Kota Pontianak, zoom sesuai konteks + init(containerId, lat, lng, zoom) { + // Reset state untuk peta baru + this._allMarkerBounds = []; + + // Gunakan koordinat Pontianak sebagai default yang kuat + const centerLat = (lat && !isNaN(lat)) ? lat : PONTIANAK.lat; + const centerLng = (lng && !isNaN(lng)) ? lng : PONTIANAK.lng; + const initZoom = (zoom && !isNaN(zoom)) ? zoom : PONTIANAK.zoom; + + this.map = L.map(containerId, { + zoomControl: true, + attributionControl: true, + // Batasi pan agar tidak keluar wilayah Kalimantan Barat + maxBounds: [[-2.0, 107.5], [2.0, 112.0]], + maxBoundsViscosity: 0.8 + }); + + // ── Langsung set view ke Pontianak dulu sebelum tile load ── + this.map.setView([centerLat, centerLng], initZoom); + + // Tile layers + const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap', + maxZoom: 19, + // Tambahkan subdomains untuk load lebih cepat + subdomains: ['a','b','c'] + }); + + const satellite = L.tileLayer( + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { + attribution: 'Tiles © Esri', + maxZoom: 19 + } + ); + + const topo = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { + attribution: '© OpenTopoMap', + maxZoom: 17 + }); + + osm.addTo(this.map); + + this.layers = { + '🗺 Peta Jalan': osm, + '🛰 Satelit': satellite, + '🏔 Topografi': topo + }; + + // Layer control dasar + this._layerControl = L.control.layers(this.layers, {}, { + position: 'topright', + collapsed: true + }).addTo(this.map); + + // Tombol reset zoom ke Pontianak + this._addResetButton(); + + return this.map; + }, + + // Tombol reset zoom ke seluruh Kota Pontianak + _addResetButton() { + const ResetControl = L.Control.extend({ + options: { position: 'topleft' }, + onAdd: () => { + const btn = L.DomUtil.create('button', ''); + btn.innerHTML = '🏙'; + btn.title = 'Kembali ke Kota Pontianak'; + btn.style.cssText = ` + width:30px;height:30px;border:2px solid rgba(0,0,0,0.2); + border-radius:4px;background:#fff;cursor:pointer; + font-size:14px;display:flex;align-items:center; + justify-content:center;box-shadow:0 1px 5px rgba(0,0,0,0.2); + `; + L.DomEvent.on(btn, 'click', L.DomEvent.stopPropagation) + .on(btn, 'click', L.DomEvent.preventDefault) + .on(btn, 'click', () => { + this.map.flyToBounds(PONTIANAK.bounds, { + duration: 0.8, + padding: [20, 20] + }); + }); + return btn; + } + }); + new ResetControl().addTo(this.map); + }, + + // ─── LAYER WARGA MISKIN ──────────────────────────────────── + addWargaLayer(wargaData, onClickCb) { + const group = L.featureGroup(); // featureGroup mendukung getBounds() + wargaData.forEach(w => { + if (!w.lat || !w.lng) return; + const lat = parseFloat(w.lat); + const lng = parseFloat(w.lng); + if (isNaN(lat) || isNaN(lng)) return; + + const color = w.is_blind_spot + ? '#7c3aed' + : (this.warnaMiskin[w.status_kemiskinan] || '#6b7280'); + const size = w.status_kemiskinan === 'Miskin Ekstrem' ? 11 : 9; + + const m = this.createMarker(lat, lng, color, size); + + const popup = ` +
+ ${w.nama || 'Data laporan'}
+ ${w.kelurahan || ''}
+ + ${w.status_kemiskinan || ''} + + ${w.level_prioritas ? `
+ Prioritas: ${w.level_prioritas} + ${w.skor_prioritas ? `(${w.skor_prioritas}/100)` : ''}` : ''} + ${w.penghasilan_bulanan !== undefined ? ` + Penghasilan: Rp ${Number(w.penghasilan_bulanan||0).toLocaleString('id-ID')}/bln` : ''} + ${w.is_blind_spot ? '
⚠ Blind Spot — Belum terjangkau RI' : ''} +
`; + + m.bindPopup(popup, { maxWidth: 240 }); + if (onClickCb) m.on('click', () => onClickCb(w)); + group.addLayer(m); + this._allMarkerBounds.push([lat, lng]); + }); + group.addTo(this.map); + return group; + }, + + // ─── LAYER RUMAH IBADAH ──────────────────────────────────── + addRILayer(riData, onClickCb) { + const group = L.featureGroup(); + riData.forEach(ri => { + if (!ri.lat || !ri.lng) return; + const lat = parseFloat(ri.lat); + const lng = parseFloat(ri.lng); + if (isNaN(lat) || isNaN(lng)) return; + + const m = this.createRIMarker(ri); + + // Radius lingkaran pelayanan + const radius = parseInt(ri.radius_meter) || 500; + const circle = L.circle([lat, lng], { + radius, + fillColor: '#16a34a', + fillOpacity: 0.07, + color: '#16a34a', + weight: 1.5, + dashArray: '6,5', + interactive: false + }); + + const popup = ` +
+ ⛪ ${ri.nama}
+ ${ri.jenis || ''} • ${ri.kelurahan || ''} + ${ri.alamat ? `
${ri.alamat}` : ''} +
+ 📏 Radius pelayanan: ${radius.toLocaleString()}m + ${ri.nama_ketua ? `Ketua: ${ri.nama_ketua}` : ''} +
`; + + m.bindPopup(popup, { maxWidth: 240 }); + if (onClickCb) m.on('click', () => onClickCb(ri)); + + group.addLayer(circle); + group.addLayer(m); + this._allMarkerBounds.push([lat, lng]); + }); + group.addTo(this.map); + return group; + }, + + // ─── LAYER LAPORAN PUBLIK ────────────────────────────────── + addLaporanLayer(laporanData) { + const group = L.featureGroup(); + laporanData.forEach(lap => { + if (!lap.lat || !lap.lng) return; + const lat = parseFloat(lap.lat); + const lng = parseFloat(lap.lng); + if (isNaN(lat) || isNaN(lng)) return; + + const m = this.createMarker(lat, lng, '#0284c7', 8); + m.bindPopup(` +
+ 📋 Laporan Masuk
+ ${lap.nama_warga || ''}
+ + ${lap.kode_laporan || ''} +
`, { maxWidth: 200 }); + group.addLayer(m); + this._allMarkerBounds.push([lat, lng]); + }); + group.addTo(this.map); + return group; + }, + + // ─── HEATMAP LAYER ───────────────────────────────────────── + addHeatmap(points) { + if (typeof L.heatLayer === 'undefined') { + console.warn('Leaflet.heat tidak tersedia'); + return; + } + const data = points + .map(p => [parseFloat(p.lat), parseFloat(p.lng), parseFloat(p.intensity || 0.5)]) + .filter(p => !isNaN(p[0]) && !isNaN(p[1]) && p[0] !== 0); + + if (this.heatLayer) this.map.removeLayer(this.heatLayer); + this.heatLayer = L.heatLayer(data, { + radius: 28, + blur: 22, + maxZoom: 16, + max: 1.0, + gradient: { + 0.0: 'transparent', + 0.3: '#fef9c3', + 0.55: '#fb923c', + 0.75: '#ef4444', + 1.0: '#7f1d1d' + } + }).addTo(this.map); + }, + + // ─── AUTO FIT BOUNDS ─────────────────────────────────────── + // Panggil ini SETELAH semua layer dimuat untuk zoom ke data + autoFitBounds(forceKotaPontianak = false) { + if (forceKotaPontianak || this._allMarkerBounds.length === 0) { + // Tidak ada data → tampilkan seluruh Kota Pontianak + this.map.flyToBounds(PONTIANAK.bounds, { + duration: 0.6, + padding: [20, 20], + maxZoom: 14 + }); + return; + } + + try { + const bounds = L.latLngBounds(this._allMarkerBounds); + if (bounds.isValid()) { + // Jika data sangat menyebar → fit ke batas data + // Jika data sangat mepet (1-2 titik) → zoom level wajar + const boundsSize = bounds.getNorthEast().distanceTo(bounds.getSouthWest()); + if (boundsSize < 500) { + // Data terlalu dekat → zoom ke tengah dengan zoom 15 + this.map.flyTo(bounds.getCenter(), 15, { duration: 0.6 }); + } else { + this.map.flyToBounds(bounds, { + duration: 0.6, + padding: [40, 40], + maxZoom: 15 // Jangan zoom terlalu dekat + }); + } + } else { + this.map.flyToBounds(PONTIANAK.bounds, { duration: 0.5 }); + } + } catch (e) { + this.map.flyToBounds(PONTIANAK.bounds, { duration: 0.5 }); + } + }, + + // fitBounds dari layerGroup (kompatibel dengan kode lama) + fitBounds(layerGroup) { + try { + const bounds = layerGroup.getBounds(); + if (bounds.isValid()) { + this.map.flyToBounds(bounds, { duration: 0.5, padding: [30, 30] }); + } + } catch (e) { + this.autoFitBounds(true); + } + } +}; + +/* ============================================================ + CHART MODULE (Chart.js wrapper) + ============================================================ */ +const Charts = { + defaults: { + fontFamily: "'Inter', system-ui, sans-serif", + colors: ['#1a3a5c', '#e8a020', '#16a34a', '#dc2626', '#0284c7', '#7c3aed', '#ea580c', '#0d9488'] + }, + + bar(canvasId, labels, datasets, opts = {}) { + const ctx = document.getElementById(canvasId); + if (!ctx) return null; + return new Chart(ctx, { + type: 'bar', + data: { + labels, + datasets: datasets.map((d, i) => ({ + backgroundColor: this.defaults.colors[i % this.defaults.colors.length] + 'cc', + borderColor: this.defaults.colors[i % this.defaults.colors.length], + borderRadius: 4, + borderWidth: 1, + ...d + })) + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: datasets.length > 1 }, + tooltip: { callbacks: { label: ctx => ' ' + Number(ctx.raw).toLocaleString('id-ID') } } + }, + scales: { + y: { beginAtZero: true, grid: { color: '#f1f5f9' }, ticks: { font: { size: 11 } } }, + x: { grid: { display: false }, ticks: { font: { size: 11 } } } + }, + ...opts + } + }); + }, + + line(canvasId, labels, datasets, opts = {}) { + const ctx = document.getElementById(canvasId); + if (!ctx) return null; + return new Chart(ctx, { + type: 'line', + data: { + labels, + datasets: datasets.map((d, i) => ({ + borderColor: this.defaults.colors[i % this.defaults.colors.length], + backgroundColor: this.defaults.colors[i % this.defaults.colors.length] + '18', + tension: 0.4, fill: true, borderWidth: 2.5, + pointRadius: 4, pointHoverRadius: 6, + ...d + })) + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: datasets.length > 1 } }, + scales: { + y: { beginAtZero: true, grid: { color: '#f1f5f9' }, ticks: { font: { size: 11 } } }, + x: { grid: { display: false }, ticks: { font: { size: 11 } } } + }, + ...opts + } + }); + }, + + donut(canvasId, labels, data, colors) { + const ctx = document.getElementById(canvasId); + if (!ctx) return null; + return new Chart(ctx, { + type: 'doughnut', + data: { + labels, + datasets: [{ + data, + backgroundColor: colors || this.defaults.colors, + borderWidth: 2, + borderColor: '#fff' + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + labels: { font: { size: 11 }, padding: 14, usePointStyle: true } + } + }, + cutout: '65%' + } + }); + } +}; + +/* ============================================================ + LOCATION PICKER — untuk form laporan & input warga + ============================================================ */ +const LocationPicker = { + map: null, + marker: null, + + init(mapId, latInputId, lngInputId, initLat, initLng) { + // Selalu mulai dari Pontianak + const startLat = (initLat && !isNaN(initLat)) ? initLat : PONTIANAK.lat; + const startLng = (initLng && !isNaN(initLng)) ? initLng : PONTIANAK.lng; + + this.map = L.map(mapId, { zoomControl: true }).setView([startLat, startLng], 14); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap', + maxZoom: 19, + subdomains: ['a','b','c'] + }).addTo(this.map); + + // Jika ada koordinat awal, langsung pasang marker + if (initLat && initLng && !isNaN(initLat)) { + this.placeMarker(initLat, initLng, latInputId, lngInputId); + } + + // Klik peta = pasang / pindah marker + this.map.on('click', e => { + this.placeMarker(e.latlng.lat, e.latlng.lng, latInputId, lngInputId); + }); + + // Kontrol tombol lokasi saya + this._addGeolocateButton(latInputId, lngInputId); + + // Instruksi klik + const hint = L.control({ position: 'bottomleft' }); + hint.onAdd = () => { + const d = L.DomUtil.create('div'); + d.style.cssText = 'background:rgba(255,255,255,0.9);padding:6px 10px;border-radius:6px;font-size:11px;color:#374151;box-shadow:0 1px 4px rgba(0,0,0,0.15)'; + d.innerHTML = '📍 Klik peta atau drag marker untuk menentukan lokasi'; + return d; + }; + hint.addTo(this.map); + }, + + placeMarker(lat, lng, latInputId, lngInputId) { + if (this.marker) this.map.removeLayer(this.marker); + this.marker = L.marker([lat, lng], { + draggable: true, + autoPan: true + }).addTo(this.map); + this.marker.bindPopup('📍 Lokasi dipilih').openPopup(); + + document.getElementById(latInputId).value = lat.toFixed(7); + document.getElementById(lngInputId).value = lng.toFixed(7); + + this.marker.on('dragend', e => { + const pos = e.target.getLatLng(); + document.getElementById(latInputId).value = pos.lat.toFixed(7); + document.getElementById(lngInputId).value = pos.lng.toFixed(7); + }); + }, + + _addGeolocateButton(latInputId, lngInputId) { + const GeoBtn = L.Control.extend({ + options: { position: 'topright' }, + onAdd: () => { + const btn = L.DomUtil.create('button', ''); + btn.innerHTML = '📍 Lokasi Saya'; + btn.title = 'Gunakan lokasi GPS saya'; + btn.style.cssText = ` + padding:6px 10px;border:2px solid rgba(0,0,0,0.2);border-radius:4px; + background:#1a3a5c;color:#fff;cursor:pointer;font-size:11px;font-weight:600; + box-shadow:0 1px 5px rgba(0,0,0,0.2);white-space:nowrap; + `; + L.DomEvent.disableClickPropagation(btn); + btn.onclick = () => { + if (!navigator.geolocation) { + alert('GPS tidak tersedia di browser Anda'); + return; + } + btn.innerHTML = '⌛ Mencari...'; + btn.disabled = true; + navigator.geolocation.getCurrentPosition( + pos => { + const { latitude, longitude } = pos.coords; + this.placeMarker(latitude, longitude, latInputId, lngInputId); + this.map.flyTo([latitude, longitude], 17, { duration: 0.8 }); + btn.innerHTML = '📍 Lokasi Saya'; + btn.disabled = false; + }, + err => { + alert('Gagal mendapatkan lokasi: ' + err.message); + btn.innerHTML = '📍 Lokasi Saya'; + btn.disabled = false; + }, + { enableHighAccuracy: true, timeout: 10000 } + ); + }; + return btn; + } + }); + new GeoBtn().addTo(this.map); + } +}; + +// ── Bootstrap ── +document.addEventListener('DOMContentLoaded', () => App.init());