Files
webgis_geospesial/assets/js/app.js
T
2026-06-11 13:17:31 +07:00

2712 lines
100 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
WebGIS - app.js
Sistem Informasi Geografis Pengentasan Kemiskinan
============================================================ */
(function () {
'use strict';
/* -- Konfigurasi ----------------------------------------- */
const DEFAULT_CENTER = [-0.05754868797441759, 109.32739998281652];
const DEFAULT_ZOOM = 14;
/* -- State Aplikasi -------------------------------------- */
let clickTimer = null; // Timer untuk membedakan single click & double click
let mode = 'point'; // mode aktif saat ini
let deleteCallback = null; // callback untuk konfirmasi hapus
// Preview sementara di peta
let previewMarker = null;
let previewCircle = null;
let previewLine = null;
let previewPolygon = null;
let previewVertices = [];
// Data untuk drawing
let jalanLatLngs = [];
let polygonLatLngs = [];
// Data runtime
let fasilitasPublikList = []; // [{id, nama, lat, lng, radius}, ...]
let pendudukList = []; // [{data, marker}, ...]
/* -- Layer Groups ---------------------------------------- */
let geoJalanLayer = null;
let geoKecamatanLayer= null;
let geoSungaiLayer = null;
const lgFasilitasPublik = L.layerGroup();
const lgPendudukDalam = L.layerGroup();
const lgPendudukLuar = L.layerGroup();
const lgSpbu24 = L.layerGroup();
const lgSpbuTidak = L.layerGroup();
const lgJalan = L.layerGroup();
const lgPolygon = L.layerGroup();
/* -- Inisialisasi Peta ----------------------------------- */
const savedCenter = (() => {
try { return JSON.parse(localStorage.getItem('webgis_center')); } catch { return null; }
})();
const savedZoom = parseInt(localStorage.getItem('webgis_zoom') || '0', 10) || DEFAULT_ZOOM;
const map = L.map('map', {
doubleClickZoom: false,
zoomControl: true
}).setView(savedCenter || DEFAULT_CENTER, savedZoom);
// Base tile (Premium CARTO Positron for SaaS look)
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
}).addTo(map);
// Scale control
L.control.scale({ imperial: false, metric: true, position: 'bottomleft' }).addTo(map);
// Simpan posisi peta
map.on('moveend zoomend', () => {
const c = map.getCenter();
localStorage.setItem('webgis_center', JSON.stringify([c.lat, c.lng]));
localStorage.setItem('webgis_zoom', String(map.getZoom()));
});
// Inisialisasi Lottie Splash Screen
const splashEl = document.getElementById('splashScreen');
if (splashEl && window.lottie) {
lottie.loadAnimation({
container: document.getElementById('lottieContainer'),
renderer: 'svg',
loop: true,
autoplay: true,
path: 'assets/animasi-lokasi.json'
});
}
// Sembunyikan loading setelah peta siap
map.whenReady(() => {
setTimeout(() => {
const ml = el('mapLoading');
if(ml) ml.classList.add('hidden');
}, 600);
// Sembunyikan Splash Screen dengan efek fade-out yang mulus
if (splashEl) {
setTimeout(() => {
splashEl.classList.add('splash-hidden');
setTimeout(() => splashEl.remove(), 500); // Hapus elemen setelah transisi opacity 0.5s selesai
}, 1500); // Delay 1.5 detik agar preloader premium sempat dinikmati user
}
});
/* -- Tambahkan layer groups ke peta ---------------------- */
[lgFasilitasPublik, lgPendudukDalam, lgPendudukLuar].forEach(lg => lg.addTo(map));
if (document.getElementById("layerSpbu24") && document.getElementById("layerSpbu24").checked) lgSpbu24.addTo(map);
if (document.getElementById("layerSpbuTidak") && document.getElementById("layerSpbuTidak").checked) lgSpbuTidak.addTo(map);
if (document.getElementById("layerJalan") && document.getElementById("layerJalan").checked) lgJalan.addTo(map);
if (document.getElementById("layerPolygon") && document.getElementById("layerPolygon").checked) lgPolygon.addTo(map);
/* -------------------------------------------------------
HELPER FUNCTIONS
------------------------------------------------------- */
function el(id) { return document.getElementById(id); }
function showToast(message, type = 'success', duration = 3500) {
const container = el('toastContainer');
const toast = document.createElement('div');
const icons = { success: '\u2713', error: '\u2715', info: '\u2139', warning: '\u26A0' };
toast.className = `toast ${type}`;
toast.innerHTML = `<span>${icons[type] || '\u2022'}</span><span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => toast.remove(), 350);
}, duration);
}
function showDeleteConfirm(message, callback) {
el('deleteMessage').textContent = message;
deleteCallback = callback;
showModal('deleteModal');
}
function showModal(id) {
el('modalBackdrop').classList.remove('hidden');
el(id).classList.remove('hidden');
}
function hideModal(id) {
el(id).classList.add('hidden');
// sembunyikan backdrop hanya jika tidak ada modal lain terbuka
const openModals = document.querySelectorAll('.modal:not(.hidden)');
if (openModals.length === 0) {
el('modalBackdrop').classList.add('hidden');
}
}
function hideAllModals() {
document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden'));
el('modalBackdrop').classList.add('hidden');
}
function setHelpText(text) {
el('helpText').textContent = text;
}
/* -- Kalkulasi Geospasial -------------------------------- */
function hitungPanjang(latlngs) {
let total = 0;
for (let i = 1; i < latlngs.length; i++) {
total += latlngs[i - 1].distanceTo(latlngs[i]);
}
return total;
}
function hitungLuas(latlngs) {
if (latlngs.length < 3) return 0;
const pts = latlngs.map(ll => map.options.crs.project(ll));
let area = 0;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
area += (pts[j].x * pts[i].y) - (pts[i].x * pts[j].y);
}
return Math.abs(area / 2);
}
function getDistanceMeter(lat1, lng1, lat2, lng2) {
return L.latLng(lat1, lng1).distanceTo(L.latLng(lat2, lng2));
}
function cekJangkauan(lat, lng) {
let hasil = { status: 'Luar Jangkauan', id: '', nama: '' };
let minDistance = Infinity;
fasilitasPublikList.forEach(ri => {
const d = getDistanceMeter(lat, lng, parseFloat(ri.latitude), parseFloat(ri.longitude));
if (d <= parseFloat(ri.radius) && d < minDistance) {
minDistance = d;
hasil = { status: 'Dalam Jangkauan', id: ri.id, nama: ri.nama };
}
});
return hasil;
}
/* -- GeoJSON helpers ------------------------------------- */
function toLineFeature(latlngs, props = {}) {
return {
type: 'Feature',
geometry: { type: 'LineString', coordinates: latlngs.map(ll => [ll.lng, ll.lat]) },
properties: props
};
}
function toPolygonFeature(latlngs, props = {}) {
const ring = latlngs.map(ll => [ll.lng, ll.lat]);
if (ring.length && (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1])) {
ring.push([ring[0][0], ring[0][1]]);
}
return {
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [ring] },
properties: props
};
}
function lineFeatureToLatLngs(feat) {
if (!feat?.geometry?.coordinates) return [];
return feat.geometry.coordinates.map(c => [c[1], c[0]]);
}
function polygonFeatureToLatLngs(feat) {
if (!feat?.geometry?.coordinates?.[0]) return [];
const ring = [...feat.geometry.coordinates[0]];
if (ring.length > 1) {
const f = ring[0], l = ring[ring.length - 1];
if (f[0] === l[0] && f[1] === l[1]) ring.pop();
}
return ring.map(c => [c[1], c[0]]);
}
/* -- Warna Helper ---------------------------------------- */
function warnaJalan(status) {
return { 'Jalan Nasional': '#d32f2f', 'Jalan Provinsi': '#f57c00', 'Jalan Kabupaten': '#388e3c' }[status] || '#1565c0';
}
function warnaPolygon(status) {
return { 'SHM': '#1b5e20', 'HGB': '#1565c0', 'HGU': '#ef6c00', 'HP': '#6a1b9a' }[status] || '#455a64';
}
function warnaKecamatan(nama) {
const colors = {
'Pontianak Barat': '#e41a1c', 'Pontianak Timur': '#377eb8',
'Pontianak Utara': '#4daf4a', 'Pontianak Selatan': '#984ea3',
'Pontianak Kota': '#ff7f00', 'Pontianak Tenggara': '#a65628'
};
return colors[nama] || '#888888';
}
/* -- Custom Marker Icons --------------------------------- */
function createFPIcon() {
const svg = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2" ry="2"></rect><line x1="9" y1="22" x2="9" y2="16"></line><line x1="15" y1="22" x2="15" y2="16"></line><line x1="9" y1="16" x2="15" y2="16"></line><circle cx="8" cy="6" r="1"></circle><circle cx="16" cy="6" r="1"></circle><circle cx="8" cy="11" r="1"></circle><circle cx="16" cy="11" r="1"></circle></svg>`;
return L.divIcon({
className: '',
html: `<div class="custom-marker fp-marker"><div class="marker-icon">${svg}</div></div>`,
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -34]
});
}
function createPointIcon(is24) {
const markerClass = is24 ? 'point-marker-24' : 'point-marker-normal';
const svg = `<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="1.5"></circle></svg>`;
return L.divIcon({
className: '',
html: `<div class="custom-marker ${markerClass}"><div class="marker-icon">${svg}</div></div>`,
iconSize: [26, 26],
iconAnchor: [13, 26],
popupAnchor: [0, -28]
});
}
function createPendudukIcon(isDalam) {
const colorClass = isDalam ? 'pm-marker-dalam' : 'pm-marker-luar';
const svg = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`;
return L.divIcon({
className: '',
html: `<div class="custom-marker ${colorClass}"><div class="marker-icon">${svg}</div></div>`,
iconSize: [28, 28],
iconAnchor: [14, 28],
popupAnchor: [0, -30]
});
}
/* -------------------------------------------------------
PREVIEW MANAGEMENT
------------------------------------------------------- */
function clearAllPreview() {
if (previewMarker) { map.removeLayer(previewMarker); previewMarker = null; }
if (previewCircle) { map.removeLayer(previewCircle); previewCircle = null; }
if (previewLine) { map.removeLayer(previewLine); previewLine = null; }
if (previewPolygon) { map.removeLayer(previewPolygon); previewPolygon = null; }
previewVertices.forEach(v => map.removeLayer(v));
previewVertices = [];
jalanLatLngs = [];
polygonLatLngs = [];
}
function addVertexMarker(latlng, color = '#1565c0') {
const v = L.circleMarker(latlng, {
radius: 5, color, fillColor: color,
fillOpacity: 1, weight: 2
}).addTo(map);
previewVertices.push(v);
return v;
}
function updateJalanPreview() {
if (previewLine) { map.removeLayer(previewLine); previewLine = null; }
previewVertices.forEach(v => map.removeLayer(v));
previewVertices = [];
jalanLatLngs.forEach((ll, i) => {
const v = addVertexMarker(ll, '#d32f2f');
v.bindTooltip(`Titik ${i + 1}`, { direction: 'top', permanent: false });
});
if (jalanLatLngs.length >= 2) {
previewLine = L.polyline(jalanLatLngs, { color: '#d32f2f', weight: 4, opacity: 0.9 }).addTo(map);
el('panjang_meter').value = hitungPanjang(jalanLatLngs).toFixed(2);
}
el('jumlah_titik_jalan').value = jalanLatLngs.length;
el('koordinat_jalan').value = JSON.stringify(toLineFeature(jalanLatLngs), null, 2);
}
function updatePolygonPreview() {
if (previewPolygon) { map.removeLayer(previewPolygon); previewPolygon = null; }
previewVertices.forEach(v => map.removeLayer(v));
previewVertices = [];
polygonLatLngs.forEach((ll, i) => {
const v = addVertexMarker(ll, '#6a1b9a');
v.bindTooltip(`Titik ${i + 1}`, { direction: 'top', permanent: false });
});
if (polygonLatLngs.length >= 3) {
previewPolygon = L.polygon(polygonLatLngs, {
color: '#6a1b9a', fillColor: '#ba68c8', fillOpacity: 0.3, weight: 3
}).addTo(map);
el('luas_tanah').value = hitungLuas(polygonLatLngs).toFixed(4);
}
el('jumlah_titik_polygon').value = polygonLatLngs.length;
el('koordinat_polygon').value = JSON.stringify(toPolygonFeature(polygonLatLngs), null, 2);
}
/* -------------------------------------------------------
MODE MANAGEMENT
------------------------------------------------------- */
const MODES = {
point: { btn: 'btnToolPoint', help: 'Klik pada peta untuk menambah titik.' },
jalan: { btn: 'btnToolJalan', help: 'Klik titik-titik jalan. Double-klik untuk selesai. Klik-kanan untuk hapus titik terakhir.' },
polygon: { btn: 'btnToolPolygon', help: 'Klik titik-titik polygon. Double-klik untuk selesai. Klik-kanan untuk hapus titik terakhir.' },
fasilitas_publik: { btn: 'btnToolFasilitasPublik',help: 'Klik pada peta untuk menentukan lokasi Fasilitas Publik.' },
penduduk_miskin:{ btn: 'btnToolPenduduk', help: 'Klik pada peta untuk menentukan lokasi penduduk miskin.' },
lokasi: { btn: 'btnToolLokasi', help: 'Mencari lokasi Anda...' },
};
function setMode(newMode) {
clearAllPreview();
hideAllModals();
mode = newMode;
// Update active button
document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
const cfg = MODES[newMode];
if (cfg) {
const btn = el(cfg.btn);
if (btn) btn.classList.add('active');
setHelpText(cfg.help);
}
// Khusus lokasi: langsung trigger
if (newMode === 'lokasi') {
map.locate({ setView: true, maxZoom: 18 });
if (el('btnToolPoint')) {
mode = 'point'; // kembali ke point setelah locate
el('btnToolPoint').classList.add('active');
} else {
mode = 'view';
}
if (el('btnToolLokasi')) el('btnToolLokasi').classList.remove('active');
}
}
/* -------------------------------------------------------
RENDER FUNCTIONS
------------------------------------------------------- */
/* -- Render Point ---------------------------------------- */
function renderPoint(item) {
const lat = parseFloat(item.latitude);
const lng = parseFloat(item.longitude);
const is24 = item.buka_24_jam === 'Ya';
const marker = L.marker([lat, lng], {
icon: createPointIcon(is24),
draggable: (typeof USER_ROLE !== 'undefined' && USER_ROLE !== 'guest')
}).addTo(is24 ? lgSpbu24 : lgSpbuTidak);
marker.on('dragend', function(e) {
const pos = e.target.getLatLng();
updateLocation('point', item.id, pos.lat, pos.lng, item.nama);
});
const popup = `
<div class="popup-card">
<div class="popup-title">&#x1F4CD; ${item.nama}</div>
<div class="popup-row"><strong>No. WA</strong><span>${item.no_wa}</span></div>
<div class="popup-row"><strong>24 Jam</strong><span>${item.buka_24_jam}</span></div>
</div>
<div class="popup-actions">
<button class="popup-btn" onclick="editPoint(${item.id})">&#x270F;&#xFE0F; Edit</button>
<button class="popup-btn del" onclick="hapusData('point',${item.id},'Hapus titik ${item.nama}?')">&#x1F5D1; Hapus</button>
</div>`;
marker.bindPopup(popup, { maxWidth: 280 });
return marker;
}
/* -- GLOBAL: EDIT Point / SPBU --------------------------- */
window.editPoint = function (id) {
fetch(`api/get_point.php?id=${id}`)
.then(r => r.json())
.then(res => {
if (res.status === 'success') {
const d = res.data;
el('point_id').value = d.id;
el('point_nama').value = d.nama;
el('point_no_wa').value = d.no_wa;
// Handle radio buttons
if (d.buka_24_jam === 'Ya') {
document.querySelector('input[name="point_buka_24_jam"][value="Ya"]').checked = true;
} else {
document.querySelector('input[name="point_buka_24_jam"][value="Tidak"]').checked = true;
}
// Populate coordinates
el('point_latitude').value = d.latitude;
el('point_longitude').value = d.longitude;
el('pointModalTitle').textContent = 'Edit Titik';
// Preview di peta
const tempGeom = { lat: parseFloat(d.latitude), lng: parseFloat(d.longitude) };
if (previewMarker) map.removeLayer(previewMarker);
previewMarker = L.marker([tempGeom.lat, tempGeom.lng], {
icon: createPointIcon(d.buka_24_jam === 'Ya')
}).addTo(map);
map.setView([tempGeom.lat, tempGeom.lng], 16);
el('pointModal').classList.remove('hidden');
el('modalBackdrop').classList.remove('hidden');
} else {
showAlert('Gagal', res.message || 'Gagal mengambil data');
}
})
.catch(e => {
console.error(e);
showAlert('Terjadi Kesalahan', 'Error: ' + e.toString() + '. Mohon segarkan (refresh) halaman.');
});
};
/* -- Render Jalan ---------------------------------------- */
function renderJalan(item) {
try {
const feat = JSON.parse(item.koordinat);
const coords = lineFeatureToLatLngs(feat);
const poly = L.polyline(coords, {
color: warnaJalan(item.status_jalan), weight: 5, opacity: 0.85
}).addTo(lgJalan);
const popup = `
<div class="popup-card">
<div class="popup-title">&#x1F6E3;&#xFE0F; ${item.nama_jalan}</div>
<div class="popup-row"><strong>Status</strong><span>${item.status_jalan}</span></div>
<div class="popup-row"><strong>Panjang</strong><span>${parseFloat(item.panjang_meter).toFixed(1)} m</span></div>
</div>
<div class="popup-actions">
<button class="popup-btn del" onclick="hapusData('jalan',${item.id},'Hapus jalan ${item.nama_jalan}?')">&#x1F5D1; Hapus</button>
</div>`;
poly.bindPopup(popup, { maxWidth: 280 });
return poly;
} catch (e) { console.error('renderJalan error:', e); }
}
/* -- Render Polygon -------------------------------------- */
function renderPolygon(item) {
try {
const feat = JSON.parse(item.koordinat);
const coords = polygonFeatureToLatLngs(feat);
const warna = warnaPolygon(item.status_kepemilikan);
const poly = L.polygon(coords, {
color: warna, fillColor: warna, fillOpacity: 0.3, weight: 3
}).addTo(lgPolygon);
const popup = `
<div class="popup-card">
<div class="popup-title">&#x1F4D0; ${item.nama_parsil}</div>
<div class="popup-row"><strong>Status</strong><span>${item.status_kepemilikan}</span></div>
<div class="popup-row"><strong>Luas</strong><span>${parseFloat(item.luas_tanah).toFixed(2)} m&sup2;</span></div>
</div>
<div class="popup-actions">
<button class="popup-btn del" onclick="hapusData('polygon',${item.id},'Hapus polygon ${item.nama_parsil}?')">&#x1F5D1; Hapus</button>
</div>`;
poly.bindPopup(popup, { maxWidth: 280 });
return poly;
} catch (e) { console.error('renderPolygon error:', e); }
}
/* -- Render Fasilitas Publik ----------------------------- */
function renderFasilitasPublik(item) {
const lat = parseFloat(item.latitude);
const lng = parseFloat(item.longitude);
const radius = parseFloat(item.radius);
fasilitasPublikList.push(item);
const marker = L.marker([lat, lng], { icon: createFPIcon() }).addTo(lgFasilitasPublik);
const circle = L.circle([lat, lng], {
radius, color: '#e53935', fillColor: '#ef9a9a',
fillOpacity: 0.12, weight: 2, dashArray: '6 4',
interactive: false
}).addTo(lgFasilitasPublik);
const popup = `
<div class="popup-card">
<div class="popup-title">
&#x1F3E2; ${item.nama}
<span class="popup-badge badge-fp">${item.jenis || 'Fasilitas Publik'}</span>
</div>
<div class="popup-row"><strong>Alamat</strong><span>${item.alamat || '-'}</span></div>
<div class="popup-row"><strong>Radius</strong><span>${radius} m</span></div>
<div class="popup-row"><strong>Kontak</strong><span>${item.kontak || '-'}</span></div>
</div>
<div class="popup-actions">
<button class="popup-btn" onclick="editFasilitasPublik(${item.id})">&#x270F;&#xFE0F; Edit</button>
<button class="popup-btn del" onclick="hapusData('fasilitas_publik',${item.id},'Hapus ${item.nama}?')">&#x1F5D1; Hapus</button>
</div>`;
marker.bindPopup(popup, { maxWidth: 300 });
return { marker, circle };
}
/* -- Render Penduduk Miskin ------------------------------ */
function renderPenduduk(item) {
const lat = parseFloat(item.latitude);
const lng = parseFloat(item.longitude);
const cek = cekJangkauan(lat, lng);
// FIX SPASIAL: Pastikan status jangkauan tersimpan di objek item untuk dirender di sidebar
item.status_jangkauan = cek.status;
const marker = L.marker([lat, lng], {
icon: createPendudukIcon(cek.status === 'Dalam Jangkauan'),
draggable: (typeof USER_ROLE !== 'undefined' && USER_ROLE !== 'guest')
}).addTo(cek.status === 'Dalam Jangkauan' ? lgPendudukDalam : lgPendudukLuar);
marker.on('dragend', function(e) {
const pos = e.target.getLatLng();
updateLocation('penduduk_miskin', item.id, pos.lat, pos.lng, item.nama);
});
const badgeClass = cek.status === 'Dalam Jangkauan' ? 'badge-dalam' : 'badge-luar';
let fotoHtml = '';
if (item.foto_rumah) {
fotoHtml += `<img src="uploads/${item.foto_rumah}" style="width:100%;height:120px;object-fit:cover;border-radius:8px;margin-bottom:8px;">`;
}
const popup = `
<div class="popup-card">
${fotoHtml}
<div class="popup-title">
&#x1F464; ${item.nama}
<span class="popup-badge ${badgeClass}">${cek.status}</span>
</div>
<div class="popup-row"><strong>NIK</strong><span>${item.nik || '-'}</span></div>
<div class="popup-row"><strong>Ditangani</strong><span>${cek.nama || item.fp_nama || '-'}</span></div>
<div class="popup-row"><strong>Keterangan</strong><span>${item.keterangan || '-'}</span></div>
</div>
<div class="popup-actions">
<button class="popup-btn" onclick="editPenduduk(${item.id})">&#x270F;&#xFE0F; Edit</button>
<button class="popup-btn del" onclick="hapusData('penduduk_miskin',${item.id},'Hapus data ${item.nama}?')">&#x1F5D1; Hapus</button>
</div>`;
marker.bindPopup(popup, { maxWidth: 300 });
pendudukList.push({ data: item, marker });
return marker;
}
/* -- Refresh Status Penduduk ----------------------------- */
function refreshStatusPenduduk() {
lgPendudukDalam.clearLayers();
lgPendudukLuar.clearLayers();
const backup = pendudukList.map(p => p.data);
pendudukList = [];
backup.forEach(item => renderPenduduk(item));
updateDashboardStats(); // Panggil fungsi update stat
}
/* -- Update Statistik Dashboard Secara Real-Time --------- */
function updateDashboardStats() {
const totalFP = fasilitasPublikList.length;
const totalPM = pendudukList.length;
let ditangani = 0;
pendudukList.forEach(p => {
const cek = cekJangkauan(parseFloat(p.data.latitude), parseFloat(p.data.longitude));
if (cek.status === 'Dalam Jangkauan') ditangani++;
});
const belum = totalPM - ditangani;
const persen = totalPM > 0 ? ((ditangani / totalPM) * 100).toFixed(1) : 0;
// Update SPBU Stats
if (el('statTotalSPBU')) {
const totalSPBU = APP_DATA.points.length;
let spbu24 = 0;
APP_DATA.points.forEach(p => { if (p.buka_24_jam === 'Ya') spbu24++; });
const spbuTidak = totalSPBU - spbu24;
el('statTotalSPBU').textContent = totalSPBU;
if (el('statSPBU24')) el('statSPBU24').textContent = spbu24;
if (el('statSPBUTidak')) el('statSPBUTidak').textContent = spbuTidak;
}
// Update angka di HTML
if (el('statTotalRI')) el('statTotalRI').textContent = totalFP;
if (el('statTotalPM')) el('statTotalPM').textContent = totalPM;
if (el('statDitangani')) el('statDitangani').textContent = ditangani;
if (el('statBelum')) el('statBelum').textContent = belum;
if (el('statPersen')) el('statPersen').textContent = persen + '%';
if (el('statProgressBar')) el('statProgressBar').style.width = persen + '%';
}
/* -- Real-Time Refresh Data (Modul Bantuan) -------------- */
async function refreshDataModul() {
try {
const res = await fetch('api/get_all_bantuan.php');
const json = await res.json();
if (json.status === 'success') {
// 1. Update data global
APP_DATA.fasilitasPublik = json.fasilitasPublik;
APP_DATA.pendudukMiskin = json.pendudukMiskin;
// 2. Clear layer peta
lgFasilitasPublik.clearLayers();
lgPendudukDalam.clearLayers();
lgPendudukLuar.clearLayers();
// 3. Reset array data lokal
fasilitasPublikList = [];
pendudukList = [];
// 4. Render ulang marker (renderPenduduk akan otomatis hitung jarak spasial terbaru)
APP_DATA.fasilitasPublik.forEach(renderFasilitasPublik);
APP_DATA.pendudukMiskin.forEach(renderPenduduk);
// 5. Update stat & panel data
updateDashboardStats();
if(window.__rebuildCards) window.__rebuildCards();
}
} catch (err) {
console.error('Gagal me-refresh data bantuan:', err);
}
}
/* -------------------------------------------------------
LOAD DATA AWAL
------------------------------------------------------- */
(APP_DATA.points || []).forEach(renderPoint);
(APP_DATA.jalan || []).forEach(renderJalan);
(APP_DATA.polygons || []).forEach(renderPolygon);
(APP_DATA.fasilitasPublik || []).forEach(renderFasilitasPublik);
(APP_DATA.pendudukMiskin || []).forEach(renderPenduduk);
/* -------------------------------------------------------
LOAD GEOJSON EKSTERNAL
------------------------------------------------------- */
function loadGeoJSON(url, options = {}) {
return fetch(url)
.then(r => { if (!r.ok) throw new Error('Gagal load: ' + url); return r.json(); })
.then(geojson => {
const layer = L.geoJSON(geojson, {
style: feat => {
const t = feat.geometry?.type || '';
if (t === 'Polygon' || t === 'MultiPolygon') {
let fill = options.fillColor || '#42a5f5';
if (options.dynamicColor && feat.properties) {
const nm = feat.properties.Ket || feat.properties.WADMKC ||
feat.properties.KECAMATAN || feat.properties.nama || '';
fill = warnaKecamatan(nm);
}
return { color: '#333', weight: 1.5, fillColor: fill, fillOpacity: 0.35 };
}
if (t === 'LineString' || t === 'MultiLineString') {
return { color: options.color || '#1565c0', weight: options.weight || 2, opacity: 0.9 };
}
return { color: options.color || '#1565c0' };
},
pointToLayer: (feat, latlng) => L.circleMarker(latlng, {
radius: 4, color: options.color || '#1565c0',
fillColor: options.fillColor || '#42a5f5', fillOpacity: 0.9, weight: 1
}),
onEachFeature: (feat, layer) => {
if (!feat.properties) return;
let html = `<div class="popup-card"><div class="popup-title">${options.title || 'GeoJSON'}</div>`;
Object.entries(feat.properties).forEach(([k, v]) => {
if (v) html += `<div class="popup-row"><strong>${k}</strong><span>${v}</span></div>`;
});
html += '</div>';
layer.bindPopup(html, { maxWidth: 280 });
}
});
if (options.adjustWeight) {
map.on('zoomend', () => {
const z = map.getZoom();
const w = z <= 12 ? 0.8 : z <= 14 ? 1.5 : z <= 16 ? 2 : 3;
layer.setStyle({ weight: w });
});
}
if (options.addToMap !== false) { layer.addTo(map); if (options.fitBounds) map.fitBounds(layer.getBounds()); } return layer;
})
.catch(err => { console.warn(err.message); return null; });
}
Promise.all([
loadGeoJSON('data/Sungai_Besar.geojson', {
title: 'Sungai Besar', color: '#0d47a1', fillColor: '#90caf9',
weight: 2, fitBounds: false, addToMap: false
}),
loadGeoJSON('data/admin_kecamatan.geojson', {
title: 'Admin Kecamatan', dynamicColor: true,
color: '#ef6c00', weight: 1.5, fitBounds: false, addToMap: false
}),
loadGeoJSON('data/Jaringan_Jalan.geojson', {
title: 'Jaringan Jalan', color: '#d32f2f',
weight: 1.5, fitBounds: false, adjustWeight: true, addToMap: false
})
]).then(([sungai, kecamatan, jalan]) => {
geoSungaiLayer = sungai;
geoKecamatanLayer = kecamatan;
geoJalanLayer = jalan;
if (el('layerSungai') && el('layerSungai').checked && geoSungaiLayer) geoSungaiLayer.addTo(map);
if (el('layerKecamatan') && el('layerKecamatan').checked && geoKecamatanLayer) geoKecamatanLayer.addTo(map);
if (el('layerGeoJalan') && el('layerGeoJalan').checked && geoJalanLayer) geoJalanLayer.addTo(map);
});
/* -------------------------------------------------------
REVERSE GEOCODING
------------------------------------------------------- */
function reverseGeocode(lat, lng) {
el('fp_alamat').value = '';
el('geocodeStatus').textContent = '\u23F3 Mencari alamat...';
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=id`)
.then(r => r.json())
.then(data => {
el('fp_alamat').value = data.display_name || 'Alamat tidak ditemukan';
el('geocodeStatus').textContent = '\u2713';
setTimeout(() => { el('geocodeStatus').textContent = ''; }, 2000);
})
.catch(() => {
el('fp_alamat').value = 'Gagal mengambil alamat';
el('geocodeStatus').textContent = '\u2715';
});
}
/* -------------------------------------------------------
MAP EVENT HANDLERS
------------------------------------------------------- */
map.on('click', e => {
const { lat, lng } = e.latlng;
// Mode yang tidak perlu timer (langsung eksekusi)
if (mode === 'point') {
clearAllPreview();
el('point_id').value = '';
el('point_nama').value = '';
el('point_no_wa').value = '';
el('point_latitude').value = lat.toFixed(10);
el('point_longitude').value = lng.toFixed(10);
document.querySelector('input[name="point_buka_24_jam"][value="Tidak"]').checked = true;
el('pointModalTitle').textContent = 'Tambah Titik';
previewMarker = L.marker(e.latlng, { icon: createPointIcon(false) }).addTo(map);
showModal('pointModal');
return;
}
if (mode === 'fasilitas_publik') {
el('fp_id').value = '';
el('fp_nama').value = '';
el('fp_jenis').value = 'Masjid';
el('fp_kontak').value = '';
el('fp_radius').value = 300;
el('fp_radius_text').textContent = '300';
el('fp_latitude').value = lat.toFixed(10);
el('fp_longitude').value = lng.toFixed(10);
el('fpModalTitle').textContent = 'Tambah Fasilitas Publik';
if (previewMarker) map.removeLayer(previewMarker);
if (previewCircle) map.removeLayer(previewCircle);
previewMarker = L.marker(e.latlng, { icon: createFPIcon() }).addTo(map);
previewCircle = L.circle(e.latlng, {
radius: 300, color: '#e53935', fillColor: '#ef9a9a',
fillOpacity: 0.12, weight: 2, dashArray: '6 4'
}).addTo(map);
reverseGeocode(lat, lng);
showModal('fasilitasPublikModal');
return;
}
if (mode === 'penduduk_miskin') {
el('pm_id').value = '';
el('pm_nama').value = '';
el('pm_nik').value = '';
el('pm_keterangan').value = '';
el('pm_latitude').value = lat.toFixed(10);
el('pm_longitude').value = lng.toFixed(10);
el('pmModalTitle').textContent = 'Tambah Penduduk Miskin';
const cek = cekJangkauan(lat, lng);
updateStatusJangkauan(cek); if (previewMarker) map.removeLayer(previewMarker);
previewMarker = L.marker(e.latlng, {
icon: createPendudukIcon(cek.status === 'Dalam Jangkauan')
}).addTo(map);
showModal('pendudukModal');
return;
}
// Khusus mode Menggambar (Jalan & Polygon), gunakan Timer
if (mode === 'jalan' || mode === 'polygon') {
if (clickTimer) clearTimeout(clickTimer); // Hapus timer lama jika ada klik berdekatan
clickTimer = setTimeout(() => {
// Eksekusi jika benar-benar single-click
if (mode === 'jalan') {
jalanLatLngs.push(e.latlng);
updateJalanPreview();
} else if (mode === 'polygon') {
polygonLatLngs.push(e.latlng);
updatePolygonPreview();
}
}, 250); // Delay 250 milidetik
}
});
map.on('dblclick', () => {
if (clickTimer) {
clearTimeout(clickTimer); // Batalkan single-click jika terdeteksi double-click
clickTimer = null;
}
if (mode === 'jalan') {
if (jalanLatLngs.length < 2) { showToast('Minimal 2 titik untuk jalan.', 'warning'); return; }
updateJalanPreview();
showModal('jalanModal');
}
if (mode === 'polygon') {
if (polygonLatLngs.length < 3) { showToast('Minimal 3 titik untuk polygon.', 'warning'); return; }
updatePolygonPreview();
showModal('polygonModal');
}
});
map.on('contextmenu', () => {
if (mode === 'jalan' && jalanLatLngs.length > 0) {
jalanLatLngs.pop(); updateJalanPreview(); return;
}
if (mode === 'polygon' && polygonLatLngs.length > 0) {
polygonLatLngs.pop(); updatePolygonPreview(); return;
}
if (mode === 'point') {
clearAllPreview();
hideModal('pointModal');
}
});
map.on('locationfound', e => {
showToast('Lokasi ditemukan!', 'success');
L.marker(e.latlng)
.addTo(map)
.bindPopup('<div class="popup-card"><div class="popup-title">&#x1F4E1; Lokasi Saya</div></div>')
.openPopup();
});
map.on('locationerror', () => {
showToast('Tidak dapat mengakses lokasi. Pastikan izin lokasi browser aktif.', 'error');
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { clearAllPreview(); hideAllModals(); }
});
/* -------------------------------------------------------
STATUS JANGKAUAN UI
------------------------------------------------------- */
function updateStatusJangkauan(cek) {
const elStatus = el('pm_status');
const elDitangani = el('pm_ditangani');
elStatus.textContent = cek.status;
elStatus.className = 'sj-value ' + (cek.status === 'Dalam Jangkauan' ? 'dalam' : 'luar');
elDitangani.textContent = cek.nama ? `Ditangani oleh: ${cek.nama}` : 'Tidak dalam jangkauan Fasilitas Publik manapun.';
}
/* -------------------------------------------------------
TOOL BUTTONS
------------------------------------------------------- */
document.querySelectorAll('.tool-btn').forEach(btn => {
btn.addEventListener('click', () => {
const m = btn.dataset.mode;
if (m) setMode(m);
});
});
/* -------------------------------------------------------
SIDEBAR TOGGLE
------------------------------------------------------- */
/* -- Toggle Sidebar -------------------------------------- */
const sidebar = el('sidebar');
const mapContainer = el('mapContainer');
const sidebarOpenBtn= el('sidebarOpenBtn');
const isMobile = () => window.innerWidth <= 680;
el('sidebarToggle').addEventListener('click', () => {
if (isMobile()) {
sidebar.classList.remove('mobile-open');
} else {
sidebar.classList.add('collapsed');
mapContainer.classList.add('full-width');
sidebarOpenBtn.classList.remove('hidden');
map.invalidateSize();
}
});
sidebarOpenBtn.addEventListener('click', () => {
if (isMobile()) {
sidebar.classList.add('mobile-open');
} else {
sidebar.classList.remove('collapsed');
mapContainer.classList.remove('full-width');
sidebarOpenBtn.classList.add('hidden');
map.invalidateSize();
}
});
/* -------------------------------------------------------
LAYER CHECKBOXES
------------------------------------------------------- */
function bindLayerToggle(checkboxId, layerGroup) {
const cb = el(checkboxId);
if (!cb) return;
cb.addEventListener('change', () => {
if (cb.checked) {
if (!map.hasLayer(layerGroup)) layerGroup.addTo(map);
} else {
map.removeLayer(layerGroup);
}
});
}
bindLayerToggle('layerFasilitasPublik', lgFasilitasPublik);
bindLayerToggle('layerPendudukDalam', lgPendudukDalam);
bindLayerToggle('layerPendudukLuar', lgPendudukLuar);
bindLayerToggle("layerSpbu24", lgSpbu24);
bindLayerToggle("layerSpbuTidak", lgSpbuTidak);
bindLayerToggle("layerJalan", lgJalan);
bindLayerToggle("layerPolygon", lgPolygon);
if (el('layerGeoJalan')) {
el('layerGeoJalan').addEventListener('change', function () {
if (geoJalanLayer) { this.checked ? geoJalanLayer.addTo(map) : map.removeLayer(geoJalanLayer); }
});
}
if (el('layerKecamatan')) {
el('layerKecamatan').addEventListener('change', function () {
if (geoKecamatanLayer) { this.checked ? geoKecamatanLayer.addTo(map) : map.removeLayer(geoKecamatanLayer); }
});
}
if (el('layerSungai')) {
el('layerSungai').addEventListener('change', function () {
if (geoSungaiLayer) { this.checked ? geoSungaiLayer.addTo(map) : map.removeLayer(geoSungaiLayer); }
});
}
/* -------------------------------------------------------
RADIUS SLIDER -> PREVIEW
------------------------------------------------------- */
el('fp_radius').addEventListener('input', function () {
const r = parseInt(this.value, 10);
el('fp_radius_text').textContent = r;
if (previewCircle) previewCircle.setRadius(r);
});
/* -------------------------------------------------------
MODAL CLOSE BUTTONS
------------------------------------------------------- */
[
['closePointModal', 'pointModal'],
['cancelPointModal', 'pointModal'],
['closeJalanModal', 'jalanModal'],
['cancelJalanModal', 'jalanModal'],
['closePolygonModal', 'polygonModal'],
['cancelPolygonModal', 'polygonModal'],
['closefasilitasPublikModal', 'fasilitasPublikModal'],
['cancelfasilitasPublikModal','fasilitasPublikModal'],
['closePendudukModal', 'pendudukModal'],
['cancelPendudukModal', 'pendudukModal'],
].forEach(([btnId, modalId]) => {
const btn = el(btnId);
if (btn) btn.addEventListener('click', () => {
hideModal(modalId);
clearAllPreview();
});
});
el('modalBackdrop').addEventListener('click', () => {
hideAllModals();
clearAllPreview();
});
/* Konfirmasi hapus */
el('confirmDelete').addEventListener('click', () => {
if (typeof deleteCallback === 'function') deleteCallback();
hideModal('deleteModal');
deleteCallback = null;
});
el('cancelDelete').addEventListener('click', () => {
hideModal('deleteModal');
deleteCallback = null;
});
/* -------------------------------------------------------
SAVE: POINT
------------------------------------------------------- */
el('btnSavePoint').addEventListener('click', () => {
const nama = el('point_nama').value.trim();
const no_wa = el('point_no_wa').value.trim();
const radio = document.querySelector('input[name="point_buka_24_jam"]:checked');
const lat = el('point_latitude').value.trim();
const lng = el('point_longitude').value.trim();
if (!nama || !no_wa || !radio || !lat || !lng) {
showAlert('Data Tidak Lengkap', 'Peringatan: <b>Semua kolom</b> wajib diisi dan Anda harus <b>mengklik peta</b> untuk menentukan lokasi SPBU terlebih dahulu.');
return;
}
const id = el('point_id').value;
const url = id ? 'point/update.php' : 'point/simpan.php';
const fd = new FormData();
if (id) fd.append('id', id);
fd.append('nama', nama);
fd.append('no_wa', no_wa);
fd.append('buka_24_jam', radio.value);
fd.append('latitude', lat);
fd.append('longitude', lng);
postData(url, fd).then(res => {
if (res.status === 'success') {
if (previewMarker) map.removeLayer(previewMarker);
if (id) {
const idx = APP_DATA.points.findIndex(x => String(x.id) === String(id));
if (idx > -1) APP_DATA.points[idx] = res.data;
lgSpbu24.clearLayers();
lgSpbuTidak.clearLayers();
APP_DATA.points.forEach(renderPoint);
} else {
APP_DATA.points.push(res.data);
renderPoint(res.data);
}
updateDashboardStats();
if(window.__rebuildCards) window.__rebuildCards();
hideModal('pointModal');
showToast(id ? 'Titik berhasil diperbarui.' : 'Titik berhasil disimpan.');
} else {
showToast(res.message || 'Gagal menyimpan titik.', 'error');
}
});
});
/* -------------------------------------------------------
SAVE: JALAN
------------------------------------------------------- */
el('btnSaveJalan').addEventListener('click', () => {
const nama = el('nama_jalan').value.trim();
const status = el('status_jalan').value;
const panjang = el('panjang_meter').value;
const koordinat = el('koordinat_jalan').value.trim();
if (!nama || !status || jalanLatLngs.length < 2) {
showAlert('Data Tidak Lengkap', 'Peringatan: <b>Nama, status jalan</b> wajib diisi dan Anda harus <b>menggambar garis</b> (minimal 2 titik) di peta.');
return;
}
const fd = new FormData();
fd.append('nama_jalan', nama);
fd.append('status_jalan', status);
fd.append('panjang_meter', panjang || '0');
fd.append('koordinat', koordinat);
postData('jalan/simpan.php', fd).then(res => {
if (res.status === 'success') {
clearAllPreview();
APP_DATA.jalan.push(res.data);
renderJalan(res.data);
hideModal('jalanModal');
el('nama_jalan').value = '';
el('status_jalan').value = '';
if(window.__rebuildCards) window.__rebuildCards();
showToast('Jalan berhasil disimpan.');
} else {
showToast(res.message || 'Gagal menyimpan jalan.', 'error');
}
});
});
/* -------------------------------------------------------
SAVE: POLYGON
------------------------------------------------------- */
el('btnSavePolygon').addEventListener('click', () => {
const nama = el('nama_parsil').value.trim();
const status = el('status_kepemilikan').value;
const luas = el('luas_tanah').value;
const koordinat = el('koordinat_polygon').value.trim();
if (!nama || !status || polygonLatLngs.length < 3) {
showAlert('Data Tidak Lengkap', 'Peringatan: <b>Nama, status kepemilikan</b> wajib diisi dan Anda harus <b>menggambar area</b> (minimal 3 titik) di peta.');
return;
}
const fd = new FormData();
fd.append('nama_parsil', nama);
fd.append('status_kepemilikan', status);
fd.append('luas_tanah', luas || '0');
fd.append('koordinat', koordinat);
postData('polygon/simpan.php', fd).then(res => {
if (res.status === 'success') {
clearAllPreview();
APP_DATA.polygons.push(res.data);
renderPolygon(res.data);
hideModal('polygonModal');
el('nama_parsil').value = '';
el('status_kepemilikan').value = '';
if(window.__rebuildCards) window.__rebuildCards();
showToast('Polygon berhasil disimpan.');
} else {
showToast(res.message || 'Gagal menyimpan polygon.', 'error');
}
});
});
/* -------------------------------------------------------
SAVE: Fasilitas Publik
------------------------------------------------------- */
el('btnSaveFasilitasPublik').addEventListener('click', () => {
const nama = el('fp_nama').value.trim();
const lat = el('fp_latitude').value.trim();
const lng = el('fp_longitude').value.trim();
if (!nama || !lat || !lng) {
showAlert('Data Tidak Lengkap', 'Peringatan: <b>Semua kolom</b> wajib diisi dan Anda harus <b>mengklik peta</b> untuk menentukan lokasi Fasilitas Publik terlebih dahulu.');
return;
}
const id = el('fp_id').value;
const url = id ? 'fasilitas_publik/update.php' : 'fasilitas_publik/simpan.php';
const fd = new FormData();
if (id) fd.append('id', id);
fd.append('nama', nama);
fd.append('jenis', el('fp_jenis').value);
fd.append('alamat', el('fp_alamat').value.trim());
fd.append('latitude', lat);
fd.append('longitude', lng);
fd.append('radius', el('fp_radius').value);
fd.append('kontak', el('fp_kontak').value.trim());
postData(url, fd).then(res => {
if (res.status === 'success') {
if (previewMarker) map.removeLayer(previewMarker);
if (previewCircle) map.removeLayer(previewCircle);
previewMarker = null; previewCircle = null;
// Panggil refreshDataModul secara real-time tanpa reload
refreshDataModul();
hideModal('fasilitasPublikModal');
showToast(id ? 'Fasilitas Publik diperbarui.' : 'Fasilitas Publik berhasil disimpan.');
} else {
showToast(res.message || 'Gagal menyimpan.', 'error');
}
});
});
/* -------------------------------------------------------
SAVE: PENDUDUK MISKIN
------------------------------------------------------- */
el('btnSavePenduduk').addEventListener('click', () => {
const nama = el('pm_nama').value.trim();
const lat = el('pm_latitude').value.trim();
const lng = el('pm_longitude').value.trim();
if (!nama || !lat || !lng) {
showAlert('Data Tidak Lengkap', 'Peringatan: <b>Semua kolom</b> wajib diisi dan Anda harus <b>mengklik peta</b> untuk menentukan lokasi Penduduk terlebih dahulu.');
return;
}
const cek = cekJangkauan(parseFloat(lat), parseFloat(lng));
const id = el('pm_id').value;
const url = id ? 'penduduk_miskin/update.php' : 'penduduk_miskin/simpan.php';
const fd = new FormData();
if (id) fd.append('id', id);
fd.append('nama', nama);
fd.append('nik', el('pm_nik').value.trim());
fd.append('keterangan', el('pm_keterangan').value.trim());
fd.append('latitude', lat);
fd.append('longitude', lng);
fd.append('status_jangkauan', cek.status);
fd.append('status_bantuan', el('pm_status_bantuan').value);
fd.append('fasilitas_publik_id', cek.id || '');
const fileRumah = el('pm_foto_rumah').files[0];
if (fileRumah) fd.append('foto_rumah', fileRumah);
const fileKk = el('pm_foto_kk').files[0];
if (fileKk) fd.append('foto_kk', fileKk);
postData(url, fd).then(res => {
if (res.status === 'success') {
if (previewMarker) { map.removeLayer(previewMarker); previewMarker = null; }
// Panggil refreshDataModul secara real-time tanpa reload
refreshDataModul();
hideModal('pendudukModal');
showToast(id ? 'Data penduduk diperbarui.' : 'Penduduk miskin berhasil disimpan.');
} else {
showToast(res.message || 'Gagal menyimpan.', 'error');
}
});
});
/* -------------------------------------------------------
GLOBAL: HAPUS DATA
------------------------------------------------------- */
window.hapusData = function (tipe, id, pesan) {
showDeleteConfirm(pesan || 'Hapus data ini?', () => {
const urlMap = {
point: 'point/hapus.php',
jalan: 'jalan/hapus.php',
polygon: 'polygon/hapus.php',
fasilitas_publik: 'fasilitas_publik/hapus.php',
penduduk_miskin: 'penduduk_miskin/hapus.php',
};
const fd = new FormData();
fd.append('id', id);
postData(urlMap[tipe], fd).then(res => {
if (res.status === 'success') {
map.closePopup(); // Tutup popup di map
showToast('Data berhasil dihapus.', 'success');
if(window.__rebuildCards) setTimeout(() => window.__rebuildCards(), 150);
// Hapus elemen dari Peta TANPA refresh web
if (tipe === 'point') {
const idx = APP_DATA.points.findIndex(x => String(x.id) === String(id));
if (idx > -1) APP_DATA.points.splice(idx, 1);
lgSpbu24.clearLayers();
lgSpbuTidak.clearLayers();
APP_DATA.points.forEach(renderPoint);
updateDashboardStats();
} else if (tipe === 'jalan') {
const idx = APP_DATA.jalan.findIndex(x => String(x.id) === String(id));
if (idx > -1) APP_DATA.jalan.splice(idx, 1);
lgJalan.clearLayers();
APP_DATA.jalan.forEach(renderJalan);
} else if (tipe === 'polygon') {
const idx = APP_DATA.polygons.findIndex(x => String(x.id) === String(id));
if (idx > -1) APP_DATA.polygons.splice(idx, 1);
lgPolygon.clearLayers();
APP_DATA.polygons.forEach(renderPolygon);
} else if (tipe === 'fasilitas_publik' || tipe === 'penduduk_miskin') {
// Khusus modul bantuan, panggil refresh penuh
refreshDataModul();
}
} else {
showToast(res.message || 'Gagal menghapus.', 'error');
}
});
});
};
/* -------------------------------------------------------
GLOBAL: UPDATE LOKASI DARI DRAG MARKER
------------------------------------------------------- */
window.updateLocation = function(tipe, id, lat, lng, nama) {
const urlMap = {
point: 'point/update_lokasi.php',
penduduk_miskin: 'penduduk_miskin/update_lokasi.php'
};
const fd = new FormData();
fd.append('id', id);
fd.append('latitude', lat.toFixed(10));
fd.append('longitude', lng.toFixed(10));
if (tipe === 'penduduk_miskin') {
const cek = cekJangkauan(lat, lng);
fd.append('status_jangkauan', cek.status);
fd.append('fasilitas_publik_id', cek.id || '');
}
postData(urlMap[tipe], fd).then(res => {
if (res.status === 'success') {
showToast('Lokasi ' + nama + ' berhasil diperbarui.', 'success');
if (tipe === 'penduduk_miskin') {
refreshDataModul();
} else if (tipe === 'point') {
const idx = APP_DATA.points.findIndex(x => String(x.id) === String(id));
if (idx > -1) {
APP_DATA.points[idx].latitude = lat;
APP_DATA.points[idx].longitude = lng;
}
}
} else {
showToast('Gagal mengubah lokasi ' + nama, 'error');
if (tipe === 'penduduk_miskin') refreshDataModul();
}
});
};
/* -------------------------------------------------------
GLOBAL: EDIT Fasilitas Publik
------------------------------------------------------- */
window.editFasilitasPublik = function (id) {
fetch(`api/get_fasilitas_publik.php?id=${id}`)
.then(r => r.json())
.then(res => {
if (res.status !== 'success') { showToast('Data tidak ditemukan.', 'error'); return; }
const d = res.data;
el('fp_id').value = d.id;
el('fp_nama').value = d.nama;
el('fp_jenis').value = d.jenis || 'Masjid';
el('fp_alamat').value = d.alamat || '';
el('fp_kontak').value = d.kontak || '';
el('fp_latitude').value = d.latitude;
el('fp_longitude').value= d.longitude;
el('fp_radius').value = d.radius;
el('fp_radius_text').textContent = d.radius;
el('fpModalTitle').textContent = 'Edit Fasilitas Publik';
// Preview di peta
if (previewMarker) map.removeLayer(previewMarker);
if (previewCircle) map.removeLayer(previewCircle);
const ll = L.latLng(parseFloat(d.latitude), parseFloat(d.longitude));
previewMarker = L.marker(ll, { icon: createFPIcon() }).addTo(map);
previewCircle = L.circle(ll, {
radius: parseInt(d.radius),
color: '#e53935', fillColor: '#ef9a9a', fillOpacity: 0.12,
weight: 2, dashArray: '6 4', interactive: false
}).addTo(map);
map.setView(ll, 16);
map.closePopup();
showModal('fasilitasPublikModal');
})
.catch(() => showToast('Gagal memuat data.', 'error'));
};
/* -------------------------------------------------------
GLOBAL: EDIT PENDUDUK
------------------------------------------------------- */
window.editPenduduk = function (id) {
fetch(`api/get_penduduk.php?id=${id}`)
.then(r => r.json())
.then(res => {
if (res.status !== 'success') { showToast('Data tidak ditemukan.', 'error'); return; }
const d = res.data;
el('pm_id').value = d.id;
el('pm_nama').value = d.nama;
el('pm_nik').value = d.nik || '';
el('pm_keterangan').value = d.keterangan || '';
el('pm_status_bantuan').value = d.status_bantuan || 'Belum Disalurkan';
el('pm_latitude').value = d.latitude;
el('pm_longitude').value = d.longitude;
el('pmModalTitle').textContent = 'Edit Penduduk Miskin';
const cek = cekJangkauan(parseFloat(d.latitude), parseFloat(d.longitude));
updateStatusJangkauan(cek);
if (previewMarker) map.removeLayer(previewMarker);
const ll = L.latLng(parseFloat(d.latitude), parseFloat(d.longitude));
previewMarker = L.marker(ll, {
icon: createPendudukIcon(cek.status === 'Dalam Jangkauan')
}).addTo(map);
map.setView(ll, 16);
map.closePopup();
showModal('pendudukModal');
})
.catch(() => showToast('Gagal memuat data.', 'error'));
};
/* -------------------------------------------------------
UTILITY: POST DATA
------------------------------------------------------- */
function postData(url, formData) {
return fetch(url, { method: 'POST', body: formData })
.then(r => r.json())
.catch(err => {
console.error(err);
showToast('Terjadi kesalahan koneksi.', 'error');
return { status: 'error', message: 'Koneksi gagal.' };
});
}
/* -------------------------------------------------------
FITUR PENCARIAN (SEARCHING)
------------------------------------------------------- */
const searchInput = el('searchInput');
const searchResults = el('searchResults');
if (searchInput && searchResults) {
searchInput.addEventListener('input', function() {
const query = this.value.toLowerCase().trim();
searchResults.innerHTML = '';
// Minimal ketik 2 huruf baru mencari
if (query.length < 2) {
searchResults.classList.add('hidden');
return;
}
let results = [];
// 1. Cari Titik Biasa
APP_DATA.points.forEach(item => {
if (item.nama.toLowerCase().includes(query)) {
results.push({ title: item.nama, type: '&#x1F4CD; Titik Lokasi', lat: item.latitude, lng: item.longitude });
}
});
// 2. Cari Fasilitas Publik
fasilitasPublikList.forEach(item => {
if (item.nama.toLowerCase().includes(query)) {
results.push({ title: item.nama, type: `&#x1F3E2; Fasilitas Publik (${item.jenis})`, lat: item.latitude, lng: item.longitude });
}
});
// 3. Cari Penduduk Miskin (Berdasarkan Nama atau NIK)
pendudukList.forEach(p => {
if (p.data.nama.toLowerCase().includes(query) || (p.data.nik && p.data.nik.includes(query))) {
results.push({ title: p.data.nama, type: '&#x1F464; Penduduk Miskin', lat: p.data.latitude, lng: p.data.longitude });
}
});
// 4. Cari Jalan
APP_DATA.jalan.forEach(item => {
if (item.nama_jalan.toLowerCase().includes(query)) {
try {
const feat = JSON.parse(item.koordinat);
// Ambil titik koordinat pertama dari garis
const coords = feat.geometry.coordinates[0];
results.push({ title: item.nama_jalan, type: `&#x1F6E3;&#xFE0F; Jalan (${item.status_jalan})`, lat: coords[1], lng: coords[0] });
} catch(e) {}
}
});
// 5. Cari Polygon
APP_DATA.polygons.forEach(item => {
if (item.nama_parsil.toLowerCase().includes(query)) {
try {
const feat = JSON.parse(item.koordinat);
// Ambil titik koordinat pertama dari polygon
const coords = feat.geometry.coordinates[0][0];
results.push({ title: item.nama_parsil, type: `&#x1F4D0; Polygon (${item.status_kepemilikan})`, lat: coords[1], lng: coords[0] });
} catch(e) {}
}
});
// Tampilkan Hasil
if (results.length > 0) {
results.forEach(res => {
const div = document.createElement('div');
div.className = 'search-item';
div.innerHTML = `
<span class="search-item-title">${res.title}</span>
<span class="search-item-type">${res.type}</span>
`;
// Aksi saat item hasil dicentang
div.addEventListener('click', () => {
// Animasi terbang ke lokasi
map.flyTo([parseFloat(res.lat), parseFloat(res.lng)], 18, {
animate: true,
duration: 1.5
});
searchResults.classList.add('hidden');
searchInput.value = res.title; // Isi kotak input dengan nama yang dipilih
// Khusus tampilan HP: Sembunyikan sidebar agar peta terlihat langsung
if (window.innerWidth <= 680) {
el('sidebar').classList.remove('mobile-open');
}
});
searchResults.appendChild(div);
});
} else {
searchResults.innerHTML = '<div class="search-item-empty">Tidak ada data ditemukan</div>';
}
searchResults.classList.remove('hidden');
});
// Menyembunyikan dropdown jika klik di tempat lain
document.addEventListener('click', (e) => {
if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
searchResults.classList.add('hidden');
}
});
}
/* -- Init ------------------------------------------------ */
/* -- Modul Filter & Init -------------------------------- */
function setupModul(modul) {
const tools = ['btnToolPoint', 'btnToolJalan', 'btnToolPolygon', 'btnToolFasilitasPublik', 'btnToolPenduduk'];
const layers = ['layerFasilitasPublik', 'layerPendudukDalam', 'layerPendudukLuar', 'layerSpbu24', 'layerSpbuTidak', 'layerJalan', 'layerPolygon', 'layerGeoJalan', 'layerKecamatan', 'layerSungai'];
// Sembunyikan semua kecuali utuh
if (modul !== 'utuh') {
tools.forEach(t => { if(el(t)) el(t).style.display = 'none'; });
layers.forEach(l => {
let lgName = '';
if (l === 'layerSpbu24') lgName = 'lgSpbu24';
else if (l === 'layerSpbuTidak') lgName = 'lgSpbuTidak';
else if (l === 'layerPendudukDalam') lgName = 'lgPendudukDalam';
else if (l === 'layerPendudukLuar') lgName = 'lgPendudukLuar';
else lgName = l.replace('layer','lg').replace('Geo','geo').replace('Kecamatan','geoKecamatan').replace('Sungai','geoSungai');
try { if(eval(lgName)) map.removeLayer(eval(lgName)); } catch(e){}
if(el(l)) {
el(l).parentElement.style.display = 'none';
el(l).checked = false;
}
});
}
if (modul === 'spbu') {
if (USER_ROLE !== 'guest') {
if(el('btnToolPoint')) el('btnToolPoint').style.display = 'inline-flex';
}
if(el('layerSpbu24')) { el('layerSpbu24').parentElement.style.display = 'flex'; el('layerSpbu24').checked = true; lgSpbu24.addTo(map); }
if(el('layerSpbuTidak')) { el('layerSpbuTidak').parentElement.style.display = 'flex'; el('layerSpbuTidak').checked = true; lgSpbuTidak.addTo(map); }
setMode(USER_ROLE === 'guest' ? 'lokasi' : 'point');
} else if (modul === 'bantuan') {
if (USER_ROLE !== 'guest') {
if(el('btnToolPenduduk')) el('btnToolPenduduk').style.display = 'inline-flex';
if(el('btnToolFasilitasPublik')) el('btnToolFasilitasPublik').style.display = 'inline-flex';
}
if(el('layerFasilitasPublik')) { el('layerFasilitasPublik').parentElement.style.display = 'flex'; el('layerFasilitasPublik').checked = true; lgFasilitasPublik.addTo(map); }
if(el('layerPendudukDalam')) { el('layerPendudukDalam').parentElement.style.display = 'flex'; el('layerPendudukDalam').checked = true; lgPendudukDalam.addTo(map); }
if(el('layerPendudukLuar')) { el('layerPendudukLuar').parentElement.style.display = 'flex'; el('layerPendudukLuar').checked = true; lgPendudukLuar.addTo(map); }
setMode(USER_ROLE === 'guest' ? 'lokasi' : 'penduduk_miskin');
} else if (modul === 'tematik') {
if(el('layerGeoJalan')) { el('layerGeoJalan').parentElement.style.display = 'flex'; el('layerGeoJalan').checked = true; if(geoJalanLayer) geoJalanLayer.addTo(map); }
if(el('layerKecamatan')) { el('layerKecamatan').parentElement.style.display = 'flex'; el('layerKecamatan').checked = true; if(geoKecamatanLayer) geoKecamatanLayer.addTo(map); }
if(el('layerSungai')) { el('layerSungai').parentElement.style.display = 'flex'; el('layerSungai').checked = true; if(geoSungaiLayer) geoSungaiLayer.addTo(map); }
setMode('lokasi');
} else if (modul === 'digitasi') {
if (USER_ROLE !== 'guest') {
if(el('btnToolJalan')) el('btnToolJalan').style.display = 'inline-flex';
if(el('btnToolPolygon')) el('btnToolPolygon').style.display = 'inline-flex';
if(el('btnToolPoint')) el('btnToolPoint').style.display = 'inline-flex';
}
if(el('layerSpbu24')) { el('layerSpbu24').parentElement.style.display = 'flex'; el('layerSpbu24').checked = true; lgSpbu24.addTo(map); }
if(el('layerSpbuTidak')) { el('layerSpbuTidak').parentElement.style.display = 'flex'; el('layerSpbuTidak').checked = true; lgSpbuTidak.addTo(map); }
if(el('layerJalan')) { el('layerJalan').parentElement.style.display = 'flex'; el('layerJalan').checked = true; lgJalan.addTo(map); }
if(el('layerPolygon')) { el('layerPolygon').parentElement.style.display = 'flex'; el('layerPolygon').checked = true; lgPolygon.addTo(map); }
setMode(USER_ROLE === 'guest' ? 'lokasi' : 'polygon');
} else { // utuh
if (typeof USER_ROLE !== 'undefined') {
if (USER_ROLE === 'guest') setMode('lokasi');
else if (USER_ROLE === 'petugas') setMode('penduduk_miskin');
else setMode('point');
} else {
setMode('point');
}
}
// Force hide section tools if guest
if (typeof USER_ROLE !== 'undefined' && USER_ROLE === 'guest' && el('sectionTools')) {
el('sectionTools').classList.add('hidden');
}
}
/* -- Alert / Modal Helper -------------------------------- */
window.showAlert = function(title, message) {
el('alertTitle').textContent = title;
el('alertMessage').innerHTML = message;
el('alertModal').classList.remove('hidden');
el('modalBackdrop').classList.remove('hidden');
};
if(el('btnAlertOk')) {
el('btnAlertOk').addEventListener('click', () => {
el('alertModal').classList.add('hidden');
// Hanya sembunyikan backdrop jika tidak ada modal lain yang terbuka
if(document.querySelectorAll('.modal:not(.hidden)').length === 0) {
el('modalBackdrop').classList.add('hidden');
}
});
}
// Set mode awal
try {
setupModul(APP_DATA.modul || 'utuh');
} catch(e) {
console.error("Error in setupModul:", e);
}
/* ===============================================================
RIGHT SIDEBAR PANEL KANAN
Data List Cards per Modul, flyTo, Search Filter
=============================================================== */
const rightSidebar = document.getElementById('rightSidebar');
const rightSidebarClose = document.getElementById('rightSidebarClose');
const rightSidebarToggle = document.getElementById('rightSidebarToggleBtn');
const rightCardList = document.getElementById('rightCardList');
const rightCardCount = document.getElementById('rightCardCount');
const rightSearchInput = document.getElementById('rightSearchInput');
/* ── Marker registry: map from data-id + type → Leaflet marker/layer ── */
const markerRegistry = {};
/* ── Register markers after render ─────────────────────────────── */
// Override renderPoint to register
const _origRenderPoint = renderPoint;
function renderPointAndRegister(item) {
const mk = _origRenderPoint(item);
if (mk) markerRegistry['point_' + item.id] = { marker: mk, lat: parseFloat(item.latitude), lng: parseFloat(item.longitude) };
return mk;
}
/* We need to patch the existing render calls */
/* Re-register all existing data loaded at init */
function registerAllMarkers() {
/* SPBU / Points */
lgSpbu24.eachLayer(mk => {
// match by iterating APP_DATA.points
});
/* We'll use a direct lookup approach by storing references during renderPoint */
}
/* ── Right Sidebar Toggle ─────────────────────────────────────── */
let rightOpen = true;
function openRightSidebar() {
rightSidebar.classList.remove('right-collapsed');
el('mapContainer').classList.add('with-right-sidebar');
rightSidebarToggle.classList.add('hidden');
map.invalidateSize();
rightOpen = true;
}
function closeRightSidebar() {
rightSidebar.classList.add('right-collapsed');
el('mapContainer').classList.remove('with-right-sidebar');
rightSidebarToggle.classList.remove('hidden');
map.invalidateSize();
rightOpen = false;
}
if (rightSidebarClose) rightSidebarClose.addEventListener('click', closeRightSidebar);
if (rightSidebarToggle) rightSidebarToggle.addEventListener('click', openRightSidebar);
// On mobile or tematik: right sidebar starts closed
if (window.innerWidth <= 680 || APP_DATA.modul === 'tematik') {
closeRightSidebar();
} else {
openRightSidebar();
}
/* ── Data Card Factory ─────────────────────────────────────────── */
function makeDataCard({ icon, iconClass, name, sub, badge, badgeClass, footerHtml, coords, onClick }) {
const card = document.createElement('div');
card.className = 'data-card';
const hdr = document.createElement('div');
hdr.className = 'data-card-header';
const iconWrap = document.createElement('div');
iconWrap.className = `data-card-icon-wrap ${iconClass}`;
iconWrap.textContent = icon;
const body = document.createElement('div');
body.className = 'data-card-body';
const nameEl = document.createElement('div');
nameEl.className = 'data-card-name';
nameEl.title = name;
nameEl.textContent = name;
const subEl = document.createElement('div');
subEl.className = 'data-card-sub';
subEl.textContent = sub;
body.appendChild(nameEl);
body.appendChild(subEl);
hdr.appendChild(iconWrap);
hdr.appendChild(body);
if (badge) {
const badgeEl = document.createElement('span');
badgeEl.className = `data-card-badge ${badgeClass}`;
badgeEl.textContent = badge;
hdr.appendChild(badgeEl);
}
card.appendChild(hdr);
if (coords) {
const coordEl = document.createElement('div');
coordEl.className = 'data-card-coords';
coordEl.innerHTML = `<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="10" r="3"/><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/></svg> ${coords}`;
card.appendChild(coordEl);
}
if (footerHtml) {
const footEl = document.createElement('div');
footEl.className = 'data-card-footer';
footEl.innerHTML = footerHtml;
card.appendChild(footEl);
}
card.addEventListener('click', onClick);
return card;
}
function emptyState(icon, title, sub) {
const div = document.createElement('div');
div.className = 'empty-state';
div.innerHTML = `
<div class="empty-state-icon">${icon}</div>
<div class="empty-state-title">${title}</div>
<div class="empty-state-sub">${sub}</div>
`;
return div;
}
function updateCount(count) {
if (rightCardCount) rightCardCount.textContent = `${count} entri`;
}
/* ── Fly & Open Popup ─────────────────────────────────────────── */
function flyToAndOpenPopup(lat, lng, markerOrLayer) {
map.flyTo([lat, lng], 17, { animate: true, duration: 1.2 });
if (markerOrLayer) {
setTimeout(() => {
try { markerOrLayer.openPopup(); } catch(e) {}
}, 1400);
}
// On mobile: close right sidebar to see map
if (window.innerWidth <= 680) closeRightSidebar();
}
/* ── Build Cards by Modul ─────────────────────────────────────── */
/** Find Leaflet marker from a layer group by lat/lng */
function findMarkerInGroup(lg, lat, lng) {
let found = null;
lg.eachLayer(mk => {
if (found) return;
const ll = mk.getLatLng ? mk.getLatLng() : null;
if (ll && Math.abs(ll.lat - lat) < 0.0001 && Math.abs(ll.lng - lng) < 0.0001) found = mk;
});
return found;
}
function buildCardsSpbu(data, container) {
if (!data.length) {
container.appendChild(emptyState('⛽', 'Belum Ada SPBU', 'Tambahkan data SPBU menggunakan alat peta.'));
return 0;
}
data.forEach(item => {
const lat = parseFloat(item.latitude), lng = parseFloat(item.longitude);
const is24 = item.buka_24_jam === 'Ya';
const card = makeDataCard({
icon: '⛽',
iconClass: 'dc-icon-spbu',
name: item.nama,
sub: `WA: ${item.no_wa || '-'}`,
badge: is24 ? 'Buka 24 Jam' : 'Tidak 24 Jam',
badgeClass: is24 ? 'badge-24jam' : 'badge-tidak24',
coords: `${lat.toFixed(5)}, ${lng.toFixed(5)}`,
onClick: () => {
const mk = findMarkerInGroup(is24 ? lgSpbu24 : lgSpbuTidak, lat, lng);
flyToAndOpenPopup(lat, lng, mk);
}
});
container.appendChild(card);
});
return data.length;
}
function buildCardsBantuan(pmData, container) {
if (!pmData.length) {
container.appendChild(emptyState('👥', 'Belum Ada Data Penduduk', 'Tambahkan data penduduk miskin menggunakan alat peta.'));
return 0;
}
pmData.forEach(item => {
const lat = parseFloat(item.data.latitude), lng = parseFloat(item.data.longitude);
const isDalam = item.data.status_jangkauan === 'Dalam Jangkauan';
const card = makeDataCard({
icon: '👤',
iconClass: 'dc-icon-pm',
name: item.data.nama,
sub: `NIK: ${item.data.nik || '-'}`,
badge: isDalam ? 'Dalam Jangkauan' : 'Luar Jangkauan',
badgeClass: isDalam ? 'badge-dalam-jk' : 'badge-luar-jk',
footerHtml: `<div style="display:flex; justify-content:space-between; width:100%; align-items:center;">
<span style="color:#64748b; font-weight:500;">Status Penyaluran</span>
<span class="badge-${item.data.status_bantuan === 'Sudah Disalurkan' ? 'sudah' : (item.data.status_bantuan === 'Proses' ? 'proses' : 'belum')}" style="padding:4px 8px; font-size:0.75rem; border-radius:6px; font-weight:700;">${item.data.status_bantuan || 'Belum Disalurkan'}</span>
</div>`,
coords: `${lat.toFixed(5)}, ${lng.toFixed(5)}`,
onClick: () => {
const mk = findMarkerInGroup(isDalam ? lgPendudukDalam : lgPendudukLuar, lat, lng);
flyToAndOpenPopup(lat, lng, mk);
}
});
container.appendChild(card);
});
return pmData.length;
}
function buildCardsTematik(container) {
const items = [];
// Fasilitas Publik
fasilitasPublikList.forEach(fp => {
const lat = parseFloat(fp.latitude), lng = parseFloat(fp.longitude);
items.push({ icon: '🏢', iconClass: 'dc-icon-fp', name: fp.nama, sub: fp.jenis || 'Fasilitas Publik', badge: null, lat, lng, group: lgFasilitasPublik });
});
if (!items.length) {
container.appendChild(emptyState('🗺️', 'Belum Ada Layer', 'Layer tematik (Kecamatan, Sungai, Jalan) belum tersedia.'));
return 0;
}
items.forEach(it => {
const card = makeDataCard({
icon: it.icon,
iconClass: it.iconClass,
name: it.name,
sub: it.sub,
badge: null,
badgeClass: '',
coords: `${it.lat.toFixed(5)}, ${it.lng.toFixed(5)}`,
onClick: () => {
const mk = findMarkerInGroup(it.group, it.lat, it.lng);
flyToAndOpenPopup(it.lat, it.lng, mk);
}
});
container.appendChild(card);
});
return items.length;
}
function buildCardsDigitasi(jalanData, polygonData, container) {
let count = 0;
// Polygons first
if (polygonData.length) {
polygonData.forEach(item => {
const badgeMap = { SHM: 'badge-shm', HGB: 'badge-hgb', HGU: 'badge-hgu', HP: 'badge-hp' };
let lat = 0, lng = 0;
try {
const feat = JSON.parse(item.koordinat);
const coords = feat.geometry.coordinates[0][0];
lat = coords[1]; lng = coords[0];
} catch(e) {}
const card = makeDataCard({
icon: '⬜',
iconClass: 'dc-icon-poly',
name: item.nama_parsil,
sub: `Luas: ${parseFloat(item.luas_tanah).toFixed(2)} m²`,
badge: item.status_kepemilikan,
badgeClass: badgeMap[item.status_kepemilikan] || 'badge-geo',
coords: lat ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : null,
onClick: () => {
if (!lat) return;
const warna = warnaPolygon(item.status_kepemilikan);
// find polygon in lgPolygon
let found = null;
lgPolygon.eachLayer(lyr => {
if (found) return;
const c = lyr.getCenter ? lyr.getCenter() : null;
if (c && Math.abs(c.lat - lat) < 0.002 && Math.abs(c.lng - lng) < 0.002) found = lyr;
});
if (lat) map.flyTo([lat, lng], 17, { animate: true, duration: 1.2 });
if (found) setTimeout(() => { try { found.openPopup(); } catch(e) {} }, 1400);
}
});
container.appendChild(card);
count++;
});
}
// Jalan
if (jalanData.length) {
jalanData.forEach(item => {
let lat = 0, lng = 0;
try {
const feat = JSON.parse(item.koordinat);
const coords = feat.geometry.coordinates[0];
lat = coords[1]; lng = coords[0];
} catch(e) {}
const statusColors = { 'Jalan Nasional': 'badge-luar-jk', 'Jalan Provinsi': 'badge-hgu', 'Jalan Kabupaten': 'badge-shm' };
const card = makeDataCard({
icon: '🛣️',
iconClass: 'dc-icon-jalan',
name: item.nama_jalan,
sub: `${parseFloat(item.panjang_meter).toFixed(0)} m`,
badge: item.status_jalan,
badgeClass: statusColors[item.status_jalan] || 'badge-geo',
coords: lat ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : null,
onClick: () => {
if (!lat) return;
let found = null;
lgJalan.eachLayer(lyr => {
if (found) return;
const lls = lyr.getLatLngs ? lyr.getLatLngs() : [];
const first = Array.isArray(lls[0]) ? lls[0][0] : lls[0];
if (first && Math.abs(first.lat - lat) < 0.001 && Math.abs(first.lng - lng) < 0.001) found = lyr;
});
map.flyTo([lat, lng], 16, { animate: true, duration: 1.2 });
if (found) setTimeout(() => { try { found.openPopup(); } catch(e) {} }, 1400);
if (window.innerWidth <= 680) closeRightSidebar();
}
});
container.appendChild(card);
count++;
});
}
if (!count) {
container.appendChild(emptyState('📐', 'Belum Ada Data Digitasi', 'Tambahkan polygon/jalan menggunakan alat peta.'));
}
return count;
}
/* ── Main builder ─────────────────────────────────────────────── */
let currentSearchQuery = '';
let currentUtuhTab = 'spbu';
function buildRightSidebarCards(modul, searchQ = '') {
if (!rightCardList) return;
rightCardList.innerHTML = '';
let count = 0;
const filteredPoints = (APP_DATA.points || []).filter(it =>
!searchQ || (it.nama && it.nama.toLowerCase().includes(searchQ))
);
const filteredPM = (pendudukList || []).filter(it =>
!searchQ || (it.data.nama && it.data.nama.toLowerCase().includes(searchQ)) || (it.data.nik && it.data.nik.includes(searchQ))
);
const filteredJalan = (APP_DATA.jalan || []).filter(it =>
!searchQ || (it.nama_jalan && it.nama_jalan.toLowerCase().includes(searchQ))
);
const filteredPolygon = (APP_DATA.polygons || []).filter(it =>
!searchQ || (it.nama_parsil && it.nama_parsil.toLowerCase().includes(searchQ))
);
const filteredFP = (fasilitasPublikList || []).filter(it =>
!searchQ || (it.nama && it.nama.toLowerCase().includes(searchQ))
);
if (modul === 'spbu') {
count = buildCardsSpbu(filteredPoints, rightCardList);
} else if (modul === 'bantuan') {
count = buildCardsBantuan(filteredPM, rightCardList);
// Also add FP
if (filteredFP.length) {
filteredFP.forEach(fp => {
const lat = parseFloat(fp.latitude), lng = parseFloat(fp.longitude);
const card = makeDataCard({
icon: '🏢', iconClass: 'dc-icon-fp',
name: fp.nama, sub: fp.jenis || 'Fasilitas Publik',
badge: null, badgeClass: '',
coords: `${lat.toFixed(5)}, ${lng.toFixed(5)}`,
onClick: () => {
const mk = findMarkerInGroup(lgFasilitasPublik, lat, lng);
flyToAndOpenPopup(lat, lng, mk);
}
});
rightCardList.appendChild(card);
count++;
});
}
} else if (modul === 'tematik') {
// Kosongkan daftar data untuk peta tematik
rightCardList.appendChild(emptyState('🗺️', 'Peta Tematik', 'Mode Peta Tematik hanya menampilkan layer poligon dan garis pada peta.'));
count = 0;
} else if (modul === 'digitasi') {
count = buildCardsDigitasi(filteredJalan, filteredPolygon, rightCardList);
} else { // utuh use tab system
if (currentUtuhTab === 'spbu') count = buildCardsSpbu(filteredPoints, rightCardList);
else if (currentUtuhTab === 'bantuan') count = buildCardsBantuan(filteredPM, rightCardList);
else if (currentUtuhTab === 'jalan') {
if (!filteredJalan.length) {
rightCardList.appendChild(emptyState('🛣️', 'Belum Ada Jalan', 'Tambahkan data jalan menggunakan alat peta.'));
} else {
filteredJalan.forEach(item => {
let lat = 0, lng = 0;
try { const f = JSON.parse(item.koordinat); lat = f.geometry.coordinates[0][1]; lng = f.geometry.coordinates[0][0]; } catch(e) {}
const card = makeDataCard({
icon: '🛣️', iconClass: 'dc-icon-jalan',
name: item.nama_jalan, sub: `${parseFloat(item.panjang_meter).toFixed(0)} m · ${item.status_jalan}`,
badge: null, badgeClass: '',
coords: lat ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : null,
onClick: () => { if (lat) map.flyTo([lat, lng], 16, { animate: true, duration: 1.2 }); if (window.innerWidth <= 680) closeRightSidebar(); }
});
rightCardList.appendChild(card);
count++;
});
}
} else if (currentUtuhTab === 'polygon') {
if (!filteredPolygon.length) {
rightCardList.appendChild(emptyState('⬜', 'Belum Ada Parsil', 'Tambahkan polygon menggunakan alat peta.'));
} else {
const badgeMap = { SHM: 'badge-shm', HGB: 'badge-hgb', HGU: 'badge-hgu', HP: 'badge-hp' };
filteredPolygon.forEach(item => {
let lat = 0, lng = 0;
try { const f = JSON.parse(item.koordinat); lat = f.geometry.coordinates[0][0][1]; lng = f.geometry.coordinates[0][0][0]; } catch(e) {}
const card = makeDataCard({
icon: '⬜', iconClass: 'dc-icon-poly',
name: item.nama_parsil, sub: `Luas: ${parseFloat(item.luas_tanah).toFixed(2)} m²`,
badge: item.status_kepemilikan, badgeClass: badgeMap[item.status_kepemilikan] || 'badge-geo',
coords: lat ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : null,
onClick: () => {
if (lat) {
map.flyTo([lat, lng], 17, { animate: true, duration: 1.2 });
let found = null;
lgPolygon.eachLayer(lyr => {
if (found) return;
const c = lyr.getCenter ? lyr.getCenter() : null;
if (c && Math.abs(c.lat - lat) < 0.002 && Math.abs(c.lng - lng) < 0.002) found = lyr;
});
if (found) setTimeout(() => { try { found.openPopup(); } catch(e) {} }, 1400);
}
if (window.innerWidth <= 680) closeRightSidebar();
}
});
rightCardList.appendChild(card);
count++;
});
}
}
}
updateCount(count);
}
/* ── Tab Switching (Modul Utuh) ───────────────────────────────── */
const tabBtns = document.querySelectorAll('.right-tab-btn');
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
tabBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentUtuhTab = btn.dataset.tab;
buildRightSidebarCards(APP_DATA.modul, currentSearchQuery);
});
});
/* ── Right Search Input ───────────────────────────────────────── */
if (rightSearchInput) {
let rsTimer = null;
rightSearchInput.addEventListener('input', function() {
clearTimeout(rsTimer);
const q = this.value.toLowerCase().trim();
rsTimer = setTimeout(() => {
currentSearchQuery = q;
buildRightSidebarCards(APP_DATA.modul, q);
}, 200);
});
}
/* ── Trigger initial card build after markers loaded ────────────── */
// We wait a tick to let renderPoint/renderPenduduk etc. complete
setTimeout(() => buildRightSidebarCards(APP_DATA.modul), 100);
/* ── Re-build after CRUD ops ─────────────────────────────────── */
// Patch postData success to rebuild cards
const _origPostData = window.postData;
// We override the save success moments via a custom event
document.addEventListener('webgis:dataChanged', () => {
buildRightSidebarCards(APP_DATA.modul, currentSearchQuery);
});
// Hook into showToast to trigger rebuild on success saves
const _origShowToast = showToast;
// We'll re-export showToast patch at module level
window.__rebuildCards = () => buildRightSidebarCards(APP_DATA.modul, currentSearchQuery);
/* ===============================================================
CHART.JS MODAL
=============================================================== */
let activeChart = null;
const chartModal = document.getElementById('chartModal');
const chartModalClose = document.getElementById('chartModalClose');
const chartModalBody = document.getElementById('chartModalBody');
const chartModalTitle = document.getElementById('chartModalTitle');
const chartModalSub = document.getElementById('chartModalSubtitle');
const chartModalIcon = document.getElementById('chartModalIcon');
const btnStatistik = document.getElementById('btnLihatStatistik');
// Chart color palette (Merah Putih premium)
const CHART_COLORS = {
red: 'rgba(225, 29, 72, 0.85)',
redLight: 'rgba(225, 29, 72, 0.18)',
redMid: 'rgba(251, 113, 133, 0.85)',
dark: 'rgba(15, 23, 42, 0.85)',
darkMid: 'rgba(71, 85, 105, 0.7)',
green: 'rgba(22, 163, 74, 0.85)',
greenLight:'rgba(22,163,74,0.18)',
blue: 'rgba(59, 130, 246, 0.85)',
amber: 'rgba(217, 119, 6, 0.85)',
purple: 'rgba(107, 33, 168, 0.85)',
teal: 'rgba(20, 184, 166, 0.85)',
};
const CHART_DEFAULTS = {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
position: 'bottom',
labels: {
font: { family: "'Plus Jakarta Sans', sans-serif", size: 12, weight: '600' },
padding: 16,
usePointStyle: true,
pointStyleWidth: 10,
color: '#334155'
}
},
tooltip: {
backgroundColor: 'rgba(15,23,42,0.9)',
titleColor: '#f8fafc',
bodyColor: '#cbd5e1',
borderColor: 'rgba(225,29,72,0.3)',
borderWidth: 1,
cornerRadius: 10,
padding: 12,
titleFont: { family: "'Plus Jakarta Sans', sans-serif", weight: '800', size: 13 },
bodyFont: { family: "'Plus Jakarta Sans', sans-serif", weight: '500', size: 12 },
}
}
};
function destroyChart() {
if (activeChart) { activeChart.destroy(); activeChart = null; }
}
function openChartModal() {
if (!chartModal) return;
destroyChart();
chartModalBody.innerHTML = '';
const modul = APP_DATA.modul;
if (modul === 'spbu') {
chartModalIcon.textContent = '⛽';
chartModalTitle.textContent = 'Statistik SPBU';
chartModalSub.textContent = 'Distribusi SPBU berdasarkan jam operasional';
buildChartSpbu();
} else if (modul === 'bantuan') {
chartModalIcon.textContent = '👥';
chartModalTitle.textContent = 'Statistik Bantuan';
chartModalSub.textContent = 'Cakupan jangkauan penduduk miskin';
buildChartBantuan();
} else if (modul === 'tematik') {
chartModalIcon.textContent = '🗺️';
chartModalTitle.textContent = 'Statistik Tematik';
chartModalSub.textContent = 'Distribusi jaringan jalan berdasarkan status';
buildChartTematik();
} else if (modul === 'digitasi') {
chartModalIcon.textContent = '📐';
chartModalTitle.textContent = 'Statistik Digitasi';
chartModalSub.textContent = 'Proporsi kepemilikan tanah & panjang jalan';
buildChartDigitasi();
} else {
chartModalIcon.textContent = '📊';
chartModalTitle.textContent = 'Dashboard Statistik';
chartModalSub.textContent = 'Ringkasan semua modul data spasial';
buildChartUtuh();
}
chartModal.classList.remove('hidden');
}
function closeChartModal() {
chartModal.classList.add('hidden');
destroyChart();
}
if (btnStatistik) btnStatistik.addEventListener('click', openChartModal);
if (chartModalClose) chartModalClose.addEventListener('click', closeChartModal);
if (chartModal) chartModal.addEventListener('click', e => { if (e.target === chartModal) closeChartModal(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeChartModal();
});
/* ── Chart Builders per Modul ─────────────────────────────────── */
function statMiniHTML(val, label, color) {
return `<div class="chart-stat-mini" style="border-left:3px solid ${color}">
<div class="chart-stat-mini-val" style="color:${color}">${val}</div>
<div class="chart-stat-mini-label">${label}</div>
</div>`;
}
function buildChartSpbu() {
const total = APP_DATA.points.length;
const ya = APP_DATA.points.filter(p => p.buka_24_jam === 'Ya').length;
const tidak = total - ya;
chartModalBody.innerHTML = `
<div class="chart-stat-row">
${statMiniHTML(total, 'Total SPBU', '#0f172a')}
${statMiniHTML(ya, 'Buka 24 Jam', CHART_COLORS.green.replace('0.85','1'))}
${statMiniHTML(tidak, 'Tidak 24 Jam', CHART_COLORS.red.replace('0.85','1'))}
</div>
<div class="chart-canvas-wrap" style="max-width:360px;margin:0 auto;">
<canvas id="chartCanvas" height="300"></canvas>
</div>`;
const ctx = document.getElementById('chartCanvas').getContext('2d');
activeChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Buka 24 Jam', 'Tidak 24 Jam'],
datasets: [{
data: [ya, tidak],
backgroundColor: [CHART_COLORS.green, CHART_COLORS.red],
borderColor: ['#fff', '#fff'],
borderWidth: 3,
hoverBorderWidth: 4,
hoverOffset: 8
}]
},
options: {
...CHART_DEFAULTS,
cutout: '65%',
plugins: {
...CHART_DEFAULTS.plugins,
legend: { ...CHART_DEFAULTS.plugins.legend, position: 'bottom' }
}
}
});
}
function buildChartBantuan() {
const totalPM = pendudukList.length;
const dalam = pendudukList.filter(p => {
const cek = cekJangkauan(parseFloat(p.data.latitude), parseFloat(p.data.longitude));
return cek.status === 'Dalam Jangkauan';
}).length;
const luar = totalPM - dalam;
const totalFP = fasilitasPublikList.length;
const persen = totalPM > 0 ? ((dalam / totalPM) * 100).toFixed(1) : 0;
chartModalBody.innerHTML = `
<div class="chart-stat-row">
${statMiniHTML(totalFP, 'Fasilitas Publik', '#3b82f6')}
${statMiniHTML(totalPM, 'Penduduk Miskin', '#0f172a')}
${statMiniHTML(dalam, 'Terjangkau', '#16a34a')}
${statMiniHTML(luar, 'Luar Jangkauan', '#e11d48')}
${statMiniHTML(persen + '%', 'Cakupan', '#d97706')}
</div>
<div class="chart-canvas-wrap" style="max-width:420px;margin:0 auto;">
<canvas id="chartCanvas" height="260"></canvas>
</div>`;
const ctx = document.getElementById('chartCanvas').getContext('2d');
activeChart = new Chart(ctx, {
type: 'bar',
data: {
labels: ['Dalam Jangkauan', 'Luar Jangkauan'],
datasets: [{
label: 'Jumlah Penduduk',
data: [dalam, luar],
backgroundColor: [CHART_COLORS.green, CHART_COLORS.red],
borderRadius: 8,
borderSkipped: false,
maxBarThickness: 80
}]
},
options: {
...CHART_DEFAULTS,
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(0,0,0,0.06)' },
ticks: { font: { family: "'Plus Jakarta Sans', sans-serif", weight: '600', size: 11 }, color: '#64748b' }
},
x: {
grid: { display: false },
ticks: { font: { family: "'Plus Jakarta Sans', sans-serif", weight: '700', size: 12 }, color: '#0f172a' }
}
},
plugins: { ...CHART_DEFAULTS.plugins, legend: { display: false } }
}
});
}
function buildChartTematik() {
// Count jalan by status from APP_DATA.jalan
const counts = { 'Jalan Nasional': 0, 'Jalan Provinsi': 0, 'Jalan Kabupaten': 0 };
const panjang = { 'Jalan Nasional': 0, 'Jalan Provinsi': 0, 'Jalan Kabupaten': 0 };
(APP_DATA.jalan || []).forEach(j => {
if (counts[j.status_jalan] !== undefined) {
counts[j.status_jalan]++;
panjang[j.status_jalan] += parseFloat(j.panjang_meter) || 0;
}
});
const totalPanjang = Object.values(panjang).reduce((a,b) => a+b, 0);
chartModalBody.innerHTML = `
<div class="chart-stat-row">
${statMiniHTML(counts['Jalan Nasional'], 'Nasional', '#d32f2f')}
${statMiniHTML(counts['Jalan Provinsi'], 'Provinsi', '#f57c00')}
${statMiniHTML(counts['Jalan Kabupaten'], 'Kabupaten', '#388e3c')}
${statMiniHTML((totalPanjang/1000).toFixed(2)+' km', 'Total Panjang', '#0f172a')}
</div>
<div class="chart-canvas-wrap" style="max-width:460px;margin:0 auto;">
<canvas id="chartCanvas" height="240"></canvas>
</div>`;
const ctx = document.getElementById('chartCanvas').getContext('2d');
activeChart = new Chart(ctx, {
type: 'bar',
data: {
labels: ['Jalan Nasional', 'Jalan Provinsi', 'Jalan Kabupaten'],
datasets: [{
label: 'Panjang (m)',
data: [panjang['Jalan Nasional'], panjang['Jalan Provinsi'], panjang['Jalan Kabupaten']],
backgroundColor: ['rgba(211,47,47,0.85)', 'rgba(245,124,0,0.85)', 'rgba(56,142,60,0.85)'],
borderRadius: 8,
borderSkipped: false,
maxBarThickness: 80
}]
},
options: {
...CHART_DEFAULTS,
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(0,0,0,0.06)' },
ticks: {
font: { family: "'Plus Jakarta Sans', sans-serif", weight: '600', size: 11 },
color: '#64748b',
callback: val => val >= 1000 ? (val/1000).toFixed(1)+'km' : val+'m'
}
},
x: {
grid: { display: false },
ticks: { font: { family: "'Plus Jakarta Sans', sans-serif", weight: '700', size: 11 }, color: '#0f172a' }
}
},
plugins: { ...CHART_DEFAULTS.plugins, legend: { display: false } }
}
});
}
function buildChartDigitasi() {
const kepMap = { SHM: 0, HGB: 0, HGU: 0, HP: 0 };
(APP_DATA.polygons || []).forEach(p => { if (kepMap[p.status_kepemilikan] !== undefined) kepMap[p.status_kepemilikan]++; });
const totalPoly = APP_DATA.polygons.length;
const totalJalan = APP_DATA.jalan.length;
chartModalBody.innerHTML = `
<div class="chart-stat-row">
${statMiniHTML(totalPoly, 'Total Parsil', '#0f172a')}
${statMiniHTML(kepMap.SHM, 'SHM', '#166534')}
${statMiniHTML(kepMap.HGB, 'HGB', '#1e40af')}
${statMiniHTML(kepMap.HGU, 'HGU', '#854d0e')}
${statMiniHTML(kepMap.HP, 'HP', '#6b21a8')}
${statMiniHTML(totalJalan, 'Ruas Jalan', '#d32f2f')}
</div>
<div class="chart-canvas-wrap" style="max-width:340px;margin:0 auto;">
<canvas id="chartCanvas" height="300"></canvas>
</div>`;
const ctx = document.getElementById('chartCanvas').getContext('2d');
activeChart = new Chart(ctx, {
type: 'polarArea',
data: {
labels: ['SHM', 'HGB', 'HGU', 'HP'],
datasets: [{
data: [kepMap.SHM, kepMap.HGB, kepMap.HGU, kepMap.HP],
backgroundColor: [
'rgba(22,163,74,0.75)',
'rgba(59,130,246,0.75)',
'rgba(217,119,6,0.75)',
'rgba(107,33,168,0.75)'
],
borderColor: '#fff',
borderWidth: 2
}]
},
options: {
...CHART_DEFAULTS,
scales: {
r: {
grid: { color: 'rgba(0,0,0,0.08)' },
ticks: {
backdropColor: 'transparent',
font: { family: "'Plus Jakarta Sans', sans-serif", size: 10 },
color: '#64748b'
}
}
}
}
});
}
function buildChartUtuh() {
const totalSPBU = APP_DATA.points.length;
const spbu24 = APP_DATA.points.filter(p => p.buka_24_jam === 'Ya').length;
const totalPM = pendudukList.length;
const dalam = pendudukList.filter(p => cekJangkauan(parseFloat(p.data.latitude), parseFloat(p.data.longitude)).status === 'Dalam Jangkauan').length;
const totalJalan = APP_DATA.jalan.length;
const totalPolygon = APP_DATA.polygons.length;
const totalFP = fasilitasPublikList.length;
chartModalBody.innerHTML = `
<div class="chart-stat-row">
${statMiniHTML(totalSPBU, 'Total SPBU', '#0f172a')}
${statMiniHTML(totalPM, 'Penduduk Miskin', '#e11d48')}
${statMiniHTML(totalFP, 'Fasilitas Publik', '#3b82f6')}
${statMiniHTML(totalJalan, 'Ruas Jalan', '#d32f2f')}
${statMiniHTML(totalPolygon,'Parsil Tanah', '#6b21a8')}
</div>
<div class="chart-dashboard-grid" style="margin-top:4px;">
<div class="chart-mini-card">
<div class="chart-mini-title">⛽ SPBU</div>
<canvas id="chartSpbu" height="180"></canvas>
</div>
<div class="chart-mini-card">
<div class="chart-mini-title">👥 Jangkauan Bantuan</div>
<canvas id="chartBantuan" height="180"></canvas>
</div>
</div>`;
const ctxS = document.getElementById('chartSpbu').getContext('2d');
new Chart(ctxS, {
type: 'doughnut',
data: {
labels: ['Buka 24 Jam', 'Tidak 24 Jam'],
datasets: [{ data: [spbu24, totalSPBU - spbu24], backgroundColor: [CHART_COLORS.green, CHART_COLORS.red], borderColor: '#fff', borderWidth: 2, hoverOffset: 6 }]
},
options: { ...CHART_DEFAULTS, cutout: '60%', plugins: { ...CHART_DEFAULTS.plugins, legend: { ...CHART_DEFAULTS.plugins.legend, labels: { ...CHART_DEFAULTS.plugins.legend.labels, font: { family: "'Plus Jakarta Sans', sans-serif", size: 10, weight: '600' }, padding: 10 } } } }
});
const ctxB = document.getElementById('chartBantuan').getContext('2d');
new Chart(ctxB, {
type: 'doughnut',
data: {
labels: ['Terjangkau', 'Luar Jangkauan'],
datasets: [{ data: [dalam, totalPM - dalam], backgroundColor: [CHART_COLORS.green, CHART_COLORS.red], borderColor: '#fff', borderWidth: 2, hoverOffset: 6 }]
},
options: { ...CHART_DEFAULTS, cutout: '60%', plugins: { ...CHART_DEFAULTS.plugins, legend: { ...CHART_DEFAULTS.plugins.legend, labels: { ...CHART_DEFAULTS.plugins.legend.labels, font: { family: "'Plus Jakarta Sans', sans-serif", size: 10, weight: '600' }, padding: 10 } } } }
});
}
/* ============================================================
MANAJEMEN DATA PENDUDUK (MODUL BANTUAN)
============================================================ */
window.openManajemenModal = function() {
document.getElementById('manajemenModal').classList.remove('hidden');
document.getElementById('modalBackdrop').classList.remove('hidden');
renderTabelManajemen('Semua');
// Set tab aktif kembali ke 'Semua'
document.querySelectorAll('.mj-tab').forEach(t => t.classList.remove('active'));
const allTab = document.querySelector('.mj-tab[data-filter="Semua"]');
if(allTab) allTab.classList.add('active');
};
if (document.getElementById('closeManajemenModal')) {
document.getElementById('closeManajemenModal').addEventListener('click', () => {
document.getElementById('manajemenModal').classList.add('hidden');
// Cek apakah modal lain tidak terbuka sebelum menutup backdrop
if(document.querySelectorAll('.modal:not(.hidden)').length <= 1) {
document.getElementById('modalBackdrop').classList.add('hidden');
}
});
}
document.querySelectorAll('.mj-tab').forEach(tab => {
tab.addEventListener('click', (e) => {
document.querySelectorAll('.mj-tab').forEach(t => t.classList.remove('active'));
e.target.classList.add('active');
renderTabelManajemen(e.target.getAttribute('data-filter'));
});
});
window.renderTabelManajemen = function(filter) {
const tbody = document.getElementById('manajemenTableBody');
if (!tbody) return;
tbody.innerHTML = '';
let no = 1;
pendudukList.forEach(item => {
const d = item.data;
const statusBantuan = d.status_bantuan || 'Belum Disalurkan';
if (filter !== 'Semua' && statusBantuan !== filter) return;
let badgeClass = 'belum';
if (statusBantuan === 'Proses') badgeClass = 'proses';
if (statusBantuan === 'Sudah Disalurkan') badgeClass = 'sudah';
let jangkauanColor = d.status_jangkauan === 'Dalam Jangkauan' ? '#16a34a' : '#e11d48';
const tr = document.createElement('tr');
tr.innerHTML = `
<td style="font-weight:600;">${no++}</td>
<td>
<div style="font-weight:700; color:#1e293b;">${d.nama}</div>
<div style="font-size:0.75rem; color:#64748b;">NIK: ${d.nik || '-'}</div>
</td>
<td>
<span style="font-weight:700; color:${jangkauanColor}; padding:4px 8px; background:${jangkauanColor}1a; border-radius:6px; display:inline-block;">
${d.status_jangkauan}
</span>
</td>
<td>
${d.foto_rumah ? `<a href="javascript:void(0)" onclick="lihatFotoModal('uploads/${d.foto_rumah}')" style="color:#2563eb; text-decoration:none; font-weight:600;">Lihat Foto</a>` : '<span style="color:#94a3b8;">-</span>'}
</td>
<td>
<div class="mj-dropdown" id="dropdown-${d.id}">
<div class="mj-dropdown-toggle ${badgeClass}" onclick="toggleDropdown(${d.id})">
${statusBantuan} <span class="caret">▼</span>
</div>
<div class="mj-dropdown-menu">
<div class="mj-dropdown-item belum" onclick="updateStatusLangsung(${d.id}, 'Belum Disalurkan', '${statusBantuan}')">Belum Disalurkan</div>
<div class="mj-dropdown-item proses" onclick="updateStatusLangsung(${d.id}, 'Proses', '${statusBantuan}')">Proses</div>
<div class="mj-dropdown-item sudah" onclick="updateStatusLangsung(${d.id}, 'Sudah Disalurkan', '${statusBantuan}')">Sudah Disalurkan</div>
</div>
</div>
</td>
`;
tbody.appendChild(tr);
});
if (no === 1) {
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; padding: 20px; color:#64748b;">Tidak ada data.</td></tr>`;
}
};
window.lihatFotoModal = function(url) {
if (typeof Swal !== 'undefined') {
Swal.fire({
imageUrl: url,
imageAlt: 'Foto',
showConfirmButton: false,
showCloseButton: true,
customClass: { popup: 'swal-foto-popup' }
});
} else {
window.open(url, '_blank');
}
};
window.updateStatusLangsung = function(id, newValue, oldValue) {
// Tutup dropdown global
if (typeof closeGlobalDropdown === 'function') closeGlobalDropdown();
if (newValue === oldValue) return;
const fd = new FormData();
fd.append('id', id);
fd.append('status_bantuan', newValue);
fetch('api/update_status_bantuan.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(res => {
if (res.status === 'success') {
showToast('Status penyaluran diperbarui.', 'success');
refreshDataModul().then(() => {
const activeFilter = document.querySelector('.mj-tab.active').getAttribute('data-filter');
renderTabelManajemen(activeFilter);
});
} else {
showToast('Gagal mengubah status', 'error');
const activeFilter = document.querySelector('.mj-tab.active').getAttribute('data-filter');
renderTabelManajemen(activeFilter);
}
}).catch(err => {
showToast('Koneksi terputus', 'error');
const activeFilter = document.querySelector('.mj-tab.active').getAttribute('data-filter');
renderTabelManajemen(activeFilter);
});
};
let globalDropdown = null;
window.closeGlobalDropdown = function() {
if (globalDropdown) {
globalDropdown.remove();
globalDropdown = null;
}
document.querySelectorAll('.mj-dropdown.open').forEach(d => d.classList.remove('open'));
};
window.toggleDropdown = function(id) {
const el = document.getElementById(`dropdown-${id}`);
if (!el) return;
// If clicking the same one that's open, close it
if (el.classList.contains('open')) {
closeGlobalDropdown();
return;
}
// Close any existing first
closeGlobalDropdown();
// Open it
el.classList.add('open');
// Create a new one on body
const menuTemplate = el.querySelector('.mj-dropdown-menu');
globalDropdown = document.createElement('div');
globalDropdown.className = 'mj-dropdown-menu';
globalDropdown.innerHTML = menuTemplate.innerHTML;
const rect = el.querySelector('.mj-dropdown-toggle').getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
globalDropdown.style.position = 'fixed';
globalDropdown.style.left = rect.left + 'px';
globalDropdown.style.zIndex = '9999999';
// Force visibility since it's not under .mj-dropdown.open anymore
globalDropdown.style.opacity = '1';
globalDropdown.style.visibility = 'visible';
globalDropdown.style.transform = 'scaleY(1)';
if (spaceBelow < 140) {
globalDropdown.style.top = 'auto';
globalDropdown.style.bottom = (window.innerHeight - rect.top + 6) + 'px';
globalDropdown.style.transformOrigin = 'bottom left';
} else {
globalDropdown.style.top = (rect.bottom + 6) + 'px';
globalDropdown.style.bottom = 'auto';
globalDropdown.style.transformOrigin = 'top left';
}
document.body.appendChild(globalDropdown);
};
// Global click to close
document.addEventListener('click', function(e) {
if (!e.target.closest('.mj-dropdown') && !e.target.closest('.mj-dropdown-menu')) {
closeGlobalDropdown();
}
});
// Close when scrolling table to prevent floating menu out of sync
document.querySelector('#manajemenModal .table-responsive')?.addEventListener('scroll', closeGlobalDropdown);
window.addEventListener('scroll', closeGlobalDropdown, true);
})();