Initial WebGIS portal project

This commit is contained in:
2026-06-10 19:53:39 +07:00
commit c0aa8e09d5
62 changed files with 9013 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
function start_admin_session(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
function is_admin_logged_in(): bool
{
start_admin_session();
return !empty($_SESSION['admin_id']);
}
function require_admin_login(bool $jsonResponse = false): void
{
if (is_admin_logged_in()) {
return;
}
if ($jsonResponse) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'Sesi login tidak valid. Silakan login ulang.',
]);
} else {
header('Location: login.php');
}
exit;
}
function current_admin_name(): string
{
start_admin_session();
return (string)($_SESSION['admin_name'] ?? $_SESSION['admin_username'] ?? 'Admin');
}
function ensure_admin_table(PDO $pdo): void
{
$pdo->exec("
CREATE TABLE IF NOT EXISTS admins (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
nama VARCHAR(100) NOT NULL DEFAULT 'Administrator',
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
$count = (int)$pdo->query("SELECT COUNT(*) FROM admins")->fetchColumn();
if ($count === 0) {
$stmt = $pdo->prepare("INSERT INTO admins (username, password_hash, nama) VALUES (?, ?, ?)");
$stmt->execute([
'admin',
password_hash('admin123', PASSWORD_DEFAULT),
'Administrator',
]);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_once 'penduduk_feature_helper.php';
require_admin_login(true);
header('Content-Type: application/json');
$input = $_POST ?: (json_decode(file_get_contents('php://input'), true) ?: []);
if (empty($input['penduduk_id']) || empty($input['jenis_bantuan']) || empty($input['tanggal_bantuan'])) {
echo json_encode(['success' => false, 'message' => 'Penduduk, jenis bantuan, dan tanggal wajib diisi.']);
exit;
}
try {
ensurePendudukFeatureSchema($pdo);
$buktiFile = saveUploadedEvidence('bukti_file');
$stmt = $pdo->prepare("
INSERT INTO riwayat_bantuan (penduduk_id, jenis_bantuan, tanggal_bantuan, nilai_bantuan, pemberi_bantuan, catatan, bukti_file)
VALUES (?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$input['penduduk_id'],
$input['jenis_bantuan'],
normalizeDateOrNull($input['tanggal_bantuan']) ?? date('Y-m-d'),
$input['nilai_bantuan'] ?? null,
$input['pemberi_bantuan'] ?? 'Admin',
$input['catatan'] ?? null,
$buktiFile,
]);
$update = $pdo->prepare("
UPDATE penduduk_miskin
SET status_bantuan = 'Sudah dibantu', jenis_bantuan = ?, tanggal_bantuan = ?
WHERE id = ?
");
$update->execute([
$input['jenis_bantuan'],
normalizeDateOrNull($input['tanggal_bantuan']) ?? date('Y-m-d'),
$input['penduduk_id'],
]);
updatePendudukPriority($pdo, (int)$input['penduduk_id']);
echo json_encode(['success' => true, 'id' => $pdo->lastInsertId()]);
} catch (Throwable $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+59
View File
@@ -0,0 +1,59 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_once 'penduduk_feature_helper.php';
require_admin_login(true);
header('Content-Type: application/json');
try {
ensurePendudukFeatureSchema($pdo);
refreshAllPriorityScores($pdo);
$stats = [];
$ri = $pdo->query("SELECT COUNT(*) as total FROM rumah_ibadah")->fetch();
$stats['total_rumah_ibadah'] = $ri['total'];
$pm = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin")->fetch();
$stats['total_penduduk'] = $pm['total'];
$pm_sudah = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE status_bantuan = 'Sudah dibantu'")->fetch();
$stats['sudah_dibantu'] = $pm_sudah['total'];
$pm_belum = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE status_bantuan = 'Belum dibantu'")->fetch();
$stats['belum_dibantu'] = $pm_belum['total'];
$pm_tunggu = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE status_bantuan = 'Menunggu verifikasi'")->fetch();
$stats['menunggu_verifikasi'] = $pm_tunggu['total'];
$blank = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE is_blank_spot = 1")->fetch();
$stats['luar_radius'] = $blank['total'];
$prioritas = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE skor_prioritas >= 70")->fetch();
$stats['prioritas_tinggi'] = $prioritas['total'];
$survei = $pdo->query("SELECT COUNT(*) as total FROM penduduk_miskin WHERE tindak_lanjut IN ('Sudah disurvei', 'Sudah diverifikasi')")->fetch();
$stats['sudah_survei'] = $survei['total'];
$stats['top_prioritas'] = $pdo->query("
SELECT id, kk_nama, kategori_kebutuhan, tindak_lanjut, skor_prioritas
FROM penduduk_miskin
ORDER BY skor_prioritas DESC, id DESC
LIMIT 5
")->fetchAll();
$stats['kebutuhan_terbanyak'] = $pdo->query("
SELECT COALESCE(NULLIF(kategori_kebutuhan, ''), 'Belum diisi') AS kategori, COUNT(*) AS total
FROM penduduk_miskin
GROUP BY COALESCE(NULLIF(kategori_kebutuhan, ''), 'Belum diisi')
ORDER BY total DESC
LIMIT 5
")->fetchAll();
echo json_encode([
'success' => true,
'data' => $stats
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
$host = getenv('DB_HOST') ?: '127.0.0.1';
$database = getenv('DB_DATABASE') ?: 'webgis_miskin';
$username = getenv('DB_USERNAME') ?: 'root';
$password = getenv('DB_PASSWORD') ?: '';
$charset = getenv('DB_CHARSET') ?: 'utf8mb4';
set_exception_handler(static function (Throwable $exception): void {
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json');
}
echo json_encode([
'success' => false,
'message' => 'Terjadi kesalahan server.',
'detail' => $exception->getMessage(),
]);
exit;
});
$dsn = "mysql:host={$host};dbname={$database};charset={$charset}";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $username, $password, $options);
} catch (PDOException $exception) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'Koneksi database gagal: ' . $exception->getMessage(),
]);
exit;
}
+145
View File
@@ -0,0 +1,145 @@
<?php
/**
* Menghitung jarak antara dua koordinat menggunakan formula Haversine.
* @param float $lat1 Latitude titik pertama
* @param float $lon1 Longitude titik pertama
* @param float $lat2 Latitude titik kedua
* @param float $lon2 Longitude titik kedua
* @return float Jarak dalam satuan meter
*/
function calculateHaversineDistance($lat1, $lon1, $lat2, $lon2) {
$earthRadius = 6371000; // Radius bumi dalam meter
$latFrom = deg2rad($lat1);
$lonFrom = deg2rad($lon1);
$latTo = deg2rad($lat2);
$lonTo = deg2rad($lon2);
$latDelta = $latTo - $latFrom;
$lonDelta = $lonTo - $lonFrom;
$angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) +
cos($latFrom) * cos($latTo) * pow(sin($lonDelta / 2), 2)));
return $angle * $earthRadius;
}
function calculateOsrmRouteDistance($lat1, $lon1, $lat2, $lon2): ?float
{
$coordinates = sprintf(
'%.8F,%.8F;%.8F,%.8F',
$lon1,
$lat1,
$lon2,
$lat2
);
$url = 'https://router.project-osrm.org/route/v1/driving/' . $coordinates . '?overview=false&alternatives=false&steps=false';
$ch = curl_init($url);
if ($ch === false) {
return null;
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 4,
CURLOPT_TIMEOUT => 8,
CURLOPT_USERAGENT => 'WebGIS-Peduli/1.0',
]);
$response = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($response === false || $httpCode !== 200) {
return null;
}
$json = json_decode($response, true);
if (!is_array($json) || ($json['code'] ?? '') !== 'Ok') {
return null;
}
$distance = $json['routes'][0]['distance'] ?? null;
return is_numeric($distance) ? (float)$distance : null;
}
function calculateRoadDistance($lat1, $lon1, $lat2, $lon2): ?float
{
return calculateOsrmRouteDistance((float)$lat1, (float)$lon1, (float)$lat2, (float)$lon2);
}
function ensurePendudukDistanceColumns(PDO $pdo): void
{
$stmt = $pdo->query("SHOW COLUMNS FROM penduduk_miskin LIKE 'jarak_rute_m'");
if ($stmt->fetch() === false) {
$pdo->exec("ALTER TABLE penduduk_miskin ADD COLUMN jarak_rute_m DECIMAL(10, 2) NULL AFTER jarak_m");
}
}
function calculatePendudukCoverage(PDO $pdo, float $lat, float $lng): array
{
$stmtRi = $pdo->query("SELECT id, lat, lng, radius FROM rumah_ibadah");
$rumahIbadah = $stmtRi->fetchAll(PDO::FETCH_ASSOC);
$nearest = null;
$nearestCovered = null;
foreach ($rumahIbadah as $ri) {
$distance = calculateHaversineDistance($lat, $lng, (float)$ri['lat'], (float)$ri['lng']);
$result = [
'ibadah_id' => (int)$ri['id'],
'ri_lat' => (float)$ri['lat'],
'ri_lng' => (float)$ri['lng'],
'jarak_m' => round($distance, 2),
'distance' => $distance,
];
if ($nearest === null || $distance < $nearest['distance']) {
$nearest = $result;
}
if ($distance <= (float)$ri['radius'] && ($nearestCovered === null || $distance < $nearestCovered['distance'])) {
$nearestCovered = $result;
}
}
$selected = $nearestCovered ?? $nearest;
$routeDistance = null;
if ($selected !== null) {
$routeDistance = calculateRoadDistance($lat, $lng, $selected['ri_lat'], $selected['ri_lng']);
}
return [
'ibadah_id' => $selected['ibadah_id'] ?? null,
'jarak_m' => $selected['jarak_m'] ?? null,
'jarak_rute_m' => $routeDistance !== null ? round($routeDistance, 2) : null,
'is_blank_spot' => $nearestCovered === null ? 1 : 0,
];
}
function recalculateAllPendudukCoverage(PDO $pdo): void
{
ensurePendudukDistanceColumns($pdo);
$stmt = $pdo->query("SELECT id, lat, lng FROM penduduk_miskin");
$penduduk = $stmt->fetchAll(PDO::FETCH_ASSOC);
$update = $pdo->prepare("
UPDATE penduduk_miskin
SET ibadah_id = ?, jarak_m = ?, jarak_rute_m = ?, is_blank_spot = ?
WHERE id = ?
");
foreach ($penduduk as $row) {
$coverage = calculatePendudukCoverage($pdo, (float)$row['lat'], (float)$row['lng']);
$update->execute([
$coverage['ibadah_id'],
$coverage['jarak_m'],
$coverage['jarak_rute_m'],
$coverage['is_blank_spot'],
$row['id'],
]);
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
require_once 'db_pdo.php';
require_once 'haversine_helper.php';
require_once 'penduduk_feature_helper.php';
require_once 'auth.php';
require_admin_login(true);
header('Content-Type: application/json');
$input = readPendudukInput();
if (!$input) {
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
exit;
}
try {
ensurePendudukDistanceColumns($pdo);
ensurePendudukFeatureSchema($pdo);
$input_lat = floatval($input['lat']);
$input_lng = floatval($input['lng']);
$coverage = calculatePendudukCoverage($pdo, $input_lat, $input_lng);
$buktiFile = saveUploadedEvidence('bukti_file');
$stmt = $pdo->prepare("
INSERT INTO penduduk_miskin
(kk_nama, kk_nik, alamat, lat, lng, ibadah_id, jarak_m, jarak_rute_m, is_blank_spot, status_bantuan, kategori_kebutuhan, deskripsi_kebutuhan, tindak_lanjut, kondisi_rumah, penghasilan, catatan_survei, bukti_file, jenis_bantuan, tanggal_bantuan)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$input['kk_nama'],
$input['kk_nik'] ?? null,
$input['alamat'],
$input['lat'],
$input['lng'],
$coverage['ibadah_id'],
$coverage['jarak_m'],
$coverage['jarak_rute_m'],
$coverage['is_blank_spot'],
$input['status_bantuan'] ?? 'Belum dibantu',
$input['kategori_kebutuhan'] ?? null,
$input['deskripsi_kebutuhan'] ?? null,
$input['tindak_lanjut'] ?? 'Belum disurvei',
$input['kondisi_rumah'] ?? null,
normalizeNonNegativeIntOrNull($input['penghasilan'] ?? '', 'Penghasilan'),
$input['catatan_survei'] ?? null,
$buktiFile,
$input['jenis_bantuan'] ?? null,
normalizeDateOrNull($input['tanggal_bantuan'] ?? null)
]);
$penduduk_id = $pdo->lastInsertId();
// 4. Insert anggota keluarga if exist
if (!empty($input['anggota']) && is_array($input['anggota'])) {
$stmtAnggota = $pdo->prepare("INSERT INTO anggota_keluarga (penduduk_id, nama, hubungan, umur, pekerjaan) VALUES (?, ?, ?, ?, ?)");
foreach ($input['anggota'] as $anggota) {
$stmtAnggota->execute([
$penduduk_id,
$anggota['nama'],
$anggota['hubungan'],
$anggota['umur'] ?? null,
$anggota['pekerjaan'] ?? null
]);
}
}
if (($input['status_bantuan'] ?? '') === 'Sudah dibantu' && !empty($input['jenis_bantuan'])) {
$stmtRiwayat = $pdo->prepare("
INSERT INTO riwayat_bantuan (penduduk_id, jenis_bantuan, tanggal_bantuan, pemberi_bantuan, catatan, bukti_file)
VALUES (?, ?, ?, ?, ?, ?)
");
$stmtRiwayat->execute([
$penduduk_id,
$input['jenis_bantuan'],
normalizeDateOrNull($input['tanggal_bantuan'] ?? null) ?? date('Y-m-d'),
'Admin',
'Dicatat dari form penduduk.',
$buktiFile,
]);
}
updatePendudukPriority($pdo, (int)$penduduk_id);
echo json_encode([
'success' => true,
'id' => $penduduk_id,
'ibadah_id' => $coverage['ibadah_id'],
'jarak_m' => $coverage['jarak_m'],
'jarak_rute_m' => $coverage['jarak_rute_m'],
'is_blank_spot' => $coverage['is_blank_spot']
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+22
View File
@@ -0,0 +1,22 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_admin_login(true);
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || empty($input['id'])) {
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
exit;
}
try {
$stmt = $pdo->prepare("DELETE FROM penduduk_miskin WHERE id=?");
$stmt->execute([$input['id']]);
// Note: anggota_keluarga will be deleted automatically due to ON DELETE CASCADE
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+179
View File
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
function ensurePendudukFeatureSchema(PDO $pdo): void
{
$columns = [
'kategori_kebutuhan' => "ALTER TABLE penduduk_miskin ADD COLUMN kategori_kebutuhan VARCHAR(100) NULL AFTER status_bantuan",
'deskripsi_kebutuhan' => "ALTER TABLE penduduk_miskin ADD COLUMN deskripsi_kebutuhan TEXT NULL AFTER kategori_kebutuhan",
'tindak_lanjut' => "ALTER TABLE penduduk_miskin ADD COLUMN tindak_lanjut VARCHAR(60) NOT NULL DEFAULT 'Belum disurvei' AFTER deskripsi_kebutuhan",
'kondisi_rumah' => "ALTER TABLE penduduk_miskin ADD COLUMN kondisi_rumah VARCHAR(80) NULL AFTER tindak_lanjut",
'penghasilan' => "ALTER TABLE penduduk_miskin ADD COLUMN penghasilan INT NULL AFTER kondisi_rumah",
'catatan_survei' => "ALTER TABLE penduduk_miskin ADD COLUMN catatan_survei TEXT NULL AFTER penghasilan",
'bukti_file' => "ALTER TABLE penduduk_miskin ADD COLUMN bukti_file VARCHAR(255) NULL AFTER catatan_survei",
'skor_prioritas' => "ALTER TABLE penduduk_miskin ADD COLUMN skor_prioritas INT NOT NULL DEFAULT 0 AFTER bukti_file",
];
foreach ($columns as $column => $sql) {
$stmt = $pdo->query("SHOW COLUMNS FROM penduduk_miskin LIKE " . $pdo->quote($column));
if ($stmt->fetch() === false) {
$pdo->exec($sql);
}
}
$pdo->exec("
CREATE TABLE IF NOT EXISTS riwayat_bantuan (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL,
jenis_bantuan VARCHAR(120) NOT NULL,
tanggal_bantuan DATE NOT NULL,
nilai_bantuan VARCHAR(80) NULL,
pemberi_bantuan VARCHAR(120) NULL,
catatan TEXT NULL,
bukti_file VARCHAR(255) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
function normalizeDateOrNull(?string $value): ?string
{
$value = trim((string)$value);
return $value === '' || $value === '0000-00-00' ? null : $value;
}
function normalizeNonNegativeIntOrNull(mixed $value, string $label): ?int
{
$value = trim((string)$value);
if ($value === '') {
return null;
}
if (!is_numeric($value) || (float)$value < 0) {
throw new InvalidArgumentException($label . ' tidak boleh bernilai negatif.');
}
return (int)$value;
}
function calculatePriorityScore(array $penduduk, array $anggota = []): int
{
$score = 0;
$anggotaCount = count($anggota);
$penghasilan = isset($penduduk['penghasilan']) ? (int)$penduduk['penghasilan'] : 0;
if (($penduduk['status_bantuan'] ?? '') === 'Belum dibantu') $score += 30;
if (($penduduk['status_bantuan'] ?? '') === 'Menunggu verifikasi') $score += 15;
if ((int)($penduduk['is_blank_spot'] ?? 0) === 1) $score += 15;
$score += min(20, $anggotaCount * 4);
foreach ($anggota as $a) {
$umur = isset($a['umur']) ? (int)$a['umur'] : null;
if ($umur !== null && ($umur < 6 || $umur >= 60)) {
$score += 5;
}
}
if ($penghasilan > 0 && $penghasilan <= 1000000) $score += 15;
elseif ($penghasilan > 1000000 && $penghasilan <= 2000000) $score += 8;
$kondisi = strtolower((string)($penduduk['kondisi_rumah'] ?? ''));
if (str_contains($kondisi, 'tidak layak')) $score += 15;
elseif (str_contains($kondisi, 'rentan')) $score += 8;
$kebutuhan = strtolower((string)($penduduk['kategori_kebutuhan'] ?? ''));
if (in_array($kebutuhan, ['kesehatan', 'rumah tidak layak', 'bantuan darurat'], true)) $score += 10;
return min(100, max(0, $score));
}
function getPriorityLabel(int $score): string
{
if ($score >= 70) return 'Prioritas Tinggi';
if ($score >= 40) return 'Prioritas Sedang';
return 'Prioritas Rendah';
}
function saveUploadedEvidence(string $fieldName): ?string
{
if (empty($_FILES[$fieldName]) || !is_array($_FILES[$fieldName])) {
return null;
}
$file = $_FILES[$fieldName];
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
return null;
}
if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
throw new RuntimeException('Upload bukti gagal.');
}
if (($file['size'] ?? 0) > 3 * 1024 * 1024) {
throw new RuntimeException('Ukuran bukti maksimal 3 MB.');
}
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
];
$mime = mime_content_type($file['tmp_name']);
if (!isset($allowed[$mime])) {
throw new RuntimeException('Format bukti harus JPG, PNG, WEBP, atau PDF.');
}
$dir = __DIR__ . '/../uploads/bukti';
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new RuntimeException('Folder upload tidak bisa dibuat.');
}
$filename = date('YmdHis') . '-' . bin2hex(random_bytes(6)) . '.' . $allowed[$mime];
$target = $dir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $target)) {
throw new RuntimeException('Bukti tidak bisa disimpan.');
}
return 'uploads/bukti/' . $filename;
}
function readPendudukInput(): array
{
if (str_starts_with((string)($_SERVER['CONTENT_TYPE'] ?? ''), 'multipart/form-data')) {
$input = $_POST;
$input['anggota'] = json_decode((string)($_POST['anggota'] ?? '[]'), true) ?: [];
return $input;
}
return json_decode(file_get_contents('php://input'), true) ?: [];
}
function updatePendudukPriority(PDO $pdo, int $pendudukId): void
{
$stmt = $pdo->prepare("SELECT * FROM penduduk_miskin WHERE id = ?");
$stmt->execute([$pendudukId]);
$penduduk = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$penduduk) return;
$stmtAnggota = $pdo->prepare("SELECT * FROM anggota_keluarga WHERE penduduk_id = ?");
$stmtAnggota->execute([$pendudukId]);
$anggota = $stmtAnggota->fetchAll(PDO::FETCH_ASSOC);
$score = calculatePriorityScore($penduduk, $anggota);
$update = $pdo->prepare("UPDATE penduduk_miskin SET skor_prioritas = ? WHERE id = ?");
$update->execute([$score, $pendudukId]);
}
function refreshAllPriorityScores(PDO $pdo): void
{
ensurePendudukFeatureSchema($pdo);
$ids = $pdo->query("SELECT id FROM penduduk_miskin")->fetchAll(PDO::FETCH_COLUMN);
foreach ($ids as $id) {
updatePendudukPriority($pdo, (int)$id);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_once 'penduduk_feature_helper.php';
require_admin_login(true);
header('Content-Type: application/json');
try {
ensurePendudukFeatureSchema($pdo);
refreshAllPriorityScores($pdo);
// Join with rumah_ibadah to get name
$stmt = $pdo->query("
SELECT p.*, r.nama as rumah_ibadah_nama
FROM penduduk_miskin p
LEFT JOIN rumah_ibadah r ON p.ibadah_id = r.id
ORDER BY p.skor_prioritas DESC, p.id DESC
");
$data = $stmt->fetchAll();
// Get all anggota
$stmtAnggota = $pdo->query("SELECT * FROM anggota_keluarga");
$anggota = $stmtAnggota->fetchAll();
// Map anggota to penduduk
$anggotaByPenduduk = [];
foreach ($anggota as $a) {
$anggotaByPenduduk[$a['penduduk_id']][] = $a;
}
$stmtRiwayat = $pdo->query("SELECT * FROM riwayat_bantuan ORDER BY tanggal_bantuan DESC, id DESC");
$riwayat = $stmtRiwayat->fetchAll();
$riwayatByPenduduk = [];
foreach ($riwayat as $r) {
$riwayatByPenduduk[$r['penduduk_id']][] = $r;
}
foreach ($data as &$d) {
$d['anggota'] = $anggotaByPenduduk[$d['id']] ?? [];
$d['riwayat_bantuan'] = $riwayatByPenduduk[$d['id']] ?? [];
$d['label_prioritas'] = getPriorityLabel((int)($d['skor_prioritas'] ?? 0));
}
echo json_encode([
'success' => true,
'data' => $data
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+84
View File
@@ -0,0 +1,84 @@
<?php
require_once 'db_pdo.php';
require_once 'haversine_helper.php';
require_once 'penduduk_feature_helper.php';
require_once 'auth.php';
require_admin_login(true);
header('Content-Type: application/json');
$input = readPendudukInput();
if (!$input || empty($input['id'])) {
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
exit;
}
try {
ensurePendudukDistanceColumns($pdo);
ensurePendudukFeatureSchema($pdo);
$input_lat = floatval($input['lat']);
$input_lng = floatval($input['lng']);
$coverage = calculatePendudukCoverage($pdo, $input_lat, $input_lng);
$buktiFile = saveUploadedEvidence('bukti_file');
if ($buktiFile === null && !empty($input['existing_bukti_file'])) {
$buktiFile = $input['existing_bukti_file'];
}
$stmt = $pdo->prepare("
UPDATE penduduk_miskin
SET kk_nama=?, kk_nik=?, alamat=?, lat=?, lng=?, status_bantuan=?, kategori_kebutuhan=?, deskripsi_kebutuhan=?, tindak_lanjut=?, kondisi_rumah=?, penghasilan=?, catatan_survei=?, bukti_file=?, jenis_bantuan=?, tanggal_bantuan=?, ibadah_id=?, jarak_m=?, jarak_rute_m=?, is_blank_spot=?
WHERE id=?
");
$stmt->execute([
$input['kk_nama'],
$input['kk_nik'] ?? null,
$input['alamat'],
$input['lat'],
$input['lng'],
$input['status_bantuan'] ?? 'Belum dibantu',
$input['kategori_kebutuhan'] ?? null,
$input['deskripsi_kebutuhan'] ?? null,
$input['tindak_lanjut'] ?? 'Belum disurvei',
$input['kondisi_rumah'] ?? null,
normalizeNonNegativeIntOrNull($input['penghasilan'] ?? '', 'Penghasilan'),
$input['catatan_survei'] ?? null,
$buktiFile,
$input['jenis_bantuan'] ?? null,
normalizeDateOrNull($input['tanggal_bantuan'] ?? null),
$coverage['ibadah_id'],
$coverage['jarak_m'],
$coverage['jarak_rute_m'],
$coverage['is_blank_spot'],
$input['id']
]);
// 4. Delete old anggota and insert new ones
$pdo->prepare("DELETE FROM anggota_keluarga WHERE penduduk_id=?")->execute([$input['id']]);
if (!empty($input['anggota']) && is_array($input['anggota'])) {
$stmtAnggota = $pdo->prepare("INSERT INTO anggota_keluarga (penduduk_id, nama, hubungan, umur, pekerjaan) VALUES (?, ?, ?, ?, ?)");
foreach ($input['anggota'] as $anggota) {
$stmtAnggota->execute([
$input['id'],
$anggota['nama'],
$anggota['hubungan'],
$anggota['umur'] ?? null,
$anggota['pekerjaan'] ?? null
]);
}
}
updatePendudukPriority($pdo, (int)$input['id']);
echo json_encode([
'success' => true,
'ibadah_id' => $coverage['ibadah_id'],
'jarak_m' => $coverage['jarak_m'],
'jarak_rute_m' => $coverage['jarak_rute_m'],
'is_blank_spot' => $coverage['is_blank_spot']
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+38
View File
@@ -0,0 +1,38 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_once 'haversine_helper.php';
require_once 'penduduk_feature_helper.php';
require_once 'rumah_ibadah_helper.php';
require_admin_login(true);
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
exit;
}
try {
assertRumahIbadahNotDuplicate($pdo, $input);
$stmt = $pdo->prepare("INSERT INTO rumah_ibadah (nama, jenis, kontak, alamat, lat, lng, radius) VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([
$input['nama'],
$input['jenis'],
$input['kontak'],
$input['alamat'],
$input['lat'],
$input['lng'],
$input['radius'] ?? 500
]);
$id = $pdo->lastInsertId();
recalculateAllPendudukCoverage($pdo);
refreshAllPriorityScores($pdo);
echo json_encode(['success' => true, 'id' => $id]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+26
View File
@@ -0,0 +1,26 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_once 'haversine_helper.php';
require_once 'penduduk_feature_helper.php';
require_admin_login(true);
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || empty($input['id'])) {
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
exit;
}
try {
$stmt = $pdo->prepare("DELETE FROM rumah_ibadah WHERE id=?");
$stmt->execute([$input['id']]);
recalculateAllPendudukCoverage($pdo);
refreshAllPriorityScores($pdo);
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
function assertRumahIbadahNotDuplicate(PDO $pdo, array $input, ?int $ignoreId = null): void
{
$nama = trim((string)($input['nama'] ?? ''));
$jenis = trim((string)($input['jenis'] ?? ''));
$lat = round((float)($input['lat'] ?? 0), 6);
$lng = round((float)($input['lng'] ?? 0), 6);
$allowedJenis = ['Masjid', 'Gereja', 'Pura', 'Vihara', 'Klenteng', 'Lainnya'];
if ($nama === '' || $jenis === '' || !isset($input['lat'], $input['lng'])) {
throw new InvalidArgumentException('Nama, jenis, dan lokasi rumah ibadah wajib diisi.');
}
if (!in_array($jenis, $allowedJenis, true)) {
throw new InvalidArgumentException('Jenis rumah ibadah tidak valid.');
}
$sql = "
SELECT id
FROM rumah_ibadah
WHERE LOWER(TRIM(nama)) = LOWER(TRIM(?))
AND jenis = ?
AND ROUND(lat, 6) = ?
AND ROUND(lng, 6) = ?
";
$params = [$nama, $jenis, $lat, $lng];
if ($ignoreId !== null) {
$sql .= " AND id <> ?";
$params[] = $ignoreId;
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
if ($stmt->fetch()) {
throw new InvalidArgumentException('Rumah ibadah dengan nama, jenis, dan lokasi yang sama sudah ada.');
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_admin_login(true);
header('Content-Type: application/json');
try {
$stmt = $pdo->query("SELECT * FROM rumah_ibadah ORDER BY nama ASC");
$data = $stmt->fetchAll();
echo json_encode([
'success' => true,
'data' => $data
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+38
View File
@@ -0,0 +1,38 @@
<?php
require_once 'db_pdo.php';
require_once 'auth.php';
require_once 'haversine_helper.php';
require_once 'penduduk_feature_helper.php';
require_once 'rumah_ibadah_helper.php';
require_admin_login(true);
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || empty($input['id'])) {
echo json_encode(['success' => false, 'message' => 'Input tidak valid']);
exit;
}
try {
assertRumahIbadahNotDuplicate($pdo, $input, (int)$input['id']);
$stmt = $pdo->prepare("UPDATE rumah_ibadah SET nama=?, jenis=?, kontak=?, alamat=?, lat=?, lng=?, radius=? WHERE id=?");
$stmt->execute([
$input['nama'],
$input['jenis'],
$input['kontak'],
$input['alamat'],
$input['lat'],
$input['lng'],
$input['radius'],
$input['id']
]);
recalculateAllPendudukCoverage($pdo);
refreshAllPriorityScores($pdo);
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/auth.php';
require_admin_login(true);
header('Content-Type: application/json');
$host = getenv('DB_HOST') ?: '127.0.0.1';
$username = getenv('DB_SETUP_USERNAME') ?: getenv('DB_USERNAME') ?: 'root';
$password = getenv('DB_SETUP_PASSWORD') ?: getenv('DB_PASSWORD') ?: '';
$charset = getenv('DB_CHARSET') ?: 'utf8mb4';
$schemaPath = __DIR__ . '/../sql/miskin_schema.sql';
$sql = file_get_contents($schemaPath);
if ($sql === false) {
echo json_encode([
'success' => false,
'message' => 'File skema database tidak ditemukan.',
]);
exit;
}
try {
$dsn = "mysql:host={$host};charset={$charset}";
$pdo = new PDO($dsn, $username, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$pdo->exec($sql);
echo json_encode([
'success' => true,
'message' => 'Database webgis_miskin berhasil di-reset.',
]);
} catch (PDOException $exception) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => 'Gagal reset database.',
'error' => $exception->getMessage(),
]);
}
+213
View File
@@ -0,0 +1,213 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #172033;
background: #eef2f7;
font-family: Arial, Helvetica, sans-serif;
line-height: 1.5;
}
.guide-page {
width: min(1040px, calc(100% - 32px));
margin: 28px auto;
background: #ffffff;
border-radius: 14px;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.13);
padding: 32px;
}
.guide-hero {
display: flex;
justify-content: space-between;
gap: 24px;
border-bottom: 2px solid #1f2937;
padding-bottom: 22px;
margin-bottom: 24px;
}
.eyebrow {
margin: 0 0 8px;
color: #4f46e5;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
}
h1 {
font-size: 2.2rem;
}
h2 {
margin-bottom: 14px;
font-size: 1.25rem;
}
section {
margin-top: 26px;
}
.lead {
max-width: 720px;
color: #475467;
font-size: 1rem;
}
.print-btn {
align-self: flex-start;
border: 0;
border-radius: 10px;
background: #4f46e5;
color: white;
cursor: pointer;
font-weight: 800;
padding: 11px 15px;
white-space: nowrap;
}
.access-panel {
border: 1px solid #d8dee9;
border-radius: 12px;
background: #f8fafc;
padding: 20px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.info-grid div,
.legend-grid div {
border: 1px solid #d8dee9;
border-radius: 10px;
background: #ffffff;
padding: 14px;
}
.info-grid span {
display: block;
color: #667085;
font-size: 0.82rem;
margin-bottom: 6px;
}
.info-grid strong {
overflow-wrap: anywhere;
}
.note {
color: #667085;
font-size: 0.9rem;
margin: 12px 0 0;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border: 1px solid #d8dee9;
padding: 10px;
text-align: left;
vertical-align: top;
}
th {
background: #f1f5f9;
}
code {
background: #eef2ff;
border-radius: 6px;
color: #3730a3;
font-weight: 700;
padding: 2px 6px;
}
.steps ol {
padding-left: 22px;
}
.steps li,
ul li {
margin-bottom: 8px;
}
.legend-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.legend-grid strong {
display: block;
margin-bottom: 4px;
}
.legend-grid p {
color: #667085;
margin: 0;
}
.dot {
display: inline-block;
width: 13px;
height: 13px;
border-radius: 50%;
margin-right: 8px;
}
.dot.worship {
background: #4f46e5;
}
.dot.reached {
background: #10b981;
}
.dot.outside {
background: #ef4444;
}
@media (max-width: 800px) {
.guide-hero,
.info-grid,
.legend-grid {
grid-template-columns: 1fr;
flex-direction: column;
}
.guide-page {
padding: 22px;
}
}
@media print {
body {
background: white;
}
.guide-page {
width: 100%;
margin: 0;
padding: 0;
box-shadow: none;
border-radius: 0;
}
.print-btn {
display: none;
}
}
+179
View File
@@ -0,0 +1,179 @@
:root {
--primary: #4f46e5;
--primary-hover: #4338ca;
--dark: #1e293b;
--gray: #64748b;
--gray-light: #e2e8f0;
--light: #f8fafc;
--white: #ffffff;
--danger: #ef4444;
--shadow-lg: 0 18px 40px rgba(15, 23, 42, 0.12);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
min-height: 100vh;
color: var(--dark);
background:
radial-gradient(circle at top left, rgba(79, 70, 229, 0.16), transparent 34%),
linear-gradient(135deg, #f8fafc 0%, #eef2ff 52%, #f8fafc 100%);
}
.login-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.login-panel {
width: min(100%, 430px);
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(226, 232, 240, 0.9);
border-radius: 18px;
box-shadow: var(--shadow-lg);
padding: 34px;
}
.brand-mark {
width: 56px;
height: 56px;
border-radius: 16px;
display: grid;
place-items: center;
color: var(--white);
background: var(--primary);
font-size: 1.45rem;
margin-bottom: 22px;
}
.eyebrow {
color: var(--primary);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 8px;
}
h1 {
font-size: 1.8rem;
line-height: 1.2;
margin-bottom: 10px;
}
.subtitle {
color: var(--gray);
line-height: 1.6;
margin-bottom: 24px;
}
.alert-error {
display: flex;
gap: 10px;
align-items: center;
color: #991b1b;
background-color: #fee2e2;
border: 1px solid #fecaca;
border-radius: 12px;
padding: 12px 14px;
margin-bottom: 18px;
font-size: 0.9rem;
}
.login-form {
display: grid;
gap: 16px;
}
.input-group {
display: grid;
gap: 8px;
}
.input-group span {
color: var(--dark);
font-size: 0.9rem;
font-weight: 700;
}
.input-shell {
height: 48px;
display: flex;
align-items: center;
gap: 12px;
border: 1px solid var(--gray-light);
border-radius: 12px;
background: var(--light);
padding: 0 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.input-shell:focus-within {
background: var(--white);
border-color: rgba(79, 70, 229, 0.45);
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1);
}
.input-shell i {
color: var(--primary);
}
.input-shell input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--dark);
font-size: 0.96rem;
}
.btn-login {
height: 48px;
border: 0;
border-radius: 12px;
background: var(--primary);
color: var(--white);
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
font-size: 0.98rem;
font-weight: 800;
margin-top: 4px;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.btn-login:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
.login-hint {
color: var(--gray);
font-size: 0.86rem;
margin-top: 18px;
text-align: center;
}
@media (max-width: 520px) {
.login-page {
padding: 16px;
}
.login-panel {
padding: 26px;
border-radius: 16px;
}
h1 {
font-size: 1.5rem;
}
}
+135
View File
@@ -0,0 +1,135 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #172033;
background: #eef2f7;
font-family: Arial, Helvetica, sans-serif;
font-size: 12px;
}
.report-page {
width: min(1180px, calc(100% - 32px));
margin: 24px auto;
background: #fff;
padding: 28px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
}
.report-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
border-bottom: 2px solid #1f2937;
padding-bottom: 18px;
margin-bottom: 18px;
}
.eyebrow {
margin: 0 0 6px;
color: #4f46e5;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1 {
margin: 0 0 8px;
font-size: 26px;
}
.print-btn {
border: 0;
border-radius: 8px;
background: #4f46e5;
color: white;
cursor: pointer;
font-weight: 700;
padding: 10px 14px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 14px;
}
.summary-grid div {
border: 1px solid #d8dee9;
border-left: 4px solid #4f46e5;
padding: 12px;
}
.summary-grid span {
display: block;
color: #667085;
margin-bottom: 6px;
}
.summary-grid strong {
font-size: 22px;
}
.report-note {
background: #f8fafc;
border: 1px solid #d8dee9;
padding: 10px 12px;
margin-bottom: 16px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border: 1px solid #d8dee9;
padding: 8px;
vertical-align: top;
}
th {
background: #f1f5f9;
text-align: left;
}
small {
color: #667085;
}
.empty {
text-align: center;
color: #667085;
padding: 24px;
}
@media print {
body {
background: white;
}
.report-page {
width: 100%;
margin: 0;
padding: 0;
box-shadow: none;
}
.print-btn {
display: none;
}
table {
page-break-inside: auto;
}
tr {
page-break-inside: avoid;
page-break-after: auto;
}
}
File diff suppressed because it is too large Load Diff
+793
View File
@@ -0,0 +1,793 @@
// Globals
let map;
let layerRI, layerPM, layerRadius;
let riData = [];
let pmData = [];
let pickingMode = null; // 'ri' or 'pm'
let tempMarker = null;
let currentView = 'dashboard';
let searchTerm = '';
const rumahIbadahIconConfig = {
Masjid: { icon: 'fa-mosque', className: 'mosque' },
Gereja: { icon: 'fa-church', className: 'church' },
Pura: { icon: 'fa-gopuram', className: 'temple' },
Vihara: { icon: 'fa-vihara', className: 'vihara' },
Klenteng: { icon: 'fa-torii-gate', className: 'klenteng' },
Lainnya: { icon: 'fa-place-of-worship', className: 'other' }
};
function getRumahIbadahIcon(jenis) {
const config = rumahIbadahIconConfig[jenis] || rumahIbadahIconConfig.Lainnya;
return L.divIcon({
className: 'ri-div-icon',
html: `<div class="ri-marker ri-${config.className}" title="${jenis || 'Rumah Ibadah'}"><i class="fas ${config.icon}"></i></div>`,
iconSize: [36, 46],
iconAnchor: [18, 43],
popupAnchor: [0, -43]
});
}
// PM Icons are now circleMarkers generated inline in renderPM
document.addEventListener('DOMContentLoaded', async () => {
await initMap();
initUI();
loadData();
});
async function initMap() {
map = L.map('map', {
zoomControl: false,
preferCanvas: true
}).setView([-0.060547, 109.344902], 13);
window.map = map;
L.control.zoom({ position: 'bottomright' }).addTo(map);
const tileOptions = {
keepBuffer: 6,
updateWhenIdle: false,
updateWhenZooming: false,
updateInterval: 100
};
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
...tileOptions,
attribution: '&copy; OpenStreetMap contributors'
});
const satelit = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
...tileOptions,
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
});
const darkMap = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
...tileOptions,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 20
});
osm.addTo(map);
const baseMaps = {
"OSM Standard": osm,
"Citra Satelit": satelit,
"Dark Mode": darkMap
};
layerRadius = L.featureGroup().addTo(map);
layerRI = L.featureGroup().addTo(map);
layerPM = L.featureGroup().addTo(map);
const overlays = {
"Radius Layanan": layerRadius,
"Rumah Ibadah": layerRI,
"Penduduk Miskin": layerPM
};
L.control.layers(baseMaps, overlays, { position: 'topright' }).addTo(map);
// Map Click for Picking Location
map.on('click', async (e) => {
if (!pickingMode) return;
const lat = e.latlng.lat;
const lng = e.latlng.lng;
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = L.marker([lat, lng]).addTo(map);
if (pickingMode === 'ri') {
document.getElementById('ri-lat').value = lat.toFixed(6);
document.getElementById('ri-lng').value = lng.toFixed(6);
document.getElementById('ri-alamat').value = 'Mencari alamat...';
document.getElementById('ri-alamat').value = await reverseGeocode(lat, lng);
document.getElementById('modal-ri').classList.add('show');
} else if (pickingMode === 'pm') {
document.getElementById('pm-lat').value = lat.toFixed(6);
document.getElementById('pm-lng').value = lng.toFixed(6);
document.getElementById('pm-alamat').value = 'Mencari alamat...';
document.getElementById('pm-alamat').value = await reverseGeocode(lat, lng);
document.getElementById('modal-pm').classList.add('show');
}
pickingMode = null;
if(tempMarker) map.removeLayer(tempMarker);
});
}
function initUI() {
// Sidebar Toggle
document.getElementById('toggle-sidebar').addEventListener('click', () => {
const sidebar = document.getElementById('sidebar');
if (window.innerWidth <= 768) {
sidebar.classList.toggle('open');
} else {
sidebar.classList.toggle('collapsed');
}
});
document.querySelectorAll('.menu-item[data-view]').forEach(item => {
item.addEventListener('click', () => switchView(item.dataset.view));
});
// Menus
const openAddRI = () => {
switchView('map');
document.getElementById('form-ri').reset();
document.getElementById('ri-id').value = '';
document.getElementById('modal-ri-title').innerText = 'Tambah Rumah Ibadah';
document.getElementById('modal-ri').classList.add('show');
};
document.getElementById('menu-add-ri').addEventListener('click', openAddRI);
document.getElementById('fab-add-ri')?.addEventListener('click', openAddRI);
const openAddPM = () => {
switchView('map');
document.getElementById('form-pm').reset();
document.getElementById('pm-id').value = '';
document.getElementById('anggota-container').innerHTML = '';
document.getElementById('bantuan-fields').style.display = 'none';
document.getElementById('modal-pm-title').innerText = 'Tambah Penduduk Miskin';
document.getElementById('modal-pm').classList.add('show');
};
document.getElementById('menu-add-pm').addEventListener('click', openAddPM);
document.getElementById('fab-add-pm')?.addEventListener('click', openAddPM);
document.getElementById('dashboard-add-ri')?.addEventListener('click', openAddRI);
document.getElementById('dashboard-add-pm')?.addEventListener('click', openAddPM);
document.getElementById('dashboard-open-map')?.addEventListener('click', () => switchView('map'));
// Filters
document.getElementById('filter-status').addEventListener('change', renderMapLayers);
document.getElementById('filter-jenis').addEventListener('change', renderMapLayers);
document.getElementById('filter-kebutuhan').addEventListener('change', renderPM);
// Search
document.getElementById('search-input').addEventListener('input', () => {
searchTerm = getSearchTerm();
renderMapLayers();
});
document.getElementById('search-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
applyMapSearch(true);
}
});
document.getElementById('search-button').addEventListener('click', () => applyMapSearch(true));
// Location
document.getElementById('btn-my-location').addEventListener('click', () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
map.flyTo([lat, lng], 16);
L.marker([lat, lng]).addTo(map).bindPopup('Lokasi Anda').openPopup();
},
(err) => alert('Gagal mendapatkan lokasi: ' + err.message)
);
} else {
alert('Geolocation tidak didukung browser ini.');
}
});
switchView(currentView);
}
function switchView(view) {
currentView = view;
const mainContent = document.querySelector('.main-content');
document.querySelectorAll('.view-panel').forEach(panel => {
panel.classList.toggle('active', panel.id === `${view}-view`);
});
document.querySelectorAll('.menu-item[data-view]').forEach(item => {
item.classList.toggle('active', item.dataset.view === view);
});
mainContent?.classList.toggle('map-mode', view === 'map');
mainContent?.classList.toggle('dashboard-mode', view !== 'map');
document.querySelector('.search-bar')?.classList.toggle('hidden', view !== 'map');
document.querySelector('.map-header-controls')?.classList.toggle('hidden', view !== 'map');
if (window.innerWidth <= 768) {
document.getElementById('sidebar').classList.remove('open');
}
if (view === 'map' && map) {
refreshMapSize();
}
}
function refreshMapSize() {
[0, 100, 300].forEach(delay => {
setTimeout(() => map.invalidateSize({ pan: false }), delay);
});
}
function getSearchTerm() {
return document.getElementById('search-input').value.trim().toLowerCase();
}
function textIncludes(value, term) {
return String(value || '').toLowerCase().includes(term);
}
function matchesRI(ri) {
if (!searchTerm) return true;
return [
ri.nama,
ri.jenis,
ri.kontak,
ri.alamat
].some(value => textIncludes(value, searchTerm));
}
function matchesPM(pm) {
if (!searchTerm) return true;
const anggotaMatch = Array.isArray(pm.anggota) && pm.anggota.some(anggota => [
anggota.nama,
anggota.hubungan,
anggota.pekerjaan
].some(value => textIncludes(value, searchTerm)));
return anggotaMatch || [
pm.kk_nama,
pm.kk_nik,
pm.alamat,
pm.status_bantuan,
pm.jenis_bantuan,
pm.kategori_kebutuhan,
pm.deskripsi_kebutuhan,
pm.tindak_lanjut,
pm.kondisi_rumah,
pm.rumah_ibadah_nama
].some(value => textIncludes(value, searchTerm));
}
function getFilteredRI() {
const filterJenis = document.getElementById('filter-jenis').value;
return riData.filter(ri => {
if (filterJenis !== 'all' && ri.jenis !== filterJenis) return false;
return matchesRI(ri);
});
}
function getFilteredPM() {
const filterStatus = document.getElementById('filter-status').value;
const filterKebutuhan = document.getElementById('filter-kebutuhan').value;
return pmData.filter(pm => {
if (filterStatus !== 'all' && pm.status_bantuan !== filterStatus) return false;
if (filterKebutuhan !== 'all' && pm.kategori_kebutuhan !== filterKebutuhan) return false;
return matchesPM(pm);
});
}
function renderMapLayers() {
renderRI();
renderPM();
}
function applyMapSearch(focusResult = false) {
searchTerm = getSearchTerm();
renderMapLayers();
if (focusResult) {
switchView('map');
focusSearchResults();
}
}
function focusSearchResults() {
const results = [
...getFilteredRI().map(item => [parseFloat(item.lat), parseFloat(item.lng)]),
...getFilteredPM().map(item => [parseFloat(item.lat), parseFloat(item.lng)])
].filter(([lat, lng]) => Number.isFinite(lat) && Number.isFinite(lng));
if (!searchTerm || results.length === 0) {
alert(searchTerm ? 'Data tidak ditemukan di peta.' : 'Masukkan kata kunci pencarian.');
return;
}
if (results.length === 1) {
map.flyTo(results[0], 16);
return;
}
map.fitBounds(L.latLngBounds(results), { padding: [40, 40], maxZoom: 16 });
}
function pickLocation(mode) {
switchView('map');
pickingMode = mode;
closeModal('modal-' + mode);
}
function closeModal(id) {
document.getElementById(id).classList.remove('show');
}
async function reverseGeocode(lat, lng) {
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`);
const data = await res.json();
return data.display_name || 'Alamat tidak ditemukan';
} catch (e) {
return 'Gagal memuat alamat';
}
}
function toggleBantuanFields() {
const status = document.getElementById('pm-status').value;
document.getElementById('bantuan-fields').style.display = status === 'Sudah dibantu' ? 'block' : 'none';
}
function addAnggotaField(data = null) {
const container = document.getElementById('anggota-container');
const id = Date.now() + Math.floor(Math.random() * 100);
const html = `
<div class="dynamic-item" id="anggota-${id}">
<button type="button" class="btn-remove" onclick="document.getElementById('anggota-${id}').remove()">X</button>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div class="form-group">
<label>Nama</label>
<input type="text" class="form-control a-nama" value="${data ? data.nama : ''}" required>
</div>
<div class="form-group">
<label>Hubungan</label>
<input type="text" class="form-control a-hubungan" value="${data ? data.hubungan : ''}" placeholder="Anak/Istri/dll" required>
</div>
<div class="form-group">
<label>Umur</label>
<input type="number" class="form-control a-umur" value="${data ? data.umur : ''}">
</div>
<div class="form-group">
<label>Pekerjaan</label>
<input type="text" class="form-control a-pekerjaan" value="${data && data.pekerjaan ? data.pekerjaan : ''}">
</div>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
}
// Data Fetching and Rendering
async function loadData() {
await loadStats();
await fetchRI();
await fetchPM();
}
async function loadStats() {
try {
const res = await fetch('api/dashboard_stats.php');
const json = await res.json();
if(json.success) {
document.getElementById('stat-ri').innerText = json.data.total_rumah_ibadah;
document.getElementById('stat-pm').innerText = json.data.total_penduduk;
document.getElementById('stat-sudah').innerText = json.data.sudah_dibantu;
document.getElementById('stat-belum').innerText = json.data.belum_dibantu;
document.getElementById('stat-menunggu').innerText = json.data.menunggu_verifikasi || 0;
document.getElementById('stat-prioritas').innerText = json.data.prioritas_tinggi || 0;
document.getElementById('stat-luar-radius').innerText = json.data.luar_radius || 0;
document.getElementById('summary-sudah').innerText = json.data.sudah_dibantu;
document.getElementById('summary-belum').innerText = json.data.belum_dibantu;
document.getElementById('summary-menunggu').innerText = json.data.menunggu_verifikasi || 0;
renderDashboardLists(json.data);
}
} catch(e) { console.error(e); }
}
function renderDashboardLists(stats) {
const priorityList = document.getElementById('priority-list');
const needList = document.getElementById('need-list');
if (priorityList) {
const items = stats.top_prioritas || [];
priorityList.innerHTML = items.length ? items.map(item => `
<div class="priority-item">
<div class="priority-score">${item.skor_prioritas || 0}</div>
<div>
<strong>${item.kk_nama}</strong>
<p>${item.kategori_kebutuhan || 'Kebutuhan belum diisi'} - ${item.tindak_lanjut || 'Belum disurvei'}</p>
</div>
</div>
`).join('') : '<div class="need-item"><p>Belum ada data prioritas.</p></div>';
}
if (needList) {
const items = stats.kebutuhan_terbanyak || [];
needList.innerHTML = items.length ? items.map(item => `
<div class="need-item">
<strong>${item.kategori}</strong>
<p>${item.total} keluarga membutuhkan kategori ini</p>
</div>
`).join('') : '<div class="need-item"><p>Belum ada data kebutuhan.</p></div>';
}
}
async function fetchRI() {
try {
const res = await fetch('api/rumah_ibadah_read.php');
const json = await res.json();
if(json.success) {
riData = json.data;
renderRI();
}
} catch(e) { console.error(e); }
}
async function fetchPM() {
try {
const res = await fetch('api/penduduk_read.php');
const json = await res.json();
if(json.success) {
pmData = json.data;
renderPM();
}
} catch(e) { console.error(e); }
}
function renderRI() {
layerRI.clearLayers();
layerRadius.clearLayers();
getFilteredRI().forEach(ri => {
const lat = parseFloat(ri.lat);
const lng = parseFloat(ri.lng);
const rad = parseInt(ri.radius) || 500;
// Circle
L.circle([lat, lng], {
color: '#4f46e5',
fillColor: '#4f46e5',
fillOpacity: 0.1,
radius: rad,
interactive: false
}).addTo(layerRadius);
// Marker
const marker = L.marker([lat, lng], { icon: getRumahIbadahIcon(ri.jenis) }).addTo(layerRI);
marker.feature = ri;
let popup = `
<div class="popup-header">
<h3>${ri.nama}</h3>
<p>${ri.jenis}</p>
</div>
<div class="popup-body">
<div class="popup-row"><div class="label">Kontak</div><div class="value">${ri.kontak || '-'}</div></div>
<div class="popup-row"><div class="label">Radius</div><div class="value">${rad} m</div></div>
<div class="popup-row" style="flex-direction: column;"><div class="label" style="width:100%">Alamat</div><div class="value" style="font-size:0.8rem; font-weight:400">${ri.alamat}</div></div>
<div class="popup-actions">
<button class="btn-edit" onclick='editRI(${JSON.stringify(ri)})'>Edit</button>
<button class="btn-delete" onclick='deleteRI(${ri.id})'>Hapus</button>
</div>
</div>
`;
marker.bindPopup(popup);
});
layerRadius.eachLayer(circle => circle.bringToBack());
}
function renderPM() {
layerPM.clearLayers();
getFilteredPM().forEach(pm => {
const lat = parseFloat(pm.lat);
const lng = parseFloat(pm.lng);
const anggotaCount = pm.anggota ? pm.anggota.length : 0;
let badgeClass = 'badge-danger';
if(pm.status_bantuan === 'Sudah dibantu') badgeClass = 'badge-success';
else if(pm.status_bantuan === 'Menunggu verifikasi') badgeClass = 'badge-warning';
const isBlankSpot = pm.is_blank_spot == 1;
const pmColor = isBlankSpot ? '#ef4444' : '#10b981'; // Merah (Blank Spot), Hijau (Terjangkau)
const riwayatHtml = Array.isArray(pm.riwayat_bantuan) && pm.riwayat_bantuan.length
? pm.riwayat_bantuan.slice(0, 3).map(r => `${r.tanggal_bantuan}: ${r.jenis_bantuan}`).join('<br>')
: '-';
const buktiHtml = pm.bukti_file
? `<a href="${pm.bukti_file}" target="_blank">Lihat bukti</a>`
: '-';
const marker = L.circleMarker([lat, lng], {
radius: 7,
fillColor: pmColor,
color: '#ffffff',
weight: 1.5,
opacity: 1,
fillOpacity: 0.9
}).addTo(layerPM);
marker.feature = pm;
let popup = `
<div class="popup-header ${pm.status_bantuan === 'Sudah dibantu' ? 'success' : pm.status_bantuan === 'Menunggu verifikasi' ? 'warning' : 'danger'}">
<h3>Keluarga ${pm.kk_nama}</h3>
<span class="badge ${badgeClass}">${pm.status_bantuan}</span>
</div>
<div class="popup-body">
<div class="popup-row"><div class="label">Jumlah Anggota</div><div class="value">${anggotaCount} orang</div></div>
<div class="popup-row"><div class="label">Rumah Ibadah Terdekat</div><div class="value">${pm.rumah_ibadah_nama || '-'}</div></div>
<div class="popup-row"><div class="label">Prioritas</div><div class="value">${pm.skor_prioritas || 0} (${pm.label_prioritas || '-'})</div></div>
<div class="popup-row"><div class="label">Kebutuhan</div><div class="value">${pm.kategori_kebutuhan || '-'}</div></div>
<div class="popup-row"><div class="label">Tindak Lanjut</div><div class="value">${pm.tindak_lanjut || 'Belum disurvei'}</div></div>
<div class="popup-row"><div class="label">Jarak Lurus</div><div class="value">${pm.jarak_m ? pm.jarak_m + ' meter' : '-'} ${isBlankSpot ? '<i style="color:red">(Di luar radius)</i>' : '<i style="color:green">(Terjangkau)</i>'}</div></div>
<div class="popup-row"><div class="label">Jarak Rute</div><div class="value">${pm.jarak_rute_m ? pm.jarak_rute_m + ' meter' : '-'}</div></div>
${pm.status_bantuan === 'Sudah dibantu' ? `<div class="popup-row"><div class="label">Bantuan</div><div class="value">${pm.jenis_bantuan} (${pm.tanggal_bantuan})</div></div>` : ''}
<div class="popup-row"><div class="label">Riwayat</div><div class="value">${riwayatHtml}</div></div>
<div class="popup-row"><div class="label">Bukti</div><div class="value">${buktiHtml}</div></div>
<div class="popup-row" style="flex-direction: column;"><div class="label" style="width:100%">Alamat</div><div class="value" style="font-size:0.8rem; font-weight:400">${pm.alamat}</div></div>
<div class="popup-actions">
<button class="btn-edit" onclick='editPM(${JSON.stringify(pm).replace(/'/g, "\\'")})'>Edit</button>
<button class="btn-edit" onclick='openBantuanModal(${pm.id})'>Bantuan</button>
<button class="btn-delete" onclick='deletePM(${pm.id})'>Hapus</button>
</div>
</div>
`;
marker.bindPopup(popup);
});
layerPM.bringToFront();
}
// CRUD Operations
async function saveRI() {
const id = document.getElementById('ri-id').value;
const data = {
nama: document.getElementById('ri-nama').value,
jenis: document.getElementById('ri-jenis').value,
kontak: document.getElementById('ri-kontak').value,
lat: document.getElementById('ri-lat').value,
lng: document.getElementById('ri-lng').value,
alamat: document.getElementById('ri-alamat').value,
radius: document.getElementById('ri-radius').value
};
if(!data.nama || !data.lat) { alert("Isi form dengan lengkap"); return; }
const url = id ? 'api/rumah_ibadah_update.php' : 'api/rumah_ibadah_create.php';
if(id) data.id = id;
try {
const res = await fetch(url, {
method: 'POST',
body: JSON.stringify(data)
});
const json = await res.json();
if(json.success) {
closeModal('modal-ri');
await fetchRI();
await fetchPM();
await loadStats();
} else {
alert(json.message);
}
} catch(e) { alert('Error saving data'); }
}
async function savePM() {
const id = document.getElementById('pm-id').value;
const data = new FormData();
const anggota = [];
data.append('kk_nama', document.getElementById('pm-nama').value);
data.append('kk_nik', document.getElementById('pm-nik').value);
data.append('lat', document.getElementById('pm-lat').value);
data.append('lng', document.getElementById('pm-lng').value);
data.append('alamat', document.getElementById('pm-alamat').value);
data.append('kategori_kebutuhan', document.getElementById('pm-kategori-kebutuhan').value);
data.append('deskripsi_kebutuhan', document.getElementById('pm-deskripsi-kebutuhan').value);
data.append('kondisi_rumah', document.getElementById('pm-kondisi-rumah').value);
data.append('penghasilan', document.getElementById('pm-penghasilan').value);
data.append('status_bantuan', document.getElementById('pm-status').value);
data.append('jenis_bantuan', document.getElementById('pm-jenis-bantuan').value);
data.append('tanggal_bantuan', document.getElementById('pm-tanggal-bantuan').value);
data.append('tindak_lanjut', document.getElementById('pm-tindak-lanjut').value);
data.append('catatan_survei', document.getElementById('pm-catatan-survei').value);
data.append('existing_bukti_file', document.getElementById('pm-existing-bukti-file').value);
const buktiFile = document.getElementById('pm-bukti-file').files[0];
if (buktiFile) data.append('bukti_file', buktiFile);
if(!data.get('kk_nama') || !data.get('lat')) { alert("Isi form dengan lengkap"); return; }
if (data.get('penghasilan') !== '' && Number(data.get('penghasilan')) < 0) {
alert('Penghasilan tidak boleh bernilai negatif.');
document.getElementById('pm-penghasilan').focus();
return;
}
const items = document.querySelectorAll('.dynamic-item');
items.forEach(el => {
anggota.push({
nama: el.querySelector('.a-nama').value,
hubungan: el.querySelector('.a-hubungan').value,
umur: el.querySelector('.a-umur').value,
pekerjaan: el.querySelector('.a-pekerjaan').value
});
});
data.append('anggota', JSON.stringify(anggota));
const url = id ? 'api/penduduk_update.php' : 'api/penduduk_create.php';
if(id) data.append('id', id);
try {
const res = await fetch(url, {
method: 'POST',
body: data
});
const json = await res.json();
if(json.success) {
closeModal('modal-pm');
await fetchPM();
await loadStats();
if(json.ibadah_id) {
const ri = riData.find(r => r.id == json.ibadah_id);
if(ri) alert(`Warga otomatis dimasukkan ke binaan: ${ri.nama}`);
}
} else {
alert(json.message);
}
} catch(e) { alert('Error saving data'); }
}
function editRI(data) {
document.getElementById('ri-id').value = data.id;
document.getElementById('ri-nama').value = data.nama;
document.getElementById('ri-jenis').value = data.jenis;
document.getElementById('ri-kontak').value = data.kontak;
document.getElementById('ri-lat').value = data.lat;
document.getElementById('ri-lng').value = data.lng;
document.getElementById('ri-alamat').value = data.alamat;
document.getElementById('ri-radius').value = data.radius;
document.getElementById('modal-ri-title').innerText = 'Edit Rumah Ibadah';
document.getElementById('modal-ri').classList.add('show');
}
function editPM(data) {
document.getElementById('pm-id').value = data.id;
document.getElementById('pm-nama').value = data.kk_nama;
document.getElementById('pm-nik').value = data.kk_nik;
document.getElementById('pm-lat').value = data.lat;
document.getElementById('pm-lng').value = data.lng;
document.getElementById('pm-alamat').value = data.alamat;
document.getElementById('pm-kategori-kebutuhan').value = data.kategori_kebutuhan || '';
document.getElementById('pm-deskripsi-kebutuhan').value = data.deskripsi_kebutuhan || '';
document.getElementById('pm-kondisi-rumah').value = data.kondisi_rumah || '';
document.getElementById('pm-penghasilan').value = data.penghasilan || '';
document.getElementById('pm-status').value = data.status_bantuan;
document.getElementById('pm-jenis-bantuan').value = data.jenis_bantuan;
document.getElementById('pm-tanggal-bantuan').value = data.tanggal_bantuan;
document.getElementById('pm-tindak-lanjut').value = data.tindak_lanjut || 'Belum disurvei';
document.getElementById('pm-catatan-survei').value = data.catatan_survei || '';
document.getElementById('pm-existing-bukti-file').value = data.bukti_file || '';
document.getElementById('pm-bukti-file').value = '';
toggleBantuanFields();
document.getElementById('anggota-container').innerHTML = '';
if(data.anggota && data.anggota.length > 0) {
data.anggota.forEach(a => addAnggotaField(a));
}
document.getElementById('modal-pm-title').innerText = 'Edit Penduduk Miskin';
document.getElementById('modal-pm').classList.add('show');
}
function setBantuanAlert(message = '', type = 'error') {
const alertBox = document.getElementById('bantuan-alert');
if (!alertBox) return;
alertBox.textContent = message;
alertBox.hidden = !message;
alertBox.classList.toggle('success', type === 'success');
}
function openBantuanModal(id) {
const penerima = pmData.find(pm => String(pm.id) === String(id));
const form = document.getElementById('form-bantuan');
form.reset();
setBantuanAlert();
document.getElementById('bantuan-penduduk-id').value = id;
document.getElementById('bantuan-penerima').value = penerima ? `Keluarga ${penerima.kk_nama}` : `ID ${id}`;
document.getElementById('bantuan-jenis').value = penerima?.kategori_kebutuhan || '';
document.getElementById('bantuan-tanggal').value = new Date().toISOString().slice(0, 10);
document.getElementById('bantuan-pemberi').value = 'Admin';
document.getElementById('bantuan-bukti-file').value = '';
document.getElementById('btn-save-bantuan').disabled = false;
document.getElementById('modal-bantuan').classList.add('show');
}
async function saveBantuan() {
const id = document.getElementById('bantuan-penduduk-id').value;
const jenis = document.getElementById('bantuan-jenis').value.trim();
const tanggal = document.getElementById('bantuan-tanggal').value;
const nilai = document.getElementById('bantuan-nilai').value;
const pemberi = document.getElementById('bantuan-pemberi').value.trim() || 'Admin';
const catatan = document.getElementById('bantuan-catatan').value.trim();
const buktiFile = document.getElementById('bantuan-bukti-file').files[0];
const saveButton = document.getElementById('btn-save-bantuan');
if (!id || !jenis || !tanggal) {
setBantuanAlert('Keluarga penerima, jenis bantuan, dan tanggal wajib diisi.');
return;
}
const formData = new FormData();
formData.append('penduduk_id', id);
formData.append('jenis_bantuan', jenis);
formData.append('tanggal_bantuan', tanggal);
formData.append('nilai_bantuan', nilai || '');
formData.append('pemberi_bantuan', pemberi);
formData.append('catatan', catatan || '');
if (buktiFile) formData.append('bukti_file', buktiFile);
try {
saveButton.disabled = true;
saveButton.innerText = 'Menyimpan...';
const res = await fetch('api/bantuan_create.php', { method: 'POST', body: formData });
const json = await res.json();
if (json.success) {
closeModal('modal-bantuan');
await fetchPM();
await loadStats();
renderMapLayers();
} else {
setBantuanAlert(json.message || 'Gagal menambah bantuan.');
}
} catch(e) {
setBantuanAlert('Terjadi kesalahan saat menyimpan bantuan.');
} finally {
saveButton.disabled = false;
saveButton.innerText = 'Simpan Bantuan';
}
}
async function deleteRI(id) {
if(!confirm('Hapus rumah ibadah ini?')) return;
try {
const res = await fetch('api/rumah_ibadah_delete.php', { method:'POST', body:JSON.stringify({id}) });
const json = await res.json();
if(json.success) { fetchRI(); fetchPM(); loadStats(); }
} catch(e) { alert('Error'); }
}
async function deletePM(id) {
if(!confirm('Hapus penduduk miskin ini?')) return;
try {
const res = await fetch('api/penduduk_delete.php', { method:'POST', body:JSON.stringify({id}) });
const json = await res.json();
if(json.success) { fetchPM(); loadStats(); }
} catch(e) { alert('Error'); }
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=index.php">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS Peduli</title>
</head>
<body>
<script>window.location.replace('index.php');</script>
<p>Mengalihkan ke halaman sistem...</p>
</body>
</html>
+498
View File
@@ -0,0 +1,498 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/api/auth.php';
require_admin_login();
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS Sebaran Penduduk Miskin</title>
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="assets/vendor/leaflet/leaflet.css" />
<!-- Custom CSS -->
<link rel="stylesheet" href="assets/css/style.css?v=<?= filemtime(__DIR__ . '/assets/css/style.css') ?>">
</head>
<body>
<div class="dashboard-container">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<i class="fas fa-map-marked-alt fa-2x" style="color: var(--primary);"></i>
<h2>WebGIS Peduli</h2>
</div>
<div class="sidebar-menu">
<div class="menu-item active" id="menu-dashboard" data-view="dashboard">
<i class="fas fa-home"></i> Dashboard
</div>
<div class="menu-item" id="menu-map" data-view="map">
<i class="fas fa-map"></i> Peta
</div>
<div class="menu-item" id="menu-add-ri">
<i class="fas fa-plus-circle"></i> Tambah Rumah Ibadah
</div>
<div class="menu-item" id="menu-add-pm">
<i class="fas fa-user-plus"></i> Tambah Penduduk
</div>
<a class="menu-item" href="laporan.php">
<i class="fas fa-file-lines"></i> Laporan
</a>
</div>
</aside>
<!-- Main Content -->
<main class="main-content dashboard-mode">
<!-- Topbar -->
<header class="topbar">
<button id="toggle-sidebar" class="btn-icon"><i class="fas fa-bars"></i></button>
<div class="search-bar hidden">
<input type="text" id="search-input" placeholder="Cari nama warga / rumah ibadah...">
<button id="search-button" type="button"><i class="fas fa-search"></i></button>
</div>
<div class="map-header-controls hidden">
<div class="header-filter">
<i class="fas fa-circle-check"></i>
<label>Status</label>
<select id="filter-status">
<option value="all">Semua Status</option>
<option value="Sudah dibantu">Sudah Dibantu</option>
<option value="Belum dibantu">Belum Dibantu</option>
<option value="Menunggu verifikasi">Menunggu Verifikasi</option>
</select>
</div>
<div class="header-filter">
<i class="fas fa-place-of-worship"></i>
<label>Jenis</label>
<select id="filter-jenis">
<option value="all">Semua Jenis</option>
<option value="Masjid">Masjid</option>
<option value="Gereja">Gereja</option>
<option value="Pura">Pura</option>
<option value="Vihara">Vihara</option>
<option value="Klenteng">Klenteng</option>
</select>
</div>
<div class="header-filter">
<i class="fas fa-hand-holding-heart"></i>
<label>Kebutuhan</label>
<select id="filter-kebutuhan">
<option value="all">Semua Kebutuhan</option>
<option value="Sembako">Sembako</option>
<option value="Pendidikan">Pendidikan</option>
<option value="Kesehatan">Kesehatan</option>
<option value="Modal Usaha">Modal Usaha</option>
<option value="Rumah Tidak Layak">Rumah Tidak Layak</option>
<option value="Bantuan Darurat">Bantuan Darurat</option>
</select>
</div>
</div>
<div class="user-profile">
<span><?= htmlspecialchars(current_admin_name(), ENT_QUOTES, 'UTF-8') ?></span>
<div class="avatar"><i class="fas fa-user-shield"></i></div>
<a class="logout-link" href="logout.php" title="Logout"><i class="fas fa-right-from-bracket"></i></a>
</div>
</header>
<!-- Dashboard -->
<section id="dashboard-view" class="view-panel dashboard-view active">
<div class="dashboard-page-header">
<div>
<p class="eyebrow">Ringkasan Data</p>
<h1>Dashboard WebGIS Peduli</h1>
<p>Pantau sebaran warga, status bantuan, dan cakupan layanan rumah ibadah dari satu halaman.</p>
</div>
<div class="dashboard-header-actions">
<button class="btn btn-primary" id="dashboard-open-map"><i class="fas fa-map-location-dot"></i> Buka Peta</button>
</div>
</div>
<div class="stats-container" id="stats-container">
<div class="stat-card">
<h3>Rumah Ibadah</h3>
<div class="value" id="stat-ri">0</div>
</div>
<div class="stat-card">
<h3>Total Warga</h3>
<div class="value" id="stat-pm">0</div>
</div>
<div class="stat-card success">
<h3>Sudah Dibantu</h3>
<div class="value" id="stat-sudah">0</div>
</div>
<div class="stat-card warning">
<h3>Menunggu</h3>
<div class="value" id="stat-menunggu">0</div>
</div>
<div class="stat-card danger">
<h3>Belum Dibantu</h3>
<div class="value" id="stat-belum">0</div>
</div>
<div class="stat-card warning">
<h3>Prioritas Tinggi</h3>
<div class="value" id="stat-prioritas">0</div>
</div>
<div class="stat-card danger">
<h3>Luar Radius</h3>
<div class="value" id="stat-luar-radius">0</div>
</div>
</div>
<div class="dashboard-grid">
<div class="dashboard-card status-card">
<div class="card-title">
<h2>Status Bantuan</h2>
<span>Data keluarga miskin</span>
</div>
<div class="status-list">
<div class="status-item">
<span class="status-dot success"></span>
<div>
<strong id="summary-sudah">0</strong>
<p>Sudah menerima bantuan</p>
</div>
</div>
<div class="status-item">
<span class="status-dot warning"></span>
<div>
<strong id="summary-menunggu">0</strong>
<p>Menunggu verifikasi</p>
</div>
</div>
<div class="status-item">
<span class="status-dot danger"></span>
<div>
<strong id="summary-belum">0</strong>
<p>Belum menerima bantuan</p>
</div>
</div>
</div>
</div>
<div class="dashboard-card">
<div class="card-title">
<h2>Aksi Cepat</h2>
<span>Input dan pemetaan data</span>
</div>
<div class="quick-actions">
<button class="quick-action" id="dashboard-add-ri">
<i class="fas fa-place-of-worship"></i>
<span>Tambah Rumah Ibadah</span>
</button>
<button class="quick-action" id="dashboard-add-pm">
<i class="fas fa-user-plus"></i>
<span>Tambah Penduduk</span>
</button>
<a class="quick-action" href="laporan.php?prioritas=tinggi">
<i class="fas fa-file-export"></i>
<span>Cetak Laporan Prioritas</span>
</a>
</div>
</div>
<div class="dashboard-card map-summary-card">
<div class="card-title">
<h2>Legenda Peta</h2>
<span>Indikator cakupan layanan</span>
</div>
<div class="legend-list">
<div><span class="legend-marker ri"></span> Rumah ibadah dan radius layanan</div>
<div><span class="legend-marker reached"></span> Warga dalam radius layanan</div>
<div><span class="legend-marker blank"></span> Warga di luar radius layanan</div>
</div>
</div>
<div class="dashboard-card">
<div class="card-title">
<h2>Prioritas Bantuan</h2>
<span>Urutan paling mendesak</span>
</div>
<div class="priority-list" id="priority-list"></div>
</div>
<div class="dashboard-card">
<div class="card-title">
<h2>Kebutuhan Terbanyak</h2>
<span>Basis penyaluran bantuan</span>
</div>
<div class="need-list" id="need-list"></div>
</div>
</div>
</section>
<!-- Peta -->
<section id="map-view" class="view-panel map-view">
<div id="map" class="map-fullscreen"></div>
<button id="btn-my-location" class="fab-location" title="Lokasi Saya"><i class="fas fa-crosshairs"></i></button>
<div class="fab-container">
<button class="fab-main" title="Tambah Data"><i class="fas fa-plus"></i></button>
<div class="fab-menu">
<button class="fab-item" id="fab-add-ri" title="Tambah Rumah Ibadah"><i class="fas fa-place-of-worship"></i></button>
<button class="fab-item" id="fab-add-pm" title="Tambah Penduduk Miskin"><i class="fas fa-user-plus"></i></button>
</div>
</div>
</section>
</main>
</div>
<!-- Modal Rumah Ibadah -->
<div class="modal-backdrop" id="modal-ri">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-ri-title">Tambah Rumah Ibadah</h2>
<button class="btn-close" onclick="closeModal('modal-ri')"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<form id="form-ri">
<input type="hidden" id="ri-id">
<div class="form-group">
<label>Nama Rumah Ibadah</label>
<input type="text" id="ri-nama" class="form-control" required>
</div>
<div class="form-group">
<label>Jenis</label>
<select id="ri-jenis" class="form-control" required>
<option value="Masjid">Masjid</option>
<option value="Gereja">Gereja</option>
<option value="Pura">Pura</option>
<option value="Vihara">Vihara</option>
<option value="Klenteng">Klenteng</option>
<option value="Lainnya">Lainnya</option>
</select>
</div>
<div class="form-group">
<label>Kontak</label>
<input type="text" id="ri-kontak" class="form-control">
</div>
<div class="form-group" style="display: flex; gap: 10px;">
<div style="flex: 1;">
<label>Latitude</label>
<input type="number" step="any" id="ri-lat" class="form-control" required readonly>
</div>
<div style="flex: 1;">
<label>Longitude</label>
<input type="number" step="any" id="ri-lng" class="form-control" required readonly>
</div>
<div style="display: flex; align-items: flex-end;">
<button type="button" class="btn btn-secondary" onclick="pickLocation('ri')"><i class="fas fa-map-marker-alt"></i></button>
</div>
</div>
<div class="form-group">
<label>Alamat (Otomatis)</label>
<textarea id="ri-alamat" class="form-control" rows="2" readonly></textarea>
</div>
<div class="form-group">
<label>Radius Pelayanan (meter)</label>
<input type="number" id="ri-radius" class="form-control" value="500" required>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-ri')">Batal</button>
<button class="btn btn-primary" onclick="saveRI()">Simpan</button>
</div>
</div>
</div>
<!-- Modal Penduduk Miskin -->
<div class="modal-backdrop" id="modal-pm">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-pm-title">Tambah Penduduk Miskin</h2>
<button class="btn-close" onclick="closeModal('modal-pm')"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<form id="form-pm">
<input type="hidden" id="pm-id">
<h3 style="margin-bottom: 12px; border-bottom: 1px solid #eee; padding-bottom: 8px;">Data Kepala Keluarga</h3>
<div class="form-group">
<label>Nama Kepala Keluarga</label>
<input type="text" id="pm-nama" class="form-control" required>
</div>
<div class="form-group">
<label>NIK</label>
<input type="text" id="pm-nik" class="form-control">
</div>
<div class="form-group" style="display: flex; gap: 10px;">
<div style="flex: 1;">
<label>Latitude</label>
<input type="number" step="any" id="pm-lat" class="form-control" required readonly>
</div>
<div style="flex: 1;">
<label>Longitude</label>
<input type="number" step="any" id="pm-lng" class="form-control" required readonly>
</div>
<div style="display: flex; align-items: flex-end;">
<button type="button" class="btn btn-secondary" onclick="pickLocation('pm')"><i class="fas fa-map-marker-alt"></i></button>
</div>
</div>
<div class="form-group">
<label>Alamat</label>
<textarea id="pm-alamat" class="form-control" rows="2" readonly></textarea>
</div>
<h3 style="margin: 20px 0 12px; border-bottom: 1px solid #eee; padding-bottom: 8px;">Kebutuhan & Kondisi</h3>
<div class="form-group">
<label>Kategori Kebutuhan</label>
<select id="pm-kategori-kebutuhan" class="form-control">
<option value="">Pilih Kebutuhan</option>
<option value="Sembako">Sembako</option>
<option value="Pendidikan">Pendidikan</option>
<option value="Kesehatan">Kesehatan</option>
<option value="Modal Usaha">Modal Usaha</option>
<option value="Rumah Tidak Layak">Rumah Tidak Layak</option>
<option value="Bantuan Darurat">Bantuan Darurat</option>
</select>
</div>
<div class="form-group">
<label>Deskripsi Kebutuhan</label>
<textarea id="pm-deskripsi-kebutuhan" class="form-control" rows="2" placeholder="Contoh: butuh sembako bulanan, biaya obat, seragam sekolah"></textarea>
</div>
<div class="form-group" style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div>
<label>Kondisi Rumah</label>
<select id="pm-kondisi-rumah" class="form-control">
<option value="">Pilih Kondisi</option>
<option value="Layak">Layak</option>
<option value="Rentan">Rentan</option>
<option value="Tidak Layak">Tidak Layak</option>
</select>
</div>
<div>
<label>Penghasilan / Bulan</label>
<input type="number" id="pm-penghasilan" class="form-control" min="0" inputmode="numeric" placeholder="0">
</div>
</div>
<h3 style="margin: 20px 0 12px; border-bottom: 1px solid #eee; padding-bottom: 8px;">Status Bantuan</h3>
<div class="form-group">
<label>Status</label>
<select id="pm-status" class="form-control" onchange="toggleBantuanFields()" required>
<option value="Belum dibantu">Belum Dibantu</option>
<option value="Menunggu verifikasi">Menunggu Verifikasi</option>
<option value="Sudah dibantu">Sudah Dibantu</option>
</select>
</div>
<div id="bantuan-fields" style="display: none;">
<div class="form-group">
<label>Jenis Bantuan</label>
<input type="text" id="pm-jenis-bantuan" class="form-control">
</div>
<div class="form-group">
<label>Tanggal Bantuan</label>
<input type="date" id="pm-tanggal-bantuan" class="form-control">
</div>
</div>
<h3 style="margin: 20px 0 12px; border-bottom: 1px solid #eee; padding-bottom: 8px;">Survey Lapangan</h3>
<div class="form-group">
<label>Status Tindak Lanjut</label>
<select id="pm-tindak-lanjut" class="form-control">
<option value="Belum disurvei">Belum Disurvei</option>
<option value="Perlu survei">Perlu Survei</option>
<option value="Sudah disurvei">Sudah Disurvei</option>
<option value="Sudah diverifikasi">Sudah Diverifikasi</option>
<option value="Sedang diproses">Sedang Diproses</option>
<option value="Sudah dibantu">Sudah Dibantu</option>
</select>
</div>
<div class="form-group">
<label>Catatan Survey</label>
<textarea id="pm-catatan-survei" class="form-control" rows="2"></textarea>
</div>
<div class="form-group">
<label>Bukti / Foto / Dokumen</label>
<input type="file" id="pm-bukti-file" class="form-control" accept="image/*,.pdf">
<input type="hidden" id="pm-existing-bukti-file">
</div>
<h3 style="margin: 20px 0 12px; border-bottom: 1px solid #eee; padding-bottom: 8px; display: flex; justify-content: space-between;">
Anggota Keluarga
<button type="button" class="btn btn-secondary" style="padding: 4px 8px; font-size: 0.8rem;" onclick="addAnggotaField()"><i class="fas fa-plus"></i> Tambah</button>
</h3>
<div id="anggota-container" class="dynamic-list">
<!-- Dynamic fields here -->
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-pm')">Batal</button>
<button class="btn btn-primary" onclick="savePM()">Simpan</button>
</div>
</div>
</div>
<!-- Modal Riwayat Bantuan -->
<div class="modal-backdrop" id="modal-bantuan">
<div class="modal-content modal-content-sm">
<div class="modal-header">
<div>
<p class="modal-kicker">Penyaluran Bantuan</p>
<h2 id="modal-bantuan-title">Tambah Riwayat Bantuan</h2>
</div>
<button class="btn-close" onclick="closeModal('modal-bantuan')"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<form id="form-bantuan">
<input type="hidden" id="bantuan-penduduk-id">
<div class="form-group">
<label>Keluarga Penerima</label>
<input type="text" id="bantuan-penerima" class="form-control" readonly>
</div>
<div class="form-group">
<label>Jenis Bantuan</label>
<input type="text" id="bantuan-jenis" class="form-control" list="bantuan-jenis-list" placeholder="Contoh: Sembako bulanan" required>
<datalist id="bantuan-jenis-list">
<option value="Sembako">
<option value="Pendidikan">
<option value="Kesehatan">
<option value="Modal Usaha">
<option value="Renovasi Rumah">
<option value="Bantuan Darurat">
</datalist>
</div>
<div class="form-grid two-cols">
<div class="form-group">
<label>Tanggal Bantuan</label>
<input type="date" id="bantuan-tanggal" class="form-control" required>
</div>
<div class="form-group">
<label>Nilai Bantuan</label>
<input type="number" id="bantuan-nilai" class="form-control" min="0" placeholder="Opsional">
</div>
</div>
<div class="form-group">
<label>Pemberi Bantuan</label>
<input type="text" id="bantuan-pemberi" class="form-control" value="Admin">
</div>
<div class="form-group">
<label>Catatan</label>
<textarea id="bantuan-catatan" class="form-control" rows="3" placeholder="Contoh: bantuan diserahkan langsung ke rumah penerima"></textarea>
</div>
<div class="form-group">
<label>Bukti Bantuan</label>
<input type="file" id="bantuan-bukti-file" class="form-control" accept="image/*,.pdf">
<p class="form-hint">Opsional, bisa berupa foto penyerahan atau dokumen PDF.</p>
</div>
<div class="form-alert" id="bantuan-alert" hidden></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-bantuan')">Batal</button>
<button class="btn btn-primary" id="btn-save-bantuan" onclick="saveBantuan()">Simpan Bantuan</button>
</div>
</div>
</div>
<!-- Scripts -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="assets/js/app.js?v=<?= filemtime(__DIR__ . '/assets/js/app.js') ?>"></script>
</body>
</html>
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/api/db_pdo.php';
require_once __DIR__ . '/api/auth.php';
require_once __DIR__ . '/api/penduduk_feature_helper.php';
require_admin_login();
ensurePendudukFeatureSchema($pdo);
refreshAllPriorityScores($pdo);
$status = trim((string)($_GET['status'] ?? ''));
$priority = trim((string)($_GET['prioritas'] ?? ''));
$where = [];
$params = [];
if ($status !== '') {
$where[] = 'p.status_bantuan = ?';
$params[] = $status;
}
if ($priority === 'tinggi') {
$where[] = 'p.skor_prioritas >= 70';
} elseif ($priority === 'sedang') {
$where[] = 'p.skor_prioritas BETWEEN 40 AND 69';
} elseif ($priority === 'rendah') {
$where[] = 'p.skor_prioritas < 40';
}
$sql = "
SELECT p.*, r.nama AS rumah_ibadah_nama
FROM penduduk_miskin p
LEFT JOIN rumah_ibadah r ON p.ibadah_id = r.id
";
if ($where) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY p.skor_prioritas DESC, p.id DESC';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$summary = [
'total' => count($rows),
'tinggi' => count(array_filter($rows, fn($r) => (int)$r['skor_prioritas'] >= 70)),
'belum' => count(array_filter($rows, fn($r) => $r['status_bantuan'] === 'Belum dibantu')),
'luar' => count(array_filter($rows, fn($r) => (int)$r['is_blank_spot'] === 1)),
];
function e(?string $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Laporan Prioritas Bantuan</title>
<link rel="stylesheet" href="assets/css/report.css?v=<?= filemtime(__DIR__ . '/assets/css/report.css') ?>">
</head>
<body>
<main class="report-page">
<header class="report-header">
<div>
<p class="eyebrow">WebGIS Peduli</p>
<h1>Laporan Prioritas Bantuan</h1>
<p>Dicetak pada <?= date('d/m/Y H:i') ?> oleh <?= e(current_admin_name()) ?></p>
</div>
<button onclick="window.print()" class="print-btn">Cetak / Simpan PDF</button>
</header>
<section class="summary-grid">
<div><span>Total Data</span><strong><?= $summary['total'] ?></strong></div>
<div><span>Prioritas Tinggi</span><strong><?= $summary['tinggi'] ?></strong></div>
<div><span>Belum Dibantu</span><strong><?= $summary['belum'] ?></strong></div>
<div><span>Luar Radius</span><strong><?= $summary['luar'] ?></strong></div>
</section>
<section class="report-note">
<strong>Keterangan:</strong> Skor prioritas dihitung dari status bantuan, jumlah tanggungan, lansia/balita,
penghasilan, kondisi rumah, kebutuhan mendesak, dan posisi terhadap radius layanan.
</section>
<table>
<thead>
<tr>
<th>No</th>
<th>Kepala Keluarga</th>
<th>Prioritas</th>
<th>Kebutuhan</th>
<th>Status</th>
<th>Tindak Lanjut</th>
<th>Rumah Ibadah</th>
<th>Jarak</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $i => $row): ?>
<tr>
<td><?= $i + 1 ?></td>
<td>
<strong><?= e($row['kk_nama']) ?></strong><br>
<small><?= e($row['alamat']) ?></small>
</td>
<td>
<strong><?= (int)$row['skor_prioritas'] ?></strong><br>
<small><?= e(getPriorityLabel((int)$row['skor_prioritas'])) ?></small>
</td>
<td><?= e($row['kategori_kebutuhan'] ?: 'Belum diisi') ?></td>
<td><?= e($row['status_bantuan']) ?></td>
<td><?= e($row['tindak_lanjut'] ?: 'Belum disurvei') ?></td>
<td><?= e($row['rumah_ibadah_nama'] ?: '-') ?></td>
<td>
Lurus: <?= e((string)$row['jarak_m']) ?> m<br>
<small>Rute: <?= e((string)($row['jarak_rute_m'] ?? '-')) ?> m</small>
</td>
<td><?= e($row['catatan_survei'] ?: $row['deskripsi_kebutuhan'] ?: '-') ?></td>
</tr>
<?php endforeach; ?>
<?php if (!$rows): ?>
<tr><td colspan="9" class="empty">Tidak ada data sesuai filter.</td></tr>
<?php endif; ?>
</tbody>
</table>
</main>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/api/db_pdo.php';
require_once __DIR__ . '/api/auth.php';
start_admin_session();
ensure_admin_table($pdo);
if (is_admin_logged_in()) {
header('Location: index.php');
exit;
}
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = (string)($_POST['csrf_token'] ?? '');
$username = trim((string)($_POST['username'] ?? ''));
$password = (string)($_POST['password'] ?? '');
if (!hash_equals((string)$_SESSION['csrf_token'], $token)) {
$error = 'Sesi form tidak valid. Muat ulang halaman lalu coba lagi.';
} elseif ($username === '' || $password === '') {
$error = 'Username dan password wajib diisi.';
} else {
$stmt = $pdo->prepare("SELECT * FROM admins WHERE username = ? AND is_active = 1 LIMIT 1");
$stmt->execute([$username]);
$admin = $stmt->fetch();
if ($admin && password_verify($password, $admin['password_hash'])) {
session_regenerate_id(true);
$_SESSION['admin_id'] = (int)$admin['id'];
$_SESSION['admin_username'] = $admin['username'];
$_SESSION['admin_name'] = $admin['nama'];
unset($_SESSION['csrf_token']);
header('Location: index.php');
exit;
}
$error = 'Username atau password salah.';
}
}
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login Admin - WebGIS Peduli</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="assets/css/login.css">
</head>
<body>
<main class="login-page">
<section class="login-panel">
<div class="brand-mark">
<i class="fas fa-map-location-dot"></i>
</div>
<p class="eyebrow">Admin Area</p>
<h1>Masuk ke WebGIS Peduli</h1>
<p class="subtitle">Gunakan akun admin untuk mengelola data peta, warga, dan rumah ibadah.</p>
<?php if ($error !== ''): ?>
<div class="alert-error">
<i class="fas fa-circle-exclamation"></i>
<span><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></span>
</div>
<?php endif; ?>
<form method="POST" class="login-form" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars((string)$_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>">
<label class="input-group">
<span>Username</span>
<div class="input-shell">
<i class="fas fa-user"></i>
<input type="text" name="username" placeholder="admin" required autofocus>
</div>
</label>
<label class="input-group">
<span>Password</span>
<div class="input-shell">
<i class="fas fa-lock"></i>
<input type="password" name="password" placeholder="Masukkan password" required>
</div>
</label>
<button type="submit" class="btn-login">
<span>Masuk</span>
<i class="fas fa-arrow-right"></i>
</button>
</form>
<p class="login-hint">Akun awal: <strong>admin</strong> / <strong>admin123</strong></p>
</section>
</main>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/api/auth.php';
start_admin_session();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params['path'],
$params['domain'],
(bool)$params['secure'],
(bool)$params['httponly']
);
}
session_destroy();
header('Location: login.php');
exit;
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/api/auth.php';
require_admin_login();
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
$baseUrl = 'http://localhost/Leaflet2/';
$projectUrl = '[ISI URL PROJECT KELAS DI SINI]';
$giteaUrl = '[https://git.ifuntanhub.dev/Arif1031/WebGIS-PovertyMap.git]';
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Panduan Penggunaan WebGIS Poverty Map</title>
<link rel="stylesheet" href="assets/css/guide.css?v=<?= filemtime(__DIR__ . '/assets/css/guide.css') ?>">
</head>
<body>
<main class="guide-page">
<header class="guide-hero">
<div>
<p class="eyebrow">Panduan Penggunaan</p>
<h1>WebGIS Poverty Map</h1>
<p class="lead">Panduan singkat untuk mengakses, mengelola data peta, mencatat bantuan, dan mencetak laporan prioritas masyarakat miskin/kurang mampu.</p>
</div>
<button class="print-btn" onclick="window.print()">Cetak / Simpan PDF</button>
</header>
<section class="access-panel">
<h2>Informasi Akses Project</h2>
<div class="info-grid">
<div>
<span>URL Aplikasi Lokal</span>
<strong><?= e($baseUrl) ?></strong>
</div>
<div>
<span>URL Project Kelas</span>
<strong><?= e($projectUrl) ?></strong>
</div>
<div>
<span>Link Repo Gitea</span>
<strong><?= e($giteaUrl) ?></strong>
</div>
</div>
</section>
<section>
<h2>Akun dan Role</h2>
<table>
<thead>
<tr>
<th>Role</th>
<th>Username</th>
<th>Password</th>
<th>Hak Akses</th>
</tr>
</thead>
<tbody>
<tr>
<td>Admin</td>
<td><code>admin</code></td>
<td><code>admin123</code></td>
<td>Mengelola dashboard, peta, rumah ibadah, penduduk miskin, bantuan, bukti, dan laporan.</td>
</tr>
</tbody>
</table>
<p class="note">Versi sistem saat ini hanya memiliki satu role aktif, yaitu Admin. Jika role baru ditambahkan, akun dapat ditulis pada tabel ini.</p>
</section>
<section class="steps">
<h2>Alur Penggunaan Utama</h2>
<ol>
<li>Buka URL aplikasi, lalu login menggunakan akun admin.</li>
<li>Periksa ringkasan data pada Dashboard untuk melihat jumlah rumah ibadah, warga, status bantuan, prioritas, dan warga luar radius.</li>
<li>Buka menu Peta untuk melihat marker rumah ibadah, radius layanan, dan marker warga.</li>
<li>Gunakan pencarian dan filter untuk menemukan warga, rumah ibadah, status bantuan, jenis rumah ibadah, atau kategori kebutuhan.</li>
<li>Tambah atau edit rumah ibadah melalui menu atau tombol tambah, lalu pilih titik koordinat di peta.</li>
<li>Tambah atau edit penduduk miskin, lengkapi kebutuhan, kondisi rumah, penghasilan, status survei, anggota keluarga, dan bukti jika ada.</li>
<li>Gunakan tombol Bantuan pada popup warga untuk mencatat riwayat bantuan tanpa popup browser.</li>
<li>Buka Laporan untuk mencetak atau menyimpan daftar prioritas bantuan sebagai PDF.</li>
</ol>
</section>
<section>
<h2>Keterangan Warna Peta</h2>
<div class="legend-grid">
<div><span class="dot worship"></span><strong>Rumah Ibadah</strong><p>Pusat layanan atau rujukan bantuan sosial.</p></div>
<div><span class="dot reached"></span><strong>Warga Dalam Radius</strong><p>Warga berada dalam radius layanan rumah ibadah.</p></div>
<div><span class="dot outside"></span><strong>Warga Luar Radius</strong><p>Warga berada di luar semua radius layanan.</p></div>
</div>
</section>
<section>
<h2>Catatan Pengisian Data</h2>
<ul>
<li>Radius default rumah ibadah adalah 500 meter dan dapat diubah oleh admin.</li>
<li>Status dalam/luar radius dihitung menggunakan jarak lurus pada peta.</li>
<li>Jarak rute jalan ditampilkan sebagai informasi tambahan jika layanan OSRM berhasil diakses.</li>
<li>Penghasilan tidak boleh bernilai negatif.</li>
<li>Bukti bantuan atau survei dapat berupa JPG, PNG, WEBP, atau PDF dengan ukuran maksimal 3 MB.</li>
</ul>
</section>
</main>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
DROP DATABASE IF EXISTS webgis_miskin;
CREATE DATABASE webgis_miskin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE webgis_miskin;
CREATE TABLE admins (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
nama VARCHAR(100) NOT NULL DEFAULT 'Administrator',
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
INSERT INTO admins (username, password_hash, nama) VALUES
('admin', '$2y$10$7O061pVz/aZ5YNK/fQyYIePIPnzDb.uZn38pC/Te3s7gRyko20dIK', 'Administrator');
CREATE TABLE rumah_ibadah (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(150) NOT NULL,
jenis ENUM('Masjid', 'Gereja', 'Pura', 'Vihara', 'Klenteng', 'Lainnya') NOT NULL,
kontak VARCHAR(50),
alamat TEXT,
lat DECIMAL(10, 8) NOT NULL,
lng DECIMAL(11, 8) NOT NULL,
radius INT NOT NULL DEFAULT 500,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE penduduk_miskin (
id INT AUTO_INCREMENT PRIMARY KEY,
kk_nama VARCHAR(150) NOT NULL,
kk_nik VARCHAR(20),
alamat TEXT,
lat DECIMAL(10, 8) NOT NULL,
lng DECIMAL(11, 8) NOT NULL,
ibadah_id INT NULL,
jarak_m DECIMAL(10, 2) NULL,
jarak_rute_m DECIMAL(10, 2) NULL,
is_blank_spot TINYINT(1) NOT NULL DEFAULT 1,
status_bantuan ENUM('Sudah dibantu', 'Belum dibantu', 'Menunggu verifikasi') NOT NULL DEFAULT 'Belum dibantu',
kategori_kebutuhan VARCHAR(100),
deskripsi_kebutuhan TEXT,
tindak_lanjut VARCHAR(60) NOT NULL DEFAULT 'Belum disurvei',
kondisi_rumah VARCHAR(80),
penghasilan INT,
catatan_survei TEXT,
bukti_file VARCHAR(255),
skor_prioritas INT NOT NULL DEFAULT 0,
jenis_bantuan VARCHAR(100),
tanggal_bantuan DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL
) ENGINE=InnoDB;
CREATE TABLE anggota_keluarga (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL,
nama VARCHAR(150) NOT NULL,
hubungan VARCHAR(50) NOT NULL,
umur INT,
pekerjaan VARCHAR(100),
FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE riwayat_bantuan (
id INT AUTO_INCREMENT PRIMARY KEY,
penduduk_id INT NOT NULL,
jenis_bantuan VARCHAR(120) NOT NULL,
tanggal_bantuan DATE NOT NULL,
nilai_bantuan VARCHAR(80),
pemberi_bantuan VARCHAR(120),
catatan TEXT,
bukti_file VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (penduduk_id) REFERENCES penduduk_miskin(id) ON DELETE CASCADE
) ENGINE=InnoDB;