From c321f2ba1842937ee52b155b4c49085f65d029b2 Mon Sep 17 00:00:00 2001 From: miaaurl Date: Thu, 11 Jun 2026 16:40:31 +0700 Subject: [PATCH] Initial commit --- index.html | 356 ++++ jalan_tanah/api/jalan.php | 138 ++ jalan_tanah/api/laporan.php | 363 ++++ jalan_tanah/api/parsil.php | 144 ++ jalan_tanah/config/database.php | 108 ++ jalan_tanah/database/db_jalan.sql | 118 ++ jalan_tanah/index.html | 1455 ++++++++++++++++ jalan_tanah/uploads/laporan/.gitkeep | 1 + poverty-map/SimpleXLSX.php | 319 ++++ poverty-map/api.php | 710 ++++++++ poverty-map/import.php | 211 +++ poverty-map/index.php | 2112 +++++++++++++++++++++++ poverty-map/koneksi.php | 41 + poverty-map/login.php | 119 ++ poverty-map/setup.sql | 111 ++ poverty-map/uploads/foto_bukti/.gitkeep | 0 poverty-map/uploads/foto_rumah/.gitkeep | 0 spbu_layer/index.php | 571 ++++++ 18 files changed, 6877 insertions(+) create mode 100644 index.html create mode 100644 jalan_tanah/api/jalan.php create mode 100644 jalan_tanah/api/laporan.php create mode 100644 jalan_tanah/api/parsil.php create mode 100644 jalan_tanah/config/database.php create mode 100644 jalan_tanah/database/db_jalan.sql create mode 100644 jalan_tanah/index.html create mode 100644 jalan_tanah/uploads/laporan/.gitkeep create mode 100644 poverty-map/SimpleXLSX.php create mode 100644 poverty-map/api.php create mode 100644 poverty-map/import.php create mode 100644 poverty-map/index.php create mode 100644 poverty-map/koneksi.php create mode 100644 poverty-map/login.php create mode 100644 poverty-map/setup.sql create mode 100644 poverty-map/uploads/foto_bukti/.gitkeep create mode 100644 poverty-map/uploads/foto_rumah/.gitkeep create mode 100644 spbu_layer/index.php diff --git a/index.html b/index.html new file mode 100644 index 0000000..509a632 --- /dev/null +++ b/index.html @@ -0,0 +1,356 @@ + + + + + + + Portal WebGIS Project — Informatika UNTAN + + + + + + + +
+
+ +
+ +
+

Portal WebGIS Project

+

Koleksi aplikasi sistem informasi geografis mahasiswa Informatika Universitas Tanjungpura untuk penugasan + kelas dan tugas besar.

+
+ + +
+ +
+
+ Tugas Akhir / Poverty Map +

WebGIS Poverty Map

+

Aplikasi pemetaan tingkat kemiskinan berbasis cakupan (coverage) rumah ibadah terdekat dengan + penghitungan radius jarak Haversine. Memiliki sistem autentikasi multi-role (Admin, Koordinator, + dan Pengambil Kepijakan) serta visualisasi peta heatmap dan sebaran penduduk miskin secara + langsung.

+
+ + Buka Aplikasi + +
+ + +
+
+ Project Kelas +

Peta SPBU Layer

+

Visualisasi sebaran stasiun pengisian bahan bakar umum (SPBU) di wilayah Pontianak dan sekitarnya + menggunakan layer kontrol interaktif peta Leaflet.js.

+
+ + Buka Project + +
+ + +
+
+ Project Kelas +

Peta Jalan Tanah

+

Peta visualisasi infrastruktur jalan tanah menggunakan pemetaan garis polylines serta poligon + kecamatan untuk menganalisis perkembangan jalan daerah.

+
+ + Buka Project + +
+
+
+ + + + + + + \ No newline at end of file diff --git a/jalan_tanah/api/jalan.php b/jalan_tanah/api/jalan.php new file mode 100644 index 0000000..274f7fb --- /dev/null +++ b/jalan_tanah/api/jalan.php @@ -0,0 +1,138 @@ +prepare("SELECT id, nama_jalan, status_jalan, panjang_meter, keterangan, geom, created_at, updated_at FROM data_jalan WHERE id = ?"); + $stmt->bind_param("i", $id); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + if (!$row) { http_response_code(404); echo json_encode(['status'=>'error','message'=>'Data tidak ditemukan']); break; } + $row['koordinat'] = parseGeoJsonToLatLng($row['geom']); + unset($row['geom']); + echo json_encode(['status'=>'success','data'=>$row]); + } else { + // Filter support: ?status=Nasional&min_panjang=100&max_panjang=5000&q=nama + $where = ['1=1']; + $params = []; + $types = ''; + + if (!empty($_GET['status'])) { + $where[] = 'status_jalan = ?'; + $params[] = $_GET['status']; + $types .= 's'; + } + if (!empty($_GET['min_panjang'])) { + $where[] = 'panjang_meter >= ?'; + $params[] = (float)$_GET['min_panjang']; + $types .= 'd'; + } + if (!empty($_GET['max_panjang'])) { + $where[] = 'panjang_meter <= ?'; + $params[] = (float)$_GET['max_panjang']; + $types .= 'd'; + } + if (!empty($_GET['q'])) { + $where[] = 'nama_jalan LIKE ?'; + $params[] = '%' . $_GET['q'] . '%'; + $types .= 's'; + } + + $sql = 'SELECT id, nama_jalan, status_jalan, panjang_meter, keterangan, geom, created_at FROM data_jalan WHERE ' . implode(' AND ', $where) . ' ORDER BY created_at DESC'; + $stmt = $conn->prepare($sql); + if ($params) $stmt->bind_param($types, ...$params); + $stmt->execute(); + $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); + + $data = array_map(function($row) { + $row['koordinat'] = parseGeoJsonToLatLng($row['geom']); + unset($row['geom']); + return $row; + }, $rows); + + echo json_encode(['status'=>'success','data'=>$data,'total'=>count($data)]); + } + break; + + case 'POST': + $input = getJsonInput(); + if (!$input) { http_response_code(400); echo json_encode(['status'=>'error','message'=>'Input tidak valid']); break; } + + $nama = trim($input['nama_jalan'] ?? ''); + $status = trim($input['status_jalan'] ?? ''); + $panjang = (float)($input['panjang_meter'] ?? 0); + $ket = trim($input['keterangan'] ?? ''); + $coords = $input['koordinat'] ?? []; + + if (!$nama || !$status || count($coords) < 2) { + http_response_code(400); + echo json_encode(['status'=>'error','message'=>'Nama, status, dan minimal 2 koordinat wajib diisi']); + break; + } + if (!in_array($status, ['Nasional','Provinsi','Kabupaten'])) { + http_response_code(400); echo json_encode(['status'=>'error','message'=>'Status tidak valid']); break; + } + + $geom = buildGeoJsonLine($coords); + $stmt = $conn->prepare("INSERT INTO data_jalan (nama_jalan,status_jalan,panjang_meter,keterangan,geom) VALUES (?,?,?,?,?)"); + $stmt->bind_param("ssdss", $nama, $status, $panjang, $ket, $geom); + + if ($stmt->execute()) { + echo json_encode(['status'=>'success','message'=>'Data jalan berhasil ditambahkan','id'=>$conn->insert_id]); + } else { + http_response_code(500); echo json_encode(['status'=>'error','message'=>'Gagal menyimpan: '.$conn->error]); + } + break; + + case 'PUT': + $input = getJsonInput(); + $id = intval($_GET['id'] ?? 0); + if (!$id || !$input) { http_response_code(400); echo json_encode(['status'=>'error','message'=>'ID dan input wajib ada']); break; } + + $nama = trim($input['nama_jalan'] ?? ''); + $status = trim($input['status_jalan'] ?? ''); + $panjang = (float)($input['panjang_meter'] ?? 0); + $ket = trim($input['keterangan'] ?? ''); + $coords = $input['koordinat'] ?? []; + + if (!$nama || !$status) { http_response_code(400); echo json_encode(['status'=>'error','message'=>'Nama dan status wajib diisi']); break; } + + $geom = buildGeoJsonLine($coords); + $stmt = $conn->prepare("UPDATE data_jalan SET nama_jalan=?,status_jalan=?,panjang_meter=?,keterangan=?,geom=? WHERE id=?"); + $stmt->bind_param("ssdssi", $nama, $status, $panjang, $ket, $geom, $id); + + if ($stmt->execute()) { + echo json_encode(['status'=>'success','message'=>'Data jalan berhasil diperbarui']); + } else { + http_response_code(500); echo json_encode(['status'=>'error','message'=>'Gagal memperbarui: '.$conn->error]); + } + break; + + case 'DELETE': + $id = intval($_GET['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['status'=>'error','message'=>'ID tidak valid']); break; } + + $stmt = $conn->prepare("DELETE FROM data_jalan WHERE id=?"); + $stmt->bind_param("i", $id); + if ($stmt->execute() && $stmt->affected_rows > 0) { + echo json_encode(['status'=>'success','message'=>'Data jalan berhasil dihapus']); + } else { + http_response_code(404); echo json_encode(['status'=>'error','message'=>'Data tidak ditemukan']); + } + break; + + default: + http_response_code(405); + echo json_encode(['status'=>'error','message'=>'Method tidak diizinkan']); +} + +$conn->close(); diff --git a/jalan_tanah/api/laporan.php b/jalan_tanah/api/laporan.php new file mode 100644 index 0000000..53c2f4e --- /dev/null +++ b/jalan_tanah/api/laporan.php @@ -0,0 +1,363 @@ +close(); + exit; +} + +switch ($method) { + + // ========================================== + // GET — list dengan filter + // ========================================== + case 'GET': + if (isset($_GET['id'])) { + $id = intval($_GET['id']); + $stmt = $conn->prepare("SELECT * FROM laporan_jalan_rusak WHERE id = ?"); + $stmt->bind_param("i", $id); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + if (!$row) { http_response_code(404); echo json_encode(['status'=>'error','message'=>'Tidak ditemukan']); break; } + $row = formatLaporanRow($row); + echo json_encode(['status'=>'success','data'=>$row]); + } else { + $where = ['1=1']; + $params = []; + $types = ''; + + // Filter: bulan terakhir (default), atau rentang tahun + if (!empty($_GET['bulan_terakhir'])) { + $bulan = intval($_GET['bulan_terakhir']); + $where[] = 'tanggal_input >= DATE_SUB(NOW(), INTERVAL ? MONTH)'; + $params[] = $bulan; + $types .= 'i'; + } elseif (!empty($_GET['tahun_terakhir'])) { + $tahun = intval($_GET['tahun_terakhir']); + $where[] = 'tanggal_input >= DATE_SUB(NOW(), INTERVAL ? YEAR)'; + $params[] = $tahun; + $types .= 'i'; + } + + if (!empty($_GET['status'])) { + $where[] = 'status = ?'; + $params[] = $_GET['status']; + $types .= 's'; + } + if (!empty($_GET['nama_jalan'])) { + $where[] = 'nama_jalan LIKE ?'; + $params[] = '%'.$_GET['nama_jalan'].'%'; + $types .= 's'; + } + if (!empty($_GET['q'])) { + $where[] = '(nama_jalan LIKE ? OR deskripsi LIKE ? OR nama_pelapor LIKE ?)'; + $params[] = '%'.$_GET['q'].'%'; + $params[] = '%'.$_GET['q'].'%'; + $params[] = '%'.$_GET['q'].'%'; + $types .= 'sss'; + } + + $sql = 'SELECT * FROM laporan_jalan_rusak WHERE ' . implode(' AND ', $where) . ' ORDER BY tanggal_input DESC'; + $stmt = $conn->prepare($sql); + if ($params) $stmt->bind_param($types, ...$params); + $stmt->execute(); + $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); + $data = array_map('formatLaporanRow', $rows); + + // Hitung cluster (radius 50m, min 3 laporan → urgent) + $clusters = buildClusters($data, 50, 3); + + echo json_encode(['status'=>'success','data'=>$data,'total'=>count($data),'clusters'=>$clusters]); + } + break; + + // ========================================== + // POST — Tambah laporan (multipart/form-data) + // ========================================== + case 'POST': + $nama_jalan = trim($_POST['nama_jalan'] ?? ''); + $deskripsi = trim($_POST['deskripsi'] ?? ''); + $nama_pelapor= trim($_POST['nama_pelapor']?? ''); + $lat = (float)($_POST['lat'] ?? 0); + $lng = (float)($_POST['lng'] ?? 0); + + if (!$nama_jalan || !$lat || !$lng) { + http_response_code(400); + echo json_encode(['status'=>'error','message'=>'Nama jalan dan koordinat wajib diisi']); + break; + } + + $foto_path = null; + $foto_lat = null; + $foto_lng = null; + $foto_datetime = null; + + // Handle upload foto + if (!empty($_FILES['foto']) && $_FILES['foto']['error'] === UPLOAD_ERR_OK) { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $_FILES['foto']['tmp_name']); + finfo_close($finfo); + + if (!in_array($mime, ALLOWED_TYPES)) { + http_response_code(400); + echo json_encode(['status'=>'error','message'=>'Format foto tidak didukung (JPG/PNG/WebP)']); + break; + } + if ($_FILES['foto']['size'] > MAX_FILE_SIZE) { + http_response_code(400); + echo json_encode(['status'=>'error','message'=>'Ukuran foto melebihi batas 8MB']); + break; + } + + // Baca EXIF sebelum pindah file + $exifData = @exif_read_data($_FILES['foto']['tmp_name']); + if ($exifData) { + // Ekstrak koordinat GPS dari EXIF + $gpsCoords = extractExifGps($exifData); + if ($gpsCoords) { + $foto_lat = $gpsCoords['lat']; + $foto_lng = $gpsCoords['lng']; + } + // Ekstrak datetime + $dt = $exifData['DateTimeOriginal'] ?? $exifData['DateTime'] ?? null; + if ($dt) { + $foto_datetime = date('Y-m-d H:i:s', strtotime(str_replace(':', '-', substr($dt, 0, 10)) . substr($dt, 10))); + } + } + + // Jika GPS tidak ada di EXIF, pakai koordinat yang dikirim user + if (!$foto_lat) $foto_lat = $lat; + if (!$foto_lng) $foto_lng = $lng; + + if (!is_dir(UPLOAD_DIR)) mkdir(UPLOAD_DIR, 0755, true); + $ext = match($mime) { 'image/png' => 'png', 'image/webp' => 'webp', default => 'jpg' }; + $filename = 'laporan_' . date('Ymd_His') . '_' . uniqid() . '.' . $ext; + $destPath = UPLOAD_DIR . $filename; + + if (!move_uploaded_file($_FILES['foto']['tmp_name'], $destPath)) { + http_response_code(500); + echo json_encode(['status'=>'error','message'=>'Gagal menyimpan foto']); + break; + } + $foto_path = UPLOAD_URL . $filename; + } + + // Koordinat point: prioritaskan GPS foto, fallback ke input user + $pointLat = $foto_lat ?? $lat; + $pointLng = $foto_lng ?? $lng; + $geom = buildGeoJsonPoint($pointLat, $pointLng); + + $stmt = $conn->prepare( + "INSERT INTO laporan_jalan_rusak (nama_pelapor,nama_jalan,deskripsi,foto_path,foto_lat,foto_lng,foto_datetime,geom) + VALUES (?,?,?,?,?,?,?,?)" + ); + $npVal = $nama_pelapor ?: null; + $stmt->bind_param("ssssddss", $npVal, $nama_jalan, $deskripsi, $foto_path, $foto_lat, $foto_lng, $foto_datetime, $geom); + + if ($stmt->execute()) { + echo json_encode(['status'=>'success','message'=>'Laporan berhasil dikirim','id'=>$conn->insert_id]); + } else { + http_response_code(500); + echo json_encode(['status'=>'error','message'=>'Gagal menyimpan: '.$conn->error]); + } + break; + + // ========================================== + // PUT — Update status laporan + // ========================================== + case 'PUT': + $input = getJsonInput(); + $id = intval($_GET['id'] ?? 0); + if (!$id || !$input) { http_response_code(400); echo json_encode(['status'=>'error','message'=>'ID dan input wajib ada']); break; } + + $status = trim($input['status'] ?? ''); + if (!in_array($status, ['pending','verified','resolved'])) { + http_response_code(400); echo json_encode(['status'=>'error','message'=>'Status tidak valid']); break; + } + + $stmt = $conn->prepare("UPDATE laporan_jalan_rusak SET status=? WHERE id=?"); + $stmt->bind_param("si", $status, $id); + if ($stmt->execute()) { + echo json_encode(['status'=>'success','message'=>'Status laporan diperbarui']); + } else { + http_response_code(500); echo json_encode(['status'=>'error','message'=>$conn->error]); + } + break; + + // ========================================== + // DELETE + // ========================================== + case 'DELETE': + $id = intval($_GET['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['status'=>'error','message'=>'ID tidak valid']); break; } + + // Hapus file foto juga + $stmt = $conn->prepare("SELECT foto_path FROM laporan_jalan_rusak WHERE id=?"); + $stmt->bind_param("i", $id); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + if ($row && $row['foto_path']) { + $fullPath = __DIR__ . '/../' . $row['foto_path']; + if (file_exists($fullPath)) @unlink($fullPath); + } + + $stmt = $conn->prepare("DELETE FROM laporan_jalan_rusak WHERE id=?"); + $stmt->bind_param("i", $id); + if ($stmt->execute() && $stmt->affected_rows > 0) { + echo json_encode(['status'=>'success','message'=>'Laporan berhasil dihapus']); + } else { + http_response_code(404); echo json_encode(['status'=>'error','message'=>'Data tidak ditemukan']); + } + break; + + default: + http_response_code(405); + echo json_encode(['status'=>'error','message'=>'Method tidak diizinkan']); +} + +$conn->close(); + +// ============================================ +// HELPERS +// ============================================ + +function formatLaporanRow(array $row): array { + $geom = json_decode($row['geom'] ?? '{}', true); + $row['lat'] = $geom['coordinates'][1] ?? null; + $row['lng'] = $geom['coordinates'][0] ?? null; + unset($row['geom']); + return $row; +} + +function extractExifGps(array $exif): ?array { + if (empty($exif['GPSLatitude']) || empty($exif['GPSLongitude'])) return null; + $lat = gpsToDecimal($exif['GPSLatitude'], $exif['GPSLatitudeRef'] ?? 'N'); + $lng = gpsToDecimal($exif['GPSLongitude'], $exif['GPSLongitudeRef'] ?? 'E'); + return ['lat' => $lat, 'lng' => $lng]; +} + +function gpsToDecimal(array $parts, string $ref): float { + $deg = evalFraction($parts[0]); + $min = evalFraction($parts[1]); + $sec = evalFraction($parts[2]); + $dec = $deg + ($min / 60) + ($sec / 3600); + return in_array($ref, ['S','W']) ? -$dec : $dec; +} + +function evalFraction(string $frac): float { + if (strpos($frac, '/') === false) return (float)$frac; + [$n, $d] = explode('/', $frac); + return $d ? (float)$n / (float)$d : 0; +} + +/** + * Hitung jarak Haversine antara dua titik (meter) + */ +function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float { + $R = 6371000; + $p1 = deg2rad($lat1); $p2 = deg2rad($lat2); + $dp = deg2rad($lat2 - $lat1); + $dl = deg2rad($lng2 - $lng1); + $a = sin($dp/2)**2 + cos($p1)*cos($p2)*sin($dl/2)**2; + return $R * 2 * atan2(sqrt($a), sqrt(1-$a)); +} + +/** + * Bangun cluster sederhana: greedy radius clustering + * Laporan dalam radius $radiusM meter dianggap satu cluster. + * Cluster dengan >= $minCount laporan ditandai urgent. + */ +function buildClusters(array $laporan, float $radiusM, int $minCount): array { + $clusters = []; + $assigned = []; + + foreach ($laporan as $i => $l) { + if (isset($assigned[$i])) continue; + if ($l['lat'] === null) continue; + + $cluster = [$i]; + $assigned[$i] = true; + + foreach ($laporan as $j => $m) { + if ($j === $i || isset($assigned[$j])) continue; + if ($m['lat'] === null) continue; + if (haversine($l['lat'], $l['lng'], $m['lat'], $m['lng']) <= $radiusM) { + $cluster[] = $j; + $assigned[$j] = true; + } + } + + if (count($cluster) >= $minCount) { + // Centroid + $cLat = array_sum(array_map(fn($k) => $laporan[$k]['lat'], $cluster)) / count($cluster); + $cLng = array_sum(array_map(fn($k) => $laporan[$k]['lng'], $cluster)) / count($cluster); + $ids = array_map(fn($k) => $laporan[$k]['id'], $cluster); + $jalanNames = array_unique(array_map(fn($k) => $laporan[$k]['nama_jalan'], $cluster)); + + $clusters[] = [ + 'lat' => $cLat, + 'lng' => $cLng, + 'count' => count($cluster), + 'ids' => $ids, + 'nama_jalan' => implode(', ', $jalanNames), + 'urgent' => count($cluster) >= $minCount, + ]; + } + } + + return $clusters; +} + +/** + * Analitik: frekuensi kerusakan per jalan, per periode + */ +function handleAnalytics(mysqli $conn): void { + $tahun = intval($_GET['tahun'] ?? 1); + if (!in_array($tahun, [1,2,3,5,10])) $tahun = 1; + + // Frekuensi per jalan + $stmt = $conn->prepare( + "SELECT nama_jalan, + COUNT(*) AS total, + SUM(CASE WHEN status='resolved' THEN 1 ELSE 0 END) AS resolved, + SUM(CASE WHEN status='verified' THEN 1 ELSE 0 END) AS verified, + SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) AS pending, + MIN(tanggal_input) AS pertama, + MAX(tanggal_input) AS terakhir + FROM laporan_jalan_rusak + WHERE tanggal_input >= DATE_SUB(NOW(), INTERVAL ? YEAR) + GROUP BY nama_jalan + ORDER BY total DESC" + ); + $stmt->bind_param("i", $tahun); + $stmt->execute(); + $perJalan = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); + + // Tren per bulan (12 bulan terakhir) + $tren = $conn->query( + "SELECT DATE_FORMAT(tanggal_input,'%Y-%m') AS bulan, COUNT(*) AS total + FROM laporan_jalan_rusak + WHERE tanggal_input >= DATE_SUB(NOW(), INTERVAL 12 MONTH) + GROUP BY bulan ORDER BY bulan ASC" + )->fetch_all(MYSQLI_ASSOC); + + // Sering rusak: jalan dengan total >= 3 dalam rentang waktu + $seringRusak = array_filter($perJalan, fn($r) => (int)$r['total'] >= 3); + + echo json_encode([ + 'status' => 'success', + 'per_jalan' => $perJalan, + 'tren_bulanan' => $tren, + 'sering_rusak' => array_values($seringRusak), + 'rentang_tahun'=> $tahun, + ]); +} diff --git a/jalan_tanah/api/parsil.php b/jalan_tanah/api/parsil.php new file mode 100644 index 0000000..aedb0b6 --- /dev/null +++ b/jalan_tanah/api/parsil.php @@ -0,0 +1,144 @@ +prepare("SELECT id, nama_parsil, pemilik, status_sertifikat, nomor_sertifikat, luas_meter2, keterangan, geom, created_at FROM data_parsil WHERE id = ?"); + $stmt->bind_param("i", $id); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + if (!$row) { http_response_code(404); echo json_encode(['status'=>'error','message'=>'Data tidak ditemukan']); break; } + $row['koordinat'] = parseGeoJsonToLatLng($row['geom']); + unset($row['geom']); + echo json_encode(['status'=>'success','data'=>$row]); + } else { + $where = ['1=1']; + $params = []; + $types = ''; + + if (!empty($_GET['status'])) { + $where[] = 'status_sertifikat = ?'; + $params[] = $_GET['status']; + $types .= 's'; + } + if (!empty($_GET['min_luas'])) { + $where[] = 'luas_meter2 >= ?'; + $params[] = (float)$_GET['min_luas']; + $types .= 'd'; + } + if (!empty($_GET['max_luas'])) { + $where[] = 'luas_meter2 <= ?'; + $params[] = (float)$_GET['max_luas']; + $types .= 'd'; + } + if (!empty($_GET['q'])) { + $where[] = '(nama_parsil LIKE ? OR pemilik LIKE ?)'; + $params[] = '%'.$_GET['q'].'%'; + $params[] = '%'.$_GET['q'].'%'; + $types .= 'ss'; + } + + $sql = 'SELECT id, nama_parsil, pemilik, status_sertifikat, nomor_sertifikat, luas_meter2, keterangan, geom, created_at FROM data_parsil WHERE ' . implode(' AND ', $where) . ' ORDER BY created_at DESC'; + $stmt = $conn->prepare($sql); + if ($params) $stmt->bind_param($types, ...$params); + $stmt->execute(); + $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); + + $data = array_map(function($row) { + $row['koordinat'] = parseGeoJsonToLatLng($row['geom']); + unset($row['geom']); + return $row; + }, $rows); + + echo json_encode(['status'=>'success','data'=>$data,'total'=>count($data)]); + } + break; + + case 'POST': + $input = getJsonInput(); + if (!$input) { http_response_code(400); echo json_encode(['status'=>'error','message'=>'Input tidak valid']); break; } + + $nama = trim($input['nama_parsil'] ?? ''); + $pemilik = trim($input['pemilik'] ?? ''); + $status = trim($input['status_sertifikat'] ?? ''); + $nosert = trim($input['nomor_sertifikat'] ?? ''); + $luas = (float)($input['luas_meter2'] ?? 0); + $ket = trim($input['keterangan'] ?? ''); + $coords = $input['koordinat'] ?? []; + + if (!$nama || !$pemilik || !$status || count($coords) < 3) { + http_response_code(400); + echo json_encode(['status'=>'error','message'=>'Nama, pemilik, status, dan minimal 3 koordinat wajib diisi']); + break; + } + if (!in_array($status, ['SHM','HGB','HGU','HP'])) { + http_response_code(400); echo json_encode(['status'=>'error','message'=>'Status tidak valid']); break; + } + + $geom = buildGeoJsonPolygon($coords); + $stmt = $conn->prepare("INSERT INTO data_parsil (nama_parsil,pemilik,status_sertifikat,nomor_sertifikat,luas_meter2,keterangan,geom) VALUES (?,?,?,?,?,?,?)"); + $stmt->bind_param("ssssdss", $nama, $pemilik, $status, $nosert, $luas, $ket, $geom); + + if ($stmt->execute()) { + echo json_encode(['status'=>'success','message'=>'Data parsil berhasil ditambahkan','id'=>$conn->insert_id]); + } else { + http_response_code(500); echo json_encode(['status'=>'error','message'=>'Gagal menyimpan: '.$conn->error]); + } + break; + + case 'PUT': + $input = getJsonInput(); + $id = intval($_GET['id'] ?? 0); + if (!$id || !$input) { http_response_code(400); echo json_encode(['status'=>'error','message'=>'ID dan input wajib ada']); break; } + + $nama = trim($input['nama_parsil'] ?? ''); + $pemilik = trim($input['pemilik'] ?? ''); + $status = trim($input['status_sertifikat'] ?? ''); + $nosert = trim($input['nomor_sertifikat'] ?? ''); + $luas = (float)($input['luas_meter2'] ?? 0); + $ket = trim($input['keterangan'] ?? ''); + $coords = $input['koordinat'] ?? []; + + if (!$nama || !$pemilik || !$status) { + http_response_code(400); echo json_encode(['status'=>'error','message'=>'Nama, pemilik, dan status wajib diisi']); break; + } + + $geom = buildGeoJsonPolygon($coords); + $stmt = $conn->prepare("UPDATE data_parsil SET nama_parsil=?,pemilik=?,status_sertifikat=?,nomor_sertifikat=?,luas_meter2=?,keterangan=?,geom=? WHERE id=?"); + $stmt->bind_param("ssssdssi", $nama, $pemilik, $status, $nosert, $luas, $ket, $geom, $id); + + if ($stmt->execute()) { + echo json_encode(['status'=>'success','message'=>'Data parsil berhasil diperbarui']); + } else { + http_response_code(500); echo json_encode(['status'=>'error','message'=>'Gagal memperbarui: '.$conn->error]); + } + break; + + case 'DELETE': + $id = intval($_GET['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['status'=>'error','message'=>'ID tidak valid']); break; } + + $stmt = $conn->prepare("DELETE FROM data_parsil WHERE id=?"); + $stmt->bind_param("i", $id); + if ($stmt->execute() && $stmt->affected_rows > 0) { + echo json_encode(['status'=>'success','message'=>'Data parsil berhasil dihapus']); + } else { + http_response_code(404); echo json_encode(['status'=>'error','message'=>'Data tidak ditemukan']); + } + break; + + default: + http_response_code(405); + echo json_encode(['status'=>'error','message'=>'Method tidak diizinkan']); +} + +$conn->close(); diff --git a/jalan_tanah/config/database.php b/jalan_tanah/config/database.php new file mode 100644 index 0000000..e15a032 --- /dev/null +++ b/jalan_tanah/config/database.php @@ -0,0 +1,108 @@ +connect_error) { + http_response_code(500); + die(json_encode(['status' => 'error', 'message' => 'Koneksi database gagal: ' . $conn->connect_error])); + } + + // Check if database exists + $dbCheck = $conn->query("SHOW DATABASES LIKE '" . DB_NAME . "'"); + $dbExists = ($dbCheck && $dbCheck->num_rows > 0); + + if (!$dbExists) { + $conn->query("CREATE DATABASE IF NOT EXISTS " . DB_NAME . " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $conn->select_db(DB_NAME); + + // Load db_jalan.sql to seed tables + $sqlPath = __DIR__ . '/../database/db_jalan.sql'; + if (file_exists($sqlPath)) { + $sqlContent = file_get_contents($sqlPath); + // Remove USE command from raw SQL to avoid conflicts + $sqlContent = preg_replace('/USE\s+[a-zA-Z0-9_]+;/i', '', $sqlContent); + $conn->multi_query($sqlContent); + // Clear multi-query results to prevent synch error + do { + if ($res = $conn->store_result()) { + $res->free(); + } + } while ($conn->next_result()); + } + } else { + $conn->select_db(DB_NAME); + } + + $conn->set_charset('utf8mb4'); + return $conn; +} + +// CORS & JSON headers — panggil sekali dari tiap API entry point +function setCorsHeaders(): void { + header('Content-Type: application/json; charset=utf-8'); + header('Access-Control-Allow-Origin: *'); + header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type'); + if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(204); + exit; + } +} + +// Helper: baca JSON body +function getJsonInput(): ?array { + $raw = file_get_contents('php://input'); + if (!$raw) return null; + $data = json_decode($raw, true); + return (json_last_error() === JSON_ERROR_NONE) ? $data : null; +} + +// Helper: validasi & encode GeoJSON dari array koordinat [lat,lng] +// Frontend tetap kirim [lat,lng] (Leaflet-style), kita konversi ke GeoJSON [lng,lat] +function buildGeoJsonLine(array $coords): string { + $gjCoords = array_map(fn($p) => [(float)$p[1], (float)$p[0]], $coords); + return json_encode(['type' => 'LineString', 'coordinates' => $gjCoords]); +} + +function buildGeoJsonPolygon(array $coords): string { + $gjCoords = array_map(fn($p) => [(float)$p[1], (float)$p[0]], $coords); + // tutup ring jika belum + if ($gjCoords[0] !== end($gjCoords)) $gjCoords[] = $gjCoords[0]; + return json_encode(['type' => 'Polygon', 'coordinates' => [$gjCoords]]); +} + +function buildGeoJsonPoint(float $lat, float $lng): string { + return json_encode(['type' => 'Point', 'coordinates' => [$lng, $lat]]); +} + +// Helper: parse GeoJSON → array [lat,lng] (untuk dikirim ke frontend/Leaflet) +function parseGeoJsonToLatLng(?string $geomStr): array { + if (!$geomStr) return []; + $geom = json_decode($geomStr, true); + if (!$geom) return []; + + switch ($geom['type'] ?? '') { + case 'LineString': + return array_map(fn($c) => [$c[1], $c[0]], $geom['coordinates']); + case 'Polygon': + return array_map(fn($c) => [$c[1], $c[0]], $geom['coordinates'][0]); + case 'Point': + return [$geom['coordinates'][1], $geom['coordinates'][0]]; + default: + return []; + } +} diff --git a/jalan_tanah/database/db_jalan.sql b/jalan_tanah/database/db_jalan.sql new file mode 100644 index 0000000..1f60e1e --- /dev/null +++ b/jalan_tanah/database/db_jalan.sql @@ -0,0 +1,118 @@ +-- ============================================ +-- WebGIS - Manajemen Jalan, Parsil & Laporan Jalan Rusak +-- Database Schema (GeoJSON-based) +-- ============================================ + +CREATE DATABASE IF NOT EXISTS db_jalan CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE db_jalan; + +-- ============================================ +-- Tabel Data Jalan (GeoJSON LineString) +-- ============================================ +CREATE TABLE IF NOT EXISTS data_jalan ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama_jalan VARCHAR(255) NOT NULL, + status_jalan ENUM('Nasional','Provinsi','Kabupaten') NOT NULL, + panjang_meter DOUBLE NOT NULL DEFAULT 0, + keterangan TEXT, + -- GeoJSON LineString: {"type":"LineString","coordinates":[[lng,lat],...]} + geom JSON NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- ============================================ +-- Tabel Data Parsil Tanah (GeoJSON Polygon) +-- ============================================ +CREATE TABLE IF NOT EXISTS data_parsil ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama_parsil VARCHAR(255) NOT NULL, + pemilik VARCHAR(255) NOT NULL, + status_sertifikat ENUM('SHM','HGB','HGU','HP') NOT NULL, + nomor_sertifikat VARCHAR(100), + luas_meter2 DOUBLE NOT NULL DEFAULT 0, + keterangan TEXT, + -- GeoJSON Polygon: {"type":"Polygon","coordinates":[[[lng,lat],...,[lng,lat]]]} + geom JSON NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- ============================================ +-- Tabel Laporan Jalan Rusak (GeoJSON Point) +-- ============================================ +CREATE TABLE IF NOT EXISTS laporan_jalan_rusak ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama_pelapor VARCHAR(255), -- opsional + nama_jalan VARCHAR(255) NOT NULL, -- untuk analisis frekuensi per jalan + deskripsi TEXT, + foto_path VARCHAR(500), -- path relatif file foto + foto_lat DOUBLE, -- dari metadata exif atau manual + foto_lng DOUBLE, -- dari metadata exif atau manual + foto_datetime DATETIME, -- dari metadata exif + -- GeoJSON Point: {"type":"Point","coordinates":[lng,lat]} + geom JSON NOT NULL, + status ENUM('pending','verified','resolved') NOT NULL DEFAULT 'pending', + tanggal_input TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- Index untuk query geospasial & analitik +CREATE INDEX idx_laporan_tanggal ON laporan_jalan_rusak(tanggal_input); +CREATE INDEX idx_laporan_nama_jalan ON laporan_jalan_rusak(nama_jalan); +CREATE INDEX idx_laporan_status ON laporan_jalan_rusak(status); +CREATE INDEX idx_jalan_status ON data_jalan(status_jalan); +CREATE INDEX idx_parsil_status ON data_parsil(status_sertifikat); + +-- ============================================ +-- Sample Data Jalan (GeoJSON) +-- ============================================ +INSERT INTO data_jalan (nama_jalan, status_jalan, panjang_meter, keterangan, geom) VALUES +( + 'Jalan Trans Kalimantan', 'Nasional', 2500.50, + 'Jalan utama lintas Kalimantan', + '{"type":"LineString","coordinates":[[109.3425,0.0263],[109.3500,0.0270],[109.3580,0.0280],[109.3650,0.0290]]}' +), +( + 'Jalan Soekarno-Hatta', 'Provinsi', 1800.75, + 'Jalan provinsi kawasan kota', + '{"type":"LineString","coordinates":[[109.3300,0.0200],[109.3380,0.0210],[109.3460,0.0225]]}' +), +( + 'Jalan Parit Baru', 'Kabupaten', 950.25, + 'Jalan kabupaten menuju kawasan industri', + '{"type":"LineString","coordinates":[[109.3200,0.0150],[109.3270,0.0160],[109.3320,0.0175]]}' +); + +-- ============================================ +-- Sample Data Parsil (GeoJSON) +-- ============================================ +INSERT INTO data_parsil (nama_parsil, pemilik, status_sertifikat, nomor_sertifikat, luas_meter2, keterangan, geom) VALUES +( + 'Kavling A1', 'Budi Santoso', 'SHM', 'SHM-001/2020', 450.50, + 'Kavling perumahan blok A', + '{"type":"Polygon","coordinates":[[[109.3320,0.0230],[109.3330,0.0230],[109.3330,0.0235],[109.3320,0.0235],[109.3320,0.0230]]]}' +), +( + 'Kavling B3', 'PT Maju Bersama', 'HGB', 'HGB-023/2019', 1250.00, + 'Kavling komersial blok B', + '{"type":"Polygon","coordinates":[[[109.3350,0.0240],[109.3365,0.0240],[109.3365,0.0250],[109.3350,0.0250],[109.3350,0.0240]]]}' +), +( + 'Lahan Usaha C1', 'CV Subur Makmur', 'HGU', 'HGU-007/2018', 5800.00, + 'Lahan perkebunan', + '{"type":"Polygon","coordinates":[[[109.3270,0.0180],[109.3295,0.0180],[109.3295,0.0195],[109.3270,0.0195],[109.3270,0.0180]]]}' +); + +-- ============================================ +-- Sample Laporan Jalan Rusak +-- ============================================ +INSERT INTO laporan_jalan_rusak (nama_pelapor, nama_jalan, deskripsi, foto_lat, foto_lng, geom, status, tanggal_input) VALUES +('Ahmad Fauzi', 'Jalan Trans Kalimantan', 'Aspal berlubang besar, berbahaya saat hujan', 0.0268, 109.3460, '{"type":"Point","coordinates":[109.3460,0.0268]}', 'pending', NOW() - INTERVAL 5 DAY), +('Siti Rahayu', 'Jalan Trans Kalimantan', 'Jalan retak dan bergelombang', 0.0271, 109.3465, '{"type":"Point","coordinates":[109.3465,0.0271]}', 'pending', NOW() - INTERVAL 8 DAY), +('Budi Hartono', 'Jalan Trans Kalimantan', 'Lubang besar di tengah jalan', 0.0269, 109.3462, '{"type":"Point","coordinates":[109.3462,0.0269]}', 'verified', NOW() - INTERVAL 3 DAY), +('Dewi Lestari', 'Jalan Soekarno-Hatta', 'Marka jalan hilang, genangan air', 0.0215, 109.3385, '{"type":"Point","coordinates":[109.3385,0.0215]}', 'pending', NOW() - INTERVAL 15 DAY), +('Eko Prasetyo', 'Jalan Soekarno-Hatta', 'Bahu jalan rusak', 0.0218, 109.3388, '{"type":"Point","coordinates":[109.3388,0.0218]}', 'pending', NOW() - INTERVAL 20 DAY), +('Fitri Handayani','Jalan Parit Baru', 'Aspal terkelupas', 0.0165, 109.3275, '{"type":"Point","coordinates":[109.3275,0.0165]}', 'resolved', NOW() - INTERVAL 45 DAY), +(NULL, 'Jalan Trans Kalimantan', 'Permukaan jalan sangat rusak parah', 0.0272, 109.3468, '{"type":"Point","coordinates":[109.3468,0.0272]}', 'pending', NOW() - INTERVAL 2 DAY), +('Hendra Wijaya', 'Jalan Trans Kalimantan', 'Lubang dalam di tepi jalan', 0.0266, 109.3458, '{"type":"Point","coordinates":[109.3458,0.0266]}', 'pending', NOW() - INTERVAL 1 DAY); diff --git a/jalan_tanah/index.html b/jalan_tanah/index.html new file mode 100644 index 0000000..1b2ddac --- /dev/null +++ b/jalan_tanah/index.html @@ -0,0 +1,1455 @@ + + + + + +WebGIS — Manajemen Jalan, Parsil & Laporan Rusak + + + + + + +
+ + + + + +
+
+
Koordinat: —
+
+ +
+ + + + + + + + + + + + + + + + +
+ + + + + diff --git a/jalan_tanah/uploads/laporan/.gitkeep b/jalan_tanah/uploads/laporan/.gitkeep new file mode 100644 index 0000000..20b6112 --- /dev/null +++ b/jalan_tanah/uploads/laporan/.gitkeep @@ -0,0 +1 @@ +# Keep this directory in version control, but ignore contents via .gitignore diff --git a/poverty-map/SimpleXLSX.php b/poverty-map/SimpleXLSX.php new file mode 100644 index 0000000..702f647 --- /dev/null +++ b/poverty-map/SimpleXLSX.php @@ -0,0 +1,319 @@ +name = $name; } + + public function writeRow(array $values, array $rowStyle = [], array $cellStyles = []): void { + $this->rows[] = ['v'=>$values, 's'=>$rowStyle, 'cs'=>$cellStyles]; + } + public function writeBlank(int $n = 1): void { + for ($i = 0; $i < $n; $i++) $this->rows[] = ['v'=>[], 's'=>[], 'cs'=>[]]; + } + public function setColWidths(array $widths): void { $this->colWidths = $widths; } + public function getRows(): array { return $this->rows; } + public function getColWidths(): array { return $this->colWidths; } +} + +class SimpleXLSX { + private array $sheets = []; + private array $sharedSt = []; // escaped_value => index + private array $xfMap = []; // fingerprint => xf index + private array $fontMap = []; // font fingerprint => font index + private array $fillMap = []; // bg hex => fill index + + public function addSheet(string $name): SimpleXLSXSheet { + $s = new SimpleXLSXSheet($name); + $this->sheets[] = $s; + return $s; + } + + // ── STYLE FINGERPRINT ───────────────────────────────────────────────────── + // Exclude 'merge' and 'height' — layout props, not cell style props + private function xfFingerprint(array $s): string { + return json_encode([ + 'b' => (bool)($s['bold'] ?? false), + 'i' => (bool)($s['italic'] ?? false), + 'fg' => strtoupper($s['color'] ?? ''), + 'bg' => strtoupper($s['bg'] ?? ''), + 'ha' => $s['halign'] ?? '', + ]); + } + + private function getXfIdx(array $s): int { + $fp = $this->xfFingerprint($s); + if (!isset($this->xfMap[$fp])) { + $this->xfMap[$fp] = count($this->xfMap) + 1; // 0 = default + } + return $this->xfMap[$fp]; + } + + private function strIdx(string $val): int { + if (!isset($this->sharedSt[$val])) $this->sharedSt[$val] = count($this->sharedSt); + return $this->sharedSt[$val]; + } + + private function colLetter(int $n): string { + $l = ''; + while ($n > 0) { $r = ($n-1)%26; $l = chr(65+$r).$l; $n = (int)(($n-1)/26); } + return $l; + } + + // ── BUILD SHEET XML ─────────────────────────────────────────────────────── + private function buildSheetXml(SimpleXLSXSheet $sheet): string { + $merges = []; // reset per sheet + + $xml = '' + . ''; + + $widths = $sheet->getColWidths(); + if (!empty($widths)) { + $xml .= ''; + foreach ($widths as $i => $w) { + $c = $i+1; + $xml .= ""; + } + $xml .= ''; + } + + $xml .= ''; + $rowNum = 0; + foreach ($sheet->getRows() as $rowData) { + $rowNum++; + $ht = $rowData['s']['height'] ?? null; + $htAttr = $ht ? " ht=\"$ht\" customHeight=\"1\"" : ''; + $xml .= ""; + + foreach ($rowData['v'] as $ci => $val) { + $colNum = $ci + 1; + $cellRef = $this->colLetter($colNum).$rowNum; + $cellStyle = array_merge($rowData['s'], $rowData['cs'][$ci] ?? []); + + // FIX: only the FIRST cell (ci===0) creates a merge record + $colspan = (int)($cellStyle['merge'] ?? 0); + if ($colspan > 1 && $ci === 0) { + $endL = $this->colLetter($colNum + $colspan - 1); + $merges[] = "$cellRef:{$endL}{$rowNum}"; + } + + $xf = $this->getXfIdx($cellStyle); + + if (is_int($val) || is_float($val)) { + $xml .= "$val"; + } elseif ($val === '' || $val === null) { + $xml .= ""; + } else { + $esc = htmlspecialchars((string)$val, ENT_XML1, 'UTF-8'); + $si = $this->strIdx($esc); + $xml .= "$si"; + } + } + $xml .= ''; + } + $xml .= ''; + + if (!empty($merges)) { + $xml .= ''; + foreach ($merges as $m) $xml .= ""; + $xml .= ''; + } + + return $xml . ''; + } + + // ── BUILD STYLES XML ────────────────────────────────────────────────────── + private function buildStylesXml(): string { + // Parse all registered styles + $stylesByIdx = []; + foreach ($this->xfMap as $fp => $idx) $stylesByIdx[$idx] = json_decode($fp, true); + ksort($stylesByIdx); + + // Collect unique fonts (bold × italic × color combinations) + $fontDefs = []; // font fingerprint => font index + // Index 0 = default font (no bold, no italic, no color) + $fontDefs['000'] = 0; + + foreach ($stylesByIdx as $s) { + $fk = ($s['b']?'1':'0').($s['i']?'1':'0').strtoupper($s['fg']??''); + if (!isset($fontDefs[$fk])) $fontDefs[$fk] = count($fontDefs); + } + + // Build font XML entries + $fontsXml = ''; + foreach ($fontDefs as $fk => $fi) { + $bold = substr($fk,0,1)==='1'; + $italic = substr($fk,1,1)==='1'; + $color = substr($fk,2); + $fontsXml .= ''; + if ($bold) $fontsXml .= ''; + if ($italic) $fontsXml .= ''; + if ($color) $fontsXml .= ""; + $fontsXml .= ''; + } + $fontsXml .= ''; + + // Collect unique fills (bg colors) + $fillBgs = []; // bg hex => fill index (0,1 reserved by xlsx spec) + foreach ($stylesByIdx as $s) { + $bg = strtoupper($s['bg'] ?? ''); + if ($bg && !isset($fillBgs[$bg])) $fillBgs[$bg] = count($fillBgs) + 2; + } + + $fillsXml = '' + . '' + . ''; + foreach ($fillBgs as $bg => $fi) { + $fillsXml .= "" + . ""; + } + $fillsXml .= ''; + + $bordersXml = ''; + + // Build xf entries + $xfDefs = []; + $xfDefs[] = ''; // index 0 default + + foreach ($stylesByIdx as $s) { + $fk = ($s['b']?'1':'0').($s['i']?'1':'0').strtoupper($s['fg']??''); + $fontId = $fontDefs[$fk] ?? 0; + $bg = strtoupper($s['bg'] ?? ''); + $fillId = ($bg && isset($fillBgs[$bg])) ? $fillBgs[$bg] : 0; + $ha = $s['ha'] ?? ''; + $alignXml = ''; + + $applyFont = $fontId > 0 ? ' applyFont="1"' : ''; + $applyFill = $fillId > 0 ? ' applyFill="1"' : ''; + $xfDefs[] = "" + . $alignXml.''; + } + + $cellXfsXml = ''.implode('', $xfDefs).''; + + return '' + . '' + . $fontsXml.$fillsXml.$bordersXml + . '' + . $cellXfsXml + . '' + . ''; + } + + // ── SHARED STRINGS XML ──────────────────────────────────────────────────── + private function buildSharedStringsXml(): string { + $count = count($this->sharedSt); + $xml = '' + . ""; + $byIdx = array_flip($this->sharedSt); + for ($i = 0; $i < $count; $i++) { + $xml .= ''.($byIdx[$i] ?? '').''; + } + return $xml . ''; + } + + private function buildWorkbookXml(): string { + $xml = '' + . '' + . ''; + foreach ($this->sheets as $i => $s) { + $id = $i + 1; + $name = htmlspecialchars($s->name, ENT_XML1, 'UTF-8'); + $xml .= ""; + } + return $xml . ''; + } + + private function buildWorkbookRels(): string { + $xml = '' + . ''; + foreach ($this->sheets as $i => $s) { + $id = $i + 1; + $xml .= ""; + } + $n = count($this->sheets); + $xml .= "' + . "'; + return $xml . ''; + } + + // ── DOWNLOAD ────────────────────────────────────────────────────────────── + public function download(string $filename): void { + // Pre-pass: register all styles and shared strings before generating XML + foreach ($this->sheets as $sheet) { + foreach ($sheet->getRows() as $row) { + foreach ($row['v'] as $ci => $val) { + $cs = array_merge($row['s'], $row['cs'][$ci] ?? []); + $this->getXfIdx($cs); + if (is_string($val) && $val !== '') { + $this->strIdx(htmlspecialchars($val, ENT_XML1, 'UTF-8')); + } + } + } + } + + $tmp = tempnam(sys_get_temp_dir(), 'xlsx_'); + $zip = new ZipArchive(); + $zip->open($tmp, ZipArchive::OVERWRITE); + + // [Content_Types].xml + $ct = '' + . '' + . '' + . '' + . '' + . '' + . ''; + foreach ($this->sheets as $i => $s) { + $id = $i + 1; + $ct .= "'; + } + $ct .= ''; + $zip->addFromString('[Content_Types].xml', $ct); + + $zip->addFromString('_rels/.rels', + '' + . '' + . ''); + + $zip->addFromString('xl/workbook.xml', $this->buildWorkbookXml()); + $zip->addFromString('xl/_rels/workbook.xml.rels', $this->buildWorkbookRels()); + $zip->addFromString('xl/styles.xml', $this->buildStylesXml()); + $zip->addFromString('xl/sharedStrings.xml', $this->buildSharedStringsXml()); + + foreach ($this->sheets as $i => $sheet) { + $zip->addFromString( + "xl/worksheets/sheet".($i+1).".xml", + $this->buildSheetXml($sheet) + ); + } + + $zip->close(); + + header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + header("Content-Disposition: attachment; filename=\"$filename\""); + header('Content-Length: '.filesize($tmp)); + header('Cache-Control: max-age=0'); + readfile($tmp); + unlink($tmp); + } +} \ No newline at end of file diff --git a/poverty-map/api.php b/poverty-map/api.php new file mode 100644 index 0000000..de5b234 --- /dev/null +++ b/poverty-map/api.php @@ -0,0 +1,710 @@ + 'unauthorized', 'message' => 'Sesi tidak valid. Silakan login kembali.']); + exit; +} +$role = $_SESSION['role']; +$my_ri_id = (int)($_SESSION['id_rumah_ibadah'] ?? 0); +$is_pk = ($role === 'pengambil_kebijakan'); + +// Set JSON header default — export_laporan akan override ini +header('Content-Type: application/json'); + +$action = $_GET['action'] ?? ''; + +// ── HAVERSINE ───────────────────────────────────────────────────────────────── +function hitungJarak($lat1, $lon1, $lat2, $lon2) { + $earthRadius = 6371000; + $latDelta = deg2rad($lat2 - $lat1); + $lonDelta = deg2rad($lon2 - $lon1); + $a = sin($latDelta / 2) * sin($latDelta / 2) + + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * + sin($lonDelta / 2) * sin($lonDelta / 2); + $c = 2 * atan2(sqrt($a), sqrt(1 - $a)); + return $earthRadius * $c; +} + +// ── UPDATE COVERAGE ─────────────────────────────────────────────────────────── +function updateCoverage($conn) { + $conn->query("UPDATE penduduk_miskin SET id_rumah_ibadah = NULL"); + + // Fetch all rumah_ibadah once to prevent N+1 query inside the loop + $rumah_ibadah_q = $conn->query("SELECT * FROM rumah_ibadah"); + $rumah_ibadah_list = []; + while ($ri = $rumah_ibadah_q->fetch_assoc()) { + $rumah_ibadah_list[] = $ri; + } + + $penduduk = $conn->query("SELECT * FROM penduduk_miskin"); + while ($p = $penduduk->fetch_assoc()) { + $terdekat_id = "NULL"; + $jarak_min = INF; + + foreach ($rumah_ibadah_list as $ri) { + $jarak = hitungJarak($p['lat'], $p['lng'], $ri['lat'], $ri['lng']); + if ($jarak <= $ri['radius'] && $jarak < $jarak_min) { + $jarak_min = $jarak; + $terdekat_id = $ri['id']; + } + } + + // Only update if a matching rumah_ibadah is found, since we already set all to NULL above + if ($terdekat_id !== "NULL") { + $conn->query("UPDATE penduduk_miskin SET id_rumah_ibadah = $terdekat_id WHERE id = " . $p['id']); + } + } +} + +// ── AUTO RESET BULANAN ──────────────────────────────────────────────────────── +function autoResetBulanan($conn) { + $bulanIni = date('Y-m'); + $conn->query("UPDATE penduduk_miskin + SET status_bantuan = 'belum', bulan_status = '$bulanIni' + WHERE bulan_status IS NULL OR bulan_status != '$bulanIni'"); +} + +// ── UPLOAD FOTO HELPER ──────────────────────────────────────────────────────── +function uploadFoto($field, $dir, $prefix) { + if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) return null; + + $allowed_exts = ['jpg', 'jpeg', 'png', 'webp']; + $allowed_mimes = ['image/jpeg', 'image/png', 'image/webp']; + + $ext = strtolower(pathinfo($_FILES[$field]['name'], PATHINFO_EXTENSION)); + if (!in_array($ext, $allowed_exts)) return null; + + // Validasi MIME type sebenarnya dari isi file + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $_FILES[$field]['tmp_name']); + finfo_close($finfo); + + if (!in_array($mime, $allowed_mimes)) return null; + + if ($_FILES[$field]['size'] > 5 * 1024 * 1024) return null; + if (!is_dir($dir)) mkdir($dir, 0755, true); + $filename = $prefix . '_' . time() . '.' . $ext; + return move_uploaded_file($_FILES[$field]['tmp_name'], $dir . $filename) ? $filename : null; +} + +// ── HELPER: ADMIN ONLY ──────────────────────────────────────────────────────── +function requireAdmin($role) { + if ($role !== 'admin') { + echo json_encode(['status' => 'error', 'message' => 'Akses ditolak. Fitur ini hanya untuk admin.']); + exit; + } +} + +// ── GET DATA ────────────────────────────────────────────────────────────────── +if ($action == 'get_data') { + autoResetBulanan($conn); + + $data = ['rumah_ibadah' => [], 'penduduk_miskin' => [], 'statistik' => []]; + + $ri = $conn->query(" + SELECT ri.*, + u.nama_lengkap AS koordinator_nama, + u.no_wa AS koordinator_wa + FROM rumah_ibadah ri + LEFT JOIN users u ON u.id_rumah_ibadah = ri.id + AND u.role = 'koordinator' + AND u.id = ( + SELECT id FROM users + WHERE id_rumah_ibadah = ri.id AND role = 'koordinator' + LIMIT 1 + ) + "); + while ($row = $ri->fetch_assoc()) $data['rumah_ibadah'][] = $row; + + // Koordinator hanya menerima PM yang di-cover RI-nya + // Admin & pengambil_kebijakan mendapat semua data + if ($role === 'koordinator' && $my_ri_id) { + $pm = $conn->query(" + SELECT p.*, r.nama as nama_cover, + COALESCE(DATEDIFF(NOW(), MAX(h.tanggal_penyaluran)), 365) AS hari_tanpa_bantuan, + ROUND( + (p.jumlah_anggota / GREATEST((SELECT MAX(jumlah_anggota) FROM penduduk_miskin), 1)) * 40 + + (LEAST(COALESCE(DATEDIFF(NOW(), MAX(h.tanggal_penyaluran)), 365), 365) / 365.0) * 60 + , 1) AS skor_prioritas + FROM penduduk_miskin p + LEFT JOIN rumah_ibadah r ON p.id_rumah_ibadah = r.id + LEFT JOIN histori_bantuan h ON h.id_penduduk_miskin = p.id + WHERE p.id_rumah_ibadah = $my_ri_id + GROUP BY p.id + ORDER BY skor_prioritas DESC + "); + } else { + $pm = $conn->query(" + SELECT p.*, r.nama as nama_cover, + COALESCE(DATEDIFF(NOW(), MAX(h.tanggal_penyaluran)), 365) AS hari_tanpa_bantuan, + ROUND( + (p.jumlah_anggota / GREATEST((SELECT MAX(jumlah_anggota) FROM penduduk_miskin), 1)) * 40 + + (LEAST(COALESCE(DATEDIFF(NOW(), MAX(h.tanggal_penyaluran)), 365), 365) / 365.0) * 60 + , 1) AS skor_prioritas + FROM penduduk_miskin p + LEFT JOIN rumah_ibadah r ON p.id_rumah_ibadah = r.id + LEFT JOIN histori_bantuan h ON h.id_penduduk_miskin = p.id + GROUP BY p.id + ORDER BY skor_prioritas DESC + "); + } + while ($row = $pm->fetch_assoc()) $data['penduduk_miskin'][] = $row; + + $total_pm = 0; // hanya yang sudah punya koordinat + $total_ri = count($data['rumah_ibadah']); + $ter_cover = 0; + $sudah_terima = 0; + $total_jiwa = 0; + $belum_validasi = 0; // import CSV yang belum digeocoding + + foreach ($data['penduduk_miskin'] as $p) { + if (empty($p['lat']) || empty($p['lng'])) { + $belum_validasi++; + continue; // skip dari kalkulasi utama + } + $total_pm++; + $total_jiwa += (int)$p['jumlah_anggota']; + if ($p['id_rumah_ibadah'] !== null) { + $ter_cover++; + if ($p['status_bantuan'] === 'sudah') $sudah_terima++; + } + } + + $data['statistik'] = [ + 'total_ri' => $total_ri, + 'total_pm' => $total_pm, + 'total_jiwa' => $total_jiwa, + 'belum_validasi' => $belum_validasi, + 'ter_cover' => $ter_cover, + 'belum_cover' => $total_pm - $ter_cover, + 'sudah_terima' => $sudah_terima, + 'belum_terima' => $ter_cover - $sudah_terima, + 'pct_cover' => $total_pm > 0 ? round($ter_cover / $total_pm * 100) : 0, + 'pct_terima' => $ter_cover > 0 ? round($sudah_terima / $ter_cover * 100) : 0, + 'bulan' => ['','Januari','Februari','Maret','April','Mei','Juni', + 'Juli','Agustus','September','Oktober','November','Desember'][(int)date('n')] + . ' ' . date('Y'), + ]; + + echo json_encode($data); +} + +// ── TAMBAH RUMAH IBADAH ─────────────────────────────────────────────────────── +if ($action == 'tambah_ri' && $_SERVER['REQUEST_METHOD'] == 'POST') { + requireAdmin($role); + $nama = $_POST['nama']; $jenis = $_POST['jenis'] ?? 'Masjid'; + $alamat = $_POST['alamat']; + + // Validasi koordinat + if (!isset($_POST['lat']) || !isset($_POST['lng']) || !is_numeric($_POST['lat']) || !is_numeric($_POST['lng'])) { + echo json_encode(['status' => 'error', 'message' => 'Titik koordinat lokasi tidak valid atau belum dipilih.']); + exit; + } + $lat = (float)$_POST['lat']; + $lng = (float)$_POST['lng']; + + $stmt = $conn->prepare("INSERT INTO rumah_ibadah (nama, jenis, alamat, lat, lng) VALUES (?, ?, ?, ?, ?)"); + $stmt->bind_param("sssdd", $nama, $jenis, $alamat, $lat, $lng); + $stmt->execute(); + updateCoverage($conn); + echo json_encode(['status' => 'success']); +} + +// ── EDIT RUMAH IBADAH ───────────────────────────────────────────────────────── +if ($action == 'edit_ri' && $_SERVER['REQUEST_METHOD'] == 'POST') { + requireAdmin($role); + $id = (int)$_POST['id']; + $nama = $_POST['nama']; $jenis = $_POST['jenis'] ?? 'Masjid'; + $alamat = $_POST['alamat']; + $stmt = $conn->prepare("UPDATE rumah_ibadah SET nama=?, jenis=?, alamat=? WHERE id=?"); + $stmt->bind_param("sssi", $nama, $jenis, $alamat, $id); + $stmt->execute(); + echo json_encode(['status' => 'success']); +} + +// ── HAPUS RUMAH IBADAH ──────────────────────────────────────────────────────── +if ($action == 'delete_ri' && $_SERVER['REQUEST_METHOD'] == 'POST') { + requireAdmin($role); + $id = (int)$_POST['id']; + $conn->query("UPDATE penduduk_miskin SET id_rumah_ibadah = NULL WHERE id_rumah_ibadah = $id"); + $conn->query("DELETE FROM rumah_ibadah WHERE id = $id"); + updateCoverage($conn); + echo json_encode(['status' => 'success']); +} + +// ── TAMBAH PENDUDUK MISKIN ──────────────────────────────────────────────────── +if ($action == 'tambah_penduduk' && $_SERVER['REQUEST_METHOD'] == 'POST') { + requireAdmin($role); + $nama = $_POST['nama_kepala']; + $jumlah = (int)$_POST['jumlah_anggota']; + $alamat = $_POST['alamat'] ?? ''; + + // Validasi koordinat + if (!isset($_POST['lat']) || !isset($_POST['lng']) || !is_numeric($_POST['lat']) || !is_numeric($_POST['lng'])) { + echo json_encode(['status' => 'error', 'message' => 'Titik koordinat lokasi tidak valid atau belum dipilih.']); + exit; + } + $lat = (float)$_POST['lat']; + $lng = (float)$_POST['lng']; + + $stmt = $conn->prepare("INSERT INTO penduduk_miskin (nama_kepala, jumlah_anggota, alamat, lat, lng) VALUES (?, ?, ?, ?, ?)"); + $stmt->bind_param("sisdd", $nama, $jumlah, $alamat, $lat, $lng); + $stmt->execute(); + $new_id = $conn->insert_id; + $foto = uploadFoto('foto_rumah', 'uploads/foto_rumah/', "rumah_{$new_id}"); + if ($foto) $conn->query("UPDATE penduduk_miskin SET foto_rumah = '$foto' WHERE id = $new_id"); + updateCoverage($conn); + echo json_encode(['status' => 'success', 'id' => $new_id]); +} + +// ── EDIT PENDUDUK MISKIN ────────────────────────────────────────────────────── +if ($action == 'edit_pm' && $_SERVER['REQUEST_METHOD'] == 'POST') { + requireAdmin($role); + $id = (int)$_POST['id']; + $nama = $_POST['nama_kepala']; + $jumlah = (int)$_POST['jumlah_anggota']; + $alamat = $_POST['alamat'] ?? ''; + $stmt = $conn->prepare("UPDATE penduduk_miskin SET nama_kepala=?, jumlah_anggota=?, alamat=? WHERE id=?"); + $stmt->bind_param("sisi", $nama, $jumlah, $alamat, $id); + $stmt->execute(); + $foto = uploadFoto('foto_rumah', 'uploads/foto_rumah/', "rumah_{$id}"); + if ($foto) { + $old = $conn->query("SELECT foto_rumah FROM penduduk_miskin WHERE id=$id")->fetch_assoc(); + if (!empty($old['foto_rumah'])) { + $old_path = 'uploads/foto_rumah/' . $old['foto_rumah']; + if (file_exists($old_path)) unlink($old_path); + } + $conn->query("UPDATE penduduk_miskin SET foto_rumah='$foto' WHERE id=$id"); + } + echo json_encode(['status' => 'success']); +} + +// ── HAPUS PENDUDUK MISKIN ───────────────────────────────────────────────────── +if ($action == 'delete_pm' && $_SERVER['REQUEST_METHOD'] == 'POST') { + requireAdmin($role); + $id = (int)$_POST['id']; + $conn->query("DELETE FROM penduduk_miskin WHERE id = $id"); + echo json_encode(['status' => 'success']); +} + +// ── UPDATE RADIUS ───────────────────────────────────────────────────────────── +if ($action == 'update_radius' && $_SERVER['REQUEST_METHOD'] == 'POST') { + requireAdmin($role); + $id = (int)$_POST['id']; $radius = (int)$_POST['radius']; + $conn->query("UPDATE rumah_ibadah SET radius = $radius WHERE id = $id"); + updateCoverage($conn); + echo json_encode(['status' => 'success']); +} + +// ── TOGGLE STATUS BANTUAN ───────────────────────────────────────────────────── +// Admin: semua PM. Koordinator: hanya PM di RI-nya. Pengambil kebijakan: tidak boleh. +if ($action == 'toggle_status' && $_SERVER['REQUEST_METHOD'] == 'POST') { + if ($role === 'pengambil_kebijakan') { + echo json_encode(['status' => 'error', 'message' => 'Akses ditolak.']); exit; + } + + $id = (int)$_POST['id']; + $status = $_POST['status']; + if ($role === 'koordinator') { + $chk = $conn->query("SELECT id_rumah_ibadah FROM penduduk_miskin WHERE id=$id")->fetch_assoc(); + if (!$chk || $chk['id_rumah_ibadah'] != $my_ri_id) { + echo json_encode(['status' => 'error', 'message' => 'Akses ditolak.']); exit; + } + // Koordinator tidak boleh membatalkan status sudah terima + if ($role === 'koordinator' && $status === 'belum') { + echo json_encode(['status' => 'error', 'message' => 'Koordinator tidak dapat membatalkan status penerimaan bantuan.']); exit; + } + } + $bulan = date('Y-m'); + $stmt = $conn->prepare("UPDATE penduduk_miskin SET status_bantuan=?, bulan_status=? WHERE id=?"); + $stmt->bind_param("ssi", $status, $bulan, $id); + $stmt->execute(); + echo json_encode(['status' => 'success']); +} + +// ── RESET SEMUA STATUS (MANUAL) ─────────────────────────────────────────────── +if ($action == 'reset_bulanan' && $_SERVER['REQUEST_METHOD'] == 'POST') { + requireAdmin($role); + $bulan = date('Y-m'); + $conn->query("UPDATE penduduk_miskin SET status_bantuan = 'belum', bulan_status = '$bulan'"); + echo json_encode(['status' => 'success']); +} + +// ── TANDAI SUDAH TERIMA ─────────────────────────────────────────────────────── +if ($action == 'tandai_sudah' && $_SERVER['REQUEST_METHOD'] == 'POST') { + if ($role === 'pengambil_kebijakan') { + echo json_encode(['status' => 'error', 'message' => 'Akses ditolak.']); exit; + } + $id = (int)$_POST['id']; + $keterangan = trim($_POST['keterangan'] ?? ''); + $bulan = (int)date('n'); + $tahun = (int)date('Y'); + $bulan_status = date('Y-m'); + + $res = $conn->query("SELECT id_rumah_ibadah FROM penduduk_miskin WHERE id = $id"); + $pm = $res->fetch_assoc(); + if (!$pm || !$pm['id_rumah_ibadah']) { + echo json_encode(['status' => 'error', 'message' => 'Penduduk belum ter-cover rumah ibadah.']); + exit; + } + $id_ri = (int)$pm['id_rumah_ibadah']; + + if ($role === 'koordinator' && $id_ri !== $my_ri_id) { + echo json_encode(['status' => 'error', 'message' => 'Akses ditolak.']); + exit; + } + + $foto_bukti = uploadFoto('foto_bukti', 'uploads/foto_bukti/', "bukti_{$id}_{$bulan}{$tahun}"); + if (!$foto_bukti) { + echo json_encode(['status' => 'error', 'message' => 'Foto bukti wajib diupload (jpg/png/webp, maks 5MB).']); + exit; + } + + $stmt = $conn->prepare( + "INSERT INTO histori_bantuan (id_penduduk_miskin, id_rumah_ibadah, bulan, tahun, foto_bukti, keterangan) + VALUES (?, ?, ?, ?, ?, ?)" + ); + $stmt->bind_param("iiiiss", $id, $id_ri, $bulan, $tahun, $foto_bukti, $keterangan); + $stmt->execute(); + + $stmt2 = $conn->prepare("UPDATE penduduk_miskin SET status_bantuan='sudah', bulan_status=? WHERE id=?"); + $stmt2->bind_param("si", $bulan_status, $id); + $stmt2->execute(); + + echo json_encode(['status' => 'success']); +} + +// ── GET HISTORI BANTUAN (per-KK) ────────────────────────────────────────────── +if ($action == 'get_histori') { + $id = (int)($_GET['id_pm'] ?? 0); + // Koordinator hanya bisa lihat histori PM di bawah RI-nya + // Admin & pengambil_kebijakan boleh lihat semua + if ($role === 'koordinator') { + $chk = $conn->query("SELECT id_rumah_ibadah FROM penduduk_miskin WHERE id=$id")->fetch_assoc(); + if (!$chk || $chk['id_rumah_ibadah'] != $my_ri_id) { + echo json_encode([]); exit; + } + } + $res = $conn->query(" + SELECT h.id, h.tanggal_penyaluran, h.bulan, h.tahun, + h.foto_bukti, h.keterangan, + r.nama AS nama_ri + FROM histori_bantuan h + LEFT JOIN rumah_ibadah r ON h.id_rumah_ibadah = r.id + WHERE h.id_penduduk_miskin = $id + ORDER BY h.tanggal_penyaluran DESC + LIMIT 36 + "); + $rows = []; + while ($row = $res->fetch_assoc()) $rows[] = $row; + echo json_encode($rows); +} + +// ── GET HISTORI GLOBAL (Tab 3) ──────────────────────────────────────────────── +if ($action == 'get_histori_global') { + $limit = min((int)($_GET['limit'] ?? 30), 100); + // Koordinator hanya lihat histori RI-nya; admin & pengambil_kebijakan lihat semua + $where = ($role === 'koordinator' && $my_ri_id) ? "WHERE h.id_rumah_ibadah = $my_ri_id" : ''; + $res = $conn->query(" + SELECT h.id, h.tanggal_penyaluran, h.bulan, h.tahun, + h.foto_bukti, h.keterangan, + p.nama_kepala, + r.nama AS nama_ri, r.jenis AS jenis_ri + FROM histori_bantuan h + LEFT JOIN penduduk_miskin p ON h.id_penduduk_miskin = p.id + LEFT JOIN rumah_ibadah r ON h.id_rumah_ibadah = r.id + $where + ORDER BY h.tanggal_penyaluran DESC + LIMIT $limit + "); + $rows = []; + while ($row = $res->fetch_assoc()) $rows[] = $row; + echo json_encode($rows); +} + +// ── GET USERS (admin only) ──────────────────────────────────────────────────── +if ($action == 'get_users') { + requireAdmin($role); + $res = $conn->query(" + SELECT u.id, u.username, u.nama_lengkap, u.role, u.id_rumah_ibadah, + r.nama AS nama_ri + FROM users u + LEFT JOIN rumah_ibadah r ON u.id_rumah_ibadah = r.id + WHERE u.role IN ('koordinator', 'pengambil_kebijakan') + ORDER BY u.role, u.nama_lengkap + "); + $rows = []; + while ($row = $res->fetch_assoc()) $rows[] = $row; + echo json_encode($rows); +} + +// ── TAMBAH USER ─────────────────────────────────────────────────────────────── +if ($action == 'tambah_user' && $_SERVER['REQUEST_METHOD'] == 'POST') { + requireAdmin($role); + $username = trim($_POST['username'] ?? ''); + $password = $_POST['password'] ?? ''; + $nama = trim($_POST['nama_lengkap'] ?? ''); + $no_wa = trim($_POST['no_wa'] ?? ''); + $new_role = $_POST['role'] ?? 'koordinator'; + $id_ri = (int)($_POST['id_rumah_ibadah'] ?? 0) ?: null; + + // Validasi role yang diizinkan + $allowed_roles = ['koordinator', 'pengambil_kebijakan']; + if (!in_array($new_role, $allowed_roles)) { + echo json_encode(['status' => 'error', 'message' => 'Role tidak valid.']); exit; + } + // Pengambil kebijakan tidak perlu RI + if ($new_role === 'pengambil_kebijakan') $id_ri = null; + + if (!$username || !$password || !$nama) { + echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap.']); exit; + } + $escaped = mysqli_real_escape_string($conn, $username); + $chk = $conn->query("SELECT id FROM users WHERE username = '$escaped'")->fetch_assoc(); + if ($chk) { + echo json_encode(['status' => 'error', 'message' => 'Username sudah digunakan.']); exit; + } + $hash = password_hash($password, PASSWORD_DEFAULT); + $stmt = $conn->prepare("INSERT INTO users (username, password, role, id_rumah_ibadah, nama_lengkap, no_wa) VALUES (?, ?, ?, ?, ?, ?)"); + $stmt->bind_param("sssiss", $username, $hash, $new_role, $id_ri, $nama, $no_wa); + $stmt->execute(); + echo json_encode(['status' => 'success']); +} + +// ── EDIT USER ───────────────────────────────────────────────────────────────── +if ($action == 'edit_user' && $_SERVER['REQUEST_METHOD'] == 'POST') { + requireAdmin($role); + $id = (int)$_POST['id']; + $username = trim($_POST['username'] ?? ''); + $nama = trim($_POST['nama_lengkap'] ?? ''); + $no_wa = trim($_POST['no_wa'] ?? ''); + $password = $_POST['password'] ?? ''; + $new_role = $_POST['role'] ?? 'koordinator'; + $id_ri = (int)($_POST['id_rumah_ibadah'] ?? 0) ?: null; + + $allowed_roles = ['koordinator', 'pengambil_kebijakan']; + if (!in_array($new_role, $allowed_roles)) { + echo json_encode(['status' => 'error', 'message' => 'Role tidak valid.']); exit; + } + if ($new_role === 'pengambil_kebijakan') $id_ri = null; + + $escaped = mysqli_real_escape_string($conn, $username); + $chk = $conn->query("SELECT id FROM users WHERE username='$escaped' AND id != $id")->fetch_assoc(); + if ($chk) { + echo json_encode(['status' => 'error', 'message' => 'Username sudah digunakan.']); exit; + } + if ($password) { + $hash = password_hash($password, PASSWORD_DEFAULT); + $stmt = $conn->prepare("UPDATE users SET username=?, password=?, role=?, nama_lengkap=?, no_wa=?, id_rumah_ibadah=? WHERE id=?"); + $stmt->bind_param("sssssii", $username, $hash, $new_role, $nama, $no_wa, $id_ri, $id); + } else { + $stmt = $conn->prepare("UPDATE users SET username=?, role=?, nama_lengkap=?, no_wa=?, id_rumah_ibadah=? WHERE id=?"); + $stmt->bind_param("ssssii", $username, $new_role, $nama, $no_wa, $id_ri, $id); + } + $stmt->execute(); + echo json_encode(['status' => 'success']); +} + +// ── HAPUS USER ──────────────────────────────────────────────────────────────── +if ($action == 'delete_user' && $_SERVER['REQUEST_METHOD'] == 'POST') { + requireAdmin($role); + $id = (int)$_POST['id']; + if ($id === (int)$_SESSION['user_id']) { + echo json_encode(['status' => 'error', 'message' => 'Tidak bisa menghapus akun sendiri.']); exit; + } + $conn->query("DELETE FROM users WHERE id=$id AND role IN ('koordinator','pengambil_kebijakan')"); + echo json_encode(['status' => 'success']); +} + +// ── GET GEOCODING QUEUE ─────────────────────────────────────────────────────── +if ($action == 'get_geocoding_queue') { + requireAdmin($role); + $res = $conn->query(" + SELECT id, nama_kepala, jumlah_anggota, alamat, status_geocoding + FROM penduduk_miskin + WHERE lat IS NULL OR lng IS NULL + ORDER BY id DESC + "); + $rows = []; + while ($row = $res->fetch_assoc()) $rows[] = $row; + echo json_encode($rows); +} + +// ── UPDATE LOKASI (dari antrean geocoding) ──────────────────────────────────── +if ($action == 'update_lokasi' && $_SERVER['REQUEST_METHOD'] == 'POST') { + requireAdmin($role); + $id = (int)$_POST['id']; + $lat = (float)$_POST['lat']; + $lng = (float)$_POST['lng']; + $conn->query("UPDATE penduduk_miskin SET lat=$lat, lng=$lng, status_geocoding='sukses' WHERE id=$id"); + updateCoverage($conn); + echo json_encode(['status' => 'success']); +} + +// ── EXPORT LAPORAN (CSV) ────────────────────────────────────────────────────── +// ── EXPORT LAPORAN (xlsx) ────────────────────────────────────────────────────── +if ($action == 'export_laporan') { + $bulan = (int)($_GET['bulan'] ?? date('n')); + $tahun = (int)($_GET['tahun'] ?? date('Y')); + + $bulan_nama = ['','Januari','Februari','Maret','April','Mei','Juni', + 'Juli','Agustus','September','Oktober','November','Desember']; + $periode = ($bulan_nama[$bulan] ?? $bulan).' '.$tahun; + + // ── DATA ────────────────────────────────────────────────────────────────── + $total_ri = (int)$conn->query("SELECT COUNT(*) n FROM rumah_ibadah")->fetch_assoc()['n']; + $total_pm = (int)$conn->query("SELECT COUNT(*) n FROM penduduk_miskin WHERE lat IS NOT NULL")->fetch_assoc()['n']; + $total_jiwa = (int)$conn->query("SELECT COALESCE(SUM(jumlah_anggota),0) n FROM penduduk_miskin WHERE lat IS NOT NULL")->fetch_assoc()['n']; + $ter_cover = (int)$conn->query("SELECT COUNT(*) n FROM penduduk_miskin WHERE lat IS NOT NULL AND id_rumah_ibadah IS NOT NULL")->fetch_assoc()['n']; + $sudah = (int)$conn->query("SELECT COUNT(DISTINCT id_penduduk_miskin) n FROM histori_bantuan WHERE bulan=$bulan AND tahun=$tahun")->fetch_assoc()['n']; + $belum = $ter_cover - $sudah; + $blm_cover = $total_pm - $ter_cover; + $pct_cov = $total_pm > 0 ? round($ter_cover / $total_pm * 100, 1) : 0; + $pct_ter = $ter_cover > 0 ? round($sudah / $ter_cover * 100, 1) : 0; + + $ri_q = $conn->query(" + SELECT r.nama AS nama_ri, r.jenis, + COUNT(DISTINCT p.id) AS total_kk, + COALESCE(SUM(p.jumlah_anggota),0) AS jiwa, + COUNT(DISTINCT h.id_penduduk_miskin) AS sudah_kk, + COUNT(DISTINCT p.id)-COUNT(DISTINCT h.id_penduduk_miskin) AS belum_kk + FROM rumah_ibadah r + LEFT JOIN penduduk_miskin p ON p.id_rumah_ibadah=r.id AND p.lat IS NOT NULL + LEFT JOIN histori_bantuan h ON h.id_penduduk_miskin=p.id AND h.bulan=$bulan AND h.tahun=$tahun + GROUP BY r.id, r.nama, r.jenis ORDER BY r.nama + "); + $rekap = []; while ($row=$ri_q->fetch_assoc()) $rekap[]=$row; + + $det_q = $conn->query(" + SELECT p.nama_kepala, p.jumlah_anggota, p.alamat, + r.nama AS nama_ri, r.jenis AS jenis_ri, + h.tanggal_penyaluran, h.keterangan, + CASE WHEN h.id IS NOT NULL THEN 'Sudah Terima' ELSE 'Belum Terima' END AS status + FROM penduduk_miskin p + LEFT JOIN rumah_ibadah r ON r.id=p.id_rumah_ibadah + LEFT JOIN histori_bantuan h ON h.id_penduduk_miskin=p.id AND h.bulan=$bulan AND h.tahun=$tahun + WHERE p.lat IS NOT NULL AND p.id_rumah_ibadah IS NOT NULL + ORDER BY r.nama, h.tanggal_penyaluran IS NULL ASC, p.nama_kepala + "); + $detail=[]; while ($row=$det_q->fetch_assoc()) $detail[]=$row; + + $unc_q = $conn->query(" + SELECT p.nama_kepala, p.jumlah_anggota, p.alamat + FROM penduduk_miskin p WHERE p.lat IS NOT NULL AND p.id_rumah_ibadah IS NULL + ORDER BY p.nama_kepala + "); + $uncov=[]; while ($row=$unc_q->fetch_assoc()) $uncov[]=$row; + + // ── SHARED STYLES ───────────────────────────────────────────────────────── + // Hanya header kolom tabel yang berwarna. Data rows bersih, font gelap. + $H1 = ['bold'=>true, 'halign'=>'center', 'height'=>22]; + $H2 = ['bold'=>true, 'height'=>18]; + $COL = ['bold'=>true, 'bg'=>'334155', 'color'=>'FFFFFF', 'halign'=>'center', 'height'=>16]; + $DATA = []; + $WARN = ['italic'=>true, 'color'=>'6B7280']; + $S_OK = ['color'=>'065F46']; // teks hijau: sudah terima + $S_NOK= ['color'=>'991B1B']; // teks merah: belum terima + $S_AMB= ['color'=>'92400E']; // teks coklat: perlu perhatian + + // ── SHEET 1: RINGKASAN ──────────────────────────────────────────────────── + $xlsx = new SimpleXLSX(); + $s1 = $xlsx->addSheet('Ringkasan'); + $s1->setColWidths([36, 18, 30, 15, 15, 15, 15, 15]); + + // PERBAIKAN: 'merge' dipindah ke parameter ke-3 (Cell Styles) di indeks 0 + $s1->writeRow(['LAPORAN DISTRIBUSI BANTUAN SOSIAL', '', '', '', '', '', '', ''], $H1, [0 => ['merge'=>8]]); + + // PERBAIKAN: Memisahkan style teks dengan merge cell + $s1->writeRow(["Periode: $periode | Digenerate: ".date('d/m/Y H:i'), '', '', '', '', '', '', ''], ['halign'=>'center','color'=>'888888'], [0 => ['merge'=>8]]); + $s1->writeBlank(); + + // PERBAIKAN: Section header Ringkasan Eksekutif + $s1->writeRow(['RINGKASAN EKSEKUTIF','','','','','','',''], $H2, [0 => ['merge'=>8]]); + $s1->writeRow(['Metrik','Nilai','Keterangan'], $COL); + + $stats = [ + ['Total Rumah Ibadah', $total_ri, ''], + ['Total KK Terdaftar (berkoordinat)', $total_pm, ''], + ['Total Jiwa', $total_jiwa, ''], + ['KK Ter-cover Rumah Ibadah', $ter_cover, $pct_cov.'% dari total KK'], + ['KK Belum Ter-cover', $blm_cover, ''], + ['Sudah Menerima Bantuan (periode ini)', $sudah, $pct_ter.'% dari KK ter-cover'], + ['Belum Menerima Bantuan', $belum, 'dari KK yang ter-cover'], + ]; + foreach ($stats as $i => $st) { + $bg = $DATA; + $s1->writeRow($st, $bg, [0=>['bold'=>true]]); + } + + // PERBAIKAN: Catatan kaki / Warning + $s1->writeRow(['* Status distribusi dari histori penyaluran aktual, bukan status sementara yang direset tiap bulan.'], $WARN, [0 => ['merge'=>8]]); + $s1->writeBlank(); + + // Rekap per RI + // PERBAIKAN: Section header Rekap Per RI + $s1->writeRow(['REKAP PER RUMAH IBADAH','','','','','','',''], $H2, [0 => ['merge'=>8]]); + $s1->writeRow(['No','Nama Rumah Ibadah','Jenis','Total KK','Total Jiwa','Sudah','Belum','% Distribusi'], $COL); + foreach ($rekap as $i => $r) { + $pct = $r['total_kk']>0 ? round($r['sudah_kk']/$r['total_kk']*100,1) : 0; + $bg = $DATA; + $s1->writeRow([$i+1,$r['nama_ri'],$r['jenis'],$r['total_kk'],$r['jiwa'], + $r['sudah_kk'],$r['belum_kk'],$pct.'%'], $bg, + [0=>['halign'=>'center'],3=>['halign'=>'center'],4=>['halign'=>'center'],5=>['halign'=>'center'],6=>['halign'=>'center'],7=>['halign'=>'center']]); + } + +// ── SHEET 2: DETAIL ─────────────────────────────────────────────────────── + $s2 = $xlsx->addSheet('Detail Distribusi'); + $s2->setColWidths([5, 28, 10, 42, 16, 22, 32]); + + // PERBAIKAN: Judul Sheet 2 + $s2->writeRow(["DETAIL DISTRIBUSI — $periode",'','','','','',''], $H1, [0 => ['merge'=>7]]); + + $cur_ri = null; $no = 0; + foreach ($detail as $r) { + if ($r['nama_ri'] !== $cur_ri) { + $cur_ri = $r['nama_ri']; $no = 0; + $s2->writeBlank(); + + // PERBAIKAN: Nama Rumah Ibadah Grouping Header + $s2->writeRow([' '.$r['nama_ri'].' ('.$r['jenis_ri'].')','','','','','',''], $H2, [0 => ['merge'=>7]]); + $s2->writeRow(['No','Nama Kepala Keluarga','Jml Anggota','Alamat', + 'Status','Tgl Penyaluran','Keterangan'], $COL); + } + $no++; + $sudah = $r['status']==='Sudah Terima'; + $statusStyle = $sudah ? $S_OK : $S_NOK; + $s2->writeRow([$no,$r['nama_kepala'],$r['jumlah_anggota'],$r['alamat']??'-',$r['status'],$r['tanggal_penyaluran']??'-',$r['keterangan']??'-'],$DATA,[0=>['halign'=>'center'],2=>['halign'=>'center'],4=>array_merge($statusStyle,['halign'=>'center'])]); + } + +// ── SHEET 3: BELUM TER-COVER ────────────────────────────────────────────── + if (!empty($uncov)) { + $s3 = $xlsx->addSheet('Belum Ter-cover'); + $s3->setColWidths([5, 28, 12, 45, 24]); + + // PERBAIKAN: Judul Sheet 3 + $s3->writeRow(['PENDUDUK BELUM TER-COVER RUMAH IBADAH','','','',''], $H1, [0 => ['merge'=>5]]); + + // PERBAIKAN: Warning text Sheet 3 + $s3->writeRow([count($uncov).' KK belum memiliki rumah ibadah penanggung jawab. Perlu penugasan segera.', '','','',''], $WARN, [0 => ['merge'=>5]]); + $s3->writeRow(['No','Nama Kepala Keluarga','Jml Anggota','Alamat','Keterangan'], $COL); + foreach ($uncov as $i => $r) { + $bg = $DATA; + $s3->writeRow([$i+1,$r['nama_kepala'],$r['jumlah_anggota'], + $r['alamat']??'-','Perlu penugasan RI'], + $bg, + [0=>['halign'=>'center'],2=>['halign'=>'center'],4=>$S_AMB]); + } + } + + $bln_str = str_pad($bulan, 2, '0', STR_PAD_LEFT); + $xlsx->download("laporan_bansos_{$bln_str}_{$tahun}.xlsx"); +} +?> \ No newline at end of file diff --git a/poverty-map/import.php b/poverty-map/import.php new file mode 100644 index 0000000..e71a589 --- /dev/null +++ b/poverty-map/import.php @@ -0,0 +1,211 @@ +'error','msg'=>'Akses ditolak.']) . "\n\n"; + exit; +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_FILES['csv_file'])) { + http_response_code(400); + echo json_encode(['status'=>'error','message'=>'Tidak ada file yang diunggah.']); + exit; +} + +$import_type = $_GET['type'] ?? 'penduduk'; // 'penduduk' | 'ri' +if (!in_array($import_type, ['penduduk','ri'])) $import_type = 'penduduk'; + +// ── SSE HEADER ──────────────────────────────────────────────────────────────── +header('Content-Type: text/event-stream'); +header('Cache-Control: no-cache'); +header('X-Accel-Buffering: no'); +set_time_limit(0); +ob_implicit_flush(true); +if (ob_get_level()) ob_end_flush(); + +function sse($type, $data) { + echo "data: " . json_encode(array_merge(['type' => $type], $data)) . "\n\n"; + if (ob_get_level()) ob_flush(); + flush(); +} + +// ── VALIDASI FILE ───────────────────────────────────────────────────────────── +$file = $_FILES['csv_file']['tmp_name']; +if (!$file || $_FILES['csv_file']['error'] !== UPLOAD_ERR_OK) { + sse('error', ['msg' => 'File gagal diunggah.']); exit; +} + +$ext = strtolower(pathinfo($_FILES['csv_file']['name'], PATHINFO_EXTENSION)); +if (!in_array($ext, ['csv', 'txt'])) { + sse('error', ['msg' => 'Hanya file .csv yang diterima. File yang diunggah: .'.$ext]); exit; +} + +// Batas ukuran file: 2MB +if ($_FILES['csv_file']['size'] > 2 * 1024 * 1024) { + sse('error', ['msg' => 'Ukuran file terlalu besar (maks 2MB).']); exit; +} + +// ── BACA & VALIDASI STRUKTUR CSV ────────────────────────────────────────────── +$raw = file_get_contents($file); + +// Cek apakah file terlihat seperti teks biasa (bukan binary/gambar/dll) +if (!mb_check_encoding($raw, 'UTF-8') && !mb_check_encoding($raw, 'ISO-8859-1')) { + sse('error', ['msg' => 'File tidak dapat dibaca sebagai teks. Pastikan file adalah CSV yang valid.']); exit; +} + +// Deteksi delimiter +$firstLine = explode("\n", $raw)[0] ?? ''; +$delim = substr_count($firstLine, ';') > substr_count($firstLine, ',') ? ';' : ','; + +$handle = fopen($file, 'r'); +$header = fgetcsv($handle, 0, $delim); +if (!$header) { sse('error', ['msg' => 'File CSV kosong atau tidak dapat dibaca.']); exit; } + +// Validasi jumlah kolom header sesuai tipe +$min_cols = $import_type === 'ri' ? 3 : 2; +if (count($header) < $min_cols) { + $expected = $import_type === 'ri' + ? 'minimal 3 kolom: Nama, Jenis, Alamat' + : 'minimal 2 kolom: Nama KK, Jumlah Anggota'; + sse('error', ['msg' => "Format CSV tidak sesuai. Tipe '$import_type' membutuhkan $expected. File ini hanya punya ".count($header)." kolom."]); exit; +} + +// Batas baris: maks 500 +$total = 0; +while (fgetcsv($handle, 0, $delim)) $total++; +rewind($handle); +fgetcsv($handle, 0, $delim); // skip header + +if ($total === 0) { sse('error', ['msg' => 'Tidak ada baris data di CSV (hanya header).']); exit; } +if ($total > 500) { + sse('error', ['msg' => "File berisi $total baris. Maksimum 500 baris per import untuk menghindari timeout geocoding. Pecah menjadi beberapa file."]); exit; +} + +sse('start', ['total' => $total, 'msg' => "Memproses $total baris (".($import_type==='ri'?'Rumah Ibadah':'Penduduk').")..."]); + +// Geocoding dihapus — koordinat diinput manual lewat klik peta setelah import. + +// ── VALIDASI NILAI HELPER ───────────────────────────────────────────────────── +$JENIS_VALID = ['Masjid','Gereja Protestan','Gereja Katolik','Vihara','Pura','Kelenteng']; + +function sanitizeStr($conn, $val, $maxLen = 255) { + $val = trim($val ?? ''); + if (mb_strlen($val) > $maxLen) $val = mb_substr($val, 0, $maxLen); + return $conn->real_escape_string($val); +} + +// ── PROSES TIAP BARIS ───────────────────────────────────────────────────────── +$sukses = 0; $gagal = 0; $dilewati = 0; $row_num = 0; +$inserted_pm_ids = []; // track ID baru untuk updateCoverage yang tepat + +while (($row = fgetcsv($handle, 0, $delim)) !== false) { + $row_num++; + $row = array_map('trim', $row); + + // Skip baris benar-benar kosong + if (count(array_filter($row, fn($v) => $v !== '')) === 0) { + $dilewati++; + sse('row', ['num'=>$row_num,'total'=>$total,'status'=>'skip','nama'=>'(baris kosong)','msg'=>'Dilewati.']); + continue; + } + + // Validasi nama tidak boleh kosong + $nama_raw = $row[0] ?? ''; + if (empty($nama_raw)) { + $dilewati++; + sse('row', ['num'=>$row_num,'total'=>$total,'status'=>'skip','nama'=>'(kosong)','msg'=>'Nama tidak boleh kosong, baris dilewati.']); + continue; + } + + if ($import_type === 'ri') { + // ── IMPORT RUMAH IBADAH ────────────────────────────────────────────── + $nama = sanitizeStr($conn, $row[0], 255); + $jenis = trim($row[1] ?? ''); + if (!in_array($jenis, $JENIS_VALID)) $jenis = 'Masjid'; // default jika tidak valid + $jenis = sanitizeStr($conn, $jenis, 50); + $alamat = sanitizeStr($conn, $row[2] ?? '', 500); + $radius = isset($row[3]) && is_numeric($row[3]) ? max(100, min(2000, (int)$row[3])) : 500; + + // Simpan tanpa koordinat — admin klik peta untuk melengkapi + $conn->query("INSERT INTO rumah_ibadah (nama, jenis, alamat, radius, lat, lng) + VALUES ('$nama', '$jenis', '$alamat', $radius, NULL, NULL)"); + $sukses++; + sse('row', ['num'=>$row_num,'total'=>$total,'status'=>'sukses','nama'=>$row[0], + 'msg'=>"$jenis · radius {$radius}m · koordinat perlu dilengkapi manual"]); + + } else { + // ── IMPORT PENDUDUK ────────────────────────────────────────────────── + $nama = sanitizeStr($conn, $row[0], 255); + $jumlah = isset($row[1]) ? max(1, min(99, (int)$row[1])) : 1; // maks 99 anggota + $alamat = trim( + ($row[2]??'') + . ($row[3]??'' ? ' RT '.($row[3]) : '') + . ($row[4]??'' ? ' RW '.($row[4]) : '') + . ($row[5]??'' ? ', Kel. '.($row[5]) : '') + . ($row[6]??'' ? ', Kec. '.($row[6]) : '') + ); + $alamat_db = sanitizeStr($conn, $alamat, 500); + // Simpan tanpa koordinat — admin klik peta untuk melengkapi + $conn->query("INSERT INTO penduduk_miskin (nama_kepala, jumlah_anggota, alamat, lat, lng) + VALUES ('$nama', $jumlah, '$alamat_db', NULL, NULL)"); + $inserted_pm_ids[] = (int)$conn->insert_id; + $sukses++; + sse('row', ['num'=>$row_num,'total'=>$total,'status'=>'sukses','nama'=>$row[0], + 'msg'=>'Tersimpan · koordinat perlu dilengkapi manual']); + } + + +} +fclose($handle); + +// ── UPDATE COVERAGE (hanya untuk PM yang baru diimport, bukan semua) ────────── +if ($import_type === 'penduduk' && !empty($inserted_pm_ids)) { + $ids_str = implode(',', $inserted_pm_ids); + $ri_all = $conn->query("SELECT * FROM rumah_ibadah"); + $ri_list = []; + while ($ri = $ri_all->fetch_assoc()) $ri_list[] = $ri; + + $penduduk = $conn->query("SELECT id, lat, lng FROM penduduk_miskin WHERE id IN ($ids_str) AND lat IS NOT NULL AND lng IS NOT NULL"); + while ($p = $penduduk->fetch_assoc()) { + $jarak_min = INF; $terdekat_id = 'NULL'; + foreach ($ri_list as $ri) { + $earthR = 6371000; + $dLat = deg2rad($ri['lat'] - $p['lat']); $dLon = deg2rad($ri['lng'] - $p['lng']); + $a = sin($dLat/2)**2 + cos(deg2rad($p['lat'])) * cos(deg2rad($ri['lat'])) * sin($dLon/2)**2; + $jarak = $earthR * 2 * atan2(sqrt($a), sqrt(1-$a)); + if ($jarak <= $ri['radius'] && $jarak < $jarak_min) { $jarak_min = $jarak; $terdekat_id = $ri['id']; } + } + $conn->query("UPDATE penduduk_miskin SET id_rumah_ibadah = $terdekat_id WHERE id = " . $p['id']); + } +} + +// ── DONE ────────────────────────────────────────────────────────────────────── +$label = $import_type === 'ri' ? 'Rumah Ibadah' : 'Penduduk'; +$msg = "Import $label selesai. $sukses data berhasil diimpor."; +if ($dilewati > 0) $msg .= " ($dilewati baris dilewati karena kosong/invalid.)"; +$msg .= " Koordinat perlu dilengkapi manual melalui klik peta."; + +sse('done', ['sukses'=>$sukses,'gagal'=>$gagal,'dilewati'=>$dilewati,'total'=>$total,'msg'=>$msg]); \ No newline at end of file diff --git a/poverty-map/index.php b/poverty-map/index.php new file mode 100644 index 0000000..cbed844 --- /dev/null +++ b/poverty-map/index.php @@ -0,0 +1,2112 @@ + + + + + + + WebGIS Poverty Map + + + + + +
+ + + +
+ + + + +
+ + + + + + + + +
+
+ + +
+ +
+ + + + +
+ + +
+ + +
+
Legenda
+
Rumah Ibadah
+
Masjid
+
Gereja Protestan
+
Gereja Katolik
+
Vihara
+
Pura
+
Kelenteng
+
Penduduk
+
Sudah Terima
+
Belum Terima
+
Belum Ter-cover
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/poverty-map/koneksi.php b/poverty-map/koneksi.php new file mode 100644 index 0000000..16ebfad --- /dev/null +++ b/poverty-map/koneksi.php @@ -0,0 +1,41 @@ +connect_error) { + die("Koneksi MySQL gagal: " . $conn->connect_error); +} + +// 2. Cek apakah database sudah ada +$db_selected = $conn->select_db($db); + +if (!$db_selected) { + // 3. Buat database baru jika belum ada + if ($conn->query("CREATE DATABASE `$db` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")) { + $conn->select_db($db); + + // 4. Baca dan eksekusi setup.sql + $sqlPath = __DIR__ . '/setup.sql'; + if (file_exists($sqlPath)) { + $sql = file_get_contents($sqlPath); + if ($conn->multi_query($sql)) { + // Konsumsi semua hasil dari multi_query untuk mengosongkan buffer MySQL + do { + if ($result = $conn->store_result()) { + $result->free(); + } + } while ($conn->next_result()); + } + } + } else { + die("Gagal membuat database: " . $conn->error); + } +} + +// 5. Konfigurasi encoding UTF-8 +$conn->set_charset("utf8mb4"); +?> \ No newline at end of file diff --git a/poverty-map/login.php b/poverty-map/login.php new file mode 100644 index 0000000..f24c836 --- /dev/null +++ b/poverty-map/login.php @@ -0,0 +1,119 @@ +prepare("SELECT id, username, password, role, id_rumah_ibadah, nama_lengkap FROM users WHERE username = ?"); + $stmt->bind_param("s", $username); + $stmt->execute(); + $user = $stmt->get_result()->fetch_assoc(); + + if ($user && password_verify($password, $user['password'])) { + session_regenerate_id(true); // cegah session fixation + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['role'] = $user['role']; + $_SESSION['id_rumah_ibadah'] = $user['id_rumah_ibadah']; + $_SESSION['nama_lengkap'] = $user['nama_lengkap'] ?: $user['username']; + header('Location: index.php'); + exit; + } else { + // Delay untuk memperlambat brute-force + sleep(1); + $error = 'Username atau password salah.'; + } + } +} +?> + + + + + + Login — WebGIS Poverty Map + + + + +
+ + +
+

WebGIS Poverty Map

+

Informatika UNTAN — GIS Project

+
+ + +
+ +
+ + +
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+
+
+ + + + diff --git a/poverty-map/setup.sql b/poverty-map/setup.sql new file mode 100644 index 0000000..c7de4d1 --- /dev/null +++ b/poverty-map/setup.sql @@ -0,0 +1,111 @@ +-- ============================================================ +-- WebGIS Bantuan Sosial — Setup Database +-- Informatika UNTAN · GIS Project +-- Jalankan file ini sekali pada database kosong. +-- ============================================================ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ------------------------------------------------------------ +-- 1. RUMAH IBADAH +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS rumah_ibadah ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama VARCHAR(255) NOT NULL, + jenis VARCHAR(50) NOT NULL DEFAULT 'Masjid' + COMMENT 'Masjid | Gereja Protestan | Gereja Katolik | Vihara | Pura | Kelenteng', + alamat TEXT NULL, + radius INT NOT NULL DEFAULT 500 COMMENT 'Radius cakupan dalam meter', + lat DOUBLE NOT NULL, + lng DOUBLE NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------ +-- 2. PENDUDUK MISKIN +-- lat & lng boleh NULL untuk data yang belum digeocoding +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS penduduk_miskin ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama_kepala VARCHAR(255) NOT NULL, + jumlah_anggota INT NOT NULL DEFAULT 1, + lat DOUBLE NULL DEFAULT NULL, + lng DOUBLE NULL DEFAULT NULL, + id_rumah_ibadah INT NULL DEFAULT NULL, + foto_rumah VARCHAR(255) NULL DEFAULT NULL COMMENT 'Nama file di uploads/foto_rumah/', + status_bantuan ENUM('sudah','belum') NOT NULL DEFAULT 'belum', + bulan_status VARCHAR(7) NULL DEFAULT NULL COMMENT 'Format YYYY-MM', + alamat VARCHAR(500) NULL DEFAULT NULL, + status_geocoding ENUM('sukses','gagal') NULL DEFAULT NULL + COMMENT 'NULL = input manual, sukses/gagal = dari import CSV', + CONSTRAINT fk_pm_ri + FOREIGN KEY (id_rumah_ibadah) + REFERENCES rumah_ibadah (id) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------ +-- 3. HISTORI BANTUAN +-- Rekam jejak penyaluran per KK per bulan +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS histori_bantuan ( + id INT AUTO_INCREMENT PRIMARY KEY, + id_penduduk_miskin INT NOT NULL, + id_rumah_ibadah INT NOT NULL, + tanggal_penyaluran DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + bulan INT NOT NULL COMMENT '1–12', + tahun INT NOT NULL COMMENT 'Contoh: 2026', + foto_bukti VARCHAR(255) NOT NULL COMMENT 'Nama file di uploads/foto_bukti/', + keterangan TEXT NULL COMMENT 'Catatan logistik opsional', + CONSTRAINT fk_hb_pm + FOREIGN KEY (id_penduduk_miskin) + REFERENCES penduduk_miskin (id) + ON DELETE CASCADE, + CONSTRAINT fk_hb_ri + FOREIGN KEY (id_rumah_ibadah) + REFERENCES rumah_ibadah (id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------ +-- 4. USERS +-- role admin : akses penuh +-- role koordinator : hanya RI yang ditugaskan +-- role pengambil_kebijakan: read-only semua data +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL COMMENT 'bcrypt hash — gunakan password_hash()', + role ENUM('admin','koordinator','pengambil_kebijakan') + NOT NULL DEFAULT 'koordinator', + id_rumah_ibadah INT NULL DEFAULT NULL + COMMENT 'Hanya diisi untuk role koordinator', + nama_lengkap VARCHAR(255) NULL DEFAULT NULL, + no_wa VARCHAR(20) NULL DEFAULT NULL + COMMENT 'Hanya relevan untuk role koordinator', + CONSTRAINT fk_user_ri + FOREIGN KEY (id_rumah_ibadah) + REFERENCES rumah_ibadah (id) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SET FOREIGN_KEY_CHECKS = 1; + +-- ------------------------------------------------------------ +-- 5. AKUN PENGGUNA AWAL (Semua Role - Password: password) +-- ------------------------------------------------------------ +INSERT INTO users (username, password, role, nama_lengkap) +VALUES +('admin', '$2y$12$LFS3b.HIcFbkwh5wVCpuz.cwkvrmYhtA7h73OWZBcjlHI5EwPoVWm', 'admin', 'Administrator'), +('kebijakan', '$2y$12$LFS3b.HIcFbkwh5wVCpuz.cwkvrmYhtA7h73OWZBcjlHI5EwPoVWm', 'pengambil_kebijakan', 'Pengambil Kebijakan'), +('koord1', '$2y$12$LFS3b.HIcFbkwh5wVCpuz.cwkvrmYhtA7h73OWZBcjlHI5EwPoVWm', 'koordinator', 'Koordinator Wilayah 1'), +('koord2', '$2y$12$LFS3b.HIcFbkwh5wVCpuz.cwkvrmYhtA7h73OWZBcjlHI5EwPoVWm', 'koordinator', 'Koordinator Wilayah 2'); + +-- ============================================================ +-- Struktur direktori upload yang harus dibuat di server: +-- uploads/ +-- uploads/foto_rumah/ +-- uploads/foto_bukti/ +-- Pastikan folder tersebut writable oleh web server (chmod 755). +-- ============================================================ \ No newline at end of file diff --git a/poverty-map/uploads/foto_bukti/.gitkeep b/poverty-map/uploads/foto_bukti/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/poverty-map/uploads/foto_rumah/.gitkeep b/poverty-map/uploads/foto_rumah/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/spbu_layer/index.php b/spbu_layer/index.php new file mode 100644 index 0000000..a0cd8b4 --- /dev/null +++ b/spbu_layer/index.php @@ -0,0 +1,571 @@ +connect_error) { + die("Koneksi ke database gagal: " . $conn->connect_error); +} +$conn->query("CREATE DATABASE IF NOT EXISTS webgis_spbu"); +$conn->select_db("webgis_spbu"); + +// Create table if not exists (automatic migration helper) +$conn->query("CREATE TABLE IF NOT EXISTS spbu ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama VARCHAR(255) NOT NULL, + nomor VARCHAR(100) NOT NULL, + status ENUM('24jam', 'tidak') NOT NULL DEFAULT 'tidak', + latitude DOUBLE NOT NULL, + longitude DOUBLE NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); + +// ================== HANDLE POST REQUESTS (API) ================== +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? ''; + + // INSERT SPBU + if ($action === 'insert') { + $nama = $conn->real_escape_string($_POST['nama']); + $nomor = $conn->real_escape_string($_POST['nomor']); + $status = $conn->real_escape_string($_POST['status']); + $lat = (float)$_POST['lat']; + $lng = (float)$_POST['lng']; + + if ($conn->query("INSERT INTO spbu (nama, nomor, status, latitude, longitude) VALUES ('$nama', '$nomor', '$status', '$lat', '$lng')")) { + echo "success"; + } else { + echo "error"; + } + exit; + } + + // UPDATE SPBU + if ($action === 'update') { + $id = (int)$_POST['id']; + $nama = $conn->real_escape_string($_POST['nama']); + $nomor = $conn->real_escape_string($_POST['nomor']); + $status = $conn->real_escape_string($_POST['status']); + + if ($conn->query("UPDATE spbu SET nama='$nama', nomor='$nomor', status='$status' WHERE id=$id")) { + echo "success"; + } else { + echo "error"; + } + exit; + } + + // DELETE SPBU + if ($action === 'delete') { + $id = (int)$_POST['id']; + if ($conn->query("DELETE FROM spbu WHERE id=$id")) { + echo "success"; + } else { + echo "error"; + } + exit; + } + + // MOVE MARKER (Drag-and-Drop) + if ($action === 'move') { + $id = (int)$_POST['id']; + $lat = (float)$_POST['lat']; + $lng = (float)$_POST['lng']; + + if ($conn->query("UPDATE spbu SET latitude='$lat', longitude='$lng' WHERE id=$id")) { + echo "success"; + } else { + echo "error"; + } + exit; + } +} + +// ================== GET DATA ================== +$data = []; +$result = $conn->query("SELECT * FROM spbu ORDER BY nama ASC"); +if ($result) { + while ($row = $result->fetch_assoc()) { + $data[] = $row; + } +} +?> + + + + + + + WebGIS SPBU Pontianak — Layer Control + + + + + + + +
+ + + + + +
+
+ + +
+ +
+ + + + + +