diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..3ea1645 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,1357 @@ +let map; +let rumahIbadah = []; +let keluarga = []; +let users = []; +let musibah = []; +let pemberdayaan = []; +let rumahIbadahLayer; +let keluargaLayer; +let radiusLayer; +let routingControl = null; +let selectedMarker = null; + +const user = window.SIPANTAS_USER || {}; +const role = window.SIPANTAS_ROLE || 'guest'; +const caps = window.SIPANTAS_CAPS || {}; +const linkedRumahIbadahId = Number(user.rumah_ibadah_id || 0); + +const guideConfig = { + admin: { + title: 'Kelola sistem dari data sampai laporan', + text: 'Mulai dari validasi data, jalankan analisis, lalu pantau blank spot.', + actionTab: 'validasi', + actionLabel: 'Buka Verifikasi', + helper: 'Admin dapat mengelola data, verifikasi akun, menjalankan analisis, dan membuat laporan.', + steps: [ + ['Verifikasi akun dan usulan', 'Buka menu Akun atau Verifikasi untuk memastikan data yang masuk layak dipakai.'], + ['Atur rumah ibadah', 'Pastikan radius, kuota, dan dana sosial setiap rumah ibadah sudah benar.'], + ['Jalankan analisis', 'Gunakan tombol analisis agar status Ditanggung dan Blank Spot diperbarui.'], + ['Pantau laporan', 'Export blank spot untuk prioritas bantuan pemerintah.'] + ] + }, + dinas: { + title: 'Pantau cakupan dan prioritas bantuan', + text: 'Fokus pada keluarga valid, blank spot, dan laporan prioritas.', + actionTab: 'peta', + actionLabel: 'Lihat Peta Data', + helper: 'Dinas dapat melihat cakupan bantuan, menjalankan analisis, dan mengunduh data blank spot.', + steps: [ + ['Cek data valid', 'Buka Verifikasi untuk melihat usulan yang masih perlu dicek.'], + ['Jalankan analisis', 'Sistem mencocokkan keluarga dengan rumah ibadah berdasarkan radius dan kuota.'], + ['Lihat blank spot', 'Gunakan filter status Blank Spot pada peta.'], + ['Export laporan', 'Unduh data prioritas untuk koordinasi tindak lanjut.'] + ] + }, + rumah_ibadah: { + title: 'Atur layanan rumah ibadah kamu', + text: 'Perbarui radius, kuota, dan catat bantuan untuk keluarga tanggungan.', + actionTab: 'rumah-ibadah', + actionLabel: 'Atur Layanan', + helper: 'Pengurus rumah ibadah bisa mengatur radius layanan, kuota, dana sosial, dan mencatat penyaluran bantuan.', + steps: [ + ['Buka Layanan Saya', 'Cek profil rumah ibadah yang terhubung dengan akunmu.'], + ['Atur radius dan kuota', 'Sesuaikan jangkauan bantuan dengan kemampuan layanan.'], + ['Cek tanggungan', 'Lihat keluarga yang masuk dalam radius setelah analisis dijalankan.'], + ['Catat bantuan', 'Simpan riwayat bantuan agar tidak terjadi tumpang tindih.'] + ] + }, + relawan: { + title: 'Bantu validasi data lapangan', + text: 'Input usulan, cek lokasi, lalu tandai status verifikasinya.', + actionTab: 'validasi', + actionLabel: 'Buka Validasi', + helper: 'Relawan membantu memasukkan usulan dan memvalidasi kondisi keluarga di lapangan.', + steps: [ + ['Input usulan', 'Buka menu Input Keluarga dan klik lokasi rumah pada peta.'], + ['Lengkapi data', 'Isi identitas, tingkat kerentanan, dan kebutuhan utama.'], + ['Verifikasi', 'Tandai data valid atau tidak valid setelah survei.'], + ['Catat bantuan', 'Catat bantuan bila ikut mendistribusikan.'] + ] + }, + masyarakat: { + title: 'Ajukan keluarga yang membutuhkan bantuan', + text: 'Pilih lokasi, isi data keluarga, lalu kirim usulan untuk diverifikasi.', + actionTab: 'input', + actionLabel: 'Ajukan Bantuan', + helper: 'Masyarakat dapat mengajukan data keluarga yang membutuhkan bantuan dan melihat status usulannya.', + steps: [ + ['Klik Ajukan Bantuan', 'Buka menu Ajukan Bantuan dari panel sebelah kiri.'], + ['Pilih lokasi rumah', 'Klik titik rumah pada peta sampai koordinat terisi.'], + ['Isi data keluarga', 'Lengkapi data KK, anggota keluarga, kerentanan, dan kebutuhan.'], + ['Kirim usulan', 'Data akan masuk sebagai Menunggu Verifikasi.'] + ] + } +}; + +const statusColors = { + 'Ditanggung': '#16a34a', + 'Blank Spot': '#dc2626', + 'Menunggu Verifikasi': '#f59e0b', + 'Tidak Valid': '#6b7280', + 'Bantuan Pemerintah': '#7c3aed', + 'Butuh Bantuan Lanjutan': '#fb7185' +}; + +function initMap() { + map = L.map('map', { zoomControl: false }).setView([-0.0263, 109.3425], 13); + L.control.zoom({ position: 'bottomright' }).addTo(map); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap contributors' + }).addTo(map); + + radiusLayer = L.layerGroup().addTo(map); + rumahIbadahLayer = L.layerGroup().addTo(map); + keluargaLayer = L.layerGroup().addTo(map); + + map.on('click', handleMapClick); +} + +async function handleMapClick(e) { + const lat = e.latlng.lat.toFixed(7); + const lng = e.latlng.lng.toFixed(7); + const activeTab = document.querySelector('.tab.active')?.dataset.tab || 'dashboard'; + + if (activeTab !== 'input' && activeTab !== 'musibah' && !(activeTab === 'rumah-ibadah' && caps.canCreateRI)) { + toast('Untuk mengisi titik lokasi, buka menu Input Keluarga, Musibah, atau Rumah Ibadah terlebih dahulu.'); + setHelpContext(activeTab); + return; + } + + if (selectedMarker) map.removeLayer(selectedMarker); + selectedMarker = L.marker([lat, lng]).addTo(map).bindPopup('Titik lokasi dipilih').openPopup(); + + if (activeTab === 'rumah-ibadah' && caps.canCreateRI) { + setValue('riLat', lat); + setValue('riLng', lng); + await fillAddress(lat, lng, 'riAlamat'); + toast('Titik rumah ibadah dipilih. Lanjut lengkapi data layanan.'); + } else if (activeTab === 'musibah') { + setValue('musibahLat', lat); + setValue('musibahLng', lng); + await fillAddress(lat, lng, 'musibahAlamat'); + toast('Lokasi kejadian dipilih. Lanjut lengkapi laporan musibah.'); + } else { + setValue('lat', lat); + setValue('lng', lng); + await fillAddress(lat, lng, 'kelAlamat'); + toast('Titik rumah keluarga dipilih. Lanjut lengkapi form.'); + } +} + +async function fillAddress(lat, lng, targetId) { + try { + const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`; + const res = await fetch(url); + const data = await res.json(); + setValue(targetId, data.display_name || ''); + } catch (error) { + toast('Alamat otomatis gagal dimuat. Isi alamat secara manual.'); + } +} + +function initUI() { + document.querySelectorAll('.tab').forEach(btn => { + btn.addEventListener('click', () => setActiveTab(btn.dataset.tab)); + }); + + onClick('btnSimpanKeluarga', simpanKeluarga); + onClick('btnBersihkanKeluarga', bersihkanFormKeluarga); + onClick('btnAnalisis', jalankanAnalisis); + onClick('btnRefresh', loadAllData); + onClick('btnLocate', locateMe); + onClick('btnFitMap', fitAllMarkers); + onClick('btnSimpanRI', simpanRumahIbadah); + onClick('btnRIBaru', resetFormRumahIbadah); + onClick('btnPrintSummary', () => window.print()); + onClick('btnTambahAnggota', () => addAnggotaForm()); + onClick('btnSimpanMusibah', simpanMusibah); + onInput('musibahKeluarga', isiLokasiMusibahDariKeluarga); + onClick('btnGoMusibah', () => setActiveTab('musibah')); + initAnggotaDefault(); + const tanggalMusibah = document.getElementById('musibahTanggal'); + if (tanggalMusibah && !tanggalMusibah.value) tanggalMusibah.value = new Date().toISOString().slice(0, 10); + + onInput('filterStatus', renderAll); + onInput('searchText', renderAll); + + const riRadius = document.getElementById('riRadius'); + if (riRadius) { + riRadius.addEventListener('input', () => setText('riRadiusLabel', riRadius.value)); + } + + setupLayerButton('btnLayerRadius', radiusLayer); + setupLayerButton('btnLayerRI', rumahIbadahLayer); + setupLayerButton('btnLayerKK', keluargaLayer); + setupPanelToggle(); + initGuides(); + onClick('btnGoInput', () => { setActiveTab('input'); toast('Klik titik rumah pada peta, lalu lengkapi form.'); }); + onClick('btnGoMap', () => setActiveTab('peta')); +} + +function setupLayerButton(buttonId, layer) { + const button = document.getElementById(buttonId); + if (!button) return; + + button.addEventListener('click', () => { + if (map.hasLayer(layer)) { + map.removeLayer(layer); + button.classList.remove('active'); + } else { + layer.addTo(map); + button.classList.add('active'); + } + }); +} + +function setupPanelToggle() { + const body = document.body; + const collapse = document.getElementById('btnPanelCollapse'); + const open = document.getElementById('btnPanelOpen'); + + const setCollapsed = (value) => { + body.classList.toggle('panel-collapsed', value); + setTimeout(() => map && map.invalidateSize(), 260); + }; + + if (collapse) collapse.addEventListener('click', () => setCollapsed(true)); + if (open) open.addEventListener('click', () => setCollapsed(false)); +} + +function setActiveTab(tabName) { + document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tabName)); + document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); + const panel = document.getElementById(`tab-${tabName}`); + if (panel) panel.classList.add('active'); + setHelpContext(tabName); +} + +function getGuide() { + return guideConfig[role] || guideConfig.masyarakat; +} + +function initGuides() { + const guide = getGuide(); + setText('quickGuideTitle', guide.title); + setText('quickGuideText', guide.text); + setText('dashboardSubtitle', guide.helper); + + const primary = document.getElementById('btnGuidePrimary'); + if (primary) { + primary.textContent = guide.actionLabel; + primary.addEventListener('click', () => setActiveTab(guide.actionTab)); + } + + const roleSteps = document.getElementById('roleSteps'); + if (roleSteps) { + roleSteps.innerHTML = guide.steps.map((step, index) => ` +
+ ${index + 1} + ${escapeHtml(step[0])} + ${escapeHtml(step[1])} +
+ `).join(''); + } + + const openGuide = () => showGuideModal(false); + onClick('btnOpenGuide', openGuide); + onClick('btnCloseGuide', closeGuideModal); + onClick('guideBackdrop', closeGuideModal); + onClick('btnGuideLater', closeGuideModal); + onClick('btnGuideAction', () => { + closeGuideModal(); + setActiveTab(guide.actionTab); + }); + + const firstKey = `sipantas-guide-seen-${role}`; + if (!localStorage.getItem(firstKey)) { + setTimeout(() => showGuideModal(true), 600); + localStorage.setItem(firstKey, '1'); + } + + setHelpContext('dashboard'); +} + +function showGuideModal(isFirstTime = false) { + const guide = getGuide(); + setText('guideTitle', guide.title); + setText('guideDescription', isFirstTime ? 'Sebelum mulai, lihat alur singkat agar tidak bingung memakai fitur utama.' : guide.text); + setText('btnGuideAction', guide.actionLabel); + + const list = document.getElementById('guideList'); + if (list) { + list.innerHTML = guide.steps.map((step, index) => ` +
+ ${index + 1} +
${escapeHtml(step[0])}${escapeHtml(step[1])}
+
+ `).join(''); + } + + const modal = document.getElementById('guideModal'); + if (modal) { + modal.classList.add('show'); + modal.setAttribute('aria-hidden', 'false'); + } +} + +function closeGuideModal() { + const modal = document.getElementById('guideModal'); + if (modal) { + modal.classList.remove('show'); + modal.setAttribute('aria-hidden', 'true'); + } +} + +function setHelpContext(tabName) { + const helper = document.getElementById('mapHelperText'); + if (!helper) return; + const labels = { + dashboard: 'Ringkasan ada di panel. Peta tetap bisa digeser dan diperbesar.', + peta: 'Klik marker untuk melihat detail. Gunakan filter pada panel untuk mempersempit data.', + input: 'Klik titik rumah keluarga di peta. Koordinat dan alamat akan masuk ke form.', + 'rumah-ibadah': caps.canCreateRI ? 'Klik lokasi rumah ibadah pada peta, lalu isi radius, kuota, dan dana sosial.' : 'Pilih rumah ibadah dari daftar untuk mengatur radius dan kuota layanan.', + validasi: 'Pilih data pada daftar validasi, fokuskan lokasi, lalu ubah status verifikasi.', + bantuan: 'Gunakan tombol Rute untuk melihat jalur dan Catat Bantuan untuk menyimpan riwayat.', + musibah: 'Pilih keluarga terkait atau klik lokasi kejadian pada peta, lalu kirim laporan musibah.', + pemberdayaan: 'Daftar ini berisi anggota keluarga yang masih mampu bekerja dan bisa diarahkan ke program produktif.', + laporan: 'Gunakan panel laporan untuk mengunduh atau mencetak ringkasan.', + akun: 'Setujui akun resmi yang sudah sesuai, atau tolak jika datanya tidak valid.' + }; + helper.textContent = labels[tabName] || 'Geser atau zoom peta untuk melihat sebaran data.'; +} + +async function loadAllData() { + try { + const requests = [ + fetch('api/rumah_ibadah.php'), + fetch('api/keluarga.php'), + fetch('api/musibah.php') + ]; + if (caps.canManagePemberdayaan) requests.push(fetch('api/pemberdayaan.php')); + const responses = await Promise.all(requests); + const [riJson, kelJson, musibahJson, pemberdayaanJson] = await Promise.all(responses.map(r => r.json())); + + if (!riJson.success) throw new Error(riJson.message || 'Gagal memuat rumah ibadah'); + if (!kelJson.success) throw new Error(kelJson.message || 'Gagal memuat keluarga'); + if (!musibahJson.success) throw new Error(musibahJson.message || 'Gagal memuat laporan musibah'); + + rumahIbadah = riJson.data || []; + keluarga = kelJson.data || []; + musibah = musibahJson.data || []; + pemberdayaan = pemberdayaanJson && pemberdayaanJson.success ? (pemberdayaanJson.data || []) : []; + + if (role === 'rumah_ibadah' && linkedRumahIbadahId) { + const own = rumahIbadah.find(ri => Number(ri.id) === linkedRumahIbadahId); + if (own && !getValue('riId')) fillRumahIbadahForm(own.id); + } + + if (caps.canManageUsers) { + await loadUsers(); + } + + renderAll(); + } catch (error) { + toast(error.message || 'Gagal memuat data.'); + } +} + +async function loadUsers() { + if (!caps.canManageUsers) return; + try { + const res = await fetch('api/users.php'); + const json = await res.json(); + if (!json.success) throw new Error(json.message || 'Gagal memuat akun'); + users = json.data || []; + } catch (error) { + toast(error.message || 'Gagal memuat daftar akun.'); + } +} + +function renderAll() { + renderRumahIbadah(); + renderKeluarga(); + updateDashboard(); + renderListKeluarga(); + renderListRumahIbadah(); + renderListValidasi(); + renderListBantuan(); + renderListMusibah(); + renderListPemberdayaan(); + populateMusibahKeluarga(); + renderListAkun(); +} + +function renderRumahIbadah() { + rumahIbadahLayer.clearLayers(); + radiusLayer.clearLayers(); + + rumahIbadah.forEach(ri => { + const lat = Number(ri.lat); + const lng = Number(ri.lng); + const radius = Number(ri.radius || 500); + const jumlahTanggungan = Number(ri.jumlah_tanggungan || 0); + const sisaKuota = Math.max(0, Number(ri.kuota || 0) - jumlahTanggungan); + + L.circle([lat, lng], { + radius, + color: '#2563eb', + fillColor: '#60a5fa', + fillOpacity: 0.12, + weight: 1.4 + }).addTo(radiusLayer); + + const marker = L.circleMarker([lat, lng], { + radius: 10, + color: '#1d4ed8', + fillColor: '#2563eb', + fillOpacity: 0.92, + weight: 2 + }).addTo(rumahIbadahLayer); + + const editBtn = canEditRumahIbadah(ri) + ? `` + : ''; + const deleteBtn = caps.canCreateRI + ? `` + : ''; + const tanggunganBtn = ``; + + marker.bindPopup(` + + Kategori: ${escapeHtml(ri.kategori)}
+ Pengurus: ${escapeHtml(ri.pengurus || '-')}
+ Kontak: ${escapeHtml(ri.kontak || '-')}
+ Dana Sosial: ${rupiah(ri.dana_sosial)}
+ Kuota: ${ri.kuota} KK • Sisa: ${sisaKuota}
+ Radius: ${ri.radius} meter
+ Tanggungan aktif: ${jumlahTanggungan} KK
+ ${escapeHtml(ri.alamat || '')}
+ ${editBtn}${tanggunganBtn}${deleteBtn} + `); + }); +} + +function renderKeluarga() { + keluargaLayer.clearLayers(); + getFilteredKeluarga().forEach(kel => { + const color = statusColors[kel.status_bantuan] || '#6b7280'; + const lat = Number(kel.lat); + const lng = Number(kel.lng); + + const marker = L.circleMarker([lat, lng], { + radius: 8, + color, + fillColor: color, + fillOpacity: 0.92, + weight: 2 + }).addTo(keluargaLayer); + + const routeBtn = kel.rumah_ibadah_id && kel.ri_lat && kel.ri_lng + ? `` + : ''; + const verifyBtns = caps.canVerify + ? ` + ` + : ''; + const bantuanBtn = canRecordBantuanFor(kel) + ? `` + : ''; + const musibahBtn = caps.canReportMusibah + ? `` + : ''; + const deleteBtn = caps.canCreateRI + ? `` + : ''; + + marker.bindPopup(` + + Status Verifikasi: ${escapeHtml(kel.status_verifikasi)}
+ Status Bantuan: ${escapeHtml(kel.status_bantuan)}
+ Penanggung: ${escapeHtml(kel.nama_rumah_ibadah || '-')}
+ Kerentanan: ${escapeHtml(kel.kerentanan)}
+ Kebutuhan: ${escapeHtml(kel.kebutuhan)}
+ Anggota KK: ${Number(kel.jumlah_anggota || 0)} orang • Mampu bekerja: ${Number(kel.jumlah_mampu_bekerja || 0)}
+ ${escapeHtml(kel.alamat || '')}
+ ${routeBtn}${bantuanBtn}${musibahBtn}${verifyBtns}${deleteBtn} + `); + }); +} + +function canEditRumahIbadah(ri) { + if (caps.canCreateRI) return true; + return role === 'rumah_ibadah' && linkedRumahIbadahId && Number(ri.id) === linkedRumahIbadahId; +} + +function canRecordBantuanFor(kel) { + if (!caps.canRecordBantuan || kel.status_bantuan !== 'Ditanggung') return false; + if (role === 'rumah_ibadah') return linkedRumahIbadahId && Number(kel.rumah_ibadah_id) === linkedRumahIbadahId; + return true; +} + +function getFilteredKeluarga() { + const status = getValue('filterStatus') || 'semua'; + const keyword = (getValue('searchText') || '').toLowerCase(); + + return keluarga.filter(k => { + const matchStatus = status === 'semua' || k.status_bantuan === status; + const text = `${k.nama_kk} ${k.alamat} ${k.kerentanan} ${k.kebutuhan} ${k.nama_rumah_ibadah || ''}`.toLowerCase(); + const matchKeyword = !keyword || text.includes(keyword); + return matchStatus && matchKeyword; + }); +} + +function updateDashboard() { + const visible = keluarga; + const total = visible.length; + const totalDitanggung = visible.filter(k => k.status_bantuan === 'Ditanggung').length; + const totalBlank = visible.filter(k => k.status_bantuan === 'Blank Spot').length; + const totalMenunggu = visible.filter(k => k.status_bantuan === 'Menunggu Verifikasi').length; + const totalValid = visible.filter(k => k.status_verifikasi === 'Valid').length; + const totalMusibahAktif = musibah.filter(m => !['Selesai','Ditolak'].includes(m.status)).length; + const totalProduktif = keluarga.reduce((sum, k) => sum + Number(k.jumlah_mampu_bekerja || 0), 0); + const cakupan = totalValid > 0 ? Math.round((totalDitanggung / totalValid) * 100) : 0; + + setText('totalRumahIbadah', rumahIbadah.length); + setText('totalKeluarga', total); + setText('totalDitanggung', totalDitanggung); + setText('totalBlankSpot', totalBlank); + setText('totalMenunggu', totalMenunggu); + setText('totalMusibahAktif', totalMusibahAktif); + setText('totalProduktif', totalProduktif); + setText('cakupanBantuan', `${cakupan}%`); + setText('cakupanLabel', `${cakupan}%`); + const bar = document.getElementById('cakupanBar'); + if (bar) bar.style.width = `${Math.min(100, Math.max(0, cakupan))}%`; + + if (role === 'rumah_ibadah') { + const own = rumahIbadah.find(ri => Number(ri.id) === linkedRumahIbadahId); + if (own) setText('dashboardTitle', `Cakupan ${own.nama}`); + } + if (role === 'masyarakat') { + setText('dashboardTitle', 'Status Usulan Saya'); + } + if (role === 'relawan') { + setText('dashboardTitle', 'Antrian Survei Lapangan'); + } +} + +function renderListKeluarga() { + const box = document.getElementById('listKeluarga'); + if (!box) return; + const rows = getFilteredKeluarga(); + + if (!rows.length) { + box.innerHTML = emptyState('Belum ada data sesuai filter', 'Coba ubah filter status atau kata kunci pencarian.'); + return; + } + + box.innerHTML = rows.map(k => keluargaCard(k, true)).join(''); +} + +function renderListRumahIbadah() { + const box = document.getElementById('listRumahIbadah'); + if (!box) return; + + const rows = role === 'rumah_ibadah' && linkedRumahIbadahId + ? rumahIbadah.filter(ri => Number(ri.id) === linkedRumahIbadahId) + : rumahIbadah; + + if (!rows.length) { + box.innerHTML = emptyState('Rumah ibadah belum tersedia', 'Tambahkan data rumah ibadah agar radius layanan dapat dianalisis.'); + return; + } + + box.innerHTML = rows.map(ri => { + const jumlah = Number(ri.jumlah_tanggungan || 0); + const sisa = Math.max(0, Number(ri.kuota || 0) - jumlah); + const actions = canEditRumahIbadah(ri) + ? `` + : ''; + const deleteAction = caps.canCreateRI ? `` : ''; + return ` +
+
${escapeHtml(ri.nama)}${escapeHtml(ri.kategori)}
+ ${escapeHtml(ri.alamat || '-')} + Radius ${ri.radius} m • Kuota ${ri.kuota} KK • Sisa ${sisa} KK • Dana ${rupiah(ri.dana_sosial)} +
${actions}${deleteAction}
+
+ `; + }).join(''); +} + +function renderListValidasi() { + const box = document.getElementById('listValidasi'); + if (!box) return; + + const rows = keluarga.filter(k => k.status_verifikasi === 'Menunggu Verifikasi' || k.status_verifikasi === 'Perlu Survei Ulang'); + if (!rows.length) { + box.innerHTML = emptyState('Tidak ada antrian verifikasi', 'Usulan baru atau data yang perlu survei ulang akan muncul di sini.'); + return; + } + + box.innerHTML = rows.map(k => keluargaCard(k, false, 'validasi')).join(''); +} + +function renderListBantuan() { + const box = document.getElementById('listBantuan'); + if (!box) return; + + let rows = keluarga.filter(k => k.status_bantuan === 'Ditanggung'); + if (role === 'rumah_ibadah' && linkedRumahIbadahId) { + rows = rows.filter(k => Number(k.rumah_ibadah_id) === linkedRumahIbadahId); + } + + if (!rows.length) { + box.innerHTML = emptyState('Belum ada tanggungan', 'Jalankan analisis setelah data rumah ibadah dan keluarga valid tersedia.'); + return; + } + + box.innerHTML = rows.map(k => keluargaCard(k, false, 'bantuan')).join(''); +} + +function renderListMusibah() { + const box = document.getElementById('listMusibah'); + if (!box) return; + if (!musibah.length) { + box.innerHTML = emptyState('Belum ada laporan musibah', 'Laporan kondisi mendadak akan muncul di sini.'); + return; + } + box.innerHTML = musibah.map(m => { + const statusClass = m.status === 'Selesai' ? 'badge-green' : (m.tingkat_urgensi === 'Darurat' || m.tingkat_urgensi === 'Tinggi' ? 'badge-red' : 'badge-yellow'); + const actions = caps.canManageMusibah + ? ` + + ` : ''; + return ` +
+
${escapeHtml(m.jenis_musibah)}${escapeHtml(m.tingkat_urgensi)} • ${escapeHtml(m.status)}
+ ${escapeHtml(m.nama_kk || 'Lokasi umum')} • ${escapeHtml(m.tanggal_kejadian || '-')} + ${escapeHtml(m.alamat || '-')} + ${escapeHtml(m.deskripsi || '')} + Kebutuhan: ${escapeHtml(m.kebutuhan_mendesak || '-')} + ${m.tindak_lanjut ? `Tindak lanjut: ${escapeHtml(m.tindak_lanjut)}` : ''} +
${actions}
+
+ `; + }).join(''); +} + +function renderListPemberdayaan() { + const box = document.getElementById('listPemberdayaan'); + if (!box) return; + if (!pemberdayaan.length) { + box.innerHTML = emptyState('Belum ada calon pemberdayaan', 'Isi anggota keluarga yang mampu bekerja pada form keluarga.'); + return; + } + box.innerHTML = pemberdayaan.map(a => { + const badge = a.status_program ? 'badge-purple' : 'badge-yellow'; + const status = a.status_program || a.status_pemberdayaan || 'Belum Dinilai'; + return ` +
+
${escapeHtml(a.nama)}${escapeHtml(status)}
+ ${escapeHtml(a.hubungan)} dari ${escapeHtml(a.nama_kk)} • ${escapeHtml(a.kerentanan)} + Pekerjaan: ${escapeHtml(a.pekerjaan || '-')} • Penghasilan: ${rupiah(a.penghasilan)} + Keahlian/minat: ${escapeHtml(a.keahlian || '-')} + Program: ${escapeHtml(a.nama_program || 'Belum ada program')} +
+
+ `; + }).join(''); +} + + +function renderListAkun() { + const box = document.getElementById('listAkun'); + if (!box) return; + if (!users.length) { + box.innerHTML = emptyState('Belum ada akun terdaftar', 'Pengajuan akun resmi akan tampil di sini untuk diverifikasi.'); + return; + } + + const pending = users.filter(u => u.status_akun === 'Menunggu Verifikasi'); + const active = users.filter(u => u.status_akun !== 'Menunggu Verifikasi'); + const rows = [...pending, ...active]; + + box.innerHTML = rows.map(u => { + const statusClass = u.status_akun === 'Aktif' ? 'badge-green' : (u.status_akun === 'Ditolak' ? 'badge-red' : 'badge-yellow'); + const roleLabel = getRoleLabel(u.role); + const detailInstansi = u.role === 'rumah_ibadah' + ? `Rumah ibadah: ${escapeHtml(u.nama_instansi || '-')} • ${escapeHtml(u.kategori_instansi || '-')}` + : ''; + const detailKontak = u.kontak ? `Kontak: ${escapeHtml(u.kontak)}` : ''; + const detailAlamat = u.alamat ? `${escapeHtml(u.alamat)}` : ''; + const detailCatatan = u.catatan_pengajuan ? `Catatan: ${escapeHtml(u.catatan_pengajuan)}` : ''; + const detailKoordinat = u.role === 'rumah_ibadah' && u.lat && u.lng ? `Koordinat: ${escapeHtml(u.lat)}, ${escapeHtml(u.lng)}` : ''; + const approveReject = u.status_akun === 'Menunggu Verifikasi' + ? ` + ` + : ''; + const deleteBtn = Number(u.id) !== Number(user.id) + ? `` + : ''; + + return ` +
+
+ ${escapeHtml(u.nama)} + ${escapeHtml(u.status_akun || 'Aktif')} +
+ ${escapeHtml(u.email)} • ${roleLabel} + ${detailInstansi}${detailKontak}${detailAlamat}${detailKoordinat}${detailCatatan} +
${approveReject}${deleteBtn}
+
+ `; + }).join(''); +} + +function emptyState(title, description) { + return ` +
+
+ ${escapeHtml(title)} + ${escapeHtml(description)} +
+ `; +} + +function getRoleLabel(value) { + const labels = { + admin: 'Administrator', + dinas: 'Dinas Sosial', + rumah_ibadah: 'Pengurus Rumah Ibadah', + relawan: 'Relawan Lapangan', + masyarakat: 'Masyarakat' + }; + return labels[value] || value; +} + +async function approveUser(id) { + if (!caps.canManageUsers) return toast('Akses ditolak.'); + if (!confirm('Setujui akun ini?')) return; + try { + const result = await apiPut('api/users.php', { id, action: 'approve' }); + toast(result.message || 'Akun disetujui.'); + await loadAllData(); + } catch (error) { + toast(error.message || 'Gagal menyetujui akun.'); + } +} + +async function rejectUser(id) { + if (!caps.canManageUsers) return toast('Akses ditolak.'); + if (!confirm('Tolak pengajuan akun ini?')) return; + try { + const result = await apiPut('api/users.php', { id, action: 'reject' }); + toast(result.message || 'Akun ditolak.'); + await loadAllData(); + } catch (error) { + toast(error.message || 'Gagal menolak akun.'); + } +} + +async function deleteUser(id) { + if (!caps.canManageUsers) return toast('Akses ditolak.'); + if (!confirm('Hapus akun ini?')) return; + try { + const res = await fetch(`api/users.php?id=${id}`, { method: 'DELETE' }); + const json = await res.json(); + if (!json.success) throw new Error(json.message || 'Gagal menghapus akun.'); + toast(json.message || 'Akun dihapus.'); + await loadAllData(); + } catch (error) { + toast(error.message || 'Gagal menghapus akun.'); + } +} + +function keluargaCard(k, allowFocus = true, mode = 'data') { + const badgeClass = getBadgeClass(k.status_bantuan); + const onclick = allowFocus ? `onclick="focusKeluarga(${k.id})"` : ''; + const route = k.rumah_ibadah_id && k.ri_lat && k.ri_lng + ? `` : ''; + const verify = caps.canVerify && mode !== 'bantuan' + ? ` + ` : ''; + const bantuan = canRecordBantuanFor(k) + ? `` : ''; + const del = caps.canCreateRI + ? `` : ''; + const musibahBtn = caps.canReportMusibah + ? `` : ''; + const anggotaRingkas = Number(k.jumlah_anggota || 0) > 0 + ? `Anggota KK: ${Number(k.jumlah_anggota || 0)} orang • Mampu bekerja: ${Number(k.jumlah_mampu_bekerja || 0)}` : ''; + const anggotaDetail = Array.isArray(k.anggota) && k.anggota.length + ? `
Lihat biodata anggota${k.anggota.map(a => `
${escapeHtml(a.nama)}${escapeHtml(a.hubungan)} • ${escapeHtml(a.jenis_kelamin)} • ${escapeHtml(a.pekerjaan || '-')} • Mampu bekerja: ${escapeHtml(a.mampu_bekerja || 'Tidak')}
`).join('')}
` : ''; + + return ` +
+
+ ${escapeHtml(k.nama_kk)} + ${escapeHtml(k.status_bantuan)} +
+ ${escapeHtml(k.alamat || '-')} + ${escapeHtml(k.kerentanan)} • ${escapeHtml(k.kebutuhan)} • Verifikasi: ${escapeHtml(k.status_verifikasi)} + Penanggung: ${escapeHtml(k.nama_rumah_ibadah || '-')} + ${anggotaRingkas}${anggotaDetail} +
${route}${bantuan}${musibahBtn}${verify}${del}
+
+ `; +} + +function getBadgeClass(status) { + if (status === 'Ditanggung') return 'badge-green'; + if (status === 'Blank Spot') return 'badge-red'; + if (status === 'Menunggu Verifikasi') return 'badge-yellow'; + if (status === 'Bantuan Pemerintah') return 'badge-purple'; + return 'badge-gray'; +} + +async function simpanKeluarga() { + const lat = getValue('lat'); + const lng = getValue('lng'); + if (!lat || !lng) { + toast('Klik titik rumah keluarga pada peta terlebih dahulu.'); + return; + } + + const payload = { + nama_kk: getValue('kelNama'), + alamat: getValue('kelAlamat'), + no_kk: getValue('kelNoKK'), + nik: getValue('kelNik'), + kerentanan: getValue('kelKerentanan'), + kebutuhan: getValue('kelKebutuhan'), + status_verifikasi: getValue('kelStatusVerifikasi') || 'Menunggu Verifikasi', + catatan: getValue('kelCatatan'), + lat, + lng, + anggota: collectAnggotaForm() + }; + + if (!payload.nama_kk || !payload.alamat) { + toast('Nama kepala keluarga dan alamat wajib diisi.'); + return; + } + if (!payload.anggota.length) { + toast('Minimal isi satu anggota keluarga sesuai KK.'); + return; + } + + try { + await apiPost('api/keluarga.php', payload); + toast(role === 'masyarakat' ? 'Usulan berhasil dikirim dan menunggu verifikasi.' : 'Data keluarga berhasil disimpan.'); + bersihkanFormKeluarga(); + await loadAllData(); + } catch (error) { + toast(error.message || 'Gagal menyimpan data keluarga.'); + } +} + +async function simpanRumahIbadah() { + if (!caps.canManageRI) { + toast('Kamu tidak punya akses manajemen rumah ibadah.'); + return; + } + + const id = Number(getValue('riId') || 0); + if (!id && !caps.canCreateRI) { + toast('Pengurus hanya dapat memperbarui rumah ibadah yang sudah terhubung dengan akunnya.'); + return; + } + + const payload = { + id, + nama: getValue('riNama'), + alamat: getValue('riAlamat'), + kategori: getValue('riKategori'), + pengurus: getValue('riPengurus'), + kontak: getValue('riKontak'), + dana_sosial: Number(getValue('riDana') || 0), + kuota: Number(getValue('riKuota') || 0), + radius: Number(getValue('riRadius') || 500), + lat: getValue('riLat'), + lng: getValue('riLng'), + status: 'Aktif' + }; + + if (!payload.nama || !payload.alamat || !payload.lat || !payload.lng) { + toast('Lengkapi nama, alamat, dan titik koordinat rumah ibadah.'); + return; + } + + try { + if (id > 0) { + await apiPut('api/rumah_ibadah.php', payload); + toast(caps.canAnalyze ? 'Data rumah ibadah berhasil diperbarui. Jalankan analisis ulang agar radius baru diterapkan.' : 'Perubahan layanan tersimpan. Status tanggungan akan mengikuti analisis terbaru dari pengelola.'); + } else { + await apiPost('api/rumah_ibadah.php', payload); + toast('Rumah ibadah baru berhasil disimpan.'); + resetFormRumahIbadah(); + } + await loadAllData(); + } catch (error) { + toast(error.message || 'Gagal menyimpan rumah ibadah.'); + } +} + +function fillRumahIbadahForm(id) { + const ri = rumahIbadah.find(r => Number(r.id) === Number(id)); + if (!ri) return; + if (!canEditRumahIbadah(ri)) { + toast('Kamu tidak berwenang mengubah rumah ibadah ini.'); + return; + } + setValue('riId', ri.id); + setValue('riNama', ri.nama || ''); + setValue('riAlamat', ri.alamat || ''); + setValue('riKategori', ri.kategori || 'Masjid'); + setValue('riPengurus', ri.pengurus || ''); + setValue('riKontak', ri.kontak || ''); + setValue('riDana', Number(ri.dana_sosial || 0)); + setValue('riKuota', Number(ri.kuota || 0)); + setValue('riRadius', Number(ri.radius || 500)); + setText('riRadiusLabel', Number(ri.radius || 500)); + setValue('riLat', ri.lat); + setValue('riLng', ri.lng); + focusPoint(ri.lat, ri.lng, 16); +} + +function resetFormRumahIbadah() { + if (!caps.canCreateRI) { + toast('Pengurus tidak dapat membuat rumah ibadah baru.'); + return; + } + ['riId','riNama','riAlamat','riPengurus','riKontak','riDana','riKuota','riLat','riLng'].forEach(id => setValue(id, '')); + setValue('riKategori', 'Masjid'); + setValue('riRadius', 500); + setText('riRadiusLabel', 500); + if (selectedMarker) { + map.removeLayer(selectedMarker); + selectedMarker = null; + } + const box = document.getElementById('anggotaContainer'); + if (box) { + box.innerHTML = ''; + addAnggotaForm({ hubungan: 'Kepala Keluarga' }); + } +} + +async function jalankanAnalisis() { + if (!caps.canAnalyze) { + toast('Analisis hanya dapat dijalankan oleh Admin atau Dinas Sosial.'); + return; + } + try { + const result = await apiPost('api/analisis.php', {}); + toast(result.message || 'Analisis selesai.'); + await loadAllData(); + } catch (error) { + toast(error.message || 'Analisis gagal.'); + } +} + +async function verifikasiKeluarga(id, status) { + if (!caps.canVerify) return toast('Kamu tidak punya akses validasi.'); + if (!confirm(`Ubah status verifikasi menjadi ${status}?`)) return; + const catatan = prompt('Catatan verifikasi, boleh dikosongkan:', '') || ''; + + try { + const result = await apiPut('api/keluarga.php', { id, status_verifikasi: status, catatan }); + toast(result.message || 'Status diperbarui.'); + await loadAllData(); + } catch (error) { + toast(error.message || 'Gagal memperbarui status.'); + } +} + +async function catatBantuan(id) { + const kel = keluarga.find(k => Number(k.id) === Number(id)); + if (!kel || !canRecordBantuanFor(kel)) { + toast('Kamu tidak berwenang mencatat bantuan untuk data ini.'); + return; + } + + const tanggal = prompt('Tanggal bantuan (YYYY-MM-DD):', new Date().toISOString().slice(0, 10)); + if (!tanggal) return; + const jenis = prompt('Jenis bantuan:', kel.kebutuhan || 'Sembako'); + if (!jenis) return; + const nilai = prompt('Estimasi nilai bantuan (angka):', '0') || '0'; + const catatan = prompt('Catatan bantuan:', '') || ''; + + try { + await apiPost('api/bantuan.php', { + keluarga_id: id, + tanggal_bantuan: tanggal, + jenis_bantuan: jenis, + nilai_bantuan: Number(nilai), + catatan + }); + toast('Riwayat bantuan berhasil dicatat.'); + } catch (error) { + toast(error.message || 'Gagal mencatat bantuan.'); + } +} + +function buatRute(id) { + const kel = keluarga.find(k => Number(k.id) === Number(id)); + if (!kel || !kel.ri_lat || !kel.ri_lng) { + toast('Data rumah ibadah penanggung belum tersedia.'); + return; + } + + if (routingControl) map.removeControl(routingControl); + routingControl = L.Routing.control({ + waypoints: [ + L.latLng(Number(kel.ri_lat), Number(kel.ri_lng)), + L.latLng(Number(kel.lat), Number(kel.lng)) + ], + routeWhileDragging: false, + addWaypoints: false, + show: false, + lineOptions: { styles: [{ color: '#7c3aed', weight: 5, opacity: 0.85 }] } + }).addTo(map); + map.setView([Number(kel.lat), Number(kel.lng)], 15); + toast('Rute distribusi bantuan ditampilkan.'); +} + +async function hapusKeluarga(id) { + if (!caps.canCreateRI) return toast('Hanya Admin/Dinas yang bisa menghapus data keluarga.'); + if (!confirm('Hapus data keluarga ini?')) return; + try { + const res = await fetch(`api/keluarga.php?id=${id}`, { method: 'DELETE' }); + const json = await res.json(); + if (!json.success) throw new Error(json.message); + toast('Data keluarga dihapus.'); + await loadAllData(); + } catch (error) { + toast(error.message || 'Gagal menghapus data.'); + } +} + +async function hapusRumahIbadah(id) { + if (!caps.canCreateRI) return toast('Hanya Admin/Dinas yang bisa menghapus rumah ibadah.'); + if (!confirm('Hapus data rumah ibadah ini? Data keluarga yang ditanggung akan dilepas.')) return; + try { + const res = await fetch(`api/rumah_ibadah.php?id=${id}`, { method: 'DELETE' }); + const json = await res.json(); + if (!json.success) throw new Error(json.message); + toast('Data rumah ibadah dihapus.'); + await loadAllData(); + } catch (error) { + toast(error.message || 'Gagal menghapus data.'); + } +} + +function focusKeluarga(id) { + const kel = keluarga.find(k => Number(k.id) === Number(id)); + if (!kel) return; + focusPoint(kel.lat, kel.lng, 17); +} + +function focusPoint(lat, lng, zoom = 16) { + map.setView([Number(lat), Number(lng)], zoom); +} + +function fitAllMarkers() { + const points = []; + rumahIbadah.forEach(ri => points.push([Number(ri.lat), Number(ri.lng)])); + keluarga.forEach(k => points.push([Number(k.lat), Number(k.lng)])); + if (!points.length) return toast('Belum ada titik pada peta.'); + map.fitBounds(points, { padding: [40, 40] }); +} + +function filterTanggunganRI(id) { + setValue('filterStatus', 'semua'); + setValue('searchText', ''); + setActiveTab('peta'); + const rows = keluarga.filter(k => Number(k.rumah_ibadah_id) === Number(id)); + const box = document.getElementById('listKeluarga'); + if (box) { + box.innerHTML = rows.length ? rows.map(k => keluargaCard(k, true)).join('') : '
Belum ada keluarga yang ditanggung rumah ibadah ini.
'; + } + const ri = rumahIbadah.find(r => Number(r.id) === Number(id)); + if (ri) focusPoint(ri.lat, ri.lng, 15); +} + +function locateMe() { + if (!navigator.geolocation) return toast('Browser tidak mendukung geolocation.'); + navigator.geolocation.getCurrentPosition( + pos => { + const lat = pos.coords.latitude; + const lng = pos.coords.longitude; + map.setView([lat, lng], 17); + const activeTab = document.querySelector('.tab.active')?.dataset.tab || 'input'; + if (activeTab === 'rumah-ibadah' && caps.canCreateRI) { + setValue('riLat', lat.toFixed(7)); + setValue('riLng', lng.toFixed(7)); + } else if (activeTab === 'musibah') { + setValue('musibahLat', lat.toFixed(7)); + setValue('musibahLng', lng.toFixed(7)); + fillAddress(lat.toFixed(7), lng.toFixed(7), 'musibahAlamat'); + } else { + setValue('lat', lat.toFixed(7)); + setValue('lng', lng.toFixed(7)); + } + if (selectedMarker) map.removeLayer(selectedMarker); + selectedMarker = L.marker([lat, lng]).addTo(map).bindPopup('Lokasi kamu').openPopup(); + toast('Lokasi berhasil diambil.'); + }, + () => toast('Gagal mengambil lokasi. Izinkan akses lokasi di browser.') + ); +} + +function bersihkanFormKeluarga() { + ['kelNama','kelAlamat','kelNoKK','kelNik','kelCatatan','lat','lng'].forEach(id => setValue(id, '')); + setValue('kelKerentanan', 'Miskin Ekstrem'); + setValue('kelKebutuhan', 'Sembako'); + setValue('kelStatusVerifikasi', 'Menunggu Verifikasi'); + if (selectedMarker) { + map.removeLayer(selectedMarker); + selectedMarker = null; + } +} + + +function initAnggotaDefault() { + const box = document.getElementById('anggotaContainer'); + if (box && !box.children.length) addAnggotaForm({ hubungan: 'Kepala Keluarga' }); +} + +function addAnggotaForm(defaults = {}) { + const box = document.getElementById('anggotaContainer'); + if (!box) return; + const index = box.children.length + 1; + const div = document.createElement('div'); + div.className = 'anggota-card'; + div.innerHTML = ` +
Anggota ${index}
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + `; + div.querySelector('.btn-remove-anggota').addEventListener('click', () => { + if (box.children.length <= 1) return toast('Minimal harus ada satu anggota keluarga.'); + div.remove(); + }); + box.appendChild(div); +} + +function collectAnggotaForm() { + return Array.from(document.querySelectorAll('#anggotaContainer .anggota-card')).map(card => { + const data = {}; + card.querySelectorAll('[data-field]').forEach(el => data[el.dataset.field] = String(el.value || '').trim()); + return data; + }).filter(a => a.nama); +} + +function populateMusibahKeluarga() { + const select = document.getElementById('musibahKeluarga'); + if (!select) return; + const current = select.value; + select.innerHTML = '' + keluarga.map(k => ``).join(''); + if (current) select.value = current; +} + +function isiLokasiMusibahDariKeluarga() { + const id = Number(getValue('musibahKeluarga') || 0); + if (!id) return; + const kel = keluarga.find(k => Number(k.id) === id); + if (!kel) return; + setValue('musibahAlamat', kel.alamat || ''); + setValue('musibahLat', kel.lat || ''); + setValue('musibahLng', kel.lng || ''); + focusPoint(kel.lat, kel.lng, 17); +} + +function buatLaporanMusibahUntuk(id) { + setActiveTab('musibah'); + setValue('musibahKeluarga', id); + isiLokasiMusibahDariKeluarga(); + toast('Lengkapi jenis musibah dan kebutuhan mendesak, lalu kirim laporan.'); +} + +async function simpanMusibah() { + const keluargaId = Number(getValue('musibahKeluarga') || 0); + const payload = { + keluarga_id: keluargaId || null, + jenis_musibah: getValue('musibahJenis'), + tingkat_urgensi: getValue('musibahUrgensi'), + tanggal_kejadian: getValue('musibahTanggal') || new Date().toISOString().slice(0, 10), + alamat: getValue('musibahAlamat'), + lat: getValue('musibahLat'), + lng: getValue('musibahLng'), + deskripsi: getValue('musibahDeskripsi'), + kebutuhan_mendesak: getValue('musibahKebutuhan') + }; + if (!payload.deskripsi) return toast('Kronologi singkat wajib diisi.'); + if (!payload.alamat || !payload.lat || !payload.lng) return toast('Pilih keluarga terkait atau klik lokasi kejadian pada peta.'); + try { + await apiPost('api/musibah.php', payload); + toast('Laporan musibah berhasil dikirim.'); + ['musibahDeskripsi','musibahKebutuhan','musibahAlamat','musibahLat','musibahLng'].forEach(id => setValue(id, '')); + setValue('musibahKeluarga', ''); + await loadAllData(); + } catch (error) { + toast(error.message || 'Gagal mengirim laporan musibah.'); + } +} + +async function updateMusibah(id, status) { + const tindak_lanjut = prompt('Catatan tindak lanjut:', '') || ''; + try { + await apiPut('api/musibah.php', { id, status, tindak_lanjut }); + toast('Status musibah diperbarui.'); + await loadAllData(); + } catch (error) { + toast(error.message || 'Gagal memperbarui laporan musibah.'); + } +} + +async function tambahProgramPemberdayaan(anggotaId) { + const anggota = pemberdayaan.find(a => Number(a.id) === Number(anggotaId)); + if (!anggota) return; + const nama_program = prompt('Nama program pemberdayaan:', anggota.keahlian ? `Program ${anggota.keahlian}` : 'Pelatihan kerja dasar'); + if (!nama_program) return; + const jenis_program = prompt('Jenis program (Pelatihan Kerja/Modal Usaha/Pendampingan UMKM/Penempatan Kerja/Bantuan Alat Kerja/Lainnya):', 'Pelatihan Kerja') || 'Pelatihan Kerja'; + const penyelenggara = prompt('Penyelenggara:', role === 'rumah_ibadah' ? 'Rumah Ibadah' : 'Dinas Sosial') || ''; + const catatan = prompt('Catatan:', `Keahlian/minat: ${anggota.keahlian || '-'}`) || ''; + try { + await apiPost('api/pemberdayaan.php', { + anggota_id: anggotaId, + jenis_program, + nama_program, + penyelenggara, + tanggal_mulai: new Date().toISOString().slice(0, 10), + status: 'Direkomendasikan', + catatan + }); + toast('Program pemberdayaan berhasil dicatat.'); + await loadAllData(); + } catch (error) { + toast(error.message || 'Gagal mencatat program pemberdayaan.'); + } +} + +async function apiPost(url, payload) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const json = await res.json(); + if (!json.success) throw new Error(json.message || 'Request gagal.'); + return json; +} + +async function apiPut(url, payload) { + const res = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const json = await res.json(); + if (!json.success) throw new Error(json.message || 'Request gagal.'); + return json; +} + +function onClick(id, handler) { + const el = document.getElementById(id); + if (el) el.addEventListener('click', handler); +} + +function onInput(id, handler) { + const el = document.getElementById(id); + if (el) { + el.addEventListener('input', handler); + el.addEventListener('change', handler); + } +} + +function getValue(id) { + const el = document.getElementById(id); + return el ? String(el.value ?? '').trim() : ''; +} + +function setValue(id, value) { + const el = document.getElementById(id); + if (el) el.value = value; +} + +function setText(id, value) { + const el = document.getElementById(id); + if (el) el.textContent = value; +} + +function rupiah(value) { + const number = Number(value || 0); + return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(number); +} + +function escapeHtml(str) { + return String(str ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function toast(message) { + const el = document.getElementById('toast'); + if (!el) return alert(message); + el.textContent = message; + el.classList.add('show'); + setTimeout(() => el.classList.remove('show'), 3200); +} + +window.addEventListener('DOMContentLoaded', async () => { + initMap(); + initUI(); + await loadAllData(); +}); + +// Global untuk popup Leaflet. +window.setActiveTab = setActiveTab; +window.fillRumahIbadahForm = fillRumahIbadahForm; +window.buatRute = buatRute; +window.verifikasiKeluarga = verifikasiKeluarga; +window.catatBantuan = catatBantuan; +window.hapusKeluarga = hapusKeluarga; +window.hapusRumahIbadah = hapusRumahIbadah; +window.focusKeluarga = focusKeluarga; +window.focusPoint = focusPoint; +window.filterTanggunganRI = filterTanggunganRI; +window.approveUser = approveUser; +window.rejectUser = rejectUser; +window.deleteUser = deleteUser; +window.buatLaporanMusibahUntuk = buatLaporanMusibahUntuk; +window.updateMusibah = updateMusibah; +window.tambahProgramPemberdayaan = tambahProgramPemberdayaan;