/** * Poverty-Mapping GIS โ€” script.js * WebGIS Distribusi Bantuan Sosial */ // ============================================================ // CONFIG // ============================================================ const MAP_CENTER = [-0.0532, 109.3458]; const MAP_ZOOM = 15; const DEFAULT_RADIUS = 300; // ============================================================ // ROLE SYSTEM // ============================================================ let currentRole = 'admin'; const ROLE_CONFIG = { admin: { label: 'Admin', icon: '๐Ÿ‘‘', canAdd: true, canEdit: true, canDelete: true, canReset: true, canReport: true, canViewReport: true, canViewDetail: true, canEditKas: true, canDragRadius: true }, surveyer: { label: 'Surveyer', icon: '๐Ÿ“‹', canAdd: true, canEdit: true, canDelete: false, canReset: false, canReport: true, canViewReport: true, canViewDetail: true, canEditKas: false, canDragRadius: false }, viewer: { label: 'Viewer', icon: '๐Ÿ‘', canAdd: false, canEdit: false, canDelete: false, canReset: false, canReport: true, canViewReport: false, canViewDetail: true, canEditKas: false, canDragRadius: false } }; function can(action) { return ROLE_CONFIG[currentRole]?.[action] === true; } function openRoleModal() { document.getElementById('roleModal').classList.remove('hidden'); document.querySelectorAll('.role-card').forEach(c => c.classList.remove('active-role')); document.getElementById(`roleCard_${currentRole}`)?.classList.add('active-role'); } function closeRoleModal() { document.getElementById('roleModal').classList.add('hidden'); } function setRole(role) { currentRole = role; const cfg = ROLE_CONFIG[role]; document.getElementById('roleIcon').textContent = cfg.icon; document.getElementById('roleLabel').textContent = cfg.label; document.querySelectorAll('.role-card').forEach(c => c.classList.remove('active-role')); document.getElementById(`roleCard_${role}`)?.classList.add('active-role'); applyRoleUI(); closeRoleModal(); } function applyRoleUI() { const cfg = ROLE_CONFIG[currentRole]; const btnGroup = document.getElementById('actionBtnGroup'); if (btnGroup) btnGroup.style.display = cfg.canAdd ? 'flex' : 'none'; const btnReset = document.getElementById('btnReset'); if (btnReset) btnReset.style.display = cfg.canReset ? 'block' : 'none'; const notice = document.getElementById('viewerNotice'); if (notice) notice.style.display = currentRole === 'viewer' ? 'block' : 'none'; const reportColList = document.getElementById('reportColList'); const reportColViewer = document.getElementById('reportColViewer'); if (cfg.canViewReport) { if (reportColList) reportColList.style.display = 'flex'; if (reportColViewer) reportColViewer.style.display = 'none'; } else { if (reportColList) reportColList.style.display = 'none'; if (reportColViewer) reportColViewer.style.display = 'flex'; } updateReportBadge(); centers.forEach(c => { if (c.marker) c.marker.setPopupContent(buildCenterPopup(c)); }); houses.forEach(h => { if (h.marker) h.marker.setPopupContent(buildHouseMapPopup(h)); }); updateSidebar(); centers.forEach(c => { if (c.handle) { if (cfg.canDragRadius) c.handle.dragging?.enable(); else c.handle.dragging?.disable(); } if (c.marker) { if (cfg.canEdit) c.marker.dragging?.enable(); else c.marker.dragging?.disable(); } }); } // ============================================================ // NAVIGATION // ============================================================ function navigateTo(page) { const pagePeta = document.getElementById('pagePeta'); const pagePelaporan = document.getElementById('pagePelaporan'); const navHome = document.getElementById('navHome'); const navReport = document.getElementById('navReport'); if (page === 'map') { pagePeta.classList.add('active-page'); pagePelaporan.classList.remove('active-page'); navHome.classList.add('active'); navReport.classList.remove('active'); setTimeout(() => map.invalidateSize(), 100); } else { pagePeta.classList.remove('active-page'); pagePelaporan.classList.add('active-page'); navHome.classList.remove('active'); navReport.classList.add('active'); applyRoleUI(); renderReportList(); } } // ============================================================ // MAP INIT // ============================================================ const map = L.map('map', { zoomControl: false }).setView(MAP_CENTER, MAP_ZOOM); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: 'ยฉ OpenStreetMap', maxZoom: 19 }).addTo(map); L.control.zoom({ position: 'bottomright' }).addTo(map); // ============================================================ // STATE // ============================================================ let isAddingCenter = false; let isAddingHouse = false; let centers = []; let houses = []; let reports = []; // ============================================================ // ICON TYPES // ============================================================ const CENTER_TYPE_MAP = { 'masjid': { emoji: '๐Ÿ•Œ', color: '#0d9488' }, 'musholla': { emoji: '๐Ÿ•Œ', color: '#0d9488' }, 'surau': { emoji: '๐Ÿ•Œ', color: '#0d9488' }, 'gereja katedral': { emoji: 'โ›ช', color: '#2563eb' }, 'gereja katolik': { emoji: 'โ›ช', color: '#2563eb' }, 'katedral': { emoji: 'โ›ช', color: '#2563eb' }, 'gereja protestan': { emoji: 'โœ๏ธ', color: '#7c3aed' }, 'gereja': { emoji: 'โ›ช', color: '#1d4ed8' }, 'kapel': { emoji: 'โœ๏ธ', color: '#7c3aed' }, 'vihara': { emoji: '๐Ÿ›•', color: '#d97706' }, 'klenteng': { emoji: '๐Ÿ›•', color: '#d97706' }, 'pura': { emoji: '๐Ÿ›•', color: '#d97706' }, 'kuil': { emoji: '๐Ÿ›•', color: '#d97706' }, 'sinagog': { emoji: 'โœก๏ธ', color: '#1d4ed8' }, 'default': { emoji: '๐Ÿ›๏ธ', color: '#64748b' } }; function getCenterType(name) { if (!name) return 'default'; const lower = name.toLowerCase(); const keys = Object.keys(CENTER_TYPE_MAP) .filter(k => k !== 'default') .sort((a, b) => b.length - a.length); for (const k of keys) { if (lower.includes(k)) return k; } return 'default'; } function createCenterIcon(name) { const info = CENTER_TYPE_MAP[getCenterType(name)] || CENTER_TYPE_MAP['default']; return L.divIcon({ className: '', html: `
${info.emoji}
`, iconSize: [38, 38], iconAnchor: [10, 38] }); } function createHouseIcon(aidStatus, hasData) { const palette = { helped: { bg: '#d97706', border: '#78350f', emoji: '๐Ÿ ' }, not_helped: { bg: '#dc2626', border: '#7f1d1d', emoji: '๐Ÿš' }, outside: { bg: '#16a34a', border: '#14532d', emoji: '๐Ÿ ' }, nodata: { bg: '#94a3b8', border: '#475569', emoji: '๐Ÿ“' } }; const key = !hasData ? 'nodata' : (aidStatus || 'outside'); const c = palette[key] || palette.nodata; return L.divIcon({ className: '', html: `
${c.emoji}
`, iconSize: [24, 24], iconAnchor: [6, 24] }); } function createHandleIcon() { return L.divIcon({ className: 'radius-handle', html: `
`, iconSize: [14, 14], iconAnchor: [7, 7] }); } // ============================================================ // UTILS // ============================================================ function haversineDistance(lat1, lng1, lat2, lng2) { const R = 6371000; const ฯ†1 = lat1 * Math.PI / 180, ฯ†2 = lat2 * Math.PI / 180; const ฮ”ฯ† = (lat2 - lat1) * Math.PI / 180; const ฮ”ฮป = (lng2 - lng1) * Math.PI / 180; const a = Math.sin(ฮ”ฯ† / 2) ** 2 + Math.cos(ฯ†1) * Math.cos(ฯ†2) * Math.sin(ฮ”ฮป / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } function radiusPoint(lat, lng, dist, bearing = 90) { const R = 6371000; const ฯ†1 = lat * Math.PI / 180, ฮป1 = lng * Math.PI / 180; const brng = bearing * Math.PI / 180; const ฯ†2 = Math.asin(Math.sin(ฯ†1) * Math.cos(dist / R) + Math.cos(ฯ†1) * Math.sin(dist / R) * Math.cos(brng)); const ฮป2 = ฮป1 + Math.atan2(Math.sin(brng) * Math.sin(dist / R) * Math.cos(ฯ†1), Math.cos(dist / R) - Math.sin(ฯ†1) * Math.sin(ฯ†2)); return L.latLng(ฯ†2 * 180 / Math.PI, ฮป2 * 180 / Math.PI); } async function reverseGeocode(lat, lng) { try { const res = await fetch( `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&addressdetails=1`, { headers: { 'Accept-Language': 'id' } } ); const data = await res.json(); const addr = data.address || {}; return { full: data.display_name || '', road: addr.road || addr.pedestrian || '', village: addr.village || addr.suburb || addr.neighbourhood || '', subdistrict: addr.city_district || addr.town || '', city: addr.city || addr.county || '', postcode: addr.postcode || '' }; } catch { return { full: `${lat.toFixed(5)}, ${lng.toFixed(5)}`, road: '', village: '', subdistrict: '', city: '', postcode: '' }; } } function formatRupiah(num) { if (num === null || num === undefined || num === '') return 'โ€”'; return 'Rp ' + Number(num).toLocaleString('id-ID'); } function formatDate(d) { if (!d) return 'โ€”'; const dt = new Date(d); if (isNaN(dt)) return d; return dt.toLocaleDateString('id-ID', { day: '2-digit', month: 'long', year: 'numeric' }); } function calcAge(tglLahir) { if (!tglLahir) return null; const today = new Date(), birth = new Date(tglLahir); let age = today.getFullYear() - birth.getFullYear(); if (today < new Date(today.getFullYear(), birth.getMonth(), birth.getDate())) age--; return age; } // ============================================================ // AID STATUS LOGIC // ============================================================ function getAidStatus(house) { if (!house.hasData) return 'outside'; if (house.aidStatus === 'helped') return 'helped'; for (const c of centers) { if (haversineDistance(house.lat, house.lng, c.lat, c.lng) <= c.radius) return 'not_helped'; } return 'outside'; } function getCoveringCenters(house) { return centers.filter(c => haversineDistance(house.lat, house.lng, c.lat, c.lng) <= c.radius); } function findNearestCenter(house) { let nearest = null, minDist = Infinity; centers.forEach(c => { const d = haversineDistance(house.lat, house.lng, c.lat, c.lng); if (d < minDist) { minDist = d; nearest = c; } }); return { center: nearest, distance: Math.round(minDist) }; } function updateAllHouseIcons() { houses.forEach(h => { const status = getAidStatus(h); if (h.aidStatus !== 'helped') h.aidStatus = (status === 'not_helped') ? 'not_helped' : 'outside'; h.marker.setIcon(createHouseIcon(h.aidStatus, h.hasData)); }); updateStats(); } // ============================================================ // STATS // ============================================================ function updateStats() { const total = houses.filter(h => h.hasData).length; const helped = houses.filter(h => h.aidStatus === 'helped').length; const inside = houses.filter(h => getAidStatus(h) === 'not_helped').length; const outside = houses.filter(h => hasData && getAidStatus(h) === 'outside').length; document.getElementById('statTotal').textContent = houses.length; document.getElementById('statHelped').textContent = helped; document.getElementById('statInside').textContent = inside; document.getElementById('statOutside').textContent = houses.filter(h => h.hasData && getAidStatus(h) === 'outside').length; } // ============================================================ // RADIUS DRAG HANDLE // ============================================================ function addRadiusHandle(centerObj) { if (centerObj.handle) map.removeLayer(centerObj.handle); const tooltip = document.getElementById('radiusTooltip'); const handle = L.marker(radiusPoint(centerObj.lat, centerObj.lng, centerObj.radius), { icon: createHandleIcon(), draggable: true, zIndexOffset: 500 }).addTo(map); handle.on('drag', function(e) { if (!can('canDragRadius')) return; centerObj.radius = Math.max(50, Math.round( haversineDistance(centerObj.lat, centerObj.lng, e.target.getLatLng().lat, e.target.getLatLng().lng) )); centerObj.circle.setRadius(centerObj.radius); tooltip.textContent = `โญ• Radius: ${centerObj.radius} m`; tooltip.classList.remove('hidden'); updateAllHouseIcons(); updateSidebar(); }); handle.on('dragend', function() { if (!can('canDragRadius')) { handle.setLatLng(radiusPoint(centerObj.lat, centerObj.lng, centerObj.radius)); return; } handle.setLatLng(radiusPoint(centerObj.lat, centerObj.lng, centerObj.radius)); tooltip.classList.add('hidden'); saveCenterBackend(centerObj, true); if (centerObj.marker.isPopupOpen()) centerObj.marker.setPopupContent(buildCenterPopup(centerObj)); }); centerObj.handle = handle; } // ============================================================ // POPUPS // ============================================================ function buildHouseMapPopup(house) { const status = getAidStatus(house); const badgeCls = !house.hasData ? 'nodata' : status; const badgeTxt = !house.hasData ? 'Belum ada data' : status === 'helped' ? 'Sudah Dibantu' : status === 'not_helped' ? 'Dalam Radius' : 'Luar Radius'; const kkName = house.anggota?.[0]?.nama || ''; let actionBtns = ''; if (house.hasData) { actionBtns = ``; if (can('canEdit')) actionBtns += ``; } else { if (can('canEdit')) actionBtns = ``; else actionBtns = `Belum ada data`; } const deleteBtn = can('canDelete') ? `` : ''; return `
โ— ${badgeTxt}
๐Ÿ  Rumah #${house.id}${kkName ? ' โ€” ' + kkName : ''}
${house.address || 'Mengambil alamat...'}
${actionBtns}${deleteBtn}
`; } function buildCenterPopup(c) { const covered = houses.filter(h => haversineDistance(h.lat, h.lng, c.lat, c.lng) <= c.radius); const helped = covered.filter(h => h.aidStatus === 'helped').length; const info = CENTER_TYPE_MAP[getCenterType(c.name)] || CENTER_TYPE_MAP['default']; const kasEditHtml = can('canEditKas') ? ` ` : ''; const kasFormHtml = can('canEditKas') ? ` ` : ''; const editBtn = can('canEdit') ? `
` : ''; const deleteBtn = can('canDelete') ? `
` : ''; return `
${info.emoji}
${c.name}
๐Ÿ“ ${c.address || 'โ€”'}
๐Ÿ’ฐ Kas ${formatRupiah(c.kas)} ${kasEditHtml}
${kasFormHtml}
โญ• Radius${c.radius} m
๐Ÿ  Dalam radius${covered.length} (${helped} dibantu)
${can('canDragRadius') ? '๐Ÿ’ก Drag titik biru untuk ubah radius' : '๐Ÿ‘ Radius hanya bisa dilihat'}
${editBtn}${deleteBtn}`; } // ============================================================ // KAS EDIT (popup inline) // ============================================================ function toggleEditKas(centerId) { const form = document.getElementById(`kasEditForm_${centerId}`); const badge = document.getElementById(`kasBadge_${centerId}`); if (!form) return; const visible = form.style.display !== 'none'; form.style.display = visible ? 'none' : 'block'; badge.style.display = visible ? 'flex' : 'none'; if (!visible) setTimeout(() => document.getElementById(`kasInput_${centerId}`)?.focus(), 50); } function saveKas(centerId) { const center = centers.find(c => c.id === centerId); if (!center) return; center.kas = parseFloat(document.getElementById(`kasInput_${centerId}`)?.value) || 0; const kasVal = document.getElementById(`kasVal_${centerId}`); if (kasVal) kasVal.textContent = formatRupiah(center.kas); toggleEditKas(centerId); updateCenterList(); saveCenterBackend(center, true); } // ============================================================ // CENTER EDIT MODAL // ============================================================ function openCenterEditModal(centerId) { if (!can('canEdit')) { alert('Role Anda tidak dapat mengedit data.'); return; } const c = centers.find(c => c.id === centerId); if (!c) return; map.closePopup(); document.getElementById('ce_id').value = c.id; document.getElementById('ce_name').value = c.name; document.getElementById('ce_address').value = c.address || ''; document.getElementById('ce_kas').value = c.kas || 0; document.getElementById('ce_radius').value = c.radius; const sel = document.getElementById('ce_type'); const type = getCenterType(c.name); const match = Array.from(sel.options).find(o => o.value === type); sel.value = match ? type : 'default'; document.getElementById('centerEditModal').classList.remove('hidden'); setTimeout(() => document.getElementById('ce_name')?.focus(), 100); } function closeCenterEditModal() { document.getElementById('centerEditModal').classList.add('hidden'); } function saveCenterEdit() { const id = parseInt(document.getElementById('ce_id')?.value); const name = document.getElementById('ce_name')?.value?.trim(); const address = document.getElementById('ce_address')?.value?.trim(); const kas = parseFloat(document.getElementById('ce_kas')?.value) || 0; const radius = parseInt(document.getElementById('ce_radius')?.value) || DEFAULT_RADIUS; if (!name) { document.getElementById('ce_name').focus(); return; } if (radius < 50) { alert('Radius minimal 50 meter!'); return; } const center = centers.find(c => c.id === id); if (!center) return; center.name = name; center.address = address; center.kas = kas; center.radius = radius; center.marker.setIcon(createCenterIcon(name)); center.circle.setRadius(radius); if (center.handle) center.handle.setLatLng(radiusPoint(center.lat, center.lng, radius)); center.marker.setPopupContent(buildCenterPopup(center)); closeCenterEditModal(); updateAllHouseIcons(); updateSidebar(); saveCenterBackend(center, true); } // ============================================================ // ADD MODES // ============================================================ function startAddingCenter() { if (!can('canAdd')) { alert('Role Anda tidak dapat menambah data.'); return; } if (isAddingCenter) { cancelModes(); return; } cancelModes(); isAddingCenter = true; setModeUI('center'); } function startAddingHouse() { if (!can('canAdd')) { alert('Role Anda tidak dapat menambah data.'); return; } if (isAddingHouse) { cancelModes(); return; } cancelModes(); isAddingHouse = true; setModeUI('house'); } function setModeUI(mode) { const badge = document.getElementById('modeIndicator'); badge.classList.remove('hidden', 'house-mode'); map.getContainer().classList.add('adding-mode'); if (mode === 'center') { document.getElementById('modeIndicatorText').textContent = 'Klik peta untuk menempatkan rumah ibadah'; document.getElementById('btnAddCenter').classList.add('active'); document.getElementById('btnAddCenter').textContent = 'โœ• Batalkan'; } else { badge.classList.add('house-mode'); document.getElementById('modeIndicatorText').textContent = 'Klik peta untuk menempatkan titik rumah miskin'; document.getElementById('btnAddHouse').classList.add('active'); document.getElementById('btnAddHouse').textContent = 'โœ• Batalkan'; } } function cancelModes() { isAddingCenter = isAddingHouse = false; document.getElementById('modeIndicator').classList.add('hidden'); document.getElementById('modeIndicator').classList.remove('house-mode'); document.getElementById('btnAddCenter').classList.remove('active'); document.getElementById('btnAddCenter').innerHTML = '๐Ÿ•Œ Tambah Rumah Ibadah'; document.getElementById('btnAddHouse').classList.remove('active'); document.getElementById('btnAddHouse').innerHTML = '๐Ÿ  Tambah Rumah Miskin'; map.getContainer().classList.remove('adding-mode'); } map.on('click', async function(e) { if (!isAddingCenter && !isAddingHouse) return; const { lat, lng } = e.latlng; if (isAddingCenter) { cancelModes(); openCenterForm(lat, lng, e.latlng); } else { cancelModes(); placeHousePin(lat, lng); } }); // ============================================================ // CENTER FORM (map popup) // ============================================================ async function openCenterForm(lat, lng, latlng) { const popup = L.popup({ maxWidth: 290, closeOnClick: false }) .setLatLng(latlng) .setContent(`

๐Ÿ•Œ Tambah Rumah Ibadah

โณ Mengambil alamat...
`) .openOn(map); const geo = await reverseGeocode(lat, lng); const loadEl = document.getElementById('fpAddrLoading'); const addrEl = document.getElementById('fpAddr'); if (loadEl) loadEl.style.display = 'none'; if (addrEl) { addrEl.style.display = 'block'; addrEl.value = geo.full; } } async function submitCenter(lat, lng) { const name = document.getElementById('fpName')?.value?.trim(); const address = document.getElementById('fpAddr')?.value?.trim() || `${lat.toFixed(5)}, ${lng.toFixed(5)}`; const kas = parseFloat(document.getElementById('fpKas')?.value) || 0; const radius = parseInt(document.getElementById('fpRadius')?.value) || DEFAULT_RADIUS; if (!name) { document.getElementById('fpName')?.focus(); return; } map.closePopup(); addCenter({ name, address, kas, lat, lng, radius }); } function addCenter(data) { const marker = L.marker([data.lat, data.lng], { icon: createCenterIcon(data.name), draggable: can('canEdit'), zIndexOffset: 200 }).addTo(map); const circle = L.circle([data.lat, data.lng], { radius: data.radius, color: '#3b82f6', weight: 1.5, fillColor: '#3b82f6', fillOpacity: 0.07, dashArray: '6 4' }).addTo(map); const centerObj = { id: data.id || null, name: data.name, address: data.address, kas: data.kas || 0, lat: data.lat, lng: data.lng, radius: data.radius, marker, circle, handle: null }; marker.bindPopup(() => buildCenterPopup(centerObj), { maxWidth: 260 }); marker.on('popupopen', () => marker.setPopupContent(buildCenterPopup(centerObj))); marker.on('dragend', function(e) { centerObj.lat = e.target.getLatLng().lat; centerObj.lng = e.target.getLatLng().lng; circle.setLatLng([centerObj.lat, centerObj.lng]); if (centerObj.handle) centerObj.handle.setLatLng(radiusPoint(centerObj.lat, centerObj.lng, centerObj.radius)); updateAllHouseIcons(); updateSidebar(); saveCenterBackend(centerObj, true); }); addRadiusHandle(centerObj); if (!can('canDragRadius')) centerObj.handle?.dragging?.disable(); if (!can('canEdit')) marker.dragging?.disable(); centers.push(centerObj); if (!data.id) { saveCenterBackend(centerObj, false); } else { marker.openPopup(); updateAllHouseIcons(); updateSidebar(); } } async function saveCenterBackend(centerObj, isUpdate) { const body = new URLSearchParams({ id: centerObj.id || 0, name: centerObj.name, address: centerObj.address || '', kas: centerObj.kas, lat: centerObj.lat, lng: centerObj.lng, radius: centerObj.radius, update: isUpdate ? '1' : '0' }); try { const res = await fetch('simpan_pusat.php', { method: 'POST', body }); const data = await res.json(); if (data.success && !centerObj.id) { centerObj.id = data.id; centerObj.marker.setPopupContent(buildCenterPopup(centerObj)); centerObj.marker.openPopup(); updateAllHouseIcons(); updateSidebar(); } } catch (e) { console.error('saveCenterBackend', e); } } // ============================================================ // HOUSE PIN // ============================================================ async function placeHousePin(lat, lng) { const marker = L.marker([lat, lng], { icon: createHouseIcon(null, false), zIndexOffset: 100 }).addTo(map); const house = { id: null, lat, lng, address: '', rt: '', rw: '', kelurahan: '', statusMiskin: '', jumlahAnggota: 0, anggota: [], aidStatus: 'outside', hasData: false, marker, _geoData: null }; houses.push(house); marker.bindPopup(() => buildHouseMapPopup(house), { maxWidth: 270 }); marker.on('popupopen', () => marker.setPopupContent(buildHouseMapPopup(house))); marker.openPopup(); try { const geo = await reverseGeocode(lat, lng); house.address = geo.full || `${lat.toFixed(5)}, ${lng.toFixed(5)}`; house._geoData = geo; } catch { house.address = `${lat.toFixed(5)}, ${lng.toFixed(5)}`; } marker.setPopupContent(buildHouseMapPopup(house)); updateStats(); updateHouseList(); await saveHouseBackend(house, false); } async function saveHouseBackend(house, isUpdate) { const body = new URLSearchParams({ id: house.id || 0, lat: house.lat, lng: house.lng, address: house.address || '', rt: house.rt || '', rw: house.rw || '', kelurahan: house.kelurahan || '', status_miskin: house.statusMiskin || '', jumlah_anggota: house.jumlahAnggota || 0, anggota: JSON.stringify(house.anggota || []), aid_status: house.aidStatus || 'outside', has_data: house.hasData ? 1 : 0, update: isUpdate ? '1' : '0' }); try { const res = await fetch('simpan_rumah.php', { method: 'POST', body }); const data = await res.json(); if (data.success && !house.id) { house.id = data.id; house.marker.setPopupContent(buildHouseMapPopup(house)); updateHouseList(); } } catch (e) { console.error('saveHouseBackend', e); } } // ============================================================ // HOUSE DATA MODAL // ============================================================ function openHouseModal(houseId) { if (!can('canEdit')) { alert('Role Anda tidak dapat mengedit data.'); return; } const house = houses.find(h => h.id === houseId); if (!house) return; map.closePopup(); const covering = getCoveringCenters(house); const nearestR = findNearestCenter(house); const geo = house._geoData || {}; const centersHtml = covering.length > 0 ? covering.map(c => { const info = CENTER_TYPE_MAP[getCenterType(c.name)] || CENTER_TYPE_MAP['default']; return `${info.emoji} ${c.name}`; }).join('') : `Tidak ada โ€” Terdekat: ${nearestR.center ? nearestR.center.name + ' (' + nearestR.distance + ' m)' : 'โ€”'}`; document.getElementById('houseModalBody').innerHTML = ` `; if (house.jumlahAnggota > 0) renderMemberForms(houseId); if (house.statusMiskin) setTimeout(() => selectStatusMiskin(house.statusMiskin), 0); document.getElementById('houseModal').classList.remove('hidden'); const modal = document.getElementById('houseModal'); const oldBar = modal.querySelector('.modal-actions'); if (oldBar) oldBar.remove(); const bar = document.createElement('div'); bar.className = 'modal-actions'; bar.innerHTML = ` `; modal.querySelector('.modal-box').appendChild(bar); } function closeHouseModal() { document.getElementById('houseModal').classList.add('hidden'); } function selectStatusMiskin(val) { document.querySelectorAll('.status-pill').forEach(el => el.classList.remove('selected', 'sangat_miskin', 'miskin', 'tidak_miskin')); document.querySelector(`.status-pill[data-val="${val}"]`)?.classList.add('selected', val); const hidden = document.getElementById('hm_statusMiskin'); if (hidden) hidden.value = val; } function renderMemberForms(houseId) { const house = houses.find(h => h.id === houseId); const jumlah = parseInt(document.getElementById('hm_jumlah')?.value) || 0; const container = document.getElementById('memberFormsContainer'); if (!container) return; container.innerHTML = ''; for (let i = 0; i < Math.min(jumlah, 20); i++) { container.insertAdjacentHTML('beforeend', buildMemberForm(i, house?.anggota?.[i] || {})); } } function buildMemberForm(i, data = {}) { const statusOpts = ['Kepala Keluarga', 'Istri/Suami', 'Anak', 'Orang Tua', 'Saudara', 'Lainnya'] .map(s => ``).join(''); const pekerjaanOpts = ['Tidak Bekerja', 'Pelajar/Mahasiswa', 'Buruh/Karyawan', 'Wiraswasta', 'PNS/TNI/Polri', 'Petani/Nelayan', 'Lainnya'] .map(p => ``).join(''); const showSalary = data.pekerjaan && !['Tidak Bekerja', 'Pelajar/Mahasiswa'].includes(data.pekerjaan); const displayDate = data.tglLahir ? formatDate(data.tglLahir) : ''; return `
${i + 1}
Anggota ke-${i + 1}
`; } function toggleSalary(i) { const pekerjaan = document.getElementById(`m_pkrj_${i}`)?.value; const row = document.getElementById(`salary_row_${i}`); if (!row) return; const hide = ['Tidak Bekerja', 'Pelajar/Mahasiswa'].includes(pekerjaan); row.classList.toggle('visible', !hide); } async function saveHouseData(houseId) { const house = houses.find(h => h.id === houseId); if (!house) return; house.rt = document.getElementById('hm_rt')?.value?.trim() || ''; house.rw = document.getElementById('hm_rw')?.value?.trim() || ''; house.kelurahan = document.getElementById('hm_kel')?.value?.trim() || ''; house.statusMiskin = document.getElementById('hm_statusMiskin')?.value || ''; house.jumlahAnggota = parseInt(document.getElementById('hm_jumlah')?.value) || 0; house.anggota = []; for (let i = 0; i < house.jumlahAnggota; i++) { house.anggota.push({ nama: document.getElementById(`m_nama_${i}`)?.value?.trim() || '', statusAnggota: document.getElementById(`m_status_${i}`)?.value || '', tglLahir: document.getElementById(`m_tgl_${i}`)?.value || '', pekerjaan: document.getElementById(`m_pkrj_${i}`)?.value || '', gaji: parseFloat(document.getElementById(`m_gaji_${i}`)?.value) || 0 }); } house.hasData = house.jumlahAnggota > 0 || !!house.statusMiskin; const status = getAidStatus(house); if (house.aidStatus !== 'helped') house.aidStatus = status; house.marker.setIcon(createHouseIcon(house.aidStatus, house.hasData)); house.marker.setPopupContent(buildHouseMapPopup(house)); closeHouseModal(); updateStats(); updateHouseList(); await saveHouseBackend(house, true); } // ============================================================ // DETAIL MODAL // ============================================================ function openDetailModal(houseId) { const house = houses.find(h => h.id === houseId); if (!house) return; map.closePopup(); const covering = getCoveringCenters(house); const status = getAidStatus(house); const nearestR = findNearestCenter(house); const statusLabels = { sangat_miskin: '๐Ÿ˜ข Sangat Miskin', miskin: '๐Ÿ˜Ÿ Miskin', tidak_miskin: '๐Ÿ˜Š Tidak Miskin', '': 'โ€”' }; const aidLabels = { helped: 'โœ… Sudah Dibantu', not_helped: 'โณ Belum Dibantu', outside: '๐ŸŸข Luar Radius' }; const membersHtml = house.anggota.length > 0 ? `` + house.anggota.map((m, i) => { const age = calcAge(m.tglLahir); return ``; }).join('') + `
#NamaStatusUsiaPekerjaanPenghasilan
${i + 1} ${m.nama || 'โ€”'} ${m.statusAnggota || 'โ€”'} ${age !== null ? age + ' th' : 'โ€”'} ${m.pekerjaan || 'โ€”'} ${m.gaji ? formatRupiah(m.gaji) : 'โ€”'}
` : '
Belum ada data anggota keluarga.
'; const nearestInfo = nearestR.center ? `${nearestR.center.name} (${nearestR.distance} m)` : 'โ€”'; const aidBtns = can('canEdit') ? `
` : ''; document.getElementById('detailModalBody').innerHTML = `
๐Ÿ“ Lokasi
Alamat
${house.address || 'โ€”'}
RT / RW
${house.rt || 'โ€”'} / ${house.rw || 'โ€”'}
Kelurahan
${house.kelurahan || 'โ€”'}
Koordinat
${house.lat.toFixed(5)}, ${house.lng.toFixed(5)}
๐Ÿ“Š Status Bantuan
Kemiskinan
${statusLabels[house.statusMiskin] || 'โ€”'}
Status Bantuan
${aidLabels[status] || 'โ€”'}
Rumah Ibadah
${covering.length > 0 ? covering.map(c => c.name).join(', ') : 'Luar radius'}
Terdekat
${nearestInfo}
${aidBtns}
๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง Anggota Keluarga (${house.jumlahAnggota} orang)
${membersHtml}
`; document.getElementById('detailModal').classList.remove('hidden'); } function closeDetailModal() { document.getElementById('detailModal').classList.add('hidden'); } async function setAidStatus(houseId, newStatus, centerId) { const house = houses.find(h => h.id === houseId); if (!house) return; house.aidStatus = newStatus; house.marker.setIcon(createHouseIcon(house.aidStatus, house.hasData)); house.marker.setPopupContent(buildHouseMapPopup(house)); updateStats(); updateHouseList(); closeDetailModal(); try { const body = new URLSearchParams({ house_id: houseId, aid_status: newStatus, center_id: centerId }); await fetch('update_status.php', { method: 'POST', body }); } catch (e) { console.error('setAidStatus', e); } } // ============================================================ // DELETE // ============================================================ function deleteHouse(houseId) { if (!can('canDelete')) return; if (!confirm('Hapus data rumah ini?')) return; const idx = houses.findIndex(h => h.id === houseId); if (idx === -1) return; map.removeLayer(houses[idx].marker); houses.splice(idx, 1); updateStats(); updateHouseList(); fetch('hapus_rumah.php', { method: 'POST', body: new URLSearchParams({ id: houseId }) }); } function deleteCenter(centerId) { if (!can('canDelete')) return; if (!confirm('Hapus rumah ibadah ini beserta data terkait?')) return; const idx = centers.findIndex(c => c.id === centerId); if (idx === -1) return; map.removeLayer(centers[idx].marker); map.removeLayer(centers[idx].circle); if (centers[idx].handle) map.removeLayer(centers[idx].handle); centers.splice(idx, 1); map.closePopup(); updateAllHouseIcons(); updateSidebar(); fetch('hapus_pusat.php', { method: 'POST', body: new URLSearchParams({ id: centerId }) }); } // ============================================================ // RESET // ============================================================ function confirmReset() { if (!can('canReset')) return; if (!confirm('RESET semua data? Tindakan ini tidak dapat dibatalkan!')) return; centers.forEach(c => { map.removeLayer(c.marker); map.removeLayer(c.circle); if (c.handle) map.removeLayer(c.handle); }); houses.forEach(h => map.removeLayer(h.marker)); centers = []; houses = []; reports = []; updateStats(); updateSidebar(); fetch('reset.php', { method: 'POST' }); } // ============================================================ // SIDEBAR // ============================================================ function updateSidebar() { updateCenterList(); updateHouseList(); } function filterCenters() { const query = document.getElementById('searchCenter')?.value?.toLowerCase() || ''; const sort = document.getElementById('sortCenter')?.value || ''; let list = centers.filter(c => c.name.toLowerCase().includes(query) || (c.address || '').toLowerCase().includes(query)); if (sort === 'kas_desc') list.sort((a, b) => (b.kas || 0) - (a.kas || 0)); else if (sort === 'kas_asc') list.sort((a, b) => (a.kas || 0) - (b.kas || 0)); else if (sort === 'tanggungan_desc') list.sort((a, b) => countCovered(b) - countCovered(a)); else if (sort === 'tanggungan_asc') list.sort((a, b) => countCovered(a) - countCovered(b)); renderCenterList(list); } function countCovered(c) { return houses.filter(h => haversineDistance(h.lat, h.lng, c.lat, c.lng) <= c.radius).length; } function updateCenterList() { document.getElementById('centerCount').textContent = centers.length; filterCenters(); } function renderCenterList(list) { const el = document.getElementById('centerList'); if (list.length === 0) { el.innerHTML = '
Belum ada rumah ibadah.
'; return; } el.innerHTML = list.map(c => { const info = CENTER_TYPE_MAP[getCenterType(c.name)] || CENTER_TYPE_MAP['default']; const covered = countCovered(c); const helped = houses.filter(h => haversineDistance(h.lat, h.lng, c.lat, c.lng) <= c.radius && h.aidStatus === 'helped').length; const delBtn = can('canDelete') ? `` : ''; return `
${info.emoji}
${c.name}
${c.address || 'โ€”'}
โญ• ${c.radius}m ๐Ÿ  ${covered} ๐Ÿ’ฐ ${formatRupiah(c.kas)} ${helped > 0 ? `โœ… ${helped}` : ''}
${delBtn}
`; }).join(''); } function focusCenter(centerId) { const c = centers.find(c => c.id === centerId); if (!c) return; map.setView([c.lat, c.lng], 16); c.marker.openPopup(); } function filterHouses() { const query = document.getElementById('searchHouse')?.value?.toLowerCase() || ''; const filterStatus = document.getElementById('filterStatus')?.value || ''; const filterMiskin = document.getElementById('filterMiskin')?.value || ''; let list = houses.filter(h => { const kkName = h.anggota?.[0]?.nama?.toLowerCase() || ''; const addr = (h.address || '').toLowerCase(); const matchQ = !query || kkName.includes(query) || addr.includes(query); const matchS = !filterStatus || getAidStatus(h) === filterStatus; const matchM = !filterMiskin || h.statusMiskin === filterMiskin; return matchQ && matchS && matchM; }); renderHouseList(list); } function updateHouseList() { document.getElementById('houseCount').textContent = houses.length; filterHouses(); } function renderHouseList(list) { const el = document.getElementById('houseList'); if (list.length === 0) { el.innerHTML = '
Belum ada data.
'; return; } el.innerHTML = list.map(h => { const status = getAidStatus(h); const kkName = h.anggota?.[0]?.nama || `Rumah #${h.id}`; const addr = h.kelurahan || h.address?.split(',')[0] || 'โ€”'; const icon = h.hasData ? (status === 'helped' ? '๐Ÿ ' : status === 'not_helped' ? '๐Ÿš' : '๐Ÿ ') : '๐Ÿ“'; const delBtn = can('canDelete') ? `` : ''; const statusLabels = { helped: 'โœ… Dibantu', not_helped: 'โณ Belum', outside: '๐ŸŸข Luar', nodata: '๐Ÿ“ Pin' }; const miskinLabels = { sangat_miskin: '๐Ÿ˜ข Sangat Miskin', miskin: '๐Ÿ˜Ÿ Miskin', tidak_miskin: '๐Ÿ˜Š Tidak Miskin' }; return `
${icon}
${kkName}
${addr}
${statusLabels[h.hasData ? status : 'nodata'] || status} ${h.statusMiskin ? `${miskinLabels[h.statusMiskin]}` : ''}
${delBtn}
`; }).join(''); } function focusHouse(houseId) { const h = houses.find(h => h.id === houseId); if (!h) return; map.setView([h.lat, h.lng], 17); h.marker.openPopup(); } // ============================================================ // REPORTS // ============================================================ function updateReportBadge() { const badge = document.getElementById('navReportBadge'); const count = can('canViewReport') ? reports.filter(r => r.status === 'baru').length : 0; if (badge) { badge.textContent = count; badge.classList.toggle('hidden', count === 0); } } function filterReports() { const query = document.getElementById('searchReport')?.value?.toLowerCase() || ''; const status = document.getElementById('filterReportStatus')?.value || ''; const list = reports.filter(r => { const matchQ = !query || r.text.toLowerCase().includes(query) || (r.name || '').toLowerCase().includes(query); const matchS = !status || r.status === status; return matchQ && matchS; }); renderReportList(list); } function renderReportList(list) { if (!list) list = reports; document.getElementById('reportCount').textContent = reports.length; const el = document.getElementById('reportList'); if (list.length === 0) { el.innerHTML = '
Belum ada laporan.
'; return; } el.innerHTML = list.map(r => { const statusBadge = `${r.status === 'baru' ? '๐Ÿ†• Baru' : r.status === 'ditangani' ? '๐Ÿ”„ Ditangani' : 'โœ… Selesai'}`; const img = r.imgBase64 ? `` : ''; const actions = can('canDelete') ? `` : ''; const statusBtns = can('canViewReport') ? ` ` : ''; return `
๐Ÿ‘ค ${r.name || 'Anonim'}
${r.lokasi ? '๐Ÿ“ ' + r.lokasi + ' ยท ' : ''}${r.time}
${statusBadge}
${r.text}
${img}
`; }).join(''); } async function changeReportStatus(reportId, newStatus) { const r = reports.find(r => r.id === reportId); if (!r) return; r.status = newStatus; filterReports(); updateReportBadge(); try { await fetch('update_laporan.php', { method: 'POST', body: new URLSearchParams({ id: reportId, status: newStatus }) }); } catch (e) { console.error(e); } } async function deleteReport(reportId) { if (!can('canDelete')) return; if (!confirm('Hapus laporan ini?')) return; reports = reports.filter(r => r.id !== reportId); filterReports(); updateReportBadge(); try { await fetch('hapus_laporan.php', { method: 'POST', body: new URLSearchParams({ id: reportId }) }); } catch (e) { console.error(e); } } // ============================================================ // REPORT SUBMIT // ============================================================ let reportImgBase64 = ''; function previewImage(event) { const file = event.target.files[0]; if (!file) return; if (file.size > 5_000_000) { alert('Ukuran file maksimal 5MB'); return; } const reader = new FileReader(); reader.onload = e => { reportImgBase64 = e.target.result; document.getElementById('previewImg').src = e.target.result; document.getElementById('imgPreview').style.display = 'block'; document.getElementById('uploadArea').style.display = 'none'; }; reader.readAsDataURL(file); } function removeImage() { reportImgBase64 = ''; document.getElementById('reportImg').value = ''; document.getElementById('imgPreview').style.display = 'none'; document.getElementById('uploadArea').style.display = 'block'; } async function submitReport() { const text = document.getElementById('reportText')?.value?.trim(); const name = document.getElementById('reportName')?.value?.trim() || 'Anonim'; const lokasi = document.getElementById('reportLocation')?.value?.trim() || ''; if (!text) { document.getElementById('reportText')?.focus(); alert('Deskripsi laporan tidak boleh kosong'); return; } try { const body = new URLSearchParams({ name, text, lokasi, img: reportImgBase64 }); const res = await fetch('simpan_laporan.php', { method: 'POST', body }); const data = await res.json(); if (data.success) { const newReport = { id: data.id, name, text, lokasi, imgBase64: reportImgBase64 || null, status: 'baru', time: new Date().toLocaleDateString('id-ID', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) }; reports.unshift(newReport); document.getElementById('reportText').value = ''; document.getElementById('reportName').value = ''; document.getElementById('reportLocation').value = ''; removeImage(); filterReports(); updateReportBadge(); alert('Laporan berhasil dikirim! Terima kasih.'); } else { alert('Gagal kirim laporan: ' + (data.message || 'Error')); } } catch (e) { console.error(e); alert('Terjadi kesalahan. Coba lagi.'); } } // ============================================================ // DATE PICKER // ============================================================ let _dpTargetInput = null; let _dpTargetBtn = null; let _dpYear = new Date().getFullYear(); let _dpMonth = new Date().getMonth(); let _dpSelected = null; function openDatepicker(inputId, btnId) { _dpTargetInput = document.getElementById(inputId); _dpTargetBtn = document.getElementById(btnId); const existing = _dpTargetInput?.value; if (existing) { const d = new Date(existing); _dpYear = d.getFullYear(); _dpMonth = d.getMonth(); _dpSelected = existing; } else { _dpYear = new Date().getFullYear(); _dpMonth = new Date().getMonth(); _dpSelected = null; } renderDatepicker(); document.getElementById('datepickerOverlay').classList.remove('hidden'); } function closeDatepicker() { document.getElementById('datepickerOverlay').classList.add('hidden'); } function closeDatepickerIfOutside(e) { if (e.target.id === 'datepickerOverlay') closeDatepicker(); } function renderDatepicker() { const box = document.getElementById('datepickerBox'); const months = ['Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember']; const days = ['Min','Sen','Sel','Rab','Kam','Jum','Sab']; const firstDay = new Date(_dpYear, _dpMonth, 1).getDay(); const daysInMonth = new Date(_dpYear, _dpMonth + 1, 0).getDate(); const today = new Date(); let gridHtml = days.map(d => `
${d}
`).join(''); for (let i = 0; i < firstDay; i++) { const prevDays = new Date(_dpYear, _dpMonth, 0).getDate(); gridHtml += `
${prevDays - firstDay + i + 1}
`; } for (let d = 1; d <= daysInMonth; d++) { const dateStr = `${_dpYear}-${String(_dpMonth + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; const isToday = d === today.getDate() && _dpMonth === today.getMonth() && _dpYear === today.getFullYear(); const isSel = dateStr === _dpSelected; const isFuture = new Date(_dpYear, _dpMonth, d) > today; gridHtml += `
${d}
`; } box.innerHTML = `
${months[_dpMonth]} ${_dpYear}
${gridHtml}
`; } function dpNav(dir) { _dpMonth += dir; if (_dpMonth < 0) { _dpMonth = 11; _dpYear--; } if (_dpMonth > 11) { _dpMonth = 0; _dpYear++; } renderDatepicker(); } function selectDate(dateStr) { _dpSelected = dateStr; renderDatepicker(); } function confirmDate() { if (!_dpSelected || !_dpTargetInput) { closeDatepicker(); return; } _dpTargetInput.value = _dpSelected; if (_dpTargetBtn) _dpTargetBtn.textContent = formatDate(_dpSelected); closeDatepicker(); } // ============================================================ // LOAD DATA FROM BACKEND // ============================================================ async function loadData() { try { const res = await fetch('get_data.php'); const data = await res.json(); if (!data.success) return; data.centers.forEach(c => { addCenter({ id: c.id, name: c.name, address: c.address, kas: c.kas, lat: c.latitude, lng: c.longitude, radius: c.radius }); }); data.houses.forEach(h => { const marker = L.marker([h.latitude, h.longitude], { icon: createHouseIcon(h.aid_status, !!h.has_data), zIndexOffset: 100 }).addTo(map); const house = { id: h.id, lat: h.latitude, lng: h.longitude, address: h.address, rt: h.rt, rw: h.rw, kelurahan: h.kelurahan, statusMiskin: h.status_miskin, jumlahAnggota: h.jumlah_anggota, anggota: h.anggota || [], aidStatus: h.aid_status, hasData: !!h.has_data, marker, _geoData: null }; marker.bindPopup(() => buildHouseMapPopup(house), { maxWidth: 270 }); marker.on('popupopen', () => marker.setPopupContent(buildHouseMapPopup(house))); houses.push(house); }); reports = data.reports || []; updateStats(); updateSidebar(); updateReportBadge(); } catch (e) { console.error('loadData', e); } } // ============================================================ // INIT // ============================================================ applyRoleUI(); loadData();