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.

+
+ +
+ + +
+ +
+
+ +
+

+ +

+

+ +

+
+ + + + + +
+
+ Buka proyek + +
+
+ +
+ + +
+ + 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

+
+
+ +

+ Password salah, coba lagi. +

+ +
+ + +
+ + + ← Kembali ke Peta Publik + +
+
+
+ + + + + +
+ + +
+ + + + + + + + +
+ + + + + +
+
+
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' + ? `
    Luas
    ${fmtArea(m.luas)}
    +
    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 + + + + + + + + + + + + + + + + +
    + + + + + +
    +
    + + +
    +
    + + + + + + + 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 + + + + + + + + + + + + + + +
    + + + +
    + +
    + + +
    +
    + +
    +
    +
    πŸ“’ Kirim Laporan
    +
    Laporkan kondisi kemiskinan yang belum terdata
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    πŸ“·
    +
    Klik untuk upload foto
    +
    JPG, PNG, maks 5MB
    + +
    + +
    + +
    +
    + +
    +
    +
    + πŸ“‹ Laporan Masuk + 0 +
    +
    Daftar laporan dari masyarakat
    +
    +
    + + +
    +
    +
    Belum ada laporan 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 `
    +
    ● ${badgeTxt}
    +
    🏠 Rumah #${house.id}${kkName ? ' β€” ' + kkName : ''}
    +
    ${house.address || 'Mengambil alamat...'}
    +
    +
    ${actionBtns}${deleteBtn}
    `; +} + +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 `
    +
    +
    ${info.emoji}
    +
    ${c.name}
    +
    +
    πŸ“ ${c.address || 'β€”'}
    +
    + πŸ’° Kas + ${formatRupiah(c.kas)} + ${kasEditHtml} +
    + ${kasFormHtml} +
    + β­• Radius${c.radius} m +
    +
    + 🏠 Dalam radius${covered.length} (${helped} dibantu) +
    +
    ${can('canDragRadius') ? 'πŸ’‘ Drag titik biru untuk ubah radius' : 'πŸ‘ Radius hanya bisa dilihat'}
    +
    ${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(` +
    +

    πŸ•Œ Tambah Rumah Ibadah

    +
    + + +
    +
    + + +
    +
    + +
    ⏳ Mengambil alamat...
    + +
    +
    + + +
    +
    + + +
    + + +
    `) + .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 = ` + + + + `; + + 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 `
    +
    ${i + 1}
    Anggota ke-${i + 1}
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + +
    + + +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    `; +} + +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 + ? ` + + ` + + house.anggota.map((m, i) => { + const age = calcAge(m.tglLahir); + return ` + + + + + + + `; + }).join('') + `
    #NamaStatusUsiaPekerjaanPenghasilan
    ${i + 1}${m.nama || 'β€”'}${m.statusAnggota || 'β€”'}${age !== null ? age + ' th' : 'β€”'}${m.pekerjaan || 'β€”'}${m.gaji ? formatRupiah(m.gaji) : 'β€”'}
    ` + : '
    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'}
    +
    Terdekat
    ${nearestInfo}
    +
    + ${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.name || 'Anonim'}
    ${r.lokasi ? 'πŸ“ ' + r.lokasi + ' Β· ' : ''}${r.time}
    + ${statusBadge} +
    +
    ${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 = ` +
    + +
    ${months[_dpMonth]} ${_dpYear}
    + +
    +
    ${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();