First Commit

This commit is contained in:
Araya's Project
2026-06-10 12:12:51 +07:00
commit 6462f2e396
34 changed files with 5884 additions and 0 deletions
+92
View File
@@ -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">&rarr;</span>
</div>
</a>
<?php endforeach; ?>
</div>
<footer class="mt-16 text-center text-xs text-slate-600">
Laragon &middot; <?= date('Y') ?>
</footer>
</div>
</body>
</html>
+11
View File
@@ -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>
+904
View File
@@ -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: '&copy; <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>
+152
View File
@@ -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);
+30
View File
@@ -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;
}
+383
View File
@@ -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: '&copy; <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>
+31
View File
@@ -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);
+51
View File
@@ -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).
+15
View File
@@ -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),
];
},
]);
+17
View File
@@ -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),
];
},
]);
+477
View File
@@ -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: '&copy; 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 &amp; 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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
/* --------------------------- Start --------------------------- */
loadAll().catch((err) => {
console.error(err);
alert('Gagal memuat data. Pastikan database sudah dibuat (import schema.sql).');
});
+25
View File
@@ -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;
}
+165
View File
@@ -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]);
}
+109
View File
@@ -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']];
}
+180
View File
@@ -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 &mdash; Mapping Tanah &amp; 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 &amp; Jalan &mdash; point &amp; 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 &amp; 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">&times;</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 </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">&times;</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>
+69
View File
@@ -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
View File
@@ -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.";
}
+70
View File
@@ -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;
+65
View File
@@ -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();
+12
View File
@@ -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();
+14
View File
@@ -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();
+12
View File
@@ -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();
+381
View File
@@ -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>
+19
View File
@@ -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');
+16
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+115
View File
@@ -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');
+28
View File
@@ -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();
+41
View File
@@ -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();
+49
View File
@@ -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();
+649
View File
@@ -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; }
+24
View File
@@ -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();
+22
View File
@@ -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();
+41
View File
@@ -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();