First Commit
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
/**
|
||||
* Landing page — daftar proyek SIG.
|
||||
* Setiap kartu mengarah ke folder proyek masing-masing.
|
||||
*/
|
||||
$projects = [
|
||||
[
|
||||
'slug' => 'sig-01',
|
||||
'title' => 'WebGIS SPBU',
|
||||
'desc' => 'Peta sebaran Stasiun Pengisian Bahan Bakar Umum (SPBU).',
|
||||
'tags' => ['Leaflet', 'PHP', 'MySQL'],
|
||||
'icon' => '⛽',
|
||||
'from' => 'from-rose-500',
|
||||
'to' => 'to-orange-500',
|
||||
],
|
||||
[
|
||||
'slug' => 'sig-02',
|
||||
'title' => 'SIG Mapping Tanah & Jalan',
|
||||
'desc' => 'Pemetaan point & click: bidang tanah (polygon) dan jalan (garis), luas/panjang otomatis.',
|
||||
'tags' => ['Leaflet.draw', 'PHP', 'MySQL'],
|
||||
'icon' => '🗺️',
|
||||
'from' => 'from-emerald-500',
|
||||
'to' => 'to-teal-500',
|
||||
],
|
||||
[
|
||||
'slug' => 'sig-03',
|
||||
'title' => 'SIG Proyek 03',
|
||||
'desc' => 'Proyek ketiga (segera hadir).',
|
||||
'tags' => ['Coming soon'],
|
||||
'icon' => '🛰️',
|
||||
'from' => 'from-indigo-500',
|
||||
'to' => 'to-violet-500',
|
||||
],
|
||||
];
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Daftar Proyek SIG</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<div class="max-w-6xl mx-auto px-6 py-16">
|
||||
<header class="mb-12 text-center">
|
||||
<h1 class="text-4xl md:text-5xl font-bold tracking-tight">
|
||||
Daftar Proyek <span class="bg-gradient-to-r from-emerald-400 to-cyan-400 bg-clip-text text-transparent">SIG</span>
|
||||
</h1>
|
||||
<p class="mt-3 text-slate-400">Pilih salah satu proyek untuk membukanya.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<?php foreach ($projects as $p): ?>
|
||||
<a href="<?= htmlspecialchars($p['slug']) ?>/"
|
||||
class="group block rounded-2xl border border-slate-800 bg-slate-900/60 p-6
|
||||
transition duration-200 hover:-translate-y-1 hover:border-slate-600
|
||||
hover:shadow-xl hover:shadow-black/40 focus:outline-none focus:ring-2 focus:ring-emerald-400">
|
||||
<div class="mb-4 inline-flex h-14 w-14 items-center justify-center rounded-xl
|
||||
bg-gradient-to-br <?= $p['from'] ?> <?= $p['to'] ?> text-2xl shadow-lg">
|
||||
<?= $p['icon'] ?>
|
||||
</div>
|
||||
<div class="mb-1 text-xs font-mono uppercase tracking-widest text-slate-500">
|
||||
<?= htmlspecialchars($p['slug']) ?>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold group-hover:text-emerald-300">
|
||||
<?= htmlspecialchars($p['title']) ?>
|
||||
</h2>
|
||||
<p class="mt-2 text-sm leading-relaxed text-slate-400">
|
||||
<?= htmlspecialchars($p['desc']) ?>
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<?php foreach ($p['tags'] as $tag): ?>
|
||||
<span class="rounded-full bg-slate-800 px-2.5 py-0.5 text-xs text-slate-300">
|
||||
<?= htmlspecialchars($tag) ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="mt-5 inline-flex items-center gap-1 text-sm font-medium text-emerald-400">
|
||||
Buka proyek
|
||||
<span class="transition-transform group-hover:translate-x-1">→</span>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<footer class="mt-16 text-center text-xs text-slate-600">
|
||||
Laragon · <?= date('Y') ?>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,904 @@
|
||||
<?php
|
||||
session_start();
|
||||
|
||||
// ── Konfigurasi password admin ─────────────────────────────────────────────
|
||||
define('ADMIN_PASSWORD', 'admin123');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (isset($_POST['logout'])) {
|
||||
session_destroy();
|
||||
header('Location: admin.php');
|
||||
exit;
|
||||
}
|
||||
if (isset($_POST['password']) && $_POST['password'] === ADMIN_PASSWORD) {
|
||||
$_SESSION['admin'] = true;
|
||||
header('Location: admin.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$isAdmin = !empty($_SESSION['admin']);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin — WebGIS SPBU</title>
|
||||
|
||||
<!-- TailwindCSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: { brand: { DEFAULT: '#1d4ed8', dark: '#1e3a8a' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Leaflet.js -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
#map { height: 100%; width: 100%; }
|
||||
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.15);
|
||||
padding: 0; overflow: hidden;
|
||||
}
|
||||
.leaflet-popup-content { margin: 0; width: 260px !important; }
|
||||
.leaflet-popup-tip-container { display: none; }
|
||||
|
||||
.spbu-marker {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
transform: rotate(-45deg);
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,.35);
|
||||
cursor: grab;
|
||||
transition: transform .15s, box-shadow .15s;
|
||||
}
|
||||
.spbu-marker:hover { box-shadow: 0 6px 18px rgba(0,0,0,.45); }
|
||||
.spbu-marker span {
|
||||
transform: rotate(45deg);
|
||||
font-size: 11px; font-weight: 800;
|
||||
color: #fff; line-height: 1;
|
||||
}
|
||||
.marker-24 { background: #16a34a; }
|
||||
.marker-biasa { background: #ea580c; }
|
||||
.marker-temp { background: #6d28d9; opacity: .85; }
|
||||
|
||||
/* Cursor saat klik peta untuk tambah */
|
||||
.map-adding { cursor: crosshair !important; }
|
||||
|
||||
/* Sidebar */
|
||||
#spbu-list { overflow-y: auto; max-height: calc(100vh - 280px); }
|
||||
|
||||
/* Modal overlay */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 9999;
|
||||
opacity: 0; pointer-events: none; transition: opacity .2s;
|
||||
}
|
||||
.modal-overlay.show { opacity: 1; pointer-events: auto; }
|
||||
.modal-box {
|
||||
background: #fff; border-radius: 16px;
|
||||
width: 420px; max-width: 95vw;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.25);
|
||||
transform: translateY(12px); transition: transform .2s;
|
||||
}
|
||||
.modal-overlay.show .modal-box { transform: translateY(0); }
|
||||
|
||||
/* Toast */
|
||||
#toast {
|
||||
position: fixed; bottom: 24px; right: 24px;
|
||||
background: #1e293b; color: #fff;
|
||||
padding: 12px 20px; border-radius: 10px;
|
||||
font-size: 13px; z-index: 99999;
|
||||
display: none; align-items: center; gap-8px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.25);
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
width: 24px; height: 24px;
|
||||
border: 3px solid #e2e8f0; border-top-color: #1d4ed8;
|
||||
border-radius: 50%;
|
||||
animation: spin .7s linear infinite;
|
||||
margin: 20px auto;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Drag hint badge */
|
||||
#drag-hint {
|
||||
position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
|
||||
background: rgba(109,40,217,.9); color: #fff;
|
||||
border-radius: 99px; padding: 6px 16px; font-size: 13px;
|
||||
z-index: 1000; pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 font-sans">
|
||||
|
||||
<?php if (!$isAdmin): ?>
|
||||
<!-- ══════════════════════════════════════════════════════════════════════
|
||||
LOGIN PAGE
|
||||
════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br
|
||||
from-blue-900 via-blue-800 to-blue-600 p-4">
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-blue-700 to-blue-900 px-8 py-6 text-white text-center">
|
||||
<svg class="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2
|
||||
2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
<h1 class="text-xl font-bold">Panel Admin</h1>
|
||||
<p class="text-blue-200 text-sm mt-0.5">WebGIS SPBU</p>
|
||||
</div>
|
||||
<form method="POST" class="px-8 py-6 space-y-4">
|
||||
<?php if (isset($_POST['password']) && $_POST['password'] !== ADMIN_PASSWORD): ?>
|
||||
<p class="bg-red-50 text-red-600 text-sm rounded-lg px-3 py-2 text-center">
|
||||
Password salah, coba lagi.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input type="password" name="password" autofocus required
|
||||
class="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
placeholder="Masukkan password admin">
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full bg-blue-700 hover:bg-blue-800 text-white font-semibold
|
||||
py-2.5 rounded-xl transition-colors text-sm">
|
||||
Masuk
|
||||
</button>
|
||||
<a href="index.php"
|
||||
class="block text-center text-sm text-gray-400 hover:text-blue-600 mt-1">
|
||||
← Kembali ke Peta Publik
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
<!-- ══════════════════════════════════════════════════════════════════════
|
||||
ADMIN DASHBOARD
|
||||
════════════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div id="toast" class="flex items-center gap-2">
|
||||
<span id="toast-icon"></span>
|
||||
<span id="toast-msg"></span>
|
||||
</div>
|
||||
|
||||
<!-- ── MODAL TAMBAH / EDIT ─────────────────────────────────────────────── -->
|
||||
<div id="modal" class="modal-overlay" onclick="closeModal(event)">
|
||||
<div class="modal-box">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<h2 id="modal-title" class="text-base font-bold text-gray-800">Tambah SPBU</h2>
|
||||
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<form id="spbu-form" class="px-6 py-5 space-y-4">
|
||||
<input type="hidden" id="f-id">
|
||||
<input type="hidden" id="f-lat">
|
||||
<input type="hidden" id="f-lng">
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">
|
||||
Kode SPBU <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input id="f-kode" type="text" required placeholder="Contoh: 31.401.06"
|
||||
class="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-400">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">
|
||||
Nama SPBU <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input id="f-nama" type="text" required placeholder="Contoh: SPBU Pertamina Sudirman"
|
||||
class="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-400">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-600 mb-2 uppercase tracking-wide">
|
||||
Jam Operasional
|
||||
</label>
|
||||
<div class="flex gap-3">
|
||||
<label class="flex-1 flex items-center gap-2 border-2 border-gray-100 rounded-xl px-4 py-3
|
||||
cursor-pointer has-[:checked]:border-green-500 has-[:checked]:bg-green-50
|
||||
transition-all">
|
||||
<input type="radio" name="is24" value="1" id="r-24" class="accent-green-600">
|
||||
<span class="text-sm font-medium text-gray-700">Buka 24 Jam</span>
|
||||
</label>
|
||||
<label class="flex-1 flex items-center gap-2 border-2 border-gray-100 rounded-xl px-4 py-3
|
||||
cursor-pointer has-[:checked]:border-orange-500 has-[:checked]:bg-orange-50
|
||||
transition-all">
|
||||
<input type="radio" name="is24" value="0" id="r-biasa" class="accent-orange-500">
|
||||
<span class="text-sm font-medium text-gray-700">Non-24 Jam</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Koordinat (info only) -->
|
||||
<div class="bg-gray-50 rounded-xl px-4 py-3">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Koordinat</p>
|
||||
<p id="f-coord-display" class="text-sm text-gray-700 font-mono">—</p>
|
||||
<p id="coord-hint" class="text-xs text-blue-500 mt-1 hidden">
|
||||
Klik peta untuk mengubah lokasi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error msg -->
|
||||
<p id="form-error" class="text-sm text-red-500 hidden"></p>
|
||||
</form>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-6 py-4 border-t border-gray-100 flex justify-end gap-3">
|
||||
<button onclick="closeModal()"
|
||||
class="px-5 py-2 text-sm font-medium text-gray-600 bg-gray-100
|
||||
hover:bg-gray-200 rounded-xl transition-colors">
|
||||
Batal
|
||||
</button>
|
||||
<button id="btn-submit" onclick="submitForm()"
|
||||
class="px-5 py-2 text-sm font-semibold text-white bg-blue-700
|
||||
hover:bg-blue-800 rounded-xl transition-colors flex items-center gap-2">
|
||||
<span id="btn-submit-text">Simpan</span>
|
||||
<svg id="btn-spin" class="hidden w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CONFIRM DELETE MODAL ────────────────────────────────────────────── -->
|
||||
<div id="modal-del" class="modal-overlay" onclick="closeDeleteModal(event)">
|
||||
<div class="modal-box max-w-xs">
|
||||
<div class="px-6 py-5 text-center">
|
||||
<div class="w-14 h-14 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-3">
|
||||
<svg class="w-7 h-7 text-red-600" fill="none" stroke="currentColor"
|
||||
stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5
|
||||
7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-bold text-gray-800 mb-1">Hapus SPBU?</h3>
|
||||
<p class="text-sm text-gray-500" id="del-name"></p>
|
||||
</div>
|
||||
<div class="px-6 pb-5 flex gap-3">
|
||||
<button onclick="closeDeleteModal()"
|
||||
class="flex-1 py-2 text-sm font-medium text-gray-600 bg-gray-100
|
||||
hover:bg-gray-200 rounded-xl transition-colors">
|
||||
Batal
|
||||
</button>
|
||||
<button onclick="confirmDelete()"
|
||||
class="flex-1 py-2 text-sm font-semibold text-white bg-red-600
|
||||
hover:bg-red-700 rounded-xl transition-colors">
|
||||
Hapus
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── LAYOUT ──────────────────────────────────────────────────────────── -->
|
||||
<div class="flex h-screen">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside class="w-80 bg-white shadow-xl flex flex-col z-10 flex-shrink-0">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="bg-gradient-to-br from-blue-700 to-blue-900 text-white px-5 py-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3 10h2l1 2h13l1-2h2M5 12v6a1 1 0 001 1h12a1 1 0 001-1v-6
|
||||
M9 22v-4h6v4M7 4h10l1 4H6L7 4z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h1 class="text-base font-bold leading-tight">Admin Panel</h1>
|
||||
<p class="text-blue-200 text-xs">WebGIS SPBU</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST">
|
||||
<button name="logout" type="submit"
|
||||
class="bg-blue-800 hover:bg-blue-900 text-xs text-blue-100
|
||||
px-3 py-1.5 rounded-lg transition-colors">
|
||||
Keluar
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Add button -->
|
||||
<button id="btn-add-mode" onclick="toggleAddMode()"
|
||||
class="w-full mt-1 bg-white text-blue-700 font-semibold text-sm py-2
|
||||
rounded-xl hover:bg-blue-50 transition-colors flex items-center
|
||||
justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
<span id="btn-add-label">Tambah SPBU (Klik Peta)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="px-4 py-3 border-b border-gray-100">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Filter</p>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="setFilter('semua')" id="btn-semua"
|
||||
class="flex-1 py-1.5 text-xs rounded-lg font-medium transition-all filter-btn
|
||||
bg-blue-600 text-white">
|
||||
Semua
|
||||
</button>
|
||||
<button onclick="setFilter('24jam')" id="btn-24jam"
|
||||
class="flex-1 py-1.5 text-xs rounded-lg font-medium transition-all filter-btn
|
||||
bg-gray-100 text-gray-600 hover:bg-green-50 hover:text-green-700">
|
||||
24 Jam
|
||||
</button>
|
||||
<button onclick="setFilter('tidak')" id="btn-tidak"
|
||||
class="flex-1 py-1.5 text-xs rounded-lg font-medium transition-all filter-btn
|
||||
bg-gray-100 text-gray-600 hover:bg-orange-50 hover:text-orange-700">
|
||||
Non-24 Jam
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="px-4 py-2.5 border-b border-gray-100 flex gap-2">
|
||||
<div class="flex-1 text-center bg-blue-50 rounded-lg py-2">
|
||||
<p class="text-lg font-bold text-blue-700" id="stat-total">—</p>
|
||||
<p class="text-xs text-blue-400">Total</p>
|
||||
</div>
|
||||
<div class="flex-1 text-center bg-green-50 rounded-lg py-2">
|
||||
<p class="text-lg font-bold text-green-700" id="stat-24">—</p>
|
||||
<p class="text-xs text-green-400">24 Jam</p>
|
||||
</div>
|
||||
<div class="flex-1 text-center bg-orange-50 rounded-lg py-2">
|
||||
<p class="text-lg font-bold text-orange-700" id="stat-biasa">—</p>
|
||||
<p class="text-xs text-orange-400">Non-24 Jam</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SPBU List -->
|
||||
<div id="spbu-list" class="flex-1 px-3 py-2 space-y-1">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-4 py-3 border-t border-gray-100">
|
||||
<a href="index.php"
|
||||
class="flex items-center justify-center gap-1.5 text-xs text-gray-500
|
||||
hover:text-blue-600 font-medium">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
Lihat Tampilan Publik
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- MAP -->
|
||||
<main class="flex-1 relative">
|
||||
<div id="map"></div>
|
||||
<div id="drag-hint">Seret marker untuk pindah lokasi</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="absolute bottom-6 right-4 bg-white rounded-xl shadow-lg px-4 py-3 z-[1000]">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Keterangan</p>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-green-600"></span>
|
||||
<span class="text-xs text-gray-700">Buka 24 Jam</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-orange-600"></span>
|
||||
<span class="text-xs text-gray-700">Non-24 Jam</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-purple-700"></span>
|
||||
<span class="text-xs text-gray-700">Posisi baru</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ── SCRIPTS ─────────────────────────────────────────────────────────── -->
|
||||
<script>
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
let allSpbu = [];
|
||||
let markers = {}; // id → Leaflet marker
|
||||
let activeFilter = 'semua';
|
||||
let addMode = false; // klik peta = tambah SPBU
|
||||
let editingId = null; // ID yg sedang diedit
|
||||
let deleteId = null;
|
||||
let pendingLat = null;
|
||||
let pendingLng = null;
|
||||
let tempMarker = null; // marker sementara saat picking lokasi baru di edit
|
||||
|
||||
// ── Map Init ───────────────────────────────────────────────────────────────
|
||||
const map = L.map('map', { zoomControl: false }).setView([-0.0263, 109.3425], 13);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
L.control.zoom({ position: 'bottomleft' }).addTo(map);
|
||||
|
||||
// ── Toast ──────────────────────────────────────────────────────────────────
|
||||
function toast(msg, type = 'success') {
|
||||
const el = document.getElementById('toast');
|
||||
const ico = document.getElementById('toast-icon');
|
||||
const txt = document.getElementById('toast-msg');
|
||||
|
||||
ico.innerHTML = type === 'success'
|
||||
? '<svg class="w-4 h-4 text-green-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>'
|
||||
: '<svg class="w-4 h-4 text-red-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>';
|
||||
|
||||
txt.textContent = msg;
|
||||
el.style.display = 'flex';
|
||||
clearTimeout(el._t);
|
||||
el._t = setTimeout(() => { el.style.display = 'none'; }, 3000);
|
||||
}
|
||||
|
||||
// ── Icons ──────────────────────────────────────────────────────────────────
|
||||
function makeIcon(is24, isTemp = false) {
|
||||
const cls = isTemp ? 'marker-temp' : (is24 ? 'marker-24' : 'marker-biasa');
|
||||
const lbl = isTemp ? '?' : (is24 ? '24' : '');
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<div class="spbu-marker ${cls}"><span>${lbl}</span></div>`,
|
||||
iconSize: [36, 36],
|
||||
iconAnchor: [18, 36],
|
||||
popupAnchor:[0, -38]
|
||||
});
|
||||
}
|
||||
|
||||
// ── Popup ──────────────────────────────────────────────────────────────────
|
||||
function makePopup(s) {
|
||||
const badge = s.is_24jam
|
||||
? `<span class="inline-flex items-center gap-1 bg-green-100 text-green-700
|
||||
text-xs font-semibold px-2 py-0.5 rounded-full">
|
||||
Buka 24 Jam
|
||||
</span>`
|
||||
: `<span class="inline-flex items-center gap-1 bg-orange-100 text-orange-700
|
||||
text-xs font-semibold px-2 py-0.5 rounded-full">
|
||||
Non-24 Jam
|
||||
</span>`;
|
||||
return `
|
||||
<div>
|
||||
<div class="px-4 py-3 border-b border-gray-100">
|
||||
<p class="font-bold text-gray-800 text-sm">${s.nama}</p>
|
||||
<p class="text-xs text-gray-400">Kode: ${s.kode}</p>
|
||||
</div>
|
||||
<div class="px-4 py-3 space-y-2">
|
||||
${badge}
|
||||
<p class="text-xs text-gray-500 font-mono">
|
||||
${s.latitude.toFixed(6)}, ${s.longitude.toFixed(6)}
|
||||
</p>
|
||||
<p class="text-xs text-purple-600 italic">
|
||||
Seret marker untuk pindah lokasi
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-4 pb-3 flex gap-2">
|
||||
<button onclick="openEditModal(${s.id})"
|
||||
class="flex-1 py-1.5 text-xs font-semibold text-white bg-blue-600
|
||||
hover:bg-blue-700 rounded-lg transition-colors">
|
||||
Edit
|
||||
</button>
|
||||
<button onclick="openDeleteModal(${s.id})"
|
||||
class="flex-1 py-1.5 text-xs font-semibold text-white bg-red-500
|
||||
hover:bg-red-600 rounded-lg transition-colors">
|
||||
Hapus
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Add Marker to Map ──────────────────────────────────────────────────────
|
||||
function addMarker(s) {
|
||||
const m = L.marker([s.latitude, s.longitude], {
|
||||
icon: makeIcon(s.is_24jam),
|
||||
draggable: true
|
||||
});
|
||||
|
||||
m.on('dragstart', () => {
|
||||
document.getElementById('drag-hint').style.display = 'block';
|
||||
m.closePopup();
|
||||
});
|
||||
|
||||
m.on('dragend', (e) => {
|
||||
document.getElementById('drag-hint').style.display = 'none';
|
||||
const { lat, lng } = e.target.getLatLng();
|
||||
updateLocation(s.id, lat, lng);
|
||||
});
|
||||
|
||||
m.bindPopup(makePopup(s), { maxWidth: 280 });
|
||||
m.addTo(map);
|
||||
markers[s.id] = m;
|
||||
}
|
||||
|
||||
// ── Render Sidebar List ────────────────────────────────────────────────────
|
||||
function renderList(data) {
|
||||
const el = document.getElementById('spbu-list');
|
||||
if (!data.length) {
|
||||
el.innerHTML = `<p class="text-sm text-gray-400 text-center py-8">Tidak ada data.</p>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = data.map(s => `
|
||||
<div onclick="focusSpbu(${s.id})"
|
||||
class="flex items-start gap-3 p-3 rounded-xl cursor-pointer
|
||||
hover:bg-blue-50 transition-colors group">
|
||||
<span class="mt-0.5 flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center
|
||||
${s.is_24jam ? 'bg-green-100' : 'bg-orange-100'}">
|
||||
<span class="text-xs font-bold ${s.is_24jam ? 'text-green-700' : 'text-orange-700'}">
|
||||
${s.is_24jam ? '24' : '—'}
|
||||
</span>
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold text-gray-800 truncate">${s.nama}</p>
|
||||
<p class="text-xs text-gray-400">Kode: ${s.kode}</p>
|
||||
</div>
|
||||
<div class="flex gap-1 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onclick="event.stopPropagation(); openEditModal(${s.id})"
|
||||
class="p-1 rounded-lg bg-blue-100 hover:bg-blue-200 text-blue-600 transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5
|
||||
m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828
|
||||
l8.586-8.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="event.stopPropagation(); openDeleteModal(${s.id})"
|
||||
class="p-1 rounded-lg bg-red-100 hover:bg-red-200 text-red-500 transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858
|
||||
L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// ── Filter ─────────────────────────────────────────────────────────────────
|
||||
function setFilter(f) {
|
||||
activeFilter = f;
|
||||
document.querySelectorAll('.filter-btn').forEach(b => {
|
||||
b.classList.remove('bg-blue-600','bg-green-600','bg-orange-500','text-white');
|
||||
b.classList.add('bg-gray-100','text-gray-600');
|
||||
});
|
||||
const colorMap = { semua:['bg-blue-600','text-white'],
|
||||
'24jam':['bg-green-600','text-white'],
|
||||
tidak:['bg-orange-500','text-white'] };
|
||||
const btn = document.getElementById('btn-' + f);
|
||||
btn.classList.remove('bg-gray-100','text-gray-600');
|
||||
btn.classList.add(...colorMap[f]);
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
let data = allSpbu;
|
||||
if (activeFilter === '24jam') data = data.filter(s => s.is_24jam);
|
||||
if (activeFilter === 'tidak') data = data.filter(s => !s.is_24jam);
|
||||
|
||||
// Redraw markers
|
||||
Object.values(markers).forEach(m => map.removeLayer(m));
|
||||
markers = {};
|
||||
data.forEach(addMarker);
|
||||
renderList(data);
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
document.getElementById('stat-total').textContent = allSpbu.length;
|
||||
document.getElementById('stat-24').textContent = allSpbu.filter(s => s.is_24jam).length;
|
||||
document.getElementById('stat-biasa').textContent = allSpbu.filter(s => !s.is_24jam).length;
|
||||
}
|
||||
|
||||
function focusSpbu(id) {
|
||||
const m = markers[id];
|
||||
if (!m) return;
|
||||
map.flyTo(m.getLatLng(), 16, { duration: .8 });
|
||||
setTimeout(() => m.openPopup(), 850);
|
||||
}
|
||||
|
||||
// ── Load Data ──────────────────────────────────────────────────────────────
|
||||
function loadSpbu() {
|
||||
fetch('api/spbu.php?filter=semua')
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (!res.success) throw new Error(res.message);
|
||||
allSpbu = res.data;
|
||||
updateStats();
|
||||
applyFilter();
|
||||
})
|
||||
.catch(err => toast(err.message, 'error'));
|
||||
}
|
||||
|
||||
// ── Add Mode ───────────────────────────────────────────────────────────────
|
||||
function toggleAddMode() {
|
||||
addMode = !addMode;
|
||||
const btn = document.getElementById('btn-add-mode');
|
||||
const label = document.getElementById('btn-add-label');
|
||||
if (addMode) {
|
||||
btn.classList.replace('bg-white','bg-yellow-300');
|
||||
btn.classList.replace('text-blue-700','text-yellow-900');
|
||||
label.textContent = 'Mode Tambah AKTIF — Klik Peta';
|
||||
map.getContainer().classList.add('map-adding');
|
||||
} else {
|
||||
btn.classList.replace('bg-yellow-300','bg-white');
|
||||
btn.classList.replace('text-yellow-900','text-blue-700');
|
||||
label.textContent = 'Tambah SPBU (Klik Peta)';
|
||||
map.getContainer().classList.remove('map-adding');
|
||||
removeTempMarker();
|
||||
}
|
||||
}
|
||||
|
||||
map.on('click', function(e) {
|
||||
if (!addMode) return;
|
||||
const { lat, lng } = e.latlng;
|
||||
pendingLat = lat;
|
||||
pendingLng = lng;
|
||||
removeTempMarker();
|
||||
tempMarker = L.marker([lat, lng], { icon: makeIcon(false, true) }).addTo(map);
|
||||
openAddModal(lat, lng);
|
||||
});
|
||||
|
||||
function removeTempMarker() {
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
}
|
||||
|
||||
// ── Modal TAMBAH ───────────────────────────────────────────────────────────
|
||||
function openAddModal(lat, lng) {
|
||||
editingId = null;
|
||||
document.getElementById('modal-title').textContent = 'Tambah SPBU Baru';
|
||||
document.getElementById('btn-submit-text').textContent = 'Tambah';
|
||||
document.getElementById('f-id').value = '';
|
||||
document.getElementById('f-kode').value = '';
|
||||
document.getElementById('f-nama').value = '';
|
||||
document.querySelector('input[name="is24"][value="1"]').checked = true;
|
||||
document.getElementById('f-lat').value = lat;
|
||||
document.getElementById('f-lng').value = lng;
|
||||
document.getElementById('f-coord-display').textContent =
|
||||
`${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||
document.getElementById('coord-hint').classList.add('hidden');
|
||||
document.getElementById('form-error').classList.add('hidden');
|
||||
showModal();
|
||||
}
|
||||
|
||||
// ── Modal EDIT ─────────────────────────────────────────────────────────────
|
||||
function openEditModal(id) {
|
||||
const s = allSpbu.find(x => x.id === id);
|
||||
if (!s) return;
|
||||
editingId = id;
|
||||
|
||||
document.getElementById('modal-title').textContent = 'Edit SPBU';
|
||||
document.getElementById('btn-submit-text').textContent = 'Simpan Perubahan';
|
||||
document.getElementById('f-id').value = s.id;
|
||||
document.getElementById('f-kode').value = s.kode;
|
||||
document.getElementById('f-nama').value = s.nama;
|
||||
document.querySelector(`input[name="is24"][value="${s.is_24jam ? 1 : 0}"]`).checked = true;
|
||||
document.getElementById('f-lat').value = s.latitude;
|
||||
document.getElementById('f-lng').value = s.longitude;
|
||||
document.getElementById('f-coord-display').textContent =
|
||||
`${s.latitude.toFixed(6)}, ${s.longitude.toFixed(6)}`;
|
||||
document.getElementById('coord-hint').classList.remove('hidden');
|
||||
document.getElementById('form-error').classList.add('hidden');
|
||||
|
||||
// Tutup popup jika ada
|
||||
if (markers[id]) markers[id].closePopup();
|
||||
|
||||
// Saat modal edit terbuka, klik peta bisa update koordinat
|
||||
map._editCoordListener = function(e) {
|
||||
if (!document.getElementById('modal').classList.contains('show')) return;
|
||||
const { lat, lng } = e.latlng;
|
||||
document.getElementById('f-lat').value = lat;
|
||||
document.getElementById('f-lng').value = lng;
|
||||
document.getElementById('f-coord-display').textContent =
|
||||
`${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||
removeTempMarker();
|
||||
tempMarker = L.marker([lat, lng], { icon: makeIcon(false, true) }).addTo(map);
|
||||
};
|
||||
map.on('click', map._editCoordListener);
|
||||
|
||||
showModal();
|
||||
}
|
||||
|
||||
function showModal() {
|
||||
document.getElementById('modal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeModal(e) {
|
||||
if (e && e.target !== document.getElementById('modal')) return;
|
||||
document.getElementById('modal').classList.remove('show');
|
||||
removeTempMarker();
|
||||
if (map._editCoordListener) {
|
||||
map.off('click', map._editCoordListener);
|
||||
map._editCoordListener = null;
|
||||
}
|
||||
if (addMode && !editingId) {
|
||||
// tutup add mode jika batal tambah
|
||||
toggleAddMode();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit Form ────────────────────────────────────────────────────────────
|
||||
function submitForm() {
|
||||
const kode = document.getElementById('f-kode').value.trim();
|
||||
const nama = document.getElementById('f-nama').value.trim();
|
||||
const is24 = document.querySelector('input[name="is24"]:checked')?.value;
|
||||
const lat = parseFloat(document.getElementById('f-lat').value);
|
||||
const lng = parseFloat(document.getElementById('f-lng').value);
|
||||
const errEl = document.getElementById('form-error');
|
||||
|
||||
if (!kode || !nama || is24 === undefined || isNaN(lat) || isNaN(lng)) {
|
||||
errEl.textContent = 'Semua field wajib diisi.';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
errEl.classList.add('hidden');
|
||||
document.getElementById('btn-spin').classList.remove('hidden');
|
||||
document.getElementById('btn-submit').disabled = true;
|
||||
|
||||
const body = { kode, nama, is_24jam: is24 === '1', latitude: lat, longitude: lng };
|
||||
const isEdit = !!editingId;
|
||||
if (isEdit) body.id = editingId;
|
||||
|
||||
fetch('api/spbu.php', {
|
||||
method: isEdit ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
document.getElementById('btn-spin').classList.add('hidden');
|
||||
document.getElementById('btn-submit').disabled = false;
|
||||
|
||||
if (!res.success) {
|
||||
errEl.textContent = res.message;
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const s = res.data;
|
||||
if (isEdit) {
|
||||
// Update local array
|
||||
const idx = allSpbu.findIndex(x => x.id === s.id);
|
||||
if (idx >= 0) allSpbu[idx] = s;
|
||||
// Rebuild marker
|
||||
if (markers[s.id]) map.removeLayer(markers[s.id]);
|
||||
delete markers[s.id];
|
||||
addMarker(s);
|
||||
} else {
|
||||
allSpbu.push(s);
|
||||
addMarker(s);
|
||||
}
|
||||
|
||||
updateStats();
|
||||
applyFilter();
|
||||
removeTempMarker();
|
||||
document.getElementById('modal').classList.remove('show');
|
||||
if (map._editCoordListener) {
|
||||
map.off('click', map._editCoordListener);
|
||||
map._editCoordListener = null;
|
||||
}
|
||||
if (addMode) toggleAddMode();
|
||||
toast(res.message);
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('btn-spin').classList.add('hidden');
|
||||
document.getElementById('btn-submit').disabled = false;
|
||||
errEl.textContent = 'Terjadi kesalahan, coba lagi.';
|
||||
errEl.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// ── Update Location (Drag) ─────────────────────────────────────────────────
|
||||
function updateLocation(id, lat, lng) {
|
||||
fetch('api/spbu.php', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, latitude: lat, longitude: lng })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (!res.success) { toast(res.message, 'error'); return; }
|
||||
const idx = allSpbu.findIndex(x => x.id === id);
|
||||
if (idx >= 0) {
|
||||
allSpbu[idx].latitude = lat;
|
||||
allSpbu[idx].longitude = lng;
|
||||
}
|
||||
// Refresh popup content
|
||||
if (markers[id]) {
|
||||
markers[id].setPopupContent(makePopup(allSpbu[idx]));
|
||||
}
|
||||
toast('Lokasi SPBU berhasil diperbarui');
|
||||
})
|
||||
.catch(() => toast('Gagal memperbarui lokasi', 'error'));
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────────────────────
|
||||
function openDeleteModal(id) {
|
||||
deleteId = id;
|
||||
const s = allSpbu.find(x => x.id === id);
|
||||
document.getElementById('del-name').textContent = s ? s.nama : '';
|
||||
document.getElementById('modal-del').classList.add('show');
|
||||
if (markers[id]) markers[id].closePopup();
|
||||
}
|
||||
|
||||
function closeDeleteModal(e) {
|
||||
if (e && e.target !== document.getElementById('modal-del')) return;
|
||||
document.getElementById('modal-del').classList.remove('show');
|
||||
deleteId = null;
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!deleteId) return;
|
||||
fetch(`api/spbu.php?id=${deleteId}`, { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (!res.success) { toast(res.message, 'error'); return; }
|
||||
if (markers[deleteId]) map.removeLayer(markers[deleteId]);
|
||||
delete markers[deleteId];
|
||||
allSpbu = allSpbu.filter(x => x.id !== deleteId);
|
||||
updateStats();
|
||||
applyFilter();
|
||||
document.getElementById('modal-del').classList.remove('show');
|
||||
deleteId = null;
|
||||
toast(res.message);
|
||||
})
|
||||
.catch(() => toast('Gagal menghapus SPBU', 'error'));
|
||||
}
|
||||
|
||||
// ── Keyboard shortcut ──────────────────────────────────────────────────────
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') {
|
||||
if (document.getElementById('modal').classList.contains('show')) {
|
||||
closeModal();
|
||||
} else if (document.getElementById('modal-del').classList.contains('show')) {
|
||||
closeDeleteModal();
|
||||
} else if (addMode) {
|
||||
toggleAddMode();
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter' && document.getElementById('modal').classList.contains('show')) {
|
||||
submitForm();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Boot ───────────────────────────────────────────────────────────────────
|
||||
loadSpbu();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
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') { exit(0); }
|
||||
|
||||
require_once __DIR__ . '/../config/database.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$pdo = getDB();
|
||||
|
||||
function respond(bool $success, $data = null, string $message = '', int $code = 200): void {
|
||||
http_response_code($code);
|
||||
echo json_encode(['success' => $success, 'message' => $message, 'data' => $data]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── GET ─────────────────────────────────────────────────────────────────────
|
||||
if ($method === 'GET') {
|
||||
$filter = $_GET['filter'] ?? 'semua';
|
||||
|
||||
$sql = 'SELECT id, kode, nama, is_24jam, latitude, longitude, created_at FROM spbu';
|
||||
$params = [];
|
||||
|
||||
if ($filter === '24jam') {
|
||||
$sql .= ' WHERE is_24jam = 1';
|
||||
} elseif ($filter === 'tidak') {
|
||||
$sql .= ' WHERE is_24jam = 0';
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY nama ASC';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
// Cast types
|
||||
foreach ($rows as &$r) {
|
||||
$r['id'] = (int) $r['id'];
|
||||
$r['is_24jam'] = (bool) $r['is_24jam'];
|
||||
$r['latitude'] = (float) $r['latitude'];
|
||||
$r['longitude']= (float) $r['longitude'];
|
||||
}
|
||||
|
||||
respond(true, $rows);
|
||||
}
|
||||
|
||||
// ── POST (tambah) ─────────────────────────────────────────────────────────
|
||||
if ($method === 'POST') {
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$kode = trim($body['kode'] ?? '');
|
||||
$nama = trim($body['nama'] ?? '');
|
||||
$is_24jam = isset($body['is_24jam']) ? (int)(bool)$body['is_24jam'] : 0;
|
||||
$latitude = isset($body['latitude']) ? (float)$body['latitude'] : null;
|
||||
$longitude = isset($body['longitude']) ? (float)$body['longitude'] : null;
|
||||
|
||||
if (!$kode || !$nama || $latitude === null || $longitude === null) {
|
||||
respond(false, null, 'Data tidak lengkap', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO spbu (kode, nama, is_24jam, latitude, longitude) VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$kode, $nama, $is_24jam, $latitude, $longitude]);
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
|
||||
$stmt2 = $pdo->prepare('SELECT id, kode, nama, is_24jam, latitude, longitude FROM spbu WHERE id = ?');
|
||||
$stmt2->execute([$id]);
|
||||
$spbu = $stmt2->fetch();
|
||||
$spbu['id'] = (int) $spbu['id'];
|
||||
$spbu['is_24jam'] = (bool) $spbu['is_24jam'];
|
||||
$spbu['latitude'] = (float) $spbu['latitude'];
|
||||
$spbu['longitude']= (float) $spbu['longitude'];
|
||||
|
||||
respond(true, $spbu, 'SPBU berhasil ditambahkan', 201);
|
||||
} catch (PDOException $e) {
|
||||
if ($e->getCode() === '23000') {
|
||||
respond(false, null, 'Kode SPBU sudah digunakan', 409);
|
||||
}
|
||||
respond(false, null, 'Gagal menyimpan data', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ── PUT (update) ─────────────────────────────────────────────────────────
|
||||
if ($method === 'PUT') {
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$id = isset($body['id']) ? (int)$body['id'] : 0;
|
||||
$kode = isset($body['kode']) ? trim($body['kode']) : null;
|
||||
$nama = isset($body['nama']) ? trim($body['nama']) : null;
|
||||
$is_24jam = isset($body['is_24jam']) ? (int)(bool)$body['is_24jam'] : null;
|
||||
$latitude = isset($body['latitude']) ? (float)$body['latitude'] : null;
|
||||
$longitude = isset($body['longitude']) ? (float)$body['longitude'] : null;
|
||||
|
||||
if (!$id) { respond(false, null, 'ID tidak valid', 400); }
|
||||
|
||||
// Build dynamic SET clause
|
||||
$sets = [];
|
||||
$params = [];
|
||||
|
||||
if ($kode !== null) { $sets[] = 'kode = ?'; $params[] = $kode; }
|
||||
if ($nama !== null) { $sets[] = 'nama = ?'; $params[] = $nama; }
|
||||
if ($is_24jam !== null) { $sets[] = 'is_24jam = ?'; $params[] = $is_24jam; }
|
||||
if ($latitude !== null) { $sets[] = 'latitude = ?'; $params[] = $latitude; }
|
||||
if ($longitude !== null) { $sets[] = 'longitude = ?'; $params[] = $longitude; }
|
||||
|
||||
if (empty($sets)) { respond(false, null, 'Tidak ada data yang diubah', 400); }
|
||||
|
||||
$params[] = $id;
|
||||
try {
|
||||
$stmt = $pdo->prepare('UPDATE spbu SET ' . implode(', ', $sets) . ' WHERE id = ?');
|
||||
$stmt->execute($params);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
respond(false, null, 'SPBU tidak ditemukan', 404);
|
||||
}
|
||||
|
||||
$stmt2 = $pdo->prepare('SELECT id, kode, nama, is_24jam, latitude, longitude FROM spbu WHERE id = ?');
|
||||
$stmt2->execute([$id]);
|
||||
$spbu = $stmt2->fetch();
|
||||
$spbu['id'] = (int) $spbu['id'];
|
||||
$spbu['is_24jam'] = (bool) $spbu['is_24jam'];
|
||||
$spbu['latitude'] = (float) $spbu['latitude'];
|
||||
$spbu['longitude']= (float) $spbu['longitude'];
|
||||
|
||||
respond(true, $spbu, 'SPBU berhasil diperbarui');
|
||||
} catch (PDOException $e) {
|
||||
if ($e->getCode() === '23000') {
|
||||
respond(false, null, 'Kode SPBU sudah digunakan', 409);
|
||||
}
|
||||
respond(false, null, 'Gagal memperbarui data', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ── DELETE ────────────────────────────────────────────────────────────────
|
||||
if ($method === 'DELETE') {
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
if (!$id) { respond(false, null, 'ID tidak valid', 400); }
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM spbu WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
respond(false, null, 'SPBU tidak ditemukan', 404);
|
||||
}
|
||||
respond(true, null, 'SPBU berhasil dihapus');
|
||||
}
|
||||
|
||||
respond(false, null, 'Method tidak diizinkan', 405);
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// Konfigurasi koneksi database — sesuaikan jika perlu
|
||||
define('DB_HOST', 'localhost');
|
||||
define('DB_NAME', 'sig_spbu');
|
||||
define('DB_USER', 'root');
|
||||
define('DB_PASS', '');
|
||||
define('DB_CHARSET', 'utf8mb4');
|
||||
|
||||
function getDB(): PDO {
|
||||
static $pdo = null;
|
||||
if ($pdo !== null) return $pdo;
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;dbname=%s;charset=%s',
|
||||
DB_HOST, DB_NAME, DB_CHARSET
|
||||
);
|
||||
try {
|
||||
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Koneksi database gagal']);
|
||||
exit;
|
||||
}
|
||||
return $pdo;
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebGIS SPBU — Peta Sebaran Stasiun BBM</title>
|
||||
|
||||
<!-- TailwindCSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: { DEFAULT: '#1d4ed8', dark: '#1e3a8a' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Leaflet.js -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
#map { height: 100%; width: 100%; }
|
||||
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.15);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-popup-content { margin: 0; width: 240px !important; }
|
||||
.leaflet-popup-tip-container { display: none; }
|
||||
|
||||
/* Custom marker */
|
||||
.spbu-marker {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
transform: rotate(-45deg);
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,.35);
|
||||
cursor: pointer;
|
||||
transition: transform .15s;
|
||||
}
|
||||
.spbu-marker:hover { transform: rotate(-45deg) scale(1.15); }
|
||||
.spbu-marker span {
|
||||
transform: rotate(45deg);
|
||||
font-size: 11px; font-weight: 800;
|
||||
color: #fff; line-height: 1;
|
||||
}
|
||||
.marker-24 { background: #16a34a; }
|
||||
.marker-biasa{ background: #ea580c; }
|
||||
|
||||
/* Sidebar scroll */
|
||||
#spbu-list { overflow-y: auto; max-height: calc(100vh - 200px); }
|
||||
|
||||
/* Pulse animation for active filter */
|
||||
.filter-active { box-shadow: 0 0 0 3px rgba(29,78,216,.35); }
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
width: 28px; height: 28px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top-color: #1d4ed8;
|
||||
border-radius: 50%;
|
||||
animation: spin .7s linear infinite;
|
||||
margin: 24px auto;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 font-sans">
|
||||
|
||||
<!-- ── LAYOUT ─────────────────────────────────────────────────────────── -->
|
||||
<div class="flex h-screen">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside class="w-80 bg-white shadow-xl flex flex-col z-10 flex-shrink-0">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="bg-gradient-to-br from-blue-700 to-blue-900 text-white px-5 py-4">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<svg class="w-7 h-7 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3 10h2l1 2h13l1-2h2M5 12v6a1 1 0 001 1h12a1 1 0 001-1v-6
|
||||
M9 22v-4h6v4M7 4h10l1 4H6L7 4z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h1 class="text-lg font-bold leading-tight">WebGIS SPBU</h1>
|
||||
<p class="text-blue-200 text-xs">Peta Sebaran Stasiun BBM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="px-4 py-3 border-b border-gray-100">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Filter Operasional</p>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="setFilter('semua')" id="btn-semua"
|
||||
class="flex-1 py-1.5 text-sm rounded-lg font-medium transition-all filter-btn filter-active
|
||||
bg-blue-600 text-white">
|
||||
Semua
|
||||
</button>
|
||||
<button onclick="setFilter('24jam')" id="btn-24jam"
|
||||
class="flex-1 py-1.5 text-sm rounded-lg font-medium transition-all filter-btn
|
||||
bg-gray-100 text-gray-600 hover:bg-green-50 hover:text-green-700">
|
||||
24 Jam
|
||||
</button>
|
||||
<button onclick="setFilter('tidak')" id="btn-tidak"
|
||||
class="flex-1 py-1.5 text-sm rounded-lg font-medium transition-all filter-btn
|
||||
bg-gray-100 text-gray-600 hover:bg-orange-50 hover:text-orange-700">
|
||||
Non-24 Jam
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="px-4 py-2.5 border-b border-gray-100 flex gap-3">
|
||||
<div class="flex-1 text-center bg-blue-50 rounded-lg py-2">
|
||||
<p class="text-xl font-bold text-blue-700" id="stat-total">—</p>
|
||||
<p class="text-xs text-blue-500">Total</p>
|
||||
</div>
|
||||
<div class="flex-1 text-center bg-green-50 rounded-lg py-2">
|
||||
<p class="text-xl font-bold text-green-700" id="stat-24">—</p>
|
||||
<p class="text-xs text-green-500">24 Jam</p>
|
||||
</div>
|
||||
<div class="flex-1 text-center bg-orange-50 rounded-lg py-2">
|
||||
<p class="text-xl font-bold text-orange-700" id="stat-biasa">—</p>
|
||||
<p class="text-xs text-orange-500">Non-24 Jam</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="px-4 py-2.5 border-b border-gray-100">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
|
||||
</svg>
|
||||
<input id="search-input" type="text" placeholder="Cari SPBU..."
|
||||
class="w-full pl-9 pr-3 py-2 text-sm border border-gray-200 rounded-lg
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-300">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SPBU List -->
|
||||
<div id="spbu-list" class="flex-1 px-3 py-2 space-y-1.5">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
|
||||
<p class="text-xs text-gray-400">© 2026 WebGIS SPBU</p>
|
||||
<a href="admin.php"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6
|
||||
a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
Admin
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- MAP -->
|
||||
<main class="flex-1 relative">
|
||||
<div id="map"></div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="absolute bottom-6 right-4 bg-white rounded-xl shadow-lg px-4 py-3 z-[1000]">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Keterangan</p>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-green-600 flex-shrink-0"></span>
|
||||
<span class="text-sm text-gray-700">Buka 24 Jam</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-orange-600 flex-shrink-0"></span>
|
||||
<span class="text-sm text-gray-700">Non-24 Jam</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ── SCRIPTS ─────────────────────────────────────────────────────────── -->
|
||||
<script>
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
let allSpbu = [];
|
||||
let markers = {};
|
||||
let activeFilter = 'semua';
|
||||
let searchTerm = '';
|
||||
|
||||
// ── Map Init ───────────────────────────────────────────────────────────────
|
||||
const map = L.map('map', { zoomControl: false }).setView([-0.0263, 109.3425], 13);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
L.control.zoom({ position: 'bottomleft' }).addTo(map);
|
||||
|
||||
// ── Marker Factory ─────────────────────────────────────────────────────────
|
||||
function makeIcon(is24) {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<div class="spbu-marker ${is24 ? 'marker-24' : 'marker-biasa'}">
|
||||
<span>${is24 ? '24' : ''}</span>
|
||||
</div>`,
|
||||
iconSize: [36, 36],
|
||||
iconAnchor: [18, 36],
|
||||
popupAnchor:[0, -38]
|
||||
});
|
||||
}
|
||||
|
||||
// ── Popup HTML ─────────────────────────────────────────────────────────────
|
||||
function popupHtml(s) {
|
||||
const badge = s.is_24jam
|
||||
? `<span class="inline-flex items-center gap-1 bg-green-100 text-green-700
|
||||
text-xs font-semibold px-2 py-0.5 rounded-full">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2
|
||||
0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415
|
||||
L11 9.586V6z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Buka 24 Jam
|
||||
</span>`
|
||||
: `<span class="inline-flex items-center gap-1 bg-orange-100 text-orange-700
|
||||
text-xs font-semibold px-2 py-0.5 rounded-full">
|
||||
Non-24 Jam
|
||||
</span>`;
|
||||
|
||||
return `
|
||||
<div>
|
||||
<div class="px-4 py-3 border-b border-gray-100">
|
||||
<p class="font-bold text-gray-800 text-sm leading-snug">${s.nama}</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">Kode: ${s.kode}</p>
|
||||
</div>
|
||||
<div class="px-4 py-3 space-y-2">
|
||||
${badge}
|
||||
<div class="flex items-start gap-1.5">
|
||||
<svg class="w-3.5 h-3.5 mt-0.5 text-gray-400 flex-shrink-0" fill="none"
|
||||
stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827
|
||||
0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<p class="text-xs text-gray-500">${s.latitude.toFixed(6)}, ${s.longitude.toFixed(6)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render List Sidebar ────────────────────────────────────────────────────
|
||||
function renderList(data) {
|
||||
const el = document.getElementById('spbu-list');
|
||||
if (!data.length) {
|
||||
el.innerHTML = `<div class="text-center py-10">
|
||||
<svg class="w-10 h-10 text-gray-300 mx-auto mb-2" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01
|
||||
M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<p class="text-sm text-gray-400">Tidak ada SPBU ditemukan</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = data.map(s => `
|
||||
<div onclick="focusSpbu(${s.id})"
|
||||
class="flex items-start gap-3 p-3 rounded-xl cursor-pointer
|
||||
hover:bg-blue-50 transition-colors group">
|
||||
<span class="mt-0.5 flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center
|
||||
${s.is_24jam ? 'bg-green-100' : 'bg-orange-100'}">
|
||||
<span class="text-xs font-bold ${s.is_24jam ? 'text-green-700' : 'text-orange-700'}">
|
||||
${s.is_24jam ? '24' : '—'}
|
||||
</span>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-gray-800 truncate group-hover:text-blue-700">${s.nama}</p>
|
||||
<p class="text-xs text-gray-400">Kode: ${s.kode}</p>
|
||||
<span class="text-xs ${s.is_24jam ? 'text-green-600' : 'text-orange-500'}">
|
||||
${s.is_24jam ? '● Buka 24 Jam' : '● Non-24 Jam'}
|
||||
</span>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// ── Filter & Search Helpers ────────────────────────────────────────────────
|
||||
function applyFilterSearch() {
|
||||
let data = allSpbu;
|
||||
if (activeFilter === '24jam') data = data.filter(s => s.is_24jam);
|
||||
if (activeFilter === 'tidak') data = data.filter(s => !s.is_24jam);
|
||||
if (searchTerm) {
|
||||
const q = searchTerm.toLowerCase();
|
||||
data = data.filter(s =>
|
||||
s.nama.toLowerCase().includes(q) || s.kode.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
// Show/hide markers
|
||||
Object.values(markers).forEach(m => map.removeLayer(m));
|
||||
markers = {};
|
||||
data.forEach(s => {
|
||||
const m = L.marker([s.latitude, s.longitude], { icon: makeIcon(s.is_24jam) })
|
||||
.bindPopup(popupHtml(s), { maxWidth: 260 });
|
||||
m.addTo(map);
|
||||
markers[s.id] = m;
|
||||
});
|
||||
|
||||
renderList(data);
|
||||
}
|
||||
|
||||
function setFilter(f) {
|
||||
activeFilter = f;
|
||||
|
||||
document.querySelectorAll('.filter-btn').forEach(b => {
|
||||
b.classList.remove('bg-blue-600', 'text-white', 'bg-green-600',
|
||||
'bg-orange-500', 'filter-active');
|
||||
b.classList.add('bg-gray-100', 'text-gray-600');
|
||||
});
|
||||
|
||||
const map_ = { semua: ['bg-blue-600','text-white'],
|
||||
'24jam': ['bg-green-600','text-white'],
|
||||
tidak: ['bg-orange-500','text-white'] };
|
||||
const btn = document.getElementById('btn-' + f);
|
||||
btn.classList.remove('bg-gray-100','text-gray-600');
|
||||
btn.classList.add(...map_[f], 'filter-active');
|
||||
|
||||
applyFilterSearch();
|
||||
}
|
||||
|
||||
function focusSpbu(id) {
|
||||
const m = markers[id];
|
||||
if (!m) return;
|
||||
map.flyTo(m.getLatLng(), 16, { duration: .8 });
|
||||
setTimeout(() => m.openPopup(), 850);
|
||||
}
|
||||
|
||||
// ── Update Stats ───────────────────────────────────────────────────────────
|
||||
function updateStats(data) {
|
||||
document.getElementById('stat-total').textContent = data.length;
|
||||
document.getElementById('stat-24').textContent = data.filter(s => s.is_24jam).length;
|
||||
document.getElementById('stat-biasa').textContent = data.filter(s => !s.is_24jam).length;
|
||||
}
|
||||
|
||||
// ── Load Data ──────────────────────────────────────────────────────────────
|
||||
function loadSpbu() {
|
||||
fetch('api/spbu.php?filter=semua')
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (!res.success) throw new Error(res.message);
|
||||
allSpbu = res.data;
|
||||
updateStats(allSpbu);
|
||||
applyFilterSearch();
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('spbu-list').innerHTML =
|
||||
`<p class="text-sm text-red-500 text-center py-6">${err.message}</p>`;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Search Input ───────────────────────────────────────────────────────────
|
||||
document.getElementById('search-input').addEventListener('input', function() {
|
||||
searchTerm = this.value.trim();
|
||||
applyFilterSearch();
|
||||
});
|
||||
|
||||
// ── Boot ───────────────────────────────────────────────────────────────────
|
||||
loadSpbu();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
-- ============================================================
|
||||
-- WebGIS SPBU - Database Setup
|
||||
-- Jalankan script ini di MySQL/phpMyAdmin
|
||||
-- ============================================================
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS sig_spbu
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE sig_spbu;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS spbu (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
kode VARCHAR(50) NOT NULL UNIQUE COMMENT 'Kode unik SPBU',
|
||||
nama VARCHAR(255) NOT NULL COMMENT 'Nama SPBU',
|
||||
is_24jam TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = buka 24 jam',
|
||||
latitude DECIMAL(10, 8) NOT NULL,
|
||||
longitude DECIMAL(11, 8) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Data contoh (Pontianak, Kalimantan Barat)
|
||||
INSERT INTO spbu (kode, nama, is_24jam, latitude, longitude) VALUES
|
||||
('61.701.01', 'SPBU Pertamina Ahmad Yani', 1, -0.02830, 109.34150),
|
||||
('61.701.02', 'SPBU Pertamina Gajah Mada', 0, -0.01970, 109.33490),
|
||||
('61.701.03', 'SPBU Pertamina Tanjungpura', 1, -0.03480, 109.33170),
|
||||
('61.701.04', 'SPBU Pertamina Imam Bonjol', 0, -0.05680, 109.34670),
|
||||
('61.701.05', 'SPBU Pertamina Soekarno-Hatta', 1, -0.06510, 109.35540),
|
||||
('61.701.06', 'SPBU Pertamina Sultan Syarif Abdurrahman', 0, -0.01330, 109.32890),
|
||||
('61.701.07', 'SPBU Pertamina Sei Raya Dalam', 1, -0.09420, 109.36800);
|
||||
@@ -0,0 +1,51 @@
|
||||
# SIG Mapping Tanah & Jalan
|
||||
|
||||
Aplikasi pemetaan **point & click**: gambar bidang **tanah** (polygon) dan **jalan** (garis)
|
||||
langsung di peta. **Luas, keliling, dan panjang dihitung otomatis** (di klien saat menggambar,
|
||||
dan diverifikasi ulang di server saat disimpan).
|
||||
|
||||
## Teknologi
|
||||
- **Frontend:** TailwindCSS (CDN), Leaflet.js + Leaflet.draw
|
||||
- **Backend:** Vanilla PHP (PDO), pola REST sederhana
|
||||
- **Database:** MySQL 8
|
||||
|
||||
## Struktur
|
||||
```
|
||||
sig-02/
|
||||
├── index.php UI peta + sidebar + modal form
|
||||
├── assets/app.js Logika peta, CRUD, perhitungan klien
|
||||
├── api/
|
||||
│ ├── tanah.php Endpoint CRUD tanah (Polygon)
|
||||
│ └── jalan.php Endpoint CRUD jalan (LineString)
|
||||
├── includes/
|
||||
│ ├── crud.php Handler CRUD generik
|
||||
│ └── geo.php Haversine + luas geodesik (perhitungan server)
|
||||
├── config/database.php Koneksi PDO
|
||||
└── schema.sql Skema database
|
||||
```
|
||||
|
||||
## Cara Menjalankan (Laragon)
|
||||
1. **Import database** (sekali saja):
|
||||
```
|
||||
mysql -u root < schema.sql
|
||||
```
|
||||
atau via HeidiSQL/phpMyAdmin: jalankan isi `schema.sql`.
|
||||
2. Pastikan kredensial di `config/database.php` sesuai (default Laragon: `root`, tanpa password).
|
||||
3. Buka di browser:
|
||||
- Laragon (Apache): `http://sig-02.test` atau `http://localhost/sig-02`
|
||||
- Atau server bawaan PHP: `php -S 127.0.0.1:8000` lalu buka `http://127.0.0.1:8000`
|
||||
|
||||
## Cara Pakai
|
||||
- Pakai toolbar gambar di **kanan-atas peta**:
|
||||
- **Polygon** → tambah **tanah** (luas & keliling muncul otomatis).
|
||||
- **Garis (polyline)** → tambah **jalan** (panjang muncul otomatis).
|
||||
- Selesai menggambar → muncul form untuk nama/pemilik/jenis/deskripsi/warna → **Simpan**.
|
||||
- **Sidebar kiri:** daftar tanah & jalan (klik untuk fokus, ✎ edit atribut, 🗑 hapus).
|
||||
- **Edit geometri:** klik ikon edit (toolbar gambar), geser titik, lalu *Save* → tersimpan otomatis.
|
||||
- Ganti basemap (Peta Jalan / Satelit) di pojok kanan-bawah.
|
||||
|
||||
## Catatan Perhitungan
|
||||
- **Panjang jalan:** jumlah jarak haversine antar titik (meter, ditampilkan m / km).
|
||||
- **Luas tanah:** rumus area geodesik bola (m², ditampilkan m² / ha).
|
||||
- **Keliling tanah:** haversine keliling ring polygon.
|
||||
- Radius bumi: WGS84 (6.378.137 m).
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/crud.php';
|
||||
|
||||
handle_crud([
|
||||
'table' => 'jalan',
|
||||
'geomType' => 'LineString',
|
||||
'fields' => ['nama', 'jenis', 'kategori', 'deskripsi', 'warna'],
|
||||
'measure' => function (array $geom): array {
|
||||
// LineString: coordinates = [ [lng,lat], ... ]
|
||||
[, $coords] = $geom;
|
||||
return [
|
||||
'panjang' => round(line_length($coords), 2),
|
||||
];
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/crud.php';
|
||||
|
||||
handle_crud([
|
||||
'table' => 'tanah',
|
||||
'geomType' => 'Polygon',
|
||||
'fields' => ['nama', 'pemilik', 'kategori', 'deskripsi', 'warna'],
|
||||
'measure' => function (array $geom): array {
|
||||
// Polygon: coordinates = [ring, ...]; ring pertama = outer ring.
|
||||
[, $coords] = $geom;
|
||||
$outer = $coords[0] ?? [];
|
||||
return [
|
||||
'luas' => round(ring_area($outer), 2),
|
||||
'keliling' => round(ring_perimeter($outer), 2),
|
||||
];
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,477 @@
|
||||
/* =========================================================================
|
||||
* SIG Mapping Tanah & Jalan
|
||||
* Leaflet + Leaflet.draw, backend vanilla PHP.
|
||||
* ========================================================================= */
|
||||
|
||||
const API = {
|
||||
tanah: 'api/tanah.php',
|
||||
jalan: 'api/jalan.php',
|
||||
};
|
||||
|
||||
const THEME = {
|
||||
tanah: { color: '#22c55e', label: 'Tanah', geom: 'Polygon' },
|
||||
jalan: { color: '#ef4444', label: 'Jalan', geom: 'LineString' },
|
||||
};
|
||||
|
||||
// Penyimpanan layer & data per id
|
||||
const store = {
|
||||
tanah: { items: [], layers: new Map() },
|
||||
jalan: { items: [], layers: new Map() },
|
||||
};
|
||||
|
||||
let activeTab = 'tanah';
|
||||
let searchQuery = '';
|
||||
|
||||
/* --------------------------- Util format --------------------------- */
|
||||
function fmtArea(m2) {
|
||||
if (m2 >= 10000) return (m2 / 10000).toFixed(2) + ' ha (' + Math.round(m2).toLocaleString('id') + ' m²)';
|
||||
return Math.round(m2).toLocaleString('id') + ' m²';
|
||||
}
|
||||
function fmtLen(m) {
|
||||
if (m >= 1000) return (m / 1000).toFixed(2) + ' km (' + Math.round(m).toLocaleString('id') + ' m)';
|
||||
return Math.round(m).toLocaleString('id') + ' m';
|
||||
}
|
||||
|
||||
/* --------------- Perhitungan klien (mirror dari PHP) --------------- */
|
||||
const R = 6378137.0;
|
||||
const rad = (d) => (d * Math.PI) / 180;
|
||||
|
||||
function haversine(a, b) {
|
||||
const dLat = rad(b[1] - a[1]);
|
||||
const dLon = rad(b[0] - a[0]);
|
||||
const h = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(rad(a[1])) * Math.cos(rad(b[1])) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
function lineLength(coords) {
|
||||
let t = 0;
|
||||
for (let i = 1; i < coords.length; i++) t += haversine(coords[i - 1], coords[i]);
|
||||
return t;
|
||||
}
|
||||
function ringArea(ring) {
|
||||
const n = ring.length;
|
||||
if (n < 3) return 0;
|
||||
let area = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p1 = ring[i], p2 = ring[(i + 1) % n];
|
||||
area += rad(p2[0] - p1[0]) * (2 + Math.sin(rad(p1[1])) + Math.sin(rad(p2[1])));
|
||||
}
|
||||
return Math.abs((area * R * R) / 2);
|
||||
}
|
||||
function ringPerimeter(ring) {
|
||||
if (ring.length < 2) return 0;
|
||||
const closed = ring.slice();
|
||||
const f = closed[0], l = closed[closed.length - 1];
|
||||
if (f[0] !== l[0] || f[1] !== l[1]) closed.push(f);
|
||||
return lineLength(closed);
|
||||
}
|
||||
|
||||
/* Hitung ukuran dari sebuah GeoJSON geometry */
|
||||
function measure(kind, geometry) {
|
||||
if (kind === 'tanah') {
|
||||
const outer = geometry.coordinates[0] || [];
|
||||
return { luas: ringArea(outer), keliling: ringPerimeter(outer) };
|
||||
}
|
||||
return { panjang: lineLength(geometry.coordinates) };
|
||||
}
|
||||
|
||||
/* --------------------------- Peta --------------------------- */
|
||||
const map = L.map('map', { center: [-0.0263, 109.3425], zoom: 13 }); // Pontianak
|
||||
|
||||
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19, attribution: '© OpenStreetMap',
|
||||
}).addTo(map);
|
||||
|
||||
const sat = L.tileLayer(
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
{ maxZoom: 19, attribution: 'Esri World Imagery' }
|
||||
);
|
||||
L.control.layers({ 'Peta Jalan': osm, 'Satelit': sat }, null, { position: 'bottomright' }).addTo(map);
|
||||
|
||||
// Group yang bisa diedit oleh Leaflet.draw
|
||||
const drawnItems = new L.FeatureGroup().addTo(map);
|
||||
|
||||
const drawControl = new L.Control.Draw({
|
||||
position: 'topright',
|
||||
draw: {
|
||||
polygon: {
|
||||
showArea: true, metric: true, allowIntersection: false,
|
||||
shapeOptions: { color: THEME.tanah.color, weight: 2, fillOpacity: 0.3 },
|
||||
},
|
||||
polyline: {
|
||||
metric: true, showLength: true,
|
||||
shapeOptions: { color: THEME.jalan.color, weight: 4 },
|
||||
},
|
||||
rectangle: false, circle: false, marker: false, circlemarker: false,
|
||||
},
|
||||
edit: { featureGroup: drawnItems, remove: false },
|
||||
});
|
||||
map.addControl(drawControl);
|
||||
|
||||
/* --- Tombol "Tambah" di sidebar: aktifkan mode menggambar Leaflet.draw --- */
|
||||
let activeDrawer = null;
|
||||
function startDraw(kind) {
|
||||
if (activeDrawer) { activeDrawer.disable(); activeDrawer = null; }
|
||||
activeDrawer = kind === 'tanah'
|
||||
? new L.Draw.Polygon(map, drawControl.options.draw.polygon)
|
||||
: new L.Draw.Polyline(map, drawControl.options.draw.polyline);
|
||||
activeDrawer.enable();
|
||||
|
||||
const hint = document.getElementById('draw-hint');
|
||||
if (hint) {
|
||||
hint.innerHTML = kind === 'tanah'
|
||||
? 'Klik titik-titik batas <b>tanah</b> di peta, klik titik awal untuk menutup.'
|
||||
: 'Klik titik-titik <b>jalan</b> di peta, klik dua kali untuk mengakhiri.';
|
||||
hint.classList.add('text-emerald-700', 'font-medium');
|
||||
}
|
||||
}
|
||||
function resetDrawHint() {
|
||||
const hint = document.getElementById('draw-hint');
|
||||
if (hint) {
|
||||
hint.innerHTML = 'Klik tombol lalu gambar di peta. <span class="text-slate-400">Luas & panjang otomatis.</span>';
|
||||
hint.classList.remove('text-emerald-700', 'font-medium');
|
||||
}
|
||||
}
|
||||
document.getElementById('add-tanah').onclick = () => startDraw('tanah');
|
||||
document.getElementById('add-jalan').onclick = () => startDraw('jalan');
|
||||
map.on(L.Draw.Event.DRAWSTOP, () => { activeDrawer = null; resetDrawHint(); });
|
||||
|
||||
/* --------------------------- API helpers --------------------------- */
|
||||
async function apiGet(kind) {
|
||||
const res = await fetch(API[kind]);
|
||||
const j = await res.json();
|
||||
return j.data || [];
|
||||
}
|
||||
async function apiSave(kind, payload, id) {
|
||||
const url = id ? `${API[kind]}?id=${id}` : API[kind];
|
||||
const res = await fetch(url, {
|
||||
method: id ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (!res.ok) throw new Error(j.error || 'Gagal menyimpan');
|
||||
return j.data;
|
||||
}
|
||||
async function apiDelete(kind, id) {
|
||||
const res = await fetch(`${API[kind]}?id=${id}`, { method: 'DELETE' });
|
||||
const j = await res.json();
|
||||
if (!res.ok) throw new Error(j.error || 'Gagal menghapus');
|
||||
return true;
|
||||
}
|
||||
|
||||
/* --------------------------- Render layer --------------------------- */
|
||||
function styleFor(kind, item) {
|
||||
const c = item.warna || THEME[kind].color;
|
||||
return kind === 'tanah'
|
||||
? { color: c, weight: 2, fillColor: c, fillOpacity: 0.35 }
|
||||
: { color: c, weight: 5, opacity: 0.9 };
|
||||
}
|
||||
|
||||
function popupHtml(kind, item) {
|
||||
if (kind === 'tanah') {
|
||||
return `<div class="text-sm">
|
||||
<b>${escapeHtml(item.nama)}</b><br>
|
||||
${item.pemilik ? 'Pemilik: ' + escapeHtml(item.pemilik) + '<br>' : ''}
|
||||
${item.kategori ? 'Status: ' + escapeHtml(item.kategori) + '<br>' : ''}
|
||||
Luas: <b>${fmtArea(+item.luas)}</b><br>
|
||||
Keliling: ${fmtLen(+item.keliling)}
|
||||
${item.deskripsi ? '<br><i>' + escapeHtml(item.deskripsi) + '</i>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="text-sm">
|
||||
<b>${escapeHtml(item.nama)}</b><br>
|
||||
${item.kategori ? 'Kategori: ' + escapeHtml(item.kategori) + '<br>' : ''}
|
||||
${item.jenis ? 'Jenis: ' + escapeHtml(item.jenis) + '<br>' : ''}
|
||||
Panjang: <b>${fmtLen(+item.panjang)}</b>
|
||||
${item.deskripsi ? '<br><i>' + escapeHtml(item.deskripsi) + '</i>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function addFeature(kind, item) {
|
||||
const layer = L.geoJSON(item.geojson, { style: styleFor(kind, item) });
|
||||
// geoJSON membuat group; ambil layer dalamnya & masukkan ke drawnItems
|
||||
layer.eachLayer((l) => {
|
||||
l.feature_id = item.id;
|
||||
l.kind = kind;
|
||||
l.bindPopup(popupHtml(kind, item));
|
||||
drawnItems.addLayer(l);
|
||||
store[kind].layers.set(item.id, l);
|
||||
});
|
||||
}
|
||||
|
||||
function removeFeatureLayer(kind, id) {
|
||||
const l = store[kind].layers.get(id);
|
||||
if (l) { drawnItems.removeLayer(l); store[kind].layers.delete(id); }
|
||||
}
|
||||
|
||||
/* --------------------------- Sidebar --------------------------- */
|
||||
function matchQuery(kind, it) {
|
||||
if (!searchQuery) return true;
|
||||
const hay = [it.nama, it.deskripsi, it.kategori, kind === 'tanah' ? it.pemilik : it.jenis]
|
||||
.filter(Boolean).join(' ').toLowerCase();
|
||||
return hay.includes(searchQuery);
|
||||
}
|
||||
|
||||
function renderList(kind) {
|
||||
const ul = document.getElementById('list-' + kind);
|
||||
const all = store[kind].items;
|
||||
const items = all.filter((it) => matchQuery(kind, it));
|
||||
// tampilkan "cocok/total" saat mencari, atau total saja saat tidak
|
||||
document.getElementById('count-' + kind).textContent =
|
||||
searchQuery ? `${items.length}/${all.length}` : all.length;
|
||||
|
||||
if (!all.length) {
|
||||
ul.innerHTML = `<li class="p-6 text-center text-sm text-slate-400">Belum ada data.<br>Gambar di peta untuk menambahkan.</li>`;
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
ul.innerHTML = `<li class="p-6 text-center text-sm text-slate-400">Tidak ada hasil untuk<br>"<b>${escapeHtml(searchQuery)}</b>".</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
ul.innerHTML = items.map((it) => {
|
||||
const ukuran = kind === 'tanah' ? fmtArea(+it.luas) : fmtLen(+it.panjang);
|
||||
const sub = kind === 'tanah'
|
||||
? (it.pemilik ? escapeHtml(it.pemilik) : '—')
|
||||
: (it.jenis ? escapeHtml(it.jenis) : '—');
|
||||
return `<li class="group p-3 hover:bg-slate-50 cursor-pointer flex items-start gap-3" data-id="${it.id}">
|
||||
<span class="mt-1 w-3 h-3 rounded-sm shrink-0" style="background:${it.warna || THEME[kind].color}"></span>
|
||||
<div class="flex-1 min-w-0" data-act="focus">
|
||||
<div class="font-medium text-sm truncate">${escapeHtml(it.nama)}</div>
|
||||
<div class="text-xs text-slate-500 truncate">${sub}</div>
|
||||
<div class="text-xs font-semibold text-slate-700 mt-0.5">${ukuran}</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||
<button data-act="edit" title="Edit" class="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700 hover:bg-amber-200">✎</button>
|
||||
<button data-act="del" title="Hapus" class="text-xs px-2 py-0.5 rounded bg-red-100 text-red-700 hover:bg-red-200">🗑</button>
|
||||
</div>
|
||||
</li>`;
|
||||
}).join('');
|
||||
|
||||
ul.querySelectorAll('li').forEach((li) => {
|
||||
const id = +li.dataset.id;
|
||||
li.querySelector('[data-act="focus"]').onclick = () => focusFeature(kind, id);
|
||||
li.querySelector('[data-act="edit"]').onclick = (e) => { e.stopPropagation(); openEdit(kind, id); };
|
||||
li.querySelector('[data-act="del"]').onclick = (e) => { e.stopPropagation(); doDelete(kind, id); };
|
||||
});
|
||||
}
|
||||
|
||||
// Redupkan fitur di peta yang tidak cocok dengan pencarian.
|
||||
function applyMapFilter() {
|
||||
['tanah', 'jalan'].forEach((kind) => {
|
||||
store[kind].items.forEach((it) => {
|
||||
const l = store[kind].layers.get(it.id);
|
||||
if (!l) return;
|
||||
if (matchQuery(kind, it)) {
|
||||
l.setStyle(styleFor(kind, it));
|
||||
} else {
|
||||
l.setStyle(kind === 'tanah' ? { opacity: 0.15, fillOpacity: 0.04 } : { opacity: 0.15 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
const luas = store.tanah.items.reduce((s, i) => s + (+i.luas || 0), 0);
|
||||
const panjang = store.jalan.items.reduce((s, i) => s + (+i.panjang || 0), 0);
|
||||
document.getElementById('sum-luas').textContent = fmtArea(luas);
|
||||
document.getElementById('sum-panjang').textContent = fmtLen(panjang);
|
||||
}
|
||||
|
||||
function focusFeature(kind, id) {
|
||||
const l = store[kind].layers.get(id);
|
||||
if (!l) return;
|
||||
if (l.getBounds) map.fitBounds(l.getBounds(), { maxZoom: 18, padding: [40, 40] });
|
||||
l.openPopup();
|
||||
}
|
||||
|
||||
/* --------------------------- Load data --------------------------- */
|
||||
async function loadAll() {
|
||||
for (const kind of ['tanah', 'jalan']) {
|
||||
store[kind].items = await apiGet(kind);
|
||||
store[kind].layers.forEach((l) => drawnItems.removeLayer(l));
|
||||
store[kind].layers.clear();
|
||||
store[kind].items.forEach((it) => addFeature(kind, it));
|
||||
renderList(kind);
|
||||
}
|
||||
updateSummary();
|
||||
if (searchQuery) applyMapFilter();
|
||||
}
|
||||
|
||||
/* --------------------------- Modal form --------------------------- */
|
||||
const modal = document.getElementById('modal');
|
||||
const form = document.getElementById('form');
|
||||
let pendingLayer = null; // layer baru hasil gambar (belum tersimpan)
|
||||
|
||||
function openModal(kind, mode, item) {
|
||||
const t = THEME[kind];
|
||||
document.getElementById('f-kind').value = kind;
|
||||
document.getElementById('f-id').value = item?.id || '';
|
||||
document.getElementById('modal-title').textContent =
|
||||
(mode === 'edit' ? 'Edit ' : 'Tambah ') + t.label;
|
||||
|
||||
const head = document.getElementById('modal-head');
|
||||
const saveBtn = document.getElementById('modal-save');
|
||||
const accent = kind === 'tanah' ? 'bg-emerald-600' : 'bg-red-600';
|
||||
head.className = 'px-5 py-4 text-white font-semibold flex items-center justify-between ' + accent;
|
||||
saveBtn.className = 'flex-1 py-2 rounded-lg text-white text-sm font-semibold ' +
|
||||
(kind === 'tanah' ? 'bg-emerald-600 hover:bg-emerald-700' : 'bg-red-600 hover:bg-red-700');
|
||||
|
||||
// tampilkan field sesuai jenis
|
||||
document.querySelectorAll('.kind-tanah').forEach((el) => el.classList.toggle('hidden', kind !== 'tanah'));
|
||||
document.querySelectorAll('.kind-jalan').forEach((el) => el.classList.toggle('hidden', kind !== 'jalan'));
|
||||
|
||||
// isi nilai
|
||||
document.getElementById('f-nama').value = item?.nama || '';
|
||||
document.getElementById('f-pemilik').value = item?.pemilik || '';
|
||||
document.getElementById('f-jenis').value = item?.jenis || '';
|
||||
document.getElementById('f-kategori-tanah').value = item?.kategori || '';
|
||||
document.getElementById('f-kategori-jalan').value = item?.kategori || '';
|
||||
document.getElementById('f-deskripsi').value = item?.deskripsi || '';
|
||||
document.getElementById('f-warna').value = item?.warna || t.color;
|
||||
document.getElementById('f-geojson').value = JSON.stringify(item.geojson);
|
||||
|
||||
// ukuran otomatis
|
||||
const m = measure(kind, item.geojson);
|
||||
const box = document.getElementById('f-measure');
|
||||
box.innerHTML = kind === 'tanah'
|
||||
? `<div><div class="text-xs text-slate-500">Luas</div><div class="font-bold text-emerald-700">${fmtArea(m.luas)}</div></div>
|
||||
<div><div class="text-xs text-slate-500">Keliling</div><div class="font-bold">${fmtLen(m.keliling)}</div></div>`
|
||||
: `<div class="col-span-2"><div class="text-xs text-slate-500">Panjang</div><div class="font-bold text-red-700">${fmtLen(m.panjang)}</div></div>`;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
setTimeout(() => document.getElementById('f-nama').focus(), 50);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.add('hidden');
|
||||
// bila ada layer gambar yang belum disimpan -> buang
|
||||
if (pendingLayer) { drawnItems.removeLayer(pendingLayer); pendingLayer = null; }
|
||||
}
|
||||
document.getElementById('modal-close').onclick = closeModal;
|
||||
document.getElementById('modal-cancel').onclick = closeModal;
|
||||
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const kind = document.getElementById('f-kind').value;
|
||||
const id = document.getElementById('f-id').value;
|
||||
const payload = {
|
||||
nama: document.getElementById('f-nama').value.trim(),
|
||||
deskripsi: document.getElementById('f-deskripsi').value.trim(),
|
||||
warna: document.getElementById('f-warna').value,
|
||||
geojson: JSON.parse(document.getElementById('f-geojson').value),
|
||||
};
|
||||
if (kind === 'tanah') {
|
||||
payload.pemilik = document.getElementById('f-pemilik').value.trim();
|
||||
payload.kategori = document.getElementById('f-kategori-tanah').value;
|
||||
} else {
|
||||
payload.jenis = document.getElementById('f-jenis').value;
|
||||
payload.kategori = document.getElementById('f-kategori-jalan').value;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('modal-save');
|
||||
btn.disabled = true; btn.textContent = 'Menyimpan...';
|
||||
try {
|
||||
await apiSave(kind, payload, id || null);
|
||||
pendingLayer = null; // sudah tersimpan; akan dirender ulang dari server
|
||||
modal.classList.add('hidden');
|
||||
await loadAll();
|
||||
switchTab(kind);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Simpan';
|
||||
}
|
||||
};
|
||||
|
||||
/* --------------------------- Aksi CRUD --------------------------- */
|
||||
function openEdit(kind, id) {
|
||||
const item = store[kind].items.find((i) => i.id === id);
|
||||
if (item) openModal(kind, 'edit', item);
|
||||
}
|
||||
|
||||
async function doDelete(kind, id) {
|
||||
const item = store[kind].items.find((i) => i.id === id);
|
||||
if (!confirm(`Hapus "${item?.nama}"?`)) return;
|
||||
try {
|
||||
await apiDelete(kind, id);
|
||||
removeFeatureLayer(kind, id);
|
||||
await loadAll();
|
||||
} catch (err) { alert(err.message); }
|
||||
}
|
||||
|
||||
/* --------------------------- Event Leaflet.draw --------------------------- */
|
||||
map.on(L.Draw.Event.CREATED, (e) => {
|
||||
const layer = e.layer;
|
||||
const kind = e.layerType === 'polygon' ? 'tanah' : 'jalan';
|
||||
pendingLayer = layer;
|
||||
drawnItems.addLayer(layer);
|
||||
const geojson = layer.toGeoJSON().geometry;
|
||||
openModal(kind, 'create', { geojson });
|
||||
});
|
||||
|
||||
// Edit geometri (toolbar edit Leaflet.draw)
|
||||
map.on(L.Draw.Event.EDITED, async (e) => {
|
||||
const jobs = [];
|
||||
e.layers.eachLayer((l) => {
|
||||
if (!l.feature_id) return;
|
||||
const kind = l.kind;
|
||||
const item = store[kind].items.find((i) => i.id === l.feature_id);
|
||||
if (!item) return;
|
||||
const geojson = l.toGeoJSON().geometry;
|
||||
jobs.push(apiSave(kind, {
|
||||
nama: item.nama, deskripsi: item.deskripsi, warna: item.warna,
|
||||
pemilik: item.pemilik, jenis: item.jenis, kategori: item.kategori, geojson,
|
||||
}, item.id));
|
||||
});
|
||||
try { await Promise.all(jobs); await loadAll(); }
|
||||
catch (err) { alert('Gagal menyimpan perubahan geometri: ' + err.message); await loadAll(); }
|
||||
});
|
||||
|
||||
/* --------------------------- Tab --------------------------- */
|
||||
function switchTab(kind) {
|
||||
activeTab = kind;
|
||||
document.querySelectorAll('.tab-btn').forEach((b) => {
|
||||
const on = b.dataset.tab === kind;
|
||||
b.classList.toggle('border-emerald-500', on && kind === 'tanah');
|
||||
b.classList.toggle('border-red-500', on && kind === 'jalan');
|
||||
b.classList.toggle('text-emerald-600', on && kind === 'tanah');
|
||||
b.classList.toggle('text-red-600', on && kind === 'jalan');
|
||||
b.classList.toggle('border-transparent', !on);
|
||||
b.classList.toggle('text-slate-500', !on);
|
||||
});
|
||||
document.getElementById('list-tanah').classList.toggle('hidden', kind !== 'tanah');
|
||||
document.getElementById('list-jalan').classList.toggle('hidden', kind !== 'jalan');
|
||||
}
|
||||
document.querySelectorAll('.tab-btn').forEach((b) => (b.onclick = () => switchTab(b.dataset.tab)));
|
||||
|
||||
/* --------------------------- Pencarian --------------------------- */
|
||||
const searchInput = document.getElementById('search');
|
||||
const searchClear = document.getElementById('search-clear');
|
||||
|
||||
function runSearch() {
|
||||
searchQuery = searchInput.value.trim().toLowerCase();
|
||||
searchClear.classList.toggle('hidden', !searchQuery);
|
||||
renderList('tanah');
|
||||
renderList('jalan');
|
||||
applyMapFilter();
|
||||
}
|
||||
searchInput.addEventListener('input', runSearch);
|
||||
searchClear.addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
searchInput.focus();
|
||||
runSearch();
|
||||
});
|
||||
|
||||
/* --------------------------- Util --------------------------- */
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
/* --------------------------- Start --------------------------- */
|
||||
loadAll().catch((err) => {
|
||||
console.error(err);
|
||||
alert('Gagal memuat data. Pastikan database sudah dibuat (import schema.sql).');
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
/**
|
||||
* Koneksi database (PDO).
|
||||
* Sesuaikan kredensial bila berbeda dari default Laragon.
|
||||
*/
|
||||
|
||||
const DB_HOST = '127.0.0.1';
|
||||
const DB_PORT = '3306';
|
||||
const DB_NAME = 'sig_mapping';
|
||||
const DB_USER = 'root';
|
||||
const DB_PASS = '';
|
||||
|
||||
function db(): PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
if ($pdo === null) {
|
||||
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_PORT, DB_NAME);
|
||||
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
}
|
||||
return $pdo;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
/**
|
||||
* Handler CRUD generik untuk fitur peta (tanah / jalan).
|
||||
*
|
||||
* $cfg = [
|
||||
* 'table' => nama tabel,
|
||||
* 'fields' => daftar kolom teks yang boleh diisi user (selain geometri/ukuran),
|
||||
* 'measure' => fungsi(array $geometry): array ukuran-ukuran yang dihitung server,
|
||||
* 'geomType' => 'Polygon' | 'LineString' (untuk validasi),
|
||||
* ]
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/database.php';
|
||||
require_once __DIR__ . '/geo.php';
|
||||
|
||||
function handle_crud(array $cfg): void
|
||||
{
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
if ($method === 'OPTIONS') {
|
||||
json_response(['ok' => true]);
|
||||
}
|
||||
|
||||
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
|
||||
|
||||
try {
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
$id ? crud_show($cfg, $id) : crud_list($cfg);
|
||||
break;
|
||||
case 'POST':
|
||||
crud_create($cfg);
|
||||
break;
|
||||
case 'PUT':
|
||||
crud_update($cfg, $id);
|
||||
break;
|
||||
case 'DELETE':
|
||||
crud_delete($cfg, $id);
|
||||
break;
|
||||
default:
|
||||
json_response(['error' => 'Method tidak didukung'], 405);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
json_response(['error' => 'Server error: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
function crud_list(array $cfg): void
|
||||
{
|
||||
$rows = db()->query("SELECT * FROM {$cfg['table']} ORDER BY id DESC")->fetchAll();
|
||||
foreach ($rows as &$r) {
|
||||
$r['geojson'] = json_decode($r['geojson'], true);
|
||||
}
|
||||
json_response(['data' => $rows]);
|
||||
}
|
||||
|
||||
function crud_show(array $cfg, int $id): void
|
||||
{
|
||||
$stmt = db()->prepare("SELECT * FROM {$cfg['table']} WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
json_response(['error' => 'Data tidak ditemukan'], 404);
|
||||
}
|
||||
$row['geojson'] = json_decode($row['geojson'], true);
|
||||
json_response(['data' => $row]);
|
||||
}
|
||||
|
||||
/** Validasi input & hitung ukuran. Mengembalikan [values, geojsonString]. */
|
||||
function crud_prepare(array $cfg, array $body): array
|
||||
{
|
||||
$geom = parse_geometry($body['geojson'] ?? null);
|
||||
if ($geom === null) {
|
||||
json_response(['error' => 'Geometri (geojson) tidak valid'], 422);
|
||||
}
|
||||
[$type, $coords] = $geom;
|
||||
if ($type !== $cfg['geomType']) {
|
||||
json_response(['error' => "Geometri harus bertipe {$cfg['geomType']}, diterima {$type}"], 422);
|
||||
}
|
||||
|
||||
$values = [];
|
||||
foreach ($cfg['fields'] as $f) {
|
||||
$values[$f] = isset($body[$f]) ? trim((string) $body[$f]) : null;
|
||||
}
|
||||
if (empty($values['nama'])) {
|
||||
json_response(['error' => 'Nama wajib diisi'], 422);
|
||||
}
|
||||
|
||||
// Warna default bila kosong.
|
||||
if (array_key_exists('warna', $values) && empty($values['warna'])) {
|
||||
$values['warna'] = $cfg['geomType'] === 'Polygon' ? '#22c55e' : '#ef4444';
|
||||
}
|
||||
|
||||
// Ukuran dihitung server (otoritatif).
|
||||
$measures = $cfg['measure']($geom);
|
||||
|
||||
$geojsonStr = json_encode(['type' => $type, 'coordinates' => $coords], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return [array_merge($values, $measures), $geojsonStr];
|
||||
}
|
||||
|
||||
function crud_create(array $cfg): void
|
||||
{
|
||||
[$values, $geojsonStr] = crud_prepare($cfg, read_json_body());
|
||||
|
||||
$cols = array_keys($values);
|
||||
$cols[] = 'geojson';
|
||||
$placeholders = implode(', ', array_fill(0, count($cols), '?'));
|
||||
$colList = implode(', ', $cols);
|
||||
|
||||
$params = array_values($values);
|
||||
$params[] = $geojsonStr;
|
||||
|
||||
$stmt = db()->prepare("INSERT INTO {$cfg['table']} ($colList) VALUES ($placeholders)");
|
||||
$stmt->execute($params);
|
||||
|
||||
crud_show($cfg, (int) db()->lastInsertId());
|
||||
}
|
||||
|
||||
function crud_update(array $cfg, int $id): void
|
||||
{
|
||||
if (!$id) {
|
||||
json_response(['error' => 'ID tidak valid'], 400);
|
||||
}
|
||||
[$values, $geojsonStr] = crud_prepare($cfg, read_json_body());
|
||||
|
||||
$sets = [];
|
||||
foreach (array_keys($values) as $c) {
|
||||
$sets[] = "$c = ?";
|
||||
}
|
||||
$sets[] = 'geojson = ?';
|
||||
|
||||
$params = array_values($values);
|
||||
$params[] = $geojsonStr;
|
||||
$params[] = $id;
|
||||
|
||||
$stmt = db()->prepare("UPDATE {$cfg['table']} SET " . implode(', ', $sets) . " WHERE id = ?");
|
||||
$stmt->execute($params);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
// Tetap kembalikan data terkini (mungkin tidak ada perubahan nilai).
|
||||
$check = db()->prepare("SELECT id FROM {$cfg['table']} WHERE id = ?");
|
||||
$check->execute([$id]);
|
||||
if (!$check->fetch()) {
|
||||
json_response(['error' => 'Data tidak ditemukan'], 404);
|
||||
}
|
||||
}
|
||||
crud_show($cfg, $id);
|
||||
}
|
||||
|
||||
function crud_delete(array $cfg, int $id): void
|
||||
{
|
||||
if (!$id) {
|
||||
json_response(['error' => 'ID tidak valid'], 400);
|
||||
}
|
||||
$stmt = db()->prepare("DELETE FROM {$cfg['table']} WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
if ($stmt->rowCount() === 0) {
|
||||
json_response(['error' => 'Data tidak ditemukan'], 404);
|
||||
}
|
||||
json_response(['ok' => true, 'deleted' => $id]);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
* Helper geometri & respons JSON.
|
||||
*
|
||||
* Perhitungan dilakukan di sisi server agar otoritatif:
|
||||
* - Panjang LineString -> haversine (meter)
|
||||
* - Luas / keliling Polygon -> rumus area geodesik bola (meter & m2)
|
||||
*
|
||||
* Koordinat mengikuti format GeoJSON: [longitude, latitude].
|
||||
*/
|
||||
|
||||
const EARTH_RADIUS = 6378137.0; // radius WGS84 (meter)
|
||||
|
||||
/** Kirim respons JSON lalu hentikan eksekusi. */
|
||||
function json_response($data, int $status = 200): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** Ambil & decode body JSON dari request. */
|
||||
function read_json_body(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/** Jarak haversine antar dua titik [lng, lat] dalam meter. */
|
||||
function haversine(array $a, array $b): float
|
||||
{
|
||||
$lon1 = deg2rad($a[0]);
|
||||
$lat1 = deg2rad($a[1]);
|
||||
$lon2 = deg2rad($b[0]);
|
||||
$lat2 = deg2rad($b[1]);
|
||||
|
||||
$dLat = $lat2 - $lat1;
|
||||
$dLon = $lon2 - $lon1;
|
||||
|
||||
$h = sin($dLat / 2) ** 2
|
||||
+ cos($lat1) * cos($lat2) * sin($dLon / 2) ** 2;
|
||||
|
||||
return 2 * EARTH_RADIUS * asin(min(1.0, sqrt($h)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Panjang total sebuah LineString (array of [lng, lat]) dalam meter.
|
||||
*/
|
||||
function line_length(array $coords): float
|
||||
{
|
||||
$total = 0.0;
|
||||
$n = count($coords);
|
||||
for ($i = 1; $i < $n; $i++) {
|
||||
$total += haversine($coords[$i - 1], $coords[$i]);
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Luas polygon geodesik (ring tunggal, array of [lng, lat]) dalam meter persegi.
|
||||
* Memakai pendekatan integral bola (mirip Google Maps computeSignedArea).
|
||||
*/
|
||||
function ring_area(array $ring): float
|
||||
{
|
||||
$n = count($ring);
|
||||
if ($n < 3) {
|
||||
return 0.0;
|
||||
}
|
||||
$area = 0.0;
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$p1 = $ring[$i];
|
||||
$p2 = $ring[($i + 1) % $n];
|
||||
$area += deg2rad($p2[0] - $p1[0])
|
||||
* (2 + sin(deg2rad($p1[1])) + sin(deg2rad($p2[1])));
|
||||
}
|
||||
$area = $area * EARTH_RADIUS * EARTH_RADIUS / 2.0;
|
||||
return abs($area);
|
||||
}
|
||||
|
||||
/** Keliling polygon (ring tunggal) dalam meter. */
|
||||
function ring_perimeter(array $ring): float
|
||||
{
|
||||
if (count($ring) < 2) {
|
||||
return 0.0;
|
||||
}
|
||||
// Pastikan ring tertutup untuk perhitungan keliling.
|
||||
$closed = $ring;
|
||||
if ($closed[0] !== $closed[count($closed) - 1]) {
|
||||
$closed[] = $closed[0];
|
||||
}
|
||||
return line_length($closed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validasi & ekstrak geometry GeoJSON.
|
||||
* Mengembalikan [type, coordinates] atau null bila tidak valid.
|
||||
*/
|
||||
function parse_geometry($geojson): ?array
|
||||
{
|
||||
if (is_string($geojson)) {
|
||||
$geojson = json_decode($geojson, true);
|
||||
}
|
||||
if (!is_array($geojson) || empty($geojson['type']) || !isset($geojson['coordinates'])) {
|
||||
return null;
|
||||
}
|
||||
return [$geojson['type'], $geojson['coordinates']];
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SIG — Mapping Tanah & Jalan</title>
|
||||
|
||||
<!-- TailwindCSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<!-- Leaflet.draw (point & click drawing) -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" />
|
||||
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
|
||||
|
||||
<style>
|
||||
#map { height: 100%; width: 100%; }
|
||||
.leaflet-draw-toolbar a { background-color: #fff; }
|
||||
/* Tooltip pengukuran saat menggambar */
|
||||
.leaflet-tooltip.measure-tip {
|
||||
background: #111827; color: #fff; border: 0; font-weight: 600;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.4);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-screen overflow-hidden bg-slate-100 text-slate-800">
|
||||
|
||||
<div class="flex h-full">
|
||||
|
||||
<!-- ============ SIDEBAR ============ -->
|
||||
<aside class="w-96 shrink-0 bg-white border-r border-slate-200 flex flex-col shadow-lg z-[1000]">
|
||||
<header class="px-5 py-4 bg-slate-900 text-white">
|
||||
<h1 class="text-lg font-bold flex items-center gap-2">
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full bg-emerald-400"></span>
|
||||
SIG Mapping
|
||||
</h1>
|
||||
<p class="text-xs text-slate-300 mt-0.5">Pemetaan Tanah & Jalan — point & click</p>
|
||||
</header>
|
||||
|
||||
<!-- Tombol Tambah -->
|
||||
<div class="px-4 py-3 border-b border-slate-200">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button id="add-tanah" type="button"
|
||||
class="flex items-center justify-center gap-1.5 py-2.5 rounded-lg bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm transition">
|
||||
<span class="text-base leading-none">+</span> Tambah Tanah
|
||||
</button>
|
||||
<button id="add-jalan" type="button"
|
||||
class="flex items-center justify-center gap-1.5 py-2.5 rounded-lg bg-red-600 hover:bg-red-700 active:bg-red-800 text-white text-sm font-semibold shadow-sm transition">
|
||||
<span class="text-base leading-none">+</span> Tambah Jalan
|
||||
</button>
|
||||
</div>
|
||||
<p id="draw-hint" class="mt-2 text-xs text-slate-500 text-center">
|
||||
Klik tombol lalu gambar di peta. <span class="text-slate-400">Luas & panjang otomatis.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pencarian -->
|
||||
<div class="px-4 py-3 border-b border-slate-200">
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">🔍</span>
|
||||
<input id="search" type="text" placeholder="Cari nama, pemilik, jenis…"
|
||||
class="w-full pl-9 pr-8 py-2 text-sm rounded-lg border border-slate-300 focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400 outline-none">
|
||||
<button id="search-clear" class="hidden absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab -->
|
||||
<div class="flex border-b border-slate-200 text-sm font-medium">
|
||||
<button data-tab="tanah" class="tab-btn flex-1 py-2.5 border-b-2 border-emerald-500 text-emerald-600">
|
||||
🟩 Tanah <span id="count-tanah" class="ml-1 text-xs bg-emerald-100 text-emerald-700 rounded-full px-1.5">0</span>
|
||||
</button>
|
||||
<button data-tab="jalan" class="tab-btn flex-1 py-2.5 border-b-2 border-transparent text-slate-500 hover:text-slate-700">
|
||||
🟥 Jalan <span id="count-jalan" class="ml-1 text-xs bg-red-100 text-red-700 rounded-full px-1.5">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Daftar -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<ul id="list-tanah" class="tab-panel divide-y divide-slate-100"></ul>
|
||||
<ul id="list-jalan" class="tab-panel hidden divide-y divide-slate-100"></ul>
|
||||
</div>
|
||||
|
||||
<!-- Ringkasan -->
|
||||
<footer class="px-5 py-3 border-t border-slate-200 bg-slate-50 text-xs text-slate-600 space-y-1">
|
||||
<div class="flex justify-between"><span>Total luas tanah</span><b id="sum-luas">0 m²</b></div>
|
||||
<div class="flex justify-between"><span>Total panjang jalan</span><b id="sum-panjang">0 m</b></div>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<!-- ============ PETA ============ -->
|
||||
<main class="relative flex-1">
|
||||
<div id="map"></div>
|
||||
<!-- badge pengukuran live -->
|
||||
<div id="live-measure"
|
||||
class="hidden absolute top-3 left-1/2 -translate-x-1/2 z-[1000] bg-slate-900 text-white text-sm font-semibold px-4 py-2 rounded-lg shadow-lg">
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ============ MODAL FORM ============ -->
|
||||
<div id="modal" class="hidden fixed inset-0 z-[2000] flex items-center justify-center bg-black/40 p-4">
|
||||
<div class="bg-white w-full max-w-md rounded-xl shadow-2xl overflow-hidden">
|
||||
<div id="modal-head" class="px-5 py-4 text-white font-semibold flex items-center justify-between">
|
||||
<span id="modal-title">Simpan Data</span>
|
||||
<button id="modal-close" class="text-white/80 hover:text-white text-xl leading-none">×</button>
|
||||
</div>
|
||||
<form id="form" class="p-5 space-y-4">
|
||||
<input type="hidden" id="f-id">
|
||||
<input type="hidden" id="f-kind">
|
||||
<input type="hidden" id="f-geojson">
|
||||
|
||||
<!-- Ukuran otomatis -->
|
||||
<div id="f-measure" class="rounded-lg bg-slate-50 border border-slate-200 p-3 text-sm grid grid-cols-2 gap-2"></div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Nama <span class="text-red-500">*</span></label>
|
||||
<input id="f-nama" required class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400 outline-none">
|
||||
</div>
|
||||
|
||||
<!-- field khusus tanah -->
|
||||
<div class="kind-tanah">
|
||||
<label class="block text-sm font-medium mb-1">Pemilik</label>
|
||||
<input id="f-pemilik" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-400 outline-none">
|
||||
</div>
|
||||
<div class="kind-tanah">
|
||||
<label class="block text-sm font-medium mb-1">Kategori / Status Tanah</label>
|
||||
<select id="f-kategori-tanah" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-400 outline-none">
|
||||
<option value="">- pilih -</option>
|
||||
<option value="Sertifikat Hak Milik (SHM)">Sertifikat Hak Milik (SHM)</option>
|
||||
<option value="Sertifikat Hak Guna Bangunan (HGB)">Sertifikat Hak Guna Bangunan (HGB)</option>
|
||||
<option value="Sertifikat Hak Guna Usaha (HGU)">Sertifikat Hak Guna Usaha (HGU)</option>
|
||||
<option value="Sertifikat Hak Pakai (HP)">Sertifikat Hak Pakai (HP)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- field khusus jalan -->
|
||||
<div class="kind-jalan hidden">
|
||||
<label class="block text-sm font-medium mb-1">Kategori Jalan</label>
|
||||
<select id="f-kategori-jalan" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:ring-2 focus:ring-red-400 outline-none">
|
||||
<option value="">- pilih -</option>
|
||||
<option value="Jalan Nasional">Jalan Nasional</option>
|
||||
<option value="Jalan Provinsi">Jalan Provinsi</option>
|
||||
<option value="Jalan Kabupaten">Jalan Kabupaten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="kind-jalan hidden">
|
||||
<label class="block text-sm font-medium mb-1">Jenis Jalan</label>
|
||||
<select id="f-jenis" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:ring-2 focus:ring-red-400 outline-none">
|
||||
<option value="">- pilih -</option>
|
||||
<option>Aspal</option>
|
||||
<option>Beton / Cor</option>
|
||||
<option>Paving</option>
|
||||
<option>Tanah / Kerikil</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Deskripsi</label>
|
||||
<textarea id="f-deskripsi" rows="2" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-400 outline-none"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Warna</label>
|
||||
<input id="f-warna" type="color" value="#22c55e" class="h-9 w-16 rounded border border-slate-300 cursor-pointer">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button type="button" id="modal-cancel" class="flex-1 py-2 rounded-lg border border-slate-300 text-slate-600 hover:bg-slate-50 text-sm font-medium">Batal</button>
|
||||
<button type="submit" id="modal-save" class="flex-1 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold">Simpan</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/app.js?v=<?php echo filemtime(__DIR__ . '/assets/app.js'); ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
-- ============================================================
|
||||
-- SIG Mapping Tanah & Jalan - Skema Database
|
||||
-- MySQL 8.x
|
||||
-- ============================================================
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS sig_mapping
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE sig_mapping;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Tabel TANAH (disimpan sebagai polygon)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS tanah (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
nama VARCHAR(150) NOT NULL,
|
||||
pemilik VARCHAR(150) NULL,
|
||||
kategori VARCHAR(80) NULL, -- status: SHM, HGB, HGU, HP
|
||||
deskripsi TEXT NULL,
|
||||
luas DOUBLE NOT NULL DEFAULT 0, -- meter persegi (m2)
|
||||
keliling DOUBLE NOT NULL DEFAULT 0, -- meter (m)
|
||||
warna VARCHAR(20) NOT NULL DEFAULT '#22c55e',
|
||||
geojson JSON NOT NULL, -- GeoJSON geometry (Polygon)
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Tabel JALAN (disimpan sebagai polyline / linestring)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS jalan (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
nama VARCHAR(150) NOT NULL,
|
||||
jenis VARCHAR(50) NULL, -- mis. Aspal, Beton, Tanah
|
||||
kategori VARCHAR(80) NULL, -- Jalan Nasional, Jalan Provinsi, Jalan Kabupaten
|
||||
deskripsi TEXT NULL,
|
||||
panjang DOUBLE NOT NULL DEFAULT 0, -- meter (m)
|
||||
warna VARCHAR(20) NOT NULL DEFAULT '#ef4444',
|
||||
geojson JSON NOT NULL, -- GeoJSON geometry (LineString)
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Migrasi: tambah kolom kategori bila tabel lama belum punya.
|
||||
-- MySQL 8 tidak mendukung "ADD COLUMN IF NOT EXISTS", jadi dicek dulu
|
||||
-- via information_schema. Aman dijalankan berulang.
|
||||
-- ------------------------------------------------------------
|
||||
DROP PROCEDURE IF EXISTS add_kategori_columns;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE add_kategori_columns()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tanah' AND COLUMN_NAME = 'kategori'
|
||||
) THEN
|
||||
ALTER TABLE tanah ADD COLUMN kategori VARCHAR(80) NULL AFTER pemilik;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'jalan' AND COLUMN_NAME = 'kategori'
|
||||
) THEN
|
||||
ALTER TABLE jalan ADD COLUMN kategori VARCHAR(80) NULL AFTER jenis;
|
||||
END IF;
|
||||
END //
|
||||
DELIMITER ;
|
||||
CALL add_kategori_columns();
|
||||
DROP PROCEDURE add_kategori_columns;
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
/**
|
||||
* Seeder data contoh — area Pontianak, Kalimantan Barat.
|
||||
* Jalankan: php seed.php (atau buka http://localhost/sig-02/seed.php)
|
||||
*
|
||||
* Menghitung luas/keliling/panjang memakai fungsi yang sama dengan aplikasi
|
||||
* agar konsisten, lalu insert ke database.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/config/database.php';
|
||||
require_once __DIR__ . '/includes/geo.php';
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// Bersihkan data lama (opsional). Komentari bila ingin menambah saja.
|
||||
$pdo->exec('DELETE FROM tanah');
|
||||
$pdo->exec('DELETE FROM jalan');
|
||||
$pdo->exec('ALTER TABLE tanah AUTO_INCREMENT = 1');
|
||||
$pdo->exec('ALTER TABLE jalan AUTO_INCREMENT = 1');
|
||||
|
||||
/* ============================ TANAH (Polygon) ============================
|
||||
* Koordinat GeoJSON: [longitude, latitude]
|
||||
*/
|
||||
$tanah = [
|
||||
[
|
||||
'nama' => 'Kawasan Tugu Khatulistiwa',
|
||||
'pemilik' => 'Pemkot Pontianak',
|
||||
'kategori' => 'Sertifikat Hak Pakai (HP)',
|
||||
'deskripsi' => 'Area landmark Tugu Khatulistiwa, Pontianak Utara.',
|
||||
'warna' => '#16a34a',
|
||||
'ring' => [
|
||||
[109.3215, -0.0005], [109.3232, -0.0005],
|
||||
[109.3232, -0.0022], [109.3215, -0.0022], [109.3215, -0.0005],
|
||||
],
|
||||
],
|
||||
[
|
||||
'nama' => 'Lahan Alun-Alun Kapuas',
|
||||
'pemilik' => 'Pemkot Pontianak',
|
||||
'kategori' => 'Sertifikat Hak Pakai (HP)',
|
||||
'deskripsi' => 'Ruang terbuka di tepi Sungai Kapuas, dekat Pelabuhan.',
|
||||
'warna' => '#22c55e',
|
||||
'ring' => [
|
||||
[109.3438, -0.0265], [109.3452, -0.0263],
|
||||
[109.3454, -0.0276], [109.3440, -0.0279], [109.3438, -0.0265],
|
||||
],
|
||||
],
|
||||
[
|
||||
'nama' => 'Blok Permukiman Sungai Jawi',
|
||||
'pemilik' => 'Warga RW 04',
|
||||
'kategori' => 'Sertifikat Hak Milik (SHM)',
|
||||
'deskripsi' => 'Petak permukiman padat di Kecamatan Pontianak Kota.',
|
||||
'warna' => '#15803d',
|
||||
'ring' => [
|
||||
[109.3290, -0.0360], [109.3312, -0.0358],
|
||||
[109.3314, -0.0378], [109.3292, -0.0380], [109.3290, -0.0360],
|
||||
],
|
||||
],
|
||||
[
|
||||
'nama' => 'Kawasan Komersial Jl. Gajah Mada',
|
||||
'pemilik' => 'Swasta',
|
||||
'kategori' => 'Sertifikat Hak Guna Bangunan (HGB)',
|
||||
'deskripsi' => 'Blok pertokoan / ruko di koridor Jalan Gajah Mada.',
|
||||
'warna' => '#65a30d',
|
||||
'ring' => [
|
||||
[109.3360, -0.0310], [109.3378, -0.0312],
|
||||
[109.3376, -0.0328], [109.3358, -0.0326], [109.3360, -0.0310],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/* ============================ JALAN (LineString) ============================ */
|
||||
$jalan = [
|
||||
[
|
||||
'nama' => 'Jl. Ahmad Yani',
|
||||
'jenis' => 'Aspal',
|
||||
'kategori' => 'Jalan Nasional',
|
||||
'deskripsi' => 'Jalan arteri utama Kota Pontianak.',
|
||||
'warna' => '#ef4444',
|
||||
'coords' => [
|
||||
[109.3260, -0.0540], [109.3300, -0.0470],
|
||||
[109.3345, -0.0400], [109.3390, -0.0330],
|
||||
],
|
||||
],
|
||||
[
|
||||
'nama' => 'Jl. Gajah Mada',
|
||||
'jenis' => 'Aspal',
|
||||
'kategori' => 'Jalan Kabupaten',
|
||||
'deskripsi' => 'Koridor pusat kuliner & perdagangan.',
|
||||
'warna' => '#dc2626',
|
||||
'coords' => [
|
||||
[109.3352, -0.0300], [109.3372, -0.0335], [109.3390, -0.0368],
|
||||
],
|
||||
],
|
||||
[
|
||||
'nama' => 'Jl. Tanjungpura',
|
||||
'jenis' => 'Aspal',
|
||||
'kategori' => 'Jalan Provinsi',
|
||||
'deskripsi' => 'Menghubungkan pusat kota ke kawasan Kapuas.',
|
||||
'warna' => '#f97316',
|
||||
'coords' => [
|
||||
[109.3400, -0.0270], [109.3415, -0.0300], [109.3428, -0.0330],
|
||||
],
|
||||
],
|
||||
[
|
||||
'nama' => 'Jl. Sultan Abdurrahman',
|
||||
'jenis' => 'Beton / Cor',
|
||||
'kategori' => 'Jalan Kabupaten',
|
||||
'deskripsi' => 'Akses menuju kawasan Sungai Jawi.',
|
||||
'warna' => '#b91c1c',
|
||||
'coords' => [
|
||||
[109.3280, -0.0350], [109.3320, -0.0345], [109.3360, -0.0342],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/* ============================ INSERT ============================ */
|
||||
$stmtT = $pdo->prepare(
|
||||
'INSERT INTO tanah (nama, pemilik, kategori, deskripsi, luas, keliling, warna, geojson)
|
||||
VALUES (?,?,?,?,?,?,?,?)'
|
||||
);
|
||||
foreach ($tanah as $t) {
|
||||
$geo = ['type' => 'Polygon', 'coordinates' => [$t['ring']]];
|
||||
$stmtT->execute([
|
||||
$t['nama'], $t['pemilik'], $t['kategori'], $t['deskripsi'],
|
||||
round(ring_area($t['ring']), 2),
|
||||
round(ring_perimeter($t['ring']), 2),
|
||||
$t['warna'],
|
||||
json_encode($geo, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
}
|
||||
|
||||
$stmtJ = $pdo->prepare(
|
||||
'INSERT INTO jalan (nama, jenis, kategori, deskripsi, panjang, warna, geojson)
|
||||
VALUES (?,?,?,?,?,?,?)'
|
||||
);
|
||||
foreach ($jalan as $j) {
|
||||
$geo = ['type' => 'LineString', 'coordinates' => $j['coords']];
|
||||
$stmtJ->execute([
|
||||
$j['nama'], $j['jenis'], $j['kategori'], $j['deskripsi'],
|
||||
round(line_length($j['coords']), 2),
|
||||
$j['warna'],
|
||||
json_encode($geo, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
}
|
||||
|
||||
$msg = sprintf("Seeding selesai: %d tanah, %d jalan (area Pontianak).", count($tanah), count($jalan));
|
||||
|
||||
if (PHP_SAPI === 'cli') {
|
||||
echo $msg . PHP_EOL;
|
||||
} else {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo $msg . "\nBuka kembali index.php untuk melihat hasilnya.";
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
-- ============================================================
|
||||
-- WebGIS BantSOSial — Database Schema
|
||||
-- Database: sig_bansos
|
||||
-- ============================================================
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS sig_bansos
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE sig_bansos;
|
||||
|
||||
-- ============================================================
|
||||
-- Table: religious_centers
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS religious_centers (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
address TEXT,
|
||||
kas DECIMAL(15,2) DEFAULT 0,
|
||||
latitude DOUBLE NOT NULL,
|
||||
longitude DOUBLE NOT NULL,
|
||||
radius INT DEFAULT 300,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ============================================================
|
||||
-- Table: houses
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS houses (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
latitude DOUBLE NOT NULL,
|
||||
longitude DOUBLE NOT NULL,
|
||||
address TEXT,
|
||||
rt VARCHAR(10) DEFAULT '',
|
||||
rw VARCHAR(10) DEFAULT '',
|
||||
kelurahan VARCHAR(100) DEFAULT '',
|
||||
status_miskin ENUM('sangat_miskin','miskin','tidak_miskin','') DEFAULT '',
|
||||
jumlah_anggota INT DEFAULT 0,
|
||||
anggota JSON,
|
||||
aid_status ENUM('helped','not_helped','outside') DEFAULT 'outside',
|
||||
has_data TINYINT(1) DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ============================================================
|
||||
-- Table: laporan
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS laporan (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
pelapor VARCHAR(150) DEFAULT 'Anonim',
|
||||
deskripsi TEXT NOT NULL,
|
||||
lokasi VARCHAR(255) DEFAULT '',
|
||||
foto_base64 MEDIUMTEXT,
|
||||
status ENUM('baru','ditangani','selesai') DEFAULT 'baru',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ============================================================
|
||||
-- Table: aid_logs
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS aid_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
house_id INT NOT NULL,
|
||||
religious_center_id INT DEFAULT 0,
|
||||
status ENUM('helped','reverted') NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (house_id) REFERENCES houses(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
require_once 'koneksi.php';
|
||||
|
||||
$response = ['success' => true, 'centers' => [], 'houses' => [], 'reports' => []];
|
||||
|
||||
$result = $conn->query("SELECT * FROM religious_centers ORDER BY id ASC");
|
||||
if ($result) {
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$response['centers'][] = [
|
||||
'id' => (int)$row['id'],
|
||||
'name' => $row['name'],
|
||||
'address' => $row['address'],
|
||||
'kas' => (float)($row['kas'] ?? 0),
|
||||
'latitude' => (float)$row['latitude'],
|
||||
'longitude' => (float)$row['longitude'],
|
||||
'radius' => (int)$row['radius']
|
||||
];
|
||||
}
|
||||
$result->free();
|
||||
}
|
||||
|
||||
$result = $conn->query("SELECT * FROM houses ORDER BY id ASC");
|
||||
if ($result) {
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$anggota = [];
|
||||
if (!empty($row['anggota'])) {
|
||||
$decoded = json_decode($row['anggota'], true);
|
||||
if (is_array($decoded)) $anggota = $decoded;
|
||||
}
|
||||
$response['houses'][] = [
|
||||
'id' => (int)$row['id'],
|
||||
'latitude' => (float)$row['latitude'],
|
||||
'longitude' => (float)$row['longitude'],
|
||||
'address' => $row['address'] ?? '',
|
||||
'rt' => $row['rt'] ?? '',
|
||||
'rw' => $row['rw'] ?? '',
|
||||
'kelurahan' => $row['kelurahan'] ?? '',
|
||||
'status_miskin' => $row['status_miskin'] ?? '',
|
||||
'jumlah_anggota' => (int)($row['jumlah_anggota'] ?? 0),
|
||||
'anggota' => $anggota,
|
||||
'aid_status' => $row['aid_status'] ?? 'outside',
|
||||
'has_data' => (int)($row['has_data'] ?? 0)
|
||||
];
|
||||
}
|
||||
$result->free();
|
||||
}
|
||||
|
||||
$result = $conn->query("SELECT * FROM laporan ORDER BY created_at DESC LIMIT 100");
|
||||
if ($result) {
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$response['reports'][] = [
|
||||
'id' => (int)$row['id'],
|
||||
'name' => $row['pelapor'] ?? 'Anonim',
|
||||
'text' => $row['deskripsi'],
|
||||
'lokasi' => $row['lokasi'] ?? '',
|
||||
'imgBase64' => $row['foto_base64'] ?? null,
|
||||
'status' => $row['status'] ?? 'baru',
|
||||
'time' => date('d/m/Y H:i', strtotime($row['created_at']))
|
||||
];
|
||||
}
|
||||
$result->free();
|
||||
}
|
||||
|
||||
echo json_encode($response);
|
||||
$conn->close();
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
require_once 'koneksi.php';
|
||||
|
||||
$id = intval($_POST['id'] ?? 0);
|
||||
if ($id < 1) { echo json_encode(['success' => false, 'message' => 'ID tidak valid']); exit; }
|
||||
|
||||
if ($conn->query("DELETE FROM laporan WHERE id=$id")) {
|
||||
echo json_encode(['success' => true, 'id' => $id]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $conn->error]);
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
require_once 'koneksi.php';
|
||||
|
||||
$id = intval($_POST['id'] ?? 0);
|
||||
if ($id < 1) { echo json_encode(['success' => false, 'message' => 'ID tidak valid']); exit; }
|
||||
|
||||
$conn->query("DELETE FROM aid_logs WHERE religious_center_id=$id");
|
||||
|
||||
if ($conn->query("DELETE FROM religious_centers WHERE id=$id")) {
|
||||
echo json_encode(['success' => true, 'id' => $id]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $conn->error]);
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
require_once 'koneksi.php';
|
||||
|
||||
$id = intval($_POST['id'] ?? 0);
|
||||
if ($id < 1) { echo json_encode(['success' => false, 'message' => 'ID tidak valid']); exit; }
|
||||
|
||||
if ($conn->query("DELETE FROM houses WHERE id=$id")) {
|
||||
echo json_encode(['success' => true, 'id' => $id]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $conn->error]);
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,381 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BantSOSial GIS — Distribusi Bantuan Sosial</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css"/>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- =====================================================================
|
||||
NAVBAR
|
||||
===================================================================== -->
|
||||
<nav id="topNav">
|
||||
<div class="nav-brand">
|
||||
<span class="nav-brand-icon">🕌</span>
|
||||
<div>
|
||||
<div class="nav-brand-title">BantSOSial GIS</div>
|
||||
<div class="nav-brand-sub">Distribusi Bantuan Sosial</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-menu">
|
||||
<button class="nav-item active" id="navHome" onclick="navigateTo('map')">
|
||||
<span class="nav-item-icon">🗺</span>
|
||||
<span class="nav-item-label">Peta</span>
|
||||
</button>
|
||||
<button class="nav-item" id="navReport" onclick="navigateTo('report')">
|
||||
<span class="nav-item-icon">📢</span>
|
||||
<span class="nav-item-label">Pelaporan</span>
|
||||
<span class="nav-badge hidden" id="navReportBadge">0</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div id="roleIndicator" class="role-pill">
|
||||
<span id="roleIcon">👑</span>
|
||||
<span id="roleLabel">Admin</span>
|
||||
</div>
|
||||
<button id="roleSwitchBtn" onclick="openRoleModal()">Ganti Role ▾</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- =====================================================================
|
||||
ROLE MODAL
|
||||
===================================================================== -->
|
||||
<div id="roleModal" class="modal-overlay hidden">
|
||||
<div class="modal-box" style="max-width:440px">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">🔐 Pilih Role Pengguna</div>
|
||||
<button class="modal-close" onclick="closeRoleModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:16px">
|
||||
<p style="font-size:12px;color:var(--text-muted);margin-bottom:14px">Pilih role sesuai hak akses Anda. Perubahan langsung berlaku.</p>
|
||||
<div class="role-cards">
|
||||
<div class="role-card" id="roleCard_admin" onclick="setRole('admin')">
|
||||
<div class="role-card-icon">👑</div>
|
||||
<div class="role-card-name">Admin</div>
|
||||
<div class="role-card-desc">Akses penuh — tambah, edit, hapus, dan kelola semua data</div>
|
||||
<div class="role-card-perms">
|
||||
<span class="perm perm-green">✓ Tambah & Edit</span>
|
||||
<span class="perm perm-green">✓ Hapus & Reset</span>
|
||||
<span class="perm perm-green">✓ Lihat laporan</span>
|
||||
<span class="perm perm-green">✓ Kirim laporan</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="role-card" id="roleCard_surveyer" onclick="setRole('surveyer')">
|
||||
<div class="role-card-icon">📋</div>
|
||||
<div class="role-card-name">Surveyer</div>
|
||||
<div class="role-card-desc">Input data & lihat laporan — tidak bisa hapus atau reset</div>
|
||||
<div class="role-card-perms">
|
||||
<span class="perm perm-green">✓ Tambah & Edit</span>
|
||||
<span class="perm perm-red">✕ Hapus & Reset</span>
|
||||
<span class="perm perm-green">✓ Lihat laporan</span>
|
||||
<span class="perm perm-green">✓ Kirim laporan</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="role-card" id="roleCard_viewer" onclick="setRole('viewer')">
|
||||
<div class="role-card-icon">👁</div>
|
||||
<div class="role-card-name">Viewer</div>
|
||||
<div class="role-card-desc">Hanya lihat peta & kirim laporan masyarakat</div>
|
||||
<div class="role-card-perms">
|
||||
<span class="perm perm-green">✓ Lihat peta</span>
|
||||
<span class="perm perm-red">✕ Edit data</span>
|
||||
<span class="perm perm-red">✕ Lihat laporan</span>
|
||||
<span class="perm perm-green">✓ Kirim laporan</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =====================================================================
|
||||
PAGE: PETA
|
||||
===================================================================== -->
|
||||
<div id="pagePeta" class="page active-page">
|
||||
|
||||
<div id="sidebar">
|
||||
<div id="modeIndicator" class="mode-badge hidden">
|
||||
<span class="pulse-dot"></span>
|
||||
<span id="modeIndicatorText">Klik peta untuk menempatkan titik</span>
|
||||
</div>
|
||||
|
||||
<div id="viewerNotice" style="display:none;margin:10px 14px 0;padding:10px 12px;background:#f3e8ff;border:1px solid #c4b5fd;border-radius:8px;font-size:11px;color:#5b21b6;font-weight:500;text-align:center;">
|
||||
👁 Mode Viewer — Anda hanya dapat melihat data
|
||||
</div>
|
||||
|
||||
<div class="btn-group" id="actionBtnGroup">
|
||||
<button id="btnAddCenter" onclick="startAddingCenter()">🕌 Tambah Rumah Ibadah</button>
|
||||
<button id="btnAddHouse" onclick="startAddingHouse()">🏠 Tambah Rumah Miskin</button>
|
||||
</div>
|
||||
|
||||
<div class="stat-panel">
|
||||
<div class="stat-title">📊 Statistik Bantuan</div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-num" id="statTotal">0</div>
|
||||
<div class="stat-label">Total Rumah</div>
|
||||
</div>
|
||||
<div class="stat-card red">
|
||||
<div class="stat-num" id="statInside">0</div>
|
||||
<div class="stat-label">Dalam Radius</div>
|
||||
</div>
|
||||
<div class="stat-card yellow">
|
||||
<div class="stat-num" id="statHelped">0</div>
|
||||
<div class="stat-label">Sudah Dibantu</div>
|
||||
</div>
|
||||
<div class="stat-card green">
|
||||
<div class="stat-num" id="statOutside">0</div>
|
||||
<div class="stat-label">Luar Radius</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
🕌 Rumah Ibadah
|
||||
<span id="centerCount" class="badge badge-teal">0</span>
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<input type="text" id="searchCenter" class="search-input" placeholder="🔍 Cari nama..." oninput="filterCenters()"/>
|
||||
</div>
|
||||
<select id="sortCenter" class="filter-select" onchange="filterCenters()">
|
||||
<option value="">— Urutkan —</option>
|
||||
<option value="kas_desc">💰 Kas terbanyak</option>
|
||||
<option value="kas_asc">💰 Kas terkecil</option>
|
||||
<option value="tanggungan_desc">👨👩👧 Tanggungan terbanyak</option>
|
||||
<option value="tanggungan_asc">👨👩👧 Tanggungan terkecil</option>
|
||||
</select>
|
||||
<div id="centerList"><div class="empty-state">Belum ada rumah ibadah.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
🏠 Data Rumah Miskin
|
||||
<span id="houseCount" class="badge badge-orange">0</span>
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<input type="text" id="searchHouse" class="search-input" placeholder="🔍 Cari nama KK..." oninput="filterHouses()"/>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<select id="filterStatus" class="filter-select" onchange="filterHouses()">
|
||||
<option value="">Semua Status</option>
|
||||
<option value="helped">✅ Sudah dibantu</option>
|
||||
<option value="not_helped">⏳ Belum dibantu</option>
|
||||
<option value="outside">🟢 Luar radius</option>
|
||||
</select>
|
||||
<select id="filterMiskin" class="filter-select" onchange="filterHouses()">
|
||||
<option value="">Semua Kemiskinan</option>
|
||||
<option value="sangat_miskin">😢 Sangat Miskin</option>
|
||||
<option value="miskin">😟 Miskin</option>
|
||||
<option value="tidak_miskin">😊 Tidak Miskin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="houseList"><div class="empty-state">Belum ada data rumah miskin.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">🗺 Legenda</div>
|
||||
<div class="legend-list">
|
||||
<div class="legend-item"><span style="font-size:13px">🕌</span> Masjid / Musholla</div>
|
||||
<div class="legend-item"><span style="font-size:13px">⛪</span> Gereja Katolik</div>
|
||||
<div class="legend-item"><span style="font-size:13px">✝️</span> Gereja Protestan</div>
|
||||
<div class="legend-item"><span style="font-size:13px">🛕</span> Vihara / Klenteng / Pura</div>
|
||||
<div class="legend-item"><span class="legend-dot green"></span> Luar radius</div>
|
||||
<div class="legend-item"><span class="legend-dot red"></span> Dalam radius (belum dibantu)</div>
|
||||
<div class="legend-item"><span class="legend-dot yellow"></span> Sudah dibantu</div>
|
||||
<div class="legend-item"><span class="legend-circle"></span> Area jangkauan</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="btnReset" class="btn-danger" onclick="confirmReset()">🗑 Reset Semua Data</button>
|
||||
</div>
|
||||
|
||||
<div id="map"></div>
|
||||
<div id="radiusTooltip" class="radius-tooltip hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- =====================================================================
|
||||
PAGE: PELAPORAN
|
||||
===================================================================== -->
|
||||
<div id="pagePelaporan" class="page">
|
||||
<div class="report-page">
|
||||
|
||||
<div class="report-col report-col-form">
|
||||
<div class="report-col-header">
|
||||
<div class="report-col-title">📢 Kirim Laporan</div>
|
||||
<div class="report-col-sub">Laporkan kondisi kemiskinan yang belum terdata</div>
|
||||
</div>
|
||||
<div class="report-form-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Deskripsi Laporan *</label>
|
||||
<textarea id="reportText" class="form-input form-textarea" rows="5"
|
||||
placeholder="Contoh: Ada keluarga miskin di Jl. Merdeka No.5 yang belum terdata..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nama Pelapor (opsional)</label>
|
||||
<input type="text" id="reportName" class="form-input" placeholder="Nama Anda..."/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Lokasi / Alamat (opsional)</label>
|
||||
<input type="text" id="reportLocation" class="form-input" placeholder="Jl. Contoh No.1, Kelurahan..."/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Foto / Bukti (opsional)</label>
|
||||
<div class="upload-area" id="uploadArea" onclick="document.getElementById('reportImg').click()">
|
||||
<div class="upload-icon">📷</div>
|
||||
<div class="upload-text">Klik untuk upload foto</div>
|
||||
<div class="upload-sub">JPG, PNG, maks 5MB</div>
|
||||
<input type="file" id="reportImg" accept="image/*" style="display:none" onchange="previewImage(event)"/>
|
||||
</div>
|
||||
<div id="imgPreview" style="display:none;margin-top:8px;">
|
||||
<img id="previewImg" style="width:100%;border-radius:8px;max-height:200px;object-fit:cover;"/>
|
||||
<button class="remove-img-btn" onclick="removeImage()">✕ Hapus foto</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="form-submit" onclick="submitReport()" style="margin-top:4px;padding:12px;font-size:13px;">
|
||||
📤 Kirim Laporan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="report-col report-col-list" id="reportColList">
|
||||
<div class="report-col-header">
|
||||
<div class="report-col-title">
|
||||
📋 Laporan Masuk
|
||||
<span id="reportCount" class="badge badge-orange" style="margin-left:8px">0</span>
|
||||
</div>
|
||||
<div class="report-col-sub">Daftar laporan dari masyarakat</div>
|
||||
</div>
|
||||
<div class="report-list-toolbar">
|
||||
<input type="text" id="searchReport" class="search-input" placeholder="🔍 Cari laporan..." oninput="filterReports()" style="flex:1"/>
|
||||
<select id="filterReportStatus" class="filter-select" onchange="filterReports()" style="width:auto;margin:0">
|
||||
<option value="">Semua</option>
|
||||
<option value="baru">🆕 Baru</option>
|
||||
<option value="ditangani">🔄 Ditangani</option>
|
||||
<option value="selesai">✅ Selesai</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="reportList" class="report-list-container">
|
||||
<div class="empty-state" style="margin-top:20px">Belum ada laporan masuk.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="report-col report-col-viewer" id="reportColViewer" style="display:none">
|
||||
<div class="report-col-header">
|
||||
<div class="report-col-title">👁 Mode Viewer</div>
|
||||
<div class="report-col-sub">Daftar laporan hanya untuk Admin dan Surveyer</div>
|
||||
</div>
|
||||
<div style="padding:40px 20px;text-align:center">
|
||||
<div style="font-size:48px;margin-bottom:16px">🔒</div>
|
||||
<div style="font-size:14px;font-weight:600;color:var(--text);margin-bottom:8px">Akses Terbatas</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);line-height:1.6">
|
||||
Sebagai Viewer, Anda dapat mengirim laporan tapi tidak dapat melihat daftar laporan yang masuk.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =====================================================================
|
||||
MODAL — FORM DATA RUMAH
|
||||
===================================================================== -->
|
||||
<div id="houseModal" class="modal-overlay hidden">
|
||||
<div class="modal-box">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">🏠 Data Kemiskinan</div>
|
||||
<button class="modal-close" onclick="closeHouseModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="houseModalBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =====================================================================
|
||||
MODAL — DETAIL RUMAH
|
||||
===================================================================== -->
|
||||
<div id="detailModal" class="modal-overlay hidden">
|
||||
<div class="modal-box modal-wide">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">📋 Detail Data Rumah</div>
|
||||
<button class="modal-close" onclick="closeDetailModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="detailModalBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =====================================================================
|
||||
MODAL — EDIT RUMAH IBADAH
|
||||
===================================================================== -->
|
||||
<div id="centerEditModal" class="modal-overlay hidden">
|
||||
<div class="modal-box" style="max-width:460px">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">🕌 Edit Rumah Ibadah</div>
|
||||
<button class="modal-close" onclick="closeCenterEditModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="centerEditModalBody">
|
||||
<input type="hidden" id="ce_id"/>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Jenis Rumah Ibadah</label>
|
||||
<select class="form-input" id="ce_type">
|
||||
<option value="masjid">🕌 Masjid / Musholla</option>
|
||||
<option value="gereja katedral">⛪ Gereja Katolik / Katedral</option>
|
||||
<option value="gereja protestan">✝️ Gereja Protestan / Kapel</option>
|
||||
<option value="vihara">🛕 Vihara / Klenteng / Pura</option>
|
||||
<option value="sinagog">✡️ Sinagog</option>
|
||||
<option value="default">🏛️ Lainnya</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nama Rumah Ibadah *</label>
|
||||
<input class="form-input" id="ce_name" type="text" placeholder="Nama rumah ibadah..."/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Alamat</label>
|
||||
<input class="form-input" id="ce_address" type="text" placeholder="Alamat..."/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1">
|
||||
<label class="form-label">Kas / Dana (Rp)</label>
|
||||
<input class="form-input" id="ce_kas" type="number" min="0" step="1000" placeholder="0"/>
|
||||
</div>
|
||||
<div class="form-group" style="flex:1">
|
||||
<label class="form-label">Radius (meter)</label>
|
||||
<input class="form-input" id="ce_radius" type="number" min="50" step="10" placeholder="300"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-modal-cancel" onclick="closeCenterEditModal()">Batal</button>
|
||||
<button class="btn-modal-save" onclick="saveCenterEdit()">💾 Simpan Perubahan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =====================================================================
|
||||
MODAL — DETAIL LAPORAN
|
||||
===================================================================== -->
|
||||
<div id="reportDetailModal" class="modal-overlay hidden">
|
||||
<div class="modal-box" style="max-width:500px">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">📋 Detail Laporan</div>
|
||||
<button class="modal-close" onclick="closeReportDetail()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="reportDetailBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =====================================================================
|
||||
CUSTOM DATE PICKER
|
||||
===================================================================== -->
|
||||
<div id="datepickerOverlay" class="datepicker-overlay hidden" onclick="closeDatepickerIfOutside(event)">
|
||||
<div class="datepicker-box" id="datepickerBox"></div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
define('DB_HOST', 'localhost');
|
||||
define('DB_USER', 'root');
|
||||
define('DB_PASS', '');
|
||||
define('DB_NAME', 'sig_bansos');
|
||||
|
||||
$conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
http_response_code(500);
|
||||
die(json_encode(['success' => false, 'message' => 'Koneksi database gagal: ' . $conn->connect_error]));
|
||||
}
|
||||
|
||||
$conn->set_charset('utf8mb4');
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
require_once 'koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['success' => false, 'message' => 'Hanya menerima POST']); exit;
|
||||
}
|
||||
|
||||
$conn->query("SET FOREIGN_KEY_CHECKS=0");
|
||||
$conn->query("TRUNCATE TABLE aid_logs");
|
||||
$conn->query("TRUNCATE TABLE houses");
|
||||
$conn->query("TRUNCATE TABLE religious_centers");
|
||||
$conn->query("TRUNCATE TABLE laporan");
|
||||
$conn->query("SET FOREIGN_KEY_CHECKS=1");
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Semua data berhasil direset']);
|
||||
$conn->close();
|
||||
+1462
File diff suppressed because it is too large
Load Diff
+115
@@ -0,0 +1,115 @@
|
||||
USE sig_bansos;
|
||||
|
||||
-- ============================================================
|
||||
-- Seed: religious_centers (5 titik di Pontianak)
|
||||
-- ============================================================
|
||||
INSERT INTO religious_centers (name, address, kas, latitude, longitude, radius) VALUES
|
||||
('Masjid Jami Pontianak', 'Jl. Tanjungpura No.1, Pontianak Selatan', 12500000, -0.0294, 109.3225, 400),
|
||||
('Masjid Mujahidin', 'Jl. Ahmad Yani, Pontianak Barat', 8750000, -0.0510, 109.3100, 350),
|
||||
('Gereja Katedral Santo Yosef','Jl. Rahadi Usman No.2, Pontianak Kota', 6200000, -0.0245, 109.3344, 300),
|
||||
('Vihara Bodhisattva Karaniya','Jl. Diponegoro No.47, Pontianak Kota', 4300000, -0.0348, 109.3278, 280),
|
||||
('Masjid Al-Falah', 'Jl. Pahlawan, Pontianak Timur', 5100000, -0.0600, 109.3600, 320);
|
||||
|
||||
-- ============================================================
|
||||
-- Seed: houses (15 rumah miskin)
|
||||
-- ============================================================
|
||||
INSERT INTO houses (latitude, longitude, address, rt, rw, kelurahan, status_miskin, jumlah_anggota, anggota, aid_status, has_data) VALUES
|
||||
|
||||
-- Cluster sekitar Masjid Jami (radius 400m)
|
||||
(-0.0301, 109.3240, 'Jl. Tanjungpura Gang Melati No.3, Pontianak Selatan', '002', '001', 'Dalam Bugis',
|
||||
'sangat_miskin', 4,
|
||||
'[{"nama":"Suryanto","statusAnggota":"Kepala Keluarga","tglLahir":"1978-04-12","pekerjaan":"Buruh/Karyawan","gaji":900000},{"nama":"Dewi Suryanto","statusAnggota":"Istri/Suami","tglLahir":"1982-07-20","pekerjaan":"Tidak Bekerja","gaji":0},{"nama":"Rian Suryanto","statusAnggota":"Anak","tglLahir":"2008-01-15","pekerjaan":"Pelajar/Mahasiswa","gaji":0},{"nama":"Sari Suryanto","statusAnggota":"Anak","tglLahir":"2011-09-03","pekerjaan":"Pelajar/Mahasiswa","gaji":0}]',
|
||||
'helped', 1),
|
||||
|
||||
(-0.0310, 109.3255, 'Jl. Tanjungpura Gang Anggrek No.7, Pontianak Selatan', '003', '001', 'Dalam Bugis',
|
||||
'miskin', 3,
|
||||
'[{"nama":"Hendra Wijaya","statusAnggota":"Kepala Keluarga","tglLahir":"1985-06-28","pekerjaan":"Petani/Nelayan","gaji":1200000},{"nama":"Yuli Wijaya","statusAnggota":"Istri/Suami","tglLahir":"1988-11-14","pekerjaan":"Tidak Bekerja","gaji":0},{"nama":"Budi Wijaya","statusAnggota":"Anak","tglLahir":"2014-03-22","pekerjaan":"Pelajar/Mahasiswa","gaji":0}]',
|
||||
'not_helped', 1),
|
||||
|
||||
(-0.0285, 109.3210, 'Jl. Gajah Mada Gg. Sejahtera No.12, Pontianak Selatan', '001', '002', 'Benua Melayu Darat',
|
||||
'sangat_miskin', 5,
|
||||
'[{"nama":"Ahmad Fauzi","statusAnggota":"Kepala Keluarga","tglLahir":"1970-02-10","pekerjaan":"Tidak Bekerja","gaji":0},{"nama":"Siti Aminah","statusAnggota":"Istri/Suami","tglLahir":"1974-08-30","pekerjaan":"Tidak Bekerja","gaji":0},{"nama":"Rizky Fauzi","statusAnggota":"Anak","tglLahir":"1998-05-17","pekerjaan":"Buruh/Karyawan","gaji":800000},{"nama":"Nurul Fauzi","statusAnggota":"Anak","tglLahir":"2002-12-01","pekerjaan":"Pelajar/Mahasiswa","gaji":0},{"nama":"Kakek Hamid","statusAnggota":"Orang Tua","tglLahir":"1945-01-01","pekerjaan":"Tidak Bekerja","gaji":0}]',
|
||||
'helped', 1),
|
||||
|
||||
-- Cluster sekitar Masjid Mujahidin (radius 350m)
|
||||
(-0.0490, 109.3085, 'Jl. Ahmad Yani Gang Damai No.4, Pontianak Barat', '005', '003', 'Akcaya',
|
||||
'miskin', 4,
|
||||
'[{"nama":"Bambang Santoso","statusAnggota":"Kepala Keluarga","tglLahir":"1975-09-05","pekerjaan":"Wiraswasta","gaji":1500000},{"nama":"Rahayu Santoso","statusAnggota":"Istri/Suami","tglLahir":"1979-04-18","pekerjaan":"Tidak Bekerja","gaji":0},{"nama":"Dika Santoso","statusAnggota":"Anak","tglLahir":"2005-07-11","pekerjaan":"Pelajar/Mahasiswa","gaji":0},{"nama":"Tika Santoso","statusAnggota":"Anak","tglLahir":"2009-02-25","pekerjaan":"Pelajar/Mahasiswa","gaji":0}]',
|
||||
'not_helped', 1),
|
||||
|
||||
(-0.0530, 109.3115, 'Jl. Kom. Yos Sudarso Gg. Mawar No.9, Pontianak Barat', '007', '004', 'Akcaya',
|
||||
'sangat_miskin', 2,
|
||||
'[{"nama":"Pak Idrus","statusAnggota":"Kepala Keluarga","tglLahir":"1952-03-14","pekerjaan":"Tidak Bekerja","gaji":0},{"nama":"Bu Rohani","statusAnggota":"Istri/Suami","tglLahir":"1956-11-08","pekerjaan":"Tidak Bekerja","gaji":0}]',
|
||||
'helped', 1),
|
||||
|
||||
(-0.0480, 109.3070, 'Jl. Purnama Gang Murni No.2, Pontianak Barat', '004', '002', 'Sungai Beliung',
|
||||
'miskin', 3,
|
||||
'[{"nama":"Eko Prasetyo","statusAnggota":"Kepala Keluarga","tglLahir":"1983-12-20","pekerjaan":"Buruh/Karyawan","gaji":1100000},{"nama":"Fitri Prasetyo","statusAnggota":"Istri/Suami","tglLahir":"1986-05-30","pekerjaan":"Tidak Bekerja","gaji":0},{"nama":"Kevin Prasetyo","statusAnggota":"Anak","tglLahir":"2012-08-14","pekerjaan":"Pelajar/Mahasiswa","gaji":0}]',
|
||||
'not_helped', 1),
|
||||
|
||||
-- Cluster sekitar Gereja Katedral (radius 300m)
|
||||
(-0.0260, 109.3360, 'Jl. Rahadi Usman Gg. Teratai No.6, Pontianak Kota', '001', '001', 'Darat Sekip',
|
||||
'tidak_miskin', 4,
|
||||
'[{"nama":"Antonius Liong","statusAnggota":"Kepala Keluarga","tglLahir":"1972-07-22","pekerjaan":"Wiraswasta","gaji":3500000},{"nama":"Maria Liong","statusAnggota":"Istri/Suami","tglLahir":"1975-10-09","pekerjaan":"Buruh/Karyawan","gaji":1800000},{"nama":"Felix Liong","statusAnggota":"Anak","tglLahir":"2003-04-05","pekerjaan":"Pelajar/Mahasiswa","gaji":0},{"nama":"Clara Liong","statusAnggota":"Anak","tglLahir":"2007-01-17","pekerjaan":"Pelajar/Mahasiswa","gaji":0}]',
|
||||
'outside', 1),
|
||||
|
||||
(-0.0235, 109.3330, 'Jl. Tanjung Raya Gg. Bakti No.11, Pontianak Kota', '002', '001', 'Darat Sekip',
|
||||
'miskin', 3,
|
||||
'[{"nama":"Yohanes Budi","statusAnggota":"Kepala Keluarga","tglLahir":"1980-08-16","pekerjaan":"Petani/Nelayan","gaji":900000},{"nama":"Elisabeth Budi","statusAnggota":"Istri/Suami","tglLahir":"1984-03-27","pekerjaan":"Tidak Bekerja","gaji":0},{"nama":"Theresia Budi","statusAnggota":"Anak","tglLahir":"2010-06-13","pekerjaan":"Pelajar/Mahasiswa","gaji":0}]',
|
||||
'not_helped', 1),
|
||||
|
||||
-- Cluster sekitar Vihara (radius 280m)
|
||||
(-0.0365, 109.3290, 'Jl. Diponegoro Gg. Lestari No.5, Pontianak Kota', '003', '002', 'Mariana',
|
||||
'miskin', 4,
|
||||
'[{"nama":"Lim Ah Kow","statusAnggota":"Kepala Keluarga","tglLahir":"1968-05-10","pekerjaan":"Wiraswasta","gaji":1200000},{"nama":"Tan Siu Lan","statusAnggota":"Istri/Suami","tglLahir":"1972-09-24","pekerjaan":"Tidak Bekerja","gaji":0},{"nama":"Lim Wei","statusAnggota":"Anak","tglLahir":"2000-11-29","pekerjaan":"Buruh/Karyawan","gaji":700000},{"nama":"Lim Hui","statusAnggota":"Anak","tglLahir":"2006-02-08","pekerjaan":"Pelajar/Mahasiswa","gaji":0}]',
|
||||
'helped', 1),
|
||||
|
||||
(-0.0340, 109.3265, 'Jl. Veteran Gg. Rukun No.8, Pontianak Kota', '006', '003', 'Mariana',
|
||||
'sangat_miskin', 2,
|
||||
'[{"nama":"Wang Cheng","statusAnggota":"Kepala Keluarga","tglLahir":"1960-04-04","pekerjaan":"Tidak Bekerja","gaji":0},{"nama":"Liu Fang","statusAnggota":"Istri/Suami","tglLahir":"1963-12-18","pekerjaan":"Tidak Bekerja","gaji":0}]',
|
||||
'not_helped', 1),
|
||||
|
||||
-- Cluster sekitar Masjid Al-Falah (radius 320m)
|
||||
(-0.0580, 109.3580, 'Jl. Pahlawan Gg. Bersatu No.3, Pontianak Timur', '001', '001', 'Saigon',
|
||||
'sangat_miskin', 5,
|
||||
'[{"nama":"Syaiful Anwar","statusAnggota":"Kepala Keluarga","tglLahir":"1973-01-25","pekerjaan":"Buruh/Karyawan","gaji":750000},{"nama":"Nurhayati","statusAnggota":"Istri/Suami","tglLahir":"1977-06-14","pekerjaan":"Tidak Bekerja","gaji":0},{"nama":"Fajar Anwar","statusAnggota":"Anak","tglLahir":"1999-10-07","pekerjaan":"Buruh/Karyawan","gaji":600000},{"nama":"Dewi Anwar","statusAnggota":"Anak","tglLahir":"2004-03-19","pekerjaan":"Pelajar/Mahasiswa","gaji":0},{"nama":"Si Kecil","statusAnggota":"Anak","tglLahir":"2016-07-30","pekerjaan":"Pelajar/Mahasiswa","gaji":0}]',
|
||||
'helped', 1),
|
||||
|
||||
(-0.0620, 109.3615, 'Jl. Sultan Hamid Gg. Damai No.10, Pontianak Timur', '002', '002', 'Saigon',
|
||||
'miskin', 3,
|
||||
'[{"nama":"Mukhtar Halim","statusAnggota":"Kepala Keluarga","tglLahir":"1981-11-03","pekerjaan":"Petani/Nelayan","gaji":1000000},{"nama":"Salmah Halim","statusAnggota":"Istri/Suami","tglLahir":"1984-08-22","pekerjaan":"Tidak Bekerja","gaji":0},{"nama":"Hafiz Halim","statusAnggota":"Anak","tglLahir":"2013-05-16","pekerjaan":"Pelajar/Mahasiswa","gaji":0}]',
|
||||
'not_helped', 1),
|
||||
|
||||
(-0.0565, 109.3560, 'Jl. DR. Wahidin Gg. Sempurna No.15, Pontianak Timur', '004', '003', 'Banjar Serasan',
|
||||
'miskin', 4,
|
||||
'[{"nama":"Roslan Idris","statusAnggota":"Kepala Keluarga","tglLahir":"1976-02-17","pekerjaan":"Wiraswasta","gaji":1300000},{"nama":"Yanti Idris","statusAnggota":"Istri/Suami","tglLahir":"1979-07-05","pekerjaan":"Tidak Bekerja","gaji":0},{"nama":"Reza Idris","statusAnggota":"Anak","tglLahir":"2007-09-28","pekerjaan":"Pelajar/Mahasiswa","gaji":0},{"nama":"Nenek Maimunah","statusAnggota":"Orang Tua","tglLahir":"1950-06-12","pekerjaan":"Tidak Bekerja","gaji":0}]',
|
||||
'not_helped', 1),
|
||||
|
||||
-- Pin tanpa data (has_data=0)
|
||||
(-0.0330, 109.3310, 'Jl. Nusa Indah, Pontianak Kota', '005', '002', 'Mariana',
|
||||
'', 0, '[]', 'outside', 0),
|
||||
|
||||
(-0.0520, 109.3090, 'Jl. Ahmad Yani, Pontianak Barat', '008', '005', 'Akcaya',
|
||||
'', 0, '[]', 'outside', 0);
|
||||
|
||||
-- ============================================================
|
||||
-- Seed: laporan
|
||||
-- ============================================================
|
||||
INSERT INTO laporan (pelapor, deskripsi, lokasi, status) VALUES
|
||||
('Budi Santoso', 'Ada keluarga lansia di Gang Flamboyan RT 007 yang tinggal sendiri, rumahnya hampir roboh. Perlu perhatian segera.', 'Gang Flamboyan RT 007, Pontianak Barat', 'baru'),
|
||||
('Siti Rahayu', 'Keluarga dengan 6 anak di Jl. Pahlawan belum pernah menerima bantuan apapun. Kondisi rumah sangat tidak layak.', 'Jl. Pahlawan, Pontianak Timur', 'ditangani'),
|
||||
('Anonim', 'Ada balita yang kekurangan gizi di RT 003 RW 001, orang tuanya pengangguran. Butuh bantuan pangan segera.', 'RT 003/001, Dalam Bugis, Pontianak Selatan', 'baru'),
|
||||
('Pak RT Hamzah', 'Terdapat 3 kepala keluarga di gang belakang pasar yang belum terdata. Mereka tinggal di bantaran sungai.', 'Bantaran Sungai Kapuas, Pontianak Kota', 'selesai'),
|
||||
('Marlina Dewi', 'Ibu tunggal dengan 4 anak di Jl. Veteran, suami meninggal tahun lalu. Sangat membutuhkan bantuan sosial.', 'Jl. Veteran, Pontianak Kota', 'baru');
|
||||
|
||||
-- ============================================================
|
||||
-- Seed: aid_logs (riwayat bantuan)
|
||||
-- ============================================================
|
||||
INSERT INTO aid_logs (house_id, religious_center_id, status, timestamp) VALUES
|
||||
(1, 1, 'helped', '2026-03-10 09:00:00'),
|
||||
(3, 1, 'helped', '2026-03-12 10:30:00'),
|
||||
(5, 2, 'helped', '2026-03-15 08:45:00'),
|
||||
(9, 4, 'helped', '2026-04-01 11:00:00'),
|
||||
(11, 5, 'helped', '2026-04-05 09:30:00'),
|
||||
(2, 1, 'reverted', '2026-02-20 14:00:00'),
|
||||
(4, 2, 'reverted', '2026-02-25 13:00:00');
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
require_once 'koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['success' => false, 'message' => 'Hanya POST']); exit;
|
||||
}
|
||||
|
||||
$name = strip_tags(trim($_POST['name'] ?? 'Anonim'));
|
||||
$text = strip_tags(trim($_POST['text'] ?? ''));
|
||||
$lokasi = strip_tags(trim($_POST['lokasi'] ?? ''));
|
||||
$img = $_POST['img'] ?? '';
|
||||
|
||||
if (empty($text)) {
|
||||
echo json_encode(['success' => false, 'message' => 'Deskripsi laporan tidak boleh kosong']); exit;
|
||||
}
|
||||
if (strlen($img) > 7_000_000) {
|
||||
echo json_encode(['success' => false, 'message' => 'Ukuran foto terlalu besar (max 5MB)']); exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO laporan (pelapor, deskripsi, lokasi, foto_base64) VALUES (?, ?, ?, ?)");
|
||||
$stmt->bind_param('ssss', $name, $text, $lokasi, $img);
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['success' => true, 'id' => $conn->insert_id]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $stmt->error]);
|
||||
}
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
require_once 'koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['success' => false, 'message' => 'Hanya menerima POST']); exit;
|
||||
}
|
||||
|
||||
$id = intval($_POST['id'] ?? 0);
|
||||
$name = $conn->real_escape_string(strip_tags(trim($_POST['name'] ?? '')));
|
||||
$address = $conn->real_escape_string(strip_tags(trim($_POST['address'] ?? '')));
|
||||
$kas = floatval($_POST['kas'] ?? 0);
|
||||
$lat = floatval($_POST['lat'] ?? 0);
|
||||
$lng = floatval($_POST['lng'] ?? 0);
|
||||
$radius = intval($_POST['radius'] ?? 300);
|
||||
$isUpd = isset($_POST['update']) && $_POST['update'] === '1';
|
||||
|
||||
if (empty($name)) {
|
||||
echo json_encode(['success' => false, 'message' => 'Nama wajib diisi']); exit;
|
||||
}
|
||||
if ($radius < 50) $radius = 50;
|
||||
|
||||
if ($id > 0 || $isUpd) {
|
||||
$stmt = $conn->prepare("UPDATE religious_centers SET name=?, address=?, kas=?, latitude=?, longitude=?, radius=?, updated_at=NOW() WHERE id=?");
|
||||
$stmt->bind_param('ssdddii', $name, $address, $kas, $lat, $lng, $radius, $id);
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['success' => true, 'id' => $id, 'action' => 'updated']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $stmt->error]);
|
||||
}
|
||||
$stmt->close();
|
||||
} else {
|
||||
$stmt = $conn->prepare("INSERT INTO religious_centers (name, address, kas, latitude, longitude, radius) VALUES (?, ?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param('ssdddi', $name, $address, $kas, $lat, $lng, $radius);
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['success' => true, 'id' => $conn->insert_id, 'action' => 'inserted']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $stmt->error]);
|
||||
}
|
||||
$stmt->close();
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
require_once 'koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['success' => false, 'message' => 'Hanya POST']); exit;
|
||||
}
|
||||
|
||||
$id = intval($_POST['id'] ?? 0);
|
||||
$lat = floatval($_POST['lat'] ?? 0);
|
||||
$lng = floatval($_POST['lng'] ?? 0);
|
||||
$address = $conn->real_escape_string(strip_tags(trim($_POST['address'] ?? '')));
|
||||
$rt = $conn->real_escape_string(strip_tags(trim($_POST['rt'] ?? '')));
|
||||
$rw = $conn->real_escape_string(strip_tags(trim($_POST['rw'] ?? '')));
|
||||
$kelurahan = $conn->real_escape_string(strip_tags(trim($_POST['kelurahan'] ?? '')));
|
||||
$statusMiskin = $_POST['status_miskin'] ?? '';
|
||||
$jumlahAnggota = intval($_POST['jumlah_anggota'] ?? 0);
|
||||
$anggotaRaw = $_POST['anggota'] ?? '[]';
|
||||
$aidStatus = $_POST['aid_status'] ?? 'outside';
|
||||
$hasData = intval($_POST['has_data'] ?? 0);
|
||||
|
||||
$validStatuses = ['sangat_miskin', 'miskin', 'tidak_miskin', ''];
|
||||
if (!in_array($statusMiskin, $validStatuses)) $statusMiskin = '';
|
||||
$validAid = ['helped', 'not_helped', 'outside'];
|
||||
if (!in_array($aidStatus, $validAid)) $aidStatus = 'outside';
|
||||
|
||||
$anggotaDecoded = json_decode($anggotaRaw, true);
|
||||
if (!is_array($anggotaDecoded)) $anggotaDecoded = [];
|
||||
$anggotaJson = json_encode($anggotaDecoded, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($id > 0) {
|
||||
$stmt = $conn->prepare("UPDATE houses SET latitude=?, longitude=?, address=?, rt=?, rw=?, kelurahan=?, status_miskin=?, jumlah_anggota=?, anggota=?, aid_status=?, has_data=?, updated_at=NOW() WHERE id=?");
|
||||
$stmt->bind_param('ddsssssiisii', $lat, $lng, $address, $rt, $rw, $kelurahan, $statusMiskin, $jumlahAnggota, $anggotaJson, $aidStatus, $hasData, $id);
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['success' => true, 'id' => $id, 'action' => 'updated']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $stmt->error]);
|
||||
}
|
||||
$stmt->close();
|
||||
} else {
|
||||
$stmt = $conn->prepare("INSERT INTO houses (latitude, longitude, address, rt, rw, kelurahan, status_miskin, jumlah_anggota, anggota, aid_status, has_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param('ddsssssissi', $lat, $lng, $address, $rt, $rw, $kelurahan, $statusMiskin, $jumlahAnggota, $anggotaJson, $aidStatus, $hasData);
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['success' => true, 'id' => $conn->insert_id, 'action' => 'inserted']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $stmt->error]);
|
||||
}
|
||||
$stmt->close();
|
||||
}
|
||||
$conn->close();
|
||||
@@ -0,0 +1,649 @@
|
||||
/* ===================================================================
|
||||
BantSOSial GIS — style.css
|
||||
=================================================================== */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--primary: #0d9488;
|
||||
--primary-light: #ccfbf1;
|
||||
--primary-dark: #0f766e;
|
||||
--orange: #ea580c;
|
||||
--orange-light: #fff7ed;
|
||||
--danger: #dc2626;
|
||||
--danger-light: #fef2f2;
|
||||
--yellow: #d97706;
|
||||
--yellow-light: #fffbeb;
|
||||
--green: #16a34a;
|
||||
--green-light: #f0fdf4;
|
||||
--purple: #7c3aed;
|
||||
--purple-light: #f5f3ff;
|
||||
--bg: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
--border: #e2e8f0;
|
||||
--border-hover: #cbd5e1;
|
||||
--text: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--text-dim: #94a3b8;
|
||||
--nav-h: 56px;
|
||||
--sidebar-w: 300px;
|
||||
--font: 'DM Sans', -apple-system, system-ui, sans-serif;
|
||||
--mono: 'DM Mono', monospace;
|
||||
--radius: 10px;
|
||||
--shadow: 0 2px 12px rgba(15,23,42,.07);
|
||||
--shadow-md: 0 8px 30px rgba(15,23,42,.10);
|
||||
}
|
||||
|
||||
html, body { height: 100%; overflow: hidden; font-family: var(--font); color: var(--text); background: var(--bg); }
|
||||
|
||||
/* ===================================================================
|
||||
NAVBAR
|
||||
=================================================================== */
|
||||
#topNav {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 1000;
|
||||
height: var(--nav-h);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.nav-brand { display: flex; align-items: center; gap: 10px; }
|
||||
.nav-brand-icon { font-size: 22px; line-height: 1; }
|
||||
.nav-brand-title { font-size: 14px; font-weight: 700; letter-spacing: -.3px; }
|
||||
.nav-brand-sub { font-size: 10px; color: var(--text-muted); }
|
||||
|
||||
.nav-menu { display: flex; gap: 4px; }
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 14px; border: none; border-radius: 8px;
|
||||
background: transparent; cursor: pointer;
|
||||
font-family: var(--font); font-size: 13px; font-weight: 500;
|
||||
color: var(--text-muted); transition: all .2s; position: relative;
|
||||
}
|
||||
.nav-item:hover { background: var(--bg); color: var(--text); }
|
||||
.nav-item.active { background: var(--primary-light); color: var(--primary-dark); font-weight: 600; }
|
||||
.nav-item-icon { font-size: 14px; }
|
||||
.nav-badge {
|
||||
position: absolute; top: 4px; right: 6px;
|
||||
min-width: 16px; height: 16px; padding: 0 4px;
|
||||
background: var(--danger); color: #fff;
|
||||
border-radius: 10px; font-size: 9px; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.nav-badge.hidden { display: none; }
|
||||
|
||||
.nav-right { display: flex; align-items: center; gap: 8px; }
|
||||
.role-pill {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 5px 12px; border-radius: 20px;
|
||||
background: var(--primary-light); color: var(--primary-dark);
|
||||
font-size: 12px; font-weight: 700;
|
||||
}
|
||||
#roleSwitchBtn {
|
||||
padding: 6px 12px; border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--surface); cursor: pointer;
|
||||
font-family: var(--font); font-size: 12px; font-weight: 500;
|
||||
color: var(--text-muted); transition: all .2s;
|
||||
}
|
||||
#roleSwitchBtn:hover { border-color: var(--border-hover); color: var(--text); }
|
||||
|
||||
/* ===================================================================
|
||||
PAGE LAYOUT
|
||||
=================================================================== */
|
||||
.page { display: none; position: fixed; inset: 0; top: var(--nav-h); }
|
||||
.page.active-page { display: flex; }
|
||||
|
||||
/* ===================================================================
|
||||
SIDEBAR
|
||||
=================================================================== */
|
||||
#sidebar {
|
||||
width: var(--sidebar-w); min-width: var(--sidebar-w);
|
||||
height: 100%; overflow-y: auto;
|
||||
background: var(--surface); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; gap: 0;
|
||||
scrollbar-width: thin; scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
#sidebar::-webkit-scrollbar { width: 4px; }
|
||||
#sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
|
||||
/* ===================================================================
|
||||
MAP
|
||||
=================================================================== */
|
||||
#map { flex: 1; height: 100%; z-index: 0; }
|
||||
#map.adding-mode { cursor: crosshair !important; }
|
||||
|
||||
/* ===================================================================
|
||||
MODE BADGE
|
||||
=================================================================== */
|
||||
.mode-badge {
|
||||
margin: 10px 14px 0;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--primary-light); border: 1px solid var(--primary);
|
||||
border-radius: 8px; font-size: 11px; font-weight: 600; color: var(--primary-dark);
|
||||
}
|
||||
.mode-badge.house-mode { background: #fff7ed; border-color: var(--orange); color: var(--orange); }
|
||||
.mode-badge.hidden { display: none !important; }
|
||||
.pulse-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--primary); flex-shrink: 0;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.mode-badge.house-mode .pulse-dot { background: var(--orange); }
|
||||
@keyframes pulse { 0%,100% { opacity:1; transform:scale(1); } 50% { opacity:.5; transform:scale(1.4); } }
|
||||
|
||||
/* ===================================================================
|
||||
BUTTONS
|
||||
=================================================================== */
|
||||
.btn-group {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.btn-group button {
|
||||
padding: 9px 14px; border: 1px solid var(--border); border-radius: 9px;
|
||||
background: var(--surface); cursor: pointer;
|
||||
font-family: var(--font); font-size: 12px; font-weight: 600;
|
||||
color: var(--text); text-align: left; transition: all .2s;
|
||||
}
|
||||
.btn-group button:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-light); }
|
||||
.btn-group button.active { background: var(--primary); color: #fff; border-color: var(--primary-dark); }
|
||||
.btn-danger {
|
||||
margin: 8px 14px 14px; padding: 9px 14px;
|
||||
border: 1px solid #fca5a5; border-radius: 9px;
|
||||
background: var(--danger-light); cursor: pointer;
|
||||
font-family: var(--font); font-size: 12px; font-weight: 600;
|
||||
color: var(--danger); transition: all .2s;
|
||||
}
|
||||
.btn-danger:hover { background: #fee2e2; }
|
||||
|
||||
/* ===================================================================
|
||||
STATS
|
||||
=================================================================== */
|
||||
.stat-panel { padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
||||
.stat-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: var(--text-muted); margin-bottom: 8px; }
|
||||
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.stat-card {
|
||||
padding: 8px 10px; border-radius: 8px;
|
||||
background: var(--bg); border: 1px solid var(--border); text-align: center;
|
||||
}
|
||||
.stat-card.red { background: #fef2f2; border-color: #fca5a5; }
|
||||
.stat-card.yellow { background: #fffbeb; border-color: #fcd34d; }
|
||||
.stat-card.green { background: #f0fdf4; border-color: #86efac; }
|
||||
.stat-num { font-size: 22px; font-weight: 800; font-family: var(--mono); color: var(--text); }
|
||||
.stat-card.red .stat-num { color: var(--danger); }
|
||||
.stat-card.yellow .stat-num { color: var(--yellow); }
|
||||
.stat-card.green .stat-num { color: var(--green); }
|
||||
.stat-label { font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
/* ===================================================================
|
||||
SECTIONS
|
||||
=================================================================== */
|
||||
.section { padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
||||
.section-title {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 12px; font-weight: 700; color: var(--text); margin-bottom: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 20px; height: 18px; padding: 0 6px;
|
||||
border-radius: 10px; font-size: 10px; font-weight: 700;
|
||||
}
|
||||
.badge-teal { background: var(--primary-light); color: var(--primary-dark); }
|
||||
.badge-orange { background: #fff7ed; color: var(--orange); }
|
||||
|
||||
.search-wrap { margin-bottom: 6px; }
|
||||
.search-input {
|
||||
width: 100%; padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px;
|
||||
font-family: var(--font); font-size: 12px; color: var(--text);
|
||||
background: var(--bg); outline: none; transition: border-color .2s;
|
||||
}
|
||||
.search-input:focus { border-color: var(--primary); }
|
||||
.filter-row { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||
.filter-select {
|
||||
width: 100%; padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px;
|
||||
font-family: var(--font); font-size: 11px; color: var(--text);
|
||||
background: var(--bg); outline: none; cursor: pointer;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
CENTER LIST ITEMS
|
||||
=================================================================== */
|
||||
.center-item {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border);
|
||||
margin-bottom: 6px; cursor: pointer; transition: all .2s;
|
||||
background: var(--surface);
|
||||
}
|
||||
.center-item:hover { border-color: var(--primary); background: var(--primary-light); }
|
||||
.center-item-icon { font-size: 18px; line-height: 1.2; flex-shrink: 0; }
|
||||
.center-item-body { flex: 1; min-width: 0; }
|
||||
.center-item-name { font-size: 12px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.center-item-addr { font-size: 10px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.center-item-meta { display: flex; gap: 6px; margin-top: 3px; flex-wrap: wrap; }
|
||||
.meta-tag {
|
||||
padding: 1px 6px; border-radius: 4px; font-size: 9px; font-weight: 600;
|
||||
background: var(--bg); border: 1px solid var(--border); color: var(--text-muted);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.center-item-del {
|
||||
padding: 3px 7px; border: 1px solid #fca5a5; border-radius: 6px;
|
||||
background: var(--danger-light); color: var(--danger);
|
||||
font-size: 10px; cursor: pointer; flex-shrink: 0; transition: all .2s;
|
||||
}
|
||||
.center-item-del:hover { background: #fee2e2; }
|
||||
|
||||
/* ===================================================================
|
||||
HOUSE LIST ITEMS
|
||||
=================================================================== */
|
||||
.house-item {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border);
|
||||
margin-bottom: 6px; cursor: pointer; transition: all .2s;
|
||||
background: var(--surface);
|
||||
}
|
||||
.house-item:hover { border-color: var(--orange); background: var(--orange-light); }
|
||||
.house-item.status-helped { border-left: 3px solid var(--yellow); }
|
||||
.house-item.status-not_helped { border-left: 3px solid var(--danger); }
|
||||
.house-item.status-outside { border-left: 3px solid var(--green); }
|
||||
.house-item-icon { font-size: 16px; line-height: 1.3; flex-shrink: 0; }
|
||||
.house-item-body { flex: 1; min-width: 0; }
|
||||
.house-item-name { font-size: 12px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.house-item-addr { font-size: 10px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.house-item-tags { display: flex; gap: 4px; margin-top: 3px; flex-wrap: wrap; }
|
||||
.tag {
|
||||
padding: 1px 6px; border-radius: 4px; font-size: 9px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: .3px;
|
||||
}
|
||||
.tag-helped { background: #fef9c3; color: #854d0e; }
|
||||
.tag-not_helped { background: #fef2f2; color: var(--danger); }
|
||||
.tag-outside { background: var(--green-light); color: var(--green); }
|
||||
.tag-nodata { background: var(--bg); color: var(--text-muted); border: 1px solid var(--border); }
|
||||
.tag-sangat_miskin { background: #fef2f2; color: #991b1b; }
|
||||
.tag-miskin { background: #fff7ed; color: #9a3412; }
|
||||
.tag-tidak_miskin { background: var(--green-light); color: #166534; }
|
||||
.house-item-del {
|
||||
padding: 3px 7px; border: 1px solid #fca5a5; border-radius: 6px;
|
||||
background: var(--danger-light); color: var(--danger);
|
||||
font-size: 10px; cursor: pointer; flex-shrink: 0; transition: all .2s;
|
||||
}
|
||||
.house-item-del:hover { background: #fee2e2; }
|
||||
|
||||
/* ===================================================================
|
||||
LEGEND
|
||||
=================================================================== */
|
||||
.legend-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.legend-item { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-muted); }
|
||||
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.legend-dot.green { background: #16a34a; }
|
||||
.legend-dot.red { background: #dc2626; }
|
||||
.legend-dot.yellow { background: #d97706; }
|
||||
.legend-circle {
|
||||
width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0;
|
||||
border: 2px dashed #3b82f6; background: rgba(59,130,246,.08);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
EMPTY STATE
|
||||
=================================================================== */
|
||||
.empty-state { text-align: center; font-size: 11px; color: var(--text-dim); padding: 12px 0; }
|
||||
|
||||
/* ===================================================================
|
||||
RADIUS TOOLTIP
|
||||
=================================================================== */
|
||||
.radius-tooltip {
|
||||
position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 800; padding: 6px 14px;
|
||||
background: rgba(15,23,42,.85); color: #fff; border-radius: 20px;
|
||||
font-size: 12px; font-weight: 700; font-family: var(--mono);
|
||||
pointer-events: none;
|
||||
}
|
||||
.radius-tooltip.hidden { display: none; }
|
||||
|
||||
/* ===================================================================
|
||||
MAP POPUPS
|
||||
=================================================================== */
|
||||
.leaflet-popup-content-wrapper { padding: 0 !important; border-radius: 12px !important; overflow: hidden; box-shadow: var(--shadow-md) !important; }
|
||||
.leaflet-popup-content { margin: 0 !important; width: 240px !important; }
|
||||
.leaflet-popup-tip { background: var(--surface) !important; }
|
||||
|
||||
.map-popup { padding: 12px 14px 8px; }
|
||||
.map-popup-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 10px; display: inline-block; margin-bottom: 6px; }
|
||||
.map-popup-badge.helped { background: #fef9c3; color: #854d0e; }
|
||||
.map-popup-badge.not_helped { background: #fef2f2; color: var(--danger); }
|
||||
.map-popup-badge.outside { background: var(--green-light); color: var(--green); }
|
||||
.map-popup-badge.nodata { background: var(--bg); color: var(--text-muted); }
|
||||
.map-popup-title { font-size: 13px; font-weight: 700; color: var(--text); margin-bottom: 3px; }
|
||||
.map-popup-addr { font-size: 11px; color: var(--text-muted); line-height: 1.5; }
|
||||
.map-popup-actions { display: flex; gap: 5px; padding: 8px 14px 12px; border-top: 1px solid var(--border); }
|
||||
.popup-btn {
|
||||
flex: 1; padding: 6px 8px; border-radius: 7px; border: 1px solid var(--border);
|
||||
background: var(--bg); cursor: pointer; font-family: var(--font);
|
||||
font-size: 10px; font-weight: 600; color: var(--text); transition: all .2s;
|
||||
}
|
||||
.popup-btn:hover { border-color: var(--border-hover); }
|
||||
.popup-btn.primary { background: var(--primary-light); border-color: var(--primary); color: var(--primary-dark); }
|
||||
.popup-btn.orange { background: #fff7ed; border-color: var(--orange); color: var(--orange); }
|
||||
|
||||
.center-popup { padding: 12px 13px 8px; }
|
||||
.center-popup-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.center-popup-icon {
|
||||
width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 16px; background: var(--primary-light); flex-shrink: 0;
|
||||
}
|
||||
.center-popup-title { font-size: 13px; font-weight: 700; color: var(--text); line-height: 1.3; }
|
||||
.center-popup-addr { font-size: 11px; color: var(--text-muted); margin-bottom: 8px; }
|
||||
.kas-badge {
|
||||
display: flex; align-items: center; gap: 6px; margin-bottom: 6px;
|
||||
padding: 5px 8px; background: var(--green-light); border: 1px solid #86efac; border-radius: 7px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.kas-label { color: var(--green); font-weight: 600; flex: 1; }
|
||||
.kas-value { font-family: var(--mono); font-size: 12px; font-weight: 700; color: var(--green); }
|
||||
.radius-info-box {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 5px 8px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 7px;
|
||||
font-size: 11px; color: #1e40af; margin-bottom: 5px;
|
||||
}
|
||||
.radius-val { font-family: var(--mono); font-weight: 700; }
|
||||
.radius-drag-hint { font-size: 10px; color: var(--text-dim); text-align: center; padding: 3px 0 5px; }
|
||||
|
||||
.form-popup { padding: 12px; }
|
||||
.form-popup h3 { font-size: 13px; font-weight: 700; margin-bottom: 10px; color: var(--text); }
|
||||
|
||||
/* ===================================================================
|
||||
MODALS
|
||||
=================================================================== */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 2000;
|
||||
background: rgba(15,23,42,.45);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.modal-overlay.hidden { display: none !important; }
|
||||
.modal-box {
|
||||
background: var(--surface); border-radius: 16px;
|
||||
width: 100%; max-width: 580px; max-height: 88vh;
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: 0 24px 60px rgba(15,23,42,.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-wide { max-width: 700px; }
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border);
|
||||
background: linear-gradient(135deg, #f0fdfa, #e0f2fe); flex-shrink: 0;
|
||||
}
|
||||
.modal-title { font-size: 15px; font-weight: 700; color: var(--text); }
|
||||
.modal-close {
|
||||
width: 28px; height: 28px; border: none; border-radius: 8px;
|
||||
background: rgba(15,23,42,.06); cursor: pointer;
|
||||
font-size: 13px; color: var(--text-muted); transition: all .2s;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-close:hover { background: rgba(15,23,42,.12); color: var(--text); }
|
||||
.modal-body { overflow-y: auto; flex: 1; padding: 20px; }
|
||||
.modal-actions {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 14px 20px; border-top: 1px solid var(--border); flex-shrink: 0;
|
||||
}
|
||||
.btn-modal-cancel {
|
||||
padding: 8px 18px; border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--surface); cursor: pointer; font-family: var(--font);
|
||||
font-size: 13px; font-weight: 500; color: var(--text-muted); transition: all .2s;
|
||||
}
|
||||
.btn-modal-cancel:hover { border-color: var(--border-hover); color: var(--text); }
|
||||
.btn-modal-save {
|
||||
padding: 8px 18px; border: none; border-radius: 8px;
|
||||
background: var(--primary); cursor: pointer; font-family: var(--font);
|
||||
font-size: 13px; font-weight: 700; color: #fff; transition: all .2s;
|
||||
}
|
||||
.btn-modal-save:hover { background: var(--primary-dark); }
|
||||
|
||||
/* ===================================================================
|
||||
FORMS (modal)
|
||||
=================================================================== */
|
||||
.modal-section { margin-bottom: 20px; }
|
||||
.modal-section:last-child { margin-bottom: 0; }
|
||||
.modal-section-title {
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .8px; color: var(--text-muted); margin-bottom: 10px;
|
||||
padding-bottom: 6px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.form-group { margin-bottom: 12px; }
|
||||
.form-group:last-child { margin-bottom: 0; }
|
||||
.form-row { display: flex; gap: 10px; }
|
||||
.form-row .form-group { flex: 1; margin-bottom: 0; }
|
||||
.form-label { display: block; font-size: 11px; font-weight: 600; color: var(--text-muted); margin-bottom: 5px; }
|
||||
.form-input {
|
||||
width: 100%; padding: 8px 10px; border: 1.5px solid var(--border); border-radius: 8px;
|
||||
font-family: var(--font); font-size: 13px; color: var(--text);
|
||||
background: var(--bg); outline: none; transition: border-color .2s;
|
||||
}
|
||||
.form-input:focus { border-color: var(--primary); background: var(--surface); }
|
||||
.form-textarea { resize: vertical; min-height: 80px; }
|
||||
.form-submit {
|
||||
width: 100%; padding: 10px; border: none; border-radius: 9px;
|
||||
background: var(--primary); color: #fff; cursor: pointer;
|
||||
font-family: var(--font); font-size: 13px; font-weight: 700; transition: all .2s;
|
||||
}
|
||||
.form-submit:hover { background: var(--primary-dark); }
|
||||
|
||||
.info-box { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 10px; }
|
||||
.info-box-row { display: flex; justify-content: space-between; padding: 7px 12px; border-bottom: 1px solid var(--border); font-size: 12px; }
|
||||
.info-box-row:last-child { border-bottom: none; }
|
||||
.info-box-label { color: var(--text-muted); font-weight: 500; }
|
||||
.info-box-val { font-weight: 600; color: var(--text); text-align: right; max-width: 60%; }
|
||||
|
||||
/* ===================================================================
|
||||
STATUS PILLS
|
||||
=================================================================== */
|
||||
.status-pills { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.status-pill {
|
||||
flex: 1; min-width: 100px; padding: 8px 10px; border-radius: 9px;
|
||||
border: 2px solid var(--border); cursor: pointer;
|
||||
font-size: 12px; font-weight: 600; text-align: center;
|
||||
background: var(--surface); transition: all .2s; color: var(--text-muted);
|
||||
}
|
||||
.status-pill:hover { border-color: var(--border-hover); }
|
||||
.status-pill.selected.sangat_miskin { background: #fef2f2; border-color: var(--danger); color: #991b1b; }
|
||||
.status-pill.selected.miskin { background: #fff7ed; border-color: var(--orange); color: #9a3412; }
|
||||
.status-pill.selected.tidak_miskin { background: var(--green-light); border-color: var(--green); color: #166534; }
|
||||
|
||||
/* ===================================================================
|
||||
MEMBER CARD
|
||||
=================================================================== */
|
||||
.member-card {
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
margin-bottom: 10px; overflow: hidden;
|
||||
}
|
||||
.member-card-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg); border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; font-weight: 700; color: var(--text-muted);
|
||||
}
|
||||
.member-num {
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
background: var(--primary); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 10px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.member-card-body { padding: 10px 12px; }
|
||||
|
||||
/* ===================================================================
|
||||
DETAIL MODAL
|
||||
=================================================================== */
|
||||
.detail-section { margin-bottom: 16px; }
|
||||
.detail-section-title {
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .8px; color: var(--text-muted); margin-bottom: 8px;
|
||||
padding-bottom: 5px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.detail-kv { background: var(--bg); border-radius: 7px; padding: 8px 10px; }
|
||||
.detail-key { font-size: 10px; color: var(--text-dim); font-weight: 600; text-transform: uppercase; letter-spacing: .4px; }
|
||||
.detail-val { font-size: 13px; font-weight: 600; color: var(--text); margin-top: 2px; }
|
||||
.member-table { width: 100%; border-collapse: collapse; font-size: 11px; }
|
||||
.member-table th { padding: 6px 10px; background: var(--bg); font-weight: 700; text-align: left; border-bottom: 1px solid var(--border); color: var(--text-muted); font-size: 10px; text-transform: uppercase; }
|
||||
.member-table td { padding: 7px 10px; border-bottom: 1px solid var(--border); color: var(--text); }
|
||||
.member-table tr:last-child td { border-bottom: none; }
|
||||
.member-table tr:hover td { background: var(--bg); }
|
||||
|
||||
.aid-btn-row { display: flex; gap: 6px; margin-top: 10px; }
|
||||
.aid-btn {
|
||||
flex: 1; padding: 8px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: var(--bg); cursor: pointer; font-family: var(--font);
|
||||
font-size: 11px; font-weight: 700; transition: all .2s; color: var(--text-muted);
|
||||
}
|
||||
.aid-btn:hover { border-color: var(--border-hover); }
|
||||
.aid-btn.active-helped { background: #fef9c3; border-color: #fcd34d; color: #854d0e; }
|
||||
.aid-btn.active-not_helped { background: #fef2f2; border-color: #fca5a5; color: var(--danger); }
|
||||
.aid-btn.active-outside { background: var(--green-light); border-color: #86efac; color: var(--green); }
|
||||
|
||||
/* ===================================================================
|
||||
ROLE CARDS
|
||||
=================================================================== */
|
||||
.role-cards { display: flex; flex-direction: column; gap: 8px; }
|
||||
.role-card {
|
||||
padding: 12px 14px; border: 2px solid var(--border); border-radius: 10px;
|
||||
cursor: pointer; transition: all .2s; background: var(--surface);
|
||||
}
|
||||
.role-card:hover { border-color: var(--border-hover); }
|
||||
.role-card.active-role { border-color: var(--primary); background: var(--primary-light); }
|
||||
.role-card-icon { font-size: 20px; margin-bottom: 4px; }
|
||||
.role-card-name { font-size: 13px; font-weight: 700; color: var(--text); margin-bottom: 3px; }
|
||||
.role-card-desc { font-size: 11px; color: var(--text-muted); margin-bottom: 8px; }
|
||||
.role-card-perms { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.perm { padding: 2px 8px; border-radius: 5px; font-size: 10px; font-weight: 600; }
|
||||
.perm-green { background: var(--green-light); color: var(--green); }
|
||||
.perm-red { background: #fef2f2; color: var(--danger); }
|
||||
|
||||
/* ===================================================================
|
||||
REPORT PAGE
|
||||
=================================================================== */
|
||||
#pagePelaporan.active-page { overflow: hidden; }
|
||||
.report-page {
|
||||
display: flex; width: 100%; height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.report-col {
|
||||
display: flex; flex-direction: column;
|
||||
height: 100%; overflow: hidden;
|
||||
}
|
||||
.report-col-form { width: 380px; min-width: 320px; border-right: 1px solid var(--border); background: var(--surface); }
|
||||
.report-col-list { flex: 1; background: var(--bg); }
|
||||
.report-col-viewer { flex: 1; background: var(--bg); }
|
||||
.report-col-header {
|
||||
padding: 16px 20px 14px; border-bottom: 1px solid var(--border);
|
||||
background: var(--surface); flex-shrink: 0;
|
||||
}
|
||||
.report-col-title { font-size: 15px; font-weight: 700; color: var(--text); display: flex; align-items: center; }
|
||||
.report-col-sub { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||
.report-form-body { padding: 16px 20px; overflow-y: auto; flex: 1; }
|
||||
.report-list-toolbar {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||
background: var(--surface); flex-shrink: 0;
|
||||
}
|
||||
.report-list-container { flex: 1; overflow-y: auto; padding: 12px 16px; }
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed var(--border); border-radius: 10px;
|
||||
padding: 20px; text-align: center; cursor: pointer;
|
||||
transition: all .2s; background: var(--bg);
|
||||
}
|
||||
.upload-area:hover { border-color: var(--primary); background: var(--primary-light); }
|
||||
.upload-icon { font-size: 28px; margin-bottom: 6px; }
|
||||
.upload-text { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.upload-sub { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||
.remove-img-btn {
|
||||
margin-top: 6px; padding: 4px 10px; border: 1px solid #fca5a5; border-radius: 6px;
|
||||
background: var(--danger-light); color: var(--danger); cursor: pointer;
|
||||
font-size: 11px; font-weight: 600;
|
||||
}
|
||||
|
||||
/* Report card */
|
||||
.report-card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
||||
margin-bottom: 10px; overflow: hidden; transition: all .2s;
|
||||
}
|
||||
.report-card:hover { border-color: var(--border-hover); box-shadow: var(--shadow); }
|
||||
.report-card-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px 8px; }
|
||||
.report-card-meta { font-size: 10px; color: var(--text-muted); }
|
||||
.report-card-name { font-weight: 700; color: var(--text); }
|
||||
.report-status {
|
||||
padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 700;
|
||||
}
|
||||
.report-status.baru { background: #dbeafe; color: #1d4ed8; }
|
||||
.report-status.ditangani { background: #fef9c3; color: #854d0e; }
|
||||
.report-status.selesai { background: var(--green-light); color: var(--green); }
|
||||
.report-card-text { padding: 0 14px 8px; font-size: 12px; color: var(--text); line-height: 1.6; }
|
||||
.report-card-footer {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 14px; border-top: 1px solid var(--border); background: var(--bg);
|
||||
}
|
||||
.report-card-time { font-size: 10px; color: var(--text-dim); }
|
||||
.report-card-actions { display: flex; gap: 5px; }
|
||||
.report-action-btn {
|
||||
padding: 3px 8px; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--surface); cursor: pointer; font-size: 10px; font-weight: 600;
|
||||
color: var(--text-muted); font-family: var(--font); transition: all .2s;
|
||||
}
|
||||
.report-action-btn:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.report-action-btn.danger { border-color: #fca5a5; color: var(--danger); }
|
||||
.report-action-btn.danger:hover { background: var(--danger-light); }
|
||||
|
||||
/* ===================================================================
|
||||
DATE PICKER
|
||||
=================================================================== */
|
||||
.datepicker-overlay {
|
||||
position: fixed; inset: 0; z-index: 3000;
|
||||
background: rgba(15,23,42,.3);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.datepicker-overlay.hidden { display: none !important; }
|
||||
.datepicker-box {
|
||||
background: var(--surface); border-radius: 14px;
|
||||
box-shadow: var(--shadow-md); padding: 16px;
|
||||
width: 280px; user-select: none;
|
||||
}
|
||||
.dp-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.dp-nav { background: none; border: 1px solid var(--border); border-radius: 7px; width: 28px; height: 28px; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; transition: all .2s; }
|
||||
.dp-nav:hover { background: var(--bg); }
|
||||
.dp-title { font-size: 13px; font-weight: 700; color: var(--text); }
|
||||
.dp-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; margin-bottom: 8px; }
|
||||
.dp-day-label { font-size: 9px; font-weight: 700; text-align: center; color: var(--text-muted); padding: 4px 0; text-transform: uppercase; }
|
||||
.dp-day {
|
||||
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 6px; font-size: 11px; cursor: pointer; transition: all .15s; color: var(--text);
|
||||
}
|
||||
.dp-day:hover { background: var(--primary-light); color: var(--primary-dark); }
|
||||
.dp-day.selected { background: var(--primary); color: #fff; font-weight: 700; }
|
||||
.dp-day.today { font-weight: 700; color: var(--primary); }
|
||||
.dp-day.other-month { color: var(--text-dim); }
|
||||
.dp-day.disabled { opacity: .35; pointer-events: none; }
|
||||
.dp-footer { display: flex; justify-content: flex-end; gap: 6px; }
|
||||
.dp-btn {
|
||||
padding: 6px 14px; border-radius: 7px; border: 1px solid var(--border);
|
||||
cursor: pointer; font-size: 12px; font-weight: 600; font-family: var(--font);
|
||||
background: var(--bg); color: var(--text-muted); transition: all .2s;
|
||||
}
|
||||
.dp-btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
.dp-btn.primary:hover { background: var(--primary-dark); }
|
||||
.date-input-wrap { position: relative; }
|
||||
.date-display-btn {
|
||||
width: 100%; padding: 8px 10px; border: 1.5px solid var(--border); border-radius: 8px;
|
||||
text-align: left; background: var(--bg); cursor: pointer;
|
||||
font-family: var(--font); font-size: 13px; color: var(--text);
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.date-display-btn:hover, .date-display-btn:focus { border-color: var(--primary); }
|
||||
|
||||
/* ===================================================================
|
||||
SALARY TOGGLE
|
||||
=================================================================== */
|
||||
.salary-row { display: none; }
|
||||
.salary-row.visible { display: flex; }
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
require_once 'koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['success' => false, 'message' => 'Hanya menerima POST']); exit;
|
||||
}
|
||||
|
||||
$id = intval($_POST['id'] ?? 0);
|
||||
$status = $_POST['status'] ?? 'baru';
|
||||
|
||||
$valid = ['baru', 'ditangani', 'selesai'];
|
||||
if (!in_array($status, $valid) || $id < 1) {
|
||||
echo json_encode(['success' => false, 'message' => 'Data tidak valid']); exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("UPDATE laporan SET status=? WHERE id=?");
|
||||
$stmt->bind_param('si', $status, $id);
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['success' => true, 'id' => $id, 'status' => $status]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $stmt->error]);
|
||||
}
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
require_once 'koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['success' => false, 'message' => 'Hanya menerima POST']); exit;
|
||||
}
|
||||
|
||||
$id = intval($_POST['id'] ?? 0);
|
||||
$radius = intval($_POST['radius'] ?? 300);
|
||||
|
||||
if ($id < 1) { echo json_encode(['success' => false, 'message' => 'ID tidak valid']); exit; }
|
||||
if ($radius < 50) $radius = 50;
|
||||
|
||||
$stmt = $conn->prepare("UPDATE religious_centers SET radius=?, updated_at=NOW() WHERE id=?");
|
||||
$stmt->bind_param('ii', $radius, $id);
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['success' => true, 'id' => $id, 'radius' => $radius]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $stmt->error]);
|
||||
}
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
require_once 'koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['success' => false, 'message' => 'Hanya menerima POST']); exit;
|
||||
}
|
||||
|
||||
$houseId = intval($_POST['house_id'] ?? 0);
|
||||
$aidStatus = $_POST['aid_status'] ?? 'outside';
|
||||
$centerId = intval($_POST['center_id'] ?? 0);
|
||||
|
||||
$validAid = ['helped', 'not_helped', 'outside'];
|
||||
if (!in_array($aidStatus, $validAid)) {
|
||||
echo json_encode(['success' => false, 'message' => 'Status tidak valid']); exit;
|
||||
}
|
||||
if ($houseId < 1) {
|
||||
echo json_encode(['success' => false, 'message' => 'house_id tidak valid']); exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("UPDATE houses SET aid_status=?, updated_at=NOW() WHERE id=?");
|
||||
$stmt->bind_param('si', $aidStatus, $houseId);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$stmt->close();
|
||||
if ($aidStatus === 'helped' && $centerId > 0) {
|
||||
$logStatus = 'helped';
|
||||
$ls = $conn->prepare("INSERT INTO aid_logs (house_id, religious_center_id, status) VALUES (?, ?, ?)");
|
||||
$ls->bind_param('iis', $houseId, $centerId, $logStatus);
|
||||
$ls->execute(); $ls->close();
|
||||
} elseif ($aidStatus !== 'helped') {
|
||||
$logStatus = 'reverted';
|
||||
$ls = $conn->prepare("INSERT INTO aid_logs (house_id, religious_center_id, status) VALUES (?, ?, ?)");
|
||||
$ls->bind_param('iis', $houseId, $centerId, $logStatus);
|
||||
$ls->execute(); $ls->close();
|
||||
}
|
||||
echo json_encode(['success' => true, 'house_id' => $houseId, 'aid_status' => $aidStatus]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $stmt->error]);
|
||||
$stmt->close();
|
||||
}
|
||||
$conn->close();
|
||||
Reference in New Issue
Block a user