commit 48f20aabedafc1afe60d612beb4b9b7a4a250094 Author: Syariffullah Date: Thu Jun 11 13:11:51 2026 +0700 initialize diff --git a/db_connect.php b/db_connect.php new file mode 100644 index 0000000..a3922ed --- /dev/null +++ b/db_connect.php @@ -0,0 +1,17 @@ +connect_error) { + die("Koneksi gagal: " . $conn->connect_error); +} +// Set charset +$conn->set_charset("utf8mb4"); + +// Jika di-include oleh file API, biarkan $conn tersedia +?> diff --git a/draw_control.js b/draw_control.js new file mode 100644 index 0000000..1304ae5 --- /dev/null +++ b/draw_control.js @@ -0,0 +1,141 @@ +// --- Setup Leaflet Draw --- + +// Layer terpisah untuk hasil gambar sementara sebelum disimpan +const drawnItems = new L.FeatureGroup(); +map.addLayer(drawnItems); + +const drawControl = new L.Control.Draw({ + draw: { + polygon: { + allowIntersection: false, + showArea: true + }, + polyline: { + metric: true + }, + circle: false, + circlemarker: false, + marker: false, // Marker default dimatikan, kita pakai klik peta untuk SPBU + rectangle: false + }, + edit: { + featureGroup: drawnItems, // Kita tidak mengedit di drawnItems, karena data aslinya di jalanLayer/parsilLayer + edit: false, + remove: false + } +}); +// Hapus map.addControl(drawControl); agar tidak tampil UI bawaan LeafletJS + + +// Flag untuk menghindari munculnya form SPBU saat sedang menggambar +map.on(L.Draw.Event.DRAWSTART, function (e) { + isDrawingMode = true; +}); + +map.on(L.Draw.Event.DRAWSTOP, function (e) { + setTimeout(() => { + isDrawingMode = false; + window.currentDrawMode = null; + window.activeDrawHandler = null; + window.deactivateAddMode(); + }, 200); +}); + +window.activateDraw = function(type) { + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + if (!isAdmin) return; + + console.log("-> activateDraw dipanggil untuk tipe:", type); + + // Toggle: jika mode yang sama ditekan lagi, batalkan + if (window.currentDrawMode === type) { + console.log("Mode", type, "sudah aktif, membatalkan..."); + window.deactivateAddMode(); + return; + } + + window.deactivateAddMode(); + window.currentDrawMode = type; + console.log("Mengaktifkan mode menggambar:", type); + + if (type === 'polyline') { + const handler = new L.Draw.Polyline(map, drawControl.options.draw.polyline); + handler.enable(); + window.activeDrawHandler = handler; + if (window.cursorTooltip) window.cursorTooltip.textContent = 'Klik untuk menggambar Jalan'; + } else if (type === 'polygon') { + const handler = new L.Draw.Polygon(map, drawControl.options.draw.polygon); + handler.enable(); + window.activeDrawHandler = handler; + if (window.cursorTooltip) window.cursorTooltip.textContent = 'Klik untuk menggambar Parsil'; + } +}; + +map.on(L.Draw.Event.CREATED, function (e) { + const type = e.layerType; + const layer = e.layer; + + // Convert layer to GeoJSON geometry + const geoJson = layer.toGeoJSON().geometry; + const geoJsonStr = JSON.stringify(geoJson); + + if (type === 'polyline') { + let length = 0; + const latlngs = layer.getLatLngs(); + for (let i = 0; i < latlngs.length - 1; i++) { + length += latlngs[i].distanceTo(latlngs[i + 1]); + } + + // Gunakan Modal alih-alih prompt + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + openModal("Tambah Jalan Baru", bodyHTML, function() { + window.saveNewJalan(geoJsonStr, length, 'modalJalanNama', 'modalJalanStatus'); + }); + + } + else if (type === 'polygon') { + const latlngs = layer.getLatLngs()[0]; + let area = 0; + if (L.GeometryUtil && L.GeometryUtil.geodesicArea) { + area = L.GeometryUtil.geodesicArea(latlngs); + } + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + openModal("Tambah Parsil Tanah", bodyHTML, function() { + window.saveNewParsil(geoJsonStr, area, 'modalParsilNama', 'modalParsilStatus'); + }); + } +}); diff --git a/geojson.js b/geojson.js new file mode 100644 index 0000000..7c4fab9 --- /dev/null +++ b/geojson.js @@ -0,0 +1,181 @@ +// --- Fitur Import GeoJSON --- + +const fileGeoJson = document.getElementById('fileGeoJson'); +let geoJsonLayers = {}; +let geoJsonCounter = 0; + +window.toggleGeoJsonMenu = function() { + const list = document.getElementById('geoJsonFileList'); + const icon = document.getElementById('geoJsonToggleIcon'); + if (list.style.display === 'none') { + list.style.display = 'block'; + icon.className = 'fas fa-minus'; + } else { + list.style.display = 'none'; + icon.className = 'fas fa-plus'; + } +}; + +// Cek apakah fitur GeoJSON ini terlihat seperti data penduduk miskin +function isMiskinFeature(feature) { + if (!feature || !feature.geometry || feature.geometry.type !== 'Point') return false; + const props = feature.properties || {}; + const keys = Object.keys(props).map(k => k.toLowerCase()); + // Dianggap data miskin jika punya properti nama (dan bukan ibadah atau spbu) + const hasNama = keys.some(k => ['nama', 'name', 'penduduk'].includes(k)); + const isIbadah = keys.some(k => ['jenis', 'radius', 'alamat'].includes(k)); + const isSpbu = keys.some(k => ['alamat_spbu', 'is_24_jam'].includes(k)); + return hasNama && !isIbadah && !isSpbu; +} + +fileGeoJson.addEventListener('change', function(e) { + const file = e.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = function(event) { + try { + const geoJsonData = JSON.parse(event.target.result); + const fileName = file.name; + + // Pisahkan fitur menjadi dua kelompok: miskin dan non-miskin + const features = geoJsonData.type === 'FeatureCollection' + ? geoJsonData.features + : [geoJsonData]; + + const miskinFeatures = features.filter(f => isMiskinFeature(f)); + const otherFeatures = features.filter(f => !isMiskinFeature(f)); + + // ---- Proses fitur miskin: Simpan ke DB, lalu tampilkan seperti penduduk miskin ---- + if (miskinFeatures.length > 0) { + const bulkData = miskinFeatures.map(f => { + const props = f.properties || {}; + const coords = f.geometry.coordinates; + return { + nama: props.nama || props.name || props.penduduk || 'Data Impor', + kategori_bantuan: props.kategori_bantuan || props.kategori || props.bantuan || 'Makan', + jumlah_jiwa: parseInt(props.jumlah_jiwa || props.jumlah || props.jiwa || 1, 10) || 1, + lat: parseFloat(coords[1]), + lng: parseFloat(coords[0]) + }; + }); + + fetch('api/penduduk_miskin/bulk_create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(bulkData) + }) + .then(res => res.json()) + .then(data => { + if (data.status === 'success') { + // Reload layer penduduk miskin agar marker tampil dengan edit/hapus/log bantuan + if (typeof loadPendudukMiskin === 'function') { + loadPendudukMiskin(); + } + if (typeof window.refreshActivePanel === 'function') { + window.refreshActivePanel(); + } + showToast(data.message || 'Data berhasil diimpor ke layer Penduduk Miskin.', 'success'); + } else { + showToast(data.message || 'Gagal menyimpan data miskin.', 'error'); + } + }) + .catch(err => { + console.error(err); + showToast('Gagal terhubung ke server saat menyimpan data miskin.', 'error'); + }); + } + + // ---- Proses fitur non-miskin: Render sebagai layer GeoJSON biasa ---- + if (otherFeatures.length > 0) { + const layerId = 'gj_' + (++geoJsonCounter); + const individualLayer = L.featureGroup(); + + const otherGeoJson = { + type: 'FeatureCollection', + features: otherFeatures + }; + + L.geoJSON(otherGeoJson, { + pointToLayer: function(feature, latlng) { + const props = feature.properties || {}; + let emoji = props.emoji; + + if (!emoji) { + const text = JSON.stringify(props).toLowerCase(); + if (text.includes('spbu')) emoji = 'โ›ฝ'; + else if (text.includes('masjid')) emoji = '๐Ÿ•Œ'; + else if (text.includes('gereja')) emoji = 'โ›ช'; + else if (text.includes('vihara')) emoji = '๐Ÿชท'; + else if (text.includes('pura')) emoji = '๐Ÿ›•'; + else if (text.includes('kelenteng')) emoji = '๐Ÿฎ'; + else emoji = '๐Ÿ“'; + } + + let cls = 'miskin-out'; + if (emoji === 'โ›ฝ') cls = 'spbu-24'; + else if (['๐Ÿ•Œ','โ›ช','๐Ÿ›•','๐Ÿชท','๐Ÿฎ'].includes(emoji)) cls = 'ibadah'; + + const icon = L.divIcon({ + className: '', + html: `
${emoji}
`, + iconSize: [38, 38], + iconAnchor: [19, 38], + popupAnchor: [0, -40] + }); + + return L.marker(latlng, { icon: icon }); + }, + onEachFeature: function(feature, layer) { + if (feature.properties) { + let popupContent = '

Informasi Feature

'; + layer.bindPopup(popupContent); + } + }, + style: function(feature) { + return { + color: (feature.properties && feature.properties.color) || '#3388ff', + weight: 2, + fillOpacity: 0.4 + }; + } + }).addTo(individualLayer); + + individualLayer.addTo(map); + geoJsonLayers[layerId] = individualLayer; + + // Tambahkan checkbox ke UI + const container = document.getElementById('geoJsonLayersContainer'); + const label = document.createElement('label'); + label.className = 'layer-option'; + label.innerHTML = ` ${fileName}`; + + label.querySelector('input').addEventListener('change', function(e) { + if (e.target.checked) { + map.addLayer(geoJsonLayers[layerId]); + } else { + map.removeLayer(geoJsonLayers[layerId]); + } + }); + + container.appendChild(label); + + if (miskinFeatures.length === 0) { + showToast('File GeoJSON berhasil dimuat!', 'success'); + } + } + + // Reset input file agar bisa import file yang sama + fileGeoJson.value = ''; + + } catch (error) { + showToast('Gagal memproses file GeoJSON. Pastikan format valid.', 'error'); + console.error(error); + } + }; + reader.readAsText(file); +}); diff --git a/geolocation.js b/geolocation.js new file mode 100644 index 0000000..aa6179b --- /dev/null +++ b/geolocation.js @@ -0,0 +1,30 @@ +// --- Fitur Geolocation --- + +// Tambahkan tombol di dalam wadah zoom control +const geoBtn = document.createElement('button'); +geoBtn.className = 'custom-layer-btn'; +geoBtn.style.position = 'relative'; +geoBtn.style.top = '0'; +geoBtn.style.left = '0'; +geoBtn.style.right = 'auto'; +geoBtn.innerHTML = ''; +geoBtn.title = 'Lokasi Saya'; +document.querySelector('.custom-zoom-control').appendChild(geoBtn); + +let userMarker = null; + +geoBtn.addEventListener('click', function() { + map.locate({setView: true, maxZoom: 16}); +}); + +map.on('locationfound', function(e) { + if (userMarker) { + map.removeLayer(userMarker); + } + userMarker = L.marker(e.latlng).addTo(map) + .bindPopup("Anda berada di sini!").openPopup(); +}); + +map.on('locationerror', function(e) { + alert("Gagal mendapatkan lokasi Anda."); +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..6a28344 --- /dev/null +++ b/index.html @@ -0,0 +1,1098 @@ + + + + + + WebGIS โ€” Sistem Informasi Geografis | Portofolio Tugas + + + + + + + + + + + + + + + + +
+
+
+ + Sistem Informasi Geografis +
+ +

+ Portofolio Proyek
+ WebGIS Interaktif +

+ +

+ Kumpulan tugas pemetaan berbasis web menggunakan teknologi geospasial modern โ€” + dari peta jalan, persil, hingga visualisasi data sosial. +

+ +
+
+
4
+
Proyek
+
+
+
+
GIS
+
Mata Kuliah
+
+
+
+
Web
+
Platform
+
+
+ + + + Lihat Semua Proyek + + +
+
+ Scroll +
+
+
+ + +
+
+

Teknologi yang Digunakan

+
+
+ + Leaflet.js +
+
+ + PHP +
+
+ + MySQL +
+
+ + HTML / CSS / JS +
+
+ + GeoJSON +
+
+ + XAMPP +
+
+
+
+ + +
+ +
+ + + + + + + + diff --git a/jalan.js b/jalan.js new file mode 100644 index 0000000..d19623b --- /dev/null +++ b/jalan.js @@ -0,0 +1,172 @@ +// --- Fitur Jalan --- + +const jalanColors = { + 'Nasional': '#ff0000', // Merah + 'Provinsi': '#0000ff', // Biru + 'Kabupaten': '#00ff00' // Hijau +}; + +function loadJalan() { + jalanLayer.clearLayers(); + fetch('api/jalan/read.php') + .then(res => res.json()) + .then(data => { + if (data.status === 'success' && data.data) { + data.data.forEach(item => { + addJalanToMap(item); + }); + } + if (window.refreshActivePanel) window.refreshActivePanel(); + }); +} + +function addJalanToMap(item) { + // geom adalah GeoJSON {type: "LineString", coordinates: [[lng, lat], ...]} + const latlngs = item.geom.coordinates.map(coord => [coord[1], coord[0]]); + + const polyline = L.polyline(latlngs, { + color: jalanColors[item.status] || '#3388ff', + weight: 5 + }); + + polyline.jalanData = item; + + // Tampilkan label nama jalan sejajar dengan garis (diagonal) + polyline.setText(item.nama, { + center: true, + offset: 0, + attributes: { + fill: '#000000', + 'font-weight': 'bold', + 'font-size': '14px', + 'dy': '7' + } + }); + + // Hitung popupContent sekali saat jalan dibuat + const d = item; + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + + const popupContent = ` +
+

Jalan ${d.nama}

+

Status: ${d.status}

+

Panjang: ${d.panjang.toFixed(2)} m

+ ${actionButtons} +
+ `; + + polyline.bindPopup(popupContent); + + jalanLayer.addLayer(polyline); +} + +window.openEditJalanModal = function(id) { + let d = null; + jalanLayer.eachLayer(function(layer) { + if (layer.jalanData && layer.jalanData.id == id) d = layer.jalanData; + }); + if (!d) return; + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + map.closePopup(); + openModal("Edit Jalan", bodyHTML, function() { + window.saveEditJalan(d.id, 'editJalanNama', 'editJalanStatus'); + }); +}; + +window.saveEditJalan = function(id, namaId, statusId) { + const nama = document.getElementById(namaId).value; + const status = document.getElementById(statusId).value; + + fetch('api/jalan/update.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, nama, status }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + closeModal(); + loadJalan(); + } else { + alert(data.message); + } + }); +}; + +window.deleteJalan = function(id) { + openConfirmModal("Yakin hapus jalan ini?", function() { + fetch('api/jalan/delete.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + loadJalan(); + } else { + alert(data.message); + } + }); + }); +}; + +window.saveNewJalan = function(geoJsonStr, panjang, namaId, statusId) { + const nama = document.getElementById(namaId).value; + if (!nama) { + alert("Nama jalan harus diisi!"); + return false; + } + + const status = document.getElementById(statusId).value; + + const geom = JSON.parse(geoJsonStr); + + fetch('api/jalan/create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nama, status, panjang, geom }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + closeModal(); + loadJalan(); + } else { + alert(data.message); + } + }); + return true; +}; + +// Initial Load +loadJalan(); diff --git a/jalan/assets/css/style.css b/jalan/assets/css/style.css new file mode 100644 index 0000000..b8fd8fb --- /dev/null +++ b/jalan/assets/css/style.css @@ -0,0 +1,1041 @@ +/* Reset & Base */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body, html { + height: 100%; + font-family: 'Google Sans Flex', 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + overflow: hidden; +} + +/* Map Container */ +#map { + width: 100vw; + height: 100vh; + z-index: 1; +} + +/* Custom UI Container over Map */ +.ui-container { + position: absolute; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 15px; +} + +/* Search Bar */ +.search-bar { + display: flex; + align-items: center; + background-color: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + overflow: hidden; + height: 48px; + transition: box-shadow 0.3s; +} + +.search-bar:focus-within { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); +} + +.search-icon { + padding: 0 15px; + color: #666; + display: flex; + align-items: center; + justify-content: center; +} + +.search-input { + flex: 1; + border: none; + outline: none; + font-size: 16px; + padding: 10px 0; + color: #333; +} + +.search-clear { + padding: 0 15px; + color: #999; + cursor: pointer; + background: none; + border: none; + display: flex; + align-items: center; + justify-content: center; + visibility: hidden; +} + +.search-clear:hover { + color: #333; +} + +/* Custom Layer Control Button */ +.custom-layer-btn { + background-color: white; + border-radius: 8px; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + cursor: pointer; + border: none; + color: #333; +} + +.custom-layer-btn:hover { + background-color: #f8f9fa; +} + +#layerBtn { + position: absolute; + top: 20px; + right: 20px; + z-index: 1000; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +body.right-panel-open #layerBtn { + right: 340px; +} + +.custom-layer-panel { + position: absolute; + top: 80px; + right: 20px; + z-index: 1000; + background-color: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 15px; + width: 250px; + display: none; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +body.right-panel-open .custom-layer-panel { + right: 340px; +} + + +.custom-layer-panel h3 { + margin-bottom: 10px; + font-size: 16px; + border-bottom: 1px solid #eee; + padding-bottom: 5px; +} + +.layer-section-title { + font-size: 11px; + font-weight: 700; + color: #6366f1; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; + margin-top: 12px; + padding-bottom: 2px; + border-bottom: 1px dashed #e2e8f0; +} + +.layer-option { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; + font-size: 14px; +} + +/* ===== Sidebar Toggle Button ===== */ +.sidebar-toggle-btn { + position: absolute; + left: 20px; + top: 190px; + z-index: 1001; + border-radius: 8px; + width: 48px; + height: 48px; + background: white; + border: none; + box-shadow: 0 3px 10px rgba(0,0,0,0.15); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #6366f1; + font-size: 13px; + transition: all 0.2s; +} + +.sidebar-toggle-btn:hover { + background: #6366f1; + color: white; + box-shadow: 0 4px 14px rgba(99,102,241,0.4); +} + +.sidebar-toggle-btn.open { + background: #6366f1; + color: white; +} + +/* ===== Left Sidebar ===== */ +.left-sidebar { + position: absolute; + left: 20px; + top: 250px; /* di bawah toggle button */ + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; +} + +.sidebar-section-label { + font-size: 9px; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + text-align: center; + letter-spacing: 0.8px; + margin-top: 6px; + margin-bottom: 2px; + pointer-events: none; + user-select: none; +} + +.sidebar-divider { + height: 1px; + background: #e2e8f0; + margin: 4px 6px; +} + +.sidebar-btn { + width: 60px; + height: 60px; + background: white; + border: none; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0,0,0,0.12); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + transition: all 0.2s ease; + padding: 6px 4px; +} + +.sidebar-btn:hover { + transform: translateX(3px); + box-shadow: 0 6px 18px rgba(0,0,0,0.18); + background: #f8f9fa; +} + +.sidebar-btn.active { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + box-shadow: 0 6px 18px rgba(99,102,241,0.4); + transform: translateX(4px); +} + +.sidebar-emoji { + font-size: 22px; + line-height: 1; +} + +.sidebar-label { + font-size: 9px; + font-weight: 600; + letter-spacing: 0.3px; + color: #555; + text-transform: uppercase; +} + +.sidebar-btn.active .sidebar-label { + color: rgba(255,255,255,0.85); +} + +/* ===== Right Panel ===== */ +.right-panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 320px; + background: #f8f9fb; + z-index: 999; + box-shadow: -4px 0 20px rgba(0,0,0,0.12); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + /* shift layer control when panel open */ +} + +.right-panel.open { + transform: translateX(0); +} + +.right-panel-header { + padding: 16px 16px 12px; + background: white; + border-bottom: 1px solid #eee; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + padding-top: 26px; +} + +.right-panel-header h3 { + font-size: 15px; + font-weight: 700; + color: #1e1e2e; +} + +.right-panel-close { + width: 30px; + height: 30px; + border: none; + background: #f3f4f6; + border-radius: 8px; + cursor: pointer; + color: #555; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + transition: background 0.2s; +} + +.right-panel-close:hover { + background: #e5e7eb; + color: #333; +} + +.right-panel-actions { + padding: 12px 14px; + background: white; + border-bottom: 1px solid #eee; + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.right-panel-actions button { + flex: 1; + padding: 9px 10px; + border: none; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: all 0.2s; +} + +.btn-panel-add { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + box-shadow: 0 3px 10px rgba(99,102,241,0.3); +} + +.btn-panel-add:hover { + box-shadow: 0 5px 14px rgba(99,102,241,0.45); + transform: translateY(-1px); +} + +.btn-panel-import { + background: #f0fdf4; + color: #16a34a; + border: 1px solid #bbf7d0 !important; +} + +.btn-panel-import:hover { + background: #dcfce7; +} + +.right-panel-body { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Panel Section Heading */ +.panel-section-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #9ca3af; + padding: 4px 2px 6px; + border-bottom: 1px solid #e5e7eb; + margin-top: 4px; +} + +/* Panel Empty State */ +.panel-empty { + text-align: center; + color: #aaa; + font-size: 13px; + padding: 40px 10px; +} + +/* ===== Data Card ===== */ +.data-card { + background: white; + border-radius: 12px; + padding: 12px 12px 10px; + box-shadow: 0 1px 4px rgba(0,0,0,0.07); + transition: box-shadow 0.2s; + cursor: pointer; +} + +.data-card:hover { + box-shadow: 0 3px 12px rgba(0,0,0,0.12); +} + +.data-card-main { + display: flex; + align-items: center; + gap: 10px; +} + +.data-card-icon { + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.card-icon-spbu { background: #f0fdf4; } +.card-icon-jalan { background: #eff6ff; } +.card-icon-parsil { background: #fefce8; } +.card-icon-ibadah { background: #fff7ed; } +.card-icon-miskin-out { background: #f0fdf4; } +.card-icon-miskin-in { background: #fef2f2; } + +.data-card-info { + flex: 1; + min-width: 0; +} + +.data-card-name { + font-size: 13px; + font-weight: 600; + color: #1e1e2e; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.data-card-sub { + font-size: 11px; + color: #6b7280; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.data-card-badge { + font-size: 10px; + font-weight: 600; + padding: 2px 7px; + border-radius: 99px; + margin-top: 4px; + display: inline-block; +} + +.badge-green { background: #dcfce7; color: #16a34a; } +.badge-red { background: #fee2e2; color: #dc2626; } +.badge-blue { background: #dbeafe; color: #2563eb; } +.badge-orange { background: #ffedd5; color: #ea580c; } +.badge-yellow { background: #fef9c3; color: #ca8a04; } + +.data-card-actions { + display: flex; + gap: 6px; + flex-shrink: 0; + align-items: center; +} + +.btn-card-action { + width: 30px; + height: 30px; + border: none; + border-radius: 7px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: all 0.15s; +} + +.btn-card-edit { + background: #eff6ff; + color: #3b82f6; +} + +.btn-card-edit:hover { + background: #3b82f6; + color: white; +} + +.btn-card-delete { + background: #fef2f2; + color: #ef4444; +} + +.btn-card-delete:hover { + background: #ef4444; + color: white; +} + +/* Expandable card (miskin) */ +.data-card-expand { + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease, padding 0.2s; + border-top: 0px solid #f3f4f6; +} + +.data-card.expanded .data-card-expand { + max-height: 400px; + border-top: 1px solid #f3f4f6; + margin-top: 10px; + padding-top: 10px; +} + +.expand-row { + display: flex; + justify-content: space-between; + font-size: 12px; + color: #6b7280; + padding: 3px 0; +} + +.expand-row span:first-child { + font-weight: 500; + color: #374151; +} + +.btn-log-bantuan { + margin-top: 10px; + width: 100%; + padding: 8px; + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + border: none; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: all 0.2s; +} + +.btn-log-bantuan:hover { + box-shadow: 0 4px 12px rgba(99,102,241,0.4); +} + +/* Log Bantuan Item */ +.log-item { + background: #f8f9fb; + border-radius: 8px; + padding: 10px 12px; + display: flex; + gap: 10px; + align-items: flex-start; +} + +.log-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #6366f1; + flex-shrink: 0; + margin-top: 5px; +} + +.log-info { + flex: 1; +} + +.log-type { + font-size: 12px; + font-weight: 600; + color: #1e1e2e; +} + +.log-meta { + font-size: 11px; + color: #9ca3af; + margin-top: 2px; +} + +.log-keterangan { + font-size: 11px; + color: #6b7280; + margin-top: 3px; + font-style: italic; +} + +/* Popup Form */ +.popup-form { + display: flex; + flex-direction: column; + gap: 10px; + width: 200px; +} + +.popup-form label { + font-size: 12px; + font-weight: bold; + color: #555; +} + +.popup-form input[type="text"], +.popup-form input[type="number"] { + width: 100%; + padding: 6px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.popup-form .radio-group { + display: flex; + gap: 10px; +} + +.popup-form button { + padding: 8px; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; +} + +.popup-form button.btn-danger { + background-color: #dc3545; +} + +.popup-form button:hover { + opacity: 0.9; +} + +/* Unified Modal */ +.unified-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); + z-index: 2000; + align-items: center; + justify-content: center; +} + +.unified-modal.show { + display: flex; +} + +.modal-content { + background-color: white; + border-radius: 12px; + width: 400px; + max-width: 90%; + box-shadow: 0 8px 30px rgba(0,0,0,0.2); + display: flex; + flex-direction: column; +} + +.modal-header { + padding: 15px; + border-bottom: 1px solid #eee; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + font-size: 16px; + margin: 0; +} + +.modal-close { + font-size: 20px; + font-weight: bold; + cursor: pointer; + color: #888; +} + +.modal-close:hover { + color: #333; +} + +.modal-body { + padding: 15px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.modal-body label { + font-size: 13px; + font-weight: 600; + color: #444; +} + +.modal-body input[type="text"], +.modal-body input[type="number"], +.modal-body input[type="date"], +.modal-body select, +.modal-body textarea { + width: 100%; + padding: 8px; + border: 1px solid #ccc; + border-radius: 6px; + font-size: 14px; + font-family: inherit; +} + +.modal-footer { + padding: 15px; + border-top: 1px solid #eee; + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.btn-save { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + border: none; + padding: 8px 18px; + border-radius: 7px; + cursor: pointer; + font-weight: 600; + font-size: 13px; +} + +.btn-cancel { + background-color: #f8f9fa; + border: 1px solid #ddd; + padding: 8px 18px; + border-radius: 7px; + cursor: pointer; + font-size: 13px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Custom Cursor Tooltip */ +.custom-cursor-tooltip { + position: absolute; + display: none; + background-color: rgba(0, 0, 0, 0.75); + color: white; + padding: 6px 12px; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + pointer-events: none; + z-index: 9999; + white-space: nowrap; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); +} + +/* Sembunyikan default tooltip Leaflet Draw */ +.leaflet-draw-tooltip { + display: none !important; +} + +/* Sub-layer collapsible */ +.layer-group-item { + margin-bottom: 6px; +} + +.layer-group-item > .layer-group-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.layer-toggle-icon { + cursor: pointer; + padding: 2px 6px; + color: #666; + font-size: 11px; + border-radius: 4px; + transition: background 0.2s; + flex-shrink: 0; +} + +.layer-toggle-icon:hover { + background: #f0f0f0; + color: #333; +} + +.layer-toggle-icon i { + transition: transform 0.2s; +} + +.layer-toggle-icon.collapsed i { + transform: rotate(-90deg); +} + +.sub-layer-list { + padding-left: 18px; + margin-top: 4px; + border-left: 2px solid #eee; + overflow: hidden; + transition: max-height 0.2s ease; +} + +.sub-option { + font-size: 12px !important; + color: #555; + margin-bottom: 3px !important; +} + +/* ===== Emoji Marker (Pin Bubble) ===== */ +.emoji-marker { + display: flex; + flex-direction: column; + align-items: center; + pointer-events: auto; /* changed from none to allow click/drag */ +} + +.emoji-marker .bubble { + width: 38px; + height: 38px; + border-radius: 50% 50% 50% 0; + transform: rotate(-45deg); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 3px 10px rgba(0,0,0,0.25); + border: 2px solid rgba(255,255,255,0.8); + animation: markerPop 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275) both; +} + +.emoji-marker .bubble span { + transform: rotate(45deg); + font-size: 18px; + line-height: 1; +} + +@keyframes markerPop { + 0% { transform: rotate(-45deg) scale(0); } + 100% { transform: rotate(-45deg) scale(1); } +} + +/* Warna bubble per tipe */ +.emoji-marker .bubble.spbu-24 { background: linear-gradient(135deg, #22c55e, #16a34a); } +.emoji-marker .bubble.spbu-not24 { background: linear-gradient(135deg, #ef4444, #dc2626); } +.emoji-marker .bubble.ibadah { background: linear-gradient(135deg, #f97316, #ea580c); } +.emoji-marker .bubble.miskin-in { background: linear-gradient(135deg, #ef4444, #dc2626); } +.emoji-marker .bubble.miskin-out { background: linear-gradient(135deg, #22c55e, #16a34a); } + +/* ===== Toast Notification ===== */ +#toastContainer { + position: fixed; + bottom: 28px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + border-radius: 12px; + font-size: 13px; + font-weight: 500; + color: white; + box-shadow: 0 6px 24px rgba(0,0,0,0.18); + backdrop-filter: blur(6px); + pointer-events: auto; + animation: toastSlideIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) both; + max-width: 340px; + text-align: center; +} + +.toast.hiding { + animation: toastSlideOut 0.3s ease forwards; +} + +.toast-success { background: linear-gradient(135deg, #22c55e, #16a34a); } +.toast-error { background: linear-gradient(135deg, #ef4444, #dc2626); } +.toast-info { background: linear-gradient(135deg, #6366f1, #4f46e5); } +.toast-warning { background: linear-gradient(135deg, #f59e0b, #d97706); } + +.toast-icon { font-size: 16px; flex-shrink: 0; } + +@keyframes toastSlideIn { + 0% { opacity: 0; transform: translateY(20px) scale(0.9); } + 100% { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes toastSlideOut { + 0% { opacity: 1; transform: translateY(0) scale(1); } + 100% { opacity: 0; transform: translateY(10px) scale(0.95); } +} + +/* Auth Widget Styling */ +.auth-widget { + position: absolute; + top: 20px; + right: 80px; + z-index: 1000; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +body.right-panel-open .auth-widget { + right: 400px; +} + +.btn-login { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + border: none; + border-radius: 24px; + height: 48px; + padding: 0 24px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; +} + +.btn-login:hover { + box-shadow: 0 6px 16px rgba(99, 102, 241, 0.45); + transform: translateY(-1px); +} + +.user-profile-pill { + background-color: white; + border-radius: 24px; + height: 48px; + padding: 0 8px 0 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: flex; + align-items: center; + gap: 12px; + border: 1px solid rgba(226, 232, 240, 0.8); + backdrop-filter: blur(10px); + background: rgba(255, 255, 255, 0.9); +} + +.user-profile-info { + display: flex; + flex-direction: column; +} + +.user-profile-name { + font-size: 12px; + font-weight: 700; + color: #1e1e2e; +} + +.user-profile-role { + font-size: 9px; + font-weight: 600; + color: #6366f1; + text-transform: uppercase; +} + +.btn-profile-logout { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: #f1f5f9; + color: #64748b; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.btn-profile-logout:hover { + background: #fee2e2; + color: #ef4444; +} + +/* User management table styling */ +#userManagementModal table th { + padding: 10px 12px; + font-weight: 600; + background-color: #f1f5f9; + border-bottom: 1px solid #cbd5e1; +} + +#userManagementModal table td { + padding: 10px 12px; + border-bottom: 1px solid #e2e8f0; +} + + + +/* ============================================================= + JALAN — Specific Styles + ============================================================= */ + +.card-icon-jalan { background: #eff6ff; } + +.legend-jalan-nasional { display:inline-block; width:28px; height:4px; background:#ff0000; border-radius:2px; } +.legend-jalan-provinsi { display:inline-block; width:28px; height:4px; background:#0000ff; border-radius:2px; } +.legend-jalan-kabupaten { display:inline-block; width:28px; height:4px; background:#00bb00; border-radius:2px; } + +.badge-nasional { background:#fee2e2; color:#dc2626; } +.badge-provinsi { background:#dbeafe; color:#2563eb; } +.badge-kabupaten { background:#dcfce7; color:#16a34a; } diff --git a/jalan/assets/js/features/draw_control.js b/jalan/assets/js/features/draw_control.js new file mode 100644 index 0000000..a1e7026 --- /dev/null +++ b/jalan/assets/js/features/draw_control.js @@ -0,0 +1,142 @@ +// --- Setup Leaflet Draw --- + +// Layer terpisah untuk hasil gambar sementara sebelum disimpan +const drawnItems = new L.FeatureGroup(); +map.addLayer(drawnItems); + +const drawControl = new L.Control.Draw({ + draw: { + polygon: { + allowIntersection: false, + showArea: true + }, + polyline: { + metric: true + }, + circle: false, + circlemarker: false, + marker: false, // Marker default dimatikan, kita pakai klik peta untuk SPBU + rectangle: false + }, + edit: { + featureGroup: drawnItems, // Kita tidak mengedit di drawnItems, karena data aslinya di jalanLayer/parsilLayer + edit: false, + remove: false + } +}); +// Hapus map.addControl(drawControl); agar tidak tampil UI bawaan LeafletJS + + +// Flag untuk menghindari munculnya form SPBU saat sedang menggambar +map.on(L.Draw.Event.DRAWSTART, function (e) { + isDrawingMode = true; +}); + +map.on(L.Draw.Event.DRAWSTOP, function (e) { + setTimeout(() => { + isDrawingMode = false; + window.currentDrawMode = null; + window.activeDrawHandler = null; + window.deactivateAddMode(); + }, 200); +}); + +window.activateDraw = function(type) { + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + if (!isAdmin) return; + + console.log("-> activateDraw dipanggil untuk tipe:", type); + + // Toggle: jika mode yang sama ditekan lagi, batalkan + if (window.currentDrawMode === type) { + console.log("Mode", type, "sudah aktif, membatalkan..."); + window.deactivateAddMode(); + return; + } + + window.deactivateAddMode(); + window.currentDrawMode = type; + console.log("Mengaktifkan mode menggambar:", type); + + if (type === 'polyline') { + const handler = new L.Draw.Polyline(map, drawControl.options.draw.polyline); + handler.enable(); + window.activeDrawHandler = handler; + if (window.cursorTooltip) window.cursorTooltip.textContent = 'Klik untuk menggambar Jalan'; + } else if (type === 'polygon') { + const handler = new L.Draw.Polygon(map, drawControl.options.draw.polygon); + handler.enable(); + window.activeDrawHandler = handler; + if (window.cursorTooltip) window.cursorTooltip.textContent = 'Klik untuk menggambar Parsil'; + } +}; + +map.on(L.Draw.Event.CREATED, function (e) { + const type = e.layerType; + const layer = e.layer; + + // Convert layer to GeoJSON geometry + const geoJson = layer.toGeoJSON().geometry; + const geoJsonStr = JSON.stringify(geoJson); + + if (type === 'polyline') { + let length = 0; + const latlngs = layer.getLatLngs(); + for (let i = 0; i < latlngs.length - 1; i++) { + length += latlngs[i].distanceTo(latlngs[i + 1]); + } + + // Gunakan Modal alih-alih prompt + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + openModal("Tambah Jalan Baru", bodyHTML, function() { + window.saveNewJalan(geoJsonStr, length, 'modalJalanNama', 'modalJalanStatus'); + }); + + } + else if (type === 'polygon') { + const latlngs = layer.getLatLngs()[0]; + let area = 0; + if (L.GeometryUtil && L.GeometryUtil.geodesicArea) { + area = L.GeometryUtil.geodesicArea(latlngs); + } + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + openModal("Tambah Parsil Tanah", bodyHTML, function() { + window.saveNewParsil(geoJsonStr, area, 'modalParsilNama', 'modalParsilStatus'); + }); + } +}); + diff --git a/jalan/assets/js/features/geojson.js b/jalan/assets/js/features/geojson.js new file mode 100644 index 0000000..62fc314 --- /dev/null +++ b/jalan/assets/js/features/geojson.js @@ -0,0 +1,182 @@ +// --- Fitur Import GeoJSON --- + +const fileGeoJson = document.getElementById('fileGeoJson'); +let geoJsonLayers = {}; +let geoJsonCounter = 0; + +window.toggleGeoJsonMenu = function() { + const list = document.getElementById('geoJsonFileList'); + const icon = document.getElementById('geoJsonToggleIcon'); + if (list.style.display === 'none') { + list.style.display = 'block'; + icon.className = 'fas fa-minus'; + } else { + list.style.display = 'none'; + icon.className = 'fas fa-plus'; + } +}; + +// Cek apakah fitur GeoJSON ini terlihat seperti data penduduk miskin +function isMiskinFeature(feature) { + if (!feature || !feature.geometry || feature.geometry.type !== 'Point') return false; + const props = feature.properties || {}; + const keys = Object.keys(props).map(k => k.toLowerCase()); + // Dianggap data miskin jika punya properti nama (dan bukan ibadah atau spbu) + const hasNama = keys.some(k => ['nama', 'name', 'penduduk'].includes(k)); + const isIbadah = keys.some(k => ['jenis', 'radius', 'alamat'].includes(k)); + const isSpbu = keys.some(k => ['alamat_spbu', 'is_24_jam'].includes(k)); + return hasNama && !isIbadah && !isSpbu; +} + +fileGeoJson.addEventListener('change', function(e) { + const file = e.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = function(event) { + try { + const geoJsonData = JSON.parse(event.target.result); + const fileName = file.name; + + // Pisahkan fitur menjadi dua kelompok: miskin dan non-miskin + const features = geoJsonData.type === 'FeatureCollection' + ? geoJsonData.features + : [geoJsonData]; + + const miskinFeatures = features.filter(f => isMiskinFeature(f)); + const otherFeatures = features.filter(f => !isMiskinFeature(f)); + + // ---- Proses fitur miskin: Simpan ke DB, lalu tampilkan seperti penduduk miskin ---- + if (miskinFeatures.length > 0) { + const bulkData = miskinFeatures.map(f => { + const props = f.properties || {}; + const coords = f.geometry.coordinates; + return { + nama: props.nama || props.name || props.penduduk || 'Data Impor', + kategori_bantuan: props.kategori_bantuan || props.kategori || props.bantuan || 'Makan', + jumlah_jiwa: parseInt(props.jumlah_jiwa || props.jumlah || props.jiwa || 1, 10) || 1, + lat: parseFloat(coords[1]), + lng: parseFloat(coords[0]) + }; + }); + + fetch('../poverty/api/penduduk_miskin/bulk_create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(bulkData) + }) + .then(res => res.json()) + .then(data => { + if (data.status === 'success') { + // Reload layer penduduk miskin agar marker tampil dengan edit/hapus/log bantuan + if (typeof loadPendudukMiskin === 'function') { + loadPendudukMiskin(); + } + if (typeof window.refreshActivePanel === 'function') { + window.refreshActivePanel(); + } + showToast(data.message || 'Data berhasil diimpor ke layer Penduduk Miskin.', 'success'); + } else { + showToast(data.message || 'Gagal menyimpan data miskin.', 'error'); + } + }) + .catch(err => { + console.error(err); + showToast('Gagal terhubung ke server saat menyimpan data miskin.', 'error'); + }); + } + + // ---- Proses fitur non-miskin: Render sebagai layer GeoJSON biasa ---- + if (otherFeatures.length > 0) { + const layerId = 'gj_' + (++geoJsonCounter); + const individualLayer = L.featureGroup(); + + const otherGeoJson = { + type: 'FeatureCollection', + features: otherFeatures + }; + + L.geoJSON(otherGeoJson, { + pointToLayer: function(feature, latlng) { + const props = feature.properties || {}; + let emoji = props.emoji; + + if (!emoji) { + const text = JSON.stringify(props).toLowerCase(); + if (text.includes('spbu')) emoji = 'โ›ฝ'; + else if (text.includes('masjid')) emoji = '๐Ÿ•Œ'; + else if (text.includes('gereja')) emoji = 'โ›ช'; + else if (text.includes('vihara')) emoji = '๐Ÿชท'; + else if (text.includes('pura')) emoji = '๐Ÿ›•'; + else if (text.includes('kelenteng')) emoji = '๐Ÿฎ'; + else emoji = '๐Ÿ“'; + } + + let cls = 'miskin-out'; + if (emoji === 'โ›ฝ') cls = 'spbu-24'; + else if (['๐Ÿ•Œ','โ›ช','๐Ÿ›•','๐Ÿชท','๐Ÿฎ'].includes(emoji)) cls = 'ibadah'; + + const icon = L.divIcon({ + className: '', + html: `
${emoji}
`, + iconSize: [38, 38], + iconAnchor: [19, 38], + popupAnchor: [0, -40] + }); + + return L.marker(latlng, { icon: icon }); + }, + onEachFeature: function(feature, layer) { + if (feature.properties) { + let popupContent = '

Informasi Feature

'; + layer.bindPopup(popupContent); + } + }, + style: function(feature) { + return { + color: (feature.properties && feature.properties.color) || '#3388ff', + weight: 2, + fillOpacity: 0.4 + }; + } + }).addTo(individualLayer); + + individualLayer.addTo(map); + geoJsonLayers[layerId] = individualLayer; + + // Tambahkan checkbox ke UI + const container = document.getElementById('geoJsonLayersContainer'); + const label = document.createElement('label'); + label.className = 'layer-option'; + label.innerHTML = ` ${fileName}`; + + label.querySelector('input').addEventListener('change', function(e) { + if (e.target.checked) { + map.addLayer(geoJsonLayers[layerId]); + } else { + map.removeLayer(geoJsonLayers[layerId]); + } + }); + + container.appendChild(label); + + if (miskinFeatures.length === 0) { + showToast('File GeoJSON berhasil dimuat!', 'success'); + } + } + + // Reset input file agar bisa import file yang sama + fileGeoJson.value = ''; + + } catch (error) { + showToast('Gagal memproses file GeoJSON. Pastikan format valid.', 'error'); + console.error(error); + } + }; + reader.readAsText(file); +}); + diff --git a/jalan/assets/js/features/geolocation.js b/jalan/assets/js/features/geolocation.js new file mode 100644 index 0000000..4d95a20 --- /dev/null +++ b/jalan/assets/js/features/geolocation.js @@ -0,0 +1,31 @@ +// --- Fitur Geolocation --- + +// Tambahkan tombol di dalam wadah zoom control +const geoBtn = document.createElement('button'); +geoBtn.className = 'custom-layer-btn'; +geoBtn.style.position = 'relative'; +geoBtn.style.top = '0'; +geoBtn.style.left = '0'; +geoBtn.style.right = 'auto'; +geoBtn.innerHTML = ''; +geoBtn.title = 'Lokasi Saya'; +document.querySelector('.custom-zoom-control').appendChild(geoBtn); + +let userMarker = null; + +geoBtn.addEventListener('click', function() { + map.locate({setView: true, maxZoom: 16}); +}); + +map.on('locationfound', function(e) { + if (userMarker) { + map.removeLayer(userMarker); + } + userMarker = L.marker(e.latlng).addTo(map) + .bindPopup("Anda berada di sini!").openPopup(); +}); + +map.on('locationerror', function(e) { + alert("Gagal mendapatkan lokasi Anda."); +}); + diff --git a/jalan/assets/js/features/jalan.js b/jalan/assets/js/features/jalan.js new file mode 100644 index 0000000..24a93ac --- /dev/null +++ b/jalan/assets/js/features/jalan.js @@ -0,0 +1,173 @@ +// --- Fitur Jalan --- + +const jalanColors = { + 'Nasional': '#ff0000', // Merah + 'Provinsi': '#0000ff', // Biru + 'Kabupaten': '#00ff00' // Hijau +}; + +function loadJalan() { + jalanLayer.clearLayers(); + fetch('../jalan/read.php') + .then(res => res.json()) + .then(data => { + if (data.status === 'success' && data.data) { + data.data.forEach(item => { + addJalanToMap(item); + }); + } + if (window.refreshActivePanel) window.refreshActivePanel(); + }); +} + +function addJalanToMap(item) { + // geom adalah GeoJSON {type: "LineString", coordinates: [[lng, lat], ...]} + const latlngs = item.geom.coordinates.map(coord => [coord[1], coord[0]]); + + const polyline = L.polyline(latlngs, { + color: jalanColors[item.status] || '#3388ff', + weight: 5 + }); + + polyline.jalanData = item; + + // Tampilkan label nama jalan sejajar dengan garis (diagonal) + polyline.setText(item.nama, { + center: true, + offset: 0, + attributes: { + fill: '#000000', + 'font-weight': 'bold', + 'font-size': '14px', + 'dy': '7' + } + }); + + // Hitung popupContent sekali saat jalan dibuat + const d = item; + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + + const popupContent = ` +
+

Jalan ${d.nama}

+

Status: ${d.status}

+

Panjang: ${d.panjang.toFixed(2)} m

+ ${actionButtons} +
+ `; + + polyline.bindPopup(popupContent); + + jalanLayer.addLayer(polyline); +} + +window.openEditJalanModal = function(id) { + let d = null; + jalanLayer.eachLayer(function(layer) { + if (layer.jalanData && layer.jalanData.id == id) d = layer.jalanData; + }); + if (!d) return; + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + map.closePopup(); + openModal("Edit Jalan", bodyHTML, function() { + window.saveEditJalan(d.id, 'editJalanNama', 'editJalanStatus'); + }); +}; + +window.saveEditJalan = function(id, namaId, statusId) { + const nama = document.getElementById(namaId).value; + const status = document.getElementById(statusId).value; + + fetch('../jalan/update.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, nama, status }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + closeModal(); + loadJalan(); + } else { + alert(data.message); + } + }); +}; + +window.deleteJalan = function(id) { + openConfirmModal("Yakin hapus jalan ini?", function() { + fetch('../jalan/delete.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + loadJalan(); + } else { + alert(data.message); + } + }); + }); +}; + +window.saveNewJalan = function(geoJsonStr, panjang, namaId, statusId) { + const nama = document.getElementById(namaId).value; + if (!nama) { + alert("Nama jalan harus diisi!"); + return false; + } + + const status = document.getElementById(statusId).value; + + const geom = JSON.parse(geoJsonStr); + + fetch('../jalan/create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nama, status, panjang, geom }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + closeModal(); + loadJalan(); + } else { + alert(data.message); + } + }); + return true; +}; + +// Initial Load +loadJalan(); + diff --git a/jalan/assets/js/features/parsil.js b/jalan/assets/js/features/parsil.js new file mode 100644 index 0000000..d8509da --- /dev/null +++ b/jalan/assets/js/features/parsil.js @@ -0,0 +1,159 @@ +// --- Fitur Parsil --- + +const parsilColors = { + 'SHM': '#28a745', // Hijau + 'HGB': '#17a2b8', // Biru Muda + 'HGU': '#ffc107', // Kuning + 'HP': '#fd7e14' // Oranye +}; + +function loadParsil() { + parsilLayer.clearLayers(); + fetch('../parsil/read.php') + .then(res => res.json()) + .then(data => { + if (data.status === 'success' && data.data) { + data.data.forEach(item => { + addParsilToMap(item); + }); + } + if (window.refreshActivePanel) window.refreshActivePanel(); + }); +} + +function addParsilToMap(item) { + // geom adalah GeoJSON Polygon + // coordinates pada Polygon formatnya: [[[lng, lat], [lng, lat], ...]] + // Leaflet Polygon butuh array of [lat, lng] + const latlngs = item.geom.coordinates[0].map(coord => [coord[1], coord[0]]); + + const polygon = L.polygon(latlngs, { + color: parsilColors[item.status] || '#3388ff', + fillColor: parsilColors[item.status] || '#3388ff', + fillOpacity: 0.5, + weight: 2 + }); + + polygon.parsilData = item; + + const d = item; + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + const popupContent = ` +
+

Parsil ${d.nama || ''}

+

Status: ${d.status}

+

Luas: ${d.luas.toFixed(2)} mยฒ

+ ${actionButtons} +
+ `; + polygon.bindPopup(popupContent); + + parsilLayer.addLayer(polygon); +} + +window.openEditParsilModal = function(id) { + let d = null; + parsilLayer.eachLayer(function(layer) { + if (layer.parsilData && layer.parsilData.id == id) d = layer.parsilData; + }); + if (!d) return; + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + map.closePopup(); + openModal("Edit Parsil Tanah", bodyHTML, function() { + window.saveEditParsil(d.id, 'editParsilNama', 'editParsilStatus'); + }); +}; + +window.saveEditParsil = function(id, namaId, statusId) { + const nama = document.getElementById(namaId).value; + const status = document.getElementById(statusId).value; + + fetch('../parsil/update.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, nama, status }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + closeModal(); + loadParsil(); + } else { + alert(data.message); + } + }); +}; + +window.deleteParsil = function(id) { + openConfirmModal("Yakin hapus parsil ini?", function() { + fetch('../parsil/delete.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + loadParsil(); + } else { + alert(data.message); + } + }); + }); +}; + +window.saveNewParsil = function(geoJsonStr, luas, namaId, statusId) { + const nama = document.getElementById(namaId).value; + const status = document.getElementById(statusId).value; + + const geom = JSON.parse(geoJsonStr); + + fetch('../parsil/create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nama, status, luas, geom }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + closeModal(); + loadParsil(); + } else { + alert(data.message); + } + }); + return true; +}; + +// Initial Load +loadParsil(); + diff --git a/jalan/assets/js/features/spbu.js b/jalan/assets/js/features/spbu.js new file mode 100644 index 0000000..07d168f --- /dev/null +++ b/jalan/assets/js/features/spbu.js @@ -0,0 +1,214 @@ +// --- Fitur SPBU --- + +// Emoji Bubble Icon builder +function makeSpbuIcon(is24) { + const cls = is24 ? 'spbu-24' : 'spbu-not24'; + return L.divIcon({ + className: '', + html: `
โ›ฝ
`, + iconSize: [38, 38], + iconAnchor: [19, 38], + popupAnchor: [0, -40] + }); +} + +const spbuGreenIcon = makeSpbuIcon(true); +const spbuRedIcon = makeSpbuIcon(false); + +let isDrawingMode = false; // Akan diset true saat draw mode aktif (jalan/parsil) + + +// Fetch SPBU +function loadSpbu() { + spbuLayer.clearLayers(); + fetch('../spbu/read.php') + .then(res => res.json()) + .then(data => { + if (data.status === 'success' && data.data) { + data.data.forEach(item => { + addSpbuMarker(item); + }); + } + if (window.refreshActivePanel) window.refreshActivePanel(); + }); +} + +function addSpbuMarker(item) { + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + const icon = makeSpbuIcon(item.is_24_jam); + const marker = L.marker([item.lat, item.lng], { + icon: icon, + draggable: isAdmin + }); + + marker.spbuData = item; // Simpan data di objek marker + + // Hitung popupContent sekali saat marker dibuat + const d = item; + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + const popupContent = ` +
+

SPBU ${d.nama}

+

No. WA: ${d.no_wa}

+

Buka 24 Jam: ${d.is_24_jam ? 'Ya' : 'Tidak'}

+ ${actionButtons} +
+ `; + marker.bindPopup(popupContent); + + // Event drag untuk update koordinat + marker.on('dragend', function(e) { + const newPos = marker.getLatLng(); + updateSpbu(item.id, item.nama, item.no_wa, item.is_24_jam, newPos.lat, newPos.lng); + }); + + spbuLayer.addLayer(marker); +} + +// Map Click -> Form Add +map.on('click', function(e) { + if (isDrawingMode) return; // Jangan muncul form jika sedang gambar garis/polygon + + // Hanya proses jika mode tambah SPBU aktif + if (window.currentAddMode === 'spbu') { + const bodyHTML = ` +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ `; + + openModal("Tambah SPBU Baru", bodyHTML, function() { + window.saveNewSpbu(e.latlng.lat, e.latlng.lng, 'modalSpbuNama', 'modalSpbuWa', 'modalSpbu24'); + }); + + // Nonaktifkan mode tambah setelah modal muncul + window.deactivateAddMode(); + } +}); + +window.saveNewSpbu = function(lat, lng, namaId, waId, radioName) { + const nama = document.getElementById(namaId).value; + if (!nama) { + alert("Nama SPBU harus diisi!"); + return; + } + const no_wa = document.getElementById(waId).value; + const is_24_jam = document.querySelector(`input[name="${radioName}"]:checked`).value; + + fetch('../spbu/create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nama, no_wa, is_24_jam, lat, lng }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + closeModal(); + loadSpbu(); + } else { + alert(data.message); + } + }); +}; + +window.openEditSpbuModal = function(id) { + let d = null; + spbuLayer.eachLayer(function(layer) { + if (layer.spbuData && layer.spbuData.id == id) d = layer.spbuData; + }); + if (!d) return; + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ + +
+
+ `; + map.closePopup(); + openModal("Edit SPBU", bodyHTML, function() { + window.saveEditSpbu(d.id, d.lat, d.lng, 'editSpbuNama', 'editSpbuWa', 'editSpbu24'); + }); +}; + +window.saveEditSpbu = function(id, lat, lng, namaId, waId, radioName) { + const nama = document.getElementById(namaId).value; + const no_wa = document.getElementById(waId).value; + const is_24_jam = document.querySelector(`input[name="${radioName}"]:checked`).value; + + updateSpbu(id, nama, no_wa, is_24_jam, lat, lng); +}; + +function updateSpbu(id, nama, no_wa, is_24_jam, lat, lng) { + fetch('../spbu/update.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, nama, no_wa, is_24_jam, lat, lng }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + closeModal(); + loadSpbu(); + } else { + alert(data.message); + } + }); +} + +window.deleteSpbu = function(id) { + openConfirmModal("Yakin hapus SPBU ini?", function() { + fetch('../spbu/delete.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + loadSpbu(); + } else { + alert(data.message); + } + }); + }); +}; + +// Initial Load +loadSpbu(); + diff --git a/jalan/create.php b/jalan/create.php new file mode 100644 index 0000000..7d65e09 --- /dev/null +++ b/jalan/create.php @@ -0,0 +1,25 @@ +geom)) { + $nama = $conn->real_escape_string($data->nama ?? 'Jalan Baru'); + $status = $conn->real_escape_string($data->status ?? 'Kabupaten'); + $panjang = (float)($data->panjang ?? 0); + $geom = $conn->real_escape_string(json_encode($data->geom)); // GeoJSON string + + $query = "INSERT INTO jalan (nama, status, panjang, geom) VALUES ('$nama', '$status', $panjang, ST_GeomFromGeoJSON('$geom'))"; + + if($conn->query($query)) { + echo json_encode(["status" => "success", "id" => $conn->insert_id, "message" => "Jalan berhasil ditambahkan."]); + } else { + echo json_encode(["status" => "error", "message" => "Gagal menambahkan jalan: " . $conn->error]); + } +} else { + echo json_encode(["status" => "error", "message" => "Data geometri tidak lengkap."]); +} +?> diff --git a/jalan/db_connect.php b/jalan/db_connect.php new file mode 100644 index 0000000..a3922ed --- /dev/null +++ b/jalan/db_connect.php @@ -0,0 +1,17 @@ +connect_error) { + die("Koneksi gagal: " . $conn->connect_error); +} +// Set charset +$conn->set_charset("utf8mb4"); + +// Jika di-include oleh file API, biarkan $conn tersedia +?> diff --git a/jalan/delete.php b/jalan/delete.php new file mode 100644 index 0000000..41960a4 --- /dev/null +++ b/jalan/delete.php @@ -0,0 +1,22 @@ +id)) { + $id = (int)$data->id; + + $query = "DELETE FROM jalan WHERE id=$id"; + + if($conn->query($query)) { + echo json_encode(["status" => "success", "message" => "Jalan berhasil dihapus."]); + } else { + echo json_encode(["status" => "error", "message" => "Gagal hapus jalan: " . $conn->error]); + } +} else { + echo json_encode(["status" => "error", "message" => "ID tidak diberikan."]); +} +?> diff --git a/jalan/index.php b/jalan/index.php new file mode 100644 index 0000000..37a4a3e --- /dev/null +++ b/jalan/index.php @@ -0,0 +1,428 @@ + + + + + + WebGIS Pemetaan Kemiskinan + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + +
+ + +
+ + + + + + + + + + + + +
+ +
+

Daftar Layer

+ +
Infrastruktur & Lahan
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
Pemetaan Kemiskinan
+ + + + + +
+
+ + +
+ +
+ + +
+
+ GeoJSON Eksternal + +
+ +
+
+ + +
+
+

Data

+ +
+
+
+
Pilih menu di kiri untuk menampilkan data
+
+
+
+ + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jalan/read.php b/jalan/read.php new file mode 100644 index 0000000..ade48aa --- /dev/null +++ b/jalan/read.php @@ -0,0 +1,25 @@ +query($query); + +$jalan_arr = array(); + +if($result->num_rows > 0) { + while($row = $result->fetch_assoc()) { + $jalan_item = array( + "id" => $row['id'], + "nama" => $row['nama'], + "status" => $row['status'], + "panjang" => (float)$row['panjang'], + "geom" => json_decode($row['geom']) + ); + array_push($jalan_arr, $jalan_item); + } + echo json_encode(["status" => "success", "data" => $jalan_arr]); +} else { + echo json_encode(["status" => "success", "data" => []]); +} +?> diff --git a/jalan/update.php b/jalan/update.php new file mode 100644 index 0000000..3e65d91 --- /dev/null +++ b/jalan/update.php @@ -0,0 +1,31 @@ +id)) { + $id = (int)$data->id; + $nama = $conn->real_escape_string($data->nama ?? ''); + $status = $conn->real_escape_string($data->status ?? 'Kabupaten'); + + // Jika ada update geometri + if(!empty($data->geom)) { + $panjang = (float)($data->panjang ?? 0); + $geom = $conn->real_escape_string(json_encode($data->geom)); + $query = "UPDATE jalan SET nama='$nama', status='$status', panjang=$panjang, geom=ST_GeomFromGeoJSON('$geom') WHERE id=$id"; + } else { + $query = "UPDATE jalan SET nama='$nama', status='$status' WHERE id=$id"; + } + + if($conn->query($query)) { + echo json_encode(["status" => "success", "message" => "Jalan berhasil diupdate."]); + } else { + echo json_encode(["status" => "error", "message" => "Gagal update jalan: " . $conn->error]); + } +} else { + echo json_encode(["status" => "error", "message" => "ID tidak diberikan."]); +} +?> diff --git a/parsil.js b/parsil.js new file mode 100644 index 0000000..0bea38a --- /dev/null +++ b/parsil.js @@ -0,0 +1,158 @@ +// --- Fitur Parsil --- + +const parsilColors = { + 'SHM': '#28a745', // Hijau + 'HGB': '#17a2b8', // Biru Muda + 'HGU': '#ffc107', // Kuning + 'HP': '#fd7e14' // Oranye +}; + +function loadParsil() { + parsilLayer.clearLayers(); + fetch('api/parsil/read.php') + .then(res => res.json()) + .then(data => { + if (data.status === 'success' && data.data) { + data.data.forEach(item => { + addParsilToMap(item); + }); + } + if (window.refreshActivePanel) window.refreshActivePanel(); + }); +} + +function addParsilToMap(item) { + // geom adalah GeoJSON Polygon + // coordinates pada Polygon formatnya: [[[lng, lat], [lng, lat], ...]] + // Leaflet Polygon butuh array of [lat, lng] + const latlngs = item.geom.coordinates[0].map(coord => [coord[1], coord[0]]); + + const polygon = L.polygon(latlngs, { + color: parsilColors[item.status] || '#3388ff', + fillColor: parsilColors[item.status] || '#3388ff', + fillOpacity: 0.5, + weight: 2 + }); + + polygon.parsilData = item; + + const d = item; + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + const popupContent = ` +
+

Parsil ${d.nama || ''}

+

Status: ${d.status}

+

Luas: ${d.luas.toFixed(2)} mยฒ

+ ${actionButtons} +
+ `; + polygon.bindPopup(popupContent); + + parsilLayer.addLayer(polygon); +} + +window.openEditParsilModal = function(id) { + let d = null; + parsilLayer.eachLayer(function(layer) { + if (layer.parsilData && layer.parsilData.id == id) d = layer.parsilData; + }); + if (!d) return; + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + map.closePopup(); + openModal("Edit Parsil Tanah", bodyHTML, function() { + window.saveEditParsil(d.id, 'editParsilNama', 'editParsilStatus'); + }); +}; + +window.saveEditParsil = function(id, namaId, statusId) { + const nama = document.getElementById(namaId).value; + const status = document.getElementById(statusId).value; + + fetch('api/parsil/update.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, nama, status }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + closeModal(); + loadParsil(); + } else { + alert(data.message); + } + }); +}; + +window.deleteParsil = function(id) { + openConfirmModal("Yakin hapus parsil ini?", function() { + fetch('api/parsil/delete.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + loadParsil(); + } else { + alert(data.message); + } + }); + }); +}; + +window.saveNewParsil = function(geoJsonStr, luas, namaId, statusId) { + const nama = document.getElementById(namaId).value; + const status = document.getElementById(statusId).value; + + const geom = JSON.parse(geoJsonStr); + + fetch('api/parsil/create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nama, status, luas, geom }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + closeModal(); + loadParsil(); + } else { + alert(data.message); + } + }); + return true; +}; + +// Initial Load +loadParsil(); diff --git a/parsil/assets/css/style.css b/parsil/assets/css/style.css new file mode 100644 index 0000000..19f11d9 --- /dev/null +++ b/parsil/assets/css/style.css @@ -0,0 +1,1043 @@ +/* Reset & Base */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body, html { + height: 100%; + font-family: 'Google Sans Flex', 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + overflow: hidden; +} + +/* Map Container */ +#map { + width: 100vw; + height: 100vh; + z-index: 1; +} + +/* Custom UI Container over Map */ +.ui-container { + position: absolute; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 15px; +} + +/* Search Bar */ +.search-bar { + display: flex; + align-items: center; + background-color: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + overflow: hidden; + height: 48px; + transition: box-shadow 0.3s; +} + +.search-bar:focus-within { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); +} + +.search-icon { + padding: 0 15px; + color: #666; + display: flex; + align-items: center; + justify-content: center; +} + +.search-input { + flex: 1; + border: none; + outline: none; + font-size: 16px; + padding: 10px 0; + color: #333; +} + +.search-clear { + padding: 0 15px; + color: #999; + cursor: pointer; + background: none; + border: none; + display: flex; + align-items: center; + justify-content: center; + visibility: hidden; +} + +.search-clear:hover { + color: #333; +} + +/* Custom Layer Control Button */ +.custom-layer-btn { + background-color: white; + border-radius: 8px; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + cursor: pointer; + border: none; + color: #333; +} + +.custom-layer-btn:hover { + background-color: #f8f9fa; +} + +#layerBtn { + position: absolute; + top: 20px; + right: 20px; + z-index: 1000; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +body.right-panel-open #layerBtn { + right: 340px; +} + +.custom-layer-panel { + position: absolute; + top: 80px; + right: 20px; + z-index: 1000; + background-color: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 15px; + width: 250px; + display: none; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +body.right-panel-open .custom-layer-panel { + right: 340px; +} + + +.custom-layer-panel h3 { + margin-bottom: 10px; + font-size: 16px; + border-bottom: 1px solid #eee; + padding-bottom: 5px; +} + +.layer-section-title { + font-size: 11px; + font-weight: 700; + color: #6366f1; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; + margin-top: 12px; + padding-bottom: 2px; + border-bottom: 1px dashed #e2e8f0; +} + +.layer-option { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; + font-size: 14px; +} + +/* ===== Sidebar Toggle Button ===== */ +.sidebar-toggle-btn { + position: absolute; + left: 20px; + top: 190px; + z-index: 1001; + border-radius: 8px; + width: 48px; + height: 48px; + background: white; + border: none; + box-shadow: 0 3px 10px rgba(0,0,0,0.15); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #6366f1; + font-size: 13px; + transition: all 0.2s; +} + +.sidebar-toggle-btn:hover { + background: #6366f1; + color: white; + box-shadow: 0 4px 14px rgba(99,102,241,0.4); +} + +.sidebar-toggle-btn.open { + background: #6366f1; + color: white; +} + +/* ===== Left Sidebar ===== */ +.left-sidebar { + position: absolute; + left: 20px; + top: 250px; /* di bawah toggle button */ + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; +} + +.sidebar-section-label { + font-size: 9px; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + text-align: center; + letter-spacing: 0.8px; + margin-top: 6px; + margin-bottom: 2px; + pointer-events: none; + user-select: none; +} + +.sidebar-divider { + height: 1px; + background: #e2e8f0; + margin: 4px 6px; +} + +.sidebar-btn { + width: 60px; + height: 60px; + background: white; + border: none; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0,0,0,0.12); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + transition: all 0.2s ease; + padding: 6px 4px; +} + +.sidebar-btn:hover { + transform: translateX(3px); + box-shadow: 0 6px 18px rgba(0,0,0,0.18); + background: #f8f9fa; +} + +.sidebar-btn.active { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + box-shadow: 0 6px 18px rgba(99,102,241,0.4); + transform: translateX(4px); +} + +.sidebar-emoji { + font-size: 22px; + line-height: 1; +} + +.sidebar-label { + font-size: 9px; + font-weight: 600; + letter-spacing: 0.3px; + color: #555; + text-transform: uppercase; +} + +.sidebar-btn.active .sidebar-label { + color: rgba(255,255,255,0.85); +} + +/* ===== Right Panel ===== */ +.right-panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 320px; + background: #f8f9fb; + z-index: 999; + box-shadow: -4px 0 20px rgba(0,0,0,0.12); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + /* shift layer control when panel open */ +} + +.right-panel.open { + transform: translateX(0); +} + +.right-panel-header { + padding: 16px 16px 12px; + background: white; + border-bottom: 1px solid #eee; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + padding-top: 26px; +} + +.right-panel-header h3 { + font-size: 15px; + font-weight: 700; + color: #1e1e2e; +} + +.right-panel-close { + width: 30px; + height: 30px; + border: none; + background: #f3f4f6; + border-radius: 8px; + cursor: pointer; + color: #555; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + transition: background 0.2s; +} + +.right-panel-close:hover { + background: #e5e7eb; + color: #333; +} + +.right-panel-actions { + padding: 12px 14px; + background: white; + border-bottom: 1px solid #eee; + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.right-panel-actions button { + flex: 1; + padding: 9px 10px; + border: none; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: all 0.2s; +} + +.btn-panel-add { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + box-shadow: 0 3px 10px rgba(99,102,241,0.3); +} + +.btn-panel-add:hover { + box-shadow: 0 5px 14px rgba(99,102,241,0.45); + transform: translateY(-1px); +} + +.btn-panel-import { + background: #f0fdf4; + color: #16a34a; + border: 1px solid #bbf7d0 !important; +} + +.btn-panel-import:hover { + background: #dcfce7; +} + +.right-panel-body { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Panel Section Heading */ +.panel-section-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #9ca3af; + padding: 4px 2px 6px; + border-bottom: 1px solid #e5e7eb; + margin-top: 4px; +} + +/* Panel Empty State */ +.panel-empty { + text-align: center; + color: #aaa; + font-size: 13px; + padding: 40px 10px; +} + +/* ===== Data Card ===== */ +.data-card { + background: white; + border-radius: 12px; + padding: 12px 12px 10px; + box-shadow: 0 1px 4px rgba(0,0,0,0.07); + transition: box-shadow 0.2s; + cursor: pointer; +} + +.data-card:hover { + box-shadow: 0 3px 12px rgba(0,0,0,0.12); +} + +.data-card-main { + display: flex; + align-items: center; + gap: 10px; +} + +.data-card-icon { + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.card-icon-spbu { background: #f0fdf4; } +.card-icon-jalan { background: #eff6ff; } +.card-icon-parsil { background: #fefce8; } +.card-icon-ibadah { background: #fff7ed; } +.card-icon-miskin-out { background: #f0fdf4; } +.card-icon-miskin-in { background: #fef2f2; } + +.data-card-info { + flex: 1; + min-width: 0; +} + +.data-card-name { + font-size: 13px; + font-weight: 600; + color: #1e1e2e; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.data-card-sub { + font-size: 11px; + color: #6b7280; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.data-card-badge { + font-size: 10px; + font-weight: 600; + padding: 2px 7px; + border-radius: 99px; + margin-top: 4px; + display: inline-block; +} + +.badge-green { background: #dcfce7; color: #16a34a; } +.badge-red { background: #fee2e2; color: #dc2626; } +.badge-blue { background: #dbeafe; color: #2563eb; } +.badge-orange { background: #ffedd5; color: #ea580c; } +.badge-yellow { background: #fef9c3; color: #ca8a04; } + +.data-card-actions { + display: flex; + gap: 6px; + flex-shrink: 0; + align-items: center; +} + +.btn-card-action { + width: 30px; + height: 30px; + border: none; + border-radius: 7px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: all 0.15s; +} + +.btn-card-edit { + background: #eff6ff; + color: #3b82f6; +} + +.btn-card-edit:hover { + background: #3b82f6; + color: white; +} + +.btn-card-delete { + background: #fef2f2; + color: #ef4444; +} + +.btn-card-delete:hover { + background: #ef4444; + color: white; +} + +/* Expandable card (miskin) */ +.data-card-expand { + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease, padding 0.2s; + border-top: 0px solid #f3f4f6; +} + +.data-card.expanded .data-card-expand { + max-height: 400px; + border-top: 1px solid #f3f4f6; + margin-top: 10px; + padding-top: 10px; +} + +.expand-row { + display: flex; + justify-content: space-between; + font-size: 12px; + color: #6b7280; + padding: 3px 0; +} + +.expand-row span:first-child { + font-weight: 500; + color: #374151; +} + +.btn-log-bantuan { + margin-top: 10px; + width: 100%; + padding: 8px; + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + border: none; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: all 0.2s; +} + +.btn-log-bantuan:hover { + box-shadow: 0 4px 12px rgba(99,102,241,0.4); +} + +/* Log Bantuan Item */ +.log-item { + background: #f8f9fb; + border-radius: 8px; + padding: 10px 12px; + display: flex; + gap: 10px; + align-items: flex-start; +} + +.log-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #6366f1; + flex-shrink: 0; + margin-top: 5px; +} + +.log-info { + flex: 1; +} + +.log-type { + font-size: 12px; + font-weight: 600; + color: #1e1e2e; +} + +.log-meta { + font-size: 11px; + color: #9ca3af; + margin-top: 2px; +} + +.log-keterangan { + font-size: 11px; + color: #6b7280; + margin-top: 3px; + font-style: italic; +} + +/* Popup Form */ +.popup-form { + display: flex; + flex-direction: column; + gap: 10px; + width: 200px; +} + +.popup-form label { + font-size: 12px; + font-weight: bold; + color: #555; +} + +.popup-form input[type="text"], +.popup-form input[type="number"] { + width: 100%; + padding: 6px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.popup-form .radio-group { + display: flex; + gap: 10px; +} + +.popup-form button { + padding: 8px; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; +} + +.popup-form button.btn-danger { + background-color: #dc3545; +} + +.popup-form button:hover { + opacity: 0.9; +} + +/* Unified Modal */ +.unified-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); + z-index: 2000; + align-items: center; + justify-content: center; +} + +.unified-modal.show { + display: flex; +} + +.modal-content { + background-color: white; + border-radius: 12px; + width: 400px; + max-width: 90%; + box-shadow: 0 8px 30px rgba(0,0,0,0.2); + display: flex; + flex-direction: column; +} + +.modal-header { + padding: 15px; + border-bottom: 1px solid #eee; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + font-size: 16px; + margin: 0; +} + +.modal-close { + font-size: 20px; + font-weight: bold; + cursor: pointer; + color: #888; +} + +.modal-close:hover { + color: #333; +} + +.modal-body { + padding: 15px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.modal-body label { + font-size: 13px; + font-weight: 600; + color: #444; +} + +.modal-body input[type="text"], +.modal-body input[type="number"], +.modal-body input[type="date"], +.modal-body select, +.modal-body textarea { + width: 100%; + padding: 8px; + border: 1px solid #ccc; + border-radius: 6px; + font-size: 14px; + font-family: inherit; +} + +.modal-footer { + padding: 15px; + border-top: 1px solid #eee; + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.btn-save { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + border: none; + padding: 8px 18px; + border-radius: 7px; + cursor: pointer; + font-weight: 600; + font-size: 13px; +} + +.btn-cancel { + background-color: #f8f9fa; + border: 1px solid #ddd; + padding: 8px 18px; + border-radius: 7px; + cursor: pointer; + font-size: 13px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Custom Cursor Tooltip */ +.custom-cursor-tooltip { + position: absolute; + display: none; + background-color: rgba(0, 0, 0, 0.75); + color: white; + padding: 6px 12px; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + pointer-events: none; + z-index: 9999; + white-space: nowrap; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); +} + +/* Sembunyikan default tooltip Leaflet Draw */ +.leaflet-draw-tooltip { + display: none !important; +} + +/* Sub-layer collapsible */ +.layer-group-item { + margin-bottom: 6px; +} + +.layer-group-item > .layer-group-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.layer-toggle-icon { + cursor: pointer; + padding: 2px 6px; + color: #666; + font-size: 11px; + border-radius: 4px; + transition: background 0.2s; + flex-shrink: 0; +} + +.layer-toggle-icon:hover { + background: #f0f0f0; + color: #333; +} + +.layer-toggle-icon i { + transition: transform 0.2s; +} + +.layer-toggle-icon.collapsed i { + transform: rotate(-90deg); +} + +.sub-layer-list { + padding-left: 18px; + margin-top: 4px; + border-left: 2px solid #eee; + overflow: hidden; + transition: max-height 0.2s ease; +} + +.sub-option { + font-size: 12px !important; + color: #555; + margin-bottom: 3px !important; +} + +/* ===== Emoji Marker (Pin Bubble) ===== */ +.emoji-marker { + display: flex; + flex-direction: column; + align-items: center; + pointer-events: auto; /* changed from none to allow click/drag */ +} + +.emoji-marker .bubble { + width: 38px; + height: 38px; + border-radius: 50% 50% 50% 0; + transform: rotate(-45deg); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 3px 10px rgba(0,0,0,0.25); + border: 2px solid rgba(255,255,255,0.8); + animation: markerPop 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275) both; +} + +.emoji-marker .bubble span { + transform: rotate(45deg); + font-size: 18px; + line-height: 1; +} + +@keyframes markerPop { + 0% { transform: rotate(-45deg) scale(0); } + 100% { transform: rotate(-45deg) scale(1); } +} + +/* Warna bubble per tipe */ +.emoji-marker .bubble.spbu-24 { background: linear-gradient(135deg, #22c55e, #16a34a); } +.emoji-marker .bubble.spbu-not24 { background: linear-gradient(135deg, #ef4444, #dc2626); } +.emoji-marker .bubble.ibadah { background: linear-gradient(135deg, #f97316, #ea580c); } +.emoji-marker .bubble.miskin-in { background: linear-gradient(135deg, #ef4444, #dc2626); } +.emoji-marker .bubble.miskin-out { background: linear-gradient(135deg, #22c55e, #16a34a); } + +/* ===== Toast Notification ===== */ +#toastContainer { + position: fixed; + bottom: 28px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + border-radius: 12px; + font-size: 13px; + font-weight: 500; + color: white; + box-shadow: 0 6px 24px rgba(0,0,0,0.18); + backdrop-filter: blur(6px); + pointer-events: auto; + animation: toastSlideIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) both; + max-width: 340px; + text-align: center; +} + +.toast.hiding { + animation: toastSlideOut 0.3s ease forwards; +} + +.toast-success { background: linear-gradient(135deg, #22c55e, #16a34a); } +.toast-error { background: linear-gradient(135deg, #ef4444, #dc2626); } +.toast-info { background: linear-gradient(135deg, #6366f1, #4f46e5); } +.toast-warning { background: linear-gradient(135deg, #f59e0b, #d97706); } + +.toast-icon { font-size: 16px; flex-shrink: 0; } + +@keyframes toastSlideIn { + 0% { opacity: 0; transform: translateY(20px) scale(0.9); } + 100% { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes toastSlideOut { + 0% { opacity: 1; transform: translateY(0) scale(1); } + 100% { opacity: 0; transform: translateY(10px) scale(0.95); } +} + +/* Auth Widget Styling */ +.auth-widget { + position: absolute; + top: 20px; + right: 80px; + z-index: 1000; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +body.right-panel-open .auth-widget { + right: 400px; +} + +.btn-login { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + border: none; + border-radius: 24px; + height: 48px; + padding: 0 24px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; +} + +.btn-login:hover { + box-shadow: 0 6px 16px rgba(99, 102, 241, 0.45); + transform: translateY(-1px); +} + +.user-profile-pill { + background-color: white; + border-radius: 24px; + height: 48px; + padding: 0 8px 0 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: flex; + align-items: center; + gap: 12px; + border: 1px solid rgba(226, 232, 240, 0.8); + backdrop-filter: blur(10px); + background: rgba(255, 255, 255, 0.9); +} + +.user-profile-info { + display: flex; + flex-direction: column; +} + +.user-profile-name { + font-size: 12px; + font-weight: 700; + color: #1e1e2e; +} + +.user-profile-role { + font-size: 9px; + font-weight: 600; + color: #6366f1; + text-transform: uppercase; +} + +.btn-profile-logout { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: #f1f5f9; + color: #64748b; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.btn-profile-logout:hover { + background: #fee2e2; + color: #ef4444; +} + +/* User management table styling */ +#userManagementModal table th { + padding: 10px 12px; + font-weight: 600; + background-color: #f1f5f9; + border-bottom: 1px solid #cbd5e1; +} + +#userManagementModal table td { + padding: 10px 12px; + border-bottom: 1px solid #e2e8f0; +} + + + +/* ============================================================= + PARSIL — Specific Styles + ============================================================= */ + +.card-icon-parsil { background: #fefce8; } + +.legend-parsil-shm { display:inline-block; width:14px; height:14px; border-radius:3px; background:#28a745; opacity:0.8; } +.legend-parsil-hgb { display:inline-block; width:14px; height:14px; border-radius:3px; background:#17a2b8; opacity:0.8; } +.legend-parsil-hgu { display:inline-block; width:14px; height:14px; border-radius:3px; background:#ffc107; opacity:0.8; } +.legend-parsil-hp { display:inline-block; width:14px; height:14px; border-radius:3px; background:#fd7e14; opacity:0.8; } + +.badge-shm { background:#dcfce7; color:#16a34a; } +.badge-hgb { background:#cffafe; color:#0e7490; } +.badge-hgu { background:#fef9c3; color:#a16207; } +.badge-hp { background:#ffedd5; color:#c2410c; } diff --git a/parsil/assets/js/features/auth.js b/parsil/assets/js/features/auth.js new file mode 100644 index 0000000..f2dcb32 --- /dev/null +++ b/parsil/assets/js/features/auth.js @@ -0,0 +1,157 @@ +// --- Fitur Autentikasi dan Manajemen User (RBAC) --- + +(function() { + window.currentUser = null; + + // Elements + const authWidget = document.getElementById('authWidget'); + const loginModal = document.getElementById('loginModal'); + const loginUsernameInput = document.getElementById('loginUsername'); + const loginPasswordInput = document.getElementById('loginPassword'); + const loginSubmitBtn = document.getElementById('loginSubmitBtn'); + const loginErrorMsg = document.getElementById('loginErrorMsg'); + const closeLoginModal = document.getElementById('closeLoginModal'); + + // Startup Session Check + function checkSession() { + fetch('../poverty/api/check_session.php') + .then(res => res.json()) + .then(data => { + if (data.status === 'success' && data.isLoggedIn) { + window.currentUser = data.data; + } else { + window.currentUser = null; + } + updateAuthUI(); + refreshAllLayers(); + }) + .catch(err => { + console.error("Session check failed:", err); + window.currentUser = null; + updateAuthUI(); + }); + } + + // Update UI based on logged-in state + function updateAuthUI() { + if (!authWidget) return; + + if (window.currentUser) { + // Logged In state + const roleLabel = window.currentUser.role === 'admin' ? 'Admin' : 'Pengelola'; + + authWidget.innerHTML = ` +
+ + +
+ `; + + document.getElementById('authLogoutBtn').addEventListener('click', logout); + } else { + // Logged Out state + authWidget.innerHTML = ` + + `; + document.getElementById('authLoginBtn').addEventListener('click', showLoginModal); + } + } + + function refreshAllLayers() { + if (typeof loadSpbu === 'function') loadSpbu(); + if (typeof loadJalan === 'function') loadJalan(); + if (typeof loadParsil === 'function') loadParsil(); + if (window.refreshActivePanel) window.refreshActivePanel(); + } + + // Login Modals logic + function showLoginModal() { + loginUsernameInput.value = ''; + loginPasswordInput.value = ''; + loginErrorMsg.style.display = 'none'; + loginModal.classList.add('show'); + } + + function hideLoginModal() { + loginModal.classList.remove('show'); + } + + if (closeLoginModal) { + closeLoginModal.addEventListener('click', hideLoginModal); + } + + if (loginSubmitBtn) { + loginSubmitBtn.addEventListener('click', function() { + const username = loginUsernameInput.value.trim(); + const password = loginPasswordInput.value; + + if (!username || !password) { + loginErrorMsg.textContent = 'Username dan password wajib diisi'; + loginErrorMsg.style.display = 'block'; + return; + } + + fetch('../poverty/api/login.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }) + .then(res => res.json()) + .then(data => { + if (data.status === 'success') { + window.currentUser = data.data; + hideLoginModal(); + updateAuthUI(); + refreshAllLayers(); + showToast('Login berhasil', 'success'); + } else { + loginErrorMsg.textContent = data.message || 'Login gagal'; + loginErrorMsg.style.display = 'block'; + } + }) + .catch(err => { + console.error(err); + loginErrorMsg.textContent = 'Koneksi ke server terputus'; + loginErrorMsg.style.display = 'block'; + }); + }); + } + + function logout() { + fetch('../poverty/api/logout.php') + .then(res => res.json()) + .then(data => { + window.currentUser = null; + updateAuthUI(); + refreshAllLayers(); + showToast('Logout berhasil', 'success'); + }) + .catch(err => { + console.error(err); + showToast('Gagal logout', 'error'); + }); + } + + // Helper functions + function escHtml(str) { + if (!str) return ''; + return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + // Toast Notification helper + function showToast(message, type = 'success') { + if (typeof window.showToast === 'function') { + window.showToast(message, type); + return; + } + alert(message); + } + + // Run session check on initialization + checkSession(); +})(); diff --git a/parsil/assets/js/features/draw_control.js b/parsil/assets/js/features/draw_control.js new file mode 100644 index 0000000..a1e7026 --- /dev/null +++ b/parsil/assets/js/features/draw_control.js @@ -0,0 +1,142 @@ +// --- Setup Leaflet Draw --- + +// Layer terpisah untuk hasil gambar sementara sebelum disimpan +const drawnItems = new L.FeatureGroup(); +map.addLayer(drawnItems); + +const drawControl = new L.Control.Draw({ + draw: { + polygon: { + allowIntersection: false, + showArea: true + }, + polyline: { + metric: true + }, + circle: false, + circlemarker: false, + marker: false, // Marker default dimatikan, kita pakai klik peta untuk SPBU + rectangle: false + }, + edit: { + featureGroup: drawnItems, // Kita tidak mengedit di drawnItems, karena data aslinya di jalanLayer/parsilLayer + edit: false, + remove: false + } +}); +// Hapus map.addControl(drawControl); agar tidak tampil UI bawaan LeafletJS + + +// Flag untuk menghindari munculnya form SPBU saat sedang menggambar +map.on(L.Draw.Event.DRAWSTART, function (e) { + isDrawingMode = true; +}); + +map.on(L.Draw.Event.DRAWSTOP, function (e) { + setTimeout(() => { + isDrawingMode = false; + window.currentDrawMode = null; + window.activeDrawHandler = null; + window.deactivateAddMode(); + }, 200); +}); + +window.activateDraw = function(type) { + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + if (!isAdmin) return; + + console.log("-> activateDraw dipanggil untuk tipe:", type); + + // Toggle: jika mode yang sama ditekan lagi, batalkan + if (window.currentDrawMode === type) { + console.log("Mode", type, "sudah aktif, membatalkan..."); + window.deactivateAddMode(); + return; + } + + window.deactivateAddMode(); + window.currentDrawMode = type; + console.log("Mengaktifkan mode menggambar:", type); + + if (type === 'polyline') { + const handler = new L.Draw.Polyline(map, drawControl.options.draw.polyline); + handler.enable(); + window.activeDrawHandler = handler; + if (window.cursorTooltip) window.cursorTooltip.textContent = 'Klik untuk menggambar Jalan'; + } else if (type === 'polygon') { + const handler = new L.Draw.Polygon(map, drawControl.options.draw.polygon); + handler.enable(); + window.activeDrawHandler = handler; + if (window.cursorTooltip) window.cursorTooltip.textContent = 'Klik untuk menggambar Parsil'; + } +}; + +map.on(L.Draw.Event.CREATED, function (e) { + const type = e.layerType; + const layer = e.layer; + + // Convert layer to GeoJSON geometry + const geoJson = layer.toGeoJSON().geometry; + const geoJsonStr = JSON.stringify(geoJson); + + if (type === 'polyline') { + let length = 0; + const latlngs = layer.getLatLngs(); + for (let i = 0; i < latlngs.length - 1; i++) { + length += latlngs[i].distanceTo(latlngs[i + 1]); + } + + // Gunakan Modal alih-alih prompt + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + openModal("Tambah Jalan Baru", bodyHTML, function() { + window.saveNewJalan(geoJsonStr, length, 'modalJalanNama', 'modalJalanStatus'); + }); + + } + else if (type === 'polygon') { + const latlngs = layer.getLatLngs()[0]; + let area = 0; + if (L.GeometryUtil && L.GeometryUtil.geodesicArea) { + area = L.GeometryUtil.geodesicArea(latlngs); + } + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + openModal("Tambah Parsil Tanah", bodyHTML, function() { + window.saveNewParsil(geoJsonStr, area, 'modalParsilNama', 'modalParsilStatus'); + }); + } +}); + diff --git a/parsil/assets/js/features/geojson.js b/parsil/assets/js/features/geojson.js new file mode 100644 index 0000000..62fc314 --- /dev/null +++ b/parsil/assets/js/features/geojson.js @@ -0,0 +1,182 @@ +// --- Fitur Import GeoJSON --- + +const fileGeoJson = document.getElementById('fileGeoJson'); +let geoJsonLayers = {}; +let geoJsonCounter = 0; + +window.toggleGeoJsonMenu = function() { + const list = document.getElementById('geoJsonFileList'); + const icon = document.getElementById('geoJsonToggleIcon'); + if (list.style.display === 'none') { + list.style.display = 'block'; + icon.className = 'fas fa-minus'; + } else { + list.style.display = 'none'; + icon.className = 'fas fa-plus'; + } +}; + +// Cek apakah fitur GeoJSON ini terlihat seperti data penduduk miskin +function isMiskinFeature(feature) { + if (!feature || !feature.geometry || feature.geometry.type !== 'Point') return false; + const props = feature.properties || {}; + const keys = Object.keys(props).map(k => k.toLowerCase()); + // Dianggap data miskin jika punya properti nama (dan bukan ibadah atau spbu) + const hasNama = keys.some(k => ['nama', 'name', 'penduduk'].includes(k)); + const isIbadah = keys.some(k => ['jenis', 'radius', 'alamat'].includes(k)); + const isSpbu = keys.some(k => ['alamat_spbu', 'is_24_jam'].includes(k)); + return hasNama && !isIbadah && !isSpbu; +} + +fileGeoJson.addEventListener('change', function(e) { + const file = e.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = function(event) { + try { + const geoJsonData = JSON.parse(event.target.result); + const fileName = file.name; + + // Pisahkan fitur menjadi dua kelompok: miskin dan non-miskin + const features = geoJsonData.type === 'FeatureCollection' + ? geoJsonData.features + : [geoJsonData]; + + const miskinFeatures = features.filter(f => isMiskinFeature(f)); + const otherFeatures = features.filter(f => !isMiskinFeature(f)); + + // ---- Proses fitur miskin: Simpan ke DB, lalu tampilkan seperti penduduk miskin ---- + if (miskinFeatures.length > 0) { + const bulkData = miskinFeatures.map(f => { + const props = f.properties || {}; + const coords = f.geometry.coordinates; + return { + nama: props.nama || props.name || props.penduduk || 'Data Impor', + kategori_bantuan: props.kategori_bantuan || props.kategori || props.bantuan || 'Makan', + jumlah_jiwa: parseInt(props.jumlah_jiwa || props.jumlah || props.jiwa || 1, 10) || 1, + lat: parseFloat(coords[1]), + lng: parseFloat(coords[0]) + }; + }); + + fetch('../poverty/api/penduduk_miskin/bulk_create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(bulkData) + }) + .then(res => res.json()) + .then(data => { + if (data.status === 'success') { + // Reload layer penduduk miskin agar marker tampil dengan edit/hapus/log bantuan + if (typeof loadPendudukMiskin === 'function') { + loadPendudukMiskin(); + } + if (typeof window.refreshActivePanel === 'function') { + window.refreshActivePanel(); + } + showToast(data.message || 'Data berhasil diimpor ke layer Penduduk Miskin.', 'success'); + } else { + showToast(data.message || 'Gagal menyimpan data miskin.', 'error'); + } + }) + .catch(err => { + console.error(err); + showToast('Gagal terhubung ke server saat menyimpan data miskin.', 'error'); + }); + } + + // ---- Proses fitur non-miskin: Render sebagai layer GeoJSON biasa ---- + if (otherFeatures.length > 0) { + const layerId = 'gj_' + (++geoJsonCounter); + const individualLayer = L.featureGroup(); + + const otherGeoJson = { + type: 'FeatureCollection', + features: otherFeatures + }; + + L.geoJSON(otherGeoJson, { + pointToLayer: function(feature, latlng) { + const props = feature.properties || {}; + let emoji = props.emoji; + + if (!emoji) { + const text = JSON.stringify(props).toLowerCase(); + if (text.includes('spbu')) emoji = 'โ›ฝ'; + else if (text.includes('masjid')) emoji = '๐Ÿ•Œ'; + else if (text.includes('gereja')) emoji = 'โ›ช'; + else if (text.includes('vihara')) emoji = '๐Ÿชท'; + else if (text.includes('pura')) emoji = '๐Ÿ›•'; + else if (text.includes('kelenteng')) emoji = '๐Ÿฎ'; + else emoji = '๐Ÿ“'; + } + + let cls = 'miskin-out'; + if (emoji === 'โ›ฝ') cls = 'spbu-24'; + else if (['๐Ÿ•Œ','โ›ช','๐Ÿ›•','๐Ÿชท','๐Ÿฎ'].includes(emoji)) cls = 'ibadah'; + + const icon = L.divIcon({ + className: '', + html: `
${emoji}
`, + iconSize: [38, 38], + iconAnchor: [19, 38], + popupAnchor: [0, -40] + }); + + return L.marker(latlng, { icon: icon }); + }, + onEachFeature: function(feature, layer) { + if (feature.properties) { + let popupContent = '

Informasi Feature

'; + layer.bindPopup(popupContent); + } + }, + style: function(feature) { + return { + color: (feature.properties && feature.properties.color) || '#3388ff', + weight: 2, + fillOpacity: 0.4 + }; + } + }).addTo(individualLayer); + + individualLayer.addTo(map); + geoJsonLayers[layerId] = individualLayer; + + // Tambahkan checkbox ke UI + const container = document.getElementById('geoJsonLayersContainer'); + const label = document.createElement('label'); + label.className = 'layer-option'; + label.innerHTML = ` ${fileName}`; + + label.querySelector('input').addEventListener('change', function(e) { + if (e.target.checked) { + map.addLayer(geoJsonLayers[layerId]); + } else { + map.removeLayer(geoJsonLayers[layerId]); + } + }); + + container.appendChild(label); + + if (miskinFeatures.length === 0) { + showToast('File GeoJSON berhasil dimuat!', 'success'); + } + } + + // Reset input file agar bisa import file yang sama + fileGeoJson.value = ''; + + } catch (error) { + showToast('Gagal memproses file GeoJSON. Pastikan format valid.', 'error'); + console.error(error); + } + }; + reader.readAsText(file); +}); + diff --git a/parsil/assets/js/features/geolocation.js b/parsil/assets/js/features/geolocation.js new file mode 100644 index 0000000..4d95a20 --- /dev/null +++ b/parsil/assets/js/features/geolocation.js @@ -0,0 +1,31 @@ +// --- Fitur Geolocation --- + +// Tambahkan tombol di dalam wadah zoom control +const geoBtn = document.createElement('button'); +geoBtn.className = 'custom-layer-btn'; +geoBtn.style.position = 'relative'; +geoBtn.style.top = '0'; +geoBtn.style.left = '0'; +geoBtn.style.right = 'auto'; +geoBtn.innerHTML = ''; +geoBtn.title = 'Lokasi Saya'; +document.querySelector('.custom-zoom-control').appendChild(geoBtn); + +let userMarker = null; + +geoBtn.addEventListener('click', function() { + map.locate({setView: true, maxZoom: 16}); +}); + +map.on('locationfound', function(e) { + if (userMarker) { + map.removeLayer(userMarker); + } + userMarker = L.marker(e.latlng).addTo(map) + .bindPopup("Anda berada di sini!").openPopup(); +}); + +map.on('locationerror', function(e) { + alert("Gagal mendapatkan lokasi Anda."); +}); + diff --git a/parsil/assets/js/features/jalan.js b/parsil/assets/js/features/jalan.js new file mode 100644 index 0000000..24a93ac --- /dev/null +++ b/parsil/assets/js/features/jalan.js @@ -0,0 +1,173 @@ +// --- Fitur Jalan --- + +const jalanColors = { + 'Nasional': '#ff0000', // Merah + 'Provinsi': '#0000ff', // Biru + 'Kabupaten': '#00ff00' // Hijau +}; + +function loadJalan() { + jalanLayer.clearLayers(); + fetch('../jalan/read.php') + .then(res => res.json()) + .then(data => { + if (data.status === 'success' && data.data) { + data.data.forEach(item => { + addJalanToMap(item); + }); + } + if (window.refreshActivePanel) window.refreshActivePanel(); + }); +} + +function addJalanToMap(item) { + // geom adalah GeoJSON {type: "LineString", coordinates: [[lng, lat], ...]} + const latlngs = item.geom.coordinates.map(coord => [coord[1], coord[0]]); + + const polyline = L.polyline(latlngs, { + color: jalanColors[item.status] || '#3388ff', + weight: 5 + }); + + polyline.jalanData = item; + + // Tampilkan label nama jalan sejajar dengan garis (diagonal) + polyline.setText(item.nama, { + center: true, + offset: 0, + attributes: { + fill: '#000000', + 'font-weight': 'bold', + 'font-size': '14px', + 'dy': '7' + } + }); + + // Hitung popupContent sekali saat jalan dibuat + const d = item; + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + + const popupContent = ` +
+

Jalan ${d.nama}

+

Status: ${d.status}

+

Panjang: ${d.panjang.toFixed(2)} m

+ ${actionButtons} +
+ `; + + polyline.bindPopup(popupContent); + + jalanLayer.addLayer(polyline); +} + +window.openEditJalanModal = function(id) { + let d = null; + jalanLayer.eachLayer(function(layer) { + if (layer.jalanData && layer.jalanData.id == id) d = layer.jalanData; + }); + if (!d) return; + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + map.closePopup(); + openModal("Edit Jalan", bodyHTML, function() { + window.saveEditJalan(d.id, 'editJalanNama', 'editJalanStatus'); + }); +}; + +window.saveEditJalan = function(id, namaId, statusId) { + const nama = document.getElementById(namaId).value; + const status = document.getElementById(statusId).value; + + fetch('../jalan/update.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, nama, status }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + closeModal(); + loadJalan(); + } else { + alert(data.message); + } + }); +}; + +window.deleteJalan = function(id) { + openConfirmModal("Yakin hapus jalan ini?", function() { + fetch('../jalan/delete.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + loadJalan(); + } else { + alert(data.message); + } + }); + }); +}; + +window.saveNewJalan = function(geoJsonStr, panjang, namaId, statusId) { + const nama = document.getElementById(namaId).value; + if (!nama) { + alert("Nama jalan harus diisi!"); + return false; + } + + const status = document.getElementById(statusId).value; + + const geom = JSON.parse(geoJsonStr); + + fetch('../jalan/create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nama, status, panjang, geom }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + closeModal(); + loadJalan(); + } else { + alert(data.message); + } + }); + return true; +}; + +// Initial Load +loadJalan(); + diff --git a/parsil/assets/js/features/parsil.js b/parsil/assets/js/features/parsil.js new file mode 100644 index 0000000..d8509da --- /dev/null +++ b/parsil/assets/js/features/parsil.js @@ -0,0 +1,159 @@ +// --- Fitur Parsil --- + +const parsilColors = { + 'SHM': '#28a745', // Hijau + 'HGB': '#17a2b8', // Biru Muda + 'HGU': '#ffc107', // Kuning + 'HP': '#fd7e14' // Oranye +}; + +function loadParsil() { + parsilLayer.clearLayers(); + fetch('../parsil/read.php') + .then(res => res.json()) + .then(data => { + if (data.status === 'success' && data.data) { + data.data.forEach(item => { + addParsilToMap(item); + }); + } + if (window.refreshActivePanel) window.refreshActivePanel(); + }); +} + +function addParsilToMap(item) { + // geom adalah GeoJSON Polygon + // coordinates pada Polygon formatnya: [[[lng, lat], [lng, lat], ...]] + // Leaflet Polygon butuh array of [lat, lng] + const latlngs = item.geom.coordinates[0].map(coord => [coord[1], coord[0]]); + + const polygon = L.polygon(latlngs, { + color: parsilColors[item.status] || '#3388ff', + fillColor: parsilColors[item.status] || '#3388ff', + fillOpacity: 0.5, + weight: 2 + }); + + polygon.parsilData = item; + + const d = item; + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + const popupContent = ` +
+

Parsil ${d.nama || ''}

+

Status: ${d.status}

+

Luas: ${d.luas.toFixed(2)} mยฒ

+ ${actionButtons} +
+ `; + polygon.bindPopup(popupContent); + + parsilLayer.addLayer(polygon); +} + +window.openEditParsilModal = function(id) { + let d = null; + parsilLayer.eachLayer(function(layer) { + if (layer.parsilData && layer.parsilData.id == id) d = layer.parsilData; + }); + if (!d) return; + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + map.closePopup(); + openModal("Edit Parsil Tanah", bodyHTML, function() { + window.saveEditParsil(d.id, 'editParsilNama', 'editParsilStatus'); + }); +}; + +window.saveEditParsil = function(id, namaId, statusId) { + const nama = document.getElementById(namaId).value; + const status = document.getElementById(statusId).value; + + fetch('../parsil/update.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, nama, status }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + closeModal(); + loadParsil(); + } else { + alert(data.message); + } + }); +}; + +window.deleteParsil = function(id) { + openConfirmModal("Yakin hapus parsil ini?", function() { + fetch('../parsil/delete.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + loadParsil(); + } else { + alert(data.message); + } + }); + }); +}; + +window.saveNewParsil = function(geoJsonStr, luas, namaId, statusId) { + const nama = document.getElementById(namaId).value; + const status = document.getElementById(statusId).value; + + const geom = JSON.parse(geoJsonStr); + + fetch('../parsil/create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nama, status, luas, geom }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + closeModal(); + loadParsil(); + } else { + alert(data.message); + } + }); + return true; +}; + +// Initial Load +loadParsil(); + diff --git a/parsil/assets/js/features/spbu.js b/parsil/assets/js/features/spbu.js new file mode 100644 index 0000000..07d168f --- /dev/null +++ b/parsil/assets/js/features/spbu.js @@ -0,0 +1,214 @@ +// --- Fitur SPBU --- + +// Emoji Bubble Icon builder +function makeSpbuIcon(is24) { + const cls = is24 ? 'spbu-24' : 'spbu-not24'; + return L.divIcon({ + className: '', + html: `
โ›ฝ
`, + iconSize: [38, 38], + iconAnchor: [19, 38], + popupAnchor: [0, -40] + }); +} + +const spbuGreenIcon = makeSpbuIcon(true); +const spbuRedIcon = makeSpbuIcon(false); + +let isDrawingMode = false; // Akan diset true saat draw mode aktif (jalan/parsil) + + +// Fetch SPBU +function loadSpbu() { + spbuLayer.clearLayers(); + fetch('../spbu/read.php') + .then(res => res.json()) + .then(data => { + if (data.status === 'success' && data.data) { + data.data.forEach(item => { + addSpbuMarker(item); + }); + } + if (window.refreshActivePanel) window.refreshActivePanel(); + }); +} + +function addSpbuMarker(item) { + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + const icon = makeSpbuIcon(item.is_24_jam); + const marker = L.marker([item.lat, item.lng], { + icon: icon, + draggable: isAdmin + }); + + marker.spbuData = item; // Simpan data di objek marker + + // Hitung popupContent sekali saat marker dibuat + const d = item; + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + const popupContent = ` +
+

SPBU ${d.nama}

+

No. WA: ${d.no_wa}

+

Buka 24 Jam: ${d.is_24_jam ? 'Ya' : 'Tidak'}

+ ${actionButtons} +
+ `; + marker.bindPopup(popupContent); + + // Event drag untuk update koordinat + marker.on('dragend', function(e) { + const newPos = marker.getLatLng(); + updateSpbu(item.id, item.nama, item.no_wa, item.is_24_jam, newPos.lat, newPos.lng); + }); + + spbuLayer.addLayer(marker); +} + +// Map Click -> Form Add +map.on('click', function(e) { + if (isDrawingMode) return; // Jangan muncul form jika sedang gambar garis/polygon + + // Hanya proses jika mode tambah SPBU aktif + if (window.currentAddMode === 'spbu') { + const bodyHTML = ` +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ `; + + openModal("Tambah SPBU Baru", bodyHTML, function() { + window.saveNewSpbu(e.latlng.lat, e.latlng.lng, 'modalSpbuNama', 'modalSpbuWa', 'modalSpbu24'); + }); + + // Nonaktifkan mode tambah setelah modal muncul + window.deactivateAddMode(); + } +}); + +window.saveNewSpbu = function(lat, lng, namaId, waId, radioName) { + const nama = document.getElementById(namaId).value; + if (!nama) { + alert("Nama SPBU harus diisi!"); + return; + } + const no_wa = document.getElementById(waId).value; + const is_24_jam = document.querySelector(`input[name="${radioName}"]:checked`).value; + + fetch('../spbu/create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nama, no_wa, is_24_jam, lat, lng }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + closeModal(); + loadSpbu(); + } else { + alert(data.message); + } + }); +}; + +window.openEditSpbuModal = function(id) { + let d = null; + spbuLayer.eachLayer(function(layer) { + if (layer.spbuData && layer.spbuData.id == id) d = layer.spbuData; + }); + if (!d) return; + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ + +
+
+ `; + map.closePopup(); + openModal("Edit SPBU", bodyHTML, function() { + window.saveEditSpbu(d.id, d.lat, d.lng, 'editSpbuNama', 'editSpbuWa', 'editSpbu24'); + }); +}; + +window.saveEditSpbu = function(id, lat, lng, namaId, waId, radioName) { + const nama = document.getElementById(namaId).value; + const no_wa = document.getElementById(waId).value; + const is_24_jam = document.querySelector(`input[name="${radioName}"]:checked`).value; + + updateSpbu(id, nama, no_wa, is_24_jam, lat, lng); +}; + +function updateSpbu(id, nama, no_wa, is_24_jam, lat, lng) { + fetch('../spbu/update.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, nama, no_wa, is_24_jam, lat, lng }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + closeModal(); + loadSpbu(); + } else { + alert(data.message); + } + }); +} + +window.deleteSpbu = function(id) { + openConfirmModal("Yakin hapus SPBU ini?", function() { + fetch('../spbu/delete.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + loadSpbu(); + } else { + alert(data.message); + } + }); + }); +}; + +// Initial Load +loadSpbu(); + diff --git a/parsil/assets/js/map.js b/parsil/assets/js/map.js new file mode 100644 index 0000000..db153cc --- /dev/null +++ b/parsil/assets/js/map.js @@ -0,0 +1,259 @@ +// Inisialisasi Peta +const map = L.map('map', { zoomControl: false }).setView([-0.0263, 109.3425], 13); + +// ===== Toast Notification ===== +window.showToast = function(message, type = 'success', duration = 3500) { + const icons = { success: 'โœ…', error: 'โŒ', info: 'โ„น๏ธ', warning: 'โš ๏ธ' }; + const container = document.getElementById('toastContainer'); + if (!container) return; + + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.innerHTML = `${icons[type] || 'โ„น๏ธ'}${message}`; + container.appendChild(toast); + + setTimeout(() => { + toast.classList.add('hiding'); + toast.addEventListener('animationend', () => toast.remove()); + }, duration); +}; + +// Custom Zoom Control Logic +document.getElementById('zoomInBtn').addEventListener('click', function() { map.zoomIn(); }); +document.getElementById('zoomOutBtn').addEventListener('click', function() { map.zoomOut(); }); + +// Base Map dari OpenStreetMap +L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' +}).addTo(map); + +// Inisialisasi FeatureGroups untuk masing-masing layer +const spbuLayer = L.featureGroup().addTo(map); +const jalanLayer = L.featureGroup().addTo(map); +const parsilLayer = L.featureGroup().addTo(map); +const rumahIbadahLayer = L.featureGroup().addTo(map); +const pendudukMiskinLayer = L.featureGroup().addTo(map); +const geoJsonLayer = L.featureGroup().addTo(map); + +const searchInput = document.getElementById('searchInput'); +const searchClear = document.getElementById('searchClear'); + +if (searchInput) { + searchInput.addEventListener('focus', function() { + if (typeof window.deactivateAddMode === 'function') window.deactivateAddMode(); + }); + + searchInput.addEventListener('input', function() { + const val = this.value.toLowerCase(); + if (val.length > 0) { + searchClear.style.visibility = 'visible'; + window.showSearchResults(val); + } else { + searchClear.style.visibility = 'hidden'; + document.getElementById('searchResults').style.display = 'none'; + } + }); +} + +if (searchClear) { + searchClear.addEventListener('click', function() { + searchInput.value = ''; + searchClear.style.visibility = 'hidden'; + document.getElementById('searchResults').style.display = 'none'; + searchInput.focus(); + }); +} + +window.showSearchResults = function(query) { + const resultsContainer = document.getElementById('searchResults'); + if (!resultsContainer) return; + resultsContainer.innerHTML = ''; + let results = []; + + if (typeof spbuLayer !== 'undefined') { + spbuLayer.eachLayer(layer => { + if (layer.spbuData && layer.spbuData.nama && layer.spbuData.nama.toLowerCase().includes(query)) { + results.push({ type: 'SPBU', nama: layer.spbuData.nama, layer: layer }); + } + }); + } + + if (typeof jalanLayer !== 'undefined') { + jalanLayer.eachLayer(layer => { + if (layer.jalanData && layer.jalanData.nama && layer.jalanData.nama.toLowerCase().includes(query)) { + results.push({ type: 'Jalan', nama: layer.jalanData.nama, layer: layer }); + } + }); + } + + if (typeof parsilLayer !== 'undefined') { + parsilLayer.eachLayer(layer => { + if (layer.parsilData && layer.parsilData.nama && layer.parsilData.nama.toLowerCase().includes(query)) { + results.push({ type: 'Parsil', nama: layer.parsilData.nama, layer: layer }); + } + }); + } + + if (results.length === 0) { + resultsContainer.innerHTML = '
Tidak ada hasil
'; + } else { + results.forEach(res => { + const item = document.createElement('div'); + item.style.padding = '10px 15px'; + item.style.cursor = 'pointer'; + item.style.borderBottom = '1px solid #eee'; + item.style.fontSize = '14px'; + item.innerHTML = `${res.type}: ${res.nama}`; + item.addEventListener('mouseenter', () => item.style.backgroundColor = '#f8f9fa'); + item.addEventListener('mouseleave', () => item.style.backgroundColor = 'white'); + item.addEventListener('click', () => { + if (res.layer instanceof L.Marker) { + map.setView(res.layer.getLatLng(), 17); + } else if (res.layer.getBounds) { + map.fitBounds(res.layer.getBounds()); + } + res.layer.openPopup(); + resultsContainer.style.display = 'none'; + }); + resultsContainer.appendChild(item); + }); + } + resultsContainer.style.display = 'block'; +}; + +// UI Logic: Custom Layer Control Toggle +const layerBtn = document.getElementById('layerBtn'); +const layerPanel = document.getElementById('layerPanel'); + +if (layerBtn && layerPanel) { + layerBtn.addEventListener('click', function() { + if (typeof window.deactivateAddMode === 'function') window.deactivateAddMode(); + layerPanel.style.display = layerPanel.style.display === 'block' ? 'none' : 'block'; + }); +} + +// Sembunyikan layer panel saat klik di peta +map.on('click', function() { + if (layerPanel) layerPanel.style.display = 'none'; +}); + +// Logic untuk Toggle Layer Visibility +const setupLayerToggle = (id, layer) => { + const el = document.getElementById(id); + if (el) { + el.addEventListener('change', function(e) { + e.target.checked ? map.addLayer(layer) : map.removeLayer(layer); + }); + } +}; + +setupLayerToggle('layerSpbu', spbuLayer); +setupLayerToggle('layerJalan', jalanLayer); +setupLayerToggle('layerParsil', parsilLayer); +setupLayerToggle('layerRumahIbadah', rumahIbadahLayer); +setupLayerToggle('layerMiskin', pendudukMiskinLayer); + +// --- Sub-layer Toggle (expand/collapse) --- +window.toggleSubLayer = function(subId, iconEl) { + const sub = document.getElementById(subId); + if (!sub) return; + const isHidden = sub.style.display === 'none'; + sub.style.display = isHidden ? '' : 'none'; + iconEl.classList.toggle('collapsed', !isHidden); +}; + +// --- Sub-layer Filter --- +window.applySubFilter = function(type) { + if (type === 'spbu') { + const checked = [...document.querySelectorAll('.sub-spbu:checked')].map(el => el.value === '1'); + spbuLayer.eachLayer(layer => { + if (layer.spbuData === undefined) return; + if (checked.includes(layer.spbuData.is_24_jam)) { + layer.getElement && layer.getElement() && (layer.getElement().style.display = ''); + } else { + layer.getElement && layer.getElement() && (layer.getElement().style.display = 'none'); + } + }); + } else if (type === 'jalan') { + const checked = [...document.querySelectorAll('.sub-jalan:checked')].map(el => el.value); + jalanLayer.eachLayer(layer => { + if (!layer.jalanData) return; + if (checked.includes(layer.jalanData.status)) { + layer.getElement && layer.getElement() && (layer.getElement().style.display = ''); + } else { + layer.getElement && layer.getElement() && (layer.getElement().style.display = 'none'); + } + }); + } else if (type === 'parsil') { + const checked = [...document.querySelectorAll('.sub-parsil:checked')].map(el => el.value); + parsilLayer.eachLayer(layer => { + if (!layer.parsilData) return; + if (checked.includes(layer.parsilData.status)) { + layer.getElement && layer.getElement() && (layer.getElement().style.display = ''); + } else { + layer.getElement && layer.getElement() && (layer.getElement().style.display = 'none'); + } + }); + } +}; + +// --- Modal Logic --- +const unifiedModal = document.getElementById('unifiedModal'); +const modalTitle = document.getElementById('modalTitle'); +const modalBody = document.getElementById('modalBody'); + +window.openModal = function(title, bodyHTML, saveCallback) { + if (!unifiedModal) return; + modalTitle.textContent = title; + modalBody.innerHTML = bodyHTML; + unifiedModal.classList.add('show'); + + const currentSaveBtn = document.getElementById('modalSaveBtn'); + if (currentSaveBtn) { + const newSaveBtn = currentSaveBtn.cloneNode(true); + currentSaveBtn.parentNode.replaceChild(newSaveBtn, currentSaveBtn); + newSaveBtn.addEventListener('click', saveCallback); + } +}; + +window.closeModal = function() { + if (unifiedModal) unifiedModal.classList.remove('show'); +}; + +const confirmModal = document.getElementById('confirmModal'); +const confirmMessage = document.getElementById('confirmMessage'); + +window.openConfirmModal = function(msg, confirmCallback) { + if (!confirmModal) return; + confirmMessage.textContent = msg; + confirmModal.classList.add('show'); + + const currentYesBtn = document.getElementById('confirmYesBtn'); + if (currentYesBtn) { + const newYesBtn = currentYesBtn.cloneNode(true); + currentYesBtn.parentNode.replaceChild(newYesBtn, currentYesBtn); + newYesBtn.addEventListener('click', function() { + confirmModal.classList.remove('show'); + confirmCallback(); + }); + } +}; + +window.closeConfirmModal = function() { + if (confirmModal) confirmModal.classList.remove('show'); +}; + +window.currentAddMode = null; // 'spbu', 'jalan', 'parsil' +window.activateAddMode = function(mode) { + if (window.currentAddMode === mode) { + window.deactivateAddMode(); + return; + } + window.currentAddMode = mode; + document.getElementById('map').style.cursor = 'crosshair'; +}; + +window.deactivateAddMode = function() { + window.currentAddMode = null; + document.getElementById('map').style.cursor = ''; +}; diff --git a/parsil/assets/js/panel.js b/parsil/assets/js/panel.js new file mode 100644 index 0000000..1aa68d6 --- /dev/null +++ b/parsil/assets/js/panel.js @@ -0,0 +1,270 @@ +// ===== Panel Management ===== +// Mengelola Left Sidebar Toggle + Right Panel + Layer Control + +(function () { + // ---- State ---- + let activePanel = null; + + // ---- Elements ---- + const sidebarToggleBtn = document.getElementById('sidebarToggleBtn'); + const sidebarToggleIcon = document.getElementById('sidebarToggleIcon'); + const leftSidebar = document.getElementById('leftSidebar'); + const rightPanel = document.getElementById('rightPanel'); + const rightPanelTitle = document.getElementById('rightPanelTitle'); + const rightPanelActions = document.getElementById('rightPanelActions'); + const rightPanelBody = document.getElementById('rightPanelBody'); + const rightPanelClose = document.getElementById('rightPanelClose'); + + // ---- Sidebar Toggle ---- + if (sidebarToggleBtn) { + sidebarToggleBtn.addEventListener('click', function () { + const isOpen = leftSidebar.style.display !== 'none'; + if (isOpen) { + leftSidebar.style.display = 'none'; + sidebarToggleBtn.classList.remove('open'); + sidebarToggleIcon.className = 'fas fa-chevron-right'; + } else { + leftSidebar.style.display = 'flex'; + sidebarToggleBtn.classList.add('open'); + sidebarToggleIcon.className = 'fas fa-chevron-left'; + } + }); + } + + // ---- Sidebar Buttons ---- + document.querySelectorAll('.sidebar-btn').forEach(btn => { + btn.addEventListener('click', function () { + const panel = this.dataset.panel; + if (activePanel === panel) { + closePanel(); + } else { + openPanel(panel); + } + }); + }); + + if (rightPanelClose) { + rightPanelClose.addEventListener('click', closePanel); + } + + // ---- Open / Close Panel ---- + function openPanel(panel) { + if (typeof window.deactivateAddMode === 'function') window.deactivateAddMode(); + activePanel = panel; + rightPanel.classList.add('open'); + document.body.classList.add('right-panel-open'); + document.querySelectorAll('.sidebar-btn').forEach(b => b.classList.remove('active')); + const activeBtn = document.querySelector(`.sidebar-btn[data-panel="${panel}"]`); + if (activeBtn) activeBtn.classList.add('active'); + renderPanel(panel); + } + + function closePanel() { + if (typeof window.deactivateAddMode === 'function') window.deactivateAddMode(); + activePanel = null; + rightPanel.classList.remove('open'); + document.body.classList.remove('right-panel-open'); + document.querySelectorAll('.sidebar-btn').forEach(b => b.classList.remove('active')); + } + + window.refreshActivePanel = function () { + if (activePanel && activePanel !== 'layer') renderPanel(activePanel); + }; + + // ---- Render Panel by Type ---- + function renderPanel(type) { + rightPanelBody.innerHTML = '
Memuat data...
'; + rightPanelActions.innerHTML = ''; + + switch (type) { + case 'spbu': renderSpbuPanel(); break; + case 'jalan': renderJalanPanel(); break; + case 'parsil': renderParsilPanel(); break; + } + } + + // ========================== + // ===== SPBU Panel ======= + // ========================== + function renderSpbuPanel() { + rightPanelTitle.textContent = 'โ›ฝ Daftar SPBU'; + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + if (isAdmin) { + rightPanelActions.innerHTML = ` + `; + document.getElementById('btnPanelAddSpbu').addEventListener('click', () => { + window.activateAddMode('spbu'); + }); + } + + const items = []; + spbuLayer.eachLayer(l => { + if (l.spbuData) items.push(l.spbuData); + }); + + if (!items.length) { + rightPanelBody.innerHTML = '
Belum ada data SPBU
'; + return; + } + + rightPanelBody.innerHTML = ''; + items.forEach(d => { + const card = document.createElement('div'); + card.className = 'data-card'; + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + card.innerHTML = ` +
+
โ›ฝ
+
+
${escHtml(d.nama)}
+
๐Ÿ“ž ${escHtml(d.no_wa || '-')} โ€ข ๐Ÿ• ${d.is_24_jam ? '24 Jam' : 'Tidak 24 Jam'}
+
+ ${actionButtons} +
`; + if (isAdmin) { + card.querySelector('.btn-card-edit').addEventListener('click', (e) => { e.stopPropagation(); window.openEditSpbuModal(d.id); }); + card.querySelector('.btn-card-delete').addEventListener('click', (e) => { e.stopPropagation(); window.deleteSpbu(d.id); }); + } + card.addEventListener('click', () => { + spbuLayer.eachLayer(l => { + if (l.spbuData && l.spbuData.id == d.id) { + map.setView(l.getLatLng(), 17); l.openPopup(); + } + }); + }); + rightPanelBody.appendChild(card); + }); + } + + // ========================== + // ===== Jalan Panel ======= + // ========================== + function renderJalanPanel() { + rightPanelTitle.textContent = '๐Ÿ›ฃ๏ธ Daftar Jalan'; + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + + rightPanelActions.innerHTML = ''; + + const items = []; + jalanLayer.eachLayer(l => { + if (l.jalanData) items.push(l.jalanData); + }); + + if (!items.length) { + rightPanelBody.innerHTML = '
Belum ada data Jalan
'; + return; + } + + rightPanelBody.innerHTML = ''; + items.forEach(d => { + const card = document.createElement('div'); + card.className = 'data-card'; + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + card.innerHTML = ` +
+
๐Ÿ›ฃ๏ธ
+
+
${escHtml(d.nama)}
+
๐Ÿ“ ${d.panjang.toFixed(2)} m โ€ข ๐Ÿท๏ธ ${escHtml(d.status)}
+
+ ${actionButtons} +
`; + if (isAdmin) { + card.querySelector('.btn-card-edit').addEventListener('click', (e) => { e.stopPropagation(); window.openEditJalanModal(d.id); }); + card.querySelector('.btn-card-delete').addEventListener('click', (e) => { e.stopPropagation(); window.deleteJalan(d.id); }); + } + card.addEventListener('click', () => { + jalanLayer.eachLayer(l => { + if (l.jalanData && l.jalanData.id == d.id) { + map.fitBounds(l.getBounds()); l.openPopup(); + } + }); + }); + rightPanelBody.appendChild(card); + }); + } + + // ========================== + // ===== Parsil Panel ======= + // ========================== + function renderParsilPanel() { + rightPanelTitle.textContent = '๐Ÿ—บ๏ธ Daftar Parsil Tanah'; + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + + rightPanelActions.innerHTML = ''; + + const items = []; + parsilLayer.eachLayer(l => { + if (l.parsilData) items.push(l.parsilData); + }); + + if (!items.length) { + rightPanelBody.innerHTML = '
Belum ada data Parsil
'; + return; + } + + rightPanelBody.innerHTML = ''; + items.forEach(d => { + const card = document.createElement('div'); + card.className = 'data-card'; + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + card.innerHTML = ` +
+
๐Ÿ—บ๏ธ
+
+
${escHtml(d.nama || 'Tanpa Nama')}
+
๐Ÿ“ ${d.luas.toFixed(2)} mยฒ โ€ข ๐Ÿท๏ธ ${escHtml(d.status)}
+
+ ${actionButtons} +
`; + if (isAdmin) { + card.querySelector('.btn-card-edit').addEventListener('click', (e) => { e.stopPropagation(); window.openEditParsilModal(d.id); }); + card.querySelector('.btn-card-delete').addEventListener('click', (e) => { e.stopPropagation(); window.deleteParsil(d.id); }); + } + card.addEventListener('click', () => { + parsilLayer.eachLayer(l => { + if (l.parsilData && l.parsilData.id == d.id) { + map.fitBounds(l.getBounds()); l.openPopup(); + } + }); + }); + rightPanelBody.appendChild(card); + }); + } + + // ---- Utility ---- + function escHtml(str) { + if (!str) return ''; + return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + // ---- Expose ---- + window.openRightPanel = openPanel; + +})(); diff --git a/parsil/create.php b/parsil/create.php new file mode 100644 index 0000000..2ebb088 --- /dev/null +++ b/parsil/create.php @@ -0,0 +1,25 @@ +geom)) { + $nama = $conn->real_escape_string($data->nama ?? ''); + $status = $conn->real_escape_string($data->status ?? 'SHM'); + $luas = (float)($data->luas ?? 0); + $geom = $conn->real_escape_string(json_encode($data->geom)); // GeoJSON string + + $query = "INSERT INTO parsil (nama, status, luas, geom) VALUES ('$nama', '$status', $luas, ST_GeomFromGeoJSON('$geom'))"; + + if($conn->query($query)) { + echo json_encode(["status" => "success", "id" => $conn->insert_id, "message" => "Parsil berhasil ditambahkan."]); + } else { + echo json_encode(["status" => "error", "message" => "Gagal menambahkan parsil: " . $conn->error]); + } +} else { + echo json_encode(["status" => "error", "message" => "Data geometri tidak lengkap."]); +} +?> diff --git a/parsil/delete.php b/parsil/delete.php new file mode 100644 index 0000000..aca7cf9 --- /dev/null +++ b/parsil/delete.php @@ -0,0 +1,22 @@ +id)) { + $id = (int)$data->id; + + $query = "DELETE FROM parsil WHERE id=$id"; + + if($conn->query($query)) { + echo json_encode(["status" => "success", "message" => "Parsil berhasil dihapus."]); + } else { + echo json_encode(["status" => "error", "message" => "Gagal hapus parsil: " . $conn->error]); + } +} else { + echo json_encode(["status" => "error", "message" => "ID tidak diberikan."]); +} +?> diff --git a/parsil/index.php b/parsil/index.php new file mode 100644 index 0000000..2c74118 --- /dev/null +++ b/parsil/index.php @@ -0,0 +1,388 @@ + + + + + + WebGIS Pemetaan Jaringan Jalan + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + +
+ + +
+ + + + + + + + + + + + +
+ +
+

Daftar Layer

+ +
Infrastruktur & Lahan
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + + + +
+
+ GeoJSON Eksternal + +
+ +
+
+ + +
+
+

Data

+ +
+
+
+
Pilih menu di kiri untuk menampilkan data
+
+
+
+ + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/parsil/read.php b/parsil/read.php new file mode 100644 index 0000000..f24bacf --- /dev/null +++ b/parsil/read.php @@ -0,0 +1,25 @@ +query($query); + +$parsil_arr = array(); + +if($result->num_rows > 0) { + while($row = $result->fetch_assoc()) { + $parsil_item = array( + "id" => $row['id'], + "nama" => $row['nama'], + "status" => $row['status'], + "luas" => (float)$row['luas'], + "geom" => json_decode($row['geom']) + ); + array_push($parsil_arr, $parsil_item); + } + echo json_encode(["status" => "success", "data" => $parsil_arr]); +} else { + echo json_encode(["status" => "success", "data" => []]); +} +?> diff --git a/parsil/update.php b/parsil/update.php new file mode 100644 index 0000000..9e27353 --- /dev/null +++ b/parsil/update.php @@ -0,0 +1,31 @@ +id)) { + $id = (int)$data->id; + $nama = $conn->real_escape_string($data->nama ?? ''); + $status = $conn->real_escape_string($data->status ?? 'SHM'); + + // Jika ada update geometri + if(!empty($data->geom)) { + $luas = (float)($data->luas ?? 0); + $geom = $conn->real_escape_string(json_encode($data->geom)); + $query = "UPDATE parsil SET nama='$nama', status='$status', luas=$luas, geom=ST_GeomFromGeoJSON('$geom') WHERE id=$id"; + } else { + $query = "UPDATE parsil SET nama='$nama', status='$status' WHERE id=$id"; + } + + if($conn->query($query)) { + echo json_encode(["status" => "success", "message" => "Parsil berhasil diupdate."]); + } else { + echo json_encode(["status" => "error", "message" => "Gagal update parsil: " . $conn->error]); + } +} else { + echo json_encode(["status" => "error", "message" => "ID tidak diberikan."]); +} +?> diff --git a/spbu.js b/spbu.js new file mode 100644 index 0000000..a072270 --- /dev/null +++ b/spbu.js @@ -0,0 +1,213 @@ +// --- Fitur SPBU --- + +// Emoji Bubble Icon builder +function makeSpbuIcon(is24) { + const cls = is24 ? 'spbu-24' : 'spbu-not24'; + return L.divIcon({ + className: '', + html: `
โ›ฝ
`, + iconSize: [38, 38], + iconAnchor: [19, 38], + popupAnchor: [0, -40] + }); +} + +const spbuGreenIcon = makeSpbuIcon(true); +const spbuRedIcon = makeSpbuIcon(false); + +let isDrawingMode = false; // Akan diset true saat draw mode aktif (jalan/parsil) + + +// Fetch SPBU +function loadSpbu() { + spbuLayer.clearLayers(); + fetch('api/spbu/read.php') + .then(res => res.json()) + .then(data => { + if (data.status === 'success' && data.data) { + data.data.forEach(item => { + addSpbuMarker(item); + }); + } + if (window.refreshActivePanel) window.refreshActivePanel(); + }); +} + +function addSpbuMarker(item) { + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + const icon = makeSpbuIcon(item.is_24_jam); + const marker = L.marker([item.lat, item.lng], { + icon: icon, + draggable: isAdmin + }); + + marker.spbuData = item; // Simpan data di objek marker + + // Hitung popupContent sekali saat marker dibuat + const d = item; + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + const popupContent = ` +
+

SPBU ${d.nama}

+

No. WA: ${d.no_wa}

+

Buka 24 Jam: ${d.is_24_jam ? 'Ya' : 'Tidak'}

+ ${actionButtons} +
+ `; + marker.bindPopup(popupContent); + + // Event drag untuk update koordinat + marker.on('dragend', function(e) { + const newPos = marker.getLatLng(); + updateSpbu(item.id, item.nama, item.no_wa, item.is_24_jam, newPos.lat, newPos.lng); + }); + + spbuLayer.addLayer(marker); +} + +// Map Click -> Form Add +map.on('click', function(e) { + if (isDrawingMode) return; // Jangan muncul form jika sedang gambar garis/polygon + + // Hanya proses jika mode tambah SPBU aktif + if (window.currentAddMode === 'spbu') { + const bodyHTML = ` +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ `; + + openModal("Tambah SPBU Baru", bodyHTML, function() { + window.saveNewSpbu(e.latlng.lat, e.latlng.lng, 'modalSpbuNama', 'modalSpbuWa', 'modalSpbu24'); + }); + + // Nonaktifkan mode tambah setelah modal muncul + window.deactivateAddMode(); + } +}); + +window.saveNewSpbu = function(lat, lng, namaId, waId, radioName) { + const nama = document.getElementById(namaId).value; + if (!nama) { + alert("Nama SPBU harus diisi!"); + return; + } + const no_wa = document.getElementById(waId).value; + const is_24_jam = document.querySelector(`input[name="${radioName}"]:checked`).value; + + fetch('api/spbu/create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nama, no_wa, is_24_jam, lat, lng }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + closeModal(); + loadSpbu(); + } else { + alert(data.message); + } + }); +}; + +window.openEditSpbuModal = function(id) { + let d = null; + spbuLayer.eachLayer(function(layer) { + if (layer.spbuData && layer.spbuData.id == id) d = layer.spbuData; + }); + if (!d) return; + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ + +
+
+ `; + map.closePopup(); + openModal("Edit SPBU", bodyHTML, function() { + window.saveEditSpbu(d.id, d.lat, d.lng, 'editSpbuNama', 'editSpbuWa', 'editSpbu24'); + }); +}; + +window.saveEditSpbu = function(id, lat, lng, namaId, waId, radioName) { + const nama = document.getElementById(namaId).value; + const no_wa = document.getElementById(waId).value; + const is_24_jam = document.querySelector(`input[name="${radioName}"]:checked`).value; + + updateSpbu(id, nama, no_wa, is_24_jam, lat, lng); +}; + +function updateSpbu(id, nama, no_wa, is_24_jam, lat, lng) { + fetch('api/spbu/update.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, nama, no_wa, is_24_jam, lat, lng }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + closeModal(); + loadSpbu(); + } else { + alert(data.message); + } + }); +} + +window.deleteSpbu = function(id) { + openConfirmModal("Yakin hapus SPBU ini?", function() { + fetch('api/spbu/delete.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + loadSpbu(); + } else { + alert(data.message); + } + }); + }); +}; + +// Initial Load +loadSpbu(); diff --git a/spbu/assets/css/style.css b/spbu/assets/css/style.css new file mode 100644 index 0000000..ed4eefc --- /dev/null +++ b/spbu/assets/css/style.css @@ -0,0 +1,1042 @@ +/* Reset & Base */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body, html { + height: 100%; + font-family: 'Google Sans Flex', 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + overflow: hidden; +} + +/* Map Container */ +#map { + width: 100vw; + height: 100vh; + z-index: 1; +} + +/* Custom UI Container over Map */ +.ui-container { + position: absolute; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 15px; +} + +/* Search Bar */ +.search-bar { + display: flex; + align-items: center; + background-color: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + overflow: hidden; + height: 48px; + transition: box-shadow 0.3s; +} + +.search-bar:focus-within { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); +} + +.search-icon { + padding: 0 15px; + color: #666; + display: flex; + align-items: center; + justify-content: center; +} + +.search-input { + flex: 1; + border: none; + outline: none; + font-size: 16px; + padding: 10px 0; + color: #333; +} + +.search-clear { + padding: 0 15px; + color: #999; + cursor: pointer; + background: none; + border: none; + display: flex; + align-items: center; + justify-content: center; + visibility: hidden; +} + +.search-clear:hover { + color: #333; +} + +/* Custom Layer Control Button */ +.custom-layer-btn { + background-color: white; + border-radius: 8px; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + cursor: pointer; + border: none; + color: #333; +} + +.custom-layer-btn:hover { + background-color: #f8f9fa; +} + +#layerBtn { + position: absolute; + top: 20px; + right: 20px; + z-index: 1000; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +body.right-panel-open #layerBtn { + right: 340px; +} + +.custom-layer-panel { + position: absolute; + top: 80px; + right: 20px; + z-index: 1000; + background-color: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 15px; + width: 250px; + display: none; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +body.right-panel-open .custom-layer-panel { + right: 340px; +} + + +.custom-layer-panel h3 { + margin-bottom: 10px; + font-size: 16px; + border-bottom: 1px solid #eee; + padding-bottom: 5px; +} + +.layer-section-title { + font-size: 11px; + font-weight: 700; + color: #6366f1; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; + margin-top: 12px; + padding-bottom: 2px; + border-bottom: 1px dashed #e2e8f0; +} + +.layer-option { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; + font-size: 14px; +} + +/* ===== Sidebar Toggle Button ===== */ +.sidebar-toggle-btn { + position: absolute; + left: 20px; + top: 190px; + z-index: 1001; + border-radius: 8px; + width: 48px; + height: 48px; + background: white; + border: none; + box-shadow: 0 3px 10px rgba(0,0,0,0.15); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #6366f1; + font-size: 13px; + transition: all 0.2s; +} + +.sidebar-toggle-btn:hover { + background: #6366f1; + color: white; + box-shadow: 0 4px 14px rgba(99,102,241,0.4); +} + +.sidebar-toggle-btn.open { + background: #6366f1; + color: white; +} + +/* ===== Left Sidebar ===== */ +.left-sidebar { + position: absolute; + left: 20px; + top: 250px; /* di bawah toggle button */ + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; +} + +.sidebar-section-label { + font-size: 9px; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + text-align: center; + letter-spacing: 0.8px; + margin-top: 6px; + margin-bottom: 2px; + pointer-events: none; + user-select: none; +} + +.sidebar-divider { + height: 1px; + background: #e2e8f0; + margin: 4px 6px; +} + +.sidebar-btn { + width: 60px; + height: 60px; + background: white; + border: none; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0,0,0,0.12); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + transition: all 0.2s ease; + padding: 6px 4px; +} + +.sidebar-btn:hover { + transform: translateX(3px); + box-shadow: 0 6px 18px rgba(0,0,0,0.18); + background: #f8f9fa; +} + +.sidebar-btn.active { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + box-shadow: 0 6px 18px rgba(99,102,241,0.4); + transform: translateX(4px); +} + +.sidebar-emoji { + font-size: 22px; + line-height: 1; +} + +.sidebar-label { + font-size: 9px; + font-weight: 600; + letter-spacing: 0.3px; + color: #555; + text-transform: uppercase; +} + +.sidebar-btn.active .sidebar-label { + color: rgba(255,255,255,0.85); +} + +/* ===== Right Panel ===== */ +.right-panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 320px; + background: #f8f9fb; + z-index: 999; + box-shadow: -4px 0 20px rgba(0,0,0,0.12); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + /* shift layer control when panel open */ +} + +.right-panel.open { + transform: translateX(0); +} + +.right-panel-header { + padding: 16px 16px 12px; + background: white; + border-bottom: 1px solid #eee; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + padding-top: 26px; +} + +.right-panel-header h3 { + font-size: 15px; + font-weight: 700; + color: #1e1e2e; +} + +.right-panel-close { + width: 30px; + height: 30px; + border: none; + background: #f3f4f6; + border-radius: 8px; + cursor: pointer; + color: #555; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + transition: background 0.2s; +} + +.right-panel-close:hover { + background: #e5e7eb; + color: #333; +} + +.right-panel-actions { + padding: 12px 14px; + background: white; + border-bottom: 1px solid #eee; + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.right-panel-actions button { + flex: 1; + padding: 9px 10px; + border: none; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: all 0.2s; +} + +.btn-panel-add { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + box-shadow: 0 3px 10px rgba(99,102,241,0.3); +} + +.btn-panel-add:hover { + box-shadow: 0 5px 14px rgba(99,102,241,0.45); + transform: translateY(-1px); +} + +.btn-panel-import { + background: #f0fdf4; + color: #16a34a; + border: 1px solid #bbf7d0 !important; +} + +.btn-panel-import:hover { + background: #dcfce7; +} + +.right-panel-body { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Panel Section Heading */ +.panel-section-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #9ca3af; + padding: 4px 2px 6px; + border-bottom: 1px solid #e5e7eb; + margin-top: 4px; +} + +/* Panel Empty State */ +.panel-empty { + text-align: center; + color: #aaa; + font-size: 13px; + padding: 40px 10px; +} + +/* ===== Data Card ===== */ +.data-card { + background: white; + border-radius: 12px; + padding: 12px 12px 10px; + box-shadow: 0 1px 4px rgba(0,0,0,0.07); + transition: box-shadow 0.2s; + cursor: pointer; +} + +.data-card:hover { + box-shadow: 0 3px 12px rgba(0,0,0,0.12); +} + +.data-card-main { + display: flex; + align-items: center; + gap: 10px; +} + +.data-card-icon { + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.card-icon-spbu { background: #f0fdf4; } +.card-icon-jalan { background: #eff6ff; } +.card-icon-parsil { background: #fefce8; } +.card-icon-ibadah { background: #fff7ed; } +.card-icon-miskin-out { background: #f0fdf4; } +.card-icon-miskin-in { background: #fef2f2; } + +.data-card-info { + flex: 1; + min-width: 0; +} + +.data-card-name { + font-size: 13px; + font-weight: 600; + color: #1e1e2e; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.data-card-sub { + font-size: 11px; + color: #6b7280; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.data-card-badge { + font-size: 10px; + font-weight: 600; + padding: 2px 7px; + border-radius: 99px; + margin-top: 4px; + display: inline-block; +} + +.badge-green { background: #dcfce7; color: #16a34a; } +.badge-red { background: #fee2e2; color: #dc2626; } +.badge-blue { background: #dbeafe; color: #2563eb; } +.badge-orange { background: #ffedd5; color: #ea580c; } +.badge-yellow { background: #fef9c3; color: #ca8a04; } + +.data-card-actions { + display: flex; + gap: 6px; + flex-shrink: 0; + align-items: center; +} + +.btn-card-action { + width: 30px; + height: 30px; + border: none; + border-radius: 7px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: all 0.15s; +} + +.btn-card-edit { + background: #eff6ff; + color: #3b82f6; +} + +.btn-card-edit:hover { + background: #3b82f6; + color: white; +} + +.btn-card-delete { + background: #fef2f2; + color: #ef4444; +} + +.btn-card-delete:hover { + background: #ef4444; + color: white; +} + +/* Expandable card (miskin) */ +.data-card-expand { + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease, padding 0.2s; + border-top: 0px solid #f3f4f6; +} + +.data-card.expanded .data-card-expand { + max-height: 400px; + border-top: 1px solid #f3f4f6; + margin-top: 10px; + padding-top: 10px; +} + +.expand-row { + display: flex; + justify-content: space-between; + font-size: 12px; + color: #6b7280; + padding: 3px 0; +} + +.expand-row span:first-child { + font-weight: 500; + color: #374151; +} + +.btn-log-bantuan { + margin-top: 10px; + width: 100%; + padding: 8px; + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + border: none; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: all 0.2s; +} + +.btn-log-bantuan:hover { + box-shadow: 0 4px 12px rgba(99,102,241,0.4); +} + +/* Log Bantuan Item */ +.log-item { + background: #f8f9fb; + border-radius: 8px; + padding: 10px 12px; + display: flex; + gap: 10px; + align-items: flex-start; +} + +.log-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #6366f1; + flex-shrink: 0; + margin-top: 5px; +} + +.log-info { + flex: 1; +} + +.log-type { + font-size: 12px; + font-weight: 600; + color: #1e1e2e; +} + +.log-meta { + font-size: 11px; + color: #9ca3af; + margin-top: 2px; +} + +.log-keterangan { + font-size: 11px; + color: #6b7280; + margin-top: 3px; + font-style: italic; +} + +/* Popup Form */ +.popup-form { + display: flex; + flex-direction: column; + gap: 10px; + width: 200px; +} + +.popup-form label { + font-size: 12px; + font-weight: bold; + color: #555; +} + +.popup-form input[type="text"], +.popup-form input[type="number"] { + width: 100%; + padding: 6px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.popup-form .radio-group { + display: flex; + gap: 10px; +} + +.popup-form button { + padding: 8px; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; +} + +.popup-form button.btn-danger { + background-color: #dc3545; +} + +.popup-form button:hover { + opacity: 0.9; +} + +/* Unified Modal */ +.unified-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); + z-index: 2000; + align-items: center; + justify-content: center; +} + +.unified-modal.show { + display: flex; +} + +.modal-content { + background-color: white; + border-radius: 12px; + width: 400px; + max-width: 90%; + box-shadow: 0 8px 30px rgba(0,0,0,0.2); + display: flex; + flex-direction: column; +} + +.modal-header { + padding: 15px; + border-bottom: 1px solid #eee; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + font-size: 16px; + margin: 0; +} + +.modal-close { + font-size: 20px; + font-weight: bold; + cursor: pointer; + color: #888; +} + +.modal-close:hover { + color: #333; +} + +.modal-body { + padding: 15px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.modal-body label { + font-size: 13px; + font-weight: 600; + color: #444; +} + +.modal-body input[type="text"], +.modal-body input[type="number"], +.modal-body input[type="date"], +.modal-body select, +.modal-body textarea { + width: 100%; + padding: 8px; + border: 1px solid #ccc; + border-radius: 6px; + font-size: 14px; + font-family: inherit; +} + +.modal-footer { + padding: 15px; + border-top: 1px solid #eee; + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.btn-save { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + border: none; + padding: 8px 18px; + border-radius: 7px; + cursor: pointer; + font-weight: 600; + font-size: 13px; +} + +.btn-cancel { + background-color: #f8f9fa; + border: 1px solid #ddd; + padding: 8px 18px; + border-radius: 7px; + cursor: pointer; + font-size: 13px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Custom Cursor Tooltip */ +.custom-cursor-tooltip { + position: absolute; + display: none; + background-color: rgba(0, 0, 0, 0.75); + color: white; + padding: 6px 12px; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + pointer-events: none; + z-index: 9999; + white-space: nowrap; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); +} + +/* Sembunyikan default tooltip Leaflet Draw */ +.leaflet-draw-tooltip { + display: none !important; +} + +/* Sub-layer collapsible */ +.layer-group-item { + margin-bottom: 6px; +} + +.layer-group-item > .layer-group-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.layer-toggle-icon { + cursor: pointer; + padding: 2px 6px; + color: #666; + font-size: 11px; + border-radius: 4px; + transition: background 0.2s; + flex-shrink: 0; +} + +.layer-toggle-icon:hover { + background: #f0f0f0; + color: #333; +} + +.layer-toggle-icon i { + transition: transform 0.2s; +} + +.layer-toggle-icon.collapsed i { + transform: rotate(-90deg); +} + +.sub-layer-list { + padding-left: 18px; + margin-top: 4px; + border-left: 2px solid #eee; + overflow: hidden; + transition: max-height 0.2s ease; +} + +.sub-option { + font-size: 12px !important; + color: #555; + margin-bottom: 3px !important; +} + +/* ===== Emoji Marker (Pin Bubble) ===== */ +.emoji-marker { + display: flex; + flex-direction: column; + align-items: center; + pointer-events: auto; /* changed from none to allow click/drag */ +} + +.emoji-marker .bubble { + width: 38px; + height: 38px; + border-radius: 50% 50% 50% 0; + transform: rotate(-45deg); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 3px 10px rgba(0,0,0,0.25); + border: 2px solid rgba(255,255,255,0.8); + animation: markerPop 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275) both; +} + +.emoji-marker .bubble span { + transform: rotate(45deg); + font-size: 18px; + line-height: 1; +} + +@keyframes markerPop { + 0% { transform: rotate(-45deg) scale(0); } + 100% { transform: rotate(-45deg) scale(1); } +} + +/* Warna bubble per tipe */ +.emoji-marker .bubble.spbu-24 { background: linear-gradient(135deg, #22c55e, #16a34a); } +.emoji-marker .bubble.spbu-not24 { background: linear-gradient(135deg, #ef4444, #dc2626); } +.emoji-marker .bubble.ibadah { background: linear-gradient(135deg, #f97316, #ea580c); } +.emoji-marker .bubble.miskin-in { background: linear-gradient(135deg, #ef4444, #dc2626); } +.emoji-marker .bubble.miskin-out { background: linear-gradient(135deg, #22c55e, #16a34a); } + +/* ===== Toast Notification ===== */ +#toastContainer { + position: fixed; + bottom: 28px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + border-radius: 12px; + font-size: 13px; + font-weight: 500; + color: white; + box-shadow: 0 6px 24px rgba(0,0,0,0.18); + backdrop-filter: blur(6px); + pointer-events: auto; + animation: toastSlideIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) both; + max-width: 340px; + text-align: center; +} + +.toast.hiding { + animation: toastSlideOut 0.3s ease forwards; +} + +.toast-success { background: linear-gradient(135deg, #22c55e, #16a34a); } +.toast-error { background: linear-gradient(135deg, #ef4444, #dc2626); } +.toast-info { background: linear-gradient(135deg, #6366f1, #4f46e5); } +.toast-warning { background: linear-gradient(135deg, #f59e0b, #d97706); } + +.toast-icon { font-size: 16px; flex-shrink: 0; } + +@keyframes toastSlideIn { + 0% { opacity: 0; transform: translateY(20px) scale(0.9); } + 100% { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes toastSlideOut { + 0% { opacity: 1; transform: translateY(0) scale(1); } + 100% { opacity: 0; transform: translateY(10px) scale(0.95); } +} + +/* Auth Widget Styling */ +.auth-widget { + position: absolute; + top: 20px; + right: 80px; + z-index: 1000; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +body.right-panel-open .auth-widget { + right: 400px; +} + +.btn-login { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + border: none; + border-radius: 24px; + height: 48px; + padding: 0 24px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; +} + +.btn-login:hover { + box-shadow: 0 6px 16px rgba(99, 102, 241, 0.45); + transform: translateY(-1px); +} + +.user-profile-pill { + background-color: white; + border-radius: 24px; + height: 48px; + padding: 0 8px 0 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: flex; + align-items: center; + gap: 12px; + border: 1px solid rgba(226, 232, 240, 0.8); + backdrop-filter: blur(10px); + background: rgba(255, 255, 255, 0.9); +} + +.user-profile-info { + display: flex; + flex-direction: column; +} + +.user-profile-name { + font-size: 12px; + font-weight: 700; + color: #1e1e2e; +} + +.user-profile-role { + font-size: 9px; + font-weight: 600; + color: #6366f1; + text-transform: uppercase; +} + +.btn-profile-logout { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: #f1f5f9; + color: #64748b; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.btn-profile-logout:hover { + background: #fee2e2; + color: #ef4444; +} + +/* User management table styling */ +#userManagementModal table th { + padding: 10px 12px; + font-weight: 600; + background-color: #f1f5f9; + border-bottom: 1px solid #cbd5e1; +} + +#userManagementModal table td { + padding: 10px 12px; + border-bottom: 1px solid #e2e8f0; +} + + + +/* ============================================================= + SPBU — Specific Styles + ============================================================= */ + +.card-icon-spbu { background: #f0fdf4; } + +.emoji-marker .bubble.spbu-24 { background: linear-gradient(135deg, #22c55e, #16a34a); } +.emoji-marker .bubble.spbu-not24 { background: linear-gradient(135deg, #ef4444, #dc2626); } + +.legend-spbu-24 { display:inline-block; width:12px; height:12px; border-radius:50%; background:linear-gradient(135deg,#22c55e,#16a34a); } +.legend-spbu-not24 { display:inline-block; width:12px; height:12px; border-radius:50%; background:linear-gradient(135deg,#ef4444,#dc2626); } + +.badge-spbu-24 { background:#dcfce7; color:#16a34a; } +.badge-spbu-not24 { background:#fee2e2; color:#dc2626; } diff --git a/spbu/assets/js/features/auth.js b/spbu/assets/js/features/auth.js new file mode 100644 index 0000000..f2dcb32 --- /dev/null +++ b/spbu/assets/js/features/auth.js @@ -0,0 +1,157 @@ +// --- Fitur Autentikasi dan Manajemen User (RBAC) --- + +(function() { + window.currentUser = null; + + // Elements + const authWidget = document.getElementById('authWidget'); + const loginModal = document.getElementById('loginModal'); + const loginUsernameInput = document.getElementById('loginUsername'); + const loginPasswordInput = document.getElementById('loginPassword'); + const loginSubmitBtn = document.getElementById('loginSubmitBtn'); + const loginErrorMsg = document.getElementById('loginErrorMsg'); + const closeLoginModal = document.getElementById('closeLoginModal'); + + // Startup Session Check + function checkSession() { + fetch('../poverty/api/check_session.php') + .then(res => res.json()) + .then(data => { + if (data.status === 'success' && data.isLoggedIn) { + window.currentUser = data.data; + } else { + window.currentUser = null; + } + updateAuthUI(); + refreshAllLayers(); + }) + .catch(err => { + console.error("Session check failed:", err); + window.currentUser = null; + updateAuthUI(); + }); + } + + // Update UI based on logged-in state + function updateAuthUI() { + if (!authWidget) return; + + if (window.currentUser) { + // Logged In state + const roleLabel = window.currentUser.role === 'admin' ? 'Admin' : 'Pengelola'; + + authWidget.innerHTML = ` +
+ + +
+ `; + + document.getElementById('authLogoutBtn').addEventListener('click', logout); + } else { + // Logged Out state + authWidget.innerHTML = ` + + `; + document.getElementById('authLoginBtn').addEventListener('click', showLoginModal); + } + } + + function refreshAllLayers() { + if (typeof loadSpbu === 'function') loadSpbu(); + if (typeof loadJalan === 'function') loadJalan(); + if (typeof loadParsil === 'function') loadParsil(); + if (window.refreshActivePanel) window.refreshActivePanel(); + } + + // Login Modals logic + function showLoginModal() { + loginUsernameInput.value = ''; + loginPasswordInput.value = ''; + loginErrorMsg.style.display = 'none'; + loginModal.classList.add('show'); + } + + function hideLoginModal() { + loginModal.classList.remove('show'); + } + + if (closeLoginModal) { + closeLoginModal.addEventListener('click', hideLoginModal); + } + + if (loginSubmitBtn) { + loginSubmitBtn.addEventListener('click', function() { + const username = loginUsernameInput.value.trim(); + const password = loginPasswordInput.value; + + if (!username || !password) { + loginErrorMsg.textContent = 'Username dan password wajib diisi'; + loginErrorMsg.style.display = 'block'; + return; + } + + fetch('../poverty/api/login.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }) + .then(res => res.json()) + .then(data => { + if (data.status === 'success') { + window.currentUser = data.data; + hideLoginModal(); + updateAuthUI(); + refreshAllLayers(); + showToast('Login berhasil', 'success'); + } else { + loginErrorMsg.textContent = data.message || 'Login gagal'; + loginErrorMsg.style.display = 'block'; + } + }) + .catch(err => { + console.error(err); + loginErrorMsg.textContent = 'Koneksi ke server terputus'; + loginErrorMsg.style.display = 'block'; + }); + }); + } + + function logout() { + fetch('../poverty/api/logout.php') + .then(res => res.json()) + .then(data => { + window.currentUser = null; + updateAuthUI(); + refreshAllLayers(); + showToast('Logout berhasil', 'success'); + }) + .catch(err => { + console.error(err); + showToast('Gagal logout', 'error'); + }); + } + + // Helper functions + function escHtml(str) { + if (!str) return ''; + return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + // Toast Notification helper + function showToast(message, type = 'success') { + if (typeof window.showToast === 'function') { + window.showToast(message, type); + return; + } + alert(message); + } + + // Run session check on initialization + checkSession(); +})(); diff --git a/spbu/assets/js/features/draw_control.js b/spbu/assets/js/features/draw_control.js new file mode 100644 index 0000000..a1e7026 --- /dev/null +++ b/spbu/assets/js/features/draw_control.js @@ -0,0 +1,142 @@ +// --- Setup Leaflet Draw --- + +// Layer terpisah untuk hasil gambar sementara sebelum disimpan +const drawnItems = new L.FeatureGroup(); +map.addLayer(drawnItems); + +const drawControl = new L.Control.Draw({ + draw: { + polygon: { + allowIntersection: false, + showArea: true + }, + polyline: { + metric: true + }, + circle: false, + circlemarker: false, + marker: false, // Marker default dimatikan, kita pakai klik peta untuk SPBU + rectangle: false + }, + edit: { + featureGroup: drawnItems, // Kita tidak mengedit di drawnItems, karena data aslinya di jalanLayer/parsilLayer + edit: false, + remove: false + } +}); +// Hapus map.addControl(drawControl); agar tidak tampil UI bawaan LeafletJS + + +// Flag untuk menghindari munculnya form SPBU saat sedang menggambar +map.on(L.Draw.Event.DRAWSTART, function (e) { + isDrawingMode = true; +}); + +map.on(L.Draw.Event.DRAWSTOP, function (e) { + setTimeout(() => { + isDrawingMode = false; + window.currentDrawMode = null; + window.activeDrawHandler = null; + window.deactivateAddMode(); + }, 200); +}); + +window.activateDraw = function(type) { + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + if (!isAdmin) return; + + console.log("-> activateDraw dipanggil untuk tipe:", type); + + // Toggle: jika mode yang sama ditekan lagi, batalkan + if (window.currentDrawMode === type) { + console.log("Mode", type, "sudah aktif, membatalkan..."); + window.deactivateAddMode(); + return; + } + + window.deactivateAddMode(); + window.currentDrawMode = type; + console.log("Mengaktifkan mode menggambar:", type); + + if (type === 'polyline') { + const handler = new L.Draw.Polyline(map, drawControl.options.draw.polyline); + handler.enable(); + window.activeDrawHandler = handler; + if (window.cursorTooltip) window.cursorTooltip.textContent = 'Klik untuk menggambar Jalan'; + } else if (type === 'polygon') { + const handler = new L.Draw.Polygon(map, drawControl.options.draw.polygon); + handler.enable(); + window.activeDrawHandler = handler; + if (window.cursorTooltip) window.cursorTooltip.textContent = 'Klik untuk menggambar Parsil'; + } +}; + +map.on(L.Draw.Event.CREATED, function (e) { + const type = e.layerType; + const layer = e.layer; + + // Convert layer to GeoJSON geometry + const geoJson = layer.toGeoJSON().geometry; + const geoJsonStr = JSON.stringify(geoJson); + + if (type === 'polyline') { + let length = 0; + const latlngs = layer.getLatLngs(); + for (let i = 0; i < latlngs.length - 1; i++) { + length += latlngs[i].distanceTo(latlngs[i + 1]); + } + + // Gunakan Modal alih-alih prompt + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + openModal("Tambah Jalan Baru", bodyHTML, function() { + window.saveNewJalan(geoJsonStr, length, 'modalJalanNama', 'modalJalanStatus'); + }); + + } + else if (type === 'polygon') { + const latlngs = layer.getLatLngs()[0]; + let area = 0; + if (L.GeometryUtil && L.GeometryUtil.geodesicArea) { + area = L.GeometryUtil.geodesicArea(latlngs); + } + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + openModal("Tambah Parsil Tanah", bodyHTML, function() { + window.saveNewParsil(geoJsonStr, area, 'modalParsilNama', 'modalParsilStatus'); + }); + } +}); + diff --git a/spbu/assets/js/features/geojson.js b/spbu/assets/js/features/geojson.js new file mode 100644 index 0000000..62fc314 --- /dev/null +++ b/spbu/assets/js/features/geojson.js @@ -0,0 +1,182 @@ +// --- Fitur Import GeoJSON --- + +const fileGeoJson = document.getElementById('fileGeoJson'); +let geoJsonLayers = {}; +let geoJsonCounter = 0; + +window.toggleGeoJsonMenu = function() { + const list = document.getElementById('geoJsonFileList'); + const icon = document.getElementById('geoJsonToggleIcon'); + if (list.style.display === 'none') { + list.style.display = 'block'; + icon.className = 'fas fa-minus'; + } else { + list.style.display = 'none'; + icon.className = 'fas fa-plus'; + } +}; + +// Cek apakah fitur GeoJSON ini terlihat seperti data penduduk miskin +function isMiskinFeature(feature) { + if (!feature || !feature.geometry || feature.geometry.type !== 'Point') return false; + const props = feature.properties || {}; + const keys = Object.keys(props).map(k => k.toLowerCase()); + // Dianggap data miskin jika punya properti nama (dan bukan ibadah atau spbu) + const hasNama = keys.some(k => ['nama', 'name', 'penduduk'].includes(k)); + const isIbadah = keys.some(k => ['jenis', 'radius', 'alamat'].includes(k)); + const isSpbu = keys.some(k => ['alamat_spbu', 'is_24_jam'].includes(k)); + return hasNama && !isIbadah && !isSpbu; +} + +fileGeoJson.addEventListener('change', function(e) { + const file = e.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = function(event) { + try { + const geoJsonData = JSON.parse(event.target.result); + const fileName = file.name; + + // Pisahkan fitur menjadi dua kelompok: miskin dan non-miskin + const features = geoJsonData.type === 'FeatureCollection' + ? geoJsonData.features + : [geoJsonData]; + + const miskinFeatures = features.filter(f => isMiskinFeature(f)); + const otherFeatures = features.filter(f => !isMiskinFeature(f)); + + // ---- Proses fitur miskin: Simpan ke DB, lalu tampilkan seperti penduduk miskin ---- + if (miskinFeatures.length > 0) { + const bulkData = miskinFeatures.map(f => { + const props = f.properties || {}; + const coords = f.geometry.coordinates; + return { + nama: props.nama || props.name || props.penduduk || 'Data Impor', + kategori_bantuan: props.kategori_bantuan || props.kategori || props.bantuan || 'Makan', + jumlah_jiwa: parseInt(props.jumlah_jiwa || props.jumlah || props.jiwa || 1, 10) || 1, + lat: parseFloat(coords[1]), + lng: parseFloat(coords[0]) + }; + }); + + fetch('../poverty/api/penduduk_miskin/bulk_create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(bulkData) + }) + .then(res => res.json()) + .then(data => { + if (data.status === 'success') { + // Reload layer penduduk miskin agar marker tampil dengan edit/hapus/log bantuan + if (typeof loadPendudukMiskin === 'function') { + loadPendudukMiskin(); + } + if (typeof window.refreshActivePanel === 'function') { + window.refreshActivePanel(); + } + showToast(data.message || 'Data berhasil diimpor ke layer Penduduk Miskin.', 'success'); + } else { + showToast(data.message || 'Gagal menyimpan data miskin.', 'error'); + } + }) + .catch(err => { + console.error(err); + showToast('Gagal terhubung ke server saat menyimpan data miskin.', 'error'); + }); + } + + // ---- Proses fitur non-miskin: Render sebagai layer GeoJSON biasa ---- + if (otherFeatures.length > 0) { + const layerId = 'gj_' + (++geoJsonCounter); + const individualLayer = L.featureGroup(); + + const otherGeoJson = { + type: 'FeatureCollection', + features: otherFeatures + }; + + L.geoJSON(otherGeoJson, { + pointToLayer: function(feature, latlng) { + const props = feature.properties || {}; + let emoji = props.emoji; + + if (!emoji) { + const text = JSON.stringify(props).toLowerCase(); + if (text.includes('spbu')) emoji = 'โ›ฝ'; + else if (text.includes('masjid')) emoji = '๐Ÿ•Œ'; + else if (text.includes('gereja')) emoji = 'โ›ช'; + else if (text.includes('vihara')) emoji = '๐Ÿชท'; + else if (text.includes('pura')) emoji = '๐Ÿ›•'; + else if (text.includes('kelenteng')) emoji = '๐Ÿฎ'; + else emoji = '๐Ÿ“'; + } + + let cls = 'miskin-out'; + if (emoji === 'โ›ฝ') cls = 'spbu-24'; + else if (['๐Ÿ•Œ','โ›ช','๐Ÿ›•','๐Ÿชท','๐Ÿฎ'].includes(emoji)) cls = 'ibadah'; + + const icon = L.divIcon({ + className: '', + html: `
${emoji}
`, + iconSize: [38, 38], + iconAnchor: [19, 38], + popupAnchor: [0, -40] + }); + + return L.marker(latlng, { icon: icon }); + }, + onEachFeature: function(feature, layer) { + if (feature.properties) { + let popupContent = '

Informasi Feature

'; + layer.bindPopup(popupContent); + } + }, + style: function(feature) { + return { + color: (feature.properties && feature.properties.color) || '#3388ff', + weight: 2, + fillOpacity: 0.4 + }; + } + }).addTo(individualLayer); + + individualLayer.addTo(map); + geoJsonLayers[layerId] = individualLayer; + + // Tambahkan checkbox ke UI + const container = document.getElementById('geoJsonLayersContainer'); + const label = document.createElement('label'); + label.className = 'layer-option'; + label.innerHTML = ` ${fileName}`; + + label.querySelector('input').addEventListener('change', function(e) { + if (e.target.checked) { + map.addLayer(geoJsonLayers[layerId]); + } else { + map.removeLayer(geoJsonLayers[layerId]); + } + }); + + container.appendChild(label); + + if (miskinFeatures.length === 0) { + showToast('File GeoJSON berhasil dimuat!', 'success'); + } + } + + // Reset input file agar bisa import file yang sama + fileGeoJson.value = ''; + + } catch (error) { + showToast('Gagal memproses file GeoJSON. Pastikan format valid.', 'error'); + console.error(error); + } + }; + reader.readAsText(file); +}); + diff --git a/spbu/assets/js/features/geolocation.js b/spbu/assets/js/features/geolocation.js new file mode 100644 index 0000000..4d95a20 --- /dev/null +++ b/spbu/assets/js/features/geolocation.js @@ -0,0 +1,31 @@ +// --- Fitur Geolocation --- + +// Tambahkan tombol di dalam wadah zoom control +const geoBtn = document.createElement('button'); +geoBtn.className = 'custom-layer-btn'; +geoBtn.style.position = 'relative'; +geoBtn.style.top = '0'; +geoBtn.style.left = '0'; +geoBtn.style.right = 'auto'; +geoBtn.innerHTML = ''; +geoBtn.title = 'Lokasi Saya'; +document.querySelector('.custom-zoom-control').appendChild(geoBtn); + +let userMarker = null; + +geoBtn.addEventListener('click', function() { + map.locate({setView: true, maxZoom: 16}); +}); + +map.on('locationfound', function(e) { + if (userMarker) { + map.removeLayer(userMarker); + } + userMarker = L.marker(e.latlng).addTo(map) + .bindPopup("Anda berada di sini!").openPopup(); +}); + +map.on('locationerror', function(e) { + alert("Gagal mendapatkan lokasi Anda."); +}); + diff --git a/spbu/assets/js/features/jalan.js b/spbu/assets/js/features/jalan.js new file mode 100644 index 0000000..24a93ac --- /dev/null +++ b/spbu/assets/js/features/jalan.js @@ -0,0 +1,173 @@ +// --- Fitur Jalan --- + +const jalanColors = { + 'Nasional': '#ff0000', // Merah + 'Provinsi': '#0000ff', // Biru + 'Kabupaten': '#00ff00' // Hijau +}; + +function loadJalan() { + jalanLayer.clearLayers(); + fetch('../jalan/read.php') + .then(res => res.json()) + .then(data => { + if (data.status === 'success' && data.data) { + data.data.forEach(item => { + addJalanToMap(item); + }); + } + if (window.refreshActivePanel) window.refreshActivePanel(); + }); +} + +function addJalanToMap(item) { + // geom adalah GeoJSON {type: "LineString", coordinates: [[lng, lat], ...]} + const latlngs = item.geom.coordinates.map(coord => [coord[1], coord[0]]); + + const polyline = L.polyline(latlngs, { + color: jalanColors[item.status] || '#3388ff', + weight: 5 + }); + + polyline.jalanData = item; + + // Tampilkan label nama jalan sejajar dengan garis (diagonal) + polyline.setText(item.nama, { + center: true, + offset: 0, + attributes: { + fill: '#000000', + 'font-weight': 'bold', + 'font-size': '14px', + 'dy': '7' + } + }); + + // Hitung popupContent sekali saat jalan dibuat + const d = item; + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + + const popupContent = ` +
+

Jalan ${d.nama}

+

Status: ${d.status}

+

Panjang: ${d.panjang.toFixed(2)} m

+ ${actionButtons} +
+ `; + + polyline.bindPopup(popupContent); + + jalanLayer.addLayer(polyline); +} + +window.openEditJalanModal = function(id) { + let d = null; + jalanLayer.eachLayer(function(layer) { + if (layer.jalanData && layer.jalanData.id == id) d = layer.jalanData; + }); + if (!d) return; + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + map.closePopup(); + openModal("Edit Jalan", bodyHTML, function() { + window.saveEditJalan(d.id, 'editJalanNama', 'editJalanStatus'); + }); +}; + +window.saveEditJalan = function(id, namaId, statusId) { + const nama = document.getElementById(namaId).value; + const status = document.getElementById(statusId).value; + + fetch('../jalan/update.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, nama, status }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + closeModal(); + loadJalan(); + } else { + alert(data.message); + } + }); +}; + +window.deleteJalan = function(id) { + openConfirmModal("Yakin hapus jalan ini?", function() { + fetch('../jalan/delete.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + loadJalan(); + } else { + alert(data.message); + } + }); + }); +}; + +window.saveNewJalan = function(geoJsonStr, panjang, namaId, statusId) { + const nama = document.getElementById(namaId).value; + if (!nama) { + alert("Nama jalan harus diisi!"); + return false; + } + + const status = document.getElementById(statusId).value; + + const geom = JSON.parse(geoJsonStr); + + fetch('../jalan/create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nama, status, panjang, geom }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + closeModal(); + loadJalan(); + } else { + alert(data.message); + } + }); + return true; +}; + +// Initial Load +loadJalan(); + diff --git a/spbu/assets/js/features/parsil.js b/spbu/assets/js/features/parsil.js new file mode 100644 index 0000000..d8509da --- /dev/null +++ b/spbu/assets/js/features/parsil.js @@ -0,0 +1,159 @@ +// --- Fitur Parsil --- + +const parsilColors = { + 'SHM': '#28a745', // Hijau + 'HGB': '#17a2b8', // Biru Muda + 'HGU': '#ffc107', // Kuning + 'HP': '#fd7e14' // Oranye +}; + +function loadParsil() { + parsilLayer.clearLayers(); + fetch('../parsil/read.php') + .then(res => res.json()) + .then(data => { + if (data.status === 'success' && data.data) { + data.data.forEach(item => { + addParsilToMap(item); + }); + } + if (window.refreshActivePanel) window.refreshActivePanel(); + }); +} + +function addParsilToMap(item) { + // geom adalah GeoJSON Polygon + // coordinates pada Polygon formatnya: [[[lng, lat], [lng, lat], ...]] + // Leaflet Polygon butuh array of [lat, lng] + const latlngs = item.geom.coordinates[0].map(coord => [coord[1], coord[0]]); + + const polygon = L.polygon(latlngs, { + color: parsilColors[item.status] || '#3388ff', + fillColor: parsilColors[item.status] || '#3388ff', + fillOpacity: 0.5, + weight: 2 + }); + + polygon.parsilData = item; + + const d = item; + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + const popupContent = ` +
+

Parsil ${d.nama || ''}

+

Status: ${d.status}

+

Luas: ${d.luas.toFixed(2)} mยฒ

+ ${actionButtons} +
+ `; + polygon.bindPopup(popupContent); + + parsilLayer.addLayer(polygon); +} + +window.openEditParsilModal = function(id) { + let d = null; + parsilLayer.eachLayer(function(layer) { + if (layer.parsilData && layer.parsilData.id == id) d = layer.parsilData; + }); + if (!d) return; + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ `; + map.closePopup(); + openModal("Edit Parsil Tanah", bodyHTML, function() { + window.saveEditParsil(d.id, 'editParsilNama', 'editParsilStatus'); + }); +}; + +window.saveEditParsil = function(id, namaId, statusId) { + const nama = document.getElementById(namaId).value; + const status = document.getElementById(statusId).value; + + fetch('../parsil/update.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, nama, status }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + closeModal(); + loadParsil(); + } else { + alert(data.message); + } + }); +}; + +window.deleteParsil = function(id) { + openConfirmModal("Yakin hapus parsil ini?", function() { + fetch('../parsil/delete.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + loadParsil(); + } else { + alert(data.message); + } + }); + }); +}; + +window.saveNewParsil = function(geoJsonStr, luas, namaId, statusId) { + const nama = document.getElementById(namaId).value; + const status = document.getElementById(statusId).value; + + const geom = JSON.parse(geoJsonStr); + + fetch('../parsil/create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nama, status, luas, geom }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + closeModal(); + loadParsil(); + } else { + alert(data.message); + } + }); + return true; +}; + +// Initial Load +loadParsil(); + diff --git a/spbu/assets/js/features/spbu.js b/spbu/assets/js/features/spbu.js new file mode 100644 index 0000000..07d168f --- /dev/null +++ b/spbu/assets/js/features/spbu.js @@ -0,0 +1,214 @@ +// --- Fitur SPBU --- + +// Emoji Bubble Icon builder +function makeSpbuIcon(is24) { + const cls = is24 ? 'spbu-24' : 'spbu-not24'; + return L.divIcon({ + className: '', + html: `
โ›ฝ
`, + iconSize: [38, 38], + iconAnchor: [19, 38], + popupAnchor: [0, -40] + }); +} + +const spbuGreenIcon = makeSpbuIcon(true); +const spbuRedIcon = makeSpbuIcon(false); + +let isDrawingMode = false; // Akan diset true saat draw mode aktif (jalan/parsil) + + +// Fetch SPBU +function loadSpbu() { + spbuLayer.clearLayers(); + fetch('../spbu/read.php') + .then(res => res.json()) + .then(data => { + if (data.status === 'success' && data.data) { + data.data.forEach(item => { + addSpbuMarker(item); + }); + } + if (window.refreshActivePanel) window.refreshActivePanel(); + }); +} + +function addSpbuMarker(item) { + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + const icon = makeSpbuIcon(item.is_24_jam); + const marker = L.marker([item.lat, item.lng], { + icon: icon, + draggable: isAdmin + }); + + marker.spbuData = item; // Simpan data di objek marker + + // Hitung popupContent sekali saat marker dibuat + const d = item; + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + const popupContent = ` +
+

SPBU ${d.nama}

+

No. WA: ${d.no_wa}

+

Buka 24 Jam: ${d.is_24_jam ? 'Ya' : 'Tidak'}

+ ${actionButtons} +
+ `; + marker.bindPopup(popupContent); + + // Event drag untuk update koordinat + marker.on('dragend', function(e) { + const newPos = marker.getLatLng(); + updateSpbu(item.id, item.nama, item.no_wa, item.is_24_jam, newPos.lat, newPos.lng); + }); + + spbuLayer.addLayer(marker); +} + +// Map Click -> Form Add +map.on('click', function(e) { + if (isDrawingMode) return; // Jangan muncul form jika sedang gambar garis/polygon + + // Hanya proses jika mode tambah SPBU aktif + if (window.currentAddMode === 'spbu') { + const bodyHTML = ` +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ `; + + openModal("Tambah SPBU Baru", bodyHTML, function() { + window.saveNewSpbu(e.latlng.lat, e.latlng.lng, 'modalSpbuNama', 'modalSpbuWa', 'modalSpbu24'); + }); + + // Nonaktifkan mode tambah setelah modal muncul + window.deactivateAddMode(); + } +}); + +window.saveNewSpbu = function(lat, lng, namaId, waId, radioName) { + const nama = document.getElementById(namaId).value; + if (!nama) { + alert("Nama SPBU harus diisi!"); + return; + } + const no_wa = document.getElementById(waId).value; + const is_24_jam = document.querySelector(`input[name="${radioName}"]:checked`).value; + + fetch('../spbu/create.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nama, no_wa, is_24_jam, lat, lng }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + closeModal(); + loadSpbu(); + } else { + alert(data.message); + } + }); +}; + +window.openEditSpbuModal = function(id) { + let d = null; + spbuLayer.eachLayer(function(layer) { + if (layer.spbuData && layer.spbuData.id == id) d = layer.spbuData; + }); + if (!d) return; + + const bodyHTML = ` +
+ + +
+
+ + +
+
+ +
+ + +
+
+ `; + map.closePopup(); + openModal("Edit SPBU", bodyHTML, function() { + window.saveEditSpbu(d.id, d.lat, d.lng, 'editSpbuNama', 'editSpbuWa', 'editSpbu24'); + }); +}; + +window.saveEditSpbu = function(id, lat, lng, namaId, waId, radioName) { + const nama = document.getElementById(namaId).value; + const no_wa = document.getElementById(waId).value; + const is_24_jam = document.querySelector(`input[name="${radioName}"]:checked`).value; + + updateSpbu(id, nama, no_wa, is_24_jam, lat, lng); +}; + +function updateSpbu(id, nama, no_wa, is_24_jam, lat, lng) { + fetch('../spbu/update.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, nama, no_wa, is_24_jam, lat, lng }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + closeModal(); + loadSpbu(); + } else { + alert(data.message); + } + }); +} + +window.deleteSpbu = function(id) { + openConfirmModal("Yakin hapus SPBU ini?", function() { + fetch('../spbu/delete.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }) + .then(res => res.json()) + .then(data => { + if(data.status === 'success') { + map.closePopup(); + loadSpbu(); + } else { + alert(data.message); + } + }); + }); +}; + +// Initial Load +loadSpbu(); + diff --git a/spbu/assets/js/map.js b/spbu/assets/js/map.js new file mode 100644 index 0000000..db153cc --- /dev/null +++ b/spbu/assets/js/map.js @@ -0,0 +1,259 @@ +// Inisialisasi Peta +const map = L.map('map', { zoomControl: false }).setView([-0.0263, 109.3425], 13); + +// ===== Toast Notification ===== +window.showToast = function(message, type = 'success', duration = 3500) { + const icons = { success: 'โœ…', error: 'โŒ', info: 'โ„น๏ธ', warning: 'โš ๏ธ' }; + const container = document.getElementById('toastContainer'); + if (!container) return; + + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.innerHTML = `${icons[type] || 'โ„น๏ธ'}${message}`; + container.appendChild(toast); + + setTimeout(() => { + toast.classList.add('hiding'); + toast.addEventListener('animationend', () => toast.remove()); + }, duration); +}; + +// Custom Zoom Control Logic +document.getElementById('zoomInBtn').addEventListener('click', function() { map.zoomIn(); }); +document.getElementById('zoomOutBtn').addEventListener('click', function() { map.zoomOut(); }); + +// Base Map dari OpenStreetMap +L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' +}).addTo(map); + +// Inisialisasi FeatureGroups untuk masing-masing layer +const spbuLayer = L.featureGroup().addTo(map); +const jalanLayer = L.featureGroup().addTo(map); +const parsilLayer = L.featureGroup().addTo(map); +const rumahIbadahLayer = L.featureGroup().addTo(map); +const pendudukMiskinLayer = L.featureGroup().addTo(map); +const geoJsonLayer = L.featureGroup().addTo(map); + +const searchInput = document.getElementById('searchInput'); +const searchClear = document.getElementById('searchClear'); + +if (searchInput) { + searchInput.addEventListener('focus', function() { + if (typeof window.deactivateAddMode === 'function') window.deactivateAddMode(); + }); + + searchInput.addEventListener('input', function() { + const val = this.value.toLowerCase(); + if (val.length > 0) { + searchClear.style.visibility = 'visible'; + window.showSearchResults(val); + } else { + searchClear.style.visibility = 'hidden'; + document.getElementById('searchResults').style.display = 'none'; + } + }); +} + +if (searchClear) { + searchClear.addEventListener('click', function() { + searchInput.value = ''; + searchClear.style.visibility = 'hidden'; + document.getElementById('searchResults').style.display = 'none'; + searchInput.focus(); + }); +} + +window.showSearchResults = function(query) { + const resultsContainer = document.getElementById('searchResults'); + if (!resultsContainer) return; + resultsContainer.innerHTML = ''; + let results = []; + + if (typeof spbuLayer !== 'undefined') { + spbuLayer.eachLayer(layer => { + if (layer.spbuData && layer.spbuData.nama && layer.spbuData.nama.toLowerCase().includes(query)) { + results.push({ type: 'SPBU', nama: layer.spbuData.nama, layer: layer }); + } + }); + } + + if (typeof jalanLayer !== 'undefined') { + jalanLayer.eachLayer(layer => { + if (layer.jalanData && layer.jalanData.nama && layer.jalanData.nama.toLowerCase().includes(query)) { + results.push({ type: 'Jalan', nama: layer.jalanData.nama, layer: layer }); + } + }); + } + + if (typeof parsilLayer !== 'undefined') { + parsilLayer.eachLayer(layer => { + if (layer.parsilData && layer.parsilData.nama && layer.parsilData.nama.toLowerCase().includes(query)) { + results.push({ type: 'Parsil', nama: layer.parsilData.nama, layer: layer }); + } + }); + } + + if (results.length === 0) { + resultsContainer.innerHTML = '
Tidak ada hasil
'; + } else { + results.forEach(res => { + const item = document.createElement('div'); + item.style.padding = '10px 15px'; + item.style.cursor = 'pointer'; + item.style.borderBottom = '1px solid #eee'; + item.style.fontSize = '14px'; + item.innerHTML = `${res.type}: ${res.nama}`; + item.addEventListener('mouseenter', () => item.style.backgroundColor = '#f8f9fa'); + item.addEventListener('mouseleave', () => item.style.backgroundColor = 'white'); + item.addEventListener('click', () => { + if (res.layer instanceof L.Marker) { + map.setView(res.layer.getLatLng(), 17); + } else if (res.layer.getBounds) { + map.fitBounds(res.layer.getBounds()); + } + res.layer.openPopup(); + resultsContainer.style.display = 'none'; + }); + resultsContainer.appendChild(item); + }); + } + resultsContainer.style.display = 'block'; +}; + +// UI Logic: Custom Layer Control Toggle +const layerBtn = document.getElementById('layerBtn'); +const layerPanel = document.getElementById('layerPanel'); + +if (layerBtn && layerPanel) { + layerBtn.addEventListener('click', function() { + if (typeof window.deactivateAddMode === 'function') window.deactivateAddMode(); + layerPanel.style.display = layerPanel.style.display === 'block' ? 'none' : 'block'; + }); +} + +// Sembunyikan layer panel saat klik di peta +map.on('click', function() { + if (layerPanel) layerPanel.style.display = 'none'; +}); + +// Logic untuk Toggle Layer Visibility +const setupLayerToggle = (id, layer) => { + const el = document.getElementById(id); + if (el) { + el.addEventListener('change', function(e) { + e.target.checked ? map.addLayer(layer) : map.removeLayer(layer); + }); + } +}; + +setupLayerToggle('layerSpbu', spbuLayer); +setupLayerToggle('layerJalan', jalanLayer); +setupLayerToggle('layerParsil', parsilLayer); +setupLayerToggle('layerRumahIbadah', rumahIbadahLayer); +setupLayerToggle('layerMiskin', pendudukMiskinLayer); + +// --- Sub-layer Toggle (expand/collapse) --- +window.toggleSubLayer = function(subId, iconEl) { + const sub = document.getElementById(subId); + if (!sub) return; + const isHidden = sub.style.display === 'none'; + sub.style.display = isHidden ? '' : 'none'; + iconEl.classList.toggle('collapsed', !isHidden); +}; + +// --- Sub-layer Filter --- +window.applySubFilter = function(type) { + if (type === 'spbu') { + const checked = [...document.querySelectorAll('.sub-spbu:checked')].map(el => el.value === '1'); + spbuLayer.eachLayer(layer => { + if (layer.spbuData === undefined) return; + if (checked.includes(layer.spbuData.is_24_jam)) { + layer.getElement && layer.getElement() && (layer.getElement().style.display = ''); + } else { + layer.getElement && layer.getElement() && (layer.getElement().style.display = 'none'); + } + }); + } else if (type === 'jalan') { + const checked = [...document.querySelectorAll('.sub-jalan:checked')].map(el => el.value); + jalanLayer.eachLayer(layer => { + if (!layer.jalanData) return; + if (checked.includes(layer.jalanData.status)) { + layer.getElement && layer.getElement() && (layer.getElement().style.display = ''); + } else { + layer.getElement && layer.getElement() && (layer.getElement().style.display = 'none'); + } + }); + } else if (type === 'parsil') { + const checked = [...document.querySelectorAll('.sub-parsil:checked')].map(el => el.value); + parsilLayer.eachLayer(layer => { + if (!layer.parsilData) return; + if (checked.includes(layer.parsilData.status)) { + layer.getElement && layer.getElement() && (layer.getElement().style.display = ''); + } else { + layer.getElement && layer.getElement() && (layer.getElement().style.display = 'none'); + } + }); + } +}; + +// --- Modal Logic --- +const unifiedModal = document.getElementById('unifiedModal'); +const modalTitle = document.getElementById('modalTitle'); +const modalBody = document.getElementById('modalBody'); + +window.openModal = function(title, bodyHTML, saveCallback) { + if (!unifiedModal) return; + modalTitle.textContent = title; + modalBody.innerHTML = bodyHTML; + unifiedModal.classList.add('show'); + + const currentSaveBtn = document.getElementById('modalSaveBtn'); + if (currentSaveBtn) { + const newSaveBtn = currentSaveBtn.cloneNode(true); + currentSaveBtn.parentNode.replaceChild(newSaveBtn, currentSaveBtn); + newSaveBtn.addEventListener('click', saveCallback); + } +}; + +window.closeModal = function() { + if (unifiedModal) unifiedModal.classList.remove('show'); +}; + +const confirmModal = document.getElementById('confirmModal'); +const confirmMessage = document.getElementById('confirmMessage'); + +window.openConfirmModal = function(msg, confirmCallback) { + if (!confirmModal) return; + confirmMessage.textContent = msg; + confirmModal.classList.add('show'); + + const currentYesBtn = document.getElementById('confirmYesBtn'); + if (currentYesBtn) { + const newYesBtn = currentYesBtn.cloneNode(true); + currentYesBtn.parentNode.replaceChild(newYesBtn, currentYesBtn); + newYesBtn.addEventListener('click', function() { + confirmModal.classList.remove('show'); + confirmCallback(); + }); + } +}; + +window.closeConfirmModal = function() { + if (confirmModal) confirmModal.classList.remove('show'); +}; + +window.currentAddMode = null; // 'spbu', 'jalan', 'parsil' +window.activateAddMode = function(mode) { + if (window.currentAddMode === mode) { + window.deactivateAddMode(); + return; + } + window.currentAddMode = mode; + document.getElementById('map').style.cursor = 'crosshair'; +}; + +window.deactivateAddMode = function() { + window.currentAddMode = null; + document.getElementById('map').style.cursor = ''; +}; diff --git a/spbu/assets/js/panel.js b/spbu/assets/js/panel.js new file mode 100644 index 0000000..1aa68d6 --- /dev/null +++ b/spbu/assets/js/panel.js @@ -0,0 +1,270 @@ +// ===== Panel Management ===== +// Mengelola Left Sidebar Toggle + Right Panel + Layer Control + +(function () { + // ---- State ---- + let activePanel = null; + + // ---- Elements ---- + const sidebarToggleBtn = document.getElementById('sidebarToggleBtn'); + const sidebarToggleIcon = document.getElementById('sidebarToggleIcon'); + const leftSidebar = document.getElementById('leftSidebar'); + const rightPanel = document.getElementById('rightPanel'); + const rightPanelTitle = document.getElementById('rightPanelTitle'); + const rightPanelActions = document.getElementById('rightPanelActions'); + const rightPanelBody = document.getElementById('rightPanelBody'); + const rightPanelClose = document.getElementById('rightPanelClose'); + + // ---- Sidebar Toggle ---- + if (sidebarToggleBtn) { + sidebarToggleBtn.addEventListener('click', function () { + const isOpen = leftSidebar.style.display !== 'none'; + if (isOpen) { + leftSidebar.style.display = 'none'; + sidebarToggleBtn.classList.remove('open'); + sidebarToggleIcon.className = 'fas fa-chevron-right'; + } else { + leftSidebar.style.display = 'flex'; + sidebarToggleBtn.classList.add('open'); + sidebarToggleIcon.className = 'fas fa-chevron-left'; + } + }); + } + + // ---- Sidebar Buttons ---- + document.querySelectorAll('.sidebar-btn').forEach(btn => { + btn.addEventListener('click', function () { + const panel = this.dataset.panel; + if (activePanel === panel) { + closePanel(); + } else { + openPanel(panel); + } + }); + }); + + if (rightPanelClose) { + rightPanelClose.addEventListener('click', closePanel); + } + + // ---- Open / Close Panel ---- + function openPanel(panel) { + if (typeof window.deactivateAddMode === 'function') window.deactivateAddMode(); + activePanel = panel; + rightPanel.classList.add('open'); + document.body.classList.add('right-panel-open'); + document.querySelectorAll('.sidebar-btn').forEach(b => b.classList.remove('active')); + const activeBtn = document.querySelector(`.sidebar-btn[data-panel="${panel}"]`); + if (activeBtn) activeBtn.classList.add('active'); + renderPanel(panel); + } + + function closePanel() { + if (typeof window.deactivateAddMode === 'function') window.deactivateAddMode(); + activePanel = null; + rightPanel.classList.remove('open'); + document.body.classList.remove('right-panel-open'); + document.querySelectorAll('.sidebar-btn').forEach(b => b.classList.remove('active')); + } + + window.refreshActivePanel = function () { + if (activePanel && activePanel !== 'layer') renderPanel(activePanel); + }; + + // ---- Render Panel by Type ---- + function renderPanel(type) { + rightPanelBody.innerHTML = '
Memuat data...
'; + rightPanelActions.innerHTML = ''; + + switch (type) { + case 'spbu': renderSpbuPanel(); break; + case 'jalan': renderJalanPanel(); break; + case 'parsil': renderParsilPanel(); break; + } + } + + // ========================== + // ===== SPBU Panel ======= + // ========================== + function renderSpbuPanel() { + rightPanelTitle.textContent = 'โ›ฝ Daftar SPBU'; + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + if (isAdmin) { + rightPanelActions.innerHTML = ` + `; + document.getElementById('btnPanelAddSpbu').addEventListener('click', () => { + window.activateAddMode('spbu'); + }); + } + + const items = []; + spbuLayer.eachLayer(l => { + if (l.spbuData) items.push(l.spbuData); + }); + + if (!items.length) { + rightPanelBody.innerHTML = '
Belum ada data SPBU
'; + return; + } + + rightPanelBody.innerHTML = ''; + items.forEach(d => { + const card = document.createElement('div'); + card.className = 'data-card'; + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + card.innerHTML = ` +
+
โ›ฝ
+
+
${escHtml(d.nama)}
+
๐Ÿ“ž ${escHtml(d.no_wa || '-')} โ€ข ๐Ÿ• ${d.is_24_jam ? '24 Jam' : 'Tidak 24 Jam'}
+
+ ${actionButtons} +
`; + if (isAdmin) { + card.querySelector('.btn-card-edit').addEventListener('click', (e) => { e.stopPropagation(); window.openEditSpbuModal(d.id); }); + card.querySelector('.btn-card-delete').addEventListener('click', (e) => { e.stopPropagation(); window.deleteSpbu(d.id); }); + } + card.addEventListener('click', () => { + spbuLayer.eachLayer(l => { + if (l.spbuData && l.spbuData.id == d.id) { + map.setView(l.getLatLng(), 17); l.openPopup(); + } + }); + }); + rightPanelBody.appendChild(card); + }); + } + + // ========================== + // ===== Jalan Panel ======= + // ========================== + function renderJalanPanel() { + rightPanelTitle.textContent = '๐Ÿ›ฃ๏ธ Daftar Jalan'; + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + + rightPanelActions.innerHTML = ''; + + const items = []; + jalanLayer.eachLayer(l => { + if (l.jalanData) items.push(l.jalanData); + }); + + if (!items.length) { + rightPanelBody.innerHTML = '
Belum ada data Jalan
'; + return; + } + + rightPanelBody.innerHTML = ''; + items.forEach(d => { + const card = document.createElement('div'); + card.className = 'data-card'; + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + card.innerHTML = ` +
+
๐Ÿ›ฃ๏ธ
+
+
${escHtml(d.nama)}
+
๐Ÿ“ ${d.panjang.toFixed(2)} m โ€ข ๐Ÿท๏ธ ${escHtml(d.status)}
+
+ ${actionButtons} +
`; + if (isAdmin) { + card.querySelector('.btn-card-edit').addEventListener('click', (e) => { e.stopPropagation(); window.openEditJalanModal(d.id); }); + card.querySelector('.btn-card-delete').addEventListener('click', (e) => { e.stopPropagation(); window.deleteJalan(d.id); }); + } + card.addEventListener('click', () => { + jalanLayer.eachLayer(l => { + if (l.jalanData && l.jalanData.id == d.id) { + map.fitBounds(l.getBounds()); l.openPopup(); + } + }); + }); + rightPanelBody.appendChild(card); + }); + } + + // ========================== + // ===== Parsil Panel ======= + // ========================== + function renderParsilPanel() { + rightPanelTitle.textContent = '๐Ÿ—บ๏ธ Daftar Parsil Tanah'; + const isAdmin = !!(window.currentUser && window.currentUser.role === 'admin'); + + rightPanelActions.innerHTML = ''; + + const items = []; + parsilLayer.eachLayer(l => { + if (l.parsilData) items.push(l.parsilData); + }); + + if (!items.length) { + rightPanelBody.innerHTML = '
Belum ada data Parsil
'; + return; + } + + rightPanelBody.innerHTML = ''; + items.forEach(d => { + const card = document.createElement('div'); + card.className = 'data-card'; + let actionButtons = ''; + if (isAdmin) { + actionButtons = ` +
+ + +
+ `; + } + card.innerHTML = ` +
+
๐Ÿ—บ๏ธ
+
+
${escHtml(d.nama || 'Tanpa Nama')}
+
๐Ÿ“ ${d.luas.toFixed(2)} mยฒ โ€ข ๐Ÿท๏ธ ${escHtml(d.status)}
+
+ ${actionButtons} +
`; + if (isAdmin) { + card.querySelector('.btn-card-edit').addEventListener('click', (e) => { e.stopPropagation(); window.openEditParsilModal(d.id); }); + card.querySelector('.btn-card-delete').addEventListener('click', (e) => { e.stopPropagation(); window.deleteParsil(d.id); }); + } + card.addEventListener('click', () => { + parsilLayer.eachLayer(l => { + if (l.parsilData && l.parsilData.id == d.id) { + map.fitBounds(l.getBounds()); l.openPopup(); + } + }); + }); + rightPanelBody.appendChild(card); + }); + } + + // ---- Utility ---- + function escHtml(str) { + if (!str) return ''; + return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + // ---- Expose ---- + window.openRightPanel = openPanel; + +})(); diff --git a/spbu/create.php b/spbu/create.php new file mode 100644 index 0000000..225f9fb --- /dev/null +++ b/spbu/create.php @@ -0,0 +1,26 @@ +lat) && !empty($data->lng)) { + $nama = $conn->real_escape_string($data->nama ?? ''); + $no_wa = $conn->real_escape_string($data->no_wa ?? ''); + $is_24_jam = isset($data->is_24_jam) ? (int)$data->is_24_jam : 0; + $lat = (float)$data->lat; + $lng = (float)$data->lng; + + $query = "INSERT INTO spbu (nama, no_wa, is_24_jam, lat, lng) VALUES ('$nama', '$no_wa', $is_24_jam, $lat, $lng)"; + + if($conn->query($query)) { + echo json_encode(["status" => "success", "id" => $conn->insert_id, "message" => "SPBU berhasil ditambahkan."]); + } else { + echo json_encode(["status" => "error", "message" => "Gagal menambahkan SPBU: " . $conn->error]); + } +} else { + echo json_encode(["status" => "error", "message" => "Data tidak lengkap."]); +} +?> diff --git a/spbu/delete.php b/spbu/delete.php new file mode 100644 index 0000000..e0afdcf --- /dev/null +++ b/spbu/delete.php @@ -0,0 +1,22 @@ +id)) { + $id = (int)$data->id; + + $query = "DELETE FROM spbu WHERE id=$id"; + + if($conn->query($query)) { + echo json_encode(["status" => "success", "message" => "SPBU berhasil dihapus."]); + } else { + echo json_encode(["status" => "error", "message" => "Gagal hapus SPBU: " . $conn->error]); + } +} else { + echo json_encode(["status" => "error", "message" => "ID tidak diberikan."]); +} +?> diff --git a/spbu/index.php b/spbu/index.php new file mode 100644 index 0000000..2c74118 --- /dev/null +++ b/spbu/index.php @@ -0,0 +1,388 @@ + + + + + + WebGIS Pemetaan Jaringan Jalan + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + +
+ + +
+ + + + + + + + + + + + +
+ +
+

Daftar Layer

+ +
Infrastruktur & Lahan
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + + + +
+
+ GeoJSON Eksternal + +
+ +
+
+ + +
+
+

Data

+ +
+
+
+
Pilih menu di kiri untuk menampilkan data
+
+
+
+ + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spbu/read.php b/spbu/read.php new file mode 100644 index 0000000..942d823 --- /dev/null +++ b/spbu/read.php @@ -0,0 +1,26 @@ +query($query); + +$spbu_arr = array(); + +if($result->num_rows > 0) { + while($row = $result->fetch_assoc()) { + $spbu_item = array( + "id" => $row['id'], + "nama" => $row['nama'], + "no_wa" => $row['no_wa'], + "is_24_jam" => (bool)$row['is_24_jam'], + "lat" => (float)$row['lat'], + "lng" => (float)$row['lng'] + ); + array_push($spbu_arr, $spbu_item); + } + echo json_encode(["status" => "success", "data" => $spbu_arr]); +} else { + echo json_encode(["status" => "success", "data" => []]); +} +?> diff --git a/spbu/update.php b/spbu/update.php new file mode 100644 index 0000000..83e5fb9 --- /dev/null +++ b/spbu/update.php @@ -0,0 +1,27 @@ +id) && !empty($data->lat) && !empty($data->lng)) { + $id = (int)$data->id; + $nama = $conn->real_escape_string($data->nama ?? ''); + $no_wa = $conn->real_escape_string($data->no_wa ?? ''); + $is_24_jam = isset($data->is_24_jam) ? (int)$data->is_24_jam : 0; + $lat = (float)$data->lat; + $lng = (float)$data->lng; + + $query = "UPDATE spbu SET nama='$nama', no_wa='$no_wa', is_24_jam=$is_24_jam, lat=$lat, lng=$lng WHERE id=$id"; + + if($conn->query($query)) { + echo json_encode(["status" => "success", "message" => "SPBU berhasil diupdate."]); + } else { + echo json_encode(["status" => "error", "message" => "Gagal update SPBU: " . $conn->error]); + } +} else { + echo json_encode(["status" => "error", "message" => "Data tidak lengkap."]); +} +?> diff --git a/style.css b/style.css new file mode 100644 index 0000000..4411222 --- /dev/null +++ b/style.css @@ -0,0 +1,1026 @@ +/* Reset & Base */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body, html { + height: 100%; + font-family: 'Google Sans Flex', 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + overflow: hidden; +} + +/* Map Container */ +#map { + width: 100vw; + height: 100vh; + z-index: 1; +} + +/* Custom UI Container over Map */ +.ui-container { + position: absolute; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 15px; +} + +/* Search Bar */ +.search-bar { + display: flex; + align-items: center; + background-color: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + overflow: hidden; + height: 48px; + transition: box-shadow 0.3s; +} + +.search-bar:focus-within { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); +} + +.search-icon { + padding: 0 15px; + color: #666; + display: flex; + align-items: center; + justify-content: center; +} + +.search-input { + flex: 1; + border: none; + outline: none; + font-size: 16px; + padding: 10px 0; + color: #333; +} + +.search-clear { + padding: 0 15px; + color: #999; + cursor: pointer; + background: none; + border: none; + display: flex; + align-items: center; + justify-content: center; + visibility: hidden; +} + +.search-clear:hover { + color: #333; +} + +/* Custom Layer Control Button */ +.custom-layer-btn { + background-color: white; + border-radius: 8px; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + cursor: pointer; + border: none; + color: #333; +} + +.custom-layer-btn:hover { + background-color: #f8f9fa; +} + +#layerBtn { + position: absolute; + top: 20px; + right: 20px; + z-index: 1000; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +body.right-panel-open #layerBtn { + right: 340px; +} + +.custom-layer-panel { + position: absolute; + top: 80px; + right: 20px; + z-index: 1000; + background-color: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 15px; + width: 250px; + display: none; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +body.right-panel-open .custom-layer-panel { + right: 340px; +} + + +.custom-layer-panel h3 { + margin-bottom: 10px; + font-size: 16px; + border-bottom: 1px solid #eee; + padding-bottom: 5px; +} + +.layer-section-title { + font-size: 11px; + font-weight: 700; + color: #6366f1; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; + margin-top: 12px; + padding-bottom: 2px; + border-bottom: 1px dashed #e2e8f0; +} + +.layer-option { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; + font-size: 14px; +} + +/* ===== Sidebar Toggle Button ===== */ +.sidebar-toggle-btn { + position: absolute; + left: 20px; + top: 190px; + z-index: 1001; + border-radius: 8px; + width: 48px; + height: 48px; + background: white; + border: none; + box-shadow: 0 3px 10px rgba(0,0,0,0.15); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #6366f1; + font-size: 13px; + transition: all 0.2s; +} + +.sidebar-toggle-btn:hover { + background: #6366f1; + color: white; + box-shadow: 0 4px 14px rgba(99,102,241,0.4); +} + +.sidebar-toggle-btn.open { + background: #6366f1; + color: white; +} + +/* ===== Left Sidebar ===== */ +.left-sidebar { + position: absolute; + left: 20px; + top: 250px; /* di bawah toggle button */ + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; +} + +.sidebar-section-label { + font-size: 9px; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + text-align: center; + letter-spacing: 0.8px; + margin-top: 6px; + margin-bottom: 2px; + pointer-events: none; + user-select: none; +} + +.sidebar-divider { + height: 1px; + background: #e2e8f0; + margin: 4px 6px; +} + +.sidebar-btn { + width: 60px; + height: 60px; + background: white; + border: none; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0,0,0,0.12); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + transition: all 0.2s ease; + padding: 6px 4px; +} + +.sidebar-btn:hover { + transform: translateX(3px); + box-shadow: 0 6px 18px rgba(0,0,0,0.18); + background: #f8f9fa; +} + +.sidebar-btn.active { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + box-shadow: 0 6px 18px rgba(99,102,241,0.4); + transform: translateX(4px); +} + +.sidebar-emoji { + font-size: 22px; + line-height: 1; +} + +.sidebar-label { + font-size: 9px; + font-weight: 600; + letter-spacing: 0.3px; + color: #555; + text-transform: uppercase; +} + +.sidebar-btn.active .sidebar-label { + color: rgba(255,255,255,0.85); +} + +/* ===== Right Panel ===== */ +.right-panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 320px; + background: #f8f9fb; + z-index: 999; + box-shadow: -4px 0 20px rgba(0,0,0,0.12); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + /* shift layer control when panel open */ +} + +.right-panel.open { + transform: translateX(0); +} + +.right-panel-header { + padding: 16px 16px 12px; + background: white; + border-bottom: 1px solid #eee; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + padding-top: 26px; +} + +.right-panel-header h3 { + font-size: 15px; + font-weight: 700; + color: #1e1e2e; +} + +.right-panel-close { + width: 30px; + height: 30px; + border: none; + background: #f3f4f6; + border-radius: 8px; + cursor: pointer; + color: #555; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + transition: background 0.2s; +} + +.right-panel-close:hover { + background: #e5e7eb; + color: #333; +} + +.right-panel-actions { + padding: 12px 14px; + background: white; + border-bottom: 1px solid #eee; + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.right-panel-actions button { + flex: 1; + padding: 9px 10px; + border: none; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: all 0.2s; +} + +.btn-panel-add { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + box-shadow: 0 3px 10px rgba(99,102,241,0.3); +} + +.btn-panel-add:hover { + box-shadow: 0 5px 14px rgba(99,102,241,0.45); + transform: translateY(-1px); +} + +.btn-panel-import { + background: #f0fdf4; + color: #16a34a; + border: 1px solid #bbf7d0 !important; +} + +.btn-panel-import:hover { + background: #dcfce7; +} + +.right-panel-body { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Panel Section Heading */ +.panel-section-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #9ca3af; + padding: 4px 2px 6px; + border-bottom: 1px solid #e5e7eb; + margin-top: 4px; +} + +/* Panel Empty State */ +.panel-empty { + text-align: center; + color: #aaa; + font-size: 13px; + padding: 40px 10px; +} + +/* ===== Data Card ===== */ +.data-card { + background: white; + border-radius: 12px; + padding: 12px 12px 10px; + box-shadow: 0 1px 4px rgba(0,0,0,0.07); + transition: box-shadow 0.2s; + cursor: pointer; +} + +.data-card:hover { + box-shadow: 0 3px 12px rgba(0,0,0,0.12); +} + +.data-card-main { + display: flex; + align-items: center; + gap: 10px; +} + +.data-card-icon { + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.card-icon-spbu { background: #f0fdf4; } +.card-icon-jalan { background: #eff6ff; } +.card-icon-parsil { background: #fefce8; } +.card-icon-ibadah { background: #fff7ed; } +.card-icon-miskin-out { background: #f0fdf4; } +.card-icon-miskin-in { background: #fef2f2; } + +.data-card-info { + flex: 1; + min-width: 0; +} + +.data-card-name { + font-size: 13px; + font-weight: 600; + color: #1e1e2e; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.data-card-sub { + font-size: 11px; + color: #6b7280; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.data-card-badge { + font-size: 10px; + font-weight: 600; + padding: 2px 7px; + border-radius: 99px; + margin-top: 4px; + display: inline-block; +} + +.badge-green { background: #dcfce7; color: #16a34a; } +.badge-red { background: #fee2e2; color: #dc2626; } +.badge-blue { background: #dbeafe; color: #2563eb; } +.badge-orange { background: #ffedd5; color: #ea580c; } +.badge-yellow { background: #fef9c3; color: #ca8a04; } + +.data-card-actions { + display: flex; + gap: 6px; + flex-shrink: 0; + align-items: center; +} + +.btn-card-action { + width: 30px; + height: 30px; + border: none; + border-radius: 7px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: all 0.15s; +} + +.btn-card-edit { + background: #eff6ff; + color: #3b82f6; +} + +.btn-card-edit:hover { + background: #3b82f6; + color: white; +} + +.btn-card-delete { + background: #fef2f2; + color: #ef4444; +} + +.btn-card-delete:hover { + background: #ef4444; + color: white; +} + +/* Expandable card (miskin) */ +.data-card-expand { + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease, padding 0.2s; + border-top: 0px solid #f3f4f6; +} + +.data-card.expanded .data-card-expand { + max-height: 400px; + border-top: 1px solid #f3f4f6; + margin-top: 10px; + padding-top: 10px; +} + +.expand-row { + display: flex; + justify-content: space-between; + font-size: 12px; + color: #6b7280; + padding: 3px 0; +} + +.expand-row span:first-child { + font-weight: 500; + color: #374151; +} + +.btn-log-bantuan { + margin-top: 10px; + width: 100%; + padding: 8px; + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + border: none; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: all 0.2s; +} + +.btn-log-bantuan:hover { + box-shadow: 0 4px 12px rgba(99,102,241,0.4); +} + +/* Log Bantuan Item */ +.log-item { + background: #f8f9fb; + border-radius: 8px; + padding: 10px 12px; + display: flex; + gap: 10px; + align-items: flex-start; +} + +.log-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #6366f1; + flex-shrink: 0; + margin-top: 5px; +} + +.log-info { + flex: 1; +} + +.log-type { + font-size: 12px; + font-weight: 600; + color: #1e1e2e; +} + +.log-meta { + font-size: 11px; + color: #9ca3af; + margin-top: 2px; +} + +.log-keterangan { + font-size: 11px; + color: #6b7280; + margin-top: 3px; + font-style: italic; +} + +/* Popup Form */ +.popup-form { + display: flex; + flex-direction: column; + gap: 10px; + width: 200px; +} + +.popup-form label { + font-size: 12px; + font-weight: bold; + color: #555; +} + +.popup-form input[type="text"], +.popup-form input[type="number"] { + width: 100%; + padding: 6px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.popup-form .radio-group { + display: flex; + gap: 10px; +} + +.popup-form button { + padding: 8px; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; +} + +.popup-form button.btn-danger { + background-color: #dc3545; +} + +.popup-form button:hover { + opacity: 0.9; +} + +/* Unified Modal */ +.unified-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); + z-index: 2000; + align-items: center; + justify-content: center; +} + +.unified-modal.show { + display: flex; +} + +.modal-content { + background-color: white; + border-radius: 12px; + width: 400px; + max-width: 90%; + box-shadow: 0 8px 30px rgba(0,0,0,0.2); + display: flex; + flex-direction: column; +} + +.modal-header { + padding: 15px; + border-bottom: 1px solid #eee; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + font-size: 16px; + margin: 0; +} + +.modal-close { + font-size: 20px; + font-weight: bold; + cursor: pointer; + color: #888; +} + +.modal-close:hover { + color: #333; +} + +.modal-body { + padding: 15px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.modal-body label { + font-size: 13px; + font-weight: 600; + color: #444; +} + +.modal-body input[type="text"], +.modal-body input[type="number"], +.modal-body input[type="date"], +.modal-body select, +.modal-body textarea { + width: 100%; + padding: 8px; + border: 1px solid #ccc; + border-radius: 6px; + font-size: 14px; + font-family: inherit; +} + +.modal-footer { + padding: 15px; + border-top: 1px solid #eee; + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.btn-save { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + border: none; + padding: 8px 18px; + border-radius: 7px; + cursor: pointer; + font-weight: 600; + font-size: 13px; +} + +.btn-cancel { + background-color: #f8f9fa; + border: 1px solid #ddd; + padding: 8px 18px; + border-radius: 7px; + cursor: pointer; + font-size: 13px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Custom Cursor Tooltip */ +.custom-cursor-tooltip { + position: absolute; + display: none; + background-color: rgba(0, 0, 0, 0.75); + color: white; + padding: 6px 12px; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + pointer-events: none; + z-index: 9999; + white-space: nowrap; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); +} + +/* Sembunyikan default tooltip Leaflet Draw */ +.leaflet-draw-tooltip { + display: none !important; +} + +/* Sub-layer collapsible */ +.layer-group-item { + margin-bottom: 6px; +} + +.layer-group-item > .layer-group-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.layer-toggle-icon { + cursor: pointer; + padding: 2px 6px; + color: #666; + font-size: 11px; + border-radius: 4px; + transition: background 0.2s; + flex-shrink: 0; +} + +.layer-toggle-icon:hover { + background: #f0f0f0; + color: #333; +} + +.layer-toggle-icon i { + transition: transform 0.2s; +} + +.layer-toggle-icon.collapsed i { + transform: rotate(-90deg); +} + +.sub-layer-list { + padding-left: 18px; + margin-top: 4px; + border-left: 2px solid #eee; + overflow: hidden; + transition: max-height 0.2s ease; +} + +.sub-option { + font-size: 12px !important; + color: #555; + margin-bottom: 3px !important; +} + +/* ===== Emoji Marker (Pin Bubble) ===== */ +.emoji-marker { + display: flex; + flex-direction: column; + align-items: center; + pointer-events: auto; /* changed from none to allow click/drag */ +} + +.emoji-marker .bubble { + width: 38px; + height: 38px; + border-radius: 50% 50% 50% 0; + transform: rotate(-45deg); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 3px 10px rgba(0,0,0,0.25); + border: 2px solid rgba(255,255,255,0.8); + animation: markerPop 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275) both; +} + +.emoji-marker .bubble span { + transform: rotate(45deg); + font-size: 18px; + line-height: 1; +} + +@keyframes markerPop { + 0% { transform: rotate(-45deg) scale(0); } + 100% { transform: rotate(-45deg) scale(1); } +} + +/* Warna bubble per tipe */ +.emoji-marker .bubble.spbu-24 { background: linear-gradient(135deg, #22c55e, #16a34a); } +.emoji-marker .bubble.spbu-not24 { background: linear-gradient(135deg, #ef4444, #dc2626); } +.emoji-marker .bubble.ibadah { background: linear-gradient(135deg, #f97316, #ea580c); } +.emoji-marker .bubble.miskin-in { background: linear-gradient(135deg, #ef4444, #dc2626); } +.emoji-marker .bubble.miskin-out { background: linear-gradient(135deg, #22c55e, #16a34a); } + +/* ===== Toast Notification ===== */ +#toastContainer { + position: fixed; + bottom: 28px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + border-radius: 12px; + font-size: 13px; + font-weight: 500; + color: white; + box-shadow: 0 6px 24px rgba(0,0,0,0.18); + backdrop-filter: blur(6px); + pointer-events: auto; + animation: toastSlideIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) both; + max-width: 340px; + text-align: center; +} + +.toast.hiding { + animation: toastSlideOut 0.3s ease forwards; +} + +.toast-success { background: linear-gradient(135deg, #22c55e, #16a34a); } +.toast-error { background: linear-gradient(135deg, #ef4444, #dc2626); } +.toast-info { background: linear-gradient(135deg, #6366f1, #4f46e5); } +.toast-warning { background: linear-gradient(135deg, #f59e0b, #d97706); } + +.toast-icon { font-size: 16px; flex-shrink: 0; } + +@keyframes toastSlideIn { + 0% { opacity: 0; transform: translateY(20px) scale(0.9); } + 100% { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes toastSlideOut { + 0% { opacity: 1; transform: translateY(0) scale(1); } + 100% { opacity: 0; transform: translateY(10px) scale(0.95); } +} + +/* Auth Widget Styling */ +.auth-widget { + position: absolute; + top: 20px; + right: 80px; + z-index: 1000; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +body.right-panel-open .auth-widget { + right: 400px; +} + +.btn-login { + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + border: none; + border-radius: 24px; + height: 48px; + padding: 0 24px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; +} + +.btn-login:hover { + box-shadow: 0 6px 16px rgba(99, 102, 241, 0.45); + transform: translateY(-1px); +} + +.user-profile-pill { + background-color: white; + border-radius: 24px; + height: 48px; + padding: 0 8px 0 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: flex; + align-items: center; + gap: 12px; + border: 1px solid rgba(226, 232, 240, 0.8); + backdrop-filter: blur(10px); + background: rgba(255, 255, 255, 0.9); +} + +.user-profile-info { + display: flex; + flex-direction: column; +} + +.user-profile-name { + font-size: 12px; + font-weight: 700; + color: #1e1e2e; +} + +.user-profile-role { + font-size: 9px; + font-weight: 600; + color: #6366f1; + text-transform: uppercase; +} + +.btn-profile-logout { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: #f1f5f9; + color: #64748b; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.btn-profile-logout:hover { + background: #fee2e2; + color: #ef4444; +} + +/* User management table styling */ +#userManagementModal table th { + padding: 10px 12px; + font-weight: 600; + background-color: #f1f5f9; + border-bottom: 1px solid #cbd5e1; +} + +#userManagementModal table td { + padding: 10px 12px; + border-bottom: 1px solid #e2e8f0; +} +