commit 6462f2e396d7bac94cefb08571b01b6091fce7a2
Author: Araya's Project <119692205+Araayaaa@users.noreply.github.com>
Date: Wed Jun 10 12:12:51 2026 +0700
First Commit
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..df29dc1
--- /dev/null
+++ b/index.php
@@ -0,0 +1,92 @@
+ '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',
+ ],
+];
+?>
+
+
+
+
+
+ Daftar Proyek SIG
+
+
+
+
+
+
+ Daftar Proyek SIG
+
+ Pilih salah satu proyek untuk membukanya.
+
+
+
+
+
+
+
+
diff --git a/sig-01/01/index.html b/sig-01/01/index.html
new file mode 100644
index 0000000..d01f779
--- /dev/null
+++ b/sig-01/01/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ Document
+
+
+
+
+
\ No newline at end of file
diff --git a/sig-01/admin.php b/sig-01/admin.php
new file mode 100644
index 0000000..13c951d
--- /dev/null
+++ b/sig-01/admin.php
@@ -0,0 +1,904 @@
+
+
+
+
+
+
+Admin β WebGIS SPBU
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Panel Admin
+
WebGIS SPBU
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Tambah SPBU
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Seret marker untuk pindah lokasi
+
+
+
+
Keterangan
+
+
+ Buka 24 Jam
+
+
+
+ Non-24 Jam
+
+
+
+ Posisi baru
+
+
+
+
+
+
+
+
+
+
diff --git a/sig-01/api/spbu.php b/sig-01/api/spbu.php
new file mode 100644
index 0000000..82b371c
--- /dev/null
+++ b/sig-01/api/spbu.php
@@ -0,0 +1,152 @@
+ $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);
diff --git a/sig-01/config/database.php b/sig-01/config/database.php
new file mode 100644
index 0000000..9431b86
--- /dev/null
+++ b/sig-01/config/database.php
@@ -0,0 +1,30 @@
+ 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;
+}
diff --git a/sig-01/index.php b/sig-01/index.php
new file mode 100644
index 0000000..54f96f0
--- /dev/null
+++ b/sig-01/index.php
@@ -0,0 +1,383 @@
+
+
+
+
+
+WebGIS SPBU β Peta Sebaran Stasiun BBM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Keterangan
+
+
+ Buka 24 Jam
+
+
+
+ Non-24 Jam
+
+
+
+
+
+
+
+
+
diff --git a/sig-01/setup.sql b/sig-01/setup.sql
new file mode 100644
index 0000000..76dd841
--- /dev/null
+++ b/sig-01/setup.sql
@@ -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);
diff --git a/sig-02/README.md b/sig-02/README.md
new file mode 100644
index 0000000..369f445
--- /dev/null
+++ b/sig-02/README.md
@@ -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).
diff --git a/sig-02/api/jalan.php b/sig-02/api/jalan.php
new file mode 100644
index 0000000..3a30e31
--- /dev/null
+++ b/sig-02/api/jalan.php
@@ -0,0 +1,15 @@
+ '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),
+ ];
+ },
+]);
diff --git a/sig-02/api/tanah.php b/sig-02/api/tanah.php
new file mode 100644
index 0000000..da0b3a6
--- /dev/null
+++ b/sig-02/api/tanah.php
@@ -0,0 +1,17 @@
+ '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),
+ ];
+ },
+]);
diff --git a/sig-02/assets/app.js b/sig-02/assets/app.js
new file mode 100644
index 0000000..6ea7ced
--- /dev/null
+++ b/sig-02/assets/app.js
@@ -0,0 +1,477 @@
+/* =========================================================================
+ * SIG Mapping Tanah & Jalan
+ * Leaflet + Leaflet.draw, backend vanilla PHP.
+ * ========================================================================= */
+
+const API = {
+ tanah: 'api/tanah.php',
+ jalan: 'api/jalan.php',
+};
+
+const THEME = {
+ tanah: { color: '#22c55e', label: 'Tanah', geom: 'Polygon' },
+ jalan: { color: '#ef4444', label: 'Jalan', geom: 'LineString' },
+};
+
+// Penyimpanan layer & data per id
+const store = {
+ tanah: { items: [], layers: new Map() },
+ jalan: { items: [], layers: new Map() },
+};
+
+let activeTab = 'tanah';
+let searchQuery = '';
+
+/* --------------------------- Util format --------------------------- */
+function fmtArea(m2) {
+ if (m2 >= 10000) return (m2 / 10000).toFixed(2) + ' ha (' + Math.round(m2).toLocaleString('id') + ' mΒ²)';
+ return Math.round(m2).toLocaleString('id') + ' mΒ²';
+}
+function fmtLen(m) {
+ if (m >= 1000) return (m / 1000).toFixed(2) + ' km (' + Math.round(m).toLocaleString('id') + ' m)';
+ return Math.round(m).toLocaleString('id') + ' m';
+}
+
+/* --------------- Perhitungan klien (mirror dari PHP) --------------- */
+const R = 6378137.0;
+const rad = (d) => (d * Math.PI) / 180;
+
+function haversine(a, b) {
+ const dLat = rad(b[1] - a[1]);
+ const dLon = rad(b[0] - a[0]);
+ const h = Math.sin(dLat / 2) ** 2 +
+ Math.cos(rad(a[1])) * Math.cos(rad(b[1])) * Math.sin(dLon / 2) ** 2;
+ return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
+}
+function lineLength(coords) {
+ let t = 0;
+ for (let i = 1; i < coords.length; i++) t += haversine(coords[i - 1], coords[i]);
+ return t;
+}
+function ringArea(ring) {
+ const n = ring.length;
+ if (n < 3) return 0;
+ let area = 0;
+ for (let i = 0; i < n; i++) {
+ const p1 = ring[i], p2 = ring[(i + 1) % n];
+ area += rad(p2[0] - p1[0]) * (2 + Math.sin(rad(p1[1])) + Math.sin(rad(p2[1])));
+ }
+ return Math.abs((area * R * R) / 2);
+}
+function ringPerimeter(ring) {
+ if (ring.length < 2) return 0;
+ const closed = ring.slice();
+ const f = closed[0], l = closed[closed.length - 1];
+ if (f[0] !== l[0] || f[1] !== l[1]) closed.push(f);
+ return lineLength(closed);
+}
+
+/* Hitung ukuran dari sebuah GeoJSON geometry */
+function measure(kind, geometry) {
+ if (kind === 'tanah') {
+ const outer = geometry.coordinates[0] || [];
+ return { luas: ringArea(outer), keliling: ringPerimeter(outer) };
+ }
+ return { panjang: lineLength(geometry.coordinates) };
+}
+
+/* --------------------------- Peta --------------------------- */
+const map = L.map('map', { center: [-0.0263, 109.3425], zoom: 13 }); // Pontianak
+
+const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 19, attribution: '© OpenStreetMap',
+}).addTo(map);
+
+const sat = L.tileLayer(
+ 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
+ { maxZoom: 19, attribution: 'Esri World Imagery' }
+);
+L.control.layers({ 'Peta Jalan': osm, 'Satelit': sat }, null, { position: 'bottomright' }).addTo(map);
+
+// Group yang bisa diedit oleh Leaflet.draw
+const drawnItems = new L.FeatureGroup().addTo(map);
+
+const drawControl = new L.Control.Draw({
+ position: 'topright',
+ draw: {
+ polygon: {
+ showArea: true, metric: true, allowIntersection: false,
+ shapeOptions: { color: THEME.tanah.color, weight: 2, fillOpacity: 0.3 },
+ },
+ polyline: {
+ metric: true, showLength: true,
+ shapeOptions: { color: THEME.jalan.color, weight: 4 },
+ },
+ rectangle: false, circle: false, marker: false, circlemarker: false,
+ },
+ edit: { featureGroup: drawnItems, remove: false },
+});
+map.addControl(drawControl);
+
+/* --- Tombol "Tambah" di sidebar: aktifkan mode menggambar Leaflet.draw --- */
+let activeDrawer = null;
+function startDraw(kind) {
+ if (activeDrawer) { activeDrawer.disable(); activeDrawer = null; }
+ activeDrawer = kind === 'tanah'
+ ? new L.Draw.Polygon(map, drawControl.options.draw.polygon)
+ : new L.Draw.Polyline(map, drawControl.options.draw.polyline);
+ activeDrawer.enable();
+
+ const hint = document.getElementById('draw-hint');
+ if (hint) {
+ hint.innerHTML = kind === 'tanah'
+ ? 'Klik titik-titik batas tanah di peta, klik titik awal untuk menutup.'
+ : 'Klik titik-titik jalan 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. Luas & panjang otomatis.';
+ 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 `
+ ${escapeHtml(item.nama)}
+ ${item.pemilik ? 'Pemilik: ' + escapeHtml(item.pemilik) + '
' : ''}
+ ${item.kategori ? 'Status: ' + escapeHtml(item.kategori) + '
' : ''}
+ Luas: ${fmtArea(+item.luas)}
+ Keliling: ${fmtLen(+item.keliling)}
+ ${item.deskripsi ? '
' + escapeHtml(item.deskripsi) + '' : ''}
+
`;
+ }
+ return `
+ ${escapeHtml(item.nama)}
+ ${item.kategori ? 'Kategori: ' + escapeHtml(item.kategori) + '
' : ''}
+ ${item.jenis ? 'Jenis: ' + escapeHtml(item.jenis) + '
' : ''}
+ Panjang: ${fmtLen(+item.panjang)}
+ ${item.deskripsi ? '
' + escapeHtml(item.deskripsi) + '' : ''}
+
`;
+}
+
+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 = `Belum ada data.
Gambar di peta untuk menambahkan.`;
+ return;
+ }
+ if (!items.length) {
+ ul.innerHTML = `Tidak ada hasil untuk
"${escapeHtml(searchQuery)}".`;
+ 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 `
+
+
+
${escapeHtml(it.nama)}
+
${sub}
+
${ukuran}
+
+
+
+
+
+ `;
+ }).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'
+ ? `
+ Keliling
${fmtLen(m.keliling)}
`
+ : `Panjang
${fmtLen(m.panjang)}
`;
+
+ modal.classList.remove('hidden');
+ setTimeout(() => document.getElementById('f-nama').focus(), 50);
+}
+
+function closeModal() {
+ modal.classList.add('hidden');
+ // bila ada layer gambar yang belum disimpan -> buang
+ if (pendingLayer) { drawnItems.removeLayer(pendingLayer); pendingLayer = null; }
+}
+document.getElementById('modal-close').onclick = closeModal;
+document.getElementById('modal-cancel').onclick = closeModal;
+
+form.onsubmit = async (e) => {
+ e.preventDefault();
+ const kind = document.getElementById('f-kind').value;
+ const id = document.getElementById('f-id').value;
+ const payload = {
+ nama: document.getElementById('f-nama').value.trim(),
+ deskripsi: document.getElementById('f-deskripsi').value.trim(),
+ warna: document.getElementById('f-warna').value,
+ geojson: JSON.parse(document.getElementById('f-geojson').value),
+ };
+ if (kind === 'tanah') {
+ payload.pemilik = document.getElementById('f-pemilik').value.trim();
+ payload.kategori = document.getElementById('f-kategori-tanah').value;
+ } else {
+ payload.jenis = document.getElementById('f-jenis').value;
+ payload.kategori = document.getElementById('f-kategori-jalan').value;
+ }
+
+ const btn = document.getElementById('modal-save');
+ btn.disabled = true; btn.textContent = 'Menyimpan...';
+ try {
+ await apiSave(kind, payload, id || null);
+ pendingLayer = null; // sudah tersimpan; akan dirender ulang dari server
+ modal.classList.add('hidden');
+ await loadAll();
+ switchTab(kind);
+ } catch (err) {
+ alert(err.message);
+ } finally {
+ btn.disabled = false; btn.textContent = 'Simpan';
+ }
+};
+
+/* --------------------------- Aksi CRUD --------------------------- */
+function openEdit(kind, id) {
+ const item = store[kind].items.find((i) => i.id === id);
+ if (item) openModal(kind, 'edit', item);
+}
+
+async function doDelete(kind, id) {
+ const item = store[kind].items.find((i) => i.id === id);
+ if (!confirm(`Hapus "${item?.nama}"?`)) return;
+ try {
+ await apiDelete(kind, id);
+ removeFeatureLayer(kind, id);
+ await loadAll();
+ } catch (err) { alert(err.message); }
+}
+
+/* --------------------------- Event Leaflet.draw --------------------------- */
+map.on(L.Draw.Event.CREATED, (e) => {
+ const layer = e.layer;
+ const kind = e.layerType === 'polygon' ? 'tanah' : 'jalan';
+ pendingLayer = layer;
+ drawnItems.addLayer(layer);
+ const geojson = layer.toGeoJSON().geometry;
+ openModal(kind, 'create', { geojson });
+});
+
+// Edit geometri (toolbar edit Leaflet.draw)
+map.on(L.Draw.Event.EDITED, async (e) => {
+ const jobs = [];
+ e.layers.eachLayer((l) => {
+ if (!l.feature_id) return;
+ const kind = l.kind;
+ const item = store[kind].items.find((i) => i.id === l.feature_id);
+ if (!item) return;
+ const geojson = l.toGeoJSON().geometry;
+ jobs.push(apiSave(kind, {
+ nama: item.nama, deskripsi: item.deskripsi, warna: item.warna,
+ pemilik: item.pemilik, jenis: item.jenis, kategori: item.kategori, geojson,
+ }, item.id));
+ });
+ try { await Promise.all(jobs); await loadAll(); }
+ catch (err) { alert('Gagal menyimpan perubahan geometri: ' + err.message); await loadAll(); }
+});
+
+/* --------------------------- Tab --------------------------- */
+function switchTab(kind) {
+ activeTab = kind;
+ document.querySelectorAll('.tab-btn').forEach((b) => {
+ const on = b.dataset.tab === kind;
+ b.classList.toggle('border-emerald-500', on && kind === 'tanah');
+ b.classList.toggle('border-red-500', on && kind === 'jalan');
+ b.classList.toggle('text-emerald-600', on && kind === 'tanah');
+ b.classList.toggle('text-red-600', on && kind === 'jalan');
+ b.classList.toggle('border-transparent', !on);
+ b.classList.toggle('text-slate-500', !on);
+ });
+ document.getElementById('list-tanah').classList.toggle('hidden', kind !== 'tanah');
+ document.getElementById('list-jalan').classList.toggle('hidden', kind !== 'jalan');
+}
+document.querySelectorAll('.tab-btn').forEach((b) => (b.onclick = () => switchTab(b.dataset.tab)));
+
+/* --------------------------- Pencarian --------------------------- */
+const searchInput = document.getElementById('search');
+const searchClear = document.getElementById('search-clear');
+
+function runSearch() {
+ searchQuery = searchInput.value.trim().toLowerCase();
+ searchClear.classList.toggle('hidden', !searchQuery);
+ renderList('tanah');
+ renderList('jalan');
+ applyMapFilter();
+}
+searchInput.addEventListener('input', runSearch);
+searchClear.addEventListener('click', () => {
+ searchInput.value = '';
+ searchInput.focus();
+ runSearch();
+});
+
+/* --------------------------- Util --------------------------- */
+function escapeHtml(s) {
+ return String(s ?? '').replace(/[&<>"']/g, (c) =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+}
+
+/* --------------------------- Start --------------------------- */
+loadAll().catch((err) => {
+ console.error(err);
+ alert('Gagal memuat data. Pastikan database sudah dibuat (import schema.sql).');
+});
diff --git a/sig-02/config/database.php b/sig-02/config/database.php
new file mode 100644
index 0000000..4b03efe
--- /dev/null
+++ b/sig-02/config/database.php
@@ -0,0 +1,25 @@
+ PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => false,
+ ]);
+ }
+ return $pdo;
+}
diff --git a/sig-02/includes/crud.php b/sig-02/includes/crud.php
new file mode 100644
index 0000000..65feb08
--- /dev/null
+++ b/sig-02/includes/crud.php
@@ -0,0 +1,165 @@
+ 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]);
+}
diff --git a/sig-02/includes/geo.php b/sig-02/includes/geo.php
new file mode 100644
index 0000000..653be1d
--- /dev/null
+++ b/sig-02/includes/geo.php
@@ -0,0 +1,109 @@
+ 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']];
+}
diff --git a/sig-02/index.php b/sig-02/index.php
new file mode 100644
index 0000000..155ca5d
--- /dev/null
+++ b/sig-02/index.php
@@ -0,0 +1,180 @@
+
+
+
+
+
+ SIG — Mapping Tanah & Jalan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Simpan Data
+
+
+
+
+
+
+
+
+
diff --git a/sig-02/schema.sql b/sig-02/schema.sql
new file mode 100644
index 0000000..8b23c63
--- /dev/null
+++ b/sig-02/schema.sql
@@ -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;
diff --git a/sig-02/seed.php b/sig-02/seed.php
new file mode 100644
index 0000000..a74a2e0
--- /dev/null
+++ b/sig-02/seed.php
@@ -0,0 +1,153 @@
+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.";
+}
diff --git a/sig-03/database.sql b/sig-03/database.sql
new file mode 100644
index 0000000..04d1b58
--- /dev/null
+++ b/sig-03/database.sql
@@ -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;
diff --git a/sig-03/get_data.php b/sig-03/get_data.php
new file mode 100644
index 0000000..05f410f
--- /dev/null
+++ b/sig-03/get_data.php
@@ -0,0 +1,65 @@
+ 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();
diff --git a/sig-03/hapus_laporan.php b/sig-03/hapus_laporan.php
new file mode 100644
index 0000000..fedc214
--- /dev/null
+++ b/sig-03/hapus_laporan.php
@@ -0,0 +1,12 @@
+ 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();
diff --git a/sig-03/hapus_pusat.php b/sig-03/hapus_pusat.php
new file mode 100644
index 0000000..f1b6f64
--- /dev/null
+++ b/sig-03/hapus_pusat.php
@@ -0,0 +1,14 @@
+ 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();
diff --git a/sig-03/hapus_rumah.php b/sig-03/hapus_rumah.php
new file mode 100644
index 0000000..6e122cf
--- /dev/null
+++ b/sig-03/hapus_rumah.php
@@ -0,0 +1,12 @@
+ 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();
diff --git a/sig-03/index.html b/sig-03/index.html
new file mode 100644
index 0000000..fdbd84f
--- /dev/null
+++ b/sig-03/index.html
@@ -0,0 +1,381 @@
+
+
+
+
+
+ BantSOSial GIS β Distribusi Bantuan Sosial
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Pilih role sesuai hak akses Anda. Perubahan langsung berlaku.
+
+
+
π
+
Admin
+
Akses penuh β tambah, edit, hapus, dan kelola semua data
+
+ β Tambah & Edit
+ β Hapus & Reset
+ β Lihat laporan
+ β Kirim laporan
+
+
+
+
π
+
Surveyer
+
Input data & lihat laporan β tidak bisa hapus atau reset
+
+ β Tambah & Edit
+ β Hapus & Reset
+ β Lihat laporan
+ β Kirim laporan
+
+
+
+
π
+
Viewer
+
Hanya lihat peta & kirim laporan masyarakat
+
+ β Lihat peta
+ β Edit data
+ β Lihat laporan
+ β Kirim laporan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Belum ada laporan masuk.
+
+
+
+
+
+
+
π
+
Akses Terbatas
+
+ Sebagai Viewer, Anda dapat mengirim laporan tapi tidak dapat melihat daftar laporan yang masuk.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sig-03/koneksi.php b/sig-03/koneksi.php
new file mode 100644
index 0000000..1b01902
--- /dev/null
+++ b/sig-03/koneksi.php
@@ -0,0 +1,19 @@
+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');
diff --git a/sig-03/reset.php b/sig-03/reset.php
new file mode 100644
index 0000000..76253a8
--- /dev/null
+++ b/sig-03/reset.php
@@ -0,0 +1,16 @@
+ 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();
diff --git a/sig-03/script.js b/sig-03/script.js
new file mode 100644
index 0000000..31df933
--- /dev/null
+++ b/sig-03/script.js
@@ -0,0 +1,1462 @@
+/**
+ * BantSOSial GIS β script.js
+ * WebGIS Distribusi Bantuan Sosial
+ */
+
+// ============================================================
+// CONFIG
+// ============================================================
+const MAP_CENTER = [-0.0532, 109.3458];
+const MAP_ZOOM = 15;
+const DEFAULT_RADIUS = 300;
+
+// ============================================================
+// ROLE SYSTEM
+// ============================================================
+let currentRole = 'admin';
+
+const ROLE_CONFIG = {
+ admin: {
+ label: 'Admin', icon: 'π',
+ canAdd: true, canEdit: true, canDelete: true, canReset: true,
+ canReport: true, canViewReport: true, canViewDetail: true,
+ canEditKas: true, canDragRadius: true
+ },
+ surveyer: {
+ label: 'Surveyer', icon: 'π',
+ canAdd: true, canEdit: true, canDelete: false, canReset: false,
+ canReport: true, canViewReport: true, canViewDetail: true,
+ canEditKas: false, canDragRadius: false
+ },
+ viewer: {
+ label: 'Viewer', icon: 'π',
+ canAdd: false, canEdit: false, canDelete: false, canReset: false,
+ canReport: true, canViewReport: false, canViewDetail: true,
+ canEditKas: false, canDragRadius: false
+ }
+};
+
+function can(action) { return ROLE_CONFIG[currentRole]?.[action] === true; }
+
+function openRoleModal() {
+ document.getElementById('roleModal').classList.remove('hidden');
+ document.querySelectorAll('.role-card').forEach(c => c.classList.remove('active-role'));
+ document.getElementById(`roleCard_${currentRole}`)?.classList.add('active-role');
+}
+function closeRoleModal() { document.getElementById('roleModal').classList.add('hidden'); }
+
+function setRole(role) {
+ currentRole = role;
+ const cfg = ROLE_CONFIG[role];
+ document.getElementById('roleIcon').textContent = cfg.icon;
+ document.getElementById('roleLabel').textContent = cfg.label;
+ document.querySelectorAll('.role-card').forEach(c => c.classList.remove('active-role'));
+ document.getElementById(`roleCard_${role}`)?.classList.add('active-role');
+ applyRoleUI();
+ closeRoleModal();
+}
+
+function applyRoleUI() {
+ const cfg = ROLE_CONFIG[currentRole];
+
+ const btnGroup = document.getElementById('actionBtnGroup');
+ if (btnGroup) btnGroup.style.display = cfg.canAdd ? 'flex' : 'none';
+
+ const btnReset = document.getElementById('btnReset');
+ if (btnReset) btnReset.style.display = cfg.canReset ? 'block' : 'none';
+
+ const notice = document.getElementById('viewerNotice');
+ if (notice) notice.style.display = currentRole === 'viewer' ? 'block' : 'none';
+
+ const reportColList = document.getElementById('reportColList');
+ const reportColViewer = document.getElementById('reportColViewer');
+ if (cfg.canViewReport) {
+ if (reportColList) reportColList.style.display = 'flex';
+ if (reportColViewer) reportColViewer.style.display = 'none';
+ } else {
+ if (reportColList) reportColList.style.display = 'none';
+ if (reportColViewer) reportColViewer.style.display = 'flex';
+ }
+
+ updateReportBadge();
+
+ centers.forEach(c => { if (c.marker) c.marker.setPopupContent(buildCenterPopup(c)); });
+ houses.forEach(h => { if (h.marker) h.marker.setPopupContent(buildHouseMapPopup(h)); });
+ updateSidebar();
+
+ centers.forEach(c => {
+ if (c.handle) {
+ if (cfg.canDragRadius) c.handle.dragging?.enable();
+ else c.handle.dragging?.disable();
+ }
+ if (c.marker) {
+ if (cfg.canEdit) c.marker.dragging?.enable();
+ else c.marker.dragging?.disable();
+ }
+ });
+}
+
+// ============================================================
+// NAVIGATION
+// ============================================================
+function navigateTo(page) {
+ const pagePeta = document.getElementById('pagePeta');
+ const pagePelaporan = document.getElementById('pagePelaporan');
+ const navHome = document.getElementById('navHome');
+ const navReport = document.getElementById('navReport');
+
+ if (page === 'map') {
+ pagePeta.classList.add('active-page');
+ pagePelaporan.classList.remove('active-page');
+ navHome.classList.add('active');
+ navReport.classList.remove('active');
+ setTimeout(() => map.invalidateSize(), 100);
+ } else {
+ pagePeta.classList.remove('active-page');
+ pagePelaporan.classList.add('active-page');
+ navHome.classList.remove('active');
+ navReport.classList.add('active');
+ applyRoleUI();
+ renderReportList();
+ }
+}
+
+// ============================================================
+// MAP INIT
+// ============================================================
+const map = L.map('map', { zoomControl: false }).setView(MAP_CENTER, MAP_ZOOM);
+
+L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: 'Β© OpenStreetMap',
+ maxZoom: 19
+}).addTo(map);
+
+L.control.zoom({ position: 'bottomright' }).addTo(map);
+
+// ============================================================
+// STATE
+// ============================================================
+let isAddingCenter = false;
+let isAddingHouse = false;
+let centers = [];
+let houses = [];
+let reports = [];
+
+// ============================================================
+// ICON TYPES
+// ============================================================
+const CENTER_TYPE_MAP = {
+ 'masjid': { emoji: 'π', color: '#0d9488' },
+ 'musholla': { emoji: 'π', color: '#0d9488' },
+ 'surau': { emoji: 'π', color: '#0d9488' },
+ 'gereja katedral': { emoji: 'βͺ', color: '#2563eb' },
+ 'gereja katolik': { emoji: 'βͺ', color: '#2563eb' },
+ 'katedral': { emoji: 'βͺ', color: '#2563eb' },
+ 'gereja protestan': { emoji: 'βοΈ', color: '#7c3aed' },
+ 'gereja': { emoji: 'βͺ', color: '#1d4ed8' },
+ 'kapel': { emoji: 'βοΈ', color: '#7c3aed' },
+ 'vihara': { emoji: 'π', color: '#d97706' },
+ 'klenteng': { emoji: 'π', color: '#d97706' },
+ 'pura': { emoji: 'π', color: '#d97706' },
+ 'kuil': { emoji: 'π', color: '#d97706' },
+ 'sinagog': { emoji: 'β‘οΈ', color: '#1d4ed8' },
+ 'default': { emoji: 'ποΈ', color: '#64748b' }
+};
+
+function getCenterType(name) {
+ if (!name) return 'default';
+ const lower = name.toLowerCase();
+ const keys = Object.keys(CENTER_TYPE_MAP)
+ .filter(k => k !== 'default')
+ .sort((a, b) => b.length - a.length);
+ for (const k of keys) { if (lower.includes(k)) return k; }
+ return 'default';
+}
+
+function createCenterIcon(name) {
+ const info = CENTER_TYPE_MAP[getCenterType(name)] || CENTER_TYPE_MAP['default'];
+ return L.divIcon({
+ className: '',
+ html: `
+ ${info.emoji}
`,
+ iconSize: [38, 38], iconAnchor: [10, 38]
+ });
+}
+
+function createHouseIcon(aidStatus, hasData) {
+ const palette = {
+ helped: { bg: '#d97706', border: '#78350f', emoji: 'π ' },
+ not_helped: { bg: '#dc2626', border: '#7f1d1d', emoji: 'π' },
+ outside: { bg: '#16a34a', border: '#14532d', emoji: 'π ' },
+ nodata: { bg: '#94a3b8', border: '#475569', emoji: 'π' }
+ };
+ const key = !hasData ? 'nodata' : (aidStatus || 'outside');
+ const c = palette[key] || palette.nodata;
+ return L.divIcon({
+ className: '',
+ html: `
+ ${c.emoji}
`,
+ iconSize: [24, 24], iconAnchor: [6, 24]
+ });
+}
+
+function createHandleIcon() {
+ return L.divIcon({
+ className: 'radius-handle',
+ html: ``,
+ iconSize: [14, 14], iconAnchor: [7, 7]
+ });
+}
+
+// ============================================================
+// UTILS
+// ============================================================
+function haversineDistance(lat1, lng1, lat2, lng2) {
+ const R = 6371000;
+ const Ο1 = lat1 * Math.PI / 180, Ο2 = lat2 * Math.PI / 180;
+ const ΞΟ = (lat2 - lat1) * Math.PI / 180;
+ const ΞΞ» = (lng2 - lng1) * Math.PI / 180;
+ const a = Math.sin(ΞΟ / 2) ** 2 + Math.cos(Ο1) * Math.cos(Ο2) * Math.sin(ΞΞ» / 2) ** 2;
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+}
+
+function radiusPoint(lat, lng, dist, bearing = 90) {
+ const R = 6371000;
+ const Ο1 = lat * Math.PI / 180, Ξ»1 = lng * Math.PI / 180;
+ const brng = bearing * Math.PI / 180;
+ const Ο2 = Math.asin(Math.sin(Ο1) * Math.cos(dist / R) + Math.cos(Ο1) * Math.sin(dist / R) * Math.cos(brng));
+ const Ξ»2 = Ξ»1 + Math.atan2(Math.sin(brng) * Math.sin(dist / R) * Math.cos(Ο1), Math.cos(dist / R) - Math.sin(Ο1) * Math.sin(Ο2));
+ return L.latLng(Ο2 * 180 / Math.PI, Ξ»2 * 180 / Math.PI);
+}
+
+async function reverseGeocode(lat, lng) {
+ try {
+ const res = await fetch(
+ `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&addressdetails=1`,
+ { headers: { 'Accept-Language': 'id' } }
+ );
+ const data = await res.json();
+ const addr = data.address || {};
+ return {
+ full: data.display_name || '',
+ road: addr.road || addr.pedestrian || '',
+ village: addr.village || addr.suburb || addr.neighbourhood || '',
+ subdistrict: addr.city_district || addr.town || '',
+ city: addr.city || addr.county || '',
+ postcode: addr.postcode || ''
+ };
+ } catch {
+ return { full: `${lat.toFixed(5)}, ${lng.toFixed(5)}`, road: '', village: '', subdistrict: '', city: '', postcode: '' };
+ }
+}
+
+function formatRupiah(num) {
+ if (num === null || num === undefined || num === '') return 'β';
+ return 'Rp ' + Number(num).toLocaleString('id-ID');
+}
+
+function formatDate(d) {
+ if (!d) return 'β';
+ const dt = new Date(d);
+ if (isNaN(dt)) return d;
+ return dt.toLocaleDateString('id-ID', { day: '2-digit', month: 'long', year: 'numeric' });
+}
+
+function calcAge(tglLahir) {
+ if (!tglLahir) return null;
+ const today = new Date(), birth = new Date(tglLahir);
+ let age = today.getFullYear() - birth.getFullYear();
+ if (today < new Date(today.getFullYear(), birth.getMonth(), birth.getDate())) age--;
+ return age;
+}
+
+// ============================================================
+// AID STATUS LOGIC
+// ============================================================
+function getAidStatus(house) {
+ if (!house.hasData) return 'outside';
+ if (house.aidStatus === 'helped') return 'helped';
+ for (const c of centers) {
+ if (haversineDistance(house.lat, house.lng, c.lat, c.lng) <= c.radius) return 'not_helped';
+ }
+ return 'outside';
+}
+
+function getCoveringCenters(house) {
+ return centers.filter(c => haversineDistance(house.lat, house.lng, c.lat, c.lng) <= c.radius);
+}
+
+function findNearestCenter(house) {
+ let nearest = null, minDist = Infinity;
+ centers.forEach(c => {
+ const d = haversineDistance(house.lat, house.lng, c.lat, c.lng);
+ if (d < minDist) { minDist = d; nearest = c; }
+ });
+ return { center: nearest, distance: Math.round(minDist) };
+}
+
+function updateAllHouseIcons() {
+ houses.forEach(h => {
+ const status = getAidStatus(h);
+ if (h.aidStatus !== 'helped') h.aidStatus = (status === 'not_helped') ? 'not_helped' : 'outside';
+ h.marker.setIcon(createHouseIcon(h.aidStatus, h.hasData));
+ });
+ updateStats();
+}
+
+// ============================================================
+// STATS
+// ============================================================
+function updateStats() {
+ const total = houses.filter(h => h.hasData).length;
+ const helped = houses.filter(h => h.aidStatus === 'helped').length;
+ const inside = houses.filter(h => getAidStatus(h) === 'not_helped').length;
+ const outside = houses.filter(h => hasData && getAidStatus(h) === 'outside').length;
+
+ document.getElementById('statTotal').textContent = houses.length;
+ document.getElementById('statHelped').textContent = helped;
+ document.getElementById('statInside').textContent = inside;
+ document.getElementById('statOutside').textContent = houses.filter(h => h.hasData && getAidStatus(h) === 'outside').length;
+}
+
+// ============================================================
+// RADIUS DRAG HANDLE
+// ============================================================
+function addRadiusHandle(centerObj) {
+ if (centerObj.handle) map.removeLayer(centerObj.handle);
+ const tooltip = document.getElementById('radiusTooltip');
+ const handle = L.marker(radiusPoint(centerObj.lat, centerObj.lng, centerObj.radius), {
+ icon: createHandleIcon(), draggable: true, zIndexOffset: 500
+ }).addTo(map);
+
+ handle.on('drag', function(e) {
+ if (!can('canDragRadius')) return;
+ centerObj.radius = Math.max(50, Math.round(
+ haversineDistance(centerObj.lat, centerObj.lng, e.target.getLatLng().lat, e.target.getLatLng().lng)
+ ));
+ centerObj.circle.setRadius(centerObj.radius);
+ tooltip.textContent = `β Radius: ${centerObj.radius} m`;
+ tooltip.classList.remove('hidden');
+ updateAllHouseIcons(); updateSidebar();
+ });
+ handle.on('dragend', function() {
+ if (!can('canDragRadius')) { handle.setLatLng(radiusPoint(centerObj.lat, centerObj.lng, centerObj.radius)); return; }
+ handle.setLatLng(radiusPoint(centerObj.lat, centerObj.lng, centerObj.radius));
+ tooltip.classList.add('hidden');
+ saveCenterBackend(centerObj, true);
+ if (centerObj.marker.isPopupOpen()) centerObj.marker.setPopupContent(buildCenterPopup(centerObj));
+ });
+ centerObj.handle = handle;
+}
+
+// ============================================================
+// POPUPS
+// ============================================================
+function buildHouseMapPopup(house) {
+ const status = getAidStatus(house);
+ const badgeCls = !house.hasData ? 'nodata' : status;
+ const badgeTxt = !house.hasData ? 'Belum ada data'
+ : status === 'helped' ? 'Sudah Dibantu'
+ : status === 'not_helped' ? 'Dalam Radius'
+ : 'Luar Radius';
+
+ const kkName = house.anggota?.[0]?.nama || '';
+
+ let actionBtns = '';
+ if (house.hasData) {
+ actionBtns = ``;
+ if (can('canEdit')) actionBtns += ``;
+ } else {
+ if (can('canEdit')) actionBtns = ``;
+ else actionBtns = `Belum ada data`;
+ }
+
+ const deleteBtn = can('canDelete')
+ ? ``
+ : '';
+
+ return `
+ `;
+}
+
+function buildCenterPopup(c) {
+ const covered = houses.filter(h => haversineDistance(h.lat, h.lng, c.lat, c.lng) <= c.radius);
+ const helped = covered.filter(h => h.aidStatus === 'helped').length;
+ const info = CENTER_TYPE_MAP[getCenterType(c.name)] || CENTER_TYPE_MAP['default'];
+
+ const kasEditHtml = can('canEditKas') ? `
+ ` : '';
+
+ const kasFormHtml = can('canEditKas') ? `
+ ` : '';
+
+ const editBtn = can('canEdit')
+ ? `
+
+
` : '';
+
+ const deleteBtn = can('canDelete')
+ ? `
+
+
` : '';
+
+ return `${editBtn}${deleteBtn}`;
+}
+
+// ============================================================
+// KAS EDIT (popup inline)
+// ============================================================
+function toggleEditKas(centerId) {
+ const form = document.getElementById(`kasEditForm_${centerId}`);
+ const badge = document.getElementById(`kasBadge_${centerId}`);
+ if (!form) return;
+ const visible = form.style.display !== 'none';
+ form.style.display = visible ? 'none' : 'block';
+ badge.style.display = visible ? 'flex' : 'none';
+ if (!visible) setTimeout(() => document.getElementById(`kasInput_${centerId}`)?.focus(), 50);
+}
+
+function saveKas(centerId) {
+ const center = centers.find(c => c.id === centerId);
+ if (!center) return;
+ center.kas = parseFloat(document.getElementById(`kasInput_${centerId}`)?.value) || 0;
+ const kasVal = document.getElementById(`kasVal_${centerId}`);
+ if (kasVal) kasVal.textContent = formatRupiah(center.kas);
+ toggleEditKas(centerId);
+ updateCenterList();
+ saveCenterBackend(center, true);
+}
+
+// ============================================================
+// CENTER EDIT MODAL
+// ============================================================
+function openCenterEditModal(centerId) {
+ if (!can('canEdit')) { alert('Role Anda tidak dapat mengedit data.'); return; }
+ const c = centers.find(c => c.id === centerId);
+ if (!c) return;
+ map.closePopup();
+
+ document.getElementById('ce_id').value = c.id;
+ document.getElementById('ce_name').value = c.name;
+ document.getElementById('ce_address').value = c.address || '';
+ document.getElementById('ce_kas').value = c.kas || 0;
+ document.getElementById('ce_radius').value = c.radius;
+
+ const sel = document.getElementById('ce_type');
+ const type = getCenterType(c.name);
+ const match = Array.from(sel.options).find(o => o.value === type);
+ sel.value = match ? type : 'default';
+
+ document.getElementById('centerEditModal').classList.remove('hidden');
+ setTimeout(() => document.getElementById('ce_name')?.focus(), 100);
+}
+function closeCenterEditModal() { document.getElementById('centerEditModal').classList.add('hidden'); }
+
+function saveCenterEdit() {
+ const id = parseInt(document.getElementById('ce_id')?.value);
+ const name = document.getElementById('ce_name')?.value?.trim();
+ const address = document.getElementById('ce_address')?.value?.trim();
+ const kas = parseFloat(document.getElementById('ce_kas')?.value) || 0;
+ const radius = parseInt(document.getElementById('ce_radius')?.value) || DEFAULT_RADIUS;
+
+ if (!name) { document.getElementById('ce_name').focus(); return; }
+ if (radius < 50) { alert('Radius minimal 50 meter!'); return; }
+
+ const center = centers.find(c => c.id === id);
+ if (!center) return;
+
+ center.name = name;
+ center.address = address;
+ center.kas = kas;
+ center.radius = radius;
+
+ center.marker.setIcon(createCenterIcon(name));
+ center.circle.setRadius(radius);
+ if (center.handle) center.handle.setLatLng(radiusPoint(center.lat, center.lng, radius));
+ center.marker.setPopupContent(buildCenterPopup(center));
+
+ closeCenterEditModal();
+ updateAllHouseIcons();
+ updateSidebar();
+ saveCenterBackend(center, true);
+}
+
+// ============================================================
+// ADD MODES
+// ============================================================
+function startAddingCenter() {
+ if (!can('canAdd')) { alert('Role Anda tidak dapat menambah data.'); return; }
+ if (isAddingCenter) { cancelModes(); return; }
+ cancelModes(); isAddingCenter = true; setModeUI('center');
+}
+function startAddingHouse() {
+ if (!can('canAdd')) { alert('Role Anda tidak dapat menambah data.'); return; }
+ if (isAddingHouse) { cancelModes(); return; }
+ cancelModes(); isAddingHouse = true; setModeUI('house');
+}
+function setModeUI(mode) {
+ const badge = document.getElementById('modeIndicator');
+ badge.classList.remove('hidden', 'house-mode');
+ map.getContainer().classList.add('adding-mode');
+ if (mode === 'center') {
+ document.getElementById('modeIndicatorText').textContent = 'Klik peta untuk menempatkan rumah ibadah';
+ document.getElementById('btnAddCenter').classList.add('active');
+ document.getElementById('btnAddCenter').textContent = 'β Batalkan';
+ } else {
+ badge.classList.add('house-mode');
+ document.getElementById('modeIndicatorText').textContent = 'Klik peta untuk menempatkan titik rumah miskin';
+ document.getElementById('btnAddHouse').classList.add('active');
+ document.getElementById('btnAddHouse').textContent = 'β Batalkan';
+ }
+}
+function cancelModes() {
+ isAddingCenter = isAddingHouse = false;
+ document.getElementById('modeIndicator').classList.add('hidden');
+ document.getElementById('modeIndicator').classList.remove('house-mode');
+ document.getElementById('btnAddCenter').classList.remove('active');
+ document.getElementById('btnAddCenter').innerHTML = 'π Tambah Rumah Ibadah';
+ document.getElementById('btnAddHouse').classList.remove('active');
+ document.getElementById('btnAddHouse').innerHTML = 'π Tambah Rumah Miskin';
+ map.getContainer().classList.remove('adding-mode');
+}
+
+map.on('click', async function(e) {
+ if (!isAddingCenter && !isAddingHouse) return;
+ const { lat, lng } = e.latlng;
+ if (isAddingCenter) { cancelModes(); openCenterForm(lat, lng, e.latlng); }
+ else { cancelModes(); placeHousePin(lat, lng); }
+});
+
+// ============================================================
+// CENTER FORM (map popup)
+// ============================================================
+async function openCenterForm(lat, lng, latlng) {
+ const popup = L.popup({ maxWidth: 290, closeOnClick: false })
+ .setLatLng(latlng)
+ .setContent(`
+ `)
+ .openOn(map);
+
+ const geo = await reverseGeocode(lat, lng);
+ const loadEl = document.getElementById('fpAddrLoading');
+ const addrEl = document.getElementById('fpAddr');
+ if (loadEl) loadEl.style.display = 'none';
+ if (addrEl) { addrEl.style.display = 'block'; addrEl.value = geo.full; }
+}
+
+async function submitCenter(lat, lng) {
+ const name = document.getElementById('fpName')?.value?.trim();
+ const address = document.getElementById('fpAddr')?.value?.trim() || `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
+ const kas = parseFloat(document.getElementById('fpKas')?.value) || 0;
+ const radius = parseInt(document.getElementById('fpRadius')?.value) || DEFAULT_RADIUS;
+ if (!name) { document.getElementById('fpName')?.focus(); return; }
+ map.closePopup();
+ addCenter({ name, address, kas, lat, lng, radius });
+}
+
+function addCenter(data) {
+ const marker = L.marker([data.lat, data.lng], {
+ icon: createCenterIcon(data.name),
+ draggable: can('canEdit'),
+ zIndexOffset: 200
+ }).addTo(map);
+
+ const circle = L.circle([data.lat, data.lng], {
+ radius: data.radius, color: '#3b82f6', weight: 1.5,
+ fillColor: '#3b82f6', fillOpacity: 0.07, dashArray: '6 4'
+ }).addTo(map);
+
+ const centerObj = {
+ id: data.id || null, name: data.name, address: data.address,
+ kas: data.kas || 0, lat: data.lat, lng: data.lng,
+ radius: data.radius, marker, circle, handle: null
+ };
+
+ marker.bindPopup(() => buildCenterPopup(centerObj), { maxWidth: 260 });
+ marker.on('popupopen', () => marker.setPopupContent(buildCenterPopup(centerObj)));
+ marker.on('dragend', function(e) {
+ centerObj.lat = e.target.getLatLng().lat;
+ centerObj.lng = e.target.getLatLng().lng;
+ circle.setLatLng([centerObj.lat, centerObj.lng]);
+ if (centerObj.handle) centerObj.handle.setLatLng(radiusPoint(centerObj.lat, centerObj.lng, centerObj.radius));
+ updateAllHouseIcons(); updateSidebar();
+ saveCenterBackend(centerObj, true);
+ });
+
+ addRadiusHandle(centerObj);
+ if (!can('canDragRadius')) centerObj.handle?.dragging?.disable();
+ if (!can('canEdit')) marker.dragging?.disable();
+
+ centers.push(centerObj);
+
+ if (!data.id) {
+ saveCenterBackend(centerObj, false);
+ } else {
+ marker.openPopup();
+ updateAllHouseIcons(); updateSidebar();
+ }
+}
+
+async function saveCenterBackend(centerObj, isUpdate) {
+ const body = new URLSearchParams({
+ id: centerObj.id || 0,
+ name: centerObj.name, address: centerObj.address || '',
+ kas: centerObj.kas, lat: centerObj.lat, lng: centerObj.lng,
+ radius: centerObj.radius, update: isUpdate ? '1' : '0'
+ });
+ try {
+ const res = await fetch('simpan_pusat.php', { method: 'POST', body });
+ const data = await res.json();
+ if (data.success && !centerObj.id) {
+ centerObj.id = data.id;
+ centerObj.marker.setPopupContent(buildCenterPopup(centerObj));
+ centerObj.marker.openPopup();
+ updateAllHouseIcons(); updateSidebar();
+ }
+ } catch (e) { console.error('saveCenterBackend', e); }
+}
+
+// ============================================================
+// HOUSE PIN
+// ============================================================
+async function placeHousePin(lat, lng) {
+ const marker = L.marker([lat, lng], { icon: createHouseIcon(null, false), zIndexOffset: 100 }).addTo(map);
+
+ const house = {
+ id: null, lat, lng, address: '',
+ rt: '', rw: '', kelurahan: '',
+ statusMiskin: '', jumlahAnggota: 0, anggota: [],
+ aidStatus: 'outside', hasData: false,
+ marker, _geoData: null
+ };
+ houses.push(house);
+
+ marker.bindPopup(() => buildHouseMapPopup(house), { maxWidth: 270 });
+ marker.on('popupopen', () => marker.setPopupContent(buildHouseMapPopup(house)));
+ marker.openPopup();
+
+ try {
+ const geo = await reverseGeocode(lat, lng);
+ house.address = geo.full || `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
+ house._geoData = geo;
+ } catch {
+ house.address = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
+ }
+
+ marker.setPopupContent(buildHouseMapPopup(house));
+ updateStats(); updateHouseList();
+ await saveHouseBackend(house, false);
+}
+
+async function saveHouseBackend(house, isUpdate) {
+ const body = new URLSearchParams({
+ id: house.id || 0,
+ lat: house.lat,
+ lng: house.lng,
+ address: house.address || '',
+ rt: house.rt || '',
+ rw: house.rw || '',
+ kelurahan: house.kelurahan || '',
+ status_miskin: house.statusMiskin || '',
+ jumlah_anggota: house.jumlahAnggota || 0,
+ anggota: JSON.stringify(house.anggota || []),
+ aid_status: house.aidStatus || 'outside',
+ has_data: house.hasData ? 1 : 0,
+ update: isUpdate ? '1' : '0'
+ });
+ try {
+ const res = await fetch('simpan_rumah.php', { method: 'POST', body });
+ const data = await res.json();
+ if (data.success && !house.id) {
+ house.id = data.id;
+ house.marker.setPopupContent(buildHouseMapPopup(house));
+ updateHouseList();
+ }
+ } catch (e) { console.error('saveHouseBackend', e); }
+}
+
+// ============================================================
+// HOUSE DATA MODAL
+// ============================================================
+function openHouseModal(houseId) {
+ if (!can('canEdit')) { alert('Role Anda tidak dapat mengedit data.'); return; }
+ const house = houses.find(h => h.id === houseId);
+ if (!house) return;
+ map.closePopup();
+
+ const covering = getCoveringCenters(house);
+ const nearestR = findNearestCenter(house);
+ const geo = house._geoData || {};
+
+ const centersHtml = covering.length > 0
+ ? covering.map(c => {
+ const info = CENTER_TYPE_MAP[getCenterType(c.name)] || CENTER_TYPE_MAP['default'];
+ return `${info.emoji} ${c.name}`;
+ }).join('')
+ : `Tidak ada β Terdekat: ${nearestR.center ? nearestR.center.name + ' (' + nearestR.distance + ' m)' : 'β'}`;
+
+ document.getElementById('houseModalBody').innerHTML = `
+
+
π Informasi Lokasi
+
+
Alamat${geo.road || house.address?.split(',')[0] || 'β'}
+
Kelurahan${geo.village || 'β'}
+
Kecamatan${geo.subdistrict || 'β'}
+
Kota${geo.city || 'β'}
+
+
+
+
+
π Rumah Ibadah yang Menangani
+
${centersHtml}
+
+
+
π Status Kemiskinan
+
+
π’ Sangat Miskin
+
π Miskin
+
π Tidak Miskin
+
+
+
+
+
π¨βπ©βπ§ Anggota Keluarga
+
+
+
+
+
+
`;
+
+ if (house.jumlahAnggota > 0) renderMemberForms(houseId);
+ if (house.statusMiskin) setTimeout(() => selectStatusMiskin(house.statusMiskin), 0);
+
+ document.getElementById('houseModal').classList.remove('hidden');
+
+ const modal = document.getElementById('houseModal');
+ const oldBar = modal.querySelector('.modal-actions');
+ if (oldBar) oldBar.remove();
+ const bar = document.createElement('div');
+ bar.className = 'modal-actions';
+ bar.innerHTML = `
+
+ `;
+ modal.querySelector('.modal-box').appendChild(bar);
+}
+
+function closeHouseModal() { document.getElementById('houseModal').classList.add('hidden'); }
+
+function selectStatusMiskin(val) {
+ document.querySelectorAll('.status-pill').forEach(el => el.classList.remove('selected', 'sangat_miskin', 'miskin', 'tidak_miskin'));
+ document.querySelector(`.status-pill[data-val="${val}"]`)?.classList.add('selected', val);
+ const hidden = document.getElementById('hm_statusMiskin');
+ if (hidden) hidden.value = val;
+}
+
+function renderMemberForms(houseId) {
+ const house = houses.find(h => h.id === houseId);
+ const jumlah = parseInt(document.getElementById('hm_jumlah')?.value) || 0;
+ const container = document.getElementById('memberFormsContainer');
+ if (!container) return;
+ container.innerHTML = '';
+ for (let i = 0; i < Math.min(jumlah, 20); i++) {
+ container.insertAdjacentHTML('beforeend', buildMemberForm(i, house?.anggota?.[i] || {}));
+ }
+}
+
+function buildMemberForm(i, data = {}) {
+ const statusOpts = ['Kepala Keluarga', 'Istri/Suami', 'Anak', 'Orang Tua', 'Saudara', 'Lainnya']
+ .map(s => ``).join('');
+ const pekerjaanOpts = ['Tidak Bekerja', 'Pelajar/Mahasiswa', 'Buruh/Karyawan', 'Wiraswasta', 'PNS/TNI/Polri', 'Petani/Nelayan', 'Lainnya']
+ .map(p => ``).join('');
+ const showSalary = data.pekerjaan && !['Tidak Bekerja', 'Pelajar/Mahasiswa'].includes(data.pekerjaan);
+ const displayDate = data.tglLahir ? formatDate(data.tglLahir) : '';
+
+ return ``;
+}
+
+function toggleSalary(i) {
+ const pekerjaan = document.getElementById(`m_pkrj_${i}`)?.value;
+ const row = document.getElementById(`salary_row_${i}`);
+ if (!row) return;
+ const hide = ['Tidak Bekerja', 'Pelajar/Mahasiswa'].includes(pekerjaan);
+ row.classList.toggle('visible', !hide);
+}
+
+async function saveHouseData(houseId) {
+ const house = houses.find(h => h.id === houseId);
+ if (!house) return;
+
+ house.rt = document.getElementById('hm_rt')?.value?.trim() || '';
+ house.rw = document.getElementById('hm_rw')?.value?.trim() || '';
+ house.kelurahan = document.getElementById('hm_kel')?.value?.trim() || '';
+ house.statusMiskin = document.getElementById('hm_statusMiskin')?.value || '';
+ house.jumlahAnggota = parseInt(document.getElementById('hm_jumlah')?.value) || 0;
+
+ house.anggota = [];
+ for (let i = 0; i < house.jumlahAnggota; i++) {
+ house.anggota.push({
+ nama: document.getElementById(`m_nama_${i}`)?.value?.trim() || '',
+ statusAnggota: document.getElementById(`m_status_${i}`)?.value || '',
+ tglLahir: document.getElementById(`m_tgl_${i}`)?.value || '',
+ pekerjaan: document.getElementById(`m_pkrj_${i}`)?.value || '',
+ gaji: parseFloat(document.getElementById(`m_gaji_${i}`)?.value) || 0
+ });
+ }
+ house.hasData = house.jumlahAnggota > 0 || !!house.statusMiskin;
+
+ const status = getAidStatus(house);
+ if (house.aidStatus !== 'helped') house.aidStatus = status;
+
+ house.marker.setIcon(createHouseIcon(house.aidStatus, house.hasData));
+ house.marker.setPopupContent(buildHouseMapPopup(house));
+
+ closeHouseModal();
+ updateStats(); updateHouseList();
+ await saveHouseBackend(house, true);
+}
+
+// ============================================================
+// DETAIL MODAL
+// ============================================================
+function openDetailModal(houseId) {
+ const house = houses.find(h => h.id === houseId);
+ if (!house) return;
+ map.closePopup();
+
+ const covering = getCoveringCenters(house);
+ const status = getAidStatus(house);
+ const nearestR = findNearestCenter(house);
+
+ const statusLabels = {
+ sangat_miskin: 'π’ Sangat Miskin',
+ miskin: 'π Miskin',
+ tidak_miskin: 'π Tidak Miskin',
+ '': 'β'
+ };
+ const aidLabels = {
+ helped: 'β
Sudah Dibantu',
+ not_helped: 'β³ Belum Dibantu',
+ outside: 'π’ Luar Radius'
+ };
+
+ const membersHtml = house.anggota.length > 0
+ ? `
+ | # | Nama | Status | Usia | Pekerjaan | Penghasilan |
+
` +
+ house.anggota.map((m, i) => {
+ const age = calcAge(m.tglLahir);
+ return `
+ | ${i + 1} |
+ ${m.nama || 'β'} |
+ ${m.statusAnggota || 'β'} |
+ ${age !== null ? age + ' th' : 'β'} |
+ ${m.pekerjaan || 'β'} |
+ ${m.gaji ? formatRupiah(m.gaji) : 'β'} |
+
`;
+ }).join('') + `
`
+ : 'Belum ada data anggota keluarga.
';
+
+ const nearestInfo = nearestR.center
+ ? `${nearestR.center.name} (${nearestR.distance} m)`
+ : 'β';
+
+ const aidBtns = can('canEdit') ? `
+
+
+
+
+
` : '';
+
+ document.getElementById('detailModalBody').innerHTML = `
+
+
π Lokasi
+
+
Alamat
${house.address || 'β'}
+
RT / RW
${house.rt || 'β'} / ${house.rw || 'β'}
+
Kelurahan
${house.kelurahan || 'β'}
+
Koordinat
${house.lat.toFixed(5)}, ${house.lng.toFixed(5)}
+
+
+
+
π Status Bantuan
+
+
Kemiskinan
${statusLabels[house.statusMiskin] || 'β'}
+
Status Bantuan
${aidLabels[status] || 'β'}
+
Rumah Ibadah
${covering.length > 0 ? covering.map(c => c.name).join(', ') : 'Luar radius'}
+
+
+ ${aidBtns}
+
+
+
π¨βπ©βπ§ Anggota Keluarga (${house.jumlahAnggota} orang)
+ ${membersHtml}
+
`;
+
+ document.getElementById('detailModal').classList.remove('hidden');
+}
+function closeDetailModal() { document.getElementById('detailModal').classList.add('hidden'); }
+
+async function setAidStatus(houseId, newStatus, centerId) {
+ const house = houses.find(h => h.id === houseId);
+ if (!house) return;
+ house.aidStatus = newStatus;
+ house.marker.setIcon(createHouseIcon(house.aidStatus, house.hasData));
+ house.marker.setPopupContent(buildHouseMapPopup(house));
+ updateStats(); updateHouseList();
+ closeDetailModal();
+
+ try {
+ const body = new URLSearchParams({ house_id: houseId, aid_status: newStatus, center_id: centerId });
+ await fetch('update_status.php', { method: 'POST', body });
+ } catch (e) { console.error('setAidStatus', e); }
+}
+
+// ============================================================
+// DELETE
+// ============================================================
+function deleteHouse(houseId) {
+ if (!can('canDelete')) return;
+ if (!confirm('Hapus data rumah ini?')) return;
+ const idx = houses.findIndex(h => h.id === houseId);
+ if (idx === -1) return;
+ map.removeLayer(houses[idx].marker);
+ houses.splice(idx, 1);
+ updateStats(); updateHouseList();
+ fetch('hapus_rumah.php', { method: 'POST', body: new URLSearchParams({ id: houseId }) });
+}
+
+function deleteCenter(centerId) {
+ if (!can('canDelete')) return;
+ if (!confirm('Hapus rumah ibadah ini beserta data terkait?')) return;
+ const idx = centers.findIndex(c => c.id === centerId);
+ if (idx === -1) return;
+ map.removeLayer(centers[idx].marker);
+ map.removeLayer(centers[idx].circle);
+ if (centers[idx].handle) map.removeLayer(centers[idx].handle);
+ centers.splice(idx, 1);
+ map.closePopup();
+ updateAllHouseIcons(); updateSidebar();
+ fetch('hapus_pusat.php', { method: 'POST', body: new URLSearchParams({ id: centerId }) });
+}
+
+// ============================================================
+// RESET
+// ============================================================
+function confirmReset() {
+ if (!can('canReset')) return;
+ if (!confirm('RESET semua data? Tindakan ini tidak dapat dibatalkan!')) return;
+ centers.forEach(c => {
+ map.removeLayer(c.marker);
+ map.removeLayer(c.circle);
+ if (c.handle) map.removeLayer(c.handle);
+ });
+ houses.forEach(h => map.removeLayer(h.marker));
+ centers = []; houses = []; reports = [];
+ updateStats(); updateSidebar();
+ fetch('reset.php', { method: 'POST' });
+}
+
+// ============================================================
+// SIDEBAR
+// ============================================================
+function updateSidebar() { updateCenterList(); updateHouseList(); }
+
+function filterCenters() {
+ const query = document.getElementById('searchCenter')?.value?.toLowerCase() || '';
+ const sort = document.getElementById('sortCenter')?.value || '';
+
+ let list = centers.filter(c => c.name.toLowerCase().includes(query) || (c.address || '').toLowerCase().includes(query));
+
+ if (sort === 'kas_desc') list.sort((a, b) => (b.kas || 0) - (a.kas || 0));
+ else if (sort === 'kas_asc') list.sort((a, b) => (a.kas || 0) - (b.kas || 0));
+ else if (sort === 'tanggungan_desc') list.sort((a, b) => countCovered(b) - countCovered(a));
+ else if (sort === 'tanggungan_asc') list.sort((a, b) => countCovered(a) - countCovered(b));
+
+ renderCenterList(list);
+}
+
+function countCovered(c) {
+ return houses.filter(h => haversineDistance(h.lat, h.lng, c.lat, c.lng) <= c.radius).length;
+}
+
+function updateCenterList() {
+ document.getElementById('centerCount').textContent = centers.length;
+ filterCenters();
+}
+
+function renderCenterList(list) {
+ const el = document.getElementById('centerList');
+ if (list.length === 0) { el.innerHTML = 'Belum ada rumah ibadah.
'; return; }
+
+ el.innerHTML = list.map(c => {
+ const info = CENTER_TYPE_MAP[getCenterType(c.name)] || CENTER_TYPE_MAP['default'];
+ const covered = countCovered(c);
+ const helped = houses.filter(h => haversineDistance(h.lat, h.lng, c.lat, c.lng) <= c.radius && h.aidStatus === 'helped').length;
+ const delBtn = can('canDelete') ? `` : '';
+ return `
+
${info.emoji}
+
+
${c.name}
+
${c.address || 'β'}
+
+ β ${c.radius}m
+ π ${covered}
+ π° ${formatRupiah(c.kas)}
+ ${helped > 0 ? `β
${helped}` : ''}
+
+
+ ${delBtn}
+
`;
+ }).join('');
+}
+
+function focusCenter(centerId) {
+ const c = centers.find(c => c.id === centerId);
+ if (!c) return;
+ map.setView([c.lat, c.lng], 16);
+ c.marker.openPopup();
+}
+
+function filterHouses() {
+ const query = document.getElementById('searchHouse')?.value?.toLowerCase() || '';
+ const filterStatus = document.getElementById('filterStatus')?.value || '';
+ const filterMiskin = document.getElementById('filterMiskin')?.value || '';
+
+ let list = houses.filter(h => {
+ const kkName = h.anggota?.[0]?.nama?.toLowerCase() || '';
+ const addr = (h.address || '').toLowerCase();
+ const matchQ = !query || kkName.includes(query) || addr.includes(query);
+ const matchS = !filterStatus || getAidStatus(h) === filterStatus;
+ const matchM = !filterMiskin || h.statusMiskin === filterMiskin;
+ return matchQ && matchS && matchM;
+ });
+
+ renderHouseList(list);
+}
+
+function updateHouseList() {
+ document.getElementById('houseCount').textContent = houses.length;
+ filterHouses();
+}
+
+function renderHouseList(list) {
+ const el = document.getElementById('houseList');
+ if (list.length === 0) { el.innerHTML = 'Belum ada data.
'; return; }
+
+ el.innerHTML = list.map(h => {
+ const status = getAidStatus(h);
+ const kkName = h.anggota?.[0]?.nama || `Rumah #${h.id}`;
+ const addr = h.kelurahan || h.address?.split(',')[0] || 'β';
+ const icon = h.hasData ? (status === 'helped' ? 'π ' : status === 'not_helped' ? 'π' : 'π ') : 'π';
+ const delBtn = can('canDelete') ? `` : '';
+ const statusLabels = { helped: 'β
Dibantu', not_helped: 'β³ Belum', outside: 'π’ Luar', nodata: 'π Pin' };
+ const miskinLabels = { sangat_miskin: 'π’ Sangat Miskin', miskin: 'π Miskin', tidak_miskin: 'π Tidak Miskin' };
+
+ return `
+
${icon}
+
+
${kkName}
+
${addr}
+
+ ${statusLabels[h.hasData ? status : 'nodata'] || status}
+ ${h.statusMiskin ? `${miskinLabels[h.statusMiskin]}` : ''}
+
+
+ ${delBtn}
+
`;
+ }).join('');
+}
+
+function focusHouse(houseId) {
+ const h = houses.find(h => h.id === houseId);
+ if (!h) return;
+ map.setView([h.lat, h.lng], 17);
+ h.marker.openPopup();
+}
+
+// ============================================================
+// REPORTS
+// ============================================================
+function updateReportBadge() {
+ const badge = document.getElementById('navReportBadge');
+ const count = can('canViewReport') ? reports.filter(r => r.status === 'baru').length : 0;
+ if (badge) {
+ badge.textContent = count;
+ badge.classList.toggle('hidden', count === 0);
+ }
+}
+
+function filterReports() {
+ const query = document.getElementById('searchReport')?.value?.toLowerCase() || '';
+ const status = document.getElementById('filterReportStatus')?.value || '';
+ const list = reports.filter(r => {
+ const matchQ = !query || r.text.toLowerCase().includes(query) || (r.name || '').toLowerCase().includes(query);
+ const matchS = !status || r.status === status;
+ return matchQ && matchS;
+ });
+ renderReportList(list);
+}
+
+function renderReportList(list) {
+ if (!list) list = reports;
+ document.getElementById('reportCount').textContent = reports.length;
+
+ const el = document.getElementById('reportList');
+ if (list.length === 0) { el.innerHTML = 'Belum ada laporan.
'; return; }
+
+ el.innerHTML = list.map(r => {
+ const statusBadge = `${r.status === 'baru' ? 'π Baru' : r.status === 'ditangani' ? 'π Ditangani' : 'β
Selesai'}`;
+ const img = r.imgBase64 ? `
` : '';
+ const actions = can('canDelete')
+ ? `` : '';
+ const statusBtns = can('canViewReport')
+ ? `
+
+ ` : '';
+
+ return `
+
+
${r.text}
+ ${img}
+
+
`;
+ }).join('');
+}
+
+async function changeReportStatus(reportId, newStatus) {
+ const r = reports.find(r => r.id === reportId);
+ if (!r) return;
+ r.status = newStatus;
+ filterReports();
+ updateReportBadge();
+ try {
+ await fetch('update_laporan.php', { method: 'POST', body: new URLSearchParams({ id: reportId, status: newStatus }) });
+ } catch (e) { console.error(e); }
+}
+
+async function deleteReport(reportId) {
+ if (!can('canDelete')) return;
+ if (!confirm('Hapus laporan ini?')) return;
+ reports = reports.filter(r => r.id !== reportId);
+ filterReports();
+ updateReportBadge();
+ try {
+ await fetch('hapus_laporan.php', { method: 'POST', body: new URLSearchParams({ id: reportId }) });
+ } catch (e) { console.error(e); }
+}
+
+// ============================================================
+// REPORT SUBMIT
+// ============================================================
+let reportImgBase64 = '';
+
+function previewImage(event) {
+ const file = event.target.files[0];
+ if (!file) return;
+ if (file.size > 5_000_000) { alert('Ukuran file maksimal 5MB'); return; }
+ const reader = new FileReader();
+ reader.onload = e => {
+ reportImgBase64 = e.target.result;
+ document.getElementById('previewImg').src = e.target.result;
+ document.getElementById('imgPreview').style.display = 'block';
+ document.getElementById('uploadArea').style.display = 'none';
+ };
+ reader.readAsDataURL(file);
+}
+
+function removeImage() {
+ reportImgBase64 = '';
+ document.getElementById('reportImg').value = '';
+ document.getElementById('imgPreview').style.display = 'none';
+ document.getElementById('uploadArea').style.display = 'block';
+}
+
+async function submitReport() {
+ const text = document.getElementById('reportText')?.value?.trim();
+ const name = document.getElementById('reportName')?.value?.trim() || 'Anonim';
+ const lokasi = document.getElementById('reportLocation')?.value?.trim() || '';
+
+ if (!text) { document.getElementById('reportText')?.focus(); alert('Deskripsi laporan tidak boleh kosong'); return; }
+
+ try {
+ const body = new URLSearchParams({ name, text, lokasi, img: reportImgBase64 });
+ const res = await fetch('simpan_laporan.php', { method: 'POST', body });
+ const data = await res.json();
+ if (data.success) {
+ const newReport = {
+ id: data.id, name, text, lokasi,
+ imgBase64: reportImgBase64 || null,
+ status: 'baru',
+ time: new Date().toLocaleDateString('id-ID', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
+ };
+ reports.unshift(newReport);
+ document.getElementById('reportText').value = '';
+ document.getElementById('reportName').value = '';
+ document.getElementById('reportLocation').value = '';
+ removeImage();
+ filterReports();
+ updateReportBadge();
+ alert('Laporan berhasil dikirim! Terima kasih.');
+ } else {
+ alert('Gagal kirim laporan: ' + (data.message || 'Error'));
+ }
+ } catch (e) { console.error(e); alert('Terjadi kesalahan. Coba lagi.'); }
+}
+
+// ============================================================
+// DATE PICKER
+// ============================================================
+let _dpTargetInput = null;
+let _dpTargetBtn = null;
+let _dpYear = new Date().getFullYear();
+let _dpMonth = new Date().getMonth();
+let _dpSelected = null;
+
+function openDatepicker(inputId, btnId) {
+ _dpTargetInput = document.getElementById(inputId);
+ _dpTargetBtn = document.getElementById(btnId);
+ const existing = _dpTargetInput?.value;
+ if (existing) {
+ const d = new Date(existing);
+ _dpYear = d.getFullYear();
+ _dpMonth = d.getMonth();
+ _dpSelected = existing;
+ } else {
+ _dpYear = new Date().getFullYear();
+ _dpMonth = new Date().getMonth();
+ _dpSelected = null;
+ }
+ renderDatepicker();
+ document.getElementById('datepickerOverlay').classList.remove('hidden');
+}
+
+function closeDatepicker() { document.getElementById('datepickerOverlay').classList.add('hidden'); }
+function closeDatepickerIfOutside(e) { if (e.target.id === 'datepickerOverlay') closeDatepicker(); }
+
+function renderDatepicker() {
+ const box = document.getElementById('datepickerBox');
+ const months = ['Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember'];
+ const days = ['Min','Sen','Sel','Rab','Kam','Jum','Sab'];
+
+ const firstDay = new Date(_dpYear, _dpMonth, 1).getDay();
+ const daysInMonth = new Date(_dpYear, _dpMonth + 1, 0).getDate();
+ const today = new Date();
+
+ let gridHtml = days.map(d => `${d}
`).join('');
+ for (let i = 0; i < firstDay; i++) {
+ const prevDays = new Date(_dpYear, _dpMonth, 0).getDate();
+ gridHtml += `${prevDays - firstDay + i + 1}
`;
+ }
+ for (let d = 1; d <= daysInMonth; d++) {
+ const dateStr = `${_dpYear}-${String(_dpMonth + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
+ const isToday = d === today.getDate() && _dpMonth === today.getMonth() && _dpYear === today.getFullYear();
+ const isSel = dateStr === _dpSelected;
+ const isFuture = new Date(_dpYear, _dpMonth, d) > today;
+ gridHtml += `${d}
`;
+ }
+
+ box.innerHTML = `
+
+ ${gridHtml}
+ `;
+}
+
+function dpNav(dir) {
+ _dpMonth += dir;
+ if (_dpMonth < 0) { _dpMonth = 11; _dpYear--; }
+ if (_dpMonth > 11) { _dpMonth = 0; _dpYear++; }
+ renderDatepicker();
+}
+
+function selectDate(dateStr) {
+ _dpSelected = dateStr;
+ renderDatepicker();
+}
+
+function confirmDate() {
+ if (!_dpSelected || !_dpTargetInput) { closeDatepicker(); return; }
+ _dpTargetInput.value = _dpSelected;
+ if (_dpTargetBtn) _dpTargetBtn.textContent = formatDate(_dpSelected);
+ closeDatepicker();
+}
+
+// ============================================================
+// LOAD DATA FROM BACKEND
+// ============================================================
+async function loadData() {
+ try {
+ const res = await fetch('get_data.php');
+ const data = await res.json();
+ if (!data.success) return;
+
+ data.centers.forEach(c => {
+ addCenter({ id: c.id, name: c.name, address: c.address, kas: c.kas, lat: c.latitude, lng: c.longitude, radius: c.radius });
+ });
+
+ data.houses.forEach(h => {
+ const marker = L.marker([h.latitude, h.longitude], {
+ icon: createHouseIcon(h.aid_status, !!h.has_data),
+ zIndexOffset: 100
+ }).addTo(map);
+
+ const house = {
+ id: h.id, lat: h.latitude, lng: h.longitude,
+ address: h.address, rt: h.rt, rw: h.rw, kelurahan: h.kelurahan,
+ statusMiskin: h.status_miskin, jumlahAnggota: h.jumlah_anggota,
+ anggota: h.anggota || [], aidStatus: h.aid_status,
+ hasData: !!h.has_data, marker, _geoData: null
+ };
+ marker.bindPopup(() => buildHouseMapPopup(house), { maxWidth: 270 });
+ marker.on('popupopen', () => marker.setPopupContent(buildHouseMapPopup(house)));
+ houses.push(house);
+ });
+
+ reports = data.reports || [];
+
+ updateStats();
+ updateSidebar();
+ updateReportBadge();
+ } catch (e) {
+ console.error('loadData', e);
+ }
+}
+
+// ============================================================
+// INIT
+// ============================================================
+applyRoleUI();
+loadData();
diff --git a/sig-03/seed.sql b/sig-03/seed.sql
new file mode 100644
index 0000000..1fd73c0
--- /dev/null
+++ b/sig-03/seed.sql
@@ -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');
diff --git a/sig-03/simpan_laporan.php b/sig-03/simpan_laporan.php
new file mode 100644
index 0000000..63fee22
--- /dev/null
+++ b/sig-03/simpan_laporan.php
@@ -0,0 +1,28 @@
+ 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();
diff --git a/sig-03/simpan_pusat.php b/sig-03/simpan_pusat.php
new file mode 100644
index 0000000..3cd9746
--- /dev/null
+++ b/sig-03/simpan_pusat.php
@@ -0,0 +1,41 @@
+ 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();
diff --git a/sig-03/simpan_rumah.php b/sig-03/simpan_rumah.php
new file mode 100644
index 0000000..69395e2
--- /dev/null
+++ b/sig-03/simpan_rumah.php
@@ -0,0 +1,49 @@
+ 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();
diff --git a/sig-03/style.css b/sig-03/style.css
new file mode 100644
index 0000000..d11596a
--- /dev/null
+++ b/sig-03/style.css
@@ -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; }
diff --git a/sig-03/update_laporan.php b/sig-03/update_laporan.php
new file mode 100644
index 0000000..5fd6eb9
--- /dev/null
+++ b/sig-03/update_laporan.php
@@ -0,0 +1,24 @@
+ 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();
diff --git a/sig-03/update_radius.php b/sig-03/update_radius.php
new file mode 100644
index 0000000..72856e1
--- /dev/null
+++ b/sig-03/update_radius.php
@@ -0,0 +1,22 @@
+ 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();
diff --git a/sig-03/update_status.php b/sig-03/update_status.php
new file mode 100644
index 0000000..95d0218
--- /dev/null
+++ b/sig-03/update_status.php
@@ -0,0 +1,41 @@
+ 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();