commit 35e1a93aeb57780c36d3a53050b6e04e4543e2b6 Author: Abimanyu Ridho Date: Sat Jun 6 00:22:16 2026 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e60579 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.env +.env.* +api/config/database.local.php +*.sql +!database.sql +*.sqlite +*.sqlite3 +*.db +*.bak +*.backup +node_modules/ +.vscode/ +.idea/ +Thumbs.db +desktop.ini \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..39844fa --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# WebGIS Poverty + +Proyek WebGIS sederhana untuk visualisasi data kemiskinan dan fasilitas. + +**Persyaratan** +- PHP + Apache (mis. XAMPP) +- MySQL + +**Setup singkat** +1. Tempatkan folder proyek di `htdocs` (sudah di c:\xampp\htdocs...). +2. Mulai Apache dan MySQL (XAMPP). +3. Buat database bernama `webgis_poverty` atau sesuaikan `api/config/database.local.php`. +4. Buka [index.html](index.html) di browser. + +**File konfigurasi penting** +- `api/config/database.local.php` — konfigurasi koneksi database lokal. +- `api/config/database.local.php.example` — contoh konfigurasi. + +**Endpoint API** +- `api/data_jalan.php` +- `api/data_parsil.php` +- `api/log_bantuan.php` +- `api/penduduk_miskin.php` +- `api/rumah_ibadah.php` +- `api/spbu.php` + +**Frontend** +- CSS: `css/app.css` +- JS: `js/app-core.js`, `js/feature-*.js` diff --git a/api/config/.htaccess b/api/config/.htaccess new file mode 100644 index 0000000..9e9e4b6 --- /dev/null +++ b/api/config/.htaccess @@ -0,0 +1,9 @@ + + Require all denied + + + Deny from all + + +# Disable directory listing +Options -Indexes diff --git a/api/config/database.local.php.example b/api/config/database.local.php.example new file mode 100644 index 0000000..5034a38 --- /dev/null +++ b/api/config/database.local.php.example @@ -0,0 +1,9 @@ + 'localhost', + 'user' => 'root', + 'pass' => 'password', + 'name' => 'webgis_poverty', + 'charset' => 'utf8mb4', +]; \ No newline at end of file diff --git a/api/config/database.php b/api/config/database.php new file mode 100644 index 0000000..615e13c --- /dev/null +++ b/api/config/database.php @@ -0,0 +1,52 @@ + getenv('DB_HOST') ?: 'localhost', + 'user' => getenv('DB_USER') ?: 'root', + 'pass' => getenv('DB_PASS') ?: '', + 'name' => getenv('DB_NAME') ?: 'webgis_poverty', + 'charset' => getenv('DB_CHARSET') ?: 'utf8mb4', + ]; + + $localConfigFile = __DIR__ . '/database.local.php'; + if (is_file($localConfigFile)) { + $localConfig = require $localConfigFile; + if (is_array($localConfig)) { + $config = array_merge($config, array_intersect_key($localConfig, $config)); + } + } + + return $config; + } +} + +$dbConfig = loadDatabaseConfig(); + +define('DB_HOST', $dbConfig['host']); +define('DB_USER', $dbConfig['user']); +define('DB_PASS', $dbConfig['pass']); +define('DB_NAME', $dbConfig['name']); +define('DB_CHARSET', $dbConfig['charset']); + +function getDB(): mysqli { + static $conn = null; + if ($conn === null) { + $conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME); + if ($conn->connect_error) { + error_log('DB connection failed: ' . $conn->connect_error); + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'message' => 'Koneksi database gagal' + ]); + exit; + } + $conn->set_charset(DB_CHARSET); + } + return $conn; +} \ No newline at end of file diff --git a/api/data_jalan.php b/api/data_jalan.php new file mode 100644 index 0000000..c84bbca --- /dev/null +++ b/api/data_jalan.php @@ -0,0 +1,112 @@ + 0) { + $stmt = $db->prepare( + "SELECT id, nama_jalan, status_jalan, + panjang_meter, keterangan, + CAST(geojson AS CHAR) AS geojson, + created_at + FROM data_jalan WHERE id = ?" + ); + $stmt->bind_param('i', $id); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + if (!$row) sendError('Data tidak ditemukan', 404); + // Parse geojson string menjadi object + $row['geojson'] = json_decode($row['geojson']); + $row['panjang_meter'] = (float)$row['panjang_meter']; + sendSuccess($row); + } else { + $rows = []; + $res = $db->query( + "SELECT id, nama_jalan, status_jalan, + panjang_meter, keterangan, + CAST(geojson AS CHAR) AS geojson, + created_at + FROM data_jalan ORDER BY created_at DESC" + ); + while ($r = $res->fetch_assoc()) { + $r['geojson'] = json_decode($r['geojson']); + $r['panjang_meter'] = (float)$r['panjang_meter']; + $rows[] = $r; + } + sendSuccess($rows); + } + break; + + case 'POST': + $body = getBody(); + foreach (['nama_jalan','status_jalan','geojson','panjang_meter'] as $f) { + if (!isset($body[$f])) sendError("Field '$f' wajib diisi"); + } + // Validasi GeoJSON LineString + $geo = is_array($body['geojson']) + ? $body['geojson'] + : json_decode($body['geojson'], true); + if (!$geo || $geo['type'] !== 'LineString') { + sendError('GeoJSON harus bertipe LineString'); + } + $geoStr = json_encode($geo); + $panjang = (float)$body['panjang_meter']; + + $stmt = $db->prepare( + "INSERT INTO data_jalan + (nama_jalan, status_jalan, panjang_meter, keterangan, geojson) + VALUES (?, ?, ?, ?, ?)" + ); + $stmt->bind_param('ssdss', + $body['nama_jalan'], $body['status_jalan'], + $panjang, $body['keterangan'], $geoStr + ); + executeOrFail($stmt, 'Gagal menyimpan data jalan'); + sendSuccess(['id' => $db->insert_id], 'Data jalan berhasil disimpan', 201); + break; + + case 'PUT': + if ($id <= 0) sendError('ID wajib disertakan'); + $body = getBody(); + $geo = is_array($body['geojson']) + ? $body['geojson'] + : json_decode($body['geojson'], true); + $geoStr = json_encode($geo); + $panjang = (float)$body['panjang_meter']; + + $stmt = $db->prepare( + "UPDATE data_jalan + SET nama_jalan=?, status_jalan=?, + panjang_meter=?, keterangan=?, geojson=? + WHERE id=?" + ); + $stmt->bind_param('ssdssi', + $body['nama_jalan'], $body['status_jalan'], + $panjang, $body['keterangan'], $geoStr, $id + ); + executeOrFail($stmt, 'Gagal memperbarui data jalan'); + sendSuccess(null, 'Data jalan berhasil diperbarui'); + break; + + case 'DELETE': + if ($id <= 0) sendError('ID wajib disertakan'); + $stmt = $db->prepare("DELETE FROM data_jalan WHERE id=?"); + $stmt->bind_param('i', $id); + executeOrFail($stmt, 'Gagal menghapus data jalan'); + $stmt->affected_rows > 0 + ? sendSuccess(null, 'Data jalan berhasil dihapus') + : sendError('Data tidak ditemukan', 404); + break; + + default: + sendError('Method tidak diizinkan', 405); +} \ No newline at end of file diff --git a/api/data_parsil.php b/api/data_parsil.php new file mode 100644 index 0000000..dff1eb8 --- /dev/null +++ b/api/data_parsil.php @@ -0,0 +1,112 @@ + 0) { + $stmt = $db->prepare( + "SELECT id, nama_pemilik, no_sertifikat, jenis_hak, + luas_m2, keterangan, + CAST(geojson AS CHAR) AS geojson, + created_at + FROM data_parsil WHERE id = ?" + ); + $stmt->bind_param('i', $id); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + if (!$row) sendError('Data tidak ditemukan', 404); + $row['geojson'] = json_decode($row['geojson']); + $row['luas_m2'] = (float)$row['luas_m2']; + sendSuccess($row); + } else { + $rows = []; + $res = $db->query( + "SELECT id, nama_pemilik, no_sertifikat, jenis_hak, + luas_m2, keterangan, + CAST(geojson AS CHAR) AS geojson, + created_at + FROM data_parsil ORDER BY created_at DESC" + ); + while ($r = $res->fetch_assoc()) { + $r['geojson'] = json_decode($r['geojson']); + $r['luas_m2'] = (float)$r['luas_m2']; + $rows[] = $r; + } + sendSuccess($rows); + } + break; + + case 'POST': + $body = getBody(); + foreach (['nama_pemilik','jenis_hak','geojson','luas_m2'] as $f) { + if (!isset($body[$f])) sendError("Field '$f' wajib diisi"); + } + $geo = is_array($body['geojson']) + ? $body['geojson'] + : json_decode($body['geojson'], true); + if (!$geo || $geo['type'] !== 'Polygon') { + sendError('GeoJSON harus bertipe Polygon'); + } + $geoStr = json_encode($geo); + $luas = (float)$body['luas_m2']; + + $stmt = $db->prepare( + "INSERT INTO data_parsil + (nama_pemilik, no_sertifikat, jenis_hak, luas_m2, keterangan, geojson) + VALUES (?, ?, ?, ?, ?, ?)" + ); + $stmt->bind_param('sssdss', + $body['nama_pemilik'], $body['no_sertifikat'], + $body['jenis_hak'], $luas, + $body['keterangan'], $geoStr + ); + executeOrFail($stmt, 'Gagal menyimpan data parsil'); + sendSuccess(['id' => $db->insert_id], 'Data parsil berhasil disimpan', 201); + break; + + case 'PUT': + if ($id <= 0) sendError('ID wajib disertakan'); + $body = getBody(); + $geo = is_array($body['geojson']) + ? $body['geojson'] + : json_decode($body['geojson'], true); + $geoStr = json_encode($geo); + $luas = (float)$body['luas_m2']; + + $stmt = $db->prepare( + "UPDATE data_parsil + SET nama_pemilik=?, no_sertifikat=?, jenis_hak=?, + luas_m2=?, keterangan=?, geojson=? + WHERE id=?" + ); + $stmt->bind_param('sssdssi', + $body['nama_pemilik'], $body['no_sertifikat'], + $body['jenis_hak'], $luas, + $body['keterangan'], $geoStr, $id + ); + executeOrFail($stmt, 'Gagal memperbarui data parsil'); + sendSuccess(null, 'Data parsil berhasil diperbarui'); + break; + + case 'DELETE': + if ($id <= 0) sendError('ID wajib disertakan'); + $stmt = $db->prepare("DELETE FROM data_parsil WHERE id=?"); + $stmt->bind_param('i', $id); + executeOrFail($stmt, 'Gagal menghapus data parsil'); + $stmt->affected_rows > 0 + ? sendSuccess(null, 'Data parsil berhasil dihapus') + : sendError('Data tidak ditemukan', 404); + break; + + default: + sendError('Method tidak diizinkan', 405); +} \ No newline at end of file diff --git a/api/helpers/response.php b/api/helpers/response.php new file mode 100644 index 0000000..17ad923 --- /dev/null +++ b/api/helpers/response.php @@ -0,0 +1,100 @@ + $v !== '')); +} + +function sendJSON(mixed $data, int $code = 200): void { + http_response_code($code); + echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +function sendSuccess(mixed $data = null, string $message = 'OK', int $code = 200): void { + sendJSON(['status' => 'success', 'message' => $message, 'data' => $data], $code); +} + +function sendError(string $message, int $code = 400): void { + sendJSON(['status' => 'error', 'message' => $message], $code); +} + +function requireWriteAuth(string $method): void { + if (!in_array($method, ['POST', 'PUT', 'DELETE'], true)) { + return; + } + + $expected = (string)(getenv('API_WRITE_TOKEN') ?: ''); + if ($expected === '') { + return; + } + + $provided = (string)($_SERVER['HTTP_X_API_TOKEN'] ?? ''); + if ($provided === '') { + $auth = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? ''); + if (preg_match('/Bearer\s+(.+)/i', $auth, $m)) { + $provided = trim($m[1]); + } + } + + if ($provided === '' || !hash_equals($expected, $provided)) { + sendError('Unauthorized', 401); + } +} + +function executeOrFail(mysqli_stmt $stmt, string $defaultMessage = 'Operasi database gagal'): void { + if ($stmt->execute()) { + return; + } + + $errno = $stmt->errno; + $error = $stmt->error; + error_log('DB stmt error [' . $errno . ']: ' . $error); + + if ($errno === 1062) { + sendError('Data duplikat terdeteksi', 409); + } + if ($errno === 1451 || $errno === 1452) { + sendError('Relasi data tidak valid', 422); + } + sendError($defaultMessage, 500); +} + +function getBody(): array { + $raw = file_get_contents('php://input'); + $data = json_decode($raw, true); + return is_array($data) ? $data : []; +} + +function getMethod(): string { + return strtoupper($_SERVER['REQUEST_METHOD']); +} \ No newline at end of file diff --git a/api/log_bantuan.php b/api/log_bantuan.php new file mode 100644 index 0000000..10bce89 --- /dev/null +++ b/api/log_bantuan.php @@ -0,0 +1,100 @@ + 0) { + // Semua log untuk satu penduduk miskin + $stmt = $db->prepare( + "SELECT lb.*, + ri.nama AS nama_rumah_ibadah, + ri.jenis AS jenis_rumah_ibadah + FROM log_bantuan lb + JOIN rumah_ibadah ri ON ri.id = lb.rumah_ibadah_id + WHERE lb.penduduk_miskin_id = ? + ORDER BY lb.tanggal_bantuan DESC" + ); + $stmt->bind_param('i', $pmId); + $stmt->execute(); + $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); + sendSuccess($rows); + } elseif ($id > 0) { + $stmt = $db->prepare( + "SELECT lb.*, + ri.nama AS nama_rumah_ibadah, + pm.nama AS nama_penduduk + FROM log_bantuan lb + JOIN rumah_ibadah ri ON ri.id = lb.rumah_ibadah_id + JOIN penduduk_miskin pm ON pm.id = lb.penduduk_miskin_id + WHERE lb.id = ?" + ); + $stmt->bind_param('i', $id); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $row ? sendSuccess($row) : sendError('Data tidak ditemukan', 404); + } else { + // Semua log bantuan dengan statistik + $sql = "SELECT lb.*, + ri.nama AS nama_rumah_ibadah, + pm.nama AS nama_penduduk + FROM log_bantuan lb + JOIN rumah_ibadah ri ON ri.id = lb.rumah_ibadah_id + JOIN penduduk_miskin pm ON pm.id = lb.penduduk_miskin_id + ORDER BY lb.tanggal_bantuan DESC + LIMIT 200"; + $rows = $db->query($sql)->fetch_all(MYSQLI_ASSOC); + sendSuccess($rows); + } + break; + + case 'POST': + $body = getBody(); + foreach (['penduduk_miskin_id','rumah_ibadah_id','tipe_bantuan','tanggal_bantuan'] as $f) { + if (empty($body[$f])) sendError("Field '$f' wajib diisi"); + } + $nilai = isset($body['nilai_bantuan']) && $body['nilai_bantuan'] !== '' + ? (float)$body['nilai_bantuan'] : null; + + $stmt = $db->prepare( + "INSERT INTO log_bantuan + (penduduk_miskin_id, rumah_ibadah_id, tipe_bantuan, + sub_kategori, keterangan, tanggal_bantuan, nilai_bantuan) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + $stmt->bind_param( + 'iissssd', + $body['penduduk_miskin_id'], + $body['rumah_ibadah_id'], + $body['tipe_bantuan'], + $body['sub_kategori'], + $body['keterangan'], + $body['tanggal_bantuan'], + $nilai + ); + executeOrFail($stmt, 'Gagal mencatat log bantuan'); + sendSuccess(['id' => $db->insert_id], 'Log bantuan berhasil dicatat', 201); + break; + + case 'DELETE': + if ($id <= 0) sendError('ID wajib disertakan'); + $stmt = $db->prepare("DELETE FROM log_bantuan WHERE id=?"); + $stmt->bind_param('i', $id); + executeOrFail($stmt, 'Gagal menghapus log bantuan'); + $stmt->affected_rows > 0 + ? sendSuccess(null, 'Log bantuan berhasil dihapus') + : sendError('Data tidak ditemukan', 404); + break; + + default: + sendError('Method tidak diizinkan', 405); +} \ No newline at end of file diff --git a/api/penduduk_miskin.php b/api/penduduk_miskin.php new file mode 100644 index 0000000..8b86ea2 --- /dev/null +++ b/api/penduduk_miskin.php @@ -0,0 +1,221 @@ +query("SELECT id, latitude, longitude FROM penduduk_miskin"); + $updated = 0; + while ($pm = $allPm->fetch_assoc()) { + $prox = hitungProximity($db, (float)$pm['latitude'], (float)$pm['longitude']); + $statusP = $prox['status']; + $riId = $prox['rumah_ibadah_id']; + $stmt = $db->prepare( + "UPDATE penduduk_miskin SET status_proximity=?, rumah_ibadah_id=? WHERE id=?" + ); + $stmt->bind_param('sii', $statusP, $riId, $pm['id']); + $stmt->execute(); + $updated++; + } + sendSuccess(['updated' => $updated], "Proximity $updated data diperbarui"); +} + +switch ($method) { + + // ── GET ─────────────────────────────────────────────────────────── + case 'GET': + if ($id > 0) { + $stmt = $db->prepare( + "SELECT pm.*, + ri.nama AS nama_rumah_ibadah, + ri.jenis AS jenis_rumah_ibadah + FROM penduduk_miskin pm + LEFT JOIN rumah_ibadah ri ON ri.id = pm.rumah_ibadah_id + WHERE pm.id = ?" + ); + $stmt->bind_param('i', $id); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + if (!$row) sendError('Data tidak ditemukan', 404); + $row['latitude'] = (float)$row['latitude']; + $row['longitude'] = (float)$row['longitude']; + + // Ambil ringkasan log bantuan + $stmtLog = $db->prepare( + "SELECT tipe_bantuan, COUNT(*) AS jumlah + FROM log_bantuan + WHERE penduduk_miskin_id = ? + GROUP BY tipe_bantuan" + ); + $stmtLog->bind_param('i', $id); + $stmtLog->execute(); + $logs = []; + $resLog = $stmtLog->get_result(); + while ($l = $resLog->fetch_assoc()) $logs[] = $l; + $row['ringkasan_bantuan'] = $logs; + + sendSuccess($row); + } else { + // GET semua + proximity recalculation cache + $sql = "SELECT pm.id, pm.nama, pm.no_wa, pm.nik, pm.alamat, + pm.jumlah_anggota, pm.latitude, pm.longitude, + pm.status_proximity, pm.rumah_ibadah_id, + ri.nama AS nama_rumah_ibadah, + -- hitung berapa kali dapat bantuan + (SELECT COUNT(*) FROM log_bantuan lb + WHERE lb.penduduk_miskin_id = pm.id) AS total_bantuan, + (SELECT COUNT(*) FROM log_bantuan lb + WHERE lb.penduduk_miskin_id = pm.id + AND lb.tipe_bantuan = 'Pemberdayaan') AS bantuan_pemberdayaan, + (SELECT COUNT(*) FROM log_bantuan lb + WHERE lb.penduduk_miskin_id = pm.id + AND lb.tipe_bantuan = 'Konsumtif') AS bantuan_konsumtif + FROM penduduk_miskin pm + LEFT JOIN rumah_ibadah ri ON ri.id = pm.rumah_ibadah_id + ORDER BY pm.created_at DESC"; + $result = $db->query($sql); + $rows = []; + while ($r = $result->fetch_assoc()) { + $r['latitude'] = (float)$r['latitude']; + $r['longitude'] = (float)$r['longitude']; + $r['total_bantuan'] = (int)$r['total_bantuan']; + $r['bantuan_pemberdayaan'] = (int)$r['bantuan_pemberdayaan']; + $r['bantuan_konsumtif'] = (int)$r['bantuan_konsumtif']; + $rows[] = $r; + } + sendSuccess($rows); + } + break; + + // ── POST ────────────────────────────────────────────────────────── + case 'POST': + $body = getBody(); + foreach (['nama', 'latitude', 'longitude'] as $f) { + if (empty($body[$f]) && $body[$f] !== 0) sendError("Field '$f' wajib diisi"); + } + + // Hitung proximity ke semua rumah ibadah + $proximity = hitungProximity($db, $body['latitude'], $body['longitude']); + + $stmt = $db->prepare( + "INSERT INTO penduduk_miskin + (nama, no_wa, nik, alamat, jumlah_anggota, + latitude, longitude, status_proximity, rumah_ibadah_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + $jumlah = isset($body['jumlah_anggota']) ? (int)$body['jumlah_anggota'] : 1; + $statusP = $proximity['status']; + $riId = $proximity['rumah_ibadah_id']; + + $stmt->bind_param( + 'ssssiddsi', + $body['nama'], $body['no_wa'], $body['nik'], + $body['alamat'], $jumlah, + $body['latitude'], $body['longitude'], + $statusP, $riId + ); + executeOrFail($stmt, 'Gagal menyimpan data penduduk miskin'); + $newId = $db->insert_id; + sendSuccess( + ['id' => $newId, 'proximity' => $proximity], + 'Penduduk Miskin berhasil disimpan', + 201 + ); + break; + + // ── PUT ─────────────────────────────────────────────────────────── + case 'PUT': + if ($id <= 0) sendError('ID wajib disertakan'); + $body = getBody(); + $proximity = hitungProximity($db, $body['latitude'], $body['longitude']); + $statusP = $proximity['status']; + $riId = $proximity['rumah_ibadah_id']; + $jumlah = isset($body['jumlah_anggota']) ? (int)$body['jumlah_anggota'] : 1; + + $stmt = $db->prepare( + "UPDATE penduduk_miskin + SET nama=?, no_wa=?, nik=?, alamat=?, jumlah_anggota=?, + latitude=?, longitude=?, status_proximity=?, rumah_ibadah_id=? + WHERE id=?" + ); + $stmt->bind_param( + 'ssssiddsii', + $body['nama'], $body['no_wa'], $body['nik'], + $body['alamat'], $jumlah, + $body['latitude'], $body['longitude'], + $statusP, $riId, $id + ); + executeOrFail($stmt, 'Gagal memperbarui data penduduk miskin'); + sendSuccess(['proximity' => $proximity], 'Data berhasil diperbarui'); + break; + + // ── DELETE ──────────────────────────────────────────────────────── + case 'DELETE': + if ($id <= 0) sendError('ID wajib disertakan'); + $stmt = $db->prepare("DELETE FROM penduduk_miskin WHERE id=?"); + $stmt->bind_param('i', $id); + executeOrFail($stmt, 'Gagal menghapus data penduduk miskin'); + $stmt->affected_rows > 0 + ? sendSuccess(null, 'Data berhasil dihapus') + : sendError('Data tidak ditemukan', 404); + break; + + default: + sendError('Method tidak diizinkan', 405); +} + +// ───────────────────────────────────────────────────────────────────────────── +// HELPER: Hitung proximity penduduk miskin terhadap semua rumah ibadah +// Menggunakan Haversine formula di PHP untuk validasi server-side +// ───────────────────────────────────────────────────────────────────────────── +function hitungProximity(mysqli $db, float $lat, float $lng): array { + $result = $db->query( + "SELECT id, nama, latitude, longitude, radius_meter FROM rumah_ibadah" + ); + $status = 'luar_radius'; + $riId = null; + $riNama = null; + $minDistAll = PHP_INT_MAX; // jarak ke RI terdekat (apapun statusnya) + $minDistIn = PHP_INT_MAX; // jarak ke RI terdekat yang dalam radius + + while ($ri = $result->fetch_assoc()) { + $dist = haversine($lat, $lng, (float)$ri['latitude'], (float)$ri['longitude']); + + // Lacak jarak terdekat secara keseluruhan (untuk field jarak_meter) + if ($dist < $minDistAll) { + $minDistAll = $dist; + } + + // Cek apakah titik ini masuk dalam radius RI ini + if ($dist <= (int)$ri['radius_meter'] && $dist < $minDistIn) { + $minDistIn = $dist; + $status = 'dalam_radius'; + $riId = (int)$ri['id']; + $riNama = $ri['nama']; + } + } + + return [ + 'status' => $status, + 'rumah_ibadah_id' => $riId, + 'nama_rumah_ibadah' => $riNama, + 'jarak_meter' => round($minDistAll, 2) + ]; +} + +function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float { + $R = 6371000; // radius bumi dalam meter + $dLat = deg2rad($lat2 - $lat1); + $dLng = deg2rad($lng2 - $lng1); + $a = sin($dLat/2)**2 + + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng/2)**2; + return $R * 2 * atan2(sqrt($a), sqrt(1-$a)); +} \ No newline at end of file diff --git a/api/rumah_ibadah.php b/api/rumah_ibadah.php new file mode 100644 index 0000000..f4a2895 --- /dev/null +++ b/api/rumah_ibadah.php @@ -0,0 +1,112 @@ + 0) { + $stmt = $db->prepare( + "SELECT id, nama, jenis, alamat, no_wa, + latitude, longitude, radius_meter, + created_at + FROM rumah_ibadah WHERE id = ?" + ); + $stmt->bind_param('i', $id); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + if ($row) { + $row['latitude'] = (float)$row['latitude']; + $row['longitude'] = (float)$row['longitude']; + $row['radius_meter'] = (int)$row['radius_meter']; + } + $row ? sendSuccess($row) : sendError('Data tidak ditemukan', 404); + } else { + $sql = "SELECT id, nama, jenis, alamat, no_wa, + latitude, longitude, radius_meter, + created_at + FROM rumah_ibadah ORDER BY created_at DESC"; + $result = $db->query($sql); + $rows = []; + while ($r = $result->fetch_assoc()) { + $r['latitude'] = (float)$r['latitude']; + $r['longitude'] = (float)$r['longitude']; + $r['radius_meter'] = (int)$r['radius_meter']; + $rows[] = $r; + } + sendSuccess($rows); + } + break; + + // ── POST: tambah data baru ──────────────────────────────────────── + case 'POST': + $body = getBody(); + $required = ['nama', 'jenis', 'latitude', 'longitude', 'radius_meter']; + foreach ($required as $field) { + if (empty($body[$field]) && $body[$field] !== 0) { + sendError("Field '$field' wajib diisi"); + } + } + $stmt = $db->prepare( + "INSERT INTO rumah_ibadah + (nama, jenis, alamat, no_wa, latitude, longitude, radius_meter) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + $stmt->bind_param( + 'ssssddi', + $body['nama'], + $body['jenis'], + $body['alamat'], + $body['no_wa'], + $body['latitude'], + $body['longitude'], + $body['radius_meter'] + ); + executeOrFail($stmt, 'Gagal menyimpan rumah ibadah'); + $newId = $db->insert_id; + sendSuccess(['id' => $newId], 'Rumah Ibadah berhasil disimpan', 201); + break; + + // ── PUT: update data ────────────────────────────────────────────── + case 'PUT': + if ($id <= 0) sendError('ID wajib disertakan'); + $body = getBody(); + $stmt = $db->prepare( + "UPDATE rumah_ibadah + SET nama=?, jenis=?, alamat=?, no_wa=?, + latitude=?, longitude=?, radius_meter=? + WHERE id=?" + ); + $stmt->bind_param( + 'ssssddii', + $body['nama'], $body['jenis'], + $body['alamat'], $body['no_wa'], + $body['latitude'], $body['longitude'], + $body['radius_meter'], $id + ); + executeOrFail($stmt, 'Gagal memperbarui rumah ibadah'); + sendSuccess(null, 'Rumah Ibadah berhasil diperbarui'); + break; + + // ── DELETE: hapus data ──────────────────────────────────────────── + case 'DELETE': + if ($id <= 0) sendError('ID wajib disertakan'); + $stmt = $db->prepare("DELETE FROM rumah_ibadah WHERE id=?"); + $stmt->bind_param('i', $id); + executeOrFail($stmt, 'Gagal menghapus rumah ibadah'); + $stmt->affected_rows > 0 + ? sendSuccess(null, 'Rumah Ibadah berhasil dihapus') + : sendError('Data tidak ditemukan', 404); + break; + + default: + sendError('Method tidak diizinkan', 405); +} \ No newline at end of file diff --git a/api/spbu.php b/api/spbu.php new file mode 100644 index 0000000..422838d --- /dev/null +++ b/api/spbu.php @@ -0,0 +1,105 @@ + 0) { + $stmt = $db->prepare( + "SELECT id, nama, no_wa, alamat, buka_24jam, + latitude, longitude, created_at + FROM spbu WHERE id = ?" + ); + $stmt->bind_param('i', $id); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + if (!$row) sendError('Data tidak ditemukan', 404); + $row['latitude'] = (float)$row['latitude']; + $row['longitude'] = (float)$row['longitude']; + $row['buka_24jam']= (bool)$row['buka_24jam']; + sendSuccess($row); + } else { + $rows = []; + if (isset($_GET['buka_24jam'])) { + $val = (int)$_GET['buka_24jam']; + $stmt = $db->prepare( + "SELECT id, nama, no_wa, alamat, buka_24jam, + latitude, longitude, created_at + FROM spbu WHERE buka_24jam = ? ORDER BY nama" + ); + $stmt->bind_param('i', $val); + $stmt->execute(); + $res = $stmt->get_result(); + } else { + $res = $db->query( + "SELECT id, nama, no_wa, alamat, buka_24jam, + latitude, longitude, created_at + FROM spbu ORDER BY nama" + ); + } + while ($r = $res->fetch_assoc()) { + $r['latitude'] = (float)$r['latitude']; + $r['longitude'] = (float)$r['longitude']; + $r['buka_24jam']= (bool)$r['buka_24jam']; + $rows[] = $r; + } + sendSuccess($rows); + } + break; + + case 'POST': + $body = getBody(); + foreach (['nama','latitude','longitude'] as $f) { + if (empty($body[$f]) && $body[$f] !== 0) sendError("Field '$f' wajib diisi"); + } + $buka = isset($body['buka_24jam']) ? (int)(bool)$body['buka_24jam'] : 0; + $stmt = $db->prepare( + "INSERT INTO spbu (nama, no_wa, alamat, buka_24jam, latitude, longitude) + VALUES (?, ?, ?, ?, ?, ?)" + ); + $stmt->bind_param('sssidd', + $body['nama'], $body['no_wa'], $body['alamat'], + $buka, $body['latitude'], $body['longitude'] + ); + executeOrFail($stmt, 'Gagal menyimpan data SPBU'); + sendSuccess(['id' => $db->insert_id], 'SPBU berhasil disimpan', 201); + break; + + case 'PUT': + if ($id <= 0) sendError('ID wajib disertakan'); + $body = getBody(); + $buka = isset($body['buka_24jam']) ? (int)(bool)$body['buka_24jam'] : 0; + $stmt = $db->prepare( + "UPDATE spbu + SET nama=?, no_wa=?, alamat=?, buka_24jam=?, latitude=?, longitude=? + WHERE id=?" + ); + $stmt->bind_param('sssiddi', + $body['nama'], $body['no_wa'], $body['alamat'], + $buka, $body['latitude'], $body['longitude'], $id + ); + executeOrFail($stmt, 'Gagal memperbarui data SPBU'); + sendSuccess(null, 'SPBU berhasil diperbarui'); + break; + + case 'DELETE': + if ($id <= 0) sendError('ID wajib disertakan'); + $stmt = $db->prepare("DELETE FROM spbu WHERE id=?"); + $stmt->bind_param('i', $id); + executeOrFail($stmt, 'Gagal menghapus data SPBU'); + $stmt->affected_rows > 0 + ? sendSuccess(null, 'SPBU berhasil dihapus') + : sendError('Data tidak ditemukan', 404); + break; + + default: + sendError('Method tidak diizinkan', 405); +} \ No newline at end of file diff --git a/css/app.css b/css/app.css new file mode 100644 index 0000000..4b073c6 --- /dev/null +++ b/css/app.css @@ -0,0 +1,895 @@ +:root { + --bg: #eef3f8; + --panel: rgba(255, 255, 255, .92); + --panel-strong: #ffffff; + --line: #d7e0ea; + --line-strong: #bcc9d7; + --text: #0f172a; + --muted: #5b6b82; + --muted-2: #7d8ca2; + --accent: #165dff; + --accent-2: #0ea5e9; + --accent-soft: #eaf1ff; + --shadow: 0 12px 36px rgba(15, 23, 42, .12); + --shadow-soft: 0 8px 18px rgba(15, 23, 42, .08); + --radius: 18px; + --radius-sm: 12px; + --font: 'Inter', sans-serif; + --font-head: 'Space Grotesk', sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + font-family: var(--font); + color: var(--text); + background: + radial-gradient(circle at top left, rgba(22, 93, 255, .12), transparent 32%), + radial-gradient(circle at right center, rgba(14, 165, 233, .10), transparent 28%), + linear-gradient(180deg, #f6f9fc 0%, var(--bg) 100%); + overflow: hidden; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +#app { + height: 100%; + display: grid; + grid-template-rows: 64px 1fr; +} + +#header { + display: flex; + align-items: center; + gap: 16px; + padding: 0 18px; + background: rgba(255, 255, 255, .8); + border-bottom: 1px solid rgba(215, 224, 234, .9); + backdrop-filter: blur(14px); + box-shadow: 0 1px 0 rgba(255, 255, 255, .7) inset; + position: relative; + z-index: 4000; + overflow: visible; +} + +.brand { + display: flex; + flex-direction: column; + gap: 2px; + white-space: nowrap; +} + +.brand-title { + font-family: var(--font-head); + font-weight: 700; + letter-spacing: -.04em; + font-size: 15px; +} + +.brand-sub { + font-size: 11px; + color: var(--muted); +} + +.nav-tabs { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.header-search { + position: relative; + width: min(500px, 100%); + flex: 1 1 360px; + max-width: 500px; + z-index: 4001; +} + +.search-box { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + border: 1px solid var(--line); + background: rgba(255, 255, 255, .96); + border-radius: 999px; + padding: 9px 14px; + box-shadow: var(--shadow-soft); +} + +.search-icon { + flex: 0 0 auto; + color: var(--muted-2); + font-size: 14px; + line-height: 1; +} + +#search-input { + width: 100%; + border: none; + outline: none; + background: transparent; + color: var(--text); + font-size: 12px; +} + +#search-input::placeholder { + color: var(--muted-2); +} + +.search-results { + position: absolute; + top: calc(100% + 8px); + left: 0; + right: 0; + z-index: 5000; + display: none; + padding: 8px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, .98); + box-shadow: 0 18px 40px rgba(15, 23, 42, .16); + backdrop-filter: blur(12px); + max-height: 320px; + overflow-y: auto; + overflow-x: hidden; +} + +.search-results.show { + display: grid; + gap: 10px; +} + +.search-results-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + padding: 2px 4px 0; +} + +.search-results-hint { + padding: 0 4px; + font-size: 11px; + color: var(--muted); +} + +.search-results-title { + font-size: 10px; + font-weight: 800; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--muted-2); +} + +.search-results-count { + font-size: 10px; + color: var(--muted); + white-space: nowrap; +} + +.search-results-list { + display: grid; + gap: 6px; +} + +.search-result-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + border: 1px solid transparent; + background: #f8fbff; + border-radius: 14px; + padding: 10px 12px; + text-align: left; + transition: all .14s ease; +} + +.search-result-item:hover, +.search-result-item.active { + border-color: #cfe0ff; + background: #eef5ff; + transform: translateY(-1px); +} + +.search-result-icon { + width: 30px; + height: 30px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: #fff; + border: 1px solid var(--line); + flex: 0 0 auto; +} + +.search-result-info { + flex: 1; + min-width: 0; +} + +.search-result-name { + font-size: 12px; + font-weight: 700; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.search-result-sub { + margin-top: 2px; + font-size: 10px; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.search-result-type { + flex: 0 0 auto; + font-size: 9px; + font-weight: 800; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--accent); + background: var(--accent-soft); + border: 1px solid #cfe0ff; + border-radius: 999px; + padding: 4px 8px; +} + +.search-empty { + padding: 14px 12px; + border: 1px dashed #cbd6e3; + border-radius: 14px; + color: var(--muted); + font-size: 12px; + text-align: center; + background: #fbfcfe; +} + +.search-empty strong { + color: var(--text); +} + +.nav-tab { + border: 1px solid transparent; + background: transparent; + color: var(--muted); + border-radius: 999px; + padding: 9px 14px; + font-size: 12px; + font-weight: 600; + transition: all .16s ease; +} + +.nav-tab:hover { + background: #f3f7fb; + color: var(--text); +} + +.nav-tab.active { + background: var(--accent-soft); + color: var(--accent); + border-color: #cfe0ff; + box-shadow: 0 0 0 1px rgba(22, 93, 255, .04) inset; +} + +.header-right { + margin-left: auto; + display: flex; + align-items: center; + gap: 10px; +} + +.coord-display, +.status-pill { + border: 1px solid var(--line); + background: rgba(255, 255, 255, .86); + box-shadow: var(--shadow-soft); + border-radius: 999px; + padding: 8px 12px; + font-size: 11px; + color: var(--muted); + white-space: nowrap; +} + +.coord-display { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.status-pill strong { + color: var(--text); +} + +#main { + display: grid; + grid-template-columns: minmax(0, 1fr) 362px; + min-height: 0; + position: relative; + z-index: 1; +} + +#map-wrap { + position: relative; + min-width: 0; +} + +#map { + height: 100%; + width: 100%; +} + +#instruction-banner { + position: absolute; + left: 18px; + top: 18px; + z-index: 500; + background: rgba(15, 23, 42, .92); + color: #fff; + padding: 10px 14px; + border-radius: 999px; + font-size: 12px; + box-shadow: var(--shadow); + opacity: 0; + transform: translateY(-8px); + pointer-events: none; + transition: all .18s ease; +} + +#instruction-banner.show { + opacity: 1; + transform: translateY(0); +} + +#panel { + min-width: 0; + background: var(--panel); + border-left: 1px solid rgba(215, 224, 234, .95); + box-shadow: -1px 0 0 rgba(255, 255, 255, .75) inset; + display: grid; + grid-template-rows: auto 1fr; + min-height: 0; +} + +#panel-head { + padding: 16px 16px 12px; + border-bottom: 1px solid var(--line); + background: linear-gradient(180deg, rgba(255, 255, 255, .95), rgba(255, 255, 255, .82)); +} + +#panel-head h2 { + margin: 0 0 8px; + font-family: var(--font-head); + letter-spacing: -.04em; + font-size: 17px; +} + +.panel-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 11px; + color: var(--muted); +} + +#panel-body { + min-height: 0; + overflow: auto; + padding: 14px; + background: linear-gradient(180deg, rgba(247, 250, 253, .8), rgba(242, 246, 250, .88)); +} + +#panel-body::-webkit-scrollbar { + width: 10px; +} + +#panel-body::-webkit-scrollbar-thumb { + background: #c7d3e0; + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.layer-section { + margin-bottom: 16px; +} + +.layer-section-title { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 10px; + font-weight: 800; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--muted-2); + margin: 0 0 8px; +} + +.layer-item, +.data-item { + display: flex; + align-items: center; + gap: 10px; + border: 1px solid var(--line); + background: var(--panel-strong); + border-radius: var(--radius-sm); + box-shadow: 0 1px 2px rgba(15, 23, 42, .04); +} + +.layer-item { + padding: 9px 11px; + margin-bottom: 6px; + transition: all .16s ease; +} + +.layer-item:hover, +.data-item:hover { + border-color: #bcd0ff; + box-shadow: 0 10px 20px rgba(22, 93, 255, .08); +} + +.layer-item.checked { + background: linear-gradient(180deg, #f8fbff, #eef5ff); + border-color: #cfe0ff; +} + +.layer-item.checked .layer-check { + color: #fff; + background: var(--accent); + border-color: var(--accent); +} + +.layer-check { + width: 18px; + height: 18px; + border-radius: 5px; + border: 1.5px solid #b8c5d4; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + font-size: 11px; + color: transparent; + background: #fff; +} + +.layer-dot { + width: 12px; + height: 12px; + border-radius: 999px; + flex: 0 0 auto; +} + +.layer-label { + font-size: 12px; + font-weight: 600; + color: var(--text); + flex: 1; + min-width: 0; +} + +.layer-count { + font-size: 10px; + font-weight: 700; + color: var(--muted); + background: #f4f7fb; + border: 1px solid var(--line); + border-radius: 999px; + padding: 3px 8px; +} + +.btn-add { + width: 100%; + text-align: left; + border: 1.5px dashed #aebfda; + background: rgba(255, 255, 255, .92); + color: var(--muted); + border-radius: var(--radius-sm); + padding: 11px 12px; + margin-top: 6px; + transition: all .16s ease; +} + +.btn-add:hover, +.btn-add.active { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-soft); +} + +.data-list { + display: grid; + gap: 8px; +} + +.data-item { + padding: 10px 11px; + transition: all .16s ease; +} + +.item-icon { + width: 28px; + height: 28px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: #f3f7fb; + flex: 0 0 auto; +} + +.item-info { + flex: 1; + min-width: 0; +} + +.item-name { + font-size: 12px; + font-weight: 700; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-sub { + font-size: 10px; + color: var(--muted); + margin-top: 2px; +} + +.item-badge { + font-size: 9px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: .04em; + border-radius: 999px; + padding: 4px 8px; + border: 1px solid transparent; + flex: 0 0 auto; +} + +.badge-green { + background: #e6f8ec; + color: #15803d; + border-color: #b9ebc9; +} + +.badge-red { + background: #feecec; + color: #dc2626; + border-color: #fecaca; +} + +.badge-blue { + background: #eaf1ff; + color: #165dff; + border-color: #cfe0ff; +} + +.badge-amber { + background: #fff4df; + color: #d97706; + border-color: #f9deb0; +} + +.badge-purple { + background: #f1eaff; + color: #7c3aed; + border-color: #ddd2ff; +} + +.loading { + padding: 14px; + border: 1px dashed #cbd6e3; + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, .7); + color: var(--muted); + font-size: 12px; + text-align: center; +} + +.spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid rgba(22, 93, 255, .18); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .8s linear infinite; + margin-right: 8px; + vertical-align: -2px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +#modal-overlay { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(15, 23, 42, .42); + z-index: 2000; + padding: 18px; +} + +#modal-overlay.open { + display: flex; +} + +#modal { + width: min(720px, 100%); + max-height: min(90vh, 860px); + background: var(--panel-strong); + border-radius: 24px; + box-shadow: 0 24px 70px rgba(15, 23, 42, .28); + overflow: hidden; + display: grid; + grid-template-rows: auto 1fr auto; +} + +.modal-head { + padding: 18px 20px 14px; + border-bottom: 1px solid var(--line); + background: linear-gradient(180deg, #fff, #fbfdff); +} + +.modal-head h3 { + margin: 0; + font-family: var(--font-head); + font-size: 18px; + letter-spacing: -.03em; +} + +#modal-body { + padding: 18px 20px; + overflow: auto; + background: #fbfcfe; +} + +#modal-footer { + padding: 14px 20px 18px; + display: flex; + justify-content: flex-end; + gap: 10px; + border-top: 1px solid var(--line); + background: #fff; +} + +.form-group { + margin-bottom: 14px; +} + +.form-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.form-label { + display: block; + margin-bottom: 6px; + font-size: 12px; + font-weight: 700; + color: var(--text); +} + +.form-input, +.form-select { + width: 100%; + border: 1px solid var(--line-strong); + background: #fff; + border-radius: 12px; + padding: 11px 12px; + outline: none; + transition: all .14s ease; +} + +.form-input:focus, +.form-select:focus { + border-color: #8eb3ff; + box-shadow: 0 0 0 4px rgba(22, 93, 255, .10); +} + +.form-coord, +.form-calc { + margin-bottom: 14px; + padding: 11px 12px; + background: #eef5ff; + color: #1140b8; + border: 1px solid #d7e5ff; + border-radius: 12px; + font-size: 12px; + font-weight: 600; +} + +.popup-actions { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.popup-drag-hint, +.proximity-info { + margin-top: 10px; + padding: 10px 12px; + border-radius: 12px; + font-size: 12px; +} + +.popup-drag-hint { + background: #f3f7fb; + color: var(--muted); +} + +.proximity-info.dalam { + background: #e6f8ec; + color: #15803d; +} + +.proximity-info.luar { + background: #feecec; + color: #dc2626; +} + +.btn { + border: 1px solid transparent; + border-radius: 12px; + padding: 10px 14px; + font-weight: 700; + font-size: 12px; + transition: all .16s ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn-primary { + background: var(--accent); + color: #fff; + box-shadow: 0 12px 22px rgba(22, 93, 255, .18); +} + +.btn-primary:hover { + background: #0d4ae0; +} + +.btn-ghost { + background: #fff; + border-color: var(--line); + color: var(--text); +} + +.btn-danger { + background: #ef4444; + color: #fff; +} + +.btn-sm { + padding: 7px 10px; + border-radius: 10px; +} + +#toast-container { + position: fixed; + right: 18px; + bottom: 18px; + display: grid; + gap: 10px; + z-index: 3000; +} + +.toast { + min-width: 240px; + max-width: 360px; + border-radius: 14px; + padding: 12px 14px; + color: #fff; + box-shadow: var(--shadow); + animation: toast-in .2s ease; +} + +.toast.info { + background: #0f172a; +} + +.toast.success { + background: #16a34a; +} + +.toast.error { + background: #dc2626; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 1100px) { + #main { + grid-template-columns: 1fr; + } + + #panel { + grid-template-rows: auto 360px; + border-left: none; + border-top: 1px solid var(--line); + } +} + +@media (max-width: 720px) { + #header { + flex-wrap: wrap; + height: auto; + padding: 12px 14px; + gap: 10px; + } + + .header-search { + order: 3; + width: 100%; + max-width: none; + flex-basis: 100%; + } + + .header-right { + margin-left: 0; + width: 100%; + justify-content: space-between; + } + + .nav-tabs { + width: 100%; + overflow: auto; + } + + .form-row { + grid-template-columns: 1fr; + } + + #panel { + grid-template-rows: auto minmax(320px, 48vh); + } + + #modal { + width: 100%; + border-radius: 20px; + } +} \ No newline at end of file diff --git a/database.sql b/database.sql new file mode 100644 index 0000000..ed87ba9 --- /dev/null +++ b/database.sql @@ -0,0 +1,81 @@ +CREATE DATABASE IF NOT EXISTS webgis_poverty; +USE webgis_poverty; + +CREATE TABLE `rumah_ibadah` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `nama` varchar(255) NOT NULL, + `jenis` varchar(100) NOT NULL, + `alamat` text, + `no_wa` varchar(20), + `latitude` double NOT NULL, + `longitude` double NOT NULL, + `radius_meter` int(11) NOT NULL DEFAULT 0, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `penduduk_miskin` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `nama` varchar(255) NOT NULL, + `no_wa` varchar(20), + `nik` varchar(20), + `alamat` text, + `jumlah_anggota` int(11) NOT NULL DEFAULT 1, + `latitude` double NOT NULL, + `longitude` double NOT NULL, + `status_proximity` varchar(50), + `rumah_ibadah_id` int(11), + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + FOREIGN KEY (`rumah_ibadah_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `log_bantuan` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `penduduk_miskin_id` int(11) NOT NULL, + `rumah_ibadah_id` int(11) NOT NULL, + `tipe_bantuan` varchar(100) NOT NULL, + `sub_kategori` varchar(100), + `keterangan` text, + `tanggal_bantuan` date NOT NULL, + `nilai_bantuan` double, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + FOREIGN KEY (`penduduk_miskin_id`) REFERENCES `penduduk_miskin` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`rumah_ibadah_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `data_jalan` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `nama_jalan` varchar(255) NOT NULL, + `status_jalan` varchar(100) NOT NULL, + `panjang_meter` double NOT NULL, + `keterangan` text, + `geojson` longtext NOT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `data_parsil` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `nama_pemilik` varchar(255) NOT NULL, + `no_sertifikat` varchar(100), + `jenis_hak` varchar(100) NOT NULL, + `luas_m2` double NOT NULL, + `keterangan` text, + `geojson` longtext NOT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `spbu` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `nama` varchar(255) NOT NULL, + `no_wa` varchar(20), + `alamat` text, + `buka_24jam` tinyint(1) NOT NULL DEFAULT 0, + `latitude` double NOT NULL, + `longitude` double NOT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/index.html b/index.html new file mode 100644 index 0000000..4d5bbaa --- /dev/null +++ b/index.html @@ -0,0 +1,91 @@ + + + + + +WebGIS - Poverty Mapping & Spatial Management + + + + + + + + + + + + + + +
+ + +
+
+
+
+
+ + +
+
+ + + +
+ + + + + + + + + diff --git a/js/app-core.js b/js/app-core.js new file mode 100644 index 0000000..89d960b --- /dev/null +++ b/js/app-core.js @@ -0,0 +1,462 @@ +'use strict'; + +const API_BASE = 'api'; +const CENTER = [-0.0263, 109.3425]; + +const map = L.map('map', { zoomControl: false }).setView(CENTER, 13); +L.control.zoom({ position: 'topleft' }).addTo(map); + +const baseLayers = { + 'OSM Standard': L.tileLayer( + 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + { attribution: '© OpenStreetMap', maxZoom: 19 } + ), + 'CartoDB Light': L.tileLayer( + 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', + { attribution: '© CartoDB', maxZoom: 19 } + ), + 'Satellite (Esri)': L.tileLayer( + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + { attribution: '© Esri', maxZoom: 19 } + ) +}; +baseLayers['OSM Standard'].addTo(map); +L.control.layers(baseLayers, {}, { position: 'bottomright', collapsed: true }).addTo(map); + +const layers = { + rumahIbadah: L.layerGroup().addTo(map), + ibadahRadius: L.layerGroup().addTo(map), + pendudukMiskin: L.layerGroup().addTo(map), + spbu24: L.layerGroup().addTo(map), + spbuNon24: L.layerGroup().addTo(map), + jalanNasional: L.layerGroup().addTo(map), + jalanProvinsi: L.layerGroup().addTo(map), + jalanKabupaten: L.layerGroup().addTo(map), + parsilSHM: L.layerGroup().addTo(map), + parsilHGB: L.layerGroup().addTo(map), + parsilHGU: L.layerGroup().addTo(map), + parsilHP: L.layerGroup().addTo(map) +}; + +const layerVisible = { + rumahIbadah: true, ibadahRadius: true, pendudukMiskin: true, + spbu24: true, spbuNon24: true, + jalanNasional: true, jalanProvinsi: true, jalanKabupaten: true, + parsilSHM: true, parsilHGB: true, parsilHGU: true, parsilHP: true +}; + +const searchEntries = []; +let searchActiveIndex = -1; +const searchPointZoom = 15; +const searchIntentPrefix = { + jalan: 'jalan', + spbu: 'spbu', + rumahibadah: 'ri', + pendudukmiskin: 'pm', + parsil: 'parsil' +}; + +const drawnItems = new L.FeatureGroup().addTo(map); +const drawControl = new L.Control.Draw({ + draw: { + polyline: { shapeOptions: { color: '#2563eb', weight: 3 } }, + polygon: { shapeOptions: { color: '#7c3aed', weight: 2, fillOpacity: 0.15 } }, + rectangle: false, circle: false, circlemarker: false, marker: false + }, + edit: { featureGroup: drawnItems, remove: false } +}); + +let pendingGeoJSON = null; +let pendingLength = null; +let pendingArea = null; +let currentTab = 'kemiskinan'; +let geometryEditState = null; +let _mapClickCb = null; + +function toast(msg, type = 'info') { + const t = document.createElement('div'); + t.className = `toast ${type}`; + t.textContent = msg; + document.getElementById('toast-container').appendChild(t); + setTimeout(() => t.remove(), 3000); +} + +function openModal(title, bodyHTML, footerHTML) { + document.getElementById('modal-title').textContent = title; + document.getElementById('modal-body').innerHTML = bodyHTML; + document.getElementById('modal-footer').innerHTML = footerHTML; + document.getElementById('modal-overlay').classList.add('open'); +} + +function closeModal() { + document.getElementById('modal-overlay').classList.remove('open'); +} + +document.getElementById('modal-overlay').addEventListener('click', e => { + if (e.target.id === 'modal-overlay') closeModal(); +}); + +async function callAPI(ep, method = 'GET', body = null) { + const opts = { method, headers: { 'Content-Type': 'application/json' } }; + if (body) opts.body = JSON.stringify(body); + try { + return await (await fetch(`${API_BASE}/${ep}`, opts)).json(); + } catch (err) { + return { status: 'error', message: 'Koneksi gagal' }; + } +} + +function showInstruction(msg) { + const el = document.getElementById('instruction-banner'); + el.textContent = '🖱 ' + msg; + el.classList.add('show'); +} + +function hideInstruction() { + document.getElementById('instruction-banner').classList.remove('show'); +} + +function esc(s) { + if (s == null) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function normalizeSearchText(text) { + return String(text || '') + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function getSearchIntent(query) { + const q = normalizeSearchText(query); + if (!q) return null; + const aliases = [ + { key: 'jalan', terms: ['jalan', 'road', 'street'] }, + { key: 'spbu', terms: ['spbu', 'pom', 'bensin', 'bbm'] }, + { key: 'rumahibadah', terms: ['masjid', 'gereja', 'pura', 'vihara', 'klenteng', 'ibadah'] }, + { key: 'pendudukmiskin', terms: ['miskin', 'penduduk', 'warga', 'keluarga'] }, + { key: 'parsil', terms: ['parsil', 'tanah', 'sertifikat', 'bidang', 'lahan'] } + ]; + for (const alias of aliases) { + if (alias.terms.some(term => q.includes(term))) return alias.key; + } + return null; +} + +function clearSearchEntries(prefix) { + for (let i = searchEntries.length - 1; i >= 0; i--) { + if (searchEntries[i].key.startsWith(prefix + ':')) searchEntries.splice(i, 1); + } +} + +function registerSearchEntries(prefix, items) { + clearSearchEntries(prefix); + items.forEach(item => searchEntries.push(item)); +} + +function getSearchMatches(query) { + const q = normalizeSearchText(query); + if (!q) return []; + const tokens = q.split(' '); + const intent = getSearchIntent(q); + const prefix = intent ? searchIntentPrefix[intent] : null; + const pool = prefix + ? searchEntries.filter(entry => entry.key.startsWith(prefix + ':')) + : searchEntries; + + return pool + .map(entry => { + let score = 0; + const entryLabel = normalizeSearchText(entry.label); + const entryType = normalizeSearchText(entry.typeLabel); + const entryText = entry.searchText; + if (entry.searchText === q) score += 120; + if (entry.searchText.startsWith(q)) score += 80; + if (entryLabel.startsWith(q)) score += 90; + if (entryType.startsWith(q)) score += 60; + if (entry.searchText.includes(q)) score += 50; + const tokenHits = tokens.filter(token => entryText.includes(token) || entryLabel.includes(token)).length; + score += tokenHits * 12; + if (tokens.every(token => entryText.includes(token) || entryLabel.includes(token))) score += 40; + if (tokens[0] && (entryText.split(' ').some(word => word.startsWith(tokens[0])) || entryLabel.split(' ').some(word => word.startsWith(tokens[0])))) score += 15; + if (intent) { + if (entry.key.startsWith(intent + ':')) score += 120; + if (entryType.includes(intent)) score += 110; + if (intent === 'jalan' && entryLabel.startsWith('jalan')) score += 40; + if (intent === 'rumahibadah' && /(masjid|gereja|pura|vihara|klenteng)/.test(entryText)) score += 35; + if (intent === 'spbu' && /(spbu|pom|bensin|bbm)/.test(entryText)) score += 35; + if (intent === 'pendudukmiskin' && /(miskin|penduduk|warga)/.test(entryText)) score += 35; + if (intent === 'parsil' && /(parsil|tanah|sertifikat|bidang)/.test(entryText)) score += 35; + } + return { ...entry, score }; + }) + .filter(entry => entry.score > 0) + .sort((a, b) => b.score - a.score || a.label.localeCompare(b.label)) + .slice(0, 10); +} + +function renderSearchResults(query) { + const panel = document.getElementById('search-results'); + if (!panel) return; + const matches = getSearchMatches(query); + if (!matches.length || !normalizeSearchText(query)) { + searchActiveIndex = -1; + panel.classList.remove('show'); + panel.innerHTML = ''; + return; + } + + if (searchActiveIndex < 0 || searchActiveIndex >= matches.length) searchActiveIndex = 0; + + panel.innerHTML = ` +
+
Rekomendasi
+
+
+ ${matches.map((item, index) => ` + `).join('')} +
`; + panel.classList.add('show'); +} + +function closeSearchResults() { + const panel = document.getElementById('search-results'); + if (!panel) return; + panel.classList.remove('show'); +} + +function selectSearchResult(item) { + const input = document.getElementById('search-input'); + if (input) input.value = item.label; + closeSearchResults(); + item.focus(); +} + +function initSearchBar() { + const input = document.getElementById('search-input'); + const panel = document.getElementById('search-results'); + const wrapper = document.getElementById('header-search'); + if (!input || !panel || !wrapper) return; + + input.addEventListener('input', () => { + searchActiveIndex = 0; + renderSearchResults(input.value); + }); + input.addEventListener('focus', () => renderSearchResults(input.value)); + input.addEventListener('keydown', e => { + const matches = getSearchMatches(input.value); + if (e.key === 'Enter') { + e.preventDefault(); + const active = matches[searchActiveIndex] || matches[0]; + if (active) selectSearchResult(active); + return; + } + if (!matches.length) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + searchActiveIndex = Math.min(searchActiveIndex + 1, matches.length - 1); + renderSearchResults(input.value); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + searchActiveIndex = Math.max(searchActiveIndex - 1, 0); + renderSearchResults(input.value); + } + }); + + panel.addEventListener('mousedown', e => { + const item = e.target.closest('.search-result-item'); + if (!item) return; + e.preventDefault(); + const matches = getSearchMatches(input.value); + const selected = matches[Number(item.dataset.index)]; + if (selected) selectSearchResult(selected); + }); + + document.addEventListener('click', e => { + if (!wrapper.contains(e.target)) closeSearchResults(); + }); +} + +function onceMapClick(cb) { + if (_mapClickCb) map.off('click', _mapClickCb); + _mapClickCb = function (e) { + _mapClickCb = null; + cb(e.latlng.lat, e.latlng.lng); + }; + map.once('click', _mapClickCb); +} + +function cancelMapClick() { + if (_mapClickCb) { + map.off('click', _mapClickCb); + _mapClickCb = null; + } + hideInstruction(); + document.querySelectorAll('.btn-add.active').forEach(b => b.classList.remove('active')); +} + +function switchTab(tab) { + currentTab = tab; + cancelMapClick(); + document.querySelectorAll('.nav-tab').forEach((el, i) => { + el.classList.toggle('active', ['kemiskinan', 'spbu', 'jalan', 'parsil'][i] === tab); + }); + try { map.removeControl(drawControl); } catch (e) { } + const titles = { kemiskinan: 'Ibadah & Kemiskinan', spbu: 'SPBU', jalan: 'Data Jalan', parsil: 'Parsil Tanah' }; + document.getElementById('panel-title').textContent = titles[tab]; + renderPanel(tab); + if (tab === 'jalan' || tab === 'parsil') map.addControl(drawControl); +} + +async function renderPanel(tab) { + const pb = document.getElementById('panel-body'); + pb.innerHTML = '
Memuat data…
'; + if (tab === 'kemiskinan') await renderKemiskinanPanel(pb); + if (tab === 'spbu') await renderSpbuPanel(pb); + if (tab === 'jalan') await renderJalanPanel(pb); + if (tab === 'parsil') await renderParsilPanel(pb); +} + +async function preloadFeatureLayers() { + const sandbox = document.createElement('div'); + try { + await Promise.all([ + renderSpbuPanel(sandbox), + renderJalanPanel(sandbox), + renderParsilPanel(sandbox) + ]); + } catch (err) { + console.warn('Gagal memuat sebagian layer awal:', err); + } +} + +function findGeomLayer(type, id) { + const keys = type === 'jalan' + ? ['jalanNasional', 'jalanProvinsi', 'jalanKabupaten'] + : ['parsilSHM', 'parsilHGB', 'parsilHGU', 'parsilHP']; + for (const key of keys) { + let found = null; + layers[key].eachLayer(layer => { + if (layer._dataId === id) found = layer; + }); + if (found) return found; + } + return null; +} + +function getGeometryEditHandler() { + return drawControl?._toolbars?.edit?._modes?.edit?.handler || null; +} + +function getGeometryGroupKey(type, data) { + return type === 'jalan' ? `jalan${data.status_jalan}` : `parsil${data.jenis_hak}`; +} + +function cloneEditableLayer(type, sourceLayer) { + if (type === 'jalan') { + const coords = sourceLayer.getLatLngs().map(ll => [ll.lat, ll.lng]); + return L.polyline(coords, { ...sourceLayer.options }); + } + const ring = sourceLayer.getLatLngs()[0].map(ll => [ll.lat, ll.lng]); + return L.polygon(ring, { ...sourceLayer.options }); +} + +function buildGeometryLayer(type, data) { + if (type === 'jalan') { + const cfg = jalanCfg[data.status_jalan] || jalanCfg.Kabupaten; + const coords = (data.geojson?.coordinates || []).map(c => [c[1], c[0]]); + return L.polyline(coords, { color: cfg.color, weight: cfg.weight, opacity: 0.9 }); + } + const cfg = parsilCfg[data.jenis_hak] || parsilCfg.SHM; + const coords = ((data.geojson?.coordinates || [])[0] || []).map(c => [c[1], c[0]]); + return L.polygon(coords, { color: cfg.color, fillColor: cfg.color, fillOpacity: 0.18, weight: 2 }); +} + +function cancelGeometryEdit() { + const handler = getGeometryEditHandler(); + if (handler) handler.disable(); + drawnItems.clearLayers(); + geometryEditState = null; + switchTab(currentTab); +} + +function focusGeom(id, type) { + const keys = type === 'jalan' + ? ['jalanNasional', 'jalanProvinsi', 'jalanKabupaten'] + : ['parsilSHM', 'parsilHGB', 'parsilHGU', 'parsilHP']; + for (const k of keys) { + layers[k].eachLayer(l => { + if (l._dataId === id) { + map.flyToBounds(l.getBounds(), { padding: [50, 50], duration: 1 }); + l.openPopup(); + } + }); + } +} + +function focusMarkerById(layerKey, id, zoom = searchPointZoom) { + const layer = layers[layerKey]; + if (!layer) return; + let target = null; + layer.eachLayer(marker => { + if (marker._dataId === id) target = marker; + }); + if (!target) return; + map.flyTo(target.getLatLng(), zoom, { duration: 1 }); + map.once('moveend', () => target.openPopup()); +} + +function toggleLayer(key, el) { + layerVisible[key] = !layerVisible[key]; + el.classList.toggle('checked', layerVisible[key]); + el.querySelector('.layer-check').textContent = layerVisible[key] ? '✓' : ''; + layerVisible[key] ? map.addLayer(layers[key]) : map.removeLayer(layers[key]); +} + +function updateStatusCount() { + let total = 0; + Object.values(layers).forEach(lg => { if (map.hasLayer(lg)) lg.eachLayer(() => total++); }); + document.getElementById('status-count').textContent = `Total fitur: ${total}`; + document.getElementById('status-zoom').textContent = `Zoom: ${map.getZoom()}`; +} + +map.on('mousemove', e => { + document.getElementById('coord-display').textContent = `Lat: ${e.latlng.lat.toFixed(6)} | Lng: ${e.latlng.lng.toFixed(6)}`; +}); +map.on('zoomend', () => { + document.getElementById('status-zoom').textContent = `Zoom: ${map.getZoom()}`; +}); +map.on(L.Draw.Event.CREATED, function (e) { + const { layer, layerType: type } = e; + if (type === 'polyline') { + const lls = layer.getLatLngs(); + pendingLength = turf.length(turf.lineString(lls.map(c => [c.lng, c.lat])), { units: 'meters' }); + pendingGeoJSON = { type: 'LineString', coordinates: lls.map(c => [c.lng, c.lat]) }; + drawnItems.addLayer(layer); + openJalanForm(); + } + if (type === 'polygon') { + const lls = layer.getLatLngs()[0]; + const ring = [...lls.map(c => [c.lng, c.lat]), [lls[0].lng, lls[0].lat]]; + pendingArea = turf.area(turf.polygon([ring])); + pendingGeoJSON = { type: 'Polygon', coordinates: [ring] }; + drawnItems.addLayer(layer); + openParsilForm(); + } +}); + +initSearchBar(); + diff --git a/js/feature-jalan.js b/js/feature-jalan.js new file mode 100644 index 0000000..acfd7b3 --- /dev/null +++ b/js/feature-jalan.js @@ -0,0 +1,220 @@ +const jalanCfg = { + Nasional: { color: '#dc2626', weight: 5 }, + Provinsi: { color: '#d97706', weight: 3.5 }, + Kabupaten: { color: '#2563eb', weight: 2.5 } +}; + +function renderJalanPanel(pb) { + return callAPI('data_jalan.php').then(res => { + const data = res.data || []; + const cnt = k => data.filter(d => d.status_jalan === k).length; + + pb.innerHTML = ` +
+
Filter Layer
+ ${['Nasional', 'Provinsi', 'Kabupaten'].map(s => ` +
+
${layerVisible['jalan' + s] ? '✓' : ''}
+
+
Jalan ${s}
+
${cnt(s)}
+
`).join('')} +
+
+
Cara Menambahkan Data Jalan
+
+ 1. Klik ikon polyline di toolbar kiri peta
+ 2. Klik titik-titik rute jalan di peta
+ 3. Double-klik untuk selesai menggambar
+ 4. Form simpan + panjang otomatis muncul +
+
+
+
Data Jalan (${data.length})
+
+ ${data.length ? data.map(d => ` +
+
🛣
+
+
${esc(d.nama_jalan)}
+
${(d.panjang_meter / 1000).toFixed(3)} km
+
+
+
${d.status_jalan.toUpperCase()}
+ + +
+
`).join('') : '
Belum ada data jalan
'} +
+
`; + loadJalanLayer(data); + registerSearchEntries('jalan', data.map(d => ({ + key: `jalan:${d.id}`, + label: d.nama_jalan, + subtitle: `Jalan ${d.status_jalan} · ${(d.panjang_meter / 1000).toFixed(3)} km${d.keterangan ? ` · ${d.keterangan}` : ''}`, + typeLabel: 'Jalan', + icon: '🛣', + searchText: normalizeSearchText(`${d.nama_jalan} ${d.status_jalan} jalan`), + focus: () => focusGeom(d.id, 'jalan') + }))); + updateStatusCount(); + }); +} + +function loadJalanLayer(data) { + ['Nasional', 'Provinsi', 'Kabupaten'].forEach(k => layers['jalan' + k].clearLayers()); + data.forEach(d => { + const cfg = jalanCfg[d.status_jalan], coords = d.geojson.coordinates.map(c => [c[1], c[0]]); + const line = L.polyline(coords, { color: cfg.color, weight: cfg.weight, opacity: 0.9 }) + .bindPopup(`

🛣 ${esc(d.nama_jalan)}

+

Status: Jalan ${d.status_jalan}

+

Panjang: ${(d.panjang_meter / 1000).toFixed(3)} km

+ ${d.keterangan ? `

${esc(d.keterangan)}

` : ''} + `); + line._dataId = d.id; + line._groupKey = 'jalan' + d.status_jalan; + layers['jalan' + d.status_jalan].addLayer(line); + }); +} + +function openJalanForm() { + openModal('Simpan Data Jalan', ` +
📏 Panjang: ${pendingLength.toFixed(1)} m = ${(pendingLength / 1000).toFixed(3)} km
+
+
+
+
+
+
`, + ` + `); +} + +async function saveJalan() { + const body = { + nama_jalan: document.getElementById('jl-nama').value.trim(), + status_jalan: document.getElementById('jl-status').value, + panjang_meter: pendingLength, + keterangan: document.getElementById('jl-ket').value, + geojson: pendingGeoJSON + }; + if (!body.nama_jalan) return toast('Nama wajib diisi', 'error'); + const res = await callAPI('data_jalan.php', 'POST', body); + if (res.status === 'success') { + toast(res.message, 'success'); + closeModal(); + drawnItems.clearLayers(); + pendingGeoJSON = null; + pendingLength = null; + switchTab('jalan'); + } else { + toast(res.message, 'error'); + } +} + +async function editJalan(id) { + map.closePopup(); + const res = await callAPI(`data_jalan.php?id=${id}`); + const data = res.data; + const sourceLayer = findGeomLayer('jalan', id); + if (!data) return toast('Data jalan tidak ditemukan', 'error'); + + const groupKey = getGeometryGroupKey('jalan', data); + const editLayer = sourceLayer ? cloneEditableLayer('jalan', sourceLayer) : buildGeometryLayer('jalan', data); + editLayer._dataId = id; + editLayer._groupKey = groupKey; + + if (sourceLayer) layers[groupKey].removeLayer(sourceLayer); + drawnItems.clearLayers(); + drawnItems.addLayer(editLayer); + + geometryEditState = { type: 'jalan', id, groupKey, sourceLayer, editLayer }; + if (editLayer.editing && editLayer.editing.enable) { + editLayer.editing.enable(); + } else { + const handler = getGeometryEditHandler(); + if (!handler) { + drawnItems.clearLayers(); + if (sourceLayer) layers[groupKey].addLayer(sourceLayer); + geometryEditState = null; + return toast('Mode edit geometri belum siap', 'error'); + } + handler.enable(); + } + map.fitBounds(editLayer.getBounds(), { padding: [50, 50], duration: 1 }); + document.getElementById('panel-title').textContent = 'Edit Data Jalan'; + document.getElementById('panel-body').innerHTML = ` +
+
Edit Jalan
+
+ Geser titik-titik garis di peta lalu simpan perubahan. +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
`; + document.getElementById('panel-body').insertAdjacentHTML('beforeend', ` +
+ + +
`); +} + +async function saveJalanEdit(id) { + const state = geometryEditState; + if (!state || state.type !== 'jalan') return; + const layer = state.editLayer; + const coords = layer.getLatLngs().map(ll => [ll.lng, ll.lat]); + const panjang_meter = turf.length(turf.lineString(coords), { units: 'meters' }); + const body = { + nama_jalan: document.getElementById('jl-edit-nama').value.trim(), + status_jalan: document.getElementById('jl-edit-status').value, + panjang_meter, + keterangan: document.getElementById('jl-edit-ket').value, + geojson: { type: 'LineString', coordinates: coords } + }; + if (!body.nama_jalan) return toast('Nama wajib diisi', 'error'); + const res = await callAPI(`data_jalan.php?id=${id}`, 'PUT', body); + if (res.status === 'success') { + toast(res.message, 'success'); + if (layer.editing && layer.editing.disable) layer.editing.disable(); + const handler = getGeometryEditHandler(); + if (handler) handler.disable(); + drawnItems.clearLayers(); + geometryEditState = null; + switchTab('jalan'); + } else { + toast(res.message, 'error'); + } +} + +async function deleteJalan(id) { + if (!confirm('Hapus?')) return; + const r = await callAPI(`data_jalan.php?id=${id}`, 'DELETE'); + if (r.status === 'success') { + toast('Dihapus', 'success'); + map.closePopup(); + switchTab('jalan'); + } +} diff --git a/js/feature-kemiskinan.js b/js/feature-kemiskinan.js new file mode 100644 index 0000000..2689c6d --- /dev/null +++ b/js/feature-kemiskinan.js @@ -0,0 +1,451 @@ +function renderKemiskinanPanel(pb) { + return Promise.all([callAPI('rumah_ibadah.php'), callAPI('penduduk_miskin.php')]).then(([riRes, pmRes]) => { + const ri = riRes.data || []; + const pm = pmRes.data || []; + + pb.innerHTML = ` +
+
Visibilitas Layer
+ ${mkLayerItem('rumahIbadah', '#f59e0b', 'Rumah Ibadah', ri.length)} + ${mkLayerItem('ibadahRadius', '#fbbf24', 'Radius Jangkauan', ri.length, true)} + ${mkLayerItem('pendudukMiskin', '#16a34a', 'Penduduk Miskin', pm.length)} +
+
+
Tambah Data
+ + +
+
+
Rumah Ibadah (${ri.length})
+
+ ${ri.length ? ri.map(r => ` +
+
+
+
${esc(r.nama)}
+
${r.jenis} · R=${r.radius_meter}m
+
+
+ + +
+
`).join('') : '
Belum ada data
'} +
+
+
+
Penduduk Miskin (${pm.length})
+
+ ${pm.length ? pm.map(p => ` +
+
${pmEmoji(p)}
+
+
${esc(p.nama)}
+
${p.total_bantuan} bantuan · ${p.jumlah_anggota} jiwa
+
+
+ ${p.status_proximity === 'dalam_radius' ? 'DALAM' : 'LUAR'} +
+
`).join('') : '
Belum ada data
'} +
+
`; + + loadRILayer(ri); + loadPMLayer(pm); + registerSearchEntries('ri', ri.map(r => ({ + key: `ri:${r.id}`, + label: r.nama, + subtitle: `Rumah Ibadah · ${r.jenis}${r.alamat ? ` · ${r.alamat}` : ''}`, + typeLabel: 'Rumah Ibadah', + icon: '⛪', + searchText: normalizeSearchText(`${r.nama} ${r.jenis} rumah ibadah`), + focus: () => focusMarkerById('rumahIbadah', r.id) + }))); + registerSearchEntries('pm', pm.map(p => ({ + key: `pm:${p.id}`, + label: p.nama, + subtitle: `Penduduk Miskin · ${p.jumlah_anggota} jiwa${p.alamat ? ` · ${p.alamat}` : ''}`, + typeLabel: 'Penduduk Miskin', + icon: '👤', + searchText: normalizeSearchText(`${p.nama} ${p.nik || ''} penduduk miskin`), + focus: () => focusMarkerById('pendudukMiskin', p.id) + }))); + updateStatusCount(); + }); +} + +function mkLayerItem(key, color, label, count, isRing = false) { + const dot = isRing + ? `
` + : `
`; + return `
+
${layerVisible[key] ? '✓' : ''}
${dot} +
${label}
+
${count}
+
`; +} + +function pmEmoji(p) { + if (p.bantuan_pemberdayaan > 0 && p.bantuan_konsumtif > 0) return '🔗'; + if (p.bantuan_pemberdayaan > 0) return '📚'; + if (p.bantuan_konsumtif > 0) return '🛍'; + return '👤'; +} + +function loadRILayer(data) { + layers.rumahIbadah.clearLayers(); + layers.ibadahRadius.clearLayers(); + + data.forEach(r => { + const circle = L.circle([r.latitude, r.longitude], { + radius: r.radius_meter, + color: '#f59e0b', fillColor: '#fef3c7', fillOpacity: 0.18, + weight: 1.5, dashArray: '6,4' + }); + layers.ibadahRadius.addLayer(circle); + + const icon = L.divIcon({ + className: '', + html: `
`, + iconSize: [32, 32], iconAnchor: [16, 16] + }); + + const marker = L.marker([r.latitude, r.longitude], { icon, draggable: true }).bindPopup(riPopup(r)); + marker._dataId = r.id; + marker.on('dragend', async function (ev) { + const { lat, lng } = ev.target.getLatLng(); + circle.setLatLng([lat, lng]); + const res = await callAPI(`rumah_ibadah.php?id=${r.id}`, 'PUT', { + nama: r.nama, jenis: r.jenis, alamat: r.alamat, + no_wa: r.no_wa, latitude: lat, longitude: lng, + radius_meter: r.radius_meter + }); + if (res.status === 'success') { + r.latitude = lat; + r.longitude = lng; + toast(`📍 ${r.nama} dipindahkan & koordinat diperbarui`, 'success'); + await recalcAllProximity(); + } else { + toast('Gagal update lokasi: ' + res.message, 'error'); + marker.setLatLng([r.latitude, r.longitude]); + } + }); + + layers.rumahIbadah.addLayer(marker); + }); +} + +function riPopup(r) { + return `

⛪ ${esc(r.nama)}

+

Jenis: ${r.jenis}

+

Radius: ${r.radius_meter} m

+ ${r.alamat ? `

Alamat: ${esc(r.alamat)}

` : ''} + ${r.no_wa ? `

WA: ${r.no_wa}

` : ''} + + `; +} + +function loadPMLayer(data) { + layers.pendudukMiskin.clearLayers(); + + data.forEach(p => { + const inR = p.status_proximity === 'dalam_radius'; + const color = inR ? '#16a34a' : '#dc2626'; + const hasBoth = p.bantuan_pemberdayaan > 0 && p.bantuan_konsumtif > 0; + const hasPemb = p.bantuan_pemberdayaan > 0; + const hasKons = p.bantuan_konsumtif > 0; + + let dot = ''; + if (hasBoth || hasPemb) dot = `
`; + if (hasKons && !hasPemb) dot = `
`; + + const icon = L.divIcon({ + className: '', + html: `
👤
${dot}
`, + iconSize: [26, 26], iconAnchor: [13, 13] + }); + + const marker = L.marker([p.latitude, p.longitude], { icon, draggable: true }).bindPopup(pmPopup(p)); + marker._dataId = p.id; + marker.on('dragend', async function (ev) { + const { lat, lng } = ev.target.getLatLng(); + const res = await callAPI(`penduduk_miskin.php?id=${p.id}`, 'PUT', { + nama: p.nama, no_wa: p.no_wa, nik: p.nik, + alamat: p.alamat, jumlah_anggota: p.jumlah_anggota, + latitude: lat, longitude: lng + }); + if (res.status === 'success') { + const prox = res.data?.proximity; + const statusMsg = prox?.status === 'dalam_radius' ? '✅ Dalam radius' : '❌ Luar radius'; + toast(`📍 ${p.nama} dipindahkan — ${statusMsg}`, 'success'); + switchTab('kemiskinan'); + } else { + toast('Gagal update: ' + res.message, 'error'); + marker.setLatLng([p.latitude, p.longitude]); + } + }); + + layers.pendudukMiskin.addLayer(marker); + }); +} + +function pmPopup(p) { + const inR = p.status_proximity === 'dalam_radius'; + return `

👤 ${esc(p.nama)}

+
+ ${inR ? `✅ Dalam radius — ${esc(p.nama_rumah_ibadah || '')}` : '❌ Di luar radius rumah ibadah'} +
+ ${p.no_wa ? `

WA: ${p.no_wa}

` : ''} + ${p.nik ? `

NIK: ${p.nik}

` : ''} + ${p.alamat ? `

Alamat: ${esc(p.alamat)}

` : ''} +

Keluarga: ${p.jumlah_anggota} jiwa

+

Bantuan: 📚${p.bantuan_pemberdayaan} · 🛍${p.bantuan_konsumtif}

+ + `; +} + +function startAdd(type) { + cancelMapClick(); + const btnId = type === 'ri' ? 'btn-add-ri' : 'btn-add-pm'; + const label = type === 'ri' ? 'lokasi Rumah Ibadah' : 'lokasi Penduduk Miskin'; + document.getElementById(btnId).classList.add('active'); + showInstruction(`Klik di peta untuk menentukan ${label}`); + onceMapClick((lat, lng) => { + document.getElementById(btnId).classList.remove('active'); + hideInstruction(); + if (type === 'ri') openRIForm(null, lat, lng); + else openPMForm(null, lat, lng); + }); +} + +function openRIForm(data, lat, lng) { + const isEdit = !!data; + const fLat = isEdit ? data.latitude : lat; + const fLng = isEdit ? data.longitude : lng; + openModal(isEdit ? 'Edit Rumah Ibadah' : 'Tambah Rumah Ibadah', ` +
📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
`, + ` + `); +} + +async function saveRI(id, lat, lng) { + const body = { + nama: document.getElementById('ri-nama').value.trim(), + jenis: document.getElementById('ri-jenis').value, + alamat: document.getElementById('ri-alamat').value.trim(), + no_wa: document.getElementById('ri-nowa').value.trim(), + latitude: lat, + longitude: lng, + radius_meter: parseInt(document.getElementById('ri-radius').value) + }; + if (!body.nama) return toast('Nama wajib diisi', 'error'); + const res = await callAPI(id ? `rumah_ibadah.php?id=${id}` : 'rumah_ibadah.php', id ? 'PUT' : 'POST', body); + if (res.status === 'success') { + toast(res.message, 'success'); + closeModal(); + await recalcAllProximity(); + switchTab('kemiskinan'); + } else { + toast(res.message, 'error'); + } +} + +function openPMForm(data, lat, lng) { + const isEdit = !!data; + const fLat = isEdit ? data.latitude : lat; + const fLng = isEdit ? data.longitude : lng; + openModal(isEdit ? 'Edit Penduduk Miskin' : 'Tambah Penduduk Miskin', ` +
📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
`, + ` + `); +} + +async function savePM(id, lat, lng) { + const body = { + nama: document.getElementById('pm-nama').value.trim(), + no_wa: document.getElementById('pm-nowa').value.trim(), + nik: document.getElementById('pm-nik').value.trim(), + alamat: document.getElementById('pm-alamat').value.trim(), + jumlah_anggota: parseInt(document.getElementById('pm-jml').value) || 1, + latitude: lat, + longitude: lng + }; + if (!body.nama) return toast('Nama wajib diisi', 'error'); + const res = await callAPI(id ? `penduduk_miskin.php?id=${id}` : 'penduduk_miskin.php', id ? 'PUT' : 'POST', body); + if (res.status === 'success') { + toast(res.message, 'success'); + closeModal(); + switchTab('kemiskinan'); + } else { + toast(res.message, 'error'); + } +} + +async function editRI(id) { + map.closePopup(); + const res = await callAPI(`rumah_ibadah.php?id=${id}`); + if (res.data) openRIForm(res.data, res.data.latitude, res.data.longitude); +} + +async function deleteRI(id) { + if (!confirm('Hapus rumah ibadah ini?')) return; + const res = await callAPI(`rumah_ibadah.php?id=${id}`, 'DELETE'); + if (res.status === 'success') { + toast('Dihapus', 'success'); + map.closePopup(); + await recalcAllProximity(); + switchTab('kemiskinan'); + } +} + +async function editPM(id) { + map.closePopup(); + const res = await callAPI(`penduduk_miskin.php?id=${id}`); + if (res.data) openPMForm(res.data, res.data.latitude, res.data.longitude); +} + +async function deletePM(id) { + if (!confirm('Hapus data ini?')) return; + const res = await callAPI(`penduduk_miskin.php?id=${id}`, 'DELETE'); + if (res.status === 'success') { + toast('Dihapus', 'success'); + map.closePopup(); + await recalcAllProximity(); + switchTab('kemiskinan'); + } +} + +async function openLogBantuan(pmId, pmNama) { + map.closePopup(); + const [logRes, riRes] = await Promise.all([ + callAPI(`log_bantuan.php?penduduk_miskin_id=${pmId}`), + callAPI('rumah_ibadah.php') + ]); + const logs = logRes.data || []; + const ris = riRes.data || []; + + openModal(`Log Bantuan — ${pmNama}`, ` +
+
Riwayat Bantuan
+ ${logs.length ? logs.map(l => ` +
+
${l.tipe_bantuan === 'Pemberdayaan' ? '📚 Pemberdayaan' : '🛍 Konsumtif'}
+
${esc(l.sub_kategori) || ''} ${l.keterangan ? '— ' + esc(l.keterangan) : ''}
+
📅 ${l.tanggal_bantuan} · ${esc(l.nama_rumah_ibadah)}
+
`).join('') : '
Belum ada riwayat bantuan
'} +
+
Tambah Bantuan Baru
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
`, + ` + `); +} + +async function saveLog(pmId) { + const body = { + penduduk_miskin_id: pmId, + rumah_ibadah_id: parseInt(document.getElementById('lb-ri').value), + tipe_bantuan: document.getElementById('lb-tipe').value, + tanggal_bantuan: document.getElementById('lb-tgl').value, + sub_kategori: document.getElementById('lb-sub').value, + keterangan: document.getElementById('lb-ket').value, + nilai_bantuan: document.getElementById('lb-nilai').value || null + }; + const res = await callAPI('log_bantuan.php', 'POST', body); + if (res.status === 'success') { + toast('Log dicatat!', 'success'); + closeModal(); + switchTab('kemiskinan'); + } else { + toast(res.message, 'error'); + } +} + +async function recalcAllProximity() { + await callAPI('penduduk_miskin.php?action=recalc_all', 'POST'); +} diff --git a/js/feature-parsil.js b/js/feature-parsil.js new file mode 100644 index 0000000..cbea01e --- /dev/null +++ b/js/feature-parsil.js @@ -0,0 +1,237 @@ +const parsilCfg = { + SHM: { color: '#16a34a', label: 'Sertifikat Hak Milik' }, + HGB: { color: '#2563eb', label: 'Hak Guna Bangunan' }, + HGU: { color: '#d97706', label: 'Hak Guna Usaha' }, + HP: { color: '#7c3aed', label: 'Hak Pakai' } +}; + +function renderParsilPanel(pb) { + return callAPI('data_parsil.php').then(res => { + const data = res.data || []; + const cnt = k => data.filter(d => d.jenis_hak === k).length; + + pb.innerHTML = ` +
+
Filter Layer
+ ${Object.entries(parsilCfg).map(([k, v]) => ` +
+
${layerVisible['parsil' + k] ? '✓' : ''}
+
+
${k} — ${v.label}
+
${cnt(k)}
+
`).join('')} +
+
+
Cara Menambahkan Data Parsil
+
+ 1. Klik ikon polygon di toolbar kiri peta
+ 2. Klik titik-titik batas bidang tanah
+ 3. Klik titik awal / double-klik untuk menutup
+ 4. Form simpan + luas otomatis muncul +
+
+
+
Data Parsil (${data.length})
+
+ ${data.length ? data.map(d => ` +
+
📐
+
+
${esc(d.nama_pemilik)}
+
${d.luas_m2.toFixed(1)} m² · ${d.no_sertifikat || '—'}
+
+
+
${d.jenis_hak}
+ + +
+
`).join('') : '
Belum ada data parsil
'} +
+
`; + loadParsilLayer(data); + registerSearchEntries('parsil', data.map(d => ({ + key: `parsil:${d.id}`, + label: d.nama_pemilik, + subtitle: `Parsil ${d.jenis_hak} · ${d.luas_m2.toFixed(1)} m²${d.no_sertifikat ? ` · ${d.no_sertifikat}` : ''}`, + typeLabel: 'Parsil', + icon: '📐', + searchText: normalizeSearchText(`${d.nama_pemilik} ${d.jenis_hak} parsil tanah`), + focus: () => focusGeom(d.id, 'parsil') + }))); + updateStatusCount(); + }); +} + +function loadParsilLayer(data) { + ['SHM', 'HGB', 'HGU', 'HP'].forEach(k => layers['parsil' + k].clearLayers()); + data.forEach(d => { + const cfg = parsilCfg[d.jenis_hak], coords = d.geojson.coordinates[0].map(c => [c[1], c[0]]); + const poly = L.polygon(coords, { color: cfg.color, fillColor: cfg.color, fillOpacity: 0.18, weight: 2 }) + .bindPopup(`

📐 ${esc(d.nama_pemilik)}

+

Jenis Hak: ${d.jenis_hak} — ${cfg.label}

+

Luas: ${d.luas_m2.toFixed(2)} m² (${(d.luas_m2 / 10000).toFixed(4)} ha)

+ ${d.no_sertifikat ? `

No. Sertifikat: ${d.no_sertifikat}

` : ''} + ${d.keterangan ? `

${esc(d.keterangan)}

` : ''} + `); + poly._dataId = d.id; + poly._groupKey = 'parsil' + d.jenis_hak; + layers['parsil' + d.jenis_hak].addLayer(poly); + }); +} + +function openParsilForm() { + openModal('Simpan Parsil Tanah', ` +
📐 Luas: ${pendingArea.toFixed(2)} m² = ${(pendingArea / 10000).toFixed(4)} ha
+
+
+
+
+
+
+
+
+
+
`, + ` + `); +} + +async function saveParsil() { + const body = { + nama_pemilik: document.getElementById('ps-nama').value.trim(), + jenis_hak: document.getElementById('ps-hak').value, + no_sertifikat: document.getElementById('ps-sert').value, + luas_m2: pendingArea, + keterangan: document.getElementById('ps-ket').value, + geojson: pendingGeoJSON + }; + if (!body.nama_pemilik) return toast('Nama wajib diisi', 'error'); + const res = await callAPI('data_parsil.php', 'POST', body); + if (res.status === 'success') { + toast(res.message, 'success'); + closeModal(); + drawnItems.clearLayers(); + pendingGeoJSON = null; + pendingArea = null; + switchTab('parsil'); + } else { + toast(res.message, 'error'); + } +} + +async function editParsil(id) { + map.closePopup(); + const res = await callAPI(`data_parsil.php?id=${id}`); + const data = res.data; + const sourceLayer = findGeomLayer('parsil', id); + if (!data) return toast('Data parsil tidak ditemukan', 'error'); + + const groupKey = getGeometryGroupKey('parsil', data); + const editLayer = sourceLayer ? cloneEditableLayer('parsil', sourceLayer) : buildGeometryLayer('parsil', data); + editLayer._dataId = id; + editLayer._groupKey = groupKey; + + if (sourceLayer) layers[groupKey].removeLayer(sourceLayer); + drawnItems.clearLayers(); + drawnItems.addLayer(editLayer); + + geometryEditState = { type: 'parsil', id, groupKey, sourceLayer, editLayer }; + if (editLayer.editing && editLayer.editing.enable) { + editLayer.editing.enable(); + } else { + const handler = getGeometryEditHandler(); + if (!handler) { + drawnItems.clearLayers(); + if (sourceLayer) layers[groupKey].addLayer(sourceLayer); + geometryEditState = null; + return toast('Mode edit geometri belum siap', 'error'); + } + handler.enable(); + } + map.fitBounds(editLayer.getBounds(), { padding: [50, 50], duration: 1 }); + document.getElementById('panel-title').textContent = 'Edit Parsil Tanah'; + document.getElementById('panel-body').innerHTML = ` +
+
Edit Parsil
+
+ Geser titik-titik batas bidang di peta lalu simpan perubahan. +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
`; + document.getElementById('panel-body').insertAdjacentHTML('beforeend', ` +
+ + +
`); +} + +async function saveParsilEdit(id) { + const state = geometryEditState; + if (!state || state.type !== 'parsil') return; + const layer = state.editLayer; + const ringLatLngs = layer.getLatLngs()[0]; + const ring = ringLatLngs.map(ll => [ll.lng, ll.lat]); + ring.push([ringLatLngs[0].lng, ringLatLngs[0].lat]); + const luas_m2 = turf.area(turf.polygon([ring])); + const body = { + nama_pemilik: document.getElementById('ps-edit-nama').value.trim(), + jenis_hak: document.getElementById('ps-edit-hak').value, + no_sertifikat: document.getElementById('ps-edit-sert').value, + luas_m2, + keterangan: document.getElementById('ps-edit-ket').value, + geojson: { type: 'Polygon', coordinates: [ring] } + }; + if (!body.nama_pemilik) return toast('Nama wajib diisi', 'error'); + const res = await callAPI(`data_parsil.php?id=${id}`, 'PUT', body); + if (res.status === 'success') { + toast(res.message, 'success'); + if (layer.editing && layer.editing.disable) layer.editing.disable(); + const handler = getGeometryEditHandler(); + if (handler) handler.disable(); + drawnItems.clearLayers(); + geometryEditState = null; + switchTab('parsil'); + } else { + toast(res.message, 'error'); + } +} + +async function deleteParsil(id) { + if (!confirm('Hapus?')) return; + const r = await callAPI(`data_parsil.php?id=${id}`, 'DELETE'); + if (r.status === 'success') { + toast('Dihapus', 'success'); + map.closePopup(); + switchTab('parsil'); + } +} diff --git a/js/feature-spbu.js b/js/feature-spbu.js new file mode 100644 index 0000000..c9fb322 --- /dev/null +++ b/js/feature-spbu.js @@ -0,0 +1,158 @@ +function renderSpbuPanel(pb) { + return callAPI('spbu.php').then(res => { + const data = res.data || []; + const d24 = data.filter(s => s.buka_24jam); + const dn24 = data.filter(s => !s.buka_24jam); + + pb.innerHTML = ` +
+
Filter Layer
+ ${mkLayerItem('spbu24', '#16a34a', 'SPBU Buka 24 Jam', d24.length)} + ${mkLayerItem('spbuNon24', '#94a3b8', 'SPBU Tidak 24 Jam', dn24.length)} +
+
+
Tambah Data
+ +
+
+
Daftar SPBU (${data.length})
+
+ ${data.length ? data.map(s => ` +
+
+
+
${esc(s.nama)}
+
${s.no_wa || '—'}
+
+
+
${s.buka_24jam ? '24 JAM' : 'TERBATAS'}
+ + +
+
`).join('') : '
Belum ada data
'} +
+
`; + + loadSPBULayer(data); + registerSearchEntries('spbu', data.map(s => ({ + key: `spbu:${s.id}`, + label: s.nama, + subtitle: `SPBU · ${s.buka_24jam ? '24 Jam' : 'Tidak 24 Jam'}${s.alamat ? ` · ${s.alamat}` : ''}`, + typeLabel: 'SPBU', + icon: '⛽', + searchText: normalizeSearchText(`${s.nama} ${s.no_wa || ''} spbu`), + focus: () => focusMarkerById(s.buka_24jam ? 'spbu24' : 'spbuNon24', s.id) + }))); + updateStatusCount(); + }); +} + +function loadSPBULayer(data) { + layers.spbu24.clearLayers(); + layers.spbuNon24.clearLayers(); + data.forEach(s => { + const is24 = s.buka_24jam; + const color = is24 ? '#16a34a' : '#64748b'; + const icon = L.divIcon({ + className: '', + html: `
`, + iconSize: [30, 30], iconAnchor: [15, 15] + }); + const marker = L.marker([s.latitude, s.longitude], { icon, draggable: true }).bindPopup(`

⛽ ${esc(s.nama)}

+

Status: ${is24 ? '✅ Buka 24 Jam' : '⏰ Tidak 24 Jam'}

+ ${s.no_wa ? `

WA: ${s.no_wa}

` : ''} + ${s.alamat ? `

Alamat: ${esc(s.alamat)}

` : ''} + + `); + marker._dataId = s.id; + + marker.on('dragend', async function (ev) { + const { lat, lng } = ev.target.getLatLng(); + const res = await callAPI(`spbu.php?id=${s.id}`, 'PUT', { + nama: s.nama, no_wa: s.no_wa, alamat: s.alamat, + buka_24jam: s.buka_24jam, latitude: lat, longitude: lng + }); + if (res.status === 'success') { + s.latitude = lat; + s.longitude = lng; + toast(`📍 ${s.nama} dipindahkan`, 'success'); + } else { + toast('Gagal update: ' + res.message, 'error'); + marker.setLatLng([s.latitude, s.longitude]); + } + }); + + is24 ? layers.spbu24.addLayer(marker) : layers.spbuNon24.addLayer(marker); + }); +} + +function startAddSPBU() { + cancelMapClick(); + document.getElementById('btn-add-spbu').classList.add('active'); + showInstruction('Klik di peta untuk menentukan lokasi SPBU'); + onceMapClick((lat, lng) => { + document.getElementById('btn-add-spbu').classList.remove('active'); + hideInstruction(); + openSPBUForm(null, lat, lng); + }); +} + +function openSPBUForm(data, lat, lng) { + const isEdit = !!data; + const fLat = isEdit ? data.latitude : lat; + const fLng = isEdit ? data.longitude : lng; + openModal(isEdit ? 'Edit SPBU' : 'Tambah SPBU', ` +
📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}
+
+
+
+
+
+
+
+
`, + ` + `); +} + +async function saveSPBU(id, lat, lng) { + const body = { + nama: document.getElementById('spbu-nama').value.trim(), + no_wa: document.getElementById('spbu-nowa').value.trim(), + alamat: document.getElementById('spbu-alamat').value.trim(), + buka_24jam: document.getElementById('spbu-24').value === '1', + latitude: lat, + longitude: lng + }; + if (!body.nama) return toast('Nama wajib diisi', 'error'); + const res = await callAPI(id ? `spbu.php?id=${id}` : 'spbu.php', id ? 'PUT' : 'POST', body); + if (res.status === 'success') { + toast(res.message, 'success'); + closeModal(); + switchTab('spbu'); + } else { + toast(res.message, 'error'); + } +} + +async function editSPBU(id) { + map.closePopup(); + const r = await callAPI(`spbu.php?id=${id}`); + if (r.data) openSPBUForm(r.data, r.data.latitude, r.data.longitude); +} + +async function deleteSPBU(id) { + if (!confirm('Hapus SPBU?')) return; + const r = await callAPI(`spbu.php?id=${id}`, 'DELETE'); + if (r.status === 'success') { + toast('Dihapus', 'success'); + map.closePopup(); + switchTab('spbu'); + } +}