commit a9b40ecf29517ecbce9b1673fc8dd0145faa8374 Author: cygouw Date: Wed Jun 10 22:01:03 2026 +0700 initial commit diff --git a/index.html b/index.html new file mode 100644 index 0000000..69f455c --- /dev/null +++ b/index.html @@ -0,0 +1,517 @@ + + + + + + Portal WebGIS & Aplikasi Spasial + + + + + + +
+
+ +
🌐
+
+ Portal WebGIS + Tugas & UAS Sistem Informasi Geografis +
+
+
+
CY
+ +
+
+
+ + +
+
Kumpulan Proyek SIG
+

Kumpulan Project WebGIS Interaktif

+

Hub aplikasi spasial interaktif hasil pengerjaan tugas praktikum dan Ujian Akhir Semester (UAS) untuk mata kuliah Sistem Informasi Geografis.

+
+ + +
+ +
+ + + + + + diff --git a/parsil_jalan_jalanrusak/README.md b/parsil_jalan_jalanrusak/README.md new file mode 100644 index 0000000..153e444 --- /dev/null +++ b/parsil_jalan_jalanrusak/README.md @@ -0,0 +1,206 @@ +# πŸ—ΊοΈ WebGIS Manajemen Jalan & Parsil Tanah + +Sistem Informasi Geografis berbasis web untuk manajemen data spasial jalan dan parsil tanah. + +--- + +## πŸ“ Struktur File + +``` +webgis/ +β”œβ”€β”€ index.html ← Halaman utama WebGIS +β”œβ”€β”€ database.sql ← Schema & sample data MySQL +β”œβ”€β”€ README.md ← Dokumentasi ini +└── api/ + β”œβ”€β”€ config.php ← Konfigurasi koneksi database + β”œβ”€β”€ jalan.php ← API CRUD Data Jalan + └── parsil.php ← API CRUD Data Parsil Tanah +``` + +--- + +## βš™οΈ Cara Install + +### 1. Persyaratan +- **Web Server**: Apache / Nginx dengan PHP 7.4+ +- **Database**: MySQL 5.7+ atau MariaDB 10.3+ +- **Browser**: Chrome, Firefox, Edge (modern) + +### 2. Setup Database + +```sql +-- Jalankan di MySQL / phpMyAdmin +SOURCE database.sql; +``` + +Atau import file `database.sql` melalui phpMyAdmin. + +### 3. Konfigurasi Database + +Edit file `api/config.php`: + +```php +define('DB_HOST', 'localhost'); // Host database +define('DB_USER', 'root'); // Username MySQL +define('DB_PASS', ''); // Password MySQL +define('DB_NAME', 'webgis_db'); // Nama database +``` + +### 4. Deploy ke Web Server + +**Menggunakan XAMPP/WAMP/LAMP:** +```bash +# Copy folder webgis/ ke: +# Windows XAMPP: C:/xampp/htdocs/webgis/ +# Linux LAMP: /var/www/html/webgis/ +``` + +**Akses aplikasi:** +``` +http://localhost/polyline_polygon/index.html +``` + +--- + +## 🌟 Fitur Utama + +### πŸ›£οΈ Manajemen Data Jalan (Polyline) +| Fitur | Deskripsi | +|-------|-----------| +| **Tambah Jalan** | Gambar polyline di peta atau input koordinat manual | +| **Status Jalan** | Nasional (merah), Provinsi (oranye), Kabupaten (hijau) | +| **Panjang Otomatis** | Dihitung menggunakan `L.latLng.distanceTo()` dari LeafletJS | +| **Edit & Hapus** | CRUD lengkap dengan konfirmasi hapus | +| **Drag Titik** | Titik koordinat bisa diubah langsung di tabel | + +### 🏘️ Manajemen Parsil Tanah (Polygon) +| Fitur | Deskripsi | +|-------|-----------| +| **Tambah Parsil** | Gambar polygon di peta atau input koordinat manual | +| **Status Kepemilikan** | SHM, HGB, HGU, HP β€” masing-masing warna berbeda | +| **Luas Otomatis** | Dihitung dengan formula Spherical Excess dari LeafletJS | +| **Data Lengkap** | Nama parsil, nomor sertifikat, nama pemilik, keterangan | + +### πŸ—ΊοΈ Fitur Peta +- **Basemap**: OpenStreetMap + Esri Satellite (switchable) +- **Draw Mode**: Klik peta untuk menggambar fitur spasial +- **Popup Interaktif**: Klik fitur di peta untuk melihat detail +- **Zoom to Feature**: Langsung zoom ke lokasi dari sidebar +- **Koordinat Real-time**: Tampil di pojok kiri bawah + +--- + +## 🎨 Kode Warna + +### Jalan +| Status | Warna | Hex | +|--------|-------|-----| +| Jalan Nasional | πŸ”΄ Merah | `#E63946` | +| Jalan Provinsi | 🟠 Oranye | `#F4A261` | +| Jalan Kabupaten | 🟒 Hijau | `#2A9D8F` | + +### Parsil Tanah +| Status | Warna | Hex | +|--------|-------|-----| +| SHM | πŸ”΅ Biru | `#4361EE` | +| HGB | 🟣 Ungu | `#7209B7` | +| HGU | 🌸 Pink | `#F72585` | +| HP | 🩡 Cyan | `#4CC9F0` | + +--- + +## πŸ“‘ API Endpoints + +### Jalan +``` +GET api/jalan.php β†’ Ambil semua data jalan +GET api/jalan.php?id={id} β†’ Ambil data jalan by ID +POST api/jalan.php β†’ Tambah data jalan baru +PUT api/jalan.php?id={id} β†’ Update data jalan +DELETE api/jalan.php?id={id} β†’ Hapus data jalan +``` + +### Parsil Tanah +``` +GET api/parsil.php β†’ Ambil semua data parsil +GET api/parsil.php?id={id} β†’ Ambil data parsil by ID +POST api/parsil.php β†’ Tambah data parsil baru +PUT api/parsil.php?id={id} β†’ Update data parsil +DELETE api/parsil.php?id={id} β†’ Hapus data parsil +``` + +### Contoh Request Body (POST/PUT Jalan) +```json +{ + "nama_jalan": "Jalan Ahmad Yani", + "status_jalan": "Jalan Nasional", + "panjang_meter": 1250.5, + "koordinat": [ + [0.0032, -109.3245], + [0.0051, -109.3180], + [0.0068, -109.3110] + ], + "keterangan": "Jalan utama kota" +} +``` + +### Contoh Request Body (POST/PUT Parsil) +```json +{ + "nama_parsil": "Kavling A-001", + "nomor_sertifikat": "SHM-2024-001", + "status_kepemilikan": "SHM", + "luas_meter2": 450.0, + "koordinat": [ + [0.0010, -109.3280], + [0.0010, -109.3260], + [0.0020, -109.3260], + [0.0020, -109.3280] + ], + "nama_pemilik": "Budi Santoso", + "keterangan": "Kavling perumahan" +} +``` + +--- + +## πŸ’‘ Cara Penggunaan + +### Menambah Data Jalan Baru +1. Klik tab **"Data Jalan"** di topbar +2. Klik **"✚ Tambah Data Baru"** di sidebar +3. **Opsi A**: Klik **"✏️ Gambar di Peta"** β†’ klik titik-titik di peta β†’ klik **"⏹ Selesai Gambar"** +4. **Opsi B**: Isi koordinat manual di tabel (lat/lng double) +5. Isi nama jalan, pilih status, tambah keterangan +6. Panjang jalan otomatis terhitung oleh LeafletJS +7. Klik **"πŸ’Ύ Simpan Data"** + +### Menambah Data Parsil Baru +1. Klik tab **"Parsil Tanah"** di topbar +2. Klik **"✚ Tambah Data Baru"** +3. Gambar polygon di peta (min. 3 titik) atau isi koordinat manual +4. Luas tanah otomatis terhitung oleh LeafletJS +5. Isi nama parsil, nomor sertifikat, status kepemilikan, nama pemilik +6. Klik **"πŸ’Ύ Simpan Data"** + +### Edit Data +1. Klik **"✏️ Edit"** pada kartu data di sidebar, atau klik fitur di peta β†’ klik Edit di popup +2. Ubah data yang diperlukan, termasuk koordinat +3. Klik **"πŸ’Ύ Simpan Data"** + +--- + +## πŸ”§ Mode Demo + +Jika server PHP belum terkonfigurasi, aplikasi otomatis berjalan dalam **Mode Demo** dengan data sample. Semua operasi CRUD tetap berfungsi secara lokal di browser (tidak tersimpan ke database). + +--- + +## πŸ“š Teknologi + +- **Frontend**: HTML5, CSS3 (Custom Properties), Vanilla JavaScript +- **Peta**: LeafletJS 1.9.4 + Leaflet.draw 1.0.4 +- **Backend**: PHP 7.4+ (REST API) +- **Database**: MySQL / MariaDB +- **Basemap**: OpenStreetMap, Esri World Imagery +- **Font**: Plus Jakarta Sans, DM Mono (Google Fonts) diff --git a/parsil_jalan_jalanrusak/api/config.php b/parsil_jalan_jalanrusak/api/config.php new file mode 100644 index 0000000..d2b614a --- /dev/null +++ b/parsil_jalan_jalanrusak/api/config.php @@ -0,0 +1,26 @@ +connect_error) { + die(json_encode(['success' => false, 'message' => 'Koneksi database gagal: ' . $conn->connect_error])); + } + $conn->set_charset('utf8mb4'); + return $conn; +} + +// Set header untuk API +header('Content-Type: application/json'); +header('Access-Control-Allow-Origin: *'); +header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); +header('Access-Control-Allow-Headers: Content-Type'); + +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + exit(0); +} +?> diff --git a/parsil_jalan_jalanrusak/api/jalan.php b/parsil_jalan_jalanrusak/api/jalan.php new file mode 100644 index 0000000..8446ce8 --- /dev/null +++ b/parsil_jalan_jalanrusak/api/jalan.php @@ -0,0 +1,147 @@ + false, 'message' => 'Method tidak diizinkan']); +} + +// ───────────────────────────────────────────── +// Helper: baris DB β†’ array siap kirim ke client +// Mengembalikan struktur flat yang dipakai frontend, +// dengan field `koordinat` berisi [[lat,lng],...] agar +// Leaflet langsung bisa dipakai, PLUS field `geojson` +// berisi GeoJSON Feature asli. +// ───────────────────────────────────────────── +function rowToResponse($row) { + $feature = json_decode($row['geojson'], true); + + // GeoJSON coords = [lng, lat] β†’ ubah ke [lat, lng] untuk Leaflet + $coords_geojson = $feature['geometry']['coordinates'] ?? []; + $koordinat_leaflet = array_map(fn($c) => [$c[1], $c[0]], $coords_geojson); + + return [ + 'id' => (int)$row['id'], + 'nama_jalan' => $row['nama_jalan'], + 'status_jalan' => $row['status_jalan'], + 'panjang_meter' => (float)$row['panjang_meter'], + 'koordinat' => $koordinat_leaflet, // [lat,lng] untuk Leaflet + 'geojson' => $feature, // GeoJSON Feature asli + 'created_at' => $row['created_at'], + 'updated_at' => $row['updated_at'], + ]; +} + +// ───────────────────────────────────────────── +// Helper: koordinat [[lat,lng],...] β†’ GeoJSON Feature string +// ───────────────────────────────────────────── +function buildGeoJSON($nama, $status, $panjang, $koordinat_leaflet) { + // Leaflet: [lat, lng] β†’ GeoJSON: [lng, lat] + $coords = array_map(fn($c) => [(float)$c[1], (float)$c[0]], $koordinat_leaflet); + + $feature = [ + 'type' => 'Feature', + 'properties' => [ + 'nama_jalan' => $nama, + 'status_jalan' => $status, + 'panjang_meter' => (float)$panjang, + ], + 'geometry' => [ + 'type' => 'LineString', + 'coordinates' => $coords, + ], + ]; + return json_encode($feature, JSON_UNESCAPED_UNICODE); +} + +function getJalan($id = null) { + $db = getDB(); + if ($id) { + $stmt = $db->prepare("SELECT * FROM jalan WHERE id = ?"); + $stmt->bind_param('i', $id); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result->fetch_assoc(); + if ($row) { + echo json_encode(['success' => true, 'data' => rowToResponse($row)]); + } else { + echo json_encode(['success' => false, 'message' => 'Data tidak ditemukan']); + } + } else { + $result = $db->query("SELECT * FROM jalan ORDER BY created_at DESC"); + $rows = []; + while ($row = $result->fetch_assoc()) { + $rows[] = rowToResponse($row); + } + echo json_encode(['success' => true, 'data' => $rows, 'total' => count($rows)]); + } + $db->close(); +} + +function createJalan() { + $db = getDB(); + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['nama_jalan']) || empty($input['status_jalan']) || empty($input['koordinat'])) { + echo json_encode(['success' => false, 'message' => 'Data tidak lengkap']); + return; + } + + $nama = $db->real_escape_string($input['nama_jalan']); + $status = $db->real_escape_string($input['status_jalan']); + $panjang = (float)($input['panjang_meter'] ?? 0); + $geojson = buildGeoJSON($nama, $status, $panjang, $input['koordinat']); + + $stmt = $db->prepare("INSERT INTO jalan (nama_jalan, status_jalan, panjang_meter, geojson) VALUES (?, ?, ?, ?)"); + $stmt->bind_param('ssds', $nama, $status, $panjang, $geojson); + + if ($stmt->execute()) { + echo json_encode(['success' => true, 'message' => 'Data jalan berhasil disimpan', 'id' => $db->insert_id]); + } else { + echo json_encode(['success' => false, 'message' => 'Gagal menyimpan data: ' . $db->error]); + } + $db->close(); +} + +function updateJalan($id) { + if (!$id) { echo json_encode(['success' => false, 'message' => 'ID diperlukan']); return; } + $db = getDB(); + $input = json_decode(file_get_contents('php://input'), true); + + $nama = $db->real_escape_string($input['nama_jalan']); + $status = $db->real_escape_string($input['status_jalan']); + $panjang = (float)($input['panjang_meter'] ?? 0); + $geojson = buildGeoJSON($nama, $status, $panjang, $input['koordinat']); + + $stmt = $db->prepare("UPDATE jalan SET nama_jalan=?, status_jalan=?, panjang_meter=?, geojson=?, updated_at=NOW() WHERE id=?"); + $stmt->bind_param('ssdsi', $nama, $status, $panjang, $geojson, $id); + + if ($stmt->execute()) { + echo json_encode(['success' => true, 'message' => 'Data jalan berhasil diperbarui']); + } else { + echo json_encode(['success' => false, 'message' => 'Gagal memperbarui data: ' . $db->error]); + } + $db->close(); +} + +function deleteJalan($id) { + if (!$id) { echo json_encode(['success' => false, 'message' => 'ID diperlukan']); return; } + $db = getDB(); + $stmt = $db->prepare("DELETE FROM jalan WHERE id = ?"); + $stmt->bind_param('i', $id); + if ($stmt->execute()) { + echo json_encode(['success' => true, 'message' => 'Data jalan berhasil dihapus']); + } else { + echo json_encode(['success' => false, 'message' => 'Gagal menghapus data']); + } + $db->close(); +} +?> diff --git a/parsil_jalan_jalanrusak/api/laporan.php b/parsil_jalan_jalanrusak/api/laporan.php new file mode 100644 index 0000000..a7c2a7c --- /dev/null +++ b/parsil_jalan_jalanrusak/api/laporan.php @@ -0,0 +1,245 @@ + false, 'message' => 'Method tidak diizinkan']); +} + +// ───────────────────────────────────────────── +// Info anggaran berdasarkan status jalan +// ───────────────────────────────────────────── +function getInfoAnggaran($status_jalan) { + switch ($status_jalan) { + case 'Jalan Nasional': + return ['pengelola' => 'Kementerian PUPR', 'sumber' => 'APBN']; + case 'Jalan Provinsi': + return ['pengelola' => 'Dinas PUPR Provinsi', 'sumber' => 'APBD Provinsi']; + case 'Jalan Kabupaten': + return ['pengelola' => 'Dinas PUPR Kabupaten/Kota', 'sumber' => 'APBD Kabupaten']; + default: + return ['pengelola' => 'Tidak Diketahui', 'sumber' => '-']; + } +} + +// ───────────────────────────────────────────── +// Helper: baris DB β†’ array siap kirim ke client +// ───────────────────────────────────────────── +function rowToResponse($row) { + $feature = json_decode($row['geojson'], true); + $coords_geojson = $feature['geometry']['coordinates'] ?? [0, 0]; + + return [ + 'id' => (int)$row['id'], + 'jalan_id' => (int)$row['jalan_id'], + 'nama_jalan' => $row['nama_jalan'], + 'status_jalan' => $row['status_jalan'], + 'info_anggaran' => getInfoAnggaran($row['status_jalan']), + 'nama_pelapor' => $row['nama_pelapor'], + 'kategori_rusak' => $row['kategori_rusak'], + 'tanggal_laporan' => $row['tanggal_laporan'], + 'keterangan' => $row['keterangan'], + 'foto_url' => $row['foto_url'], + 'lat' => $coords_geojson[1], + 'lng' => $coords_geojson[0], + 'geojson' => $feature, + 'created_at' => $row['created_at'], + 'updated_at' => $row['updated_at'], + ]; +} + +function getLaporan($id = null) { + $db = getDB(); + + // Query parameter opsional: filter bulan, tahun, jalan_id + $where = ['1=1']; + $params = []; + $types = ''; + + if (isset($_GET['bulan_terakhir'])) { + $n = (int)$_GET['bulan_terakhir']; + $where[] = "tanggal_laporan >= DATE_SUB(CURDATE(), INTERVAL ? MONTH)"; + $params[] = $n; + $types .= 'i'; + } + if (isset($_GET['tahun_terakhir'])) { + $n = (int)$_GET['tahun_terakhir']; + $where[] = "tanggal_laporan >= DATE_SUB(CURDATE(), INTERVAL ? YEAR)"; + $params[] = $n; + $types .= 'i'; + } + if (isset($_GET['jalan_id'])) { + $where[] = "jalan_id = ?"; + $params[] = (int)$_GET['jalan_id']; + $types .= 'i'; + } + if (isset($_GET['kategori'])) { + $where[] = "kategori_rusak = ?"; + $params[] = $_GET['kategori']; + $types .= 's'; + } + + if ($id) { + $stmt = $db->prepare("SELECT * FROM laporan_kerusakan WHERE id = ?"); + $stmt->bind_param('i', $id); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result->fetch_assoc(); + if ($row) { + echo json_encode(['success' => true, 'data' => rowToResponse($row)]); + } else { + echo json_encode(['success' => false, 'message' => 'Data tidak ditemukan']); + } + } else { + $sql = "SELECT * FROM laporan_kerusakan WHERE " . implode(' AND ', $where) . " ORDER BY tanggal_laporan DESC"; + $stmt = $db->prepare($sql); + if ($params) { + $stmt->bind_param($types, ...$params); + } + $stmt->execute(); + $result = $stmt->get_result(); + $rows = []; + while ($row = $result->fetch_assoc()) { + $rows[] = rowToResponse($row); + } + + // Hitung jalan yang sering rusak (muncul >= 3 laporan dalam 3 tahun) + $res_sering = $db->query(" + SELECT jalan_id, nama_jalan, COUNT(*) as jumlah + FROM laporan_kerusakan + WHERE tanggal_laporan >= DATE_SUB(CURDATE(), INTERVAL 3 YEAR) + GROUP BY jalan_id, nama_jalan + HAVING jumlah >= 3 + ORDER BY jumlah DESC + "); + $sering_rusak = []; + while ($sr = $res_sering->fetch_assoc()) { + $sering_rusak[] = ['jalan_id' => (int)$sr['jalan_id'], 'nama_jalan' => $sr['nama_jalan'], 'jumlah_laporan' => (int)$sr['jumlah']]; + } + + echo json_encode([ + 'success' => true, + 'data' => $rows, + 'total' => count($rows), + 'sering_rusak' => $sering_rusak + ]); + } + $db->close(); +} + +function createLaporan() { + $db = getDB(); + + // Cek apakah ada upload foto + $foto_url = null; + if (!empty($_FILES['foto']['tmp_name'])) { + $ext = pathinfo($_FILES['foto']['name'], PATHINFO_EXTENSION); + $filename = 'laporan_' . time() . '_' . rand(1000,9999) . '.' . strtolower($ext); + $upload_dir = '../uploads/laporan/'; + if (!is_dir($upload_dir)) mkdir($upload_dir, 0755, true); + if (move_uploaded_file($_FILES['foto']['tmp_name'], $upload_dir . $filename)) { + $foto_url = 'uploads/laporan/' . $filename; + } + } + + // Baca JSON dari body (jika bukan multipart) + $input = []; + $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; + if (strpos($contentType, 'application/json') !== false) { + $input = json_decode(file_get_contents('php://input'), true) ?? []; + // Foto bisa base64 embedded + if (!empty($input['foto_base64'])) { + $imgData = base64_decode(preg_replace('#^data:image/\w+;base64,#i', '', $input['foto_base64'])); + $filename = 'laporan_' . time() . '_' . rand(1000,9999) . '.jpg'; + $upload_dir = '../uploads/laporan/'; + if (!is_dir($upload_dir)) mkdir($upload_dir, 0755, true); + file_put_contents($upload_dir . $filename, $imgData); + $foto_url = 'uploads/laporan/' . $filename; + } + } else { + $input = $_POST; + } + + if (empty($input['lat']) || empty($input['lng']) || empty($input['kategori_rusak'])) { + echo json_encode(['success' => false, 'message' => 'Data tidak lengkap (lat, lng, kategori_rusak wajib)']); + return; + } + + $lat = (float)$input['lat']; + $lng = (float)$input['lng']; + $jalan_id = (int)($input['jalan_id'] ?? 0); + $nama_jalan = $db->real_escape_string($input['nama_jalan'] ?? 'Tidak Diketahui'); + $status_jalan = $db->real_escape_string($input['status_jalan'] ?? ''); + $nama_pelapor = $db->real_escape_string($input['nama_pelapor'] ?? 'Anonim'); + $kategori = $db->real_escape_string($input['kategori_rusak']); + $tanggal = $db->real_escape_string($input['tanggal_laporan'] ?? date('Y-m-d')); + $keterangan = $db->real_escape_string($input['keterangan'] ?? ''); + + // Build GeoJSON Point + $geojson = json_encode([ + 'type' => 'Feature', + 'properties' => [ + 'nama_jalan' => $nama_jalan, + 'status_jalan' => $status_jalan, + 'kategori_rusak' => $kategori, + 'tanggal_laporan'=> $tanggal, + 'nama_pelapor' => $nama_pelapor, + ], + 'geometry' => [ + 'type' => 'Point', + 'coordinates' => [$lng, $lat], // GeoJSON: [lng, lat] + ], + ], JSON_UNESCAPED_UNICODE); + + $stmt = $db->prepare("INSERT INTO laporan_kerusakan (jalan_id, nama_jalan, status_jalan, nama_pelapor, kategori_rusak, tanggal_laporan, keterangan, foto_url, geojson) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); + $stmt->bind_param('issssssss', $jalan_id, $nama_jalan, $status_jalan, $nama_pelapor, $kategori, $tanggal, $keterangan, $foto_url, $geojson); + + if ($stmt->execute()) { + echo json_encode(['success' => true, 'message' => 'Laporan berhasil disimpan', 'id' => $db->insert_id]); + } else { + echo json_encode(['success' => false, 'message' => 'Gagal menyimpan: ' . $db->error]); + } + $db->close(); +} + +function updateLaporan($id) { + if (!$id) { echo json_encode(['success' => false, 'message' => 'ID diperlukan']); return; } + $db = getDB(); + $input = json_decode(file_get_contents('php://input'), true); + + $kategori = $db->real_escape_string($input['kategori_rusak'] ?? ''); + $tanggal = $db->real_escape_string($input['tanggal_laporan'] ?? date('Y-m-d')); + $keterangan = $db->real_escape_string($input['keterangan'] ?? ''); + + $stmt = $db->prepare("UPDATE laporan_kerusakan SET kategori_rusak=?, tanggal_laporan=?, keterangan=?, updated_at=NOW() WHERE id=?"); + $stmt->bind_param('sssi', $kategori, $tanggal, $keterangan, $id); + + if ($stmt->execute()) { + echo json_encode(['success' => true, 'message' => 'Laporan berhasil diperbarui']); + } else { + echo json_encode(['success' => false, 'message' => 'Gagal memperbarui: ' . $db->error]); + } + $db->close(); +} + +function deleteLaporan($id) { + if (!$id) { echo json_encode(['success' => false, 'message' => 'ID diperlukan']); return; } + $db = getDB(); + $stmt = $db->prepare("DELETE FROM laporan_kerusakan WHERE id = ?"); + $stmt->bind_param('i', $id); + if ($stmt->execute()) { + echo json_encode(['success' => true, 'message' => 'Laporan berhasil dihapus']); + } else { + echo json_encode(['success' => false, 'message' => 'Gagal menghapus: ' . $db->error]); + } + $db->close(); +} +?> diff --git a/parsil_jalan_jalanrusak/api/parsil.php b/parsil_jalan_jalanrusak/api/parsil.php new file mode 100644 index 0000000..cb37709 --- /dev/null +++ b/parsil_jalan_jalanrusak/api/parsil.php @@ -0,0 +1,161 @@ + false, 'message' => 'Method tidak diizinkan']); +} + +// ───────────────────────────────────────────── +// Helper: baris DB β†’ array siap kirim ke client +// ───────────────────────────────────────────── +function rowToResponse($row) { + $feature = json_decode($row['geojson'], true); + + // GeoJSON Polygon coords = array of rings, ring[0] = outer ring + // Setiap titik [lng, lat] β†’ ubah ke [lat, lng] untuk Leaflet + // Hilangkan titik penutup (sama dengan titik pertama) yang GeoJSON haruskan + $ring_geojson = $feature['geometry']['coordinates'][0] ?? []; + $koordinat_leaflet = array_map(fn($c) => [$c[1], $c[0]], $ring_geojson); + // Hapus titik penutup jika sama dengan titik pertama + $n = count($koordinat_leaflet); + if ($n > 1 && $koordinat_leaflet[0] === $koordinat_leaflet[$n - 1]) { + array_pop($koordinat_leaflet); + } + + return [ + 'id' => (int)$row['id'], + 'nama_parsil' => $row['nomor_sertifikat'] ?? $row['nama_pemilik'] ?? 'Parsil', + 'nomor_sertifikat' => $row['nomor_sertifikat'], + 'status_kepemilikan' => $row['status_kepemilikan'], + 'luas_meter2' => (float)$row['luas_meter2'], + 'koordinat' => $koordinat_leaflet, // [lat,lng] untuk Leaflet + 'geojson' => $feature, // GeoJSON Feature asli + 'nama_pemilik' => $row['nama_pemilik'], + 'created_at' => $row['created_at'], + 'updated_at' => $row['updated_at'], + ]; +} + +// ───────────────────────────────────────────── +// Helper: koordinat [[lat,lng],...] β†’ GeoJSON Feature string +// GeoJSON Polygon ring harus ditutup (titik pertama = terakhir) +// ───────────────────────────────────────────── +function buildGeoJSON($nosert, $status, $luas, $pemilik, $koordinat_leaflet) { + // Leaflet: [lat, lng] β†’ GeoJSON: [lng, lat] + $coords = array_map(fn($c) => [(float)$c[1], (float)$c[0]], $koordinat_leaflet); + + // Tutup ring: tambahkan titik pertama di akhir jika belum + if (!empty($coords) && $coords[0] !== $coords[count($coords) - 1]) { + $coords[] = $coords[0]; + } + + $feature = [ + 'type' => 'Feature', + 'properties' => [ + 'nomor_sertifikat' => $nosert, + 'status_kepemilikan' => $status, + 'luas_meter2' => (float)$luas, + 'nama_pemilik' => $pemilik, + ], + 'geometry' => [ + 'type' => 'Polygon', + 'coordinates' => [$coords], // array of rings + ], + ]; + return json_encode($feature, JSON_UNESCAPED_UNICODE); +} + +function getParsil($id = null) { + $db = getDB(); + if ($id) { + $stmt = $db->prepare("SELECT * FROM parsil_tanah WHERE id = ?"); + $stmt->bind_param('i', $id); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result->fetch_assoc(); + if ($row) { + echo json_encode(['success' => true, 'data' => rowToResponse($row)]); + } else { + echo json_encode(['success' => false, 'message' => 'Data tidak ditemukan']); + } + } else { + $result = $db->query("SELECT * FROM parsil_tanah ORDER BY created_at DESC"); + $rows = []; + while ($row = $result->fetch_assoc()) { + $rows[] = rowToResponse($row); + } + echo json_encode(['success' => true, 'data' => $rows, 'total' => count($rows)]); + } + $db->close(); +} + +function createParsil() { + $db = getDB(); + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['status_kepemilikan']) || empty($input['koordinat'])) { + echo json_encode(['success' => false, 'message' => 'Data tidak lengkap']); + return; + } + + $nosert = $db->real_escape_string($input['nomor_sertifikat'] ?? ''); + $status = $db->real_escape_string($input['status_kepemilikan']); + $luas = (float)($input['luas_meter2'] ?? 0); + $pemilik = $db->real_escape_string($input['nama_pemilik'] ?? ''); + $geojson = buildGeoJSON($nosert, $status, $luas, $pemilik, $input['koordinat']); + + $stmt = $db->prepare("INSERT INTO parsil_tanah (nomor_sertifikat, status_kepemilikan, luas_meter2, geojson, nama_pemilik) VALUES (?, ?, ?, ?, ?)"); + $stmt->bind_param('ssdss', $nosert, $status, $luas, $geojson, $pemilik); + + if ($stmt->execute()) { + echo json_encode(['success' => true, 'message' => 'Data parsil berhasil disimpan', 'id' => $db->insert_id]); + } else { + echo json_encode(['success' => false, 'message' => 'Gagal menyimpan data: ' . $db->error]); + } + $db->close(); +} + +function updateParsil($id) { + if (!$id) { echo json_encode(['success' => false, 'message' => 'ID diperlukan']); return; } + $db = getDB(); + $input = json_decode(file_get_contents('php://input'), true); + + $nosert = $db->real_escape_string($input['nomor_sertifikat'] ?? ''); + $status = $db->real_escape_string($input['status_kepemilikan']); + $luas = (float)($input['luas_meter2'] ?? 0); + $pemilik = $db->real_escape_string($input['nama_pemilik'] ?? ''); + $geojson = buildGeoJSON($nosert, $status, $luas, $pemilik, $input['koordinat']); + + $stmt = $db->prepare("UPDATE parsil_tanah SET nomor_sertifikat=?, status_kepemilikan=?, luas_meter2=?, geojson=?, nama_pemilik=?, updated_at=NOW() WHERE id=?"); + $stmt->bind_param('ssdssi', $nosert, $status, $luas, $geojson, $pemilik, $id); + + if ($stmt->execute()) { + echo json_encode(['success' => true, 'message' => 'Data parsil berhasil diperbarui']); + } else { + echo json_encode(['success' => false, 'message' => 'Gagal memperbarui data: ' . $db->error]); + } + $db->close(); +} + +function deleteParsil($id) { + if (!$id) { echo json_encode(['success' => false, 'message' => 'ID diperlukan']); return; } + $db = getDB(); + $stmt = $db->prepare("DELETE FROM parsil_tanah WHERE id = ?"); + $stmt->bind_param('i', $id); + if ($stmt->execute()) { + echo json_encode(['success' => true, 'message' => 'Data parsil berhasil dihapus']); + } else { + echo json_encode(['success' => false, 'message' => 'Gagal menghapus data']); + } + $db->close(); +} +?> diff --git a/parsil_jalan_jalanrusak/database.sql b/parsil_jalan_jalanrusak/database.sql new file mode 100644 index 0000000..e7ab2f2 --- /dev/null +++ b/parsil_jalan_jalanrusak/database.sql @@ -0,0 +1,206 @@ +-- ============================================ +-- WebGIS Manajemen Jalan & Parsil Tanah +-- Database Schema β€” Format GeoJSON +-- ============================================ + +CREATE DATABASE IF NOT EXISTS polyline_polygon CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE polyline_polygon; + +-- ============================================ +-- Tabel Data Jalan (LineString GeoJSON) +-- ============================================ +CREATE TABLE IF NOT EXISTS jalan ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama_jalan VARCHAR(255) NOT NULL, + status_jalan ENUM('Jalan Nasional', 'Jalan Provinsi', 'Jalan Kabupaten') NOT NULL, + panjang_meter DOUBLE DEFAULT 0, + geojson LONGTEXT NOT NULL COMMENT 'GeoJSON Feature object with geometry type LineString', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- ============================================ +-- Tabel Data Parsil Tanah (Polygon GeoJSON) +-- ============================================ +CREATE TABLE IF NOT EXISTS parsil_tanah ( + id INT AUTO_INCREMENT PRIMARY KEY, + nomor_sertifikat VARCHAR(100), + status_kepemilikan ENUM('SHM', 'HGB', 'HGU', 'HP') NOT NULL, + luas_meter2 DOUBLE DEFAULT 0, + geojson LONGTEXT NOT NULL COMMENT 'GeoJSON Feature object with geometry type Polygon', + nama_pemilik VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- ============================================ +-- Tabel Laporan Kerusakan Jalan (Point GeoJSON) +-- ============================================ +CREATE TABLE IF NOT EXISTS laporan_kerusakan ( + id INT AUTO_INCREMENT PRIMARY KEY, + jalan_id INT DEFAULT 0 COMMENT 'FK ke tabel jalan (0 jika tidak terdeteksi)', + nama_jalan VARCHAR(255) DEFAULT 'Tidak Diketahui', + status_jalan VARCHAR(50) DEFAULT '', + nama_pelapor VARCHAR(255) DEFAULT 'Anonim', + kategori_rusak ENUM('Ringan', 'Sedang', 'Berat') NOT NULL, + tanggal_laporan DATE NOT NULL, + keterangan TEXT, + foto_url VARCHAR(500) DEFAULT NULL COMMENT 'Path foto kerusakan yang diupload', + geojson LONGTEXT NOT NULL COMMENT 'GeoJSON Feature Point [lng, lat]', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_jalan_id (jalan_id), + INDEX idx_tanggal (tanggal_laporan), + INDEX idx_kategori (kategori_rusak) +); + +-- ============================================ +-- Sample Data Jalan +-- Catatan: GeoJSON menggunakan [lng, lat] (bukan [lat, lng]) +-- ============================================ +INSERT INTO jalan (nama_jalan, status_jalan, panjang_meter, geojson) VALUES +( + 'Jalan Ahmad Yani', + 'Jalan Nasional', + 1250.5, + '{ + "type": "Feature", + "properties": { + "nama_jalan": "Jalan Ahmad Yani", + "status_jalan": "Jalan Nasional", + "panjang_meter": 1250.5 + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [109.3300, -0.0220], + [109.3340, -0.0235], + [109.3375, -0.0250], + [109.3410, -0.0263] + ] + } + }' +), +( + 'Jalan Gajah Mada', + 'Jalan Provinsi', + 870.3, + '{ + "type": "Feature", + "properties": { + "nama_jalan": "Jalan Gajah Mada", + "status_jalan": "Jalan Provinsi", + "panjang_meter": 870.3 + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [109.3280, -0.0280], + [109.3330, -0.0270], + [109.3380, -0.0260] + ] + } + }' +), +( + 'Jalan Pahlawan', + 'Jalan Kabupaten', + 540.7, + '{ + "type": "Feature", + "properties": { + "nama_jalan": "Jalan Pahlawan", + "status_jalan": "Jalan Kabupaten", + "panjang_meter": 540.7 + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [109.3420, -0.0300], + [109.3450, -0.0290], + [109.3480, -0.0280] + ] + } + }' +); + +-- ============================================ +-- Sample Data Parsil Tanah +-- Polygon: koordinat pertama dan terakhir harus sama (closed ring) +-- ============================================ +INSERT INTO parsil_tanah (nomor_sertifikat, status_kepemilikan, luas_meter2, geojson, nama_pemilik) VALUES +( + 'SHM-2024-001', + 'SHM', + 450.0, + '{ + "type": "Feature", + "properties": { + "nomor_sertifikat": "SHM-2024-001", + "status_kepemilikan": "SHM", + "luas_meter2": 450.0, + "nama_pemilik": "Budi Santoso" + }, + "geometry": { + "type": "Polygon", + "coordinates": [[ + [109.3355, -0.0255], + [109.3370, -0.0255], + [109.3370, -0.0245], + [109.3355, -0.0245], + [109.3355, -0.0255] + ]] + } + }', + 'Budi Santoso' +), +( + 'HGB-2024-002', + 'HGB', + 620.5, + '{ + "type": "Feature", + "properties": { + "nomor_sertifikat": "HGB-2024-002", + "status_kepemilikan": "HGB", + "luas_meter2": 620.5, + "nama_pemilik": "PT. Maju Jaya" + }, + "geometry": { + "type": "Polygon", + "coordinates": [[ + [109.3395, -0.0270], + [109.3415, -0.0270], + [109.3415, -0.0258], + [109.3395, -0.0258], + [109.3395, -0.0270] + ]] + } + }', + 'PT. Maju Jaya' +), +( + 'HGU-2024-003', + 'HGU', + 1200.0, + '{ + "type": "Feature", + "properties": { + "nomor_sertifikat": "HGU-2024-003", + "status_kepemilikan": "HGU", + "luas_meter2": 1200.0, + "nama_pemilik": "CV. Agro Lestari" + }, + "geometry": { + "type": "Polygon", + "coordinates": [[ + [109.3310, -0.0285], + [109.3335, -0.0285], + [109.3335, -0.0268], + [109.3310, -0.0268], + [109.3310, -0.0285] + ]] + } + }', + 'CV. Agro Lestari' +); diff --git a/parsil_jalan_jalanrusak/index.html b/parsil_jalan_jalanrusak/index.html new file mode 100644 index 0000000..209b0a3 --- /dev/null +++ b/parsil_jalan_jalanrusak/index.html @@ -0,0 +1,1770 @@ + + + + + + +WebGIS β€” Manajemen Jalan & Parsil Tanah + + + + + + +
+
+
πŸ—ΊοΈ
+
+
WebGIS Manajemen Infrastruktur
+
Sistem Informasi Geografis β€” Jalan, Parsil & Laporan Kerusakan
+
+
+
+ + + +
+
+ +
+ + +
+
+
✏️
+
+ + + +
+
Lat: β€” | Lng: β€”
+
+
+ + + + + + + +
+ + + + + diff --git a/povertymap/api/audit.php b/povertymap/api/audit.php new file mode 100644 index 0000000..d3afb32 --- /dev/null +++ b/povertymap/api/audit.php @@ -0,0 +1,66 @@ + 'Method tidak didukung'], 405); +} + +$admin = requireRole('admin'); +$db = getDB(); + +// Pagination +$page = max(1, (int)($_GET['page'] ?? 1)); +$limit = min(100, max(10, (int)($_GET['limit'] ?? 50))); +$offset = ($page - 1) * $limit; + +// Filter +$filterAksi = $_GET['aksi'] ?? null; +$filterUser = $_GET['user_id'] ?? null; + +$where = []; +$params = []; + +if ($filterAksi) { + $where[] = "al.aksi LIKE ?"; + $params[] = "%$filterAksi%"; +} +if ($filterUser) { + $where[] = "al.user_id = ?"; + $params[] = (int)$filterUser; +} + +$whereClause = count($where) > 0 ? 'WHERE ' . implode(' AND ', $where) : ''; + +// Total count +$countSql = "SELECT COUNT(*) FROM audit_logs al $whereClause"; +$countStmt = $db->prepare($countSql); +$countStmt->execute($params); +$total = (int)$countStmt->fetchColumn(); + +// Data +$sql = " + SELECT al.*, u.username, u.nama_lengkap + FROM audit_logs al + LEFT JOIN users u ON u.id = al.user_id + $whereClause + ORDER BY al.created_at DESC + LIMIT $limit OFFSET $offset +"; +$stmt = $db->prepare($sql); +$stmt->execute($params); +$rows = $stmt->fetchAll(); + +jsonResponse([ + 'data' => $rows, + 'page' => $page, + 'limit' => $limit, + 'total' => $total, + 'pages' => ceil($total / $limit), +]); diff --git a/povertymap/api/auth.php b/povertymap/api/auth.php new file mode 100644 index 0000000..9ecd1f1 --- /dev/null +++ b/povertymap/api/auth.php @@ -0,0 +1,183 @@ + false, 'error' => 'Username dan password wajib diisi'], 422); + } + + $stmt = $db->prepare("SELECT * FROM users WHERE username = ? AND is_active = 1"); + $stmt->execute([$username]); + $user = $stmt->fetch(); + + if (!$user || !password_verify($password, $user['password'])) { + jsonResponse(['success' => false, 'error' => 'Username atau password salah'], 401); + } + + // Set session + $_SESSION['user'] = [ + 'id' => (int)$user['id'], + 'username' => $user['username'], + 'nama_lengkap' => $user['nama_lengkap'], + 'role' => $user['role'], + ]; + + auditLog($db, (int)$user['id'], 'LOGIN', 'users', (int)$user['id']); + + jsonResponse([ + 'success' => true, + 'user' => $_SESSION['user'] + ]); +} + +// ══════════════════════════════════════════════ +// LOGOUT +// ══════════════════════════════════════════════ +if ($action === 'logout' && $_SERVER['REQUEST_METHOD'] === 'POST') { + $user = getCurrentUser(); + if ($user) { + auditLog($db, $user['id'], 'LOGOUT', 'users', $user['id']); + } + session_destroy(); + jsonResponse(['success' => true]); +} + +// ══════════════════════════════════════════════ +// ME β€” cek session aktif +// ══════════════════════════════════════════════ +if ($action === 'me' && $_SERVER['REQUEST_METHOD'] === 'GET') { + $user = getCurrentUser(); + if (!$user) { + jsonResponse(['success' => false, 'logged_in' => false], 200); + } + jsonResponse(['success' => true, 'logged_in' => true, 'user' => $user]); +} + +// ══════════════════════════════════════════════ +// REGISTER β€” admin only +// ══════════════════════════════════════════════ +if ($action === 'register' && $_SERVER['REQUEST_METHOD'] === 'POST') { + $admin = requireRole('admin'); + $body = json_decode(file_get_contents('php://input'), true) ?? []; + + $username = trim($body['username'] ?? ''); + $password = $body['password'] ?? ''; + $nama_lengkap = trim($body['nama_lengkap'] ?? ''); + $role = $body['role'] ?? 'surveyor'; + + if (!$username || !$password || !$nama_lengkap) { + jsonResponse(['success' => false, 'error' => 'Semua field wajib diisi'], 422); + } + if (strlen($password) < 6) { + jsonResponse(['success' => false, 'error' => 'Password minimal 6 karakter'], 422); + } + if (!in_array($role, ['admin', 'surveyor', 'pemangku'])) { + jsonResponse(['success' => false, 'error' => 'Role tidak valid'], 422); + } + + // Cek username unik + $check = $db->prepare("SELECT id FROM users WHERE username = ?"); + $check->execute([$username]); + if ($check->fetch()) { + jsonResponse(['success' => false, 'error' => 'Username sudah digunakan'], 409); + } + + $hashed = password_hash($password, PASSWORD_BCRYPT); + $stmt = $db->prepare("INSERT INTO users (username, password, nama_lengkap, role) VALUES (?, ?, ?, ?)"); + $stmt->execute([$username, $hashed, $nama_lengkap, $role]); + $newId = (int)$db->lastInsertId(); + + auditLog($db, $admin['id'], 'CREATE_USER', 'users', $newId, null, [ + 'username' => $username, 'nama_lengkap' => $nama_lengkap, 'role' => $role + ]); + + jsonResponse(['success' => true, 'id' => $newId]); +} + +// ══════════════════════════════════════════════ +// LIST USERS β€” admin only +// ══════════════════════════════════════════════ +if ($action === 'users' && $_SERVER['REQUEST_METHOD'] === 'GET') { + requireRole('admin'); + $rows = $db->query("SELECT id, username, nama_lengkap, role, is_active, created_at FROM users ORDER BY created_at DESC")->fetchAll(); + jsonResponse(['data' => $rows]); +} + +// ══════════════════════════════════════════════ +// UPDATE USER β€” admin only +// ══════════════════════════════════════════════ +if ($action === 'update' && $_SERVER['REQUEST_METHOD'] === 'PUT') { + $admin = requireRole('admin'); + $id = isset($_GET['id']) ? (int)$_GET['id'] : null; + if (!$id) jsonResponse(['success' => false, 'error' => 'ID diperlukan'], 400); + + $body = json_decode(file_get_contents('php://input'), true) ?? []; + + // Ambil data lama + $old = $db->prepare("SELECT id, username, nama_lengkap, role, is_active FROM users WHERE id = ?"); + $old->execute([$id]); + $oldData = $old->fetch(); + if (!$oldData) jsonResponse(['success' => false, 'error' => 'User tidak ditemukan'], 404); + + $fields = []; $vals = []; + + if (!empty($body['nama_lengkap'])) { + $fields[] = "nama_lengkap = ?"; $vals[] = trim($body['nama_lengkap']); + } + if (!empty($body['role']) && in_array($body['role'], ['admin','surveyor','pemangku'])) { + $fields[] = "role = ?"; $vals[] = $body['role']; + } + if (isset($body['is_active'])) { + $fields[] = "is_active = ?"; $vals[] = $body['is_active'] ? 1 : 0; + } + if (!empty($body['password']) && strlen($body['password']) >= 6) { + $fields[] = "password = ?"; $vals[] = password_hash($body['password'], PASSWORD_BCRYPT); + } + + if (empty($fields)) jsonResponse(['success' => false, 'error' => 'Tidak ada data diubah'], 422); + + $vals[] = $id; + $db->prepare("UPDATE users SET " . implode(',', $fields) . " WHERE id = ?")->execute($vals); + + auditLog($db, $admin['id'], 'UPDATE_USER', 'users', $id, $oldData, $body); + + jsonResponse(['success' => true]); +} + +// ══════════════════════════════════════════════ +// DELETE (deactivate) USER β€” admin only +// ══════════════════════════════════════════════ +if ($action === 'delete' && $_SERVER['REQUEST_METHOD'] === 'DELETE') { + $admin = requireRole('admin'); + $id = isset($_GET['id']) ? (int)$_GET['id'] : null; + if (!$id) jsonResponse(['success' => false, 'error' => 'ID diperlukan'], 400); + + if ($id === $admin['id']) { + jsonResponse(['success' => false, 'error' => 'Tidak bisa menghapus akun sendiri'], 400); + } + + $db->prepare("UPDATE users SET is_active = 0 WHERE id = ?")->execute([$id]); + + auditLog($db, $admin['id'], 'DEACTIVATE_USER', 'users', $id); + + jsonResponse(['success' => true]); +} + +jsonResponse(['error' => 'Action tidak valid'], 400); diff --git a/povertymap/api/config.php b/povertymap/api/config.php new file mode 100644 index 0000000..6b554bf --- /dev/null +++ b/povertymap/api/config.php @@ -0,0 +1,106 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]; + try { + $pdo = new PDO($dsn, DB_USER, DB_PASS, $options); + } catch (PDOException $e) { + http_response_code(500); + die(json_encode(['success' => false, 'error' => 'Koneksi DB gagal: ' . $e->getMessage()])); + } + } + return $pdo; +} + +// ── JSON response helper ────────────────────── +function jsonResponse(array $data, int $code = 200): void { + http_response_code($code); + header('Content-Type: application/json; charset=utf-8'); + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + if ($origin) { + header('Access-Control-Allow-Origin: ' . $origin); + } else { + header('Access-Control-Allow-Origin: *'); + } + header('Access-Control-Allow-Credentials: true'); + echo json_encode($data, JSON_UNESCAPED_UNICODE); + exit; +} + +// ── Auth Helpers ────────────────────────────── + +/** + * Cek apakah user sudah login. + * Return data user atau null. + */ +function getCurrentUser(): ?array { + return $_SESSION['user'] ?? null; +} + +/** + * Paksa login. Jika belum login, return 401. + */ +function requireAuth(): array { + $user = getCurrentUser(); + if (!$user) { + jsonResponse(['success' => false, 'error' => 'Unauthorized β€” silakan login'], 401); + } + return $user; +} + +/** + * Paksa role tertentu. Jika role tidak sesuai, return 403. + * @param array|string $roles Role yang diizinkan + */ +function requireRole($roles): array { + $user = requireAuth(); + if (is_string($roles)) $roles = [$roles]; + if (!in_array($user['role'], $roles)) { + jsonResponse(['success' => false, 'error' => 'Forbidden β€” Anda tidak memiliki akses'], 403); + } + return $user; +} + +// ── Audit Log Helper ────────────────────────── + +/** + * Catat aktivitas ke audit_logs + */ +function auditLog(PDO $db, int $userId, string $aksi, ?string $targetType = null, ?int $targetId = null, $before = null, $after = null): void { + $stmt = $db->prepare(" + INSERT INTO audit_logs (user_id, aksi, target_type, target_id, data_before, data_after, ip_address) + VALUES (?, ?, ?, ?, ?, ?, ?) + "); + $stmt->execute([ + $userId, + $aksi, + $targetType, + $targetId, + $before ? json_encode($before, JSON_UNESCAPED_UNICODE) : null, + $after ? json_encode($after, JSON_UNESCAPED_UNICODE) : null, + $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1' + ]); +} \ No newline at end of file diff --git a/povertymap/api/helpers.php b/povertymap/api/helpers.php new file mode 100644 index 0000000..57b94c2 --- /dev/null +++ b/povertymap/api/helpers.php @@ -0,0 +1,37 @@ +query("SELECT id, lat, lng, radius FROM rumah_ibadah WHERE status = 'verified'")->fetchAll(); + $nearest = null; + $minDist = PHP_FLOAT_MAX; + foreach ($ibadahs as $ib) { + $d = haversine($lat, $lng, (float)$ib['lat'], (float)$ib['lng']); + if ($d <= $ib['radius'] && $d < $minDist) { + $nearest = (int)$ib['id']; + $minDist = $d; + } + } + return $nearest; +} + +// ── Re-assign SEMUA warga verified (dipanggil saat radius/ibadah berubah) ── +function reassignAllWarga(PDO $db): void { + $wargas = $db->query("SELECT id, lat, lng FROM warga_miskin WHERE status = 'verified'")->fetchAll(); + $stmt = $db->prepare("UPDATE warga_miskin SET ibadah_id = ? WHERE id = ?"); + foreach ($wargas as $w) { + $ibadahId = findNearestIbadah($db, (float)$w['lat'], (float)$w['lng']); + $stmt->execute([$ibadahId, $w['id']]); + } +} diff --git a/povertymap/api/ibadah.php b/povertymap/api/ibadah.php new file mode 100644 index 0000000..405b249 --- /dev/null +++ b/povertymap/api/ibadah.php @@ -0,0 +1,181 @@ +prepare("SELECT * FROM rumah_ibadah WHERE id = ?"); + $stmt->execute([$id]); + $row = $stmt->fetch(); + jsonResponse($row ?: ['error' => 'Tidak ditemukan'], $row ? 200 : 404); + } + + // Admin, Surveyor, & Pemangku melihat semua, lainnya hanya verified + if ($user && ($user['role'] === 'admin' || $user['role'] === 'surveyor' || $user['role'] === 'pemangku')) { + $rows = $db->query(" + SELECT ri.*, + COUNT(wm.id) AS jumlah_kk, + COALESCE(SUM(wm.jumlah_anggota),0) AS jumlah_jiwa, + u.nama_lengkap AS surveyor_nama + FROM rumah_ibadah ri + LEFT JOIN warga_miskin wm ON wm.ibadah_id = ri.id AND wm.status = 'verified' + LEFT JOIN users u ON u.id = ri.surveyor_id + GROUP BY ri.id + ORDER BY ri.created_at DESC + ")->fetchAll(); + } else { + // Pemangku / publik: hanya verified + $rows = $db->query(" + SELECT ri.*, + COUNT(wm.id) AS jumlah_kk, + COALESCE(SUM(wm.jumlah_anggota),0) AS jumlah_jiwa + FROM rumah_ibadah ri + LEFT JOIN warga_miskin wm ON wm.ibadah_id = ri.id AND wm.status = 'verified' + WHERE ri.status = 'verified' + GROUP BY ri.id + ORDER BY ri.created_at DESC + ")->fetchAll(); + } + + jsonResponse(['data' => $rows]); +} + +// ── POST: tambah ───────────────────────────── +if ($method === 'POST') { + $user = requireRole(['admin', 'surveyor']); + $body = json_decode(file_get_contents('php://input'), true) ?? []; + + $required = ['nama','jenis','pic','lat','lng']; + foreach ($required as $f) { + if (empty($body[$f])) jsonResponse(['success'=>false,'error'=>"Field '$f' wajib diisi"], 422); + } + + // Surveyor β†’ pending, Admin β†’ verified + $status = ($user['role'] === 'admin') ? 'verified' : 'pending'; + + $stmt = $db->prepare(" + INSERT INTO rumah_ibadah (nama, jenis, alamat, pic, no_wa, radius, lat, lng, foto_path, surveyor_id, status, kecamatan) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "); + $stmt->execute([ + $body['nama'], + $body['jenis'], + $body['alamat'] ?? '', + $body['pic'], + $body['no_wa'] ?? '', + (int)($body['radius'] ?? 500), + (float)$body['lat'], + (float)$body['lng'], + $body['foto_path'] ?? null, + $user['id'], + $status, + $body['kecamatan'] ?? null, + ]); + $newId = (int)$db->lastInsertId(); + + // re-assign warga hanya jika langsung verified + if ($status === 'verified') { + reassignAllWarga($db); + } + + auditLog($db, $user['id'], 'CREATE_IBADAH', 'rumah_ibadah', $newId, null, $body); + + jsonResponse(['success'=>true, 'id'=>$newId, 'status'=>$status]); +} + +// ── PUT: update ────────────────────────────── +if ($method === 'PUT') { + $user = requireAuth(); + if (!$id) jsonResponse(['success'=>false,'error'=>'ID diperlukan'], 400); + + // Cek permission + $old = $db->prepare("SELECT * FROM rumah_ibadah WHERE id = ?"); + $old->execute([$id]); + $oldData = $old->fetch(); + if (!$oldData) jsonResponse(['success'=>false,'error'=>'Tidak ditemukan'], 404); + + // Surveyor hanya bisa edit milik sendiri yang masih pending + if ($user['role'] === 'surveyor') { + if ((int)$oldData['surveyor_id'] !== $user['id']) { + jsonResponse(['success'=>false,'error'=>'Anda hanya bisa edit data milik Anda'], 403); + } + if ($oldData['status'] !== 'pending') { + jsonResponse(['success'=>false,'error'=>'Data yang sudah diverifikasi tidak bisa diedit'], 403); + } + } + // Pemangku tidak boleh edit + if ($user['role'] === 'pemangku') { + jsonResponse(['success'=>false,'error'=>'Anda tidak memiliki akses edit'], 403); + } + + $body = json_decode(file_get_contents('php://input'), true) ?? []; + + $fields = []; $vals = []; + foreach (['nama','jenis','alamat','pic','no_wa','radius','lat','lng','foto_path','kecamatan'] as $f) { + if (array_key_exists($f, $body)) { + $fields[] = "$f = ?"; + $vals[] = in_array($f,['lat','lng']) ? (float)$body[$f] : ($f==='radius' ? (int)$body[$f] : $body[$f]); + } + } + if (empty($fields)) jsonResponse(['success'=>false,'error'=>'Tidak ada data diubah'], 422); + $vals[] = $id; + $db->prepare("UPDATE rumah_ibadah SET ".implode(',',$fields)." WHERE id = ?")->execute($vals); + + // re-assign ulang jika data verified + if ($oldData['status'] === 'verified') { + reassignAllWarga($db); + } + + auditLog($db, $user['id'], 'UPDATE_IBADAH', 'rumah_ibadah', $id, $oldData, $body); + + jsonResponse(['success'=>true]); +} + +// ── DELETE ─────────────────────────────────── +if ($method === 'DELETE') { + $user = requireAuth(); + if (!$id) jsonResponse(['success'=>false,'error'=>'ID diperlukan'], 400); + + $old = $db->prepare("SELECT * FROM rumah_ibadah WHERE id = ?"); + $old->execute([$id]); + $oldData = $old->fetch(); + if (!$oldData) jsonResponse(['success'=>false,'error'=>'Tidak ditemukan'], 404); + + // Surveyor bisa hapus milik sendiri + if ($user['role'] === 'surveyor') { + if ((int)$oldData['surveyor_id'] !== $user['id']) { + jsonResponse(['success'=>false,'error'=>'Anda hanya bisa hapus data milik Anda'], 403); + } + } + if ($user['role'] === 'pemangku') { + jsonResponse(['success'=>false,'error'=>'Anda tidak memiliki akses hapus'], 403); + } + + // warga_miskin.ibadah_id akan SET NULL (FK ON DELETE SET NULL) + $db->prepare("DELETE FROM rumah_ibadah WHERE id = ?")->execute([$id]); + + // Jika rumah ibadah terverifikasi dihapus, reassign warga sekitar + if ($oldData['status'] === 'verified') { + reassignAllWarga($db); + } + + auditLog($db, $user['id'], 'DELETE_IBADAH', 'rumah_ibadah', $id, $oldData); + + jsonResponse(['success'=>true]); +} + +jsonResponse(['error'=>'Method tidak didukung'], 405); diff --git a/povertymap/api/stats.php b/povertymap/api/stats.php new file mode 100644 index 0000000..496ee0b --- /dev/null +++ b/povertymap/api/stats.php @@ -0,0 +1,74 @@ +query(" + SELECT + (SELECT COUNT(*) FROM rumah_ibadah WHERE status = 'verified') AS ibadah, + COUNT(*) AS kk_total, + COALESCE(SUM(CASE WHEN ibadah_id IS NOT NULL THEN 1 ELSE 0 END), 0) AS kk_terlayani, + COALESCE(SUM(jumlah_anggota), 0) AS jiwa_total, + COALESCE(SUM(CASE WHEN ibadah_id IS NOT NULL THEN jumlah_anggota ELSE 0 END), 0) AS jiwa_terlayani, + COALESCE(SUM(CASE WHEN bantuan_diterima = 'sembako' THEN 1 ELSE 0 END), 0) AS bantuan_sembako, + COALESCE(SUM(CASE WHEN bantuan_diterima = 'beasiswa' THEN 1 ELSE 0 END), 0) AS bantuan_beasiswa, + COALESCE(SUM(CASE WHEN bantuan_diterima = 'modal' THEN 1 ELSE 0 END), 0) AS bantuan_modal, + COALESCE(SUM(CASE WHEN bantuan_diterima = 'tunai' THEN 1 ELSE 0 END), 0) AS bantuan_tunai, + COALESCE(SUM(CASE WHEN bantuan_diterima = 'tidak_ada' THEN 1 ELSE 0 END), 0) AS bantuan_tidak_ada + FROM warga_miskin + WHERE status = 'verified' +")->fetch(PDO::FETCH_ASSOC); + +$totalIbadah = (int)($stats['ibadah'] ?? 0); +$totalWarga = (int)($stats['kk_total'] ?? 0); +$totalCovered = (int)($stats['kk_terlayani'] ?? 0); +$totalJiwa = (int)($stats['jiwa_total'] ?? 0); +$jiwaLayan = (int)($stats['jiwa_terlayani'] ?? 0); + +$response = [ + 'ibadah' => $totalIbadah, + 'kk_total' => $totalWarga, + 'kk_terlayani' => $totalCovered, + 'kk_belum' => $totalWarga - $totalCovered, + 'jiwa_total' => $totalJiwa, + 'jiwa_terlayani' => $jiwaLayan, + 'bantuan_sembako' => (int)($stats['bantuan_sembako'] ?? 0), + 'bantuan_beasiswa' => (int)($stats['bantuan_beasiswa'] ?? 0), + 'bantuan_modal' => (int)($stats['bantuan_modal'] ?? 0), + 'bantuan_tunai' => (int)($stats['bantuan_tunai'] ?? 0), + 'bantuan_tidak_ada' => (int)($stats['bantuan_tidak_ada'] ?? 0), +]; + +// Data pending β€” hanya untuk admin (digabung dalam 1 query) +if ($user && $user['role'] === 'admin') { + $adminStats = $db->query(" + SELECT + (SELECT COUNT(*) FROM rumah_ibadah WHERE status = 'pending') AS pending_ibadah, + (SELECT COUNT(*) FROM warga_miskin WHERE status = 'pending') AS pending_warga, + (SELECT COUNT(*) FROM users WHERE is_active = 1) AS total_users + ")->fetch(PDO::FETCH_ASSOC); + $response['pending_ibadah'] = (int)($adminStats['pending_ibadah'] ?? 0); + $response['pending_warga'] = (int)($adminStats['pending_warga'] ?? 0); + $response['total_users'] = (int)($adminStats['total_users'] ?? 0); +} + +// Data pending milik surveyor (digabung dalam 1 query) +if ($user && $user['role'] === 'surveyor') { + $stmt = $db->prepare(" + SELECT + (SELECT COUNT(*) FROM warga_miskin WHERE surveyor_id = ? AND status = 'pending') AS my_pending_warga, + (SELECT COUNT(*) FROM rumah_ibadah WHERE surveyor_id = ? AND status = 'pending') AS my_pending_ibadah + "); + $stmt->execute([$user['id'], $user['id']]); + $surveyorStats = $stmt->fetch(PDO::FETCH_ASSOC); + $response['my_pending_warga'] = (int)($surveyorStats['my_pending_warga'] ?? 0); + $response['my_pending_ibadah'] = (int)($surveyorStats['my_pending_ibadah'] ?? 0); +} + +jsonResponse($response); \ No newline at end of file diff --git a/povertymap/api/upload.php b/povertymap/api/upload.php new file mode 100644 index 0000000..a92884b --- /dev/null +++ b/povertymap/api/upload.php @@ -0,0 +1,118 @@ + 'Method tidak didukung'], 405); +} + +$user = requireRole(['admin', 'surveyor']); + +// ── Validasi file ──────────────────────────── +if (!isset($_FILES['foto']) || $_FILES['foto']['error'] !== UPLOAD_ERR_OK) { + jsonResponse(['success' => false, 'error' => 'File foto wajib diunggah'], 422); +} + +$file = $_FILES['foto']; + +// Cek ukuran +if ($file['size'] > MAX_UPLOAD_SIZE) { + jsonResponse(['success' => false, 'error' => 'Ukuran file melebihi batas 5MB'], 422); +} + +// Cek tipe file +$allowedTypes = ['image/jpeg', 'image/jpg', 'image/png']; +$finfo = finfo_open(FILEINFO_MIME_TYPE); +$mimeType = finfo_file($finfo, $file['tmp_name']); +finfo_close($finfo); + +if (!in_array($mimeType, $allowedTypes)) { + jsonResponse(['success' => false, 'error' => 'Hanya file JPEG dan PNG yang diizinkan'], 422); +} + +// ── Buat folder uploads jika belum ada ─────── +if (!is_dir(UPLOAD_DIR)) { + mkdir(UPLOAD_DIR, 0755, true); +} + +// ── Generate nama file unik ────────────────── +$ext = ($mimeType === 'image/png') ? 'png' : 'jpg'; +$filename = date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $ext; +$destPath = UPLOAD_DIR . $filename; +$webPath = 'uploads/' . $filename; + +// ── Pindahkan file ─────────────────────────── +if (!move_uploaded_file($file['tmp_name'], $destPath)) { + jsonResponse(['success' => false, 'error' => 'Gagal menyimpan file'], 500); +} + +// ── Extract EXIF GPS ───────────────────────── +$lat = null; +$lng = null; +$hasGps = false; + +if ($mimeType === 'image/jpeg' || $mimeType === 'image/jpg') { + $exif = @exif_read_data($destPath, 'GPS', true); + if ($exif && isset($exif['GPS']['GPSLatitude']) && isset($exif['GPS']['GPSLongitude'])) { + $lat = exifGpsToDecimal( + $exif['GPS']['GPSLatitude'], + $exif['GPS']['GPSLatitudeRef'] ?? 'N' + ); + $lng = exifGpsToDecimal( + $exif['GPS']['GPSLongitude'], + $exif['GPS']['GPSLongitudeRef'] ?? 'E' + ); + $hasGps = true; + } +} + +auditLog(getDB(), $user['id'], 'UPLOAD_FOTO', null, null, null, [ + 'filename' => $filename, 'has_gps' => $hasGps +]); + +jsonResponse([ + 'success' => true, + 'foto_path' => $webPath, + 'lat' => $lat, + 'lng' => $lng, + 'has_gps' => $hasGps, +]); + +// ══════════════════════════════════════════════ +// EXIF GPS Helper +// ══════════════════════════════════════════════ + +/** + * Konversi EXIF GPS coordinate (DMS) ke desimal + * @param array $coord Array of 3 fractions [degrees, minutes, seconds] + * @param string $ref Hemisphere reference (N/S/E/W) + */ +function exifGpsToDecimal(array $coord, string $ref): float { + $degrees = exifFractionToFloat($coord[0]); + $minutes = exifFractionToFloat($coord[1]); + $seconds = exifFractionToFloat($coord[2]); + + $decimal = $degrees + ($minutes / 60) + ($seconds / 3600); + + if ($ref === 'S' || $ref === 'W') { + $decimal *= -1; + } + + return round($decimal, 8); +} + +/** + * Konversi fraction string "num/den" ke float + */ +function exifFractionToFloat(string $fraction): float { + $parts = explode('/', $fraction); + if (count($parts) === 2 && (float)$parts[1] !== 0.0) { + return (float)$parts[0] / (float)$parts[1]; + } + return (float)$parts[0]; +} diff --git a/povertymap/api/verify.php b/povertymap/api/verify.php new file mode 100644 index 0000000..1d054ad --- /dev/null +++ b/povertymap/api/verify.php @@ -0,0 +1,99 @@ + 'Parameter type harus "ibadah" atau "warga"'], 400); +} + +$table = $type === 'ibadah' ? 'rumah_ibadah' : 'warga_miskin'; + +// ── GET: list data pending ─────────────────── +if ($method === 'GET') { + $admin = requireRole('admin'); + + $status = $_GET['status'] ?? 'pending'; + if (!in_array($status, ['pending', 'verified', 'rejected'])) $status = 'pending'; + + if ($type === 'ibadah') { + $stmt = $db->prepare(" + SELECT ri.*, u.nama_lengkap AS surveyor_nama + FROM rumah_ibadah ri + LEFT JOIN users u ON u.id = ri.surveyor_id + WHERE ri.status = ? + ORDER BY ri.created_at DESC + "); + } else { + $stmt = $db->prepare(" + SELECT wm.*, + ri.nama AS ibadah_nama, + u.nama_lengkap AS surveyor_nama + FROM warga_miskin wm + LEFT JOIN rumah_ibadah ri ON ri.id = wm.ibadah_id + LEFT JOIN users u ON u.id = wm.surveyor_id + WHERE wm.status = ? + ORDER BY wm.created_at DESC + "); + } + $stmt->execute([$status]); + jsonResponse(['data' => $stmt->fetchAll()]); +} + +// ── PUT: approve / reject ──────────────────── +if ($method === 'PUT') { + $admin = requireRole('admin'); + if (!$id) jsonResponse(['success' => false, 'error' => 'ID diperlukan'], 400); + + $body = json_decode(file_get_contents('php://input'), true) ?? []; + $newStatus = $body['status'] ?? ''; + if (!in_array($newStatus, ['verified', 'rejected'])) { + jsonResponse(['success' => false, 'error' => 'Status harus "verified" atau "rejected"'], 422); + } + + // Ambil data lama + $old = $db->prepare("SELECT * FROM $table WHERE id = ?"); + $old->execute([$id]); + $oldData = $old->fetch(); + if (!$oldData) { + jsonResponse(['success' => false, 'error' => 'Data tidak ditemukan'], 404); + } + + // Update status + $alasan = ($newStatus === 'rejected') ? ($body['alasan_penolakan'] ?? '') : null; + $stmt = $db->prepare("UPDATE $table SET status = ?, alasan_penolakan = ? WHERE id = ?"); + $stmt->execute([$newStatus, $alasan, $id]); + + // Jika diverifikasi, proses spasial + if ($newStatus === 'verified') { + if ($type === 'warga') { + // Cari ibadah terdekat untuk warga yang baru verified + $ibadahId = findNearestIbadah($db, (float)$oldData['lat'], (float)$oldData['lng']); + $db->prepare("UPDATE warga_miskin SET ibadah_id = ? WHERE id = ?")->execute([$ibadahId, $id]); + } elseif ($type === 'ibadah') { + // Ibadah baru verified β†’ re-assign semua warga + reassignAllWarga($db); + } + } + + $aksi = ($newStatus === 'verified') ? 'VERIFY_' : 'REJECT_'; + $aksi .= strtoupper($type); + auditLog($db, $admin['id'], $aksi, $table, $id, $oldData, [ + 'status' => $newStatus, 'alasan_penolakan' => $alasan + ]); + + jsonResponse(['success' => true, 'status' => $newStatus]); +} + +jsonResponse(['error' => 'Method tidak didukung'], 405); diff --git a/povertymap/api/warga.php b/povertymap/api/warga.php new file mode 100644 index 0000000..6b7b067 --- /dev/null +++ b/povertymap/api/warga.php @@ -0,0 +1,233 @@ + 0 ? 'WHERE ' . implode(' AND ', $where) . ' ' . $statusFilter : ($statusFilter ? 'WHERE 1=1 ' . $statusFilter : ''); + + $sql = " + SELECT wm.*, + ri.nama AS ibadah_nama, + ri.jenis AS ibadah_jenis, + u.nama_lengkap AS surveyor_nama + FROM warga_miskin wm + LEFT JOIN rumah_ibadah ri ON ri.id = wm.ibadah_id + LEFT JOIN users u ON u.id = wm.surveyor_id + $whereClause + ORDER BY wm.created_at DESC + "; + $stmt = $db->prepare($sql); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + + jsonResponse(['data' => $rows]); +} + +// ── POST: tambah warga ─────────────────────── +if ($method === 'POST') { + $user = requireRole(['admin', 'surveyor']); + $body = json_decode(file_get_contents('php://input'), true) ?? []; + + foreach (['nama_kk','lat','lng'] as $f) { + if (empty($body[$f])) jsonResponse(['success'=>false,'error'=>"Field '$f' wajib diisi"], 422); + } + + // Validasi NIK: harus 16 digit angka jika diisi + $nik = $body['nik'] ?? null; + if ($nik !== null && $nik !== '') { + $nik = trim($nik); + if (!preg_match('/^\d{16}$/', $nik)) { + jsonResponse(['success'=>false,'error'=>'NIK harus tepat 16 digit angka'], 422); + } + // Cek duplikasi NIK + $chk = $db->prepare("SELECT id FROM warga_miskin WHERE nik = ?"); + $chk->execute([$nik]); + if ($chk->fetch()) { + jsonResponse(['success'=>false,'error'=>'NIK Kepala Keluarga sudah terdaftar di sistem'], 422); + } + } else { + $nik = null; + } + + $lat = (float)$body['lat']; + $lng = (float)$body['lng']; + + // Surveyor β†’ pending, Admin β†’ verified + $status = ($user['role'] === 'admin') ? 'verified' : 'pending'; + + // otomatis cari rumah ibadah terdekat (hanya jika verified) + $ibadahId = ($status === 'verified') ? findNearestIbadah($db, $lat, $lng) : null; + + $bantuan = $body['bantuan_diterima'] ?? 'tidak_ada'; + if (!in_array($bantuan, ['tidak_ada', 'sembako', 'beasiswa', 'modal', 'tunai'])) { + $bantuan = 'tidak_ada'; + } + + $stmt = $db->prepare(" + INSERT INTO warga_miskin (nama_kk, jumlah_anggota, keterangan, lat, lng, ibadah_id, foto_path, surveyor_id, status, alamat, nik, kecamatan, bantuan_diterima) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "); + $stmt->execute([ + $body['nama_kk'], + (int)($body['jumlah_anggota'] ?? 1), + $body['keterangan'] ?? '', + $lat, $lng, $ibadahId, + $body['foto_path'] ?? null, + $user['id'], + $status, + $body['alamat'] ?? null, + $nik, + $body['kecamatan'] ?? null, + $bantuan, + ]); + + $newId = (int)$db->lastInsertId(); + + // ambil nama ibadah untuk respon + $ibadahNama = null; + if ($ibadahId) { + $r = $db->prepare("SELECT nama FROM rumah_ibadah WHERE id = ?"); + $r->execute([$ibadahId]); + $ibadahNama = $r->fetchColumn(); + } + + auditLog($db, $user['id'], 'CREATE_WARGA', 'warga_miskin', $newId, null, $body); + + jsonResponse([ + 'success' => true, + 'id' => $newId, + 'status' => $status, + 'ibadah_id' => $ibadahId, + 'ibadah_nama' => $ibadahNama, + ]); +} + +// ── PUT: update warga ──────────────────────── +if ($method === 'PUT') { + $user = requireAuth(); + if (!$id) jsonResponse(['success'=>false,'error'=>'ID diperlukan'], 400); + + $old = $db->prepare("SELECT * FROM warga_miskin WHERE id = ?"); + $old->execute([$id]); + $oldData = $old->fetch(); + if (!$oldData) jsonResponse(['success'=>false,'error'=>'Tidak ditemukan'], 404); + + // Surveyor hanya bisa edit milik sendiri yang masih pending + if ($user['role'] === 'surveyor') { + if ((int)$oldData['surveyor_id'] !== $user['id']) { + jsonResponse(['success'=>false,'error'=>'Anda hanya bisa edit data milik Anda'], 403); + } + if ($oldData['status'] !== 'pending') { + jsonResponse(['success'=>false,'error'=>'Data yang sudah diverifikasi tidak bisa diedit'], 403); + } + } + if ($user['role'] === 'pemangku') { + jsonResponse(['success'=>false,'error'=>'Anda tidak memiliki akses edit'], 403); + } + + $body = json_decode(file_get_contents('php://input'), true) ?? []; + + // Validasi NIK jika diubah + if (array_key_exists('nik', $body) && $body['nik'] !== null && $body['nik'] !== '') { + $nikVal = trim($body['nik']); + if (!preg_match('/^\d{16}$/', $nikVal)) { + jsonResponse(['success'=>false,'error'=>'NIK harus tepat 16 digit angka'], 422); + } + // Cek duplikasi NIK (kecuali data sendiri) + $chk = $db->prepare("SELECT id FROM warga_miskin WHERE nik = ? AND id != ?"); + $chk->execute([$nikVal, $id]); + if ($chk->fetch()) { + jsonResponse(['success'=>false,'error'=>'NIK Kepala Keluarga sudah terdaftar di sistem'], 422); + } + } + + $fields = []; $vals = []; + foreach (['nama_kk','jumlah_anggota','keterangan','lat','lng','foto_path','alamat','nik','kecamatan','bantuan_diterima'] as $f) { + if (array_key_exists($f, $body)) { + $fields[] = "$f = ?"; + if ($f === 'nik') { + $nikVal = $body['nik'] !== null ? trim($body['nik']) : ''; + $vals[] = ($nikVal === '') ? null : $nikVal; + } else { + $vals[] = in_array($f,['lat','lng']) ? (float)$body[$f] + : ($f==='jumlah_anggota' ? (int)$body[$f] : $body[$f]); + } + } + } + if (empty($fields)) jsonResponse(['success'=>false,'error'=>'Tidak ada data diubah'], 422); + + // jika koordinat berubah dan data verified, cari ulang ibadah terdekat + if ($oldData['status'] === 'verified' && (array_key_exists('lat', $body) || array_key_exists('lng', $body))) { + $lat = array_key_exists('lat',$body) ? (float)$body['lat'] : (float)$oldData['lat']; + $lng = array_key_exists('lng',$body) ? (float)$body['lng'] : (float)$oldData['lng']; + $ibadahId = findNearestIbadah($db, $lat, $lng); + $fields[] = "ibadah_id = ?"; + $vals[] = $ibadahId; + } + + $vals[] = $id; + $db->prepare("UPDATE warga_miskin SET ".implode(',',$fields)." WHERE id = ?")->execute($vals); + + auditLog($db, $user['id'], 'UPDATE_WARGA', 'warga_miskin', $id, $oldData, $body); + + jsonResponse(['success'=>true]); +} + +// ── DELETE ─────────────────────────────────── +if ($method === 'DELETE') { + $user = requireAuth(); + if (!$id) jsonResponse(['success'=>false,'error'=>'ID diperlukan'], 400); + + $old = $db->prepare("SELECT * FROM warga_miskin WHERE id = ?"); + $old->execute([$id]); + $oldData = $old->fetch(); + if (!$oldData) jsonResponse(['success'=>false,'error'=>'Tidak ditemukan'], 404); + + // Surveyor bisa hapus milik sendiri + if ($user['role'] === 'surveyor') { + if ((int)$oldData['surveyor_id'] !== $user['id']) { + jsonResponse(['success'=>false,'error'=>'Anda hanya bisa hapus data milik Anda'], 403); + } + } + if ($user['role'] === 'pemangku') { + jsonResponse(['success'=>false,'error'=>'Anda tidak memiliki akses hapus'], 403); + } + + $db->prepare("DELETE FROM warga_miskin WHERE id = ?")->execute([$id]); + + auditLog($db, $user['id'], 'DELETE_WARGA', 'warga_miskin', $id, $oldData); + + jsonResponse(['success'=>true]); +} + +jsonResponse(['error'=>'Method tidak didukung'], 405); diff --git a/povertymap/database.sql b/povertymap/database.sql new file mode 100644 index 0000000..1fc8235 --- /dev/null +++ b/povertymap/database.sql @@ -0,0 +1,85 @@ +CREATE DATABASE IF NOT EXISTS webgis_poverty_mapping + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE webgis_poverty_mapping; + +CREATE TABLE users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + nama_lengkap VARCHAR(150) NOT NULL, + role ENUM('admin','surveyor','pemangku') NOT NULL DEFAULT 'surveyor', + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Admin default β€” password: admin123 +-- Hash di-generate dengan password_hash('admin123', PASSWORD_BCRYPT) +INSERT INTO users (username, password, nama_lengkap, role) VALUES + ('admin', '$2y$10$Xb5FU6uo67rVETXAlSYJ..wn8IudyYwGzUbyMe8yQ1TLFlvt.S9F6', 'Administrator', 'admin'); + +CREATE TABLE rumah_ibadah ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama VARCHAR(200) NOT NULL, + jenis ENUM('masjid','gereja','pura','klenteng','vihara','lain') NOT NULL DEFAULT 'masjid', + alamat TEXT, + pic VARCHAR(150) NOT NULL, + no_wa VARCHAR(30) DEFAULT NULL, + radius INT NOT NULL DEFAULT 500, -- meter + lat DOUBLE NOT NULL, + lng DOUBLE NOT NULL, + foto_path VARCHAR(255) DEFAULT NULL, + surveyor_id INT DEFAULT NULL, + status ENUM('pending','verified','rejected') NOT NULL DEFAULT 'verified', + alasan_penolakan TEXT DEFAULT NULL, + kecamatan VARCHAR(100) DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_ri_surveyor FOREIGN KEY (surveyor_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE warga_miskin ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama_kk VARCHAR(200) NOT NULL, + nik VARCHAR(16) DEFAULT NULL, + jumlah_anggota INT NOT NULL DEFAULT 1, + keterangan TEXT, + alamat TEXT DEFAULT NULL, + kecamatan VARCHAR(100) DEFAULT NULL, + lat DOUBLE NOT NULL, + lng DOUBLE NOT NULL, + ibadah_id INT DEFAULT NULL, -- NULL = belum terjangkau + foto_path VARCHAR(255) DEFAULT NULL, + surveyor_id INT DEFAULT NULL, + status ENUM('pending','verified','rejected') NOT NULL DEFAULT 'verified', + alasan_penolakan TEXT DEFAULT NULL, + bantuan_diterima ENUM('sembako','beasiswa','modal','tunai','tidak_ada') NOT NULL DEFAULT 'tidak_ada', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uq_warga_nik UNIQUE (nik), + CONSTRAINT fk_wm_ibadah FOREIGN KEY (ibadah_id) REFERENCES rumah_ibadah(id) ON DELETE SET NULL, + CONSTRAINT fk_wm_surveyor FOREIGN KEY (surveyor_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE audit_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + aksi VARCHAR(100) NOT NULL, + target_type VARCHAR(50) DEFAULT NULL, + target_id INT DEFAULT NULL, + data_before JSON DEFAULT NULL, + data_after JSON DEFAULT NULL, + ip_address VARCHAR(45) DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_audit_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_ri_status ON rumah_ibadah(status); +CREATE INDEX idx_ri_kecamatan ON rumah_ibadah(kecamatan); +CREATE INDEX idx_wm_status ON warga_miskin(status); +CREATE INDEX idx_wm_kecamatan ON warga_miskin(kecamatan); +CREATE INDEX idx_wm_ibadah ON warga_miskin(ibadah_id); +CREATE INDEX idx_audit_user ON audit_logs(user_id); +CREATE INDEX idx_audit_aksi ON audit_logs(aksi); +CREATE INDEX idx_audit_created ON audit_logs(created_at); \ No newline at end of file diff --git a/povertymap/index.html b/povertymap/index.html new file mode 100644 index 0000000..6391da0 --- /dev/null +++ b/povertymap/index.html @@ -0,0 +1,3626 @@ + + + + + + + + WebGIS Poverty Mapping Dashboard + + + + + + + + + +
+ +
+ + + + + +
+
+
+

Edit Data

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

Detail Data

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

Detail Data

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

Memuat data...

+
+ + + + + + + + + + \ No newline at end of file diff --git a/povertymap/style.css b/povertymap/style.css new file mode 100644 index 0000000..d0d400b --- /dev/null +++ b/povertymap/style.css @@ -0,0 +1,3306 @@ +:root { + --bg: #f5f7fa; + --white: #ffffff; + --sidebar-w: 240px; + --blue: #3b82f6; + --blue-dark: #2563eb; + --blue-light: #eff6ff; + --green: #10b981; + --green-light: #ecfdf5; + --red: #ef4444; + --red-light: #fef2f2; + --amber: #f59e0b; + --amber-light: #fffbeb; + --purple: #8b5cf6; + --purple-light: #f5f3ff; + --text: #111827; + --text2: #6b7280; + --text3: #9ca3af; + --border: #e5e7eb; + --border2: #f3f4f6; + --shadow: 0 1px 3px rgba(0, 0, 0, .08), 0 1px 2px rgba(0, 0, 0, .06); + --shadow-md: 0 4px 16px rgba(0, 0, 0, .08); + --shadow-lg: 0 10px 40px rgba(0, 0, 0, .12); + --radius: 12px; + --radius-sm: 8px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Plus Jakarta Sans', sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + overflow: hidden; +} + +a { + text-decoration: none; + color: inherit; +} + +/* ═══ LOGIN ═══ */ +#login-screen { + position: fixed; + inset: 0; + z-index: 10000; + background: #f0f2f5; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + overflow: hidden; +} + +/* Dekoratif background shapes */ +#login-screen::before { + content: ''; + position: absolute; + top: -120px; + right: -120px; + width: 420px; + height: 420px; + background: radial-gradient(circle, rgba(59, 130, 246, .12) 0%, transparent 70%); + border-radius: 50%; + pointer-events: none; +} + +#login-screen::after { + content: ''; + position: absolute; + bottom: -80px; + left: -80px; + width: 320px; + height: 320px; + background: radial-gradient(circle, rgba(16, 185, 129, .10) 0%, transparent 70%); + border-radius: 50%; + pointer-events: none; +} + +#login-screen.hidden { + display: none; +} + +.login-wrapper { + width: 100%; + max-width: 440px; + position: relative; + z-index: 1; +} + +.login-brand { + text-align: center; + margin-bottom: 24px; +} + +.login-brand .brand-icon { + width: 56px; + height: 56px; + background: linear-gradient(135deg, var(--blue) 0%, var(--blue-dark) 100%); + border-radius: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 26px; + margin-bottom: 12px; + box-shadow: 0 6px 20px rgba(59, 130, 246, .30); +} + +.login-brand .brand-icon i { + color: #fff !important; +} + +.login-brand h1 { + font-size: 22px; + font-weight: 800; + letter-spacing: -.5px; + color: var(--text); +} + +.login-brand p { + font-size: 12px; + color: var(--text2); + margin-top: 4px; +} + +.login-card { + background: #fff; + border-radius: 20px; + padding: 28px 32px 32px; + box-shadow: 0 4px 24px rgba(0, 0, 0, .08), 0 1px 4px rgba(0, 0, 0, .04); + border: 1px solid rgba(0, 0, 0, .06); + overflow: hidden; + position: relative; +} + +/* ── STEP INDICATOR ── */ +.step-indicator { + display: flex; + align-items: center; + justify-content: center; + gap: 0; + margin-bottom: 22px; +} + +.step-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--border); + transition: all .3s ease; +} + +.step-dot.active { + width: 22px; + border-radius: 4px; + background: var(--blue); +} + +.step-line { + width: 32px; + height: 2px; + background: var(--border); + margin: 0 4px; +} + +/* ── STEP CONTAINERS ── */ +.login-step { + position: relative; +} + +.login-step.hidden-step { + display: none; +} + +/* Slide animations */ +@keyframes slideOutLeft { + from { + transform: translateX(0); + opacity: 1; + } + + to { + transform: translateX(-48px); + opacity: 0; + } +} + +@keyframes slideInRight { + from { + transform: translateX(48px); + opacity: 0; + } + + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOutRight { + from { + transform: translateX(0); + opacity: 1; + } + + to { + transform: translateX(48px); + opacity: 0; + } +} + +@keyframes slideInLeft { + from { + transform: translateX(-48px); + opacity: 0; + } + + to { + transform: translateX(0); + opacity: 1; + } +} + +.login-step.slide-out-left { + animation: slideOutLeft .22s ease forwards; +} + +.login-step.slide-in-right { + animation: slideInRight .24s ease forwards; +} + +.login-step.slide-out-right { + animation: slideOutRight .22s ease forwards; +} + +.login-step.slide-in-left { + animation: slideInLeft .24s ease forwards; +} + +/* ── STEP 1 β€” ROLE GRID ── */ +.login-step h2 { + font-size: 17px; + font-weight: 700; + color: var(--text); + margin-bottom: 4px; +} + +.login-step .sub { + font-size: 12px; + color: var(--text2); + margin-bottom: 18px; +} + +.role-grid-full { + display: flex; + flex-direction: column; + gap: 10px; +} + +.role-card { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + border: 1.5px solid var(--border); + border-radius: 12px; + cursor: pointer; + background: #fafafa; + transition: all .18s ease; + position: relative; + overflow: hidden; +} + +.role-card::before { + content: ''; + position: absolute; + inset: 0; + background: var(--blue-light); + opacity: 0; + transition: opacity .18s ease; +} + +.role-card:hover { + border-color: var(--blue); + transform: translateX(3px); + box-shadow: 0 4px 16px rgba(59, 130, 246, .12); +} + +.role-card:hover::before { + opacity: 1; +} + +.role-card.selected { + border-color: var(--blue); + background: var(--blue-light); + transform: translateX(3px) scale(.99); + box-shadow: 0 2px 12px rgba(59, 130, 246, .18); +} + +.role-card-icon { + width: 42px; + height: 42px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; + position: relative; + z-index: 1; + transition: transform .18s ease; +} + +.role-card:hover .role-card-icon, +.role-card.selected .role-card-icon { + transform: scale(1.08); +} + +.role-card-icon.admin-icon { + background: rgba(59, 130, 246, .12); + color: var(--blue); +} + +.role-card-icon.surveyor-icon { + background: rgba(16, 185, 129, .12); + color: #10b981; +} + +.role-card-icon.pemangku-icon { + background: rgba(139, 92, 246, .12); + color: #8b5cf6; +} + +.role-card-body { + flex: 1; + position: relative; + z-index: 1; +} + +.role-card-name { + display: block; + font-size: 13px; + font-weight: 700; + color: var(--text); + margin-bottom: 2px; +} + +.role-card-desc { + display: block; + font-size: 11px; + color: var(--text2); + line-height: 1.4; +} + +.role-card-arrow { + color: var(--text3); + font-size: 12px; + position: relative; + z-index: 1; + transition: transform .18s ease, color .18s ease; +} + +.role-card:hover .role-card-arrow { + transform: translateX(3px); + color: var(--blue); +} + +/* ── STEP 2 β€” CREDENTIALS ── */ +/* Back button β€” subtle, top-left hierarchy */ +.back-btn { + position: absolute; + top: 0; + left: 0; + width: 30px; + height: 30px; + border: none; + background: none; + border-radius: 8px; + cursor: pointer; + color: var(--text2); + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + transition: all .18s ease; + padding: 0; +} + +.back-btn:hover { + background: var(--blue-light); + color: var(--blue); +} + +/* Active role badge */ +.active-role-badge { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 10px; + margin-bottom: 20px; + margin-left: 36px; + /* offset from back button */ + background: #f8fafc; + border: 1px solid var(--border); +} + +.arb-icon { + width: 34px; + height: 34px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + flex-shrink: 0; +} + +.arb-icon.admin-icon { + background: rgba(59, 130, 246, .12); + color: var(--blue); +} + +.arb-icon.surveyor-icon { + background: rgba(16, 185, 129, .12); + color: #10b981; +} + +.arb-icon.pemangku-icon { + background: rgba(139, 92, 246, .12); + color: #8b5cf6; +} + +.arb-info { + display: flex; + flex-direction: column; +} + +.arb-label { + font-size: 10px; + color: var(--text3); + font-weight: 500; +} + +.arb-name { + font-size: 13px; + font-weight: 700; + color: var(--text); +} + +.login-field { + margin-bottom: 14px; +} + +.login-field label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text); + margin-bottom: 6px; +} + +.login-field .input-wrap { + position: relative; +} + +.login-field .input-wrap>i:first-child { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--text3); + font-size: 13px; + pointer-events: none; +} + +.login-field input { + width: 100%; + padding: 11px 12px 11px 36px; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 13px; + color: var(--text); + background: #fafafa; + outline: none; + transition: border-color .2s, background .2s, box-shadow .2s; +} + +.login-field input:focus { + border-color: var(--blue); + background: #fff; + box-shadow: 0 0 0 3px rgba(59, 130, 246, .10); +} + +.login-field .input-wrap .toggle-pass { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + color: var(--text3); + font-size: 13px; + padding: 2px 4px; + display: flex; + align-items: center; + transition: color .2s; + left: auto; +} + +.login-field .input-wrap .toggle-pass:hover { + color: var(--blue); +} + +.login-field input[type="password"], +.login-field input[type="text"] { + padding-right: 36px; +} + +.login-btn { + width: 100%; + padding: 13px; + border: none; + border-radius: var(--radius-sm); + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 14px; + font-weight: 700; + cursor: pointer; + background: linear-gradient(135deg, var(--blue), var(--blue-dark)); + color: #fff; + transition: all .25s; + margin-top: 6px; + letter-spacing: .2px; +} + +.login-btn:hover { + transform: translateY(-1px); + box-shadow: 0 8px 24px rgba(59, 130, 246, .35); +} + +.login-btn:disabled { + opacity: .6; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.login-error { + color: var(--red); + font-size: 11px; + text-align: center; + margin-top: 8px; + min-height: 16px; + background: var(--red-light); + border-radius: 6px; + padding: 0; + transition: padding .15s; +} + +.login-error:not(:empty) { + padding: 8px 10px; +} + +/* ═══ APP LAYOUT ═══ */ +#app { + display: flex; + height: 100vh; + overflow: hidden; +} + +#app.hidden { + display: none; +} + +/* ═══ SIDEBAR ═══ */ +#sidebar { + width: var(--sidebar-w); + min-width: var(--sidebar-w); + background: var(--white); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; + position: relative; + z-index: 100; + transition: width .22s cubic-bezier(0.4, 0, 0.2, 1), min-width .22s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* --- COLLAPSED SIDEBAR RULES --- */ +#sidebar.collapsed { + width: 68px; + min-width: 68px; +} +#sidebar.collapsed .sidebar-header { + padding: 20px 15px 16px; +} +#sidebar.collapsed .sidebar-logo .logo-text { + display: none; +} +#sidebar.collapsed .sidebar-logo { + justify-content: center; +} +#sidebar.collapsed .nav-section-label { + display: none; +} +#sidebar.collapsed .nav-label { + display: none; +} +#sidebar.collapsed .nav-item { + justify-content: center; + padding: 10px 0; + margin-left: 4px; + margin-right: 4px; +} +#sidebar.collapsed .nav-badge { + position: absolute; + top: 4px; + right: 6px; + margin-left: 0; + min-width: 8px; + height: 8px; + width: 8px; + padding: 0; + font-size: 0; + border-radius: 50%; + border: 1.5px solid var(--white); +} +#sidebar.collapsed .sidebar-footer { + padding: 14px 10px; +} +#sidebar.collapsed .user-pill { + justify-content: center; + padding: 10px 0; +} +#sidebar.collapsed .user-info { + display: none; +} +#sidebar.collapsed .logout-btn-small { + display: none; +} + +.sidebar-header { + padding: 20px 16px 16px; + border-bottom: 1px solid var(--border2); +} + +.sidebar-logo { + display: flex; + align-items: center; + gap: 10px; +} + +.sidebar-logo .logo-icon { + width: 36px; + height: 36px; + background: linear-gradient(135deg, var(--blue), var(--blue-dark)); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: #fff; +} + +.sidebar-logo .logo-text h1 { + font-size: 16px; + font-weight: 800; + color: var(--text); + letter-spacing: -.4px; +} + +.sidebar-logo .logo-text p { + font-size: 9px; + color: var(--text2); + font-weight: 500; +} + +.sidebar-nav { + flex: 1; + overflow-y: auto; + padding: 12px 10px; + scrollbar-width: thin; +} + +.nav-section { + margin-bottom: 6px; +} + +.nav-section-label { + font-size: 9px; + font-weight: 700; + color: var(--text3); + text-transform: uppercase; + letter-spacing: .8px; + padding: 0 8px; + margin-bottom: 4px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all .2s; + position: relative; + font-size: 13px; + font-weight: 500; + color: var(--text2); + margin-bottom: 1px; +} + +.nav-item:hover { + background: var(--bg); + color: var(--text); +} + +.nav-item.active { + background: var(--blue-light); + color: var(--blue-dark); + font-weight: 600; +} + +.nav-item.active .nav-icon { + color: var(--blue-dark); +} + +.nav-icon { + width: 18px; + text-align: center; + font-size: 14px; + color: var(--text3); + flex-shrink: 0; +} + +.nav-badge { + margin-left: auto; + background: var(--red); + color: #fff; + font-size: 9px; + font-weight: 700; + padding: 1px 6px; + border-radius: 10px; + min-width: 18px; + text-align: center; +} + +.sidebar-footer { + padding: 14px 12px; + border-top: 1px solid var(--border2); +} + +.user-pill { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + border-radius: var(--radius-sm); + background: var(--bg); + cursor: pointer; + transition: all .2s; +} + +.user-pill:hover { + background: var(--border2); +} + +.user-avatar { + width: 34px; + height: 34px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + color: #fff; + flex-shrink: 0; +} + +.user-avatar.admin { + background: linear-gradient(135deg, var(--amber), #f97316); +} + +.user-avatar.surveyor { + background: linear-gradient(135deg, var(--green), #059669); +} + +.user-avatar.pemangku { + background: linear-gradient(135deg, var(--blue), var(--purple)); +} + +.user-info { + flex: 1; + min-width: 0; +} + +.user-info .u-name { + font-size: 12px; + font-weight: 700; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-info .u-role { + font-size: 10px; + color: var(--text2); +} + +.logout-btn-small { + background: none; + border: none; + color: var(--text3); + cursor: pointer; + font-size: 13px; + padding: 4px; + border-radius: 5px; + transition: all .2s; +} + +.logout-btn-small:hover { + color: var(--red); + background: var(--red-light); +} + +/* ═══ MAIN CONTENT ═══ */ +#main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +#topbar { + background: var(--white); + border-bottom: 1px solid var(--border); + padding: 0 24px; + height: 58px; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.topbar-left { + display: flex; + align-items: center; + gap: 12px; +} + +.topbar-left h2 { + font-size: 18px; + font-weight: 800; + color: var(--text); + letter-spacing: -.3px; + margin: 0; +} + +.topbar-left .breadcrumb { + font-size: 11px; + color: var(--text2); + margin-top: 1px; +} + +.topbar-right { + display: flex; + align-items: center; + gap: 10px; +} + +.topbar-btn { + width: 36px; + height: 36px; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + background: var(--white); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text2); + font-size: 14px; + transition: all .2s; + position: relative; +} + +.topbar-btn:hover { + border-color: var(--blue); + color: var(--blue); +} + +.notif-dot { + position: absolute; + top: 4px; + right: 4px; + width: 6px; + height: 6px; + background: var(--red); + border-radius: 50%; + border: 1.5px solid #fff; +} + +#content-area { + flex: 1; + overflow-y: auto; + padding: 24px; + scrollbar-width: thin; +} + +/* ═══ STAT CARDS ═══ */ +.stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 20px; +} + +.stat-card { + background: var(--white); + border-radius: var(--radius); + padding: 18px 20px; + box-shadow: var(--shadow); + border: 1px solid var(--border); + transition: all .2s; +} + +.stat-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.stat-card .s-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.stat-card .s-label { + font-size: 11px; + font-weight: 600; + color: var(--text2); + text-transform: uppercase; + letter-spacing: .4px; +} + +.stat-card .s-icon { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; +} + +.stat-card .s-value { + font-size: 26px; + font-weight: 800; + color: var(--text); + letter-spacing: -1px; + line-height: 1; +} + +.stat-card .s-sub { + font-size: 11px; + color: var(--text2); + margin-top: 5px; + display: flex; + align-items: center; + gap: 5px; +} + +.stat-card .s-sub .badge-up { + color: var(--green); + font-weight: 600; +} + +.stat-card .s-sub .badge-down { + color: var(--red); + font-weight: 600; +} + +.icon-blue { + background: var(--blue-light); + color: var(--blue-dark); +} + +.icon-green { + background: var(--green-light); + color: var(--green); +} + +.icon-red { + background: var(--red-light); + color: var(--red); +} + +.icon-amber { + background: var(--amber-light); + color: var(--amber); +} + +.icon-purple { + background: var(--purple-light); + color: var(--purple); +} + +/* ═══ CONTENT GRID ═══ */ +.content-grid { + display: grid; + grid-template-columns: 1fr 320px; + gap: 16px; + margin-bottom: 16px; + align-items: stretch; +} + +.content-grid-full { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + margin-bottom: 16px; +} + +.content-grid-form { + display: grid; + grid-template-columns: 380px 1fr; + gap: 16px; + margin-bottom: 16px; +} + +@media (max-width: 992px) { + + .content-grid, + .content-grid-form { + grid-template-columns: 1fr !important; + } + + .card-map, + #map-input-ib, + #map-input-wg { + height: 400px !important; + } + + .content-grid>.card:first-child .card-map { + height: 400px !important; + flex: none; + } +} + +.card { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + border: 1px solid var(--border); + overflow: hidden; +} + +.card-header { + padding: 16px 20px 12px; + border-bottom: 1px solid var(--border2); + display: flex; + align-items: center; + justify-content: space-between; +} + +.card-header h3 { + font-size: 14px; + font-weight: 700; + color: var(--text); +} + +.card-header .card-sub { + font-size: 11px; + color: var(--text2); +} + +.card-body { + padding: 16px 20px; +} + +.card-map { + height: 380px; + position: relative; +} + +/* Dashboard map: ikuti tinggi kartu penuh */ +.content-grid>.card { + height: 100%; +} + +.content-grid>.card:first-child { + display: flex; + flex-direction: column; +} + +.content-grid>.card:first-child .card-map { + height: auto; + flex: 1; + min-height: 340px; +} + +#dash-map-container { + height: 100%; + min-height: 340px; +} + +#map { + width: 100%; + height: 100%; +} + +/* ═══ MODE PILL ═══ */ +#mode-pill { + position: fixed; + top: 70px; + left: 50%; + transform: translateX(-50%); + background: #fff; + border: 2px solid var(--blue); + border-radius: 40px; + padding: 8px 20px; + font-size: 12px; + font-weight: 700; + color: var(--blue-dark); + display: none; + align-items: center; + gap: 10px; + z-index: 9000; + box-shadow: var(--shadow-md); + cursor: pointer; +} + +#mode-pill.show { + display: flex; +} + +#mode-pill .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--blue); + animation: blink 1s infinite; +} + +@keyframes blink { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: .2; + } +} + +/* ═══ SIDE INFO PANEL ═══ */ +.info-panel {} + +.info-panel .info-section { + margin-bottom: 16px; +} + +.info-panel .info-section:last-child { + margin-bottom: 0; +} + +.info-section-title { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .5px; + color: var(--text2); + margin-bottom: 10px; +} + +.legend-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 7px; + font-size: 12px; + color: var(--text2); +} + +.legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.mini-stat { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--border2); +} + +.mini-stat:last-child { + border-bottom: none; +} + +.mini-stat .m-label { + font-size: 12px; + color: var(--text2); +} + +.mini-stat .m-val { + font-size: 14px; + font-weight: 700; + color: var(--text); +} + +/* ═══ DATA LISTS ═══ */ +.list-item { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 0; + border-bottom: 1px solid var(--border2); + cursor: pointer; + transition: all .15s; +} + +.list-item:last-child { + border-bottom: none; +} + +.list-item:hover { + background: var(--bg); + margin: 0 -20px; + padding: 11px 20px; +} + +.list-icon { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; +} + +.list-body { + flex: 1; + min-width: 0; +} + +.list-name { + font-size: 13px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.list-sub { + font-size: 11px; + color: var(--text2); + margin-top: 2px; +} + +.list-end { + text-align: right; + flex-shrink: 0; +} + +.badge { + display: inline-flex; + align-items: center; + font-size: 10px; + font-weight: 700; + padding: 3px 8px; + border-radius: 20px; +} + +.badge-pending { + background: var(--amber-light); + color: var(--amber); +} + +.badge-verified { + background: var(--green-light); + color: var(--green); +} + +.badge-rejected { + background: var(--red-light); + color: var(--red); +} + +.badge-in { + background: var(--green-light); + color: var(--green); +} + +.badge-out { + background: var(--border2); + color: var(--text3); +} + +/* ═══ FORMS ═══ */ +.form-section-title { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .5px; + color: var(--text2); + margin-bottom: 14px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border2); +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.form-group { + margin-bottom: 12px; +} + +.form-group label { + display: block; + font-size: 11px; + font-weight: 600; + color: var(--text2); + margin-bottom: 5px; + text-transform: uppercase; + letter-spacing: .4px; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 9px 12px; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 13px; + color: var(--text); + outline: none; + transition: border-color .2s; + background: #fff; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + border-color: var(--blue); +} + +.form-group select option { + background: #fff; +} + +.radius-row { + display: flex; + align-items: center; + gap: 10px; +} + +.radius-row input[type=range] { + flex: 1; + accent-color: var(--blue); +} + +.radius-val { + font-size: 13px; + font-weight: 700; + color: var(--blue-dark); + min-width: 48px; + text-align: right; +} + +.coord-box { + background: var(--bg); + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + padding: 9px 12px; + font-size: 12px; + color: var(--text2); + display: flex; + align-items: center; + gap: 8px; +} + +.coord-box.has-coord { + border-color: var(--green); + background: var(--green-light); +} + +.coord-box.has-coord .coord-val { + color: var(--green); + font-weight: 700; +} + +.coord-hint { + font-size: 10px; + color: var(--text2); + margin-top: 5px; + text-align: center; +} + +.submit-btn { + padding: 10px 20px; + border: none; + border-radius: var(--radius-sm); + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: all .2s; +} + +.submit-btn.primary { + background: var(--blue); + color: #fff; +} + +.submit-btn.primary:hover { + background: var(--blue-dark); + transform: translateY(-1px); +} + +.submit-btn.success { + background: var(--green); + color: #fff; +} + +.submit-btn.danger { + background: var(--red-light); + color: var(--red); + border: 1px solid rgba(239, 68, 68, .3); +} + +.submit-btn.secondary { + background: var(--bg); + color: var(--text2); + border: 1.5px solid var(--border); +} + +.submit-btn.full { + width: 100%; +} + +.submit-btn:disabled { + opacity: .5; + cursor: not-allowed; + transform: none !important; +} + +/* ═══ UPLOAD FOTO ═══ */ +.upload-area { + border: 2px dashed var(--border); + border-radius: var(--radius-sm); + padding: 24px; + text-align: center; + cursor: pointer; + transition: all .2s; + position: relative; + background: var(--bg); +} + +.upload-area:hover, +.upload-area.drag { + border-color: var(--blue); + background: var(--blue-light); +} + +.upload-area .u-icon { + font-size: 28px; + margin-bottom: 6px; +} + +.upload-area p { + font-size: 11px; + color: var(--text2); + line-height: 1.6; +} + +.upload-area p strong { + color: var(--blue-dark); +} + +.upload-area input[type=file] { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.upload-preview { + margin-top: 10px; + position: relative; + display: none; +} + +.upload-preview img { + width: 100%; + border-radius: var(--radius-sm); + max-height: 160px; + object-fit: cover; +} + +.upload-preview .rem-btn { + position: absolute; + top: 6px; + right: 6px; + background: rgba(0, 0, 0, .6); + border: none; + color: #fff; + width: 24px; + height: 24px; + border-radius: 50%; + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; +} + +.exif-chip { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 10px; + font-weight: 700; + padding: 4px 10px; + border-radius: 20px; + margin-top: 6px; +} + +.exif-found { + background: var(--green-light); + color: var(--green); +} + +.exif-none { + background: var(--border2); + color: var(--text3); +} + +/* ═══ VERIFY β€” TABLE LAYOUT ═══ */ +.verify-toolbar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; + flex-wrap: wrap; +} + +.verify-toolbar .vt-search { + flex: 1; + min-width: 180px; + position: relative; +} + +.verify-toolbar .vt-search input { + width: 100%; + padding: 8px 12px 8px 34px; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + font-size: 13px; + font-family: 'Plus Jakarta Sans', sans-serif; + background: #fff; + color: var(--text); + outline: none; + transition: border .15s; + box-sizing: border-box; +} + +.verify-toolbar .vt-search input:focus { + border-color: var(--blue); +} + +.verify-toolbar .vt-search .vt-search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--text3); + font-size: 12px; + pointer-events: none; +} + +.verify-toolbar select { + padding: 8px 10px; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + font-size: 12px; + font-family: 'Plus Jakarta Sans', sans-serif; + background: #fff; + color: var(--text); + cursor: pointer; + outline: none; +} + +.verify-count-badge { + font-size: 12px; + font-weight: 700; + color: var(--text2); + white-space: nowrap; +} + +/* Table */ +.verify-table-wrap { + background: #fff; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + margin-bottom: 24px; +} + +.verify-table-wrap table { + width: 100%; + border-collapse: collapse; +} + +.verify-table-wrap thead tr { + background: var(--border2); + border-bottom: 1px solid var(--border); +} + +.verify-table-wrap thead th { + padding: 10px 14px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .5px; + color: var(--text2); + text-align: left; + white-space: nowrap; +} + +.verify-table-wrap tbody tr { + border-bottom: 1px solid var(--border2); + transition: background .15s; +} + +.verify-table-wrap tbody tr:last-child { + border-bottom: none; +} + +.verify-table-wrap tbody tr:hover { + background: #f9fafb; +} + +.verify-table-wrap td { + padding: 11px 14px; + font-size: 13px; + color: var(--text); + vertical-align: middle; +} + +.vt-thumb { + width: 44px; + height: 44px; + border-radius: 8px; + object-fit: cover; + cursor: pointer; + border: 1.5px solid var(--border); + transition: transform .15s; +} + +.vt-thumb:hover { + transform: scale(1.08); +} + +.vt-no-thumb { + width: 44px; + height: 44px; + border-radius: 8px; + background: var(--border2); + display: flex; + align-items: center; + justify-content: center; + color: var(--text3); + font-size: 16px; +} + +.vt-name { + font-weight: 700; + font-size: 13px; +} + +.vt-sub { + font-size: 11px; + color: var(--text2); + margin-top: 2px; +} + +.vt-coord { + font-size: 11px; + color: var(--blue-dark); + font-weight: 600; +} + +.vt-actions { + display: flex; + gap: 6px; + align-items: center; +} + +.vt-actions button { + padding: 6px 10px; + border-radius: 6px; + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 11px; + font-weight: 700; + cursor: pointer; + border: none; + transition: all .15s; + white-space: nowrap; +} + +.btn-detail { + background: var(--blue-light); + color: var(--blue-dark); + border: 1.5px solid #bfdbfe !important; +} + +.btn-detail:hover { + background: var(--blue); + color: #fff; +} + +.btn-approve { + background: var(--green-light); + color: var(--green); +} + +.btn-approve:hover { + background: var(--green); + color: #fff; +} + +.btn-reject { + background: var(--red-light); + color: var(--red); +} + +.btn-reject:hover { + background: var(--red); + color: #fff; +} + +/* ═══ CATATAN PENOLAKAN (untuk surveyor) ═══ */ +.rejection-notice { + display: flex; + align-items: flex-start; + gap: 8px; + background: #fef2f2; + border: 1.5px solid #fca5a5; + border-left: 4px solid var(--red); + border-radius: var(--radius-sm); + padding: 9px 11px; + margin-top: 8px; + font-size: 12px; + color: #7f1d1d; + line-height: 1.45; +} + +.rejection-notice .rn-icon { + color: var(--red); + font-size: 13px; + margin-top: 1px; + flex-shrink: 0; +} + +.rejection-notice .rn-label { + font-weight: 800; + font-size: 10px; + text-transform: uppercase; + letter-spacing: .4px; + color: var(--red); + margin-bottom: 2px; +} + +/* Versi compact untuk daftar card */ +.dc-rejection { + display: flex; + align-items: flex-start; + gap: 5px; + background: #fef2f2; + border-left: 3px solid var(--red); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + padding: 5px 8px; + margin-top: 6px; + font-size: 11px; + color: #991b1b; + line-height: 1.4; +} + +.dc-rejection i { + color: var(--red); + margin-top: 1px; + flex-shrink: 0; + font-size: 11px; +} + +/* ═══ VERIFY TABS ═══ */ +.verify-tabs { + display: flex; + gap: 6px; + margin-bottom: 14px; + border-bottom: 2px solid var(--border); + padding-bottom: 0; +} + +.verify-tab { + padding: 8px 16px; + border: none; + background: none; + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 13px; + font-weight: 700; + color: var(--text2); + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + border-radius: 6px 6px 0 0; + transition: all .15s; + display: flex; + align-items: center; + gap: 7px; +} + +.verify-tab:hover { + background: var(--border2); + color: var(--text); +} + +.verify-tab.active { + color: var(--blue-dark); + border-bottom-color: var(--blue); + background: var(--blue-light); +} + +.verify-tab .vtab-count { + background: var(--border); + color: var(--text2); + border-radius: 20px; + padding: 1px 8px; + font-size: 11px; +} + +.verify-tab.active .vtab-count { + background: var(--blue); + color: #fff; +} + +/* Badge amber untuk ibadah */ +.verify-tab.tab-ibadah.active .vtab-count { + background: var(--amber); + color: #fff; +} + +.verify-tab.tab-ibadah.active { + color: #92400e; + border-bottom-color: var(--amber); + background: var(--amber-light); +} + +/* Kolom tambahan warga miskin */ +.vt-tag { + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--green-light); + color: var(--green); + border-radius: 20px; + padding: 2px 8px; + font-size: 11px; + font-weight: 700; + white-space: nowrap; +} + +.vt-tag.red { + background: var(--red-light); + color: var(--red); +} + +.vt-tag.amber { + background: var(--amber-light); + color: var(--amber); +} + +.vt-col-nik { + font-size: 12px; + font-weight: 700; + color: var(--text); + font-family: monospace; +} + +.vt-col-kec { + font-size: 11px; + color: var(--text2); + margin-top: 2px; +} + +.vt-col-jiwa { + font-size: 13px; + font-weight: 800; + color: var(--blue-dark); +} + +.vt-col-jiwa-sub { + font-size: 10px; + color: var(--text3); +} + +/* ═══ DETAIL MODAL ═══ */ +#detail-modal { + display: none; + position: fixed; + inset: 0; + z-index: 3000; + background: rgba(0, 0, 0, .45); + backdrop-filter: blur(2px); + align-items: center; + justify-content: center; + padding: 16px; +} + +#detail-modal.show { + display: flex; +} + +.dm-box { + background: #fff; + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 560px; + max-height: 90vh; + overflow-y: auto; + animation: dmIn .2s ease; +} + +@keyframes dmIn { + from { + opacity: 0; + transform: scale(.96) translateY(10px); + } + + to { + opacity: 1; + transform: none; + } +} + +.dm-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px 14px; + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background: #fff; + z-index: 1; +} + +.dm-header h3 { + font-size: 15px; + font-weight: 800; + display: flex; + align-items: center; + gap: 8px; +} + +.dm-close { + width: 30px; + height: 30px; + border-radius: 50%; + border: none; + background: var(--border2); + color: var(--text2); + cursor: pointer; + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + transition: background .15s; +} + +.dm-close:hover { + background: var(--border); +} + +.dm-body { + padding: 18px 20px; +} + +.dm-photo { + width: 100%; + max-height: 200px; + object-fit: cover; + border-radius: var(--radius-sm); + margin-bottom: 16px; + cursor: pointer; +} + +.dm-section { + margin-bottom: 14px; +} + +.dm-section-title { + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: .6px; + color: var(--text3); + margin-bottom: 8px; +} + +.dm-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.dm-field { + background: var(--border2); + border-radius: 8px; + padding: 8px 10px; +} + +.dm-field.full { + grid-column: 1 / -1; +} + +.dm-field label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .4px; + color: var(--text3); + display: block; + margin-bottom: 3px; +} + +.dm-field span { + font-size: 13px; + font-weight: 600; + color: var(--text); + word-break: break-word; +} + +.dm-footer { + padding: 14px 20px; + border-top: 1px solid var(--border); + display: flex; + gap: 8px; + justify-content: flex-end; + position: sticky; + bottom: 0; + background: #fff; +} + +.dm-footer button { + padding: 9px 18px; + border-radius: var(--radius-sm); + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 13px; + font-weight: 700; + cursor: pointer; + border: none; + transition: all .15s; +} + +/* ═══ USER CARDS ═══ */ +.user-card-list { + border-radius: var(--radius); + overflow: hidden; + border: 1px solid var(--border); +} + +.user-card-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: #fff; + border-bottom: 1px solid var(--border2); + transition: all .2s; +} + +.user-card-item:last-child { + border-bottom: none; +} + +.user-card-item:hover { + background: var(--bg); +} + +.uc-avatar { + width: 38px; + height: 38px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + font-weight: 700; + color: #fff; + flex-shrink: 0; +} + +.uc-info { + flex: 1; + min-width: 0; +} + +.uc-info .uc-name { + font-size: 13px; + font-weight: 600; + color: var(--text); +} + +.uc-info .uc-role { + font-size: 11px; + color: var(--text2); +} + +.uc-actions { + display: flex; + gap: 6px; +} + +.uc-btn { + border: none; + border-radius: var(--radius-sm); + padding: 5px 10px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all .2s; +} + +/* ═══ AUDIT TABLE ═══ */ +.audit-tbl { + width: 100%; + border-collapse: collapse; +} + +.audit-tbl th { + text-align: left; + padding: 10px 12px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .4px; + color: var(--text2); + border-bottom: 2px solid var(--border2); +} + +.audit-tbl td { + padding: 10px 12px; + font-size: 12px; + border-bottom: 1px solid var(--border2); + vertical-align: middle; +} + +.audit-tbl tr:hover td { + background: var(--bg); +} + +.aksi-chip { + font-size: 10px; + font-weight: 700; + padding: 3px 8px; + border-radius: 20px; +} + +/* ═══ DRAG EDIT ═══ */ +.drag-hint-box { + background: var(--blue-light); + border-radius: var(--radius-sm); + padding: 10px 12px; + font-size: 11px; + color: var(--blue-dark); + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.drag-toggle { + width: 100%; + padding: 9px; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 12px; + font-weight: 600; + cursor: pointer; + background: #fff; + color: var(--text2); + transition: all .2s; +} + +.drag-toggle.active { + border-color: var(--blue); + color: var(--blue-dark); + background: var(--blue-light); +} + +/* ═══ EDIT MODAL ═══ */ +#edit-panel { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .55); + z-index: 9500; + display: none; + align-items: center; + justify-content: center; + backdrop-filter: blur(6px); + animation: fadeIn .2s ease; +} + +#edit-panel.open { + display: flex; +} + +.ep-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border2); + display: flex; + align-items: center; + justify-content: space-between; +} + +.ep-header h3 { + font-size: 14px; + font-weight: 700; +} + +.ep-close { + background: none; + border: none; + color: var(--text3); + font-size: 18px; + cursor: pointer; + padding: 4px; + border-radius: var(--radius-sm); + transition: all .2s; +} + +.ep-close:hover { + background: var(--red-light); + color: var(--red); +} + +.ep-body { + flex: 1; + overflow-y: auto; + padding: 16px 20px; + scrollbar-width: thin; +} + +.ep-footer { + padding: 12px 20px; + border-top: 1px solid var(--border2); + display: flex; + gap: 8px; +} + +.ep-save { + flex: 1; + padding: 10px; + border: none; + border-radius: var(--radius-sm); + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 13px; + font-weight: 700; + cursor: pointer; + background: var(--blue); + color: #fff; + transition: all .2s; +} + +.ep-save:hover { + background: var(--blue-dark); +} + +.ep-del { + padding: 10px 14px; + border: 1.5px solid rgba(239, 68, 68, .3); + border-radius: var(--radius-sm); + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 13px; + font-weight: 700; + cursor: pointer; + background: var(--red-light); + color: var(--red); + transition: all .2s; +} + +.ep-del:hover { + background: var(--red); + color: #fff; +} + +/* ═══ MODAL ═══ */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .5); + z-index: 9500; + display: none; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); +} + +.modal-overlay.show { + display: flex; +} + +.modal-box { + background: #fff; + border-radius: var(--radius); + padding: 28px; + width: 400px; + max-width: 92vw; + box-shadow: var(--shadow-lg); +} + +.modal-box h3 { + font-size: 16px; + font-weight: 700; + margin-bottom: 6px; +} + +.modal-box p { + font-size: 12px; + color: var(--text2); + margin-bottom: 16px; +} + +.modal-box textarea { + width: 100%; + padding: 10px 12px; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 13px; + outline: none; + resize: vertical; + min-height: 80px; + margin-bottom: 14px; + transition: border-color .2s; +} + +.modal-box textarea:focus { + border-color: var(--blue); +} + +.modal-btns { + display: flex; + gap: 8px; +} + +.modal-btns button { + flex: 1; + padding: 11px; + border: none; + border-radius: var(--radius-sm); + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +/* ═══ PREVIEW MODAL ═══ */ +#preview-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .55); + z-index: 9500; + display: none; + align-items: center; + justify-content: center; + backdrop-filter: blur(6px); + animation: fadeIn .2s ease; +} + +#preview-modal.show { + display: flex; +} + +@keyframes fadeIn { + from { + opacity: 0 + } + + to { + opacity: 1 + } +} + +@keyframes modalSlideUp { + from { + opacity: 0; + transform: translateY(24px) scale(.96) + } + + to { + opacity: 1; + transform: translateY(0) scale(1) + } +} + +.preview-box { + background: #fff; + border-radius: 16px; + width: 900px; + max-width: 95vw; + max-height: 85vh; + overflow: hidden; + box-shadow: 0 20px 60px rgba(0, 0, 0, .25); + display: flex; + flex-direction: column; + animation: modalSlideUp .3s cubic-bezier(.22, 1, .36, 1); +} + +/* ═══ TWO COLUMN MODAL LAYOUT ═══ */ +.modal-two-col { + display: flex; + flex-direction: row; + flex: 1; + overflow: hidden; + min-height: 400px; + max-height: calc(85vh - 130px); +} + +.modal-col-left { + flex: 1; + overflow-y: auto; + padding: 20px 24px; + scrollbar-width: thin; +} + +.modal-col-right { + flex: 1.2; + padding: 20px 24px 20px 0; + display: flex; + flex-direction: column; +} + +@media (max-width: 768px) { + .modal-two-col { + flex-direction: column; + overflow-y: auto; + max-height: calc(85vh - 80px); + } + + .modal-col-right { + padding: 0 24px 24px 24px; + min-height: 300px; + } +} + +/* ═══ WIDER DAFTAR GRID ═══ */ +.daftar-grid-container { + grid-template-columns: 4.5fr 5.5fr !important; +} + +@media (max-width: 992px) { + .daftar-grid-container { + grid-template-columns: 1fr !important; + } +} + +.preview-box .pb-header { + padding: 18px 24px; + border-bottom: 1px solid var(--border2); + display: flex; + align-items: center; + justify-content: space-between; +} + +.preview-box .pb-header h3 { + font-size: 15px; + font-weight: 700; + display: flex; + align-items: center; + gap: 8px; +} + +.preview-box .pb-close { + background: none; + border: none; + color: var(--text3); + font-size: 18px; + cursor: pointer; + padding: 6px; + border-radius: var(--radius-sm); + transition: all .2s; +} + +.preview-box .pb-close:hover { + background: var(--red-light); + color: var(--red); +} + +.preview-box .pb-body { + flex: 1; + overflow-y: auto; + padding: 20px 24px; + scrollbar-width: thin; +} + +/* ── foto thumbnail (preview modal) ── */ +.pb-foto-thumb { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: var(--bg); + border-radius: var(--radius-sm); + margin-bottom: 14px; + cursor: pointer; + border: 1.5px solid var(--border); + transition: border-color .2s, background .2s; +} + +.pb-foto-thumb:hover { + border-color: var(--blue); + background: var(--bg2); +} + +.pb-foto-thumb img { + width: 64px; + height: 64px; + object-fit: cover; + border-radius: 8px; + flex-shrink: 0; +} + +.pb-foto-thumb-info { + flex: 1; + min-width: 0; +} + +.pb-foto-thumb-label { + font-size: 10px; + color: var(--text3); + font-weight: 600; + text-transform: uppercase; + letter-spacing: .5px; + margin-bottom: 3px; +} + +.pb-foto-thumb-hint { + font-size: 11px; + color: var(--blue); + display: flex; + align-items: center; + gap: 4px; +} + +/* ── lightbox ── */ +#lightbox { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .92); + z-index: 99999; + align-items: center; + justify-content: center; + cursor: zoom-out; +} + +#lightbox.show { + display: flex; +} + +#lightbox img { + max-width: 92vw; + max-height: 92vh; + border-radius: 10px; + box-shadow: 0 24px 80px rgba(0, 0, 0, .7); + cursor: default; + animation: lbIn .18s ease; +} + +@keyframes lbIn { + from { + opacity: 0; + transform: scale(.92); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +#lightbox-close { + position: fixed; + top: 18px; + right: 22px; + width: 38px; + height: 38px; + background: rgba(255, 255, 255, .12); + border: none; + border-radius: 50%; + color: #fff; + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background .15s; +} + +#lightbox-close:hover { + background: rgba(255, 255, 255, .25); +} + +.pb-field { + margin-bottom: 12px; +} + +.pb-field .pb-label { + font-size: 10px; + font-weight: 700; + color: var(--text3); + text-transform: uppercase; + letter-spacing: .5px; + margin-bottom: 3px; +} + +.pb-field .pb-value { + font-size: 13px; + font-weight: 500; + color: var(--text); + padding: 8px 12px; + background: var(--bg); + border-radius: var(--radius-sm); + border: 1px solid var(--border2); + min-height: 34px; +} + +.pb-field-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.pb-footer { + padding: 14px 24px; + border-top: 1px solid var(--border2); + display: flex; + justify-content: flex-end; +} + +.pb-footer button { + padding: 10px 24px; + border: none; + border-radius: var(--radius-sm); + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 13px; + font-weight: 600; + cursor: pointer; + background: var(--bg); + color: var(--text2); + transition: all .2s; +} + +.pb-footer button:hover { + background: var(--border); + color: var(--text); +} + +.btn-icon-action { + width: 28px; + height: 28px; + border-radius: 8px; + border: none; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + font-size: 13px; +} + +.btn-icon-action.view { + background: var(--blue-light); + color: var(--blue-dark); +} + +.btn-icon-action.view:hover { + background: var(--blue); + color: #fff; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(59, 130, 246, 0.2); +} + +.btn-icon-action.edit { + background: var(--amber-light); + color: var(--amber); +} + +.btn-icon-action.edit:hover { + background: var(--amber); + color: #fff; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(245, 158, 11, 0.2); +} + +.btn-icon-action.delete { + background: var(--red-light); + color: var(--red); +} + +.btn-icon-action.delete:hover { + background: var(--red); + color: #fff; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(239, 68, 68, 0.2); +} + +/* ═══ HEATMAP TOGGLE ═══ */ +.toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 0; +} + +.toggle-row label:first-child { + font-size: 13px; + font-weight: 500; + color: var(--text); +} + +.switch { + position: relative; + width: 44px; + height: 24px; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.switch .sl { + position: absolute; + inset: 0; + background: var(--border); + border-radius: 24px; + cursor: pointer; + transition: .25s; +} + +.switch .sl::before { + content: ''; + position: absolute; + width: 18px; + height: 18px; + left: 3px; + top: 3px; + background: #fff; + border-radius: 50%; + transition: .25s; +} + +.switch input:checked+.sl { + background: var(--blue); +} + +.switch input:checked+.sl::before { + transform: translateX(20px); +} + +/* ═══ TOAST ═══ */ +#toast { + position: fixed; + bottom: 24px; + right: 24px; + background: #fff; + border-radius: var(--radius-sm); + padding: 12px 18px; + font-size: 13px; + font-weight: 600; + z-index: 9999; + transform: translateY(80px); + opacity: 0; + transition: all .3s cubic-bezier(.34, 1.56, .64, 1); + max-width: 320px; + box-shadow: var(--shadow-lg); + border-left: 4px solid var(--blue); +} + +#toast.show { + transform: translateY(0); + opacity: 1; +} + +#toast.success { + border-color: var(--green); +} + +#toast.error { + border-color: var(--red); +} + +#toast.info { + border-color: var(--blue); +} + +/* ═══ LOADING ═══ */ +#loading { + position: fixed; + inset: 0; + background: rgba(255, 255, 255, .9); + z-index: 9998; + display: none; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 12px; + backdrop-filter: blur(4px); +} + +#loading.show { + display: flex; +} + +.spin { + width: 36px; + height: 36px; + border: 3px solid var(--border); + border-top-color: var(--blue); + border-radius: 50%; + animation: spin .8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +#loading p { + font-size: 12px; + color: var(--text2); +} + +.empty-state { + text-align: center; + padding: 40px 20px; + color: var(--text2); +} + +.empty-state .e-icon { + font-size: 36px; + margin-bottom: 10px; +} + +.empty-state p { + font-size: 13px; + line-height: 1.6; +} + +/* ═══ HEATMAP PAGE ═══ */ +#heatmap-map { + height: 440px; + border-radius: var(--radius-sm); + overflow: hidden; +} + +/* ═══ DAFTAR DATA V2 ═══ */ +.daftar-tabs { + display: flex; + gap: 4px; + background: var(--bg); + border-radius: var(--radius-sm); + padding: 4px; + margin-bottom: 16px; +} + +.daftar-tab { + flex: 1; + padding: 10px 16px; + border: none; + border-radius: 6px; + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 13px; + font-weight: 600; + cursor: pointer; + background: transparent; + color: var(--text2); + transition: all .25s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.daftar-tab:hover { + color: var(--text); + background: rgba(255, 255, 255, .5); +} + +.daftar-tab.active { + background: #fff; + color: var(--blue-dark); + box-shadow: 0 1px 4px rgba(0, 0, 0, .08); +} + +.daftar-tab .tab-count { + background: var(--border2); + color: var(--text3); + font-size: 10px; + font-weight: 700; + padding: 2px 7px; + border-radius: 10px; + transition: all .25s; +} + +.daftar-tab.active .tab-count { + background: var(--blue-light); + color: var(--blue-dark); +} + +.daftar-toolbar { + display: flex; + gap: 10px; + margin-bottom: 14px; + flex-wrap: wrap; +} + +.daftar-search { + flex: 1; + min-width: 180px; + position: relative; +} + +.daftar-search i { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--text3); + font-size: 13px; + pointer-events: none; +} + +.daftar-search input { + width: 100%; + padding: 9px 12px 9px 34px; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 12px; + color: var(--text); + outline: none; + transition: border-color .2s; + background: #fff; +} + +.daftar-search input:focus { + border-color: var(--blue); +} + +.daftar-search input::placeholder { + color: var(--text3); +} + +.daftar-filter select { + padding: 9px 28px 9px 10px; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 12px; + color: var(--text2); + outline: none; + cursor: pointer; + background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%236b7280'%3E%3Cpath fill-rule='evenodd' d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z'/%3E%3C/svg%3E") no-repeat right 8px center/14px; + appearance: none; + -webkit-appearance: none; + transition: border-color .2s; +} + +.daftar-filter select:focus { + border-color: var(--blue); +} + +.daftar-summary { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + padding: 0 2px; +} + +.daftar-summary .ds-text { + font-size: 11px; + color: var(--text3); + font-weight: 500; +} + +.daftar-summary .ds-text strong { + color: var(--text2); +} + +.daftar-list { + min-height: 200px; +} + +.daftar-card { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border: 1.5px solid var(--border2); + border-radius: var(--radius-sm); + margin-bottom: 8px; + cursor: pointer; + transition: all .2s; + background: #fff; + position: relative; +} + +.daftar-card:hover { + border-color: var(--blue); + box-shadow: 0 2px 8px rgba(59, 130, 246, .1); +} + +.daftar-card.active { + border-color: var(--blue); + background: var(--blue-light); + box-shadow: 0 2px 12px rgba(59, 130, 246, .15); +} + +.daftar-card .dc-icon { + width: 38px; + height: 38px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; +} + +.daftar-card .dc-body { + flex: 1; + min-width: 0; +} + +.daftar-card .dc-name { + font-size: 13px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.daftar-card .dc-sub { + font-size: 11px; + color: var(--text2); + margin-top: 2px; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.daftar-card .dc-end { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + flex-shrink: 0; +} + +.daftar-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + margin-top: 16px; + padding: 8px 0; +} + +.daftar-pagination button { + width: 34px; + height: 34px; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + background: #fff; + color: var(--text2); + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all .2s; + display: flex; + align-items: center; + justify-content: center; +} + +.daftar-pagination button:hover:not(:disabled) { + border-color: var(--blue); + color: var(--blue-dark); + background: var(--blue-light); +} + +.daftar-pagination button.active { + background: var(--blue); + color: #fff; + border-color: var(--blue); +} + +.daftar-pagination button:disabled { + opacity: .4; + cursor: not-allowed; +} + +.daftar-pagination .pg-info { + font-size: 11px; + color: var(--text3); + padding: 0 8px; +} + +.daftar-map-card { + position: sticky; + top: 0; +} + +.daftar-map-card .map-container { + height: calc(100vh - 220px); + min-height: 380px; +} + +#map-daftar { + height: 100%; + width: 100%; + border-radius: 0 0 var(--radius) var(--radius); +} + +::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +/* ═══ INTERACTIVE TOOLTIP STYLING ═══ */ +.leaflet-tooltip.interactive-map-tooltip { + background: #ffffff !important; + border: 1.5px solid var(--border) !important; + border-radius: var(--radius-sm) !important; + box-shadow: var(--shadow-md) !important; + padding: 10px 14px !important; + pointer-events: auto !important; + /* Mengizinkan klik dan hover di dalam tooltip */ + opacity: 1 !important; + min-width: 180px; +} + +.leaflet-tooltip.interactive-map-tooltip::before { + display: none !important; + /* Hilangkan panah bawaan */ +} + +.map-tooltip { + font-family: 'Plus Jakarta Sans', sans-serif; + color: var(--text); +} + +.map-tooltip .tooltip-title { + font-size: 13px; + font-weight: 700; + margin-bottom: 6px; + border-bottom: 1.5px solid var(--border2); + padding-bottom: 4px; + display: flex; + align-items: center; + gap: 6px; +} + +.map-tooltip .tooltip-row { + font-size: 11px; + margin-bottom: 4px; + color: var(--text2); + display: flex; + justify-content: space-between; + gap: 8px; +} + +.map-tooltip .tooltip-row strong { + color: var(--text); + font-weight: 600; +} + +.map-tooltip .tooltip-footer { + margin-top: 8px; + display: flex; + justify-content: flex-end; + border-top: 1px dashed var(--border); + padding-top: 6px; +} + +.map-tooltip .tooltip-btn { + background: var(--blue-light); + color: var(--blue-dark); + border: none; + border-radius: 4px; + padding: 4px 8px; + font-size: 10px; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; + transition: all 0.2s; +} + +.map-tooltip .tooltip-btn:hover { + background: var(--blue); + color: #fff; +} + +/* ═══ LAYER CONTROL PREMIUM STYLING ═══ */ +.leaflet-control-layers { + border: 1.5px solid var(--border) !important; + border-radius: var(--radius-sm) !important; + box-shadow: var(--shadow-md) !important; + font-family: 'Plus Jakarta Sans', sans-serif !important; + background: #ffffff !important; + transition: all 0.2s ease-in-out !important; +} + +.leaflet-control-layers-toggle { + width: 34px !important; + height: 34px !important; + background-size: 18px 18px !important; + border-radius: var(--radius-sm) !important; + transition: background-color 0.2s !important; +} + +.leaflet-control-layers-toggle:hover { + background-color: var(--border2) !important; +} + +.leaflet-control-layers-expanded { + padding: 10px 14px !important; + color: var(--text) !important; + font-size: 12px !important; + font-weight: 500 !important; +} + +.leaflet-control-layers-expanded label { + margin-bottom: 6px !important; + display: flex !important; + align-items: center !important; + cursor: pointer !important; + gap: 6px !important; +} + +.leaflet-control-layers-expanded input { + margin: 0 !important; + cursor: pointer !important; +} + +.leaflet-control-layers-expanded .leaflet-control-layers-separator { + margin: 6px 0 !important; + border-top: 1px solid var(--border2) !important; +} \ No newline at end of file diff --git a/povertymap/uploads/.htaccess b/povertymap/uploads/.htaccess new file mode 100644 index 0000000..f452238 --- /dev/null +++ b/povertymap/uploads/.htaccess @@ -0,0 +1,15 @@ +# Hanya izinkan akses file gambar + + Require all granted + + +# Blok semua file lain + + Require all denied + + +# Disable directory listing +Options -Indexes + +# Disable PHP execution +php_flag engine off diff --git a/spbu/api.php b/spbu/api.php new file mode 100644 index 0000000..df44353 --- /dev/null +++ b/spbu/api.php @@ -0,0 +1,155 @@ + false, 'message' => 'PHP Error: ' . $err['message']]); + } +}); + +header('Content-Type: application/json'); +header('Access-Control-Allow-Origin: *'); + +require_once 'config.php'; + +$action = $_GET['action'] ?? $_POST['action'] ?? ''; + +// ── GET ALL ─────────────────────────────────────────── +if ($action === 'all') { + $conn = getConnection(); + + $check = $conn->query("SHOW TABLES LIKE 'spbu'"); + if ($check->num_rows === 0) { + echo json_encode(['success' => false, 'message' => "Tabel 'spbu' tidak ditemukan. Pastikan Anda sudah import file database.sql"]); + $conn->close(); exit; + } + + $result = $conn->query( + "SELECT id, nama, nomor_spbu, status, latitude, longitude, + DATE_FORMAT(created_at, '%d %b %Y %H:%i') AS created_at + FROM spbu ORDER BY created_at DESC" + ); + + if (!$result) { + echo json_encode(['success' => false, 'message' => 'Query error: ' . $conn->error]); + $conn->close(); exit; + } + + $rows = []; + while ($row = $result->fetch_assoc()) $rows[] = $row; + echo json_encode(['success' => true, 'data' => $rows]); + $conn->close(); + +// ── SAVE ────────────────────────────────────────────── +} elseif ($action === 'save') { + $nama = trim($_POST['nama'] ?? ''); + $nomor_spbu = trim($_POST['nomor_spbu'] ?? ''); + $status = trim($_POST['status'] ?? ''); + $latitude = $_POST['latitude'] ?? ''; + $longitude = $_POST['longitude'] ?? ''; + + if (!$nama || !$nomor_spbu || !$status || $latitude === '' || $longitude === '') { + echo json_encode(['success' => false, 'message' => 'Semua field wajib diisi']); exit; + } + if (!in_array($status, ['buka 24 jam', 'tidak'])) { + echo json_encode(['success' => false, 'message' => 'Status tidak valid']); exit; + } + + $lat = (double) $latitude; + $lng = (double) $longitude; + + $conn = getConnection(); + $stmt = $conn->prepare("INSERT INTO spbu (nama, nomor_spbu, status, latitude, longitude) VALUES (?,?,?,?,?)"); + if (!$stmt) { echo json_encode(['success' => false, 'message' => 'Prepare error: ' . $conn->error]); exit; } + $stmt->bind_param('sssdd', $nama, $nomor_spbu, $status, $lat, $lng); + if ($stmt->execute()) { + echo json_encode(['success' => true, 'message' => 'SPBU berhasil disimpan', 'id' => $stmt->insert_id]); + } else { + echo json_encode(['success' => false, 'message' => 'Execute error: ' . $conn->error]); + } + $stmt->close(); $conn->close(); + +// ── UPDATE ──────────────────────────────────────────── +} elseif ($action === 'update') { + $id = (int) ($_POST['id'] ?? 0); + $nama = trim($_POST['nama'] ?? ''); + $nomor_spbu = trim($_POST['nomor_spbu'] ?? ''); + $status = trim($_POST['status'] ?? ''); + $latitude = $_POST['latitude'] ?? ''; + $longitude = $_POST['longitude'] ?? ''; + + if (!$id || !$nama || !$nomor_spbu || !$status || $latitude === '' || $longitude === '') { + echo json_encode(['success' => false, 'message' => 'Semua field wajib diisi']); exit; + } + + $lat = (double) $latitude; + $lng = (double) $longitude; + + $conn = getConnection(); + $stmt = $conn->prepare("UPDATE spbu SET nama=?, nomor_spbu=?, status=?, latitude=?, longitude=? WHERE id=?"); + if (!$stmt) { echo json_encode(['success' => false, 'message' => 'Prepare error: ' . $conn->error]); exit; } + $stmt->bind_param('sssddi', $nama, $nomor_spbu, $status, $lat, $lng, $id); + if ($stmt->execute()) { + echo json_encode(['success' => true, 'message' => 'Data berhasil diupdate']); + } else { + echo json_encode(['success' => false, 'message' => 'Execute error: ' . $conn->error]); + } + $stmt->close(); $conn->close(); + +// ── UPDATE KOORDINAT SAJA ───────────────────────────── +} elseif ($action === 'update_coords') { + $id = (int) ($_POST['id'] ?? 0); + $latitude = $_POST['latitude'] ?? ''; + $longitude = $_POST['longitude'] ?? ''; + + if (!$id || $latitude === '' || $longitude === '') { + echo json_encode(['success' => false, 'message' => 'Data tidak lengkap']); exit; + } + + $lat = (double) $latitude; + $lng = (double) $longitude; + + $conn = getConnection(); + $stmt = $conn->prepare("UPDATE spbu SET latitude=?, longitude=? WHERE id=?"); + if (!$stmt) { echo json_encode(['success' => false, 'message' => 'Prepare error: ' . $conn->error]); exit; } + $stmt->bind_param('ddi', $lat, $lng, $id); + if ($stmt->execute()) { + echo json_encode(['success' => true, 'message' => 'Koordinat berhasil diupdate']); + } else { + echo json_encode(['success' => false, 'message' => 'Execute error: ' . $conn->error]); + } + $stmt->close(); $conn->close(); + +// ── DELETE ──────────────────────────────────────────── +} elseif ($action === 'delete') { + $id = (int) ($_POST['id'] ?? 0); + if (!$id) { echo json_encode(['success' => false, 'message' => 'ID tidak valid']); exit; } + + $conn = getConnection(); + $stmt = $conn->prepare("DELETE FROM spbu WHERE id=?"); + if (!$stmt) { echo json_encode(['success' => false, 'message' => 'Prepare error: ' . $conn->error]); exit; } + $stmt->bind_param('i', $id); + if ($stmt->execute()) { + echo json_encode(['success' => true, 'message' => 'Data berhasil dihapus']); + } else { + echo json_encode(['success' => false, 'message' => 'Execute error: ' . $conn->error]); + } + $stmt->close(); $conn->close(); + +// ── CEK KONEKSI (debug) ─────────────────────────────── +} elseif ($action === 'ping') { + $conn = getConnection(); + echo json_encode(['success' => true, 'message' => 'Koneksi OK', 'db' => DB_NAME]); + $conn->close(); + +} else { + echo json_encode(['success' => false, 'message' => 'Action tidak dikenali: ' . $action]); +} +?> \ No newline at end of file diff --git a/spbu/config.php b/spbu/config.php new file mode 100644 index 0000000..45dbd75 --- /dev/null +++ b/spbu/config.php @@ -0,0 +1,20 @@ +set_charset('utf8mb4'); + if ($conn->connect_error) { + http_response_code(500); + die(json_encode(['success' => false, 'message' => 'Koneksi gagal: ' . $conn->connect_error])); + } + return $conn; +} +?> diff --git a/spbu/database.sql b/spbu/database.sql new file mode 100644 index 0000000..5a7a7dd --- /dev/null +++ b/spbu/database.sql @@ -0,0 +1,23 @@ +-- ============================================ +-- WebGIS SPBU β€” Database Setup +-- ============================================ + +CREATE DATABASE IF NOT EXISTS webgis_spbu CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE webgis_spbu; + +CREATE TABLE IF NOT EXISTS spbu ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama VARCHAR(255) NOT NULL, + nomor_spbu VARCHAR(100) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'tidak', + latitude DOUBLE NOT NULL, + longitude DOUBLE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Contoh data awal +INSERT INTO spbu (nama, nomor_spbu, status, latitude, longitude) VALUES +('SPBU Pontianak Kota', '64.761.01', 'buka 24 jam', -0.0263, 109.3425), +('SPBU Sungai Raya', '64.761.02', 'tidak', -0.0512, 109.3610), +('SPBU Ahmad Yani', '64.761.03', 'buka 24 jam', -0.0198, 109.3580); diff --git a/spbu/index.php b/spbu/index.php new file mode 100644 index 0000000..ea53aca --- /dev/null +++ b/spbu/index.php @@ -0,0 +1,1393 @@ + + + + + + WebGIS SPBU β€” Manajemen Lokasi + + + + + + + + + + + + +
+
+
β›½
+
+
WebGIS SPBU
+
Manajemen Lokasi SPBU
+
+
+
+
+
+ Buka 24 Jam +
+
+
+ Tidak 24 Jam +
+
+
+ + +
+ + + + + +
+
+
+ Mode input aktif β€” klik peta untuk memilih lokasi +
+
+ +
+ + +
+ +
+ + +
+
+
+ +
+
Hapus Data SPBU?
+
Data ini akan dihapus permanen dan tidak dapat dikembalikan.
+
+ + +
+
+
+ + +
+ + +
+ + + + + diff --git a/spbu/show_layer.php b/spbu/show_layer.php new file mode 100644 index 0000000..410f27b --- /dev/null +++ b/spbu/show_layer.php @@ -0,0 +1,1177 @@ + + + + + + WebGIS SPBU β€” Layer Groups Control + + + + + + + + + + +
+
+
β›½
+
+
WEBGIS SPBU
+
Layer Groups & Control
+
+
+
+
+
+ 0 Buka 24 Jam +  Β·  +
+ 0 Tidak 24 Jam +
+ βž• Tambah SPBU +
+
+ + +
+ + + + + +
+
+
+ +
+ + +
+ +
+ + +
+
+
πŸ—‘οΈ
+
Hapus Data SPBU?
+
Data ini akan dihapus permanen.
+
+ + +
+
+
+ + +
+ + +
+ + + + +