Initial commit
This commit is contained in:
356
index.html
Normal file
356
index.html
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Portal WebGIS Project — Informatika UNTAN</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-grad-1: #030e2c;
|
||||||
|
--bg-grad-2: #05143b;
|
||||||
|
--bg-grad-3: #163372;
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.05);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--accent-blue: #3b82f6;
|
||||||
|
--accent-cyan: #06b6d4;
|
||||||
|
--accent-violet: #8b5cf6;
|
||||||
|
--text-light: #f8fafc;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, var(--bg-grad-1) 0%, var(--bg-grad-2) 50%, var(--bg-grad-3) 100%);
|
||||||
|
color: var(--text-light);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ambient Glow Elements */
|
||||||
|
.glow-sphere {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(120px);
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-1 {
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
background: var(--accent-blue);
|
||||||
|
top: -100px;
|
||||||
|
left: -100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-2 {
|
||||||
|
width: 500px;
|
||||||
|
height: 500px;
|
||||||
|
background: var(--accent-violet);
|
||||||
|
bottom: -150px;
|
||||||
|
right: -100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
animation: fadeInDown 0.8s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(to right, #ffffff, #a5f3fc, #c084fc);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid Layout */
|
||||||
|
.projects-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
animation: fadeInUp 1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glassmorphic Project Card */
|
||||||
|
.card {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0) 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-main {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #93c5fd;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-class {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: #a7f3d0;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent-blue) 0%, #1d4ed8 100%);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
|
||||||
|
box-shadow: 0 6px 25px rgba(59, 130, 246, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text-light);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info Section / Footer info */
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 0;
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
background: rgba(3, 14, 44, 0.6);
|
||||||
|
z-index: 10;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: #c084fc;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-link:hover {
|
||||||
|
color: #d8b4fe;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Ambient Glowing Spheres -->
|
||||||
|
<div class="glow-sphere glow-1"></div>
|
||||||
|
<div class="glow-sphere glow-2"></div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- Header -->
|
||||||
|
<header>
|
||||||
|
<h1>Portal WebGIS Project</h1>
|
||||||
|
<p>Koleksi aplikasi sistem informasi geografis mahasiswa Informatika Universitas Tanjungpura untuk penugasan
|
||||||
|
kelas dan tugas besar.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Projects Grid -->
|
||||||
|
<div class="projects-grid">
|
||||||
|
<!-- 1. WebGIS Poverty Map -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="badge badge-main">Tugas Akhir / Poverty Map</span>
|
||||||
|
<h2>WebGIS Poverty Map</h2>
|
||||||
|
<p>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.</p>
|
||||||
|
</div>
|
||||||
|
<a href="poverty-map/index.php" class="btn-link btn-primary">
|
||||||
|
Buka Aplikasi <span>→</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. SPBU Layer -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="badge badge-class">Project Kelas</span>
|
||||||
|
<h2>Peta SPBU Layer</h2>
|
||||||
|
<p>Visualisasi sebaran stasiun pengisian bahan bakar umum (SPBU) di wilayah Pontianak dan sekitarnya
|
||||||
|
menggunakan layer kontrol interaktif peta Leaflet.js.</p>
|
||||||
|
</div>
|
||||||
|
<a href="spbu_layer/index.php" class="btn-link btn-secondary">
|
||||||
|
Buka Project <span>→</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Jalan Tanah -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="badge badge-class">Project Kelas</span>
|
||||||
|
<h2>Peta Jalan Tanah</h2>
|
||||||
|
<p>Peta visualisasi infrastruktur jalan tanah menggunakan pemetaan garis polylines serta poligon
|
||||||
|
kecamatan untuk menganalisis perkembangan jalan daerah.</p>
|
||||||
|
</div>
|
||||||
|
<a href="jalan_tanah/index.html" class="btn-link btn-secondary">
|
||||||
|
Buka Project <span>→</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer>
|
||||||
|
<p>© 2026 Informatika Universitas Tanjungpura. All rights reserved.</p>
|
||||||
|
<p>Kode Sumber tersimpan di Gitea: <a href="REPLACE_WITH_GITEA_REPO_URL" class="repo-link">Repository Gitea
|
||||||
|
Project</a></p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
138
jalan_tanah/api/jalan.php
Normal file
138
jalan_tanah/api/jalan.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
// api/jalan.php — CRUD Data Jalan (GeoJSON LineString)
|
||||||
|
|
||||||
|
require_once '../config/database.php';
|
||||||
|
setCorsHeaders();
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$conn = getConnection();
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
|
||||||
|
case 'GET':
|
||||||
|
if (isset($_GET['id'])) {
|
||||||
|
$id = intval($_GET['id']);
|
||||||
|
$stmt = $conn->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();
|
||||||
363
jalan_tanah/api/laporan.php
Normal file
363
jalan_tanah/api/laporan.php
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
<?php
|
||||||
|
// api/laporan.php — CRUD + Analitik Laporan Jalan Rusak
|
||||||
|
|
||||||
|
require_once '../config/database.php';
|
||||||
|
setCorsHeaders();
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$conn = getConnection();
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
// ---- Endpoint analitik terpisah ----
|
||||||
|
if ($method === 'GET' && $action === 'analytics') {
|
||||||
|
handleAnalytics($conn);
|
||||||
|
$conn->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
144
jalan_tanah/api/parsil.php
Normal file
144
jalan_tanah/api/parsil.php
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
// api/parsil.php — CRUD Data Parsil Tanah (GeoJSON Polygon)
|
||||||
|
|
||||||
|
require_once '../config/database.php';
|
||||||
|
setCorsHeaders();
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$conn = getConnection();
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
|
||||||
|
case 'GET':
|
||||||
|
if (isset($_GET['id'])) {
|
||||||
|
$id = intval($_GET['id']);
|
||||||
|
$stmt = $conn->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();
|
||||||
108
jalan_tanah/config/database.php
Normal file
108
jalan_tanah/config/database.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================
|
||||||
|
// config/database.php
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
define('DB_HOST', 'localhost');
|
||||||
|
define('DB_USER', 'root');
|
||||||
|
define('DB_PASS', '');
|
||||||
|
define('DB_NAME', 'db_jalan');
|
||||||
|
|
||||||
|
// Path untuk upload foto laporan (relatif dari root project)
|
||||||
|
define('UPLOAD_DIR', __DIR__ . '/../uploads/laporan/');
|
||||||
|
define('UPLOAD_URL', 'uploads/laporan/');
|
||||||
|
define('MAX_FILE_SIZE', 8 * 1024 * 1024); // 8MB
|
||||||
|
define('ALLOWED_TYPES', ['image/jpeg', 'image/png', 'image/webp']);
|
||||||
|
|
||||||
|
function getConnection(): mysqli {
|
||||||
|
$conn = new mysqli(DB_HOST, DB_USER, DB_PASS);
|
||||||
|
if ($conn->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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
118
jalan_tanah/database/db_jalan.sql
Normal file
118
jalan_tanah/database/db_jalan.sql
Normal file
@@ -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);
|
||||||
1455
jalan_tanah/index.html
Normal file
1455
jalan_tanah/index.html
Normal file
File diff suppressed because it is too large
Load Diff
1
jalan_tanah/uploads/laporan/.gitkeep
Normal file
1
jalan_tanah/uploads/laporan/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Keep this directory in version control, but ignore contents via .gitignore
|
||||||
319
poverty-map/SimpleXLSX.php
Normal file
319
poverty-map/SimpleXLSX.php
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* SimpleXLSX.php — Minimal xlsx writer, no dependencies.
|
||||||
|
* Requires: PHP ZipArchive extension (enabled in php.ini: extension=zip)
|
||||||
|
*/
|
||||||
|
|
||||||
|
class SimpleXLSXSheet {
|
||||||
|
public string $name;
|
||||||
|
private array $rows = [];
|
||||||
|
private array $colWidths = [];
|
||||||
|
|
||||||
|
public function __construct(string $name) { $this->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 = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
. '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"'
|
||||||
|
. ' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
|
||||||
|
|
||||||
|
$widths = $sheet->getColWidths();
|
||||||
|
if (!empty($widths)) {
|
||||||
|
$xml .= '<cols>';
|
||||||
|
foreach ($widths as $i => $w) {
|
||||||
|
$c = $i+1;
|
||||||
|
$xml .= "<col min=\"$c\" max=\"$c\" width=\"$w\" customWidth=\"1\"/>";
|
||||||
|
}
|
||||||
|
$xml .= '</cols>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$xml .= '<sheetData>';
|
||||||
|
$rowNum = 0;
|
||||||
|
foreach ($sheet->getRows() as $rowData) {
|
||||||
|
$rowNum++;
|
||||||
|
$ht = $rowData['s']['height'] ?? null;
|
||||||
|
$htAttr = $ht ? " ht=\"$ht\" customHeight=\"1\"" : '';
|
||||||
|
$xml .= "<row r=\"$rowNum\"$htAttr>";
|
||||||
|
|
||||||
|
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 .= "<c r=\"$cellRef\" s=\"$xf\"><v>$val</v></c>";
|
||||||
|
} elseif ($val === '' || $val === null) {
|
||||||
|
$xml .= "<c r=\"$cellRef\" s=\"$xf\"/>";
|
||||||
|
} else {
|
||||||
|
$esc = htmlspecialchars((string)$val, ENT_XML1, 'UTF-8');
|
||||||
|
$si = $this->strIdx($esc);
|
||||||
|
$xml .= "<c r=\"$cellRef\" t=\"s\" s=\"$xf\"><v>$si</v></c>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$xml .= '</row>';
|
||||||
|
}
|
||||||
|
$xml .= '</sheetData>';
|
||||||
|
|
||||||
|
if (!empty($merges)) {
|
||||||
|
$xml .= '<mergeCells count="'.count($merges).'">';
|
||||||
|
foreach ($merges as $m) $xml .= "<mergeCell ref=\"$m\"/>";
|
||||||
|
$xml .= '</mergeCells>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $xml . '</worksheet>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 = '<fonts count="'.count($fontDefs).'">';
|
||||||
|
foreach ($fontDefs as $fk => $fi) {
|
||||||
|
$bold = substr($fk,0,1)==='1';
|
||||||
|
$italic = substr($fk,1,1)==='1';
|
||||||
|
$color = substr($fk,2);
|
||||||
|
$fontsXml .= '<font>';
|
||||||
|
if ($bold) $fontsXml .= '<b/>';
|
||||||
|
if ($italic) $fontsXml .= '<i/>';
|
||||||
|
if ($color) $fontsXml .= "<color rgb=\"FF{$color}\"/>";
|
||||||
|
$fontsXml .= '<sz val="11"/><name val="Calibri"/></font>';
|
||||||
|
}
|
||||||
|
$fontsXml .= '</fonts>';
|
||||||
|
|
||||||
|
// 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 = '<fills count="'.(count($fillBgs)+2).'">'
|
||||||
|
. '<fill><patternFill patternType="none"/></fill>'
|
||||||
|
. '<fill><patternFill patternType="gray125"/></fill>';
|
||||||
|
foreach ($fillBgs as $bg => $fi) {
|
||||||
|
$fillsXml .= "<fill><patternFill patternType=\"solid\">"
|
||||||
|
. "<fgColor rgb=\"FF{$bg}\"/></patternFill></fill>";
|
||||||
|
}
|
||||||
|
$fillsXml .= '</fills>';
|
||||||
|
|
||||||
|
$bordersXml = '<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>';
|
||||||
|
|
||||||
|
// Build xf entries
|
||||||
|
$xfDefs = [];
|
||||||
|
$xfDefs[] = '<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'; // 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 = '<alignment vertical="center"'.($ha?" horizontal=\"$ha\"":'').' wrapText="0"/>';
|
||||||
|
|
||||||
|
$applyFont = $fontId > 0 ? ' applyFont="1"' : '';
|
||||||
|
$applyFill = $fillId > 0 ? ' applyFill="1"' : '';
|
||||||
|
$xfDefs[] = "<xf numFmtId=\"0\" fontId=\"$fontId\" fillId=\"$fillId\""
|
||||||
|
. " borderId=\"0\" xfId=\"0\"$applyFont$applyFill applyAlignment=\"1\">"
|
||||||
|
. $alignXml.'</xf>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$cellXfsXml = '<cellXfs count="'.count($xfDefs).'">'.implode('', $xfDefs).'</cellXfs>';
|
||||||
|
|
||||||
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
. '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||||
|
. $fontsXml.$fillsXml.$bordersXml
|
||||||
|
. '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
||||||
|
. $cellXfsXml
|
||||||
|
. '<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
|
||||||
|
. '</styleSheet>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SHARED STRINGS XML ────────────────────────────────────────────────────
|
||||||
|
private function buildSharedStringsXml(): string {
|
||||||
|
$count = count($this->sharedSt);
|
||||||
|
$xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
. "<sst xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\""
|
||||||
|
. " count=\"$count\" uniqueCount=\"$count\">";
|
||||||
|
$byIdx = array_flip($this->sharedSt);
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$xml .= '<si><t xml:space="preserve">'.($byIdx[$i] ?? '').'</t></si>';
|
||||||
|
}
|
||||||
|
return $xml . '</sst>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildWorkbookXml(): string {
|
||||||
|
$xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
. '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"'
|
||||||
|
. ' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||||
|
. '<sheets>';
|
||||||
|
foreach ($this->sheets as $i => $s) {
|
||||||
|
$id = $i + 1;
|
||||||
|
$name = htmlspecialchars($s->name, ENT_XML1, 'UTF-8');
|
||||||
|
$xml .= "<sheet name=\"$name\" sheetId=\"$id\" r:id=\"rId$id\"/>";
|
||||||
|
}
|
||||||
|
return $xml . '</sheets></workbook>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildWorkbookRels(): string {
|
||||||
|
$xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
|
||||||
|
foreach ($this->sheets as $i => $s) {
|
||||||
|
$id = $i + 1;
|
||||||
|
$xml .= "<Relationship Id=\"rId$id\""
|
||||||
|
. ' Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"'
|
||||||
|
. " Target=\"worksheets/sheet$id.xml\"/>";
|
||||||
|
}
|
||||||
|
$n = count($this->sheets);
|
||||||
|
$xml .= "<Relationship Id=\"rId".($n+1)."\""
|
||||||
|
. ' Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings"'
|
||||||
|
. ' Target="sharedStrings.xml"/>'
|
||||||
|
. "<Relationship Id=\"rId".($n+2)."\""
|
||||||
|
. ' Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"'
|
||||||
|
. ' Target="styles.xml"/>';
|
||||||
|
return $xml . '</Relationships>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||||
|
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||||
|
. '<Default Extension="xml" ContentType="application/xml"/>'
|
||||||
|
. '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||||||
|
. '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
|
||||||
|
. '<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>';
|
||||||
|
foreach ($this->sheets as $i => $s) {
|
||||||
|
$id = $i + 1;
|
||||||
|
$ct .= "<Override PartName=\"/xl/worksheets/sheet$id.xml\""
|
||||||
|
. ' ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
|
||||||
|
}
|
||||||
|
$ct .= '</Types>';
|
||||||
|
$zip->addFromString('[Content_Types].xml', $ct);
|
||||||
|
|
||||||
|
$zip->addFromString('_rels/.rels',
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||||
|
. '<Relationship Id="rId1"'
|
||||||
|
. ' Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"'
|
||||||
|
. ' Target="xl/workbook.xml"/></Relationships>');
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
710
poverty-map/api.php
Normal file
710
poverty-map/api.php
Normal file
@@ -0,0 +1,710 @@
|
|||||||
|
<?php
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
require 'koneksi.php';
|
||||||
|
require_once 'SimpleXLSX.php';
|
||||||
|
|
||||||
|
// ── AUTENTIKASI ───────────────────────────────────────────────────────────────
|
||||||
|
if (!isset($_SESSION['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['status' => '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");
|
||||||
|
}
|
||||||
|
?>
|
||||||
211
poverty-map/import.php
Normal file
211
poverty-map/import.php
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* import.php — Bulk Import CSV via SSE (Server-Sent Events)
|
||||||
|
*
|
||||||
|
* Mendukung dua tipe import:
|
||||||
|
* ?type=penduduk → INSERT ke penduduk_miskin
|
||||||
|
* ?type=ri → INSERT ke rumah_ibadah
|
||||||
|
*
|
||||||
|
* Format CSV Penduduk (header baris 1 diabaikan):
|
||||||
|
* Nama Kepala Keluarga | Jumlah Anggota | Alamat | RT | RW | Kelurahan | Kecamatan
|
||||||
|
*
|
||||||
|
* Format CSV Rumah Ibadah (header baris 1 diabaikan):
|
||||||
|
* Nama | Jenis | Alamat | Radius(opsional)
|
||||||
|
* Jenis: Masjid / Gereja Protestan / Gereja Katolik / Vihara / Pura / Kelenteng
|
||||||
|
* Jika Jenis kosong → default Masjid. Jika Radius kosong → default 500.
|
||||||
|
*
|
||||||
|
* Catatan: Koordinat (lat/lng) TIDAK diisi saat import — admin melengkapi
|
||||||
|
* koordinat secara manual lewat klik peta setelah import selesai.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
require 'koneksi.php';
|
||||||
|
|
||||||
|
if (!isset($_SESSION['user_id']) || $_SESSION['role'] !== 'admin') {
|
||||||
|
http_response_code(403);
|
||||||
|
echo "data: " . json_encode(['type'=>'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]);
|
||||||
2112
poverty-map/index.php
Normal file
2112
poverty-map/index.php
Normal file
File diff suppressed because it is too large
Load Diff
41
poverty-map/koneksi.php
Normal file
41
poverty-map/koneksi.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
$host = "localhost";
|
||||||
|
$user = "root";
|
||||||
|
$pass = "";
|
||||||
|
$db = "webgis_bansos";
|
||||||
|
|
||||||
|
// 1. Koneksi awal ke MySQL host tanpa memilih database
|
||||||
|
$conn = new mysqli($host, $user, $pass);
|
||||||
|
if ($conn->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");
|
||||||
|
?>
|
||||||
119
poverty-map/login.php
Normal file
119
poverty-map/login.php
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
require 'koneksi.php';
|
||||||
|
|
||||||
|
// Handle logout action
|
||||||
|
if (isset($_GET['action']) && $_GET['action'] === 'logout') {
|
||||||
|
session_destroy();
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jika sudah login, langsung ke dashboard
|
||||||
|
if (isset($_SESSION['user_id'])) {
|
||||||
|
header('Location: index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$username = trim($_POST['username'] ?? '');
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
||||||
|
if ($username === '' || $password === '') {
|
||||||
|
$error = 'Username dan password wajib diisi.';
|
||||||
|
} else {
|
||||||
|
$stmt = $conn->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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login — WebGIS Poverty Map</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
body { background: linear-gradient(135deg, #030e2c 0%, #05143b 50%, #163372 100%); }
|
||||||
|
.card-shadow { box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-sm">
|
||||||
|
|
||||||
|
<!-- Logo / Judul -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-white tracking-wide">WebGIS Poverty Map</h1>
|
||||||
|
<p class="text-blue-200 text-sm mt-1">Informatika UNTAN — GIS Project</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Login -->
|
||||||
|
<div class="bg-white rounded-2xl p-7 card-shadow">
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="mb-4 bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-lg flex items-start gap-2">
|
||||||
|
<span class="mt-0.5 flex-shrink-0">⚠</span>
|
||||||
|
<span><?= htmlspecialchars($error) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="POST" autocomplete="off">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-semibold text-gray-600 mb-1.5">Username</label>
|
||||||
|
<input type="text" name="username"
|
||||||
|
value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
|
||||||
|
placeholder="Masukkan username"
|
||||||
|
class="w-full px-3 py-2.5 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||||
|
autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-xs font-semibold text-gray-600 mb-1.5">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="password" name="password" id="pwInput"
|
||||||
|
placeholder="Masukkan password"
|
||||||
|
class="w-full px-3 py-2.5 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition pr-10"
|
||||||
|
required>
|
||||||
|
<button type="button" onclick="togglePw()"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 text-sm"
|
||||||
|
tabindex="-1" id="pwToggle">👁</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-blue-700 hover:bg-blue-800 text-white font-bold py-2.5 rounded-lg transition text-sm tracking-wide">
|
||||||
|
Masuk
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function togglePw() {
|
||||||
|
const i = document.getElementById('pwInput');
|
||||||
|
const b = document.getElementById('pwToggle');
|
||||||
|
if (i.type === 'password') { i.type = 'text'; b.innerHTML = '🚫'; }
|
||||||
|
else { i.type = 'password'; b.innerHTML = '👁'; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
111
poverty-map/setup.sql
Normal file
111
poverty-map/setup.sql
Normal file
@@ -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).
|
||||||
|
-- ============================================================
|
||||||
0
poverty-map/uploads/foto_bukti/.gitkeep
Normal file
0
poverty-map/uploads/foto_bukti/.gitkeep
Normal file
0
poverty-map/uploads/foto_rumah/.gitkeep
Normal file
0
poverty-map/uploads/foto_rumah/.gitkeep
Normal file
571
spbu_layer/index.php
Normal file
571
spbu_layer/index.php
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
<?php
|
||||||
|
$conn = new mysqli("localhost", "root", "");
|
||||||
|
if ($conn->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WebGIS SPBU Pontianak — Layer Control</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
}
|
||||||
|
#map {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(156, 163, 175, 0.4);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(156, 163, 175, 0.6);
|
||||||
|
}
|
||||||
|
#map.crosshair-cursor, #map.crosshair-cursor .leaflet-interactive {
|
||||||
|
cursor: crosshair !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="overflow-hidden bg-slate-50">
|
||||||
|
|
||||||
|
<div class="flex h-screen w-screen relative">
|
||||||
|
|
||||||
|
<!-- SIDEBAR PANEL -->
|
||||||
|
<div id="sidebar" class="w-80 md:w-96 flex flex-col h-full bg-white text-slate-800 shadow-xl z-[1000] border-r border-slate-200">
|
||||||
|
<!-- Sidebar Header -->
|
||||||
|
<div class="p-5 border-b border-slate-200 bg-slate-50">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-base font-bold text-slate-900 tracking-wide">WebGIS Peta SPBU</h1>
|
||||||
|
<p class="text-[10px] text-slate-500">Informatika UNTAN · GIS Project</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List Mode & Search View -->
|
||||||
|
<div id="view-list" class="flex-1 flex flex-col min-h-0 bg-white">
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
|
||||||
|
<!-- Search + Filter Row -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<input type="text" id="search-input" oninput="filterList()" placeholder="Cari nama/nomor..."
|
||||||
|
class="w-full pl-8 pr-3 py-2 text-xs bg-slate-50 border border-slate-300 rounded-xl text-slate-700 placeholder-slate-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition">
|
||||||
|
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 text-xs">🔍</span>
|
||||||
|
</div>
|
||||||
|
<!-- Status Filter Dropdown -->
|
||||||
|
<select id="status-filter" onchange="filterList()"
|
||||||
|
class="px-2 py-2 text-xs bg-slate-50 border border-slate-300 rounded-xl text-slate-700 focus:outline-none focus:border-blue-500 transition cursor-pointer font-medium">
|
||||||
|
<option value="semua">Semua Status</option>
|
||||||
|
<option value="24jam">24 Jam</option>
|
||||||
|
<option value="tidak">Biasa</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add mode Trigger -->
|
||||||
|
<button id="add-mode-btn" onclick="toggleAddMode()"
|
||||||
|
class="w-full flex items-center justify-center gap-2 text-xs font-semibold py-2.5 rounded-xl border border-dashed border-blue-500 text-blue-600 bg-blue-50/50 hover:bg-blue-50 transition">
|
||||||
|
<span id="add-btn-icon">✚</span> <span id="add-btn-text">Tambah SPBU (Klik Peta)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable SPBU list -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-4 pb-4 custom-scrollbar space-y-2.5" id="spbu-list-container">
|
||||||
|
<!-- Cards populated via JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add / Edit Form Panel (Hidden by default) -->
|
||||||
|
<div id="view-form" class="hidden flex-1 flex flex-col p-5 space-y-4 bg-white border-t border-slate-200">
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-200 pb-3">
|
||||||
|
<h3 id="form-title" class="font-bold text-slate-950 text-sm">Tambah SPBU Baru</h3>
|
||||||
|
<button onclick="hideForm()" class="text-slate-400 hover:text-red-500 text-sm">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" id="form-id">
|
||||||
|
<input type="hidden" id="form-lat">
|
||||||
|
<input type="hidden" id="form-lng">
|
||||||
|
|
||||||
|
<div class="space-y-3.5">
|
||||||
|
<div>
|
||||||
|
<label class="text-[10px] font-semibold text-slate-500 block mb-1">Nama SPBU</label>
|
||||||
|
<input type="text" id="form-nama" placeholder="Contoh: SPBU Ahmad Yani"
|
||||||
|
class="w-full px-3 py-2 text-xs bg-slate-50 border border-slate-300 rounded-lg text-slate-800 focus:outline-none focus:border-blue-500 focus:bg-white transition">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-[10px] font-semibold text-slate-500 block mb-1">Nomor SPBU</label>
|
||||||
|
<input type="text" id="form-nomor" placeholder="Contoh: 61.781.01"
|
||||||
|
class="w-full px-3 py-2 text-xs bg-slate-50 border border-slate-300 rounded-lg text-slate-800 focus:outline-none focus:border-blue-500 focus:bg-white transition">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-[10px] font-semibold text-slate-500 block mb-1">Operasional</label>
|
||||||
|
<select id="form-status"
|
||||||
|
class="w-full px-3 py-2 text-xs bg-slate-50 border border-slate-300 rounded-lg text-slate-800 focus:outline-none focus:border-blue-500 focus:bg-white transition">
|
||||||
|
<option value="24jam">Buka 24 Jam</option>
|
||||||
|
<option value="tidak">Tidak 24 Jam</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-[10px] text-slate-500 bg-slate-50 p-2.5 rounded-lg border border-slate-200">
|
||||||
|
<div>Lat: <span id="label-lat" class="text-slate-700 font-mono">-</span></div>
|
||||||
|
<div>Lng: <span id="label-lng" class="text-slate-700 font-mono">-</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 pt-2">
|
||||||
|
<button id="form-submit-btn" onclick="submitForm()"
|
||||||
|
class="flex-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-bold py-2.5 rounded-lg transition">✓ Simpan</button>
|
||||||
|
<button onclick="hideForm()"
|
||||||
|
class="flex-1 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-bold py-2.5 rounded-lg transition">Batal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MAP VIEW -->
|
||||||
|
<div class="flex-grow h-full relative">
|
||||||
|
<div id="map"></div>
|
||||||
|
<!-- Alert Toast for Map Clicking Add Mode -->
|
||||||
|
<div id="add-mode-toast" class="hidden absolute bottom-6 left-1/2 -translate-x-1/2 bg-blue-600 border border-blue-400 text-white font-semibold text-xs px-4 py-2.5 rounded-xl shadow-2xl z-[9000] flex items-center gap-2 animate-bounce">
|
||||||
|
<span>🗺</span>
|
||||||
|
<span>Klik lokasi di peta untuk menempatkan marker SPBU baru</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Leaflet JS and APP SCRIPT -->
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script>
|
||||||
|
// Database raw data
|
||||||
|
var spbuData = <?php echo json_encode($data); ?>;
|
||||||
|
var markers = {};
|
||||||
|
var addMode = false;
|
||||||
|
var tempMarker = null;
|
||||||
|
|
||||||
|
// Custom Leaflet Icons
|
||||||
|
var icon24h = L.icon({
|
||||||
|
iconUrl: 'https://maps.google.com/mapfiles/ms/icons/green-dot.png',
|
||||||
|
iconSize: [32, 32],
|
||||||
|
iconAnchor: [16, 32],
|
||||||
|
popupAnchor: [0, -32]
|
||||||
|
});
|
||||||
|
|
||||||
|
var iconNot24h = L.icon({
|
||||||
|
iconUrl: 'https://maps.google.com/mapfiles/ms/icons/red-dot.png',
|
||||||
|
iconSize: [32, 32],
|
||||||
|
iconAnchor: [16, 32],
|
||||||
|
popupAnchor: [0, -32]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Layer Groups
|
||||||
|
var layer24h = L.layerGroup();
|
||||||
|
var layerNot24h = L.layerGroup();
|
||||||
|
var osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png');
|
||||||
|
|
||||||
|
// Setup Map
|
||||||
|
var map = L.map('map', {
|
||||||
|
center: [-0.02, 109.34],
|
||||||
|
zoom: 13,
|
||||||
|
layers: [osm, layer24h, layerNot24h]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Layers Control
|
||||||
|
var baseMaps = { "OpenStreetMap": osm };
|
||||||
|
var overlayMaps = {
|
||||||
|
"Buka 24 Jam": layer24h,
|
||||||
|
"Tidak 24 Jam": layerNot24h
|
||||||
|
};
|
||||||
|
L.control.layers(baseMaps, overlayMaps, { position: 'topright' }).addTo(map);
|
||||||
|
|
||||||
|
// Render SPBUs on Map & Sidebar
|
||||||
|
function renderAll() {
|
||||||
|
// Clear maps layer groups
|
||||||
|
layer24h.clearLayers();
|
||||||
|
layerNot24h.clearLayers();
|
||||||
|
markers = {};
|
||||||
|
|
||||||
|
// Render to map
|
||||||
|
spbuData.forEach(function(spbu) {
|
||||||
|
var is24 = (spbu.status === '24jam');
|
||||||
|
var icon = is24 ? icon24h : iconNot24h;
|
||||||
|
|
||||||
|
var marker = L.marker([spbu.latitude, spbu.longitude], {
|
||||||
|
icon: icon,
|
||||||
|
draggable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Popup binding
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div class="text-xs p-1 select-none">
|
||||||
|
<div class="font-bold text-slate-800 text-sm mb-1">${spbu.nama}</div>
|
||||||
|
<div class="text-slate-500 mb-0.5">No. SPBU: ${spbu.nomor}</div>
|
||||||
|
<div class="mb-2">Status: <span class="font-bold ${is24 ? 'text-green-600' : 'text-red-500'}">${is24 ? 'Buka 24 Jam' : 'Tidak 24 Jam'}</span></div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button onclick="editSPBU(${spbu.id})" class="px-2 py-1 bg-blue-600 text-white font-bold rounded hover:bg-blue-700 transition">Edit</button>
|
||||||
|
<button onclick="deleteSPBU(${spbu.id})" class="px-2 py-1 bg-red-600 text-white font-bold rounded hover:bg-red-700 transition">Hapus</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Drag and drop handler
|
||||||
|
marker.on('dragend', function() {
|
||||||
|
var pos = marker.getLatLng();
|
||||||
|
if (confirm(`Pindahkan lokasi ${spbu.nama} ke koordinat baru?`)) {
|
||||||
|
updateMarkerPosition(spbu.id, pos.lat, pos.lng);
|
||||||
|
} else {
|
||||||
|
renderAll(); // revert
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
markers[spbu.id] = marker;
|
||||||
|
|
||||||
|
if (is24) {
|
||||||
|
marker.addTo(layer24h);
|
||||||
|
} else {
|
||||||
|
marker.addTo(layerNot24h);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render lists in sidebar
|
||||||
|
renderList(spbuData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render List inside Sidebar
|
||||||
|
function renderList(list) {
|
||||||
|
var container = document.getElementById("spbu-list-container");
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
container.innerHTML = `<div class="text-center text-xs text-slate-500 py-6 italic">Tidak ada data ditemukan</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.forEach(function(spbu) {
|
||||||
|
var is24 = (spbu.status === '24jam');
|
||||||
|
var div = document.createElement("div");
|
||||||
|
div.className = "p-4 bg-white border border-slate-200 hover:border-blue-400 hover:shadow-md rounded-2xl cursor-pointer transition-all duration-300 transform hover:-translate-y-1 flex items-start gap-3.5 relative overflow-hidden group shadow-sm";
|
||||||
|
div.onclick = function() {
|
||||||
|
map.setView([spbu.latitude, spbu.longitude], 15);
|
||||||
|
markers[spbu.id].openPopup();
|
||||||
|
};
|
||||||
|
|
||||||
|
var statusBg = is24 ? 'bg-emerald-50 text-emerald-600 border-emerald-100' : 'bg-rose-50 text-rose-600 border-rose-100';
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<!-- Left Circle Status Icon -->
|
||||||
|
<div class="w-9 h-9 rounded-full flex items-center justify-center border shrink-0 ${statusBg} transition-colors group-hover:bg-blue-50 group-hover:text-blue-600 group-hover:border-blue-100">
|
||||||
|
<span class="text-sm">⛽</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Middle Content -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="font-bold text-slate-900 text-xs truncate pr-1 group-hover:text-blue-600 transition-colors">${spbu.nama}</h4>
|
||||||
|
<p class="text-[10px] text-slate-400 font-mono mt-0.5">No: ${spbu.nomor}</p>
|
||||||
|
|
||||||
|
<!-- Badges and Actions Row -->
|
||||||
|
<div class="flex items-center justify-between border-t border-slate-100 pt-2.5 mt-2.5">
|
||||||
|
<!-- Badge -->
|
||||||
|
<span class="text-[8px] px-2 py-0.5 rounded-full ${is24 ? 'bg-emerald-100 text-emerald-700 border-green-200' : 'bg-rose-100 text-rose-700 border-rose-200'} font-semibold border">
|
||||||
|
${is24 ? '24 Jam' : 'Biasa'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Actions pill buttons -->
|
||||||
|
<div class="flex items-center gap-1.5 opacity-80 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button onclick="event.stopPropagation(); editSPBU(${spbu.id})"
|
||||||
|
class="text-[9px] font-bold px-2 py-1 bg-slate-100 hover:bg-blue-50 hover:text-blue-600 text-slate-600 rounded-md transition-all border border-transparent hover:border-blue-200">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button onclick="event.stopPropagation(); deleteSPBU(${spbu.id})"
|
||||||
|
class="text-[9px] font-bold px-2 py-1 bg-slate-100 hover:bg-rose-50 hover:text-rose-600 text-slate-600 rounded-md transition-all border border-transparent hover:border-rose-200">
|
||||||
|
Hapus
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter Sidebar List
|
||||||
|
function filterList() {
|
||||||
|
var query = document.getElementById("search-input").value.toLowerCase();
|
||||||
|
var statusVal = document.getElementById("status-filter").value;
|
||||||
|
|
||||||
|
var filtered = spbuData.filter(function(spbu) {
|
||||||
|
var matchesQuery = spbu.nama.toLowerCase().includes(query) || spbu.nomor.toLowerCase().includes(query);
|
||||||
|
var matchesStatus = (statusVal === 'semua' || spbu.status === statusVal);
|
||||||
|
return matchesQuery && matchesStatus;
|
||||||
|
});
|
||||||
|
renderList(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle Add Mode
|
||||||
|
function toggleAddMode() {
|
||||||
|
addMode = !addMode;
|
||||||
|
var mapEl = document.getElementById("map");
|
||||||
|
var toast = document.getElementById("add-mode-toast");
|
||||||
|
var btnText = document.getElementById("add-btn-text");
|
||||||
|
var btnIcon = document.getElementById("add-btn-icon");
|
||||||
|
|
||||||
|
if (addMode) {
|
||||||
|
mapEl.classList.add("crosshair-cursor");
|
||||||
|
toast.classList.remove("hidden");
|
||||||
|
btnText.innerText = "Batal Menambah";
|
||||||
|
btnIcon.innerHTML = "✕";
|
||||||
|
hideForm();
|
||||||
|
} else {
|
||||||
|
mapEl.classList.remove("crosshair-cursor");
|
||||||
|
toast.classList.add("hidden");
|
||||||
|
btnText.innerText = "Tambah SPBU (Klik Peta)";
|
||||||
|
btnIcon.innerHTML = "✚";
|
||||||
|
if (tempMarker) {
|
||||||
|
map.removeLayer(tempMarker);
|
||||||
|
tempMarker = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map Click Event
|
||||||
|
map.on('click', function(e) {
|
||||||
|
if (!addMode) return;
|
||||||
|
|
||||||
|
var lat = e.latlng.lat;
|
||||||
|
var lng = e.latlng.lng;
|
||||||
|
|
||||||
|
if (tempMarker) {
|
||||||
|
map.removeLayer(tempMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
tempMarker = L.marker([lat, lng]).addTo(map);
|
||||||
|
|
||||||
|
// Populate Form Fields
|
||||||
|
document.getElementById("form-id").value = "";
|
||||||
|
document.getElementById("form-nama").value = "";
|
||||||
|
document.getElementById("form-nomor").value = "";
|
||||||
|
document.getElementById("form-status").value = "24jam";
|
||||||
|
document.getElementById("form-lat").value = lat;
|
||||||
|
document.getElementById("form-lng").value = lng;
|
||||||
|
|
||||||
|
document.getElementById("label-lat").innerText = lat.toFixed(6);
|
||||||
|
document.getElementById("label-lng").innerText = lng.toFixed(6);
|
||||||
|
|
||||||
|
document.getElementById("form-title").innerText = "Tambah SPBU Baru";
|
||||||
|
document.getElementById("form-submit-btn").innerText = "Simpan Data";
|
||||||
|
|
||||||
|
showForm();
|
||||||
|
toggleAddMode(); // Turn off add mode once spot clicked
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show/Hide Sidebar Form
|
||||||
|
function showForm() {
|
||||||
|
document.getElementById("view-list").classList.add("hidden");
|
||||||
|
document.getElementById("view-form").classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideForm() {
|
||||||
|
document.getElementById("view-list").classList.remove("hidden");
|
||||||
|
document.getElementById("view-form").classList.add("hidden");
|
||||||
|
if (tempMarker) {
|
||||||
|
map.removeLayer(tempMarker);
|
||||||
|
tempMarker = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger Edit Form
|
||||||
|
function editSPBU(id) {
|
||||||
|
var spbu = spbuData.find(d => d.id == id);
|
||||||
|
if (!spbu) return;
|
||||||
|
|
||||||
|
document.getElementById("form-id").value = spbu.id;
|
||||||
|
document.getElementById("form-nama").value = spbu.nama;
|
||||||
|
document.getElementById("form-nomor").value = spbu.nomor;
|
||||||
|
document.getElementById("form-status").value = spbu.status;
|
||||||
|
document.getElementById("form-lat").value = spbu.latitude;
|
||||||
|
document.getElementById("form-lng").value = spbu.longitude;
|
||||||
|
|
||||||
|
document.getElementById("label-lat").innerText = parseFloat(spbu.latitude).toFixed(6);
|
||||||
|
document.getElementById("label-lng").innerText = parseFloat(spbu.longitude).toFixed(6);
|
||||||
|
|
||||||
|
document.getElementById("form-title").innerText = "Edit Detail SPBU";
|
||||||
|
document.getElementById("form-submit-btn").innerText = "Update Data";
|
||||||
|
|
||||||
|
map.closePopup();
|
||||||
|
showForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// AJAX: Submit Form (Insert or Update)
|
||||||
|
function submitForm() {
|
||||||
|
var id = document.getElementById("form-id").value;
|
||||||
|
var nama = document.getElementById("form-nama").value;
|
||||||
|
var nomor = document.getElementById("form-nomor").value;
|
||||||
|
var status = document.getElementById("form-status").value;
|
||||||
|
var lat = document.getElementById("form-lat").value;
|
||||||
|
var lng = document.getElementById("form-lng").value;
|
||||||
|
|
||||||
|
if (!nama || !nomor) {
|
||||||
|
alert("Harap lengkapi semua bidang!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var action = id ? 'update' : 'insert';
|
||||||
|
var bodyParams = `action=${action}&nama=${encodeURIComponent(nama)}&nomor=${encodeURIComponent(nomor)}&status=${status}&lat=${lat}&lng=${lng}`;
|
||||||
|
if (id) bodyParams += `&id=${id}`;
|
||||||
|
|
||||||
|
fetch("", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: bodyParams
|
||||||
|
})
|
||||||
|
.then(res => res.text())
|
||||||
|
.then(data => {
|
||||||
|
if (data.includes("success")) {
|
||||||
|
alert(id ? "Data SPBU berhasil diperbarui!" : "SPBU baru berhasil disimpan!");
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert("Gagal memproses data.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// AJAX: Delete SPBU
|
||||||
|
function deleteSPBU(id) {
|
||||||
|
var spbu = spbuData.find(d => d.id == id);
|
||||||
|
if (!spbu) return;
|
||||||
|
|
||||||
|
if (confirm(`Apakah Anda yakin ingin menghapus ${spbu.nama}?`)) {
|
||||||
|
fetch("", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: `action=delete&id=${id}`
|
||||||
|
})
|
||||||
|
.then(res => res.text())
|
||||||
|
.then(data => {
|
||||||
|
if (data.includes("success")) {
|
||||||
|
alert("Data SPBU berhasil dihapus!");
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert("Gagal menghapus data.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AJAX: Drag-and-drop Move SPBU
|
||||||
|
function updateMarkerPosition(id, lat, lng) {
|
||||||
|
fetch("", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: `action=move&id=${id}&lat=${lat}&lng=${lng}`
|
||||||
|
})
|
||||||
|
.then(res => res.text())
|
||||||
|
.then(data => {
|
||||||
|
if (data.includes("success")) {
|
||||||
|
alert("Lokasi marker berhasil dipindahkan!");
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert("Gagal menyimpan perubahan lokasi.");
|
||||||
|
renderAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial render
|
||||||
|
renderAll();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user