chore: prepare docker webgis deployment

This commit is contained in:
Andrie
2026-06-11 18:14:21 +07:00
parent d2214ad9c8
commit a90748d9c1
149 changed files with 20844 additions and 5 deletions
+86
View File
@@ -0,0 +1,86 @@
// modules/heatmap.js — Heatmap kepadatan, intensitas proporsional jumlah_jiwa (F9)
(function () {
let heatmapLayer = null;
let active = false;
let heatmapKategori = '';
function canUseHeatmap() {
return !!(window.APP_USER?.isOp || window.APP_USER?.isAdmin);
}
function buildHeatData() {
const source = (window._pendudukAll || window._pendudukList || []).filter(function (p) {
return !heatmapKategori || p.kategori === heatmapKategori;
});
const validSource = source
.map(function (p) {
const rawLat = p.lat;
const rawLng = p.lng;
const lat = Number(p.lat);
const lng = Number(p.lng);
return {
hasCoordinate: rawLat !== null && rawLat !== '' && rawLng !== null && rawLng !== '',
lat: lat,
lng: lng,
jumlah_jiwa: Number(p.jumlah_jiwa || 1)
};
})
.filter(function (p) {
const lat = p.lat;
const lng = p.lng;
return p.hasCoordinate && Number.isFinite(lat) && Number.isFinite(lng);
});
const maxJiwa = Math.max(1, ...validSource.map(function (p) {
return p.jumlah_jiwa || 1;
}));
return validSource.map(function (p) {
return [p.lat, p.lng, 0.2 + 0.8 * ((p.jumlah_jiwa || 1) / maxJiwa)];
});
}
window._heatmapUpdate = function () {
if (!window.APP_USER?.isOp && !window.APP_USER?.isAdmin) {
active = false;
if (heatmapLayer && typeof map !== 'undefined' && map.hasLayer(heatmapLayer)) {
map.removeLayer(heatmapLayer);
}
return;
}
if (!active || !heatmapLayer) return;
heatmapLayer.setLatLngs(buildHeatData());
};
window._heatmapSetKategori = function (k) {
heatmapKategori = k;
window._heatmapUpdate();
};
window._toggleHeatmap = function (visible) {
if (!canUseHeatmap() || typeof L === 'undefined' || !L.heatLayer || typeof map === 'undefined') {
active = false;
const checkbox = document.getElementById('chkHeatmap');
if (checkbox) checkbox.checked = false;
if (heatmapLayer && typeof map !== 'undefined' && map.hasLayer(heatmapLayer)) {
map.removeLayer(heatmapLayer);
}
return;
}
active = visible;
if (active) {
if (!heatmapLayer) {
heatmapLayer = L.heatLayer([], {
radius : 25,
blur : 15,
maxZoom : 17,
gradient: { 0.4: 'blue', 0.6: 'cyan', 0.7: 'lime', 0.8: 'yellow', 1.0: 'red' }
});
}
heatmapLayer.addTo(map);
window._heatmapUpdate();
showToast('Heatmap Kepadatan diaktifkan.');
} else {
if (heatmapLayer) map.removeLayer(heatmapLayer);
showToast('Heatmap dinonaktifkan.');
}
};
})();
+887
View File
@@ -0,0 +1,887 @@
// modules/ibadah.js — Manajemen Rumah Ibadah dengan Radius Buffer & Reverse Geocoding
(function () {
const layerGroup = L.layerGroup();
window._ibadahLayer = layerGroup;
// Data list: [{id, lat, lng, nama, jenis, radius}] — dibaca oleh penduduk.js
window._ibadahList = [];
let allMarkers = {}; // { id: { marker, circle } }
let tempMarker = null;
let _previewCircle = null;
let active = false;
let bufferVisible = true;
// ── Icon & Color Factory ──────────────────────────────────────────
const JENIS_ICON = {
'Masjid' : '🕌',
'Mushola' : '🕌',
'Gereja' : '⛪',
'Pura' : '🛕',
'Vihara' : '🏯',
'Klenteng' : '🏮',
'Lainnya' : '🕍',
};
// Warna border circle per jenis (sesuai PRD F2)
const JENIS_COLOR = {
'Masjid' : { stroke: '#38bdf8', fill: '#0ea5e9' }, // biru
'Mushola' : { stroke: '#67e8f9', fill: '#06b6d4' }, // cyan
'Gereja' : { stroke: '#4ade80', fill: '#16a34a' }, // hijau
'Pura' : { stroke: '#f97316', fill: '#ea580c' }, // oranye
'Vihara' : { stroke: '#facc15', fill: '#ca8a04' }, // kuning
'Klenteng' : { stroke: '#f43f5e', fill: '#be123c' }, // merah
'Lainnya' : { stroke: '#a78bfa', fill: '#7c3aed' }, // ungu
};
const OPERATOR_HIGHLIGHT = {
stroke: '#f59e0b',
fill: '#fbbf24',
ring: 'rgba(245,158,11,0.22)',
shadow: 'rgba(180,83,9,0.35)'
};
function isOwnOperatorIbadah(id) {
const appUser = window.APP_USER || {};
if (!appUser.isOp || appUser.ibadahId === null || appUser.ibadahId === undefined) return false;
return parseInt(appUser.ibadahId) === parseInt(id);
}
// ── KK list helper (called on popupopen & after setPopupContent) ──
function _refreshKkList(id) {
const bodyEl = document.getElementById('kkListBody_' + id);
const countEl = document.getElementById('kkCount_' + id);
if (!bodyEl) return;
const list = (window._pendudukAll || window._pendudukList || []).filter(p => p.ibadah && parseInt(p.ibadah.id) === parseInt(id));
// Update live count (overrides stale DB value from initial load)
if (countEl) {
const totalJiwa = list.reduce((s, p) => s + (parseInt(p.jumlah_jiwa) || 1), 0);
countEl.innerHTML = `<span style="color:#38bdf8;font-weight:700;">${list.length}</span> KK &nbsp;·&nbsp; <span style="color:#38bdf8;font-weight:700;">${totalJiwa}</span> jiwa`;
}
if (list.length === 0) {
bodyEl.innerHTML = '<span style="color:var(--c-muted);font-size:12px;">Belum ada warga binaan.</span>';
return;
}
const MAX = 8;
let html = list.slice(0, MAX).map(p => `
<div onclick="window._pendudukFlyTo(${p.id})" style="
padding:5px 8px;margin:2px 0;border-radius:6px;cursor:pointer;
background:rgba(255,255,255,.04);display:flex;justify-content:space-between;
align-items:center;border:1px solid rgba(255,255,255,.07);transition:background .15s;
"
onmouseover="this.style.background='rgba(255,255,255,.1)'"
onmouseout="this.style.background='rgba(255,255,255,.04)'">
<span style="font-size:12px;">${escapeHTML(p.nama_kk || '(Anonim)')}</span>
<span style="font-size:10px;color:var(--c-muted);white-space:nowrap;margin-left:6px;">${p.jumlah_jiwa || 1} jiwa</span>
</div>`).join('');
if (list.length > MAX) {
html += `<div style="font-size:11px;color:var(--c-muted);text-align:center;margin-top:4px;">+${list.length - MAX} lainnya</div>`;
}
bodyEl.innerHTML = html;
}
function createIbadahIcon(jenis, ownOperatorIbadah = false) {
const emoji = JENIS_ICON[jenis] || '🕍';
const size = ownOperatorIbadah ? 46 : 36;
const border = ownOperatorIbadah ? `4px solid ${OPERATOR_HIGHLIGHT.stroke}` : '2px solid rgba(56,189,248,0.8)';
const background = ownOperatorIbadah ? 'linear-gradient(135deg, #fffbeb, #fef3c7)' : 'linear-gradient(135deg, #ffffff, #f0f9ff)';
const shadow = ownOperatorIbadah
? `0 0 0 8px ${OPERATOR_HIGHLIGHT.ring}, 0 8px 22px ${OPERATOR_HIGHLIGHT.shadow}`
: '0 3px 12px rgba(14,165,233,0.45)';
const fontSize = ownOperatorIbadah ? 18 : 16;
return L.divIcon({
className: '',
html: `<div style="
width:${size}px; height:${size}px;
background: ${background};
border: ${border};
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
box-shadow: ${shadow};
display:flex; align-items:center; justify-content:center;
">
<span style="transform:rotate(45deg); font-size:${fontSize}px; line-height:1;">${emoji}</span>
</div>`,
iconSize: [size, size],
iconAnchor: ownOperatorIbadah ? [12, 46] : [8, 36],
popupAnchor: ownOperatorIbadah ? [10, -48] : [10, -38]
});
}
const iconTemp = L.divIcon({
className: '',
html: `<div style="
width:32px; height:32px;
background: rgba(56,189,248,0.15);
border: 2px dashed #38bdf8;
border-radius: 50%;
animation: pulse-ibadah 1s ease-in-out infinite;
"></div>`,
iconSize: [32, 32],
iconAnchor: [16, 16],
});
// ── Tambah ke peta ────────────────────────────────────────────────
function addToMap(data) {
const lat = parseFloat(data.lat);
const lng = parseFloat(data.lng);
const radius = parseInt(data.radius_m) || 500;
const jenis = data.jenis || 'Lainnya';
const emoji = JENIS_ICON[jenis] || '🕍';
const ownOperatorIbadah = isOwnOperatorIbadah(data.id);
// Lingkaran radius — warna berbeda per jenis
const clr = JENIS_COLOR[jenis] || JENIS_COLOR['Lainnya'];
const circle = L.circle([lat, lng], {
radius: radius,
color: ownOperatorIbadah ? OPERATOR_HIGHLIGHT.stroke : clr.stroke,
weight: ownOperatorIbadah ? 3 : 1.5,
opacity: ownOperatorIbadah ? 1 : 0.8,
fillColor: ownOperatorIbadah ? OPERATOR_HIGHLIGHT.fill : clr.fill,
fillOpacity: ownOperatorIbadah ? 0.12 : 0.07,
});
const isAdmin = window.APP_USER && window.APP_USER.isAdmin;
const marker = L.marker([lat, lng], {
icon: createIbadahIcon(jenis, ownOperatorIbadah),
draggable: isAdmin,
zIndexOffset: ownOperatorIbadah ? 1000 : 100
});
marker._ibadahId = data.id;
marker._ibadahNama = data.nama;
marker._ibadahJenis = jenis;
marker._ibadahRadius = radius;
marker._circle = circle;
// Drag: update posisi + recalculate penduduk
marker.on('dragend', function (e) {
const { lat: newLat, lng: newLng } = e.target.getLatLng();
circle.setLatLng([newLat, newLng]);
// Update _ibadahList (parseInt to guard against string/number type mismatch)
const entry = window._ibadahList.find(x => parseInt(x.id) === parseInt(data.id));
if (entry) { entry.lat = newLat; entry.lng = newLng; }
const fd = new FormData();
fd.append('id', data.id);
fd.append('lat', newLat.toFixed(8));
fd.append('lng', newLng.toFixed(8));
fetch('api/ibadah/update_posisi.php', { method: 'POST', body: appendCsrf(fd) })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
showToast(`Posisi "${escapeHTML(data.nama)}" diperbarui.`);
// Recalculate semua penduduk
if (typeof window._pendudukRecalcAll === 'function') {
window._pendudukRecalcAll();
}
} else throw new Error(j.message);
})
.catch(err => showToast(err.message || 'Gagal update posisi.', 'error'));
});
// Popup info
marker.bindPopup(buildInfoPopup(data), { maxWidth: 340 });
// KK list: populate on popup open and after inline edits
marker.on('popupopen', function () { _refreshKkList(data.id); });
if (bufferVisible) circle.addTo(layerGroup);
marker.addTo(layerGroup);
// Simpan data lengkap agar bisa di-update nanti
allMarkers[data.id] = { marker, circle, data: { ...data, radius_m: radius } };
// Update global list untuk penduduk.js
updateIbadahList();
}
function buildInfoPopup(data) {
const isAdmin = window.APP_USER && window.APP_USER.isAdmin;
const ownOperatorIbadah = isOwnOperatorIbadah(data.id);
const jenis = data.jenis || 'Lainnya';
const emoji = JENIS_ICON[jenis] || '🕍';
const r = parseInt(data.radius_m) || 500;
const alamatHtml = data.alamat
? `<div class="info-row">
<div class="info-row-icon"><i data-lucide="map-pin"></i></div>
<div>
<div class="info-row-label">Alamat</div>
<div class="info-row-value" style="font-size:12px;line-height:1.4;">${escapeHTML(data.alamat)}</div>
</div>
</div>`
: '';
const kontakValue = data.kontak ? escapeHTML(data.kontak) : '';
const kontakHtml = (isAdmin || data.kontak) ? `
<div class="info-row" style="align-items:flex-start;">
<div class="info-row-icon"><i data-lucide="phone"></i></div>
<div style="flex:1;">
<div class="info-row-label">Kontak Pengurus</div>
${isAdmin ? `
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
<input type="text" id="edit_kontak_${data.id}"
value="${kontakValue}" placeholder="No HP/WA (Opsional)"
style="flex:1;padding:6px 8px;border-radius:4px;border:1px solid rgba(255,255,255,0.15);background:rgba(0,0,0,0.2);color:var(--c-text);font-size:12px;font-family:var(--font-body);outline:none;">
<button onclick="window._ibadahSimpanKontak(${data.id})"
style="padding:6px 10px;background:rgba(124,58,237,.15);border:1px solid rgba(168,85,247,.4);border-radius:6px;color:#a855f7;font-size:12px;font-weight:700;cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;gap:4px;"
onmouseover="this.style.background='rgba(124,58,237,.25)'"
onmouseout="this.style.background='rgba(124,58,237,.15)'">
<i data-lucide="save" style="width:12px;height:12px;"></i> Simpan
</button>
</div>
<div id="kontak_status_${data.id}" style="font-size:11px;margin-top:4px;font-family:var(--font-mono);"></div>
` : `<div class="info-row-value" style="margin-top:4px;">${kontakValue || '—'}</div>`}
</div>
</div>` : '';
return `<div class="info-popup">
<div class="info-popup-header">
<div class="info-popup-icon" style="background:rgba(124,58,237,.2);">${emoji}</div>
<div>
<div class="info-popup-name">${escapeHTML(data.nama)}</div>
<div class="info-popup-id">#${data.id} · ${escapeHTML(jenis)}</div>
</div>
</div>
${ownOperatorIbadah ? `
<div class="info-row" style="border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.10);">
<div class="info-row-icon" style="color:#f59e0b;"><i data-lucide="star"></i></div>
<div>
<div class="info-row-label" style="color:#92400e;">Rumah ibadah Anda</div>
<div class="info-row-value" style="font-size:12px;line-height:1.4;color:#78350f;">Akun operator ini terhubung ke rumah ibadah tersebut.</div>
</div>
</div>` : ''}
${alamatHtml}
${kontakHtml}
${isAdmin ? `
<div class="info-row" style="align-items:flex-start;">
<div class="info-row-icon"><i data-lucide="ruler"></i></div>
<div style="flex:1;">
<div class="info-row-label">Radius Wilayah</div>
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
<input type="range" id="edit_radius_${data.id}"
value="${r}" min="100" max="5000" step="50"
oninput="document.getElementById('edit_radius_val_${data.id}').textContent=this.value; window._ibadahPreviewRadiusById(${data.id},this.value)"
style="flex:1;height:4px;accent-color:#38bdf8;cursor:pointer;">
<span id="edit_radius_val_${data.id}"
style="min-width:45px;text-align:right;font-family:var(--font-mono);
font-size:12px;color:#38bdf8;font-weight:700;">${r}m</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--c-muted);margin-top:2px;">
<span>100m</span><span>5000m</span>
</div>
<button onclick="window._ibadahSimpanRadius(${data.id})"
style="margin-top:8px;width:100%;padding:6px;background:rgba(14,165,233,.12);
border:1px solid rgba(56,189,248,.4);border-radius:7px;
color:#38bdf8;font-size:12px;font-weight:700;cursor:pointer;
font-family:var(--font-body);transition:all .2s;display:inline-flex;align-items:center;justify-content:center;gap:4px;"
onmouseover="this.style.background='rgba(14,165,233,.25)'"
onmouseout="this.style.background='rgba(14,165,233,.12)'">
<i data-lucide="save" style="width:12px;height:12px;"></i> Simpan Radius
</button>
<div id="radius_status_${data.id}" style="font-size:11px;margin-top:4px;font-family:var(--font-mono);"></div>
</div>
</div>` : `
<div class="info-row">
<div class="info-row-icon"><i data-lucide="ruler"></i></div>
<div>
<div class="info-row-label">Radius Wilayah</div>
<div class="info-row-value" style="font-family:var(--font-mono);color:#38bdf8;">${r}m</div>
</div>
</div>`}
<div class="info-row">
<div class="info-row-icon"><i data-lucide="users"></i></div>
<div>
<div class="info-row-label">Warga Binaan <span style="font-size:9px;color:var(--c-muted);font-weight:400;">(terverifikasi)</span></div>
<div id="kkCount_${data.id}" class="info-row-value" style="font-family:var(--font-mono);">
<span style="color:#38bdf8;font-weight:700;">${parseInt(data.total_kk) || 0}</span> KK
&nbsp;·&nbsp;
<span style="color:#38bdf8;font-weight:700;">${parseInt(data.total_jiwa) || 0}</span> jiwa
</div>
${parseInt(data.jiwa_terjangkau) > 0 || parseInt(data.jiwa_blankspot) > 0 ? `
<div style="font-size:10px;color:var(--c-muted);margin-top:3px;">
<span style="color:#22c55e;">● ${parseInt(data.jiwa_terjangkau)||0} terjangkau</span>
&nbsp;·&nbsp;
<span style="color:#ef4444;">● ${parseInt(data.jiwa_blankspot)||0} blank spot</span>
</div>` : ''}
</div>
</div>
<div class="info-row">
<div class="info-row-icon"><i data-lucide="globe"></i></div>
<div>
<div class="info-row-label">Koordinat</div>
<div class="info-row-value" style="font-family:var(--font-mono);font-size:11px;">
${parseFloat(data.lat).toFixed(6)}, ${parseFloat(data.lng).toFixed(6)}
</div>
</div>
</div>
${(window.APP_USER && window.APP_USER.loggedIn) ? `
<div style="margin-top:8px;">
<div class="info-row-label" style="margin-bottom:6px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;">
Daftar KK Binaan <span style="font-weight:400;color:var(--c-muted);">(klik untuk fokus)</span>
</div>
<div id="kkListBody_${data.id}" style="max-height:180px;overflow-y:auto;">
<span style="color:var(--c-muted);font-size:12px;display:inline-flex;align-items:center;gap:4px;"><i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;"></i> Memuat...</span>
</div>
</div>` : `
<div style="margin-top:10px;padding:8px 10px;background:rgba(56,139,253,.06);
border:1px solid rgba(56,139,253,.15);border-radius:8px;font-size:11px;
color:var(--c-muted);line-height:1.5;text-align:center;">
<i data-lucide="lock" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Data detail warga hanya tersedia untuk petugas yang login.<br>
<a href="auth/login.php" style="color:#38bdf8;text-decoration:none;font-weight:700;">Masuk →</a>
</div>`}
${(window.APP_USER && window.APP_USER.isAdmin) ? `
<div style="display:flex;gap:8px;margin-top:8px;">
<a href="api/export/csv.php?ibadah_id=${data.id}"
style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;
padding:9px;background:rgba(56,139,253,.1);color:#79c0ff;
border:1px solid rgba(56,139,253,.3);border-radius:7px;
font-size:12px;font-weight:700;text-decoration:none;transition:all .2s;"
onmouseover="this.style.background='rgba(56,139,253,.2)'"
onmouseout="this.style.background='rgba(56,139,253,.1)'">
<i data-lucide="file-text" style="width:12px;height:12px;"></i> Export CSV
</a>
<a href="api/export/pdf.php?ibadah_id=${data.id}" target="_blank"
style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;
padding:9px;background:rgba(248,81,73,.08);color:#ffb3ad;
border:1px solid rgba(248,81,73,.25);border-radius:7px;
font-size:12px;font-weight:700;text-decoration:none;transition:all .2s;"
onmouseover="this.style.background='rgba(248,81,73,.18)'"
onmouseout="this.style.background='rgba(248,81,73,.08)'">
<i data-lucide="printer" style="width:12px;height:12px;"></i> Print PDF
</a>
</div>` : ''}
${isAdmin ? `<button class="btn-hapus" onclick="window._ibadahHapus(${data.id}, this)" style="display:inline-flex;align-items:center;justify-content:center;gap:4px;"><i data-lucide="trash-2" style="width:12px;height:12px;"></i> Hapus Rumah Ibadah</button>` : ''}
</div>`;
}
// ── Update global list ────────────────────────────────────────────
function updateIbadahList() {
window._ibadahList = Object.values(allMarkers).map(entry => {
const m = entry.marker;
const ll = m.getLatLng();
return {
id: m._ibadahId,
lat: ll.lat,
lng: ll.lng,
nama: m._ibadahNama,
jenis: m._ibadahJenis,
radius: m._ibadahRadius
};
});
if (typeof window._statsUpdate === 'function') window._statsUpdate();
}
// ── Load data ─────────────────────────────────────────────────────
function loadData() {
layerGroup.clearLayers();
allMarkers = {};
window._ibadahList = [];
fetch('api/ibadah/ambil.php?_=' + Date.now(), { cache: 'no-store' })
.then(r => r.json())
.then(j => {
if (j.status !== 'success') throw new Error(j.message);
j.data.forEach(addToMap);
updateIbadahList();
refreshList(j.data);
// Setelah load ibadah, recalculate penduduk
if (typeof window._pendudukRecalcAll === 'function') {
window._pendudukRecalcAll();
}
})
.catch(err => {
showToast('Gagal memuat data ibadah.', 'error');
console.error(err);
});
}
// ── Refresh panel list ────────────────────────────────────────────
function refreshList(data) {
if (typeof window.dlRefreshList !== 'function') return;
const items = (data || []).map(d => ({
id: d.id,
name: d.nama,
badge: d.jenis,
badgeColor: '#a855f7',
dotColor: '#a855f7',
}));
window.dlRefreshList('Ibadah', items);
}
// ── Hapus ─────────────────────────────────────────────────────────
window._ibadahHapus = function (id, btnEl) {
// Step 1: cek operator terkait (confirmed=0)
const fd0 = new FormData();
fd0.append('id', id);
fd0.append('confirmed', '0');
fetch('api/ibadah/hapus.php', { method: 'POST', body: appendCsrf(fd0) })
.then(r => r.json())
.then(j => {
if (j.status === 'warning') {
showDeleteConfirm(j.message + '\n\nLanjutkan hapus?').then(ok => {
if (ok) _doHapusIbadah(id, btnEl);
});
} else if (j.status === 'success') {
_applyHapusIbadah(id);
showToast('Rumah ibadah berhasil dihapus.');
} else {
showToast(j.message, 'error');
}
})
.catch(() => showToast('Gagal menghubungi server.', 'error'));
};
function _doHapusIbadah(id, btnEl) {
showDeleteConfirm('Yakin ingin menghapus permanen rumah ibadah ini?').then(ok => {
if (!ok) return;
btnEl.disabled = true;
btnEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menghapus...';
lucide.createIcons();
const fd = new FormData();
fd.append('id', id);
fd.append('confirmed', '1');
fetch('api/ibadah/hapus.php', { method: 'POST', body: appendCsrf(fd) })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
map.closePopup();
_applyHapusIbadah(id);
showToast('Rumah ibadah berhasil dihapus.');
if (typeof window._pendudukRecalcAll === 'function') window._pendudukRecalcAll();
} else {
showToast(j.message, 'error');
btnEl.disabled = false;
btnEl.innerHTML = '<i data-lucide="trash-2" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Hapus Rumah Ibadah';
lucide.createIcons();
}
});
});
}
function _applyHapusIbadah(id) {
if (allMarkers[id]) {
layerGroup.removeLayer(allMarkers[id].marker);
layerGroup.removeLayer(allMarkers[id].circle);
delete allMarkers[id];
}
updateIbadahList();
refreshList(Object.values(allMarkers).map(e => ({
id: e.marker._ibadahId,
nama: e.marker._ibadahNama,
jenis: e.marker._ibadahJenis,
})));
}
// ── Simpan perubahan kontak (dari popup info) ─────────────────────
window._ibadahSimpanKontak = function (id) {
const inputEl = document.getElementById('edit_kontak_' + id);
const statusEl = document.getElementById('kontak_status_' + id);
if (!inputEl) return;
const kontak = inputEl.value.trim();
statusEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menyimpan...';
statusEl.style.color = 'var(--c-muted)';
lucide.createIcons();
const fd = new FormData();
fd.append('id', id);
fd.append('kontak', kontak);
fetch('api/ibadah/update_kontak.php', { method: 'POST', body: appendCsrf(fd) })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
if (allMarkers[id]) {
allMarkers[id].data.kontak = kontak;
allMarkers[id].marker.setPopupContent(buildInfoPopup(allMarkers[id].data));
_refreshKkList(id);
}
showToast(`Kontak berhasil diperbarui.`);
} else throw new Error(j.message);
})
.catch(err => {
statusEl.innerHTML = '<i data-lucide="x" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> ' + escapeHTML(err.message);
statusEl.style.color = 'var(--c-danger)';
lucide.createIcons();
});
};
// ── Simpan perubahan radius (dari popup info) ─────────────────────
window._ibadahSimpanRadius = function (id) {
const inputEl = document.getElementById('edit_radius_' + id);
const statusEl = document.getElementById('radius_status_' + id);
if (!inputEl) return;
const radius = Math.max(100, Math.min(5000, parseInt(inputEl.value) || 500));
inputEl.value = radius; // normalize
statusEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menyimpan...';
statusEl.style.color = 'var(--c-muted)';
lucide.createIcons();
const fd = new FormData();
fd.append('id', id);
fd.append('radius_m', radius);
fetch('api/ibadah/update_radius.php', { method: 'POST', body: appendCsrf(fd) })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
if (allMarkers[id]) {
allMarkers[id].circle.setRadius(radius);
allMarkers[id].marker._ibadahRadius = radius;
// Update objek data yang disimpan
allMarkers[id].data.radius_m = radius;
// Update isi popup agar saat dibuka lagi nilainya sudah yang baru
allMarkers[id].marker.setPopupContent(buildInfoPopup(allMarkers[id].data));
_refreshKkList(id);
}
// Update _ibadahList
const entry = window._ibadahList.find(x => parseInt(x.id) === id);
if (entry) entry.radius = radius;
// Recalculate semua penduduk dengan radius baru
if (typeof window._pendudukRecalcAll === 'function') {
window._pendudukRecalcAll();
}
// Alert jika ada warga baru yang jadi blank spot akibat radius diperkecil
setTimeout(() => {
const newBlankCount = (window._pendudukAll || window._pendudukList || [])
.filter(p => !p.ibadah).length;
if (newBlankCount > 0) {
showToast(
`⚠ Radius diperkecil — ${newBlankCount} warga kini menjadi Blank Spot.`,
'error'
);
}
if (typeof window._updateBlankSpotCounter === 'function') {
window._updateBlankSpotCounter();
}
}, 200);
showToast(`Radius berhasil diubah ke ${radius}m.`);
} else throw new Error(j.message);
})
.catch(err => {
statusEl.innerHTML = '<i data-lucide="x" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> ' + escapeHTML(err.message);
statusEl.style.color = 'var(--c-danger)';
lucide.createIcons();
});
};
// ── Klik peta: Form tambah ibadah ─────────────────────────────────
function onMapClick(e) {
if (typeof activeTool === 'undefined' || activeTool !== 'ibadah') return;
const lat = e.latlng.lat.toFixed(8);
const lng = e.latlng.lng.toFixed(8);
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = L.marker([lat, lng], { icon: iconTemp }).addTo(map);
// Buka popup dengan alamat "Loading..."
const popup = L.popup({ maxWidth: 340, closeOnClick: false })
.setLatLng(e.latlng)
.setContent(buildFormPopup(lat, lng, 'Mengambil alamat...'))
.openOn(map);
// Reverse Geocoding via Nominatim
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&accept-language=id`, {
headers: { 'Accept-Language': 'id' }
})
.then(r => r.json())
.then(geo => {
const alamat = geo.display_name || '';
// Update field alamat di form
const el = document.getElementById('f_ibadah_alamat');
if (el) el.value = alamat;
})
.catch(() => {
const el = document.getElementById('f_ibadah_alamat');
if (el) el.value = '';
});
setTimeout(() => { const el = document.getElementById('f_ibadah_nama'); if (el) el.focus(); }, 200);
// Tampilkan preview circle radius saat popup terbuka
if (_previewCircle) { layerGroup.removeLayer(_previewCircle); _previewCircle = null; }
_previewCircle = L.circle([lat, lng], {
radius: 500, color: '#38bdf8', weight: 1.5, opacity: 0.6,
fillColor: '#0ea5e9', fillOpacity: 0.05, dashArray: '6,4'
}).addTo(layerGroup);
// Hapus preview circle & temp marker saat popup ditutup
const _markerThisClick = tempMarker;
const _circleThisClick = _previewCircle;
map.once('popupclose', function () {
if (_circleThisClick) { try { layerGroup.removeLayer(_circleThisClick); } catch(_){} }
if (_previewCircle === _circleThisClick) _previewCircle = null;
if (_markerThisClick) { try { map.removeLayer(_markerThisClick); } catch(_){} }
if (tempMarker === _markerThisClick) tempMarker = null;
});
}
function buildFormPopup(lat, lng, alamatPlaceholder) {
return `<div class="form-popup">
<div class="form-popup-header">
<div class="form-popup-icon" style="background:rgba(14,165,233,.12);"><i data-lucide="map-pin"></i></div>
<div>
<div class="form-popup-title">Tambah Rumah Ibadah</div>
<div class="form-popup-coords">${lat}, ${lng}</div>
</div>
</div>
<div class="form-group">
<label>Nama Tempat *</label>
<input type="text" id="f_ibadah_nama" placeholder="cth. Masjid Al-Ikhlas" maxlength="100" autocomplete="off">
</div>
<div class="form-group">
<label>Jenis</label>
<select id="f_ibadah_jenis">
<option value="Masjid">🕌 Masjid</option>
<option value="Mushola">🕌 Mushola</option>
<option value="Gereja">⛪ Gereja</option>
<option value="Pura">🛕 Pura</option>
<option value="Vihara">🏯 Vihara</option>
<option value="Klenteng">🏮 Klenteng</option>
</select>
</div>
<div class="form-group">
<label><i data-lucide="ruler" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Radius Wilayah</label>
<div style="display:flex;align-items:center;gap:8px;margin-top:4px;">
<input type="range" id="f_ibadah_radius" value="500" min="100" max="5000" step="50"
oninput="document.getElementById('f_ibadah_radius_val').textContent=this.value+'m'; window._ibadahPreviewRadius(this.value)"
style="flex:1;height:4px;accent-color:#38bdf8;cursor:pointer;">
<span id="f_ibadah_radius_val"
style="min-width:50px;text-align:right;font-family:var(--font-mono);
font-size:12px;color:#38bdf8;font-weight:700;">500m</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--c-muted);margin-top:2px;">
<span>100m</span><span>5000m</span>
</div>
</div>
<div class="form-group">
<label>Alamat (Otomatis)</label>
<input type="text" id="f_ibadah_alamat" value="${escapeHTML(alamatPlaceholder)}" placeholder="Mendeteksi alamat..." autocomplete="off">
</div>
<div class="form-group">
<label>Kontak Pengurus</label>
<input type="text" id="f_ibadah_kontak" placeholder="No HP/WA (Opsional)" maxlength="100" autocomplete="off">
</div>
<input type="hidden" id="f_ibadah_lat" value="${lat}">
<input type="hidden" id="f_ibadah_lng" value="${lng}">
<button class="btn-save" id="btnSimpanIbadah" onclick="window._ibadahSimpan()" style="background:linear-gradient(135deg,#7c3aed,#a855f7);display:inline-flex;align-items:center;justify-content:center;gap:4px;">
<i data-lucide="save" style="width:14px;height:14px;"></i> Simpan Rumah Ibadah
</button>
<div class="form-status" id="ibadahStatus"></div>
</div>`;
}
// ── Live preview radius (form tambah baru) ────────────────────────
window._ibadahPreviewRadius = function (val) {
const r = Math.max(100, Math.min(5000, parseInt(val) || 500));
if (_previewCircle) _previewCircle.setRadius(r);
};
// ── Live preview radius (edit marker yg sudah ada) ────────────────
let _recalcDebounce = null;
window._ibadahPreviewRadiusById = function (id, val) {
const r = Math.max(100, Math.min(5000, parseInt(val) || 500));
const targetId = parseInt(id);
// 1. Update visual circle & marker property
if (allMarkers[targetId]) {
allMarkers[targetId].circle.setRadius(r);
allMarkers[targetId].marker._ibadahRadius = r;
}
// 2. Paksa sinkronisasi window._ibadahList agar penduduk.js melihat radius terbaru
if (window._ibadahList) {
const entry = window._ibadahList.find(x => parseInt(x.id) === targetId);
if (entry) {
entry.radius = r;
} else {
// Jika tidak ketemu (jarang terjadi), panggil sync full
updateIbadahList();
}
}
// 3. Recalculate warna penduduk (debounce agar ringan)
clearTimeout(_recalcDebounce);
_recalcDebounce = setTimeout(() => {
console.log(`[Ibadah] Recalculating proximity for Radius: ${r}m`);
if (typeof window._pendudukRecalcAll === 'function') {
window._pendudukRecalcAll();
}
}, 50); // Dipercepat ke 50ms untuk feel yang lebih instan
};
// ── Simpan ────────────────────────────────────────────────────────
window._ibadahSimpan = function () {
const nama = document.getElementById('f_ibadah_nama').value.trim();
const jenis = document.getElementById('f_ibadah_jenis').value;
const radius = Math.max(100, Math.min(5000, parseInt(document.getElementById('f_ibadah_radius')?.value) || 500));
const alamat = document.getElementById('f_ibadah_alamat').value.trim();
const kontak = document.getElementById('f_ibadah_kontak')?.value.trim() || '';
const lat = document.getElementById('f_ibadah_lat').value;
const lng = document.getElementById('f_ibadah_lng').value;
const status = document.getElementById('ibadahStatus');
const btn = document.getElementById('btnSimpanIbadah');
if (!nama) {
status.textContent = '⚠ Nama wajib diisi.';
status.className = 'form-status error';
document.getElementById('f_ibadah_nama').focus();
return;
}
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menyimpan...';
lucide.createIcons();
const fd = new FormData();
fd.append('nama', nama);
fd.append('jenis', jenis);
fd.append('alamat', alamat);
fd.append('kontak', kontak);
fd.append('lat', lat);
fd.append('lng', lng);
fd.append('radius_m', radius);
fetch('api/ibadah/simpan.php', { method: 'POST', body: appendCsrf(fd) })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
map.closePopup();
if (_previewCircle) { layerGroup.removeLayer(_previewCircle); _previewCircle = null; }
addToMap(j.data);
updateIbadahList();
refreshList(Object.values(allMarkers).map(e => ({
id: e.marker._ibadahId,
nama: e.marker._ibadahNama,
jenis: e.marker._ibadahJenis,
})));
showToast(`"${escapeHTML(nama)}" berhasil ditambahkan!`);
// Recalculate penduduk setelah ibadah baru ditambah
if (typeof window._pendudukRecalcAll === 'function') {
window._pendudukRecalcAll();
}
} else throw new Error(j.message);
})
.catch(err => {
status.innerHTML = '<i data-lucide="x" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> ' + escapeHTML(err.message);
status.className = 'form-status error';
btn.disabled = false;
btn.innerHTML = '<i data-lucide="save" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i> Simpan Rumah Ibadah';
lucide.createIcons();
});
};
// ── Recalculate All proximity (Admin only, with race-condition guard) ──
let _recalcInProgress = false;
window._recalcAll = function () {
if (_recalcInProgress) {
showToast('Proses hitung ulang sedang berjalan...', 'error');
return;
}
showDeleteConfirm(
'Proses ini dapat mengubah assignment warga ke rumah ibadah/operator berdasarkan radius terkini. Lanjutkan?',
{ type: 'warn', iconName: 'refresh-cw', title: 'Konfirmasi Hitung Ulang Proximity', btnLabel: 'Ya, Hitung Ulang' }
).then(confirmed => {
if (!confirmed) return;
_recalcInProgress = true;
const allBtns = document.querySelectorAll(
'#btnToolIbadah, #btnToolPenduduk, #btnRecalcAll, .btn-hapus, .btn-save'
);
allBtns.forEach(b => { b.disabled = true; });
const btn = document.getElementById('btnRecalcAll');
const icon = btn ? btn.querySelector('.tb-icon') : null;
if (btn) {
if (icon) icon.textContent = '⏳';
btn.style.color = 'var(--c-muted)';
}
showToast('Menghitung ulang proximity...');
fetch('api/ibadah/recalculate.php', { method: 'POST', body: appendCsrf(new FormData()) })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
showToast(`${j.message}`);
if (typeof window._pendudukReload === 'function') {
window._pendudukReload();
} else if (typeof window._pendudukRecalcAll === 'function') {
window._pendudukRecalcAll();
}
} else {
showToast(j.message || 'Gagal menghitung ulang.', 'error');
}
})
.catch(() => showToast('Gagal menghubungi server.', 'error'))
.finally(() => {
_recalcInProgress = false;
allBtns.forEach(b => { b.disabled = false; });
if (btn) {
if (icon) icon.textContent = '🔄';
btn.style.color = 'var(--c-warn)';
}
});
});
};
window._ibadahReload = loadData;
// ── Init ─────────────────────────────────────────────────────────
window.initIbadah = function () {
if (active) { loadData(); return; }
active = true;
layerGroup.addTo(map);
map.on('click', onMapClick);
window._dlFocusFns['Ibadah'] = function (id) {
const entry = allMarkers[id];
if (!entry) return;
map.setView(entry.marker.getLatLng(), Math.max(map.getZoom(), 17), { animate: true });
setTimeout(() => entry.marker.openPopup(), 350);
};
loadData();
};
// ── Toggle visibilitas layer ──────────────────────────────────────
window._ibadahToggleLayer = function (visible) {
if (visible) {
if (!map.hasLayer(layerGroup)) map.addLayer(layerGroup);
} else {
map.removeLayer(layerGroup);
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
}
};
window._setBufferVisible = function (visible) {
bufferVisible = visible;
Object.values(allMarkers).forEach(function (entry) {
if (visible) {
if (!layerGroup.hasLayer(entry.circle)) entry.circle.addTo(layerGroup);
} else {
if (layerGroup.hasLayer(entry.circle)) layerGroup.removeLayer(entry.circle);
}
});
};
})();
+195
View File
@@ -0,0 +1,195 @@
// modules/kebutuhan.js — Manajemen Kebutuhan per Warga Miskin (F14)
(function () {
const KATEGORI_ICON = {
'Sembako' : 'shopping-cart',
'Biaya Sekolah' : 'graduation-cap',
'Biaya Kesehatan' : 'heart-pulse',
'Modal Usaha' : 'briefcase',
'Renovasi Rumah' : 'home',
'Perlengkapan Rumah': 'armchair',
'Pakaian' : 'shirt',
'Lainnya' : 'clipboard-list',
};
const STATUS_COLOR = {
'Belum Terpenuhi': '#ef4444',
'Dalam Proses' : '#e3b341',
'Terpenuhi' : '#3fb950',
};
const KATEGORI_LIST = [
'Sembako','Biaya Sekolah','Biaya Kesehatan','Modal Usaha',
'Renovasi Rumah','Perlengkapan Rumah','Pakaian','Lainnya',
];
function isOp() { return window.APP_USER && (window.APP_USER.isOp || window.APP_USER.isAdmin); }
function esc(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(str ?? ''));
return d.innerHTML;
}
// ── Bangun seksi kebutuhan untuk popup penduduk ───────────────────────────
window._kebutuhanBuildSection = function (pendudukId, kebutuhanOpen, statusVerifikasi) {
const count = parseInt(kebutuhanOpen) || 0;
const canMutate = isOp() && statusVerifikasi === 'Terverifikasi';
const badgeHtml = count > 0
? `<span id="kebutuhanBadge_${pendudukId}"
style="background:#ef444422;color:#ef4444;border:1px solid #ef444444;
font-size:10px;font-weight:700;padding:2px 7px;border-radius:20px;
margin-left:4px;">${count} Belum Terpenuhi</span>`
: `<span id="kebutuhanBadge_${pendudukId}"></span>`;
const formHtml = canMutate ? `
<div id="kebutuhanForm_${pendudukId}" style="display:none;margin-top:8px;padding:8px;
background:rgba(255,255,255,.03);border-radius:6px;border:1px solid var(--c-border);">
<select id="kf_kat_${pendudukId}"
style="width:100%;padding:5px 8px;background:var(--c-surface2);border:1px solid var(--c-border);
border-radius:6px;color:var(--c-text);font-size:12px;margin-bottom:6px;">
${KATEGORI_LIST.map(k => `<option value="${esc(k)}">${esc(k)}</option>`).join('')}
</select>
<input type="text" id="kf_desc_${pendudukId}"
placeholder="Deskripsi opsional (maks 300 karakter)" maxlength="300"
style="width:100%;padding:5px 8px;background:var(--c-surface2);border:1px solid var(--c-border);
<div style="display:flex;gap:6px;">
<button onclick="window._kebutuhanSimpan(${pendudukId})"
style="flex:1;padding:5px;font-size:11px;background:rgba(63,185,80,.1);color:#3fb950;
border:1px solid rgba(63,185,80,.3);border-radius:6px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
<i data-lucide="save" style="width:12px;height:12px;"></i> Simpan
</button>
<button onclick="document.getElementById('kebutuhanForm_${pendudukId}').style.display='none'"
style="padding:5px 10px;font-size:11px;background:transparent;color:var(--c-muted);
border:1px solid var(--c-border);border-radius:6px;cursor:pointer;">
Batal
</button>
</div>
</div>
<button id="btnTambahKebutuhan_${pendudukId}"
onclick="document.getElementById('kebutuhanForm_${pendudukId}').style.display='';
document.getElementById('btnTambahKebutuhan_${pendudukId}').style.display='none';
if(typeof lucide !== 'undefined') lucide.createIcons();"
style="font-size:11px;padding:3px 8px;margin-top:6px;border-radius:4px;cursor:pointer;
border:1px solid rgba(63,185,80,.3);background:transparent;color:#3fb950;">
+ Tambah Kebutuhan
</button>` : (isOp() ? `<div style="font-size:11px;color:var(--c-muted);font-weight:600;margin-top:6px;">Menunggu verifikasi admin</div>` : '');
return `<div class="info-row" style="align-items:flex-start;border-top:1px solid var(--c-border);padding-top:8px;margin-top:4px;">
<div class="info-row-icon"><i data-lucide="clipboard-list"></i></div>
<div style="flex:1;">
<div class="info-row-label">Kebutuhan ${badgeHtml}</div>
<div id="kebutuhanBody_${pendudukId}" data-verifikasi="${esc(statusVerifikasi || 'Pending')}" style="margin-top:4px;">
<button onclick="window._kebutuhanLoad(${pendudukId})"
style="font-size:11px;padding:2px 7px;border-radius:4px;cursor:pointer;
border:1px solid var(--c-border);background:transparent;color:var(--c-muted);">
Muat Kebutuhan
</button>
</div>
${formHtml}
</div>
</div>`;
};
// ── Muat dan render daftar kebutuhan ─────────────────────────────────────
window._kebutuhanLoad = function (pendudukId) {
const body = document.getElementById('kebutuhanBody_' + pendudukId);
if (!body) return;
const statusVerifikasi = body.dataset.verifikasi || 'Pending';
const canMutate = isOp() && statusVerifikasi === 'Terverifikasi';
body.innerHTML = '<span style="font-size:11px;color:var(--c-muted);display:inline-flex;align-items:center;gap:4px;"><i data-lucide="loader-2" class="lucide animate-spin" style="width:12px;height:12px;"></i> Memuat...</span>';
if (typeof lucide !== 'undefined') lucide.createIcons();
fetch('api/kebutuhan/ambil.php?penduduk_id=' + pendudukId + '&_=' + Date.now(), { cache: 'no-store' })
.then(r => r.json())
.then(j => {
if (j.status !== 'success') throw new Error(j.message);
const rows = j.data;
const openCount = rows.filter(r => r.status === 'Belum Terpenuhi').length;
if (typeof window._pendudukRefreshKebutuhanCount === 'function') {
window._pendudukRefreshKebutuhanCount(pendudukId, openCount);
}
if (rows.length === 0) {
body.innerHTML = '<span style="font-size:11px;color:var(--c-muted);">Belum ada kebutuhan tercatat.</span>';
return;
}
body.innerHTML = rows.map(r => {
const sc = STATUS_COLOR[r.status] || '#8b949e';
const icon = KATEGORI_ICON[r.kategori] || 'clipboard-list';
const iconHtml = `<i data-lucide="${icon}" class="lucide-inline" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i>`;
const statusBtns = canMutate
? ['Belum Terpenuhi', 'Dalam Proses', 'Terpenuhi'].map(s =>
`<button onclick="window._kebutuhanUpdateStatus(${r.id},'${s}',${pendudukId})"
style="font-size:10px;padding:2px 6px;border-radius:4px;cursor:pointer;
border:1px solid ${STATUS_COLOR[s]};
background:${s === r.status ? STATUS_COLOR[s] + '33' : 'transparent'};
color:${STATUS_COLOR[s]};font-weight:${s === r.status ? '700' : '400'};">${s}</button>`
).join('')
: `<span style="font-size:11px;color:${sc};font-weight:700;">${esc(r.status)}</span>`;
return `<div style="padding:5px 0;border-bottom:1px solid rgba(255,255,255,.06);">
<div style="font-size:12px;font-weight:600;color:var(--c-text);display:flex;align-items:center;gap:2px;">
${iconHtml} ${esc(r.kategori)}
</div>
${r.deskripsi ? `<div style="font-size:11px;color:var(--c-muted);margin:2px 0;">${esc(r.deskripsi)}</div>` : ''}
<div style="display:flex;gap:4px;margin-top:4px;flex-wrap:wrap;">${statusBtns}</div>
</div>`;
}).join('');
if (typeof lucide !== 'undefined') lucide.createIcons();
})
.catch(err => {
const b = document.getElementById('kebutuhanBody_' + pendudukId);
if (b) {
b.innerHTML = `<span style="font-size:11px;color:#ef4444;display:inline-flex;align-items:center;gap:4px;"><i data-lucide="x-circle" style="width:12px;height:12px;"></i> Gagal: ${esc(err.message)}</span>`;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
});
};
// ── Simpan kebutuhan baru ─────────────────────────────────────────────────
window._kebutuhanSimpan = function (pendudukId) {
const kat = document.getElementById('kf_kat_' + pendudukId)?.value;
const desc = document.getElementById('kf_desc_' + pendudukId)?.value?.trim() || '';
if (!kat) return;
const fd = new FormData();
fd.append('penduduk_id', pendudukId);
fd.append('kategori', kat);
fd.append('deskripsi', desc);
fetch('api/kebutuhan/simpan.php', { method: 'POST', body: appendCsrf(fd) })
.then(r => r.json())
.then(j => {
if (j.status !== 'success') throw new Error(j.message);
const formEl = document.getElementById('kebutuhanForm_' + pendudukId);
const btnEl = document.getElementById('btnTambahKebutuhan_' + pendudukId);
const descEl = document.getElementById('kf_desc_' + pendudukId);
if (formEl) formEl.style.display = 'none';
if (btnEl) btnEl.style.display = '';
if (descEl) descEl.value = '';
window._kebutuhanLoad(pendudukId);
if (typeof window.dispatchDataChanged === 'function') {
window.dispatchDataChanged('kebutuhan', { pendudukId, mutation: 'create' });
}
showToast('Kebutuhan berhasil disimpan.');
})
.catch(err => showToast(err.message || 'Gagal menyimpan kebutuhan.', 'error'));
};
// ── Update status kebutuhan ───────────────────────────────────────────────
window._kebutuhanUpdateStatus = function (kebutuhanId, status, pendudukId) {
const catatan = prompt('Catatan perubahan kebutuhan (opsional):', '');
if (catatan === null) return;
const fd = new FormData();
fd.append('id', kebutuhanId);
fd.append('status', status);
fd.append('catatan', catatan);
fetch('api/kebutuhan/update_status.php', { method: 'POST', body: appendCsrf(fd) })
.then(r => r.json())
.then(j => {
if (j.status !== 'success') throw new Error(j.message);
window._kebutuhanLoad(pendudukId);
if (typeof window.dispatchDataChanged === 'function') {
window.dispatchDataChanged('kebutuhan', { pendudukId, kebutuhanId, mutation: 'status' });
}
showToast('Status kebutuhan diperbarui.');
})
.catch(err => showToast(err.message || 'Gagal memperbarui status.', 'error'));
};
})();
+975
View File
@@ -0,0 +1,975 @@
// modules/penduduk.js — Manajemen Penduduk Miskin dengan Proximity Logic (Haversine)
(function () {
const layerGroup = L.layerGroup();
window._pendudukLayer = layerGroup;
window._pendudukAll = []; // Dataset penuh dari API.
window._pendudukVisible = []; // Dataset yang sedang terlihat/listed.
window._pendudukList = window._pendudukVisible; // Alias kompatibilitas.
let allMarkers = {}; // { id: circleMarker }
let filterState = { search: '', kategori: '', keterjangkauan: '', jenisIbadah: '', statusBantuan: '', kebutuhanKategori: '', kebutuhanStatus: '' };
let subLayerVisible = { terjangkau: true, blankspot: true };
let tempMarker = null;
let active = false;
// ── Warna ─────────────────────────────────────────────────────────
const COLOR_TERJANGKAU = '#22c55e'; // Hijau — dalam radius (terjangkau)
const COLOR_BLANK_SPOT = '#ef4444'; // Merah — di luar semua radius (blank spot)
const STATUS_COLOR = { 'Belum Ditangani': '#ef4444', 'Dalam Proses': '#e3b341', 'Sudah Ditangani': '#3fb950' };
// ── Formula Haversine (return jarak dalam meter) ──────────────────
function haversine(lat1, lng1, lat2, lng2) {
const R = 6371000; // radius bumi dalam meter
const toR = x => x * Math.PI / 180;
const dLat = toR(lat2 - lat1);
const dLng = toR(lng2 - lng1);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(toR(lat1)) * Math.cos(toR(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
// ── Cari ibadah terdekat dalam radius 500m ────────────────────────
function findNearestIbadah(lat, lng) {
let nearest = null;
let minDist = Infinity;
const list = window._ibadahList || [];
for (const ib of list) {
const d = haversine(lat, lng, ib.lat, ib.lng);
// Gunakan ib.radius yang sudah di-update real-time oleh ibadah.js
const activeRadius = parseInt(ib.radius) || 500;
if (d <= activeRadius && d < minDist) {
minDist = d;
nearest = { ...ib, jarak: Math.round(d), radius: activeRadius };
}
}
return nearest; // null = di luar semua radius
}
// ── Render / update warna satu marker ────────────────────────────
function applyColor(cm, ibadah) {
const color = ibadah ? COLOR_TERJANGKAU : COLOR_BLANK_SPOT;
cm.setStyle({
color : color,
fillColor : color,
fillOpacity: 0.55,
weight : 2,
});
cm._nearestIbadah = ibadah;
}
// ── Buat popup teks ───────────────────────────────────────────────
function buildInfoPopup(data, ibadah) {
const isAdmin = window.APP_USER && window.APP_USER.isAdmin;
const ibadahSection = ibadah
? `<div class="info-row" style="background:rgba(34,197,94,.06);border-radius:8px;padding:6px 8px;margin:2px 0;">
<div class="info-row-icon"><i data-lucide="map-pin"></i></div>
<div style="flex:1;">
<div class="info-row-label">Rumah Ibadah Terdekat</div>
<div class="info-row-value" style="color:#22c55e;font-weight:600;">${escapeHTML(ibadah.nama)}</div>
<div style="font-size:11px;color:var(--c-muted);margin-top:3px;display:inline-flex;align-items:center;gap:3px;flex-wrap:wrap;">
<i data-lucide="ruler" style="width:12px;height:12px;vertical-align:middle;margin-right:2px;"></i> ${ibadah.jarak}m · <i data-lucide="circle" style="width:10px;height:10px;vertical-align:middle;margin-right:2px;"></i> Radius ${ibadah.radius || 500}m · ${ibadah.jenis || ''}
</div>
</div>
</div>`
: `<div class="info-row" style="background:rgba(239,68,68,.06);border-radius:8px;padding:6px 8px;margin:2px 0;">
<div class="info-row-icon" style="color:#ef4444;"><i data-lucide="alert-triangle"></i></div>
<div>
<div class="info-row-label">Status Wilayah</div>
<div class="info-row-value" style="color:#ef4444;font-weight:600;">Di luar semua radius ibadah (Blank Spot)</div>
</div>
</div>`;
const nikHtml = (data.nik && window.APP_USER && window.APP_USER.loggedIn)
? `<div style="font-size:11px;color:var(--c-muted);margin-top:2px;">NIK: ${escapeHTML(data.nik)}</div>` : '';
const catatanHtml = (data.catatan && window.APP_USER && window.APP_USER.loggedIn)
? `<div class="info-row">
<div class="info-row-icon"><i data-lucide="file-text"></i></div>
<div>
<div class="info-row-label">Catatan Kondisi</div>
<div class="info-row-value" style="font-size:12px;line-height:1.4;">${escapeHTML(data.catatan)}</div>
</div>
</div>` : '';
const isOp = window.APP_USER && window.APP_USER.isOp;
const st = data.status_bantuan || 'Belum Ditangani';
const stC = STATUS_COLOR[st] || '#8b949e';
const sv = data.status_verifikasi || 'Pending';
const statusBtns = sv === 'Terverifikasi'
? ['Belum Ditangani', 'Dalam Proses', 'Sudah Ditangani'].map(s =>
`<button onclick="window._updateStatus(${data.id},'${s}',this)"
style="font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer;
border:1px solid ${STATUS_COLOR[s]};
background:${s === st ? STATUS_COLOR[s] + '33' : 'transparent'};
color:${STATUS_COLOR[s]};font-weight:${s === st ? '700' : '400'};">${escapeHTML(s)}</button>`
).join('')
: `<span style="font-size:11px;color:var(--c-muted);font-weight:600;">Menunggu verifikasi admin</span>`;
const statusSection = isOp
? `<div class="info-row" style="align-items:flex-start;">
<div class="info-row-icon"><i data-lucide="clipboard-list"></i></div>
<div style="flex:1;">
<div class="info-row-label">Status Bantuan</div>
<span style="display:inline-block;font-size:11px;font-weight:700;padding:2px 8px;border-radius:20px;
background:${stC}22;color:${stC}0d;margin-top:3px;border:1px solid ${stC}44;color:${stC};">${escapeHTML(st)}</span>
<div data-sg="${data.id}" style="display:flex;gap:4px;margin-top:6px;flex-wrap:wrap;">${statusBtns}</div>
<button onclick="window._showRiwayat(${data.id})"
style="font-size:11px;padding:3px 8px;margin-top:6px;border-radius:4px;cursor:pointer;
border:1px solid var(--c-border);background:transparent;color:var(--c-muted);display:inline-flex;align-items:center;gap:4px;">
<i data-lucide="history" style="width:11px;height:11px;"></i> Lihat Riwayat
</button>
</div>
</div>` : '';
// ── Badge & aksi verifikasi ───────────────────────────────
const svClr = { Terverifikasi: '#22c55e', Pending: '#e3b341', Ditolak: '#ef4444' }[sv] || '#8b949e';
const svIcon = {
Terverifikasi: '<i data-lucide="check-circle" style="width:11px;height:11px;vertical-align:middle;margin-right:4px;"></i>',
Pending: '<i data-lucide="clock" style="width:11px;height:11px;vertical-align:middle;margin-right:4px;"></i>',
Ditolak: '<i data-lucide="x-circle" style="width:11px;height:11px;vertical-align:middle;margin-right:4px;"></i>'
}[sv] || '';
const verifBadge = (isAdmin || isOp)
? `<div style="display:inline-flex;align-items:center;font-size:10px;font-weight:700;
padding:2px 8px;border-radius:20px;margin-top:4px;
background:${svClr}20;color:${svClr};border:1px solid ${svClr}44;">
${svIcon}<span>${sv}</span>
</div>` : '';
const verifActions = (isAdmin && sv === 'Pending')
? `<div style="display:flex;gap:6px;margin-top:8px;padding-top:10px;border-top:1px solid rgba(255,255,255,.08);">
<button onclick="window._verifikasiPenduduk(${data.id},'approve',this)"
style="flex:1;padding:7px;border-radius:7px;cursor:pointer;font-size:12px;font-weight:700;
border:1px solid #22c55e;background:rgba(34,197,94,.1);color:#22c55e;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
<i data-lucide="check" style="width:12px;height:12px;"></i> Verifikasi
</button>
<button onclick="window._verifikasiPenduduk(${data.id},'reject',this)"
style="flex:1;padding:7px;border-radius:7px;cursor:pointer;font-size:12px;font-weight:700;
border:1px solid #ef4444;background:rgba(239,68,68,.1);color:#ef4444;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
<i data-lucide="x" style="width:12px;height:12px;"></i> Tolak
</button>
</div>` : '';
const actionBtns = isAdmin
? `<div style="display:flex;gap:8px;margin-top:8px;">
<button class="btn-hapus" style="flex:1;background:rgba(227,179,65,.08);color:#f0c240;border-color:rgba(227,179,65,.3);display:inline-flex;align-items:center;justify-content:center;gap:4px;"
onclick="window._pendudukNonaktifkan(${data.id}, this)">
<i data-lucide="user-x" style="width:12px;height:12px;"></i> Nonaktifkan
</button>
<button class="btn-hapus" style="flex:1;display:inline-flex;align-items:center;justify-content:center;gap:4px;"
onclick="window._pendudukHapus(${data.id}, this)">
<i data-lucide="trash-2" style="width:12px;height:12px;"></i> Hapus Data
</button>
</div>` : '';
const kebutuhanSection = (window.APP_USER && window.APP_USER.loggedIn && typeof window._kebutuhanBuildSection === 'function')
? window._kebutuhanBuildSection(data.id, data.kebutuhan_open, sv)
: '';
return `<div class="info-popup">
<div class="info-popup-header">
<div class="info-popup-icon" style="background:${ibadah ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)'};"><i data-lucide="home"></i></div>
<div>
<div class="info-popup-name">${escapeHTML(data.nama_kk || 'Warga #' + data.id)}</div>
${nikHtml}
<div class="info-popup-id">#${data.id} · ${data.kategori || 'Miskin'}</div>
${verifBadge}
</div>
</div>
${ibadahSection}
<div class="info-row">
<div class="info-row-icon"><i data-lucide="users"></i></div>
<div><div class="info-row-label">Jumlah Jiwa</div>
<div class="info-row-value">${data.jumlah_jiwa} orang</div></div>
</div>
<div class="info-row">
<div class="info-row-icon"><i data-lucide="map-pin"></i></div>
<div><div class="info-row-label">Alamat</div>
<div class="info-row-value">${escapeHTML(data.alamat || '-')}</div></div>
</div>
${catatanHtml}
${statusSection}
${kebutuhanSection}
${verifActions}
${actionBtns}
</div>`;
}
// ── Tambah marker ke peta ─────────────────────────────────────────
function addToMap(data) {
const lat = parseFloat(data.lat);
const lng = parseFloat(data.lng);
// Koordinat null = publik — simpan ke list saja tanpa marker
if (isNaN(lat) || isNaN(lng)) {
const ibadahRef = data.ibadah_id
? { id: data.ibadah_id, nama: data.nama_ibadah, jenis: data.jenis_ibadah }
: null;
allMarkers[data.id] = { cm: null, dragHandle: null, data, _ibadahRef: ibadahRef };
return;
}
const ibadah = findNearestIbadah(lat, lng);
const color = ibadah ? COLOR_TERJANGKAU : COLOR_BLANK_SPOT;
const cm = L.circleMarker([lat, lng], {
radius : 8,
color : color,
fillColor : color,
fillOpacity: 0.55,
weight : 2,
});
cm._pendudukId = data.id;
cm._pendudukNama = data.nama_kk;
cm._pendudukJiwa = data.jumlah_jiwa;
cm._nearestIbadah = ibadah;
cm.bindPopup(buildInfoPopup(data, ibadah), { maxWidth: 280 });
// Drag to update position
// circleMarker tidak draggable by default, gunakan workaround dengan L.marker invisible
cm.on('click', function () {
cm.openPopup();
});
// Draggable via L.marker overlay (admin only)
const isAdmin = window.APP_USER && window.APP_USER.isAdmin;
const dragHandle = L.marker([lat, lng], {
icon: L.divIcon({
className: '',
html: '<div style="width:16px;height:16px;cursor:move;"></div>',
iconSize: [16, 16],
iconAnchor: [8, 8],
}),
draggable: isAdmin,
zIndexOffset: -500,
opacity: 0,
});
dragHandle._cmRef = cm;
dragHandle._dataRef = data;
// Forward klik ke popup circleMarker
dragHandle.on('click', function () {
cm.openPopup();
});
dragHandle.on('drag', function (e) {
const { lat: nLat, lng: nLng } = e.target.getLatLng();
cm.setLatLng([nLat, nLng]);
if (allMarkers[data.id]) {
allMarkers[data.id].data.lat = nLat;
allMarkers[data.id].data.lng = nLng;
}
updatePendudukList();
});
dragHandle.on('dragend', function (e) {
const { lat: nLat, lng: nLng } = e.target.getLatLng();
cm.setLatLng([nLat, nLng]);
cm.closePopup();
const ibadahBaru = findNearestIbadah(nLat, nLng);
applyColor(cm, ibadahBaru);
// Spread from allMarkers (live, may have updated status_bantuan etc.), not stale closure
const updatedData = { ...allMarkers[data.id].data, lat: nLat, lng: nLng, is_blank_spot: ibadahBaru ? 0 : 1 };
allMarkers[data.id].data = updatedData;
cm.bindPopup(buildInfoPopup(updatedData, ibadahBaru), { maxWidth: 280 });
updatePendudukList();
refreshList();
const fd = new FormData();
fd.append('id', data.id);
fd.append('lat', nLat.toFixed(8));
fd.append('lng', nLng.toFixed(8));
fd.append('ibadah_id', ibadahBaru ? ibadahBaru.id : '');
fetch('api/penduduk/update_posisi.php', { method: 'POST', body: appendCsrf(fd) })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
showToast(`Posisi "${escapeHTML(data.nama_kk)}" diperbarui.`);
} else throw new Error(j.message);
})
.catch(err => showToast(err.message || 'Gagal update posisi.', 'error'));
});
cm.addTo(layerGroup);
dragHandle.addTo(layerGroup);
allMarkers[data.id] = { cm, dragHandle, data };
}
// ── Recalculate semua warna penduduk (dipanggil saat ibadah berubah) ──
window._pendudukRecalcAll = function () {
Object.values(allMarkers).forEach(({ cm, data }) => {
if (!cm) return; // publik — tidak ada marker
const ll = cm.getLatLng();
const ibadah = findNearestIbadah(ll.lat, ll.lng);
applyColor(cm, ibadah);
const updatedData = { ...data, lat: ll.lat, lng: ll.lng, is_blank_spot: ibadah ? 0 : 1 };
allMarkers[data.id].data = updatedData;
cm.bindPopup(buildInfoPopup(updatedData, ibadah), { maxWidth: 280 });
});
updatePendudukList();
refreshList();
};
// ── Load data ─────────────────────────────────────────────────────
function loadData() {
// Reset blank spot filter so imported/reloaded data renders unfiltered
_blankSpotFilterActive = false;
const btn = document.getElementById('btnBlankSpot');
if (btn) btn.style.background = 'transparent';
layerGroup.clearLayers();
allMarkers = {};
fetch('api/penduduk/ambil.php?_=' + Date.now(), { cache: 'no-store' })
.then(r => r.json())
.then(j => {
if (j.status !== 'success') throw new Error(j.message);
j.data.forEach(addToMap);
updatePendudukList();
refreshList();
})
.catch(err => {
showToast('Gagal memuat data penduduk.', 'error');
console.error(err);
});
}
// ── Refresh panel list ────────────────────────────────────────────
window._pendudukSetFilter = function () {
filterState.search = (document.getElementById('searchPenduduk')?.value || '').toLowerCase().trim();
filterState.kategori = document.getElementById('filterKategori')?.value || '';
filterState.keterjangkauan = document.getElementById('filterKeterjangkauan')?.value || '';
filterState.jenisIbadah = document.getElementById('filterJenisIbadah')?.value || '';
filterState.statusBantuan = document.getElementById('filterStatusBantuan')?.value || '';
filterState.kebutuhanKategori = document.getElementById('filterKebutuhanKategori')?.value || '';
filterState.kebutuhanStatus = document.getElementById('filterKebutuhanStatus')?.value || '';
applyFilters();
};
// Update kebutuhan_open count in live data + badge (called by kebutuhan.js)
window._pendudukRefreshKebutuhanCount = function (pendudukId, newCount) {
const entry = allMarkers[pendudukId];
if (!entry) return;
entry.data.kebutuhan_open = newCount;
const badgeEl = document.getElementById('kebutuhanBadge_' + pendudukId);
if (badgeEl) {
if (newCount > 0) {
badgeEl.textContent = newCount + ' Belum Terpenuhi';
badgeEl.style.cssText = 'background:#ef444422;color:#ef4444;border:1px solid #ef444444;font-size:10px;font-weight:700;padding:2px 7px;border-radius:20px;margin-left:4px;';
} else {
badgeEl.textContent = '';
badgeEl.style.cssText = '';
}
}
};
window._setPendudukSubLayerVisible = function (sub, visible) {
subLayerVisible[sub] = visible;
applyFilters();
};
function applyFilters() {
Object.values(allMarkers).forEach(function (m) {
// Entry publik tanpa marker — skip layer toggle
if (!m.cm) return;
const d = m.data;
let show = true;
// Sub-layer toggles
if (parseInt(d.is_blank_spot) === 1) {
if (!subLayerVisible.blankspot) show = false;
} else {
if (!subLayerVisible.terjangkau) show = false;
}
// Search: nama KK atau nama ibadah (OR), AND dengan filter lain
if (show && filterState.search) {
const q = filterState.search;
const matchKK = (d.nama_kk || '').toLowerCase().includes(q);
const matchIbadah = (d.nama_ibadah || '').toLowerCase().includes(q);
if (!matchKK && !matchIbadah) show = false;
}
// Kategori
if (show && filterState.kategori && d.kategori !== filterState.kategori) show = false;
// Keterjangkauan
if (show && filterState.keterjangkauan) {
if (filterState.keterjangkauan === 'terjangkau' && parseInt(d.is_blank_spot) === 1) show = false;
if (filterState.keterjangkauan === 'blankspot' && parseInt(d.is_blank_spot) !== 1) show = false;
}
// Jenis ibadah terdekat
if (show && filterState.jenisIbadah && d.jenis_ibadah !== filterState.jenisIbadah) show = false;
// Status bantuan
if (show && filterState.statusBantuan && d.status_bantuan !== filterState.statusBantuan) show = false;
// Kebutuhan — kategori (cek apakah ada kebutuhan open di kategori tsb)
if (show && filterState.kebutuhanKategori) {
const openCats = (d.kebutuhan_kategori_open || '').split(',').filter(Boolean);
if (!openCats.includes(filterState.kebutuhanKategori)) show = false;
}
// Kebutuhan — status (ada ≥1 kebutuhan dalam status tsb)
if (show && filterState.kebutuhanStatus) {
const ks = filterState.kebutuhanStatus;
if (ks === 'Belum Terpenuhi' && !(parseInt(d.kebutuhan_open) > 0)) show = false;
if (ks === 'Dalam Proses' && !(parseInt(d.kebutuhan_proses) > 0)) show = false;
if (ks === 'Terpenuhi' && !(parseInt(d.kebutuhan_terpenuhi) > 0)) show = false;
}
if (show) {
if (!layerGroup.hasLayer(m.cm)) m.cm.addTo(layerGroup);
if (!layerGroup.hasLayer(m.dragHandle)) m.dragHandle.addTo(layerGroup);
} else {
if (layerGroup.hasLayer(m.cm)) layerGroup.removeLayer(m.cm);
if (layerGroup.hasLayer(m.dragHandle)) layerGroup.removeLayer(m.dragHandle);
}
});
updatePendudukList();
refreshList();
}
function updatePendudukList() {
function toListItem(m) {
const hasMarker = !!m.cm;
const ibadah = hasMarker ? m.cm._nearestIbadah : m._ibadahRef;
return {
id : m.data.id,
lat : hasMarker ? m.cm.getLatLng().lat : (m.data.lat ?? null),
lng : hasMarker ? m.cm.getLatLng().lng : (m.data.lng ?? null),
jiwa : parseInt(m.data.jumlah_jiwa) || 1,
jumlah_jiwa : parseInt(m.data.jumlah_jiwa) || 1,
nama_kk : m.data.nama_kk,
kategori : m.data.kategori || 'Miskin',
is_blank_spot : parseInt(m.data.is_blank_spot) || 0,
status_bantuan : m.data.status_bantuan || 'Belum Ditangani',
ibadah_jenis : m.data.jenis_ibadah,
ibadah_nama : m.data.nama_ibadah,
isRadius : !!ibadah,
ibadah : ibadah,
kebutuhan_open : parseInt(m.data.kebutuhan_open) || 0,
kebutuhan_proses : parseInt(m.data.kebutuhan_proses) || 0,
kebutuhan_terpenuhi : parseInt(m.data.kebutuhan_terpenuhi) || 0,
kebutuhan_kategori_open : m.data.kebutuhan_kategori_open || '',
};
}
const entries = Object.values(allMarkers);
const visibleEntries = entries.filter(m => !m.cm || layerGroup.hasLayer(m.cm));
window._pendudukAll = entries.map(toListItem);
window._pendudukVisible = visibleEntries.map(toListItem);
window._pendudukList = window._pendudukVisible;
console.log('[Penduduk] List updated, all:', window._pendudukAll.length, 'visible:', window._pendudukVisible.length);
if (typeof window._statsUpdate === 'function') window._statsUpdate();
if (typeof window._heatmapUpdate === 'function') window._heatmapUpdate();
if (typeof window._updateBlankSpotCounter === 'function') window._updateBlankSpotCounter();
}
function refreshList() {
const listEl = document.getElementById('dlListPenduduk');
const countEl = document.getElementById('dlCountPenduduk');
const headerEl = document.getElementById('dlHeaderPenduduk');
if (!listEl) return;
const entries = Object.values(allMarkers).filter(m => !m.cm || layerGroup.hasLayer(m.cm));
const total = entries.length;
if (countEl) countEl.textContent = total;
if (!total) {
listEl.innerHTML = '<div class="dl-empty">Belum ada data.</div>';
return;
}
const cats = [
{ key: 'Sangat Miskin', color: '#ef4444' },
{ key: 'Miskin', color: '#f59e0b' },
{ key: 'Hampir Miskin', color: '#e3b341' },
];
const counts = {}, jiwaMap = {};
entries.forEach(m => {
const k = m.data.kategori || 'Miskin';
counts[k] = (counts[k] || 0) + 1;
jiwaMap[k] = (jiwaMap[k] || 0) + (parseInt(m.data.jumlah_jiwa) || 1);
});
listEl.innerHTML = cats
.filter(c => counts[c.key])
.map(c => {
const n = counts[c.key];
const j = jiwaMap[c.key];
return '<div class="dl-item" style="cursor:default;">' +
'<span class="dl-item-dot" style="background:' + c.color + ';"></span>' +
'<span class="dl-item-name" style="color:var(--sb-text);">' + c.key + '</span>' +
'<span class="dl-item-badge" style="color:' + c.color + ';border-color:' + c.color + '22;background:' + c.color + '11;">' +
n + ' KK · ' + j + ' jiwa' +
'</span>' +
'</div>';
}).join('');
if (headerEl) headerEl.classList.add('open');
listEl.classList.add('open');
}
// ── Hapus (soft delete — untuk data yang keliru diinput) ──────────
window._pendudukHapus = function (id, btnEl) {
showDeleteConfirm('Hapus data ini? Tindakan ini untuk data yang diinput salah.').then(confirmed => {
if (!confirmed) return;
btnEl.disabled = true;
btnEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menghapus...';
lucide.createIcons();
const fd = new FormData();
fd.append('id', id);
fetch('api/penduduk/hapus.php', { method: 'POST', body: appendCsrf(fd) })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
map.closePopup();
if (allMarkers[id]) {
layerGroup.removeLayer(allMarkers[id].cm);
layerGroup.removeLayer(allMarkers[id].dragHandle);
delete allMarkers[id];
}
updatePendudukList();
refreshList();
showToast('Data penduduk dihapus (soft delete).');
} else throw new Error(j.message);
})
.catch(err => {
showToast(err.message || 'Gagal menghapus.', 'error');
btnEl.disabled = false;
btnEl.innerHTML = '<i data-lucide="trash-2" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Hapus Data';
lucide.createIcons();
});
});
};
// ── Nonaktifkan (warga meninggal / pindah — arsip tetap ada) ─────
window._pendudukNonaktifkan = function (id, btnEl) {
showDeleteConfirm(
'Tandai warga ini sebagai tidak aktif (meninggal/pindah)? Data akan disimpan untuk arsip.',
{ type: 'warn', iconName: 'user-x', title: 'Konfirmasi Nonaktifkan', btnLabel: 'Ya, Nonaktifkan' }
).then(confirmed => {
if (!confirmed) return;
btnEl.disabled = true;
btnEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Memproses...';
lucide.createIcons();
const fd = new FormData();
fd.append('id', id);
fetch('api/penduduk/nonaktifkan.php', { method: 'POST', body: appendCsrf(fd) })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
map.closePopup();
if (allMarkers[id]) {
layerGroup.removeLayer(allMarkers[id].cm);
layerGroup.removeLayer(allMarkers[id].dragHandle);
delete allMarkers[id];
}
updatePendudukList();
refreshList();
showToast('Warga dinonaktifkan (arsip tersimpan).');
} else throw new Error(j.message);
})
.catch(err => {
showToast(err.message || 'Gagal.', 'error');
btnEl.disabled = false;
btnEl.innerHTML = '<i data-lucide="user-x" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Nonaktifkan';
lucide.createIcons();
});
});
};
// ── Klik peta: Form tambah penduduk ──────────────────────────────
function onMapClick(e) {
if (typeof activeTool === 'undefined' || activeTool !== 'penduduk') return;
const lat = e.latlng.lat.toFixed(8);
const lng = e.latlng.lng.toFixed(8);
if (tempMarker) map.removeLayer(tempMarker);
// Preview warna sebelum disimpan
const ibadahPreview = findNearestIbadah(parseFloat(lat), parseFloat(lng));
const colorPreview = ibadahPreview ? COLOR_TERJANGKAU : COLOR_BLANK_SPOT;
const statusPreview = ibadahPreview
? `<span style="color:#22c55e;display:inline-flex;align-items:center;gap:4px;"><i data-lucide="check" style="width:12px;height:12px;"></i> Dalam radius <b>${escapeHTML(ibadahPreview.nama)}</b> (${ibadahPreview.jarak}m)</span>`
: `<span style="color:#ef4444;display:inline-flex;align-items:center;gap:4px;"><i data-lucide="alert-triangle" style="width:12px;height:12px;"></i> Di luar semua radius ibadah (Blank Spot)</span>`;
tempMarker = L.circleMarker([lat, lng], {
radius: 10, color: colorPreview, fillColor: colorPreview, fillOpacity: 0.4, weight: 2
}).addTo(map);
L.popup({ maxWidth: 300, closeOnClick: false })
.setLatLng(e.latlng)
.setContent(`<div class="form-popup">
<div class="form-popup-header">
<div class="form-popup-icon" style="background:rgba(34,197,94,.12);"><i data-lucide="home"></i></div>
<div>
<div class="form-popup-title">Tambah Penduduk Miskin</div>
<div class="form-popup-coords">${lat}, ${lng}</div>
</div>
</div>
<div style="padding:8px 12px;background:rgba(255,255,255,0.04);border-radius:7px;font-size:12px;margin-bottom:14px;color:var(--c-muted);">
${statusPreview}
</div>
<div class="form-group">
<label>Nama Kepala Keluarga *</label>
<input type="text" id="f_pddk_nama" placeholder="cth. Budi Santoso" maxlength="150" autocomplete="off">
</div>
<div class="form-group">
<label>NIK</label>
<input type="text" id="f_pddk_nik" placeholder="16 digit NIK" maxlength="16" autocomplete="off">
</div>
<div class="form-group">
<label>Kategori</label>
<select id="f_pddk_kategori">
<option value="Sangat Miskin">Sangat Miskin</option>
<option value="Miskin" selected>Miskin</option>
<option value="Hampir Miskin">Hampir Miskin</option>
</select>
</div>
<div class="form-group">
<label>Jumlah Jiwa</label>
<input type="number" id="f_pddk_jiwa" value="1" min="1" max="99">
</div>
<div class="form-group">
<label>Alamat (Otomatis)</label>
<textarea id="f_pddk_alamat" placeholder="Mendeteksi alamat..." rows="2">Mendeteksi alamat...</textarea>
</div>
<div class="form-group">
<label>Catatan Kondisi Khusus</label>
<textarea id="f_pddk_catatan" placeholder="Kebutuhan khusus, kondisi rumah, dll." rows="2"
style="width:100%;padding:8px 12px;background:var(--c-bg);border:1px solid var(--c-border);
border-radius:7px;color:var(--c-text);font-family:var(--font-body);font-size:13px;
outline:none;resize:vertical;"></textarea>
</div>
<input type="hidden" id="f_pddk_lat" value="${lat}">
<input type="hidden" id="f_pddk_lng" value="${lng}">
<input type="hidden" id="f_pddk_ibadah_id" value="${ibadahPreview ? ibadahPreview.id : ''}">
<button class="btn-save" id="btnSimpanPenduduk" onclick="window._pendudukSimpan()"
style="background:linear-gradient(135deg,#16a34a,#22c55e);display:inline-flex;align-items:center;justify-content:center;gap:4px;">
<i data-lucide="save" style="width:14px;height:14px;"></i> Simpan Penduduk
</button>
<div class="form-status" id="pendudukStatus"></div>
</div>`)
.openOn(map);
// Hapus temp marker saat popup dibatalkan/ditutup
const _markerThisClick = tempMarker;
map.once('popupclose', function () {
if (_markerThisClick) { try { map.removeLayer(_markerThisClick); } catch(_){} }
if (tempMarker === _markerThisClick) tempMarker = null;
});
// Reverse Geocoding via Nominatim
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&accept-language=id`, {
headers: { 'Accept-Language': 'id' }
})
.then(r => r.json())
.then(geo => {
const alamat = geo.display_name || '';
const el = document.getElementById('f_pddk_alamat');
if (el) el.value = alamat;
})
.catch(() => {
const el = document.getElementById('f_pddk_alamat');
if (el) el.value = '';
});
setTimeout(() => { const el = document.getElementById('f_pddk_nama'); if (el) el.focus(); }, 200);
}
// ── Simpan ────────────────────────────────────────────────────────
window._pendudukSimpan = function () {
const nama = document.getElementById('f_pddk_nama').value.trim();
const nik = document.getElementById('f_pddk_nik').value.trim();
const kategori = document.getElementById('f_pddk_kategori').value;
const alamat = document.getElementById('f_pddk_alamat').value.trim();
const jiwa = parseInt(document.getElementById('f_pddk_jiwa').value) || 1;
const lat = document.getElementById('f_pddk_lat').value;
const lng = document.getElementById('f_pddk_lng').value;
const ibadahId = document.getElementById('f_pddk_ibadah_id').value;
const status = document.getElementById('pendudukStatus');
const btn = document.getElementById('btnSimpanPenduduk');
if (!nama) {
status.textContent = '⚠ Nama kepala keluarga wajib diisi.';
status.className = 'form-status error';
document.getElementById('f_pddk_nama').focus();
return;
}
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> Menyimpan...';
lucide.createIcons();
const fd = new FormData();
fd.append('nama_kk', nama);
fd.append('nik', nik);
fd.append('kategori', kategori);
fd.append('alamat', alamat);
fd.append('catatan', document.getElementById('f_pddk_catatan')?.value?.trim() || '');
fd.append('jumlah_jiwa', jiwa);
fd.append('lat', lat);
fd.append('lng', lng);
fd.append('ibadah_id', ibadahId);
fetch('api/penduduk/simpan.php', { method: 'POST', body: appendCsrf(fd) })
.then(r => r.json())
.then(j => {
if (j.status === 'success') {
map.closePopup();
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
addToMap(j.data);
updatePendudukList();
refreshList();
if (j.data.is_blank_spot) {
showToast(`⚠ "${escapeHTML(nama)}" ditambahkan sebagai BLANK SPOT — tidak terjangkau ibadah manapun.`, 'error');
} else {
showToast(`"${escapeHTML(nama)}" berhasil ditambahkan!`);
}
} else throw new Error(j.message);
})
.catch(err => {
status.innerHTML = '<i data-lucide="x" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i> ' + escapeHTML(err.message);
status.className = 'form-status error';
btn.disabled = false;
btn.innerHTML = '<i data-lucide="save" style="width:14px;height:14px;vertical-align:middle;margin-right:4px;"></i> Simpan Penduduk';
lucide.createIcons();
});
};
// ── Update status bantuan ─────────────────────────────────────────
window._updateStatus = function (id, newStatus, btnEl) {
btnEl.disabled = true;
const catatan = prompt('Catatan perubahan status (opsional):', '');
if (catatan === null) {
btnEl.disabled = false;
return;
}
const fd = new FormData();
fd.append('id', id);
fd.append('status', newStatus);
fd.append('catatan', catatan);
fetch('api/penduduk/update_status.php', { method: 'POST', body: appendCsrf(fd) })
.then(r => r.json())
.then(j => {
if (j.status !== 'success') throw new Error(j.message);
const entry = allMarkers[id];
if (entry && entry.cm) {
entry.data.status_bantuan = newStatus;
const ibadah = entry.cm._nearestIbadah;
entry.cm.bindPopup(buildInfoPopup(entry.data, ibadah), { maxWidth: 280 });
entry.cm.openPopup();
}
updatePendudukList();
refreshList();
if (typeof window.dispatchDataChanged === 'function') {
window.dispatchDataChanged('penduduk', { id, mutation: 'status_bantuan' });
}
showToast('Status bantuan diperbarui.');
})
.catch(err => {
btnEl.disabled = false;
showToast(err.message || 'Gagal memperbarui status.', 'error');
});
};
// ── Lihat riwayat bantuan ─────────────────────────────────────────
window._showRiwayat = function (id) {
fetch('api/penduduk/riwayat.php?id=' + id + '&_=' + Date.now(), { cache: 'no-store' })
.then(r => r.json())
.then(j => {
if (j.status !== 'success') throw new Error(j.message);
const rows = j.data;
const tbody = rows.length === 0
? '<tr><td colspan="4" style="text-align:center;padding:12px;color:var(--c-muted);">Belum ada riwayat.</td></tr>'
: rows.map(r => {
const lama = r.status_lama || '—';
const baru = r.status_baru || '—';
const tgl = r.created_at ? r.created_at.substring(0, 16) : '—';
return `<tr style="border-bottom:1px solid var(--c-border);">
<td style="padding:6px 8px;font-size:12px;">${escapeHTML(tgl)}</td>
<td style="padding:6px 8px;font-size:12px;color:${STATUS_COLOR[baru]||'#ccc'};font-weight:700;">${escapeHTML(baru)}</td>
<td style="padding:6px 8px;font-size:12px;color:var(--c-muted);">${escapeHTML(lama)}</td>
<td style="padding:6px 8px;font-size:12px;color:var(--c-muted);">${escapeHTML(r.user_nama||'—')}</td>
</tr>`;
}).join('');
let overlay = document.getElementById('_riwayatOverlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = '_riwayatOverlay';
overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;';
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
}
overlay.innerHTML = `
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;
padding:20px;width:min(480px,94vw);max-height:80vh;overflow-y:auto;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;">
<div style="font-weight:700;font-size:14px;color:var(--c-text);">Riwayat Bantuan</div>
<button onclick="document.getElementById('_riwayatOverlay').remove()"
style="background:none;border:none;color:var(--c-muted);font-size:18px;cursor:pointer;">×</button>
</div>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:1px solid var(--c-border);">
<th style="text-align:left;padding:6px 8px;font-size:11px;color:var(--c-muted);font-weight:600;">Waktu</th>
<th style="text-align:left;padding:6px 8px;font-size:11px;color:var(--c-muted);font-weight:600;">Status Baru</th>
<th style="text-align:left;padding:6px 8px;font-size:11px;color:var(--c-muted);font-weight:600;">Sebelumnya</th>
<th style="text-align:left;padding:6px 8px;font-size:11px;color:var(--c-muted);font-weight:600;">Oleh</th>
</tr>
</thead>
<tbody>${tbody}</tbody>
</table>
</div>
</div>`;
})
.catch(err => showToast(err.message || 'Gagal memuat riwayat.', 'error'));
};
// ── Blank Spot counter + filter ───────────────────────────────────
window._updateBlankSpotCounter = function () {
const count = Object.values(allMarkers).filter(m => m.cm && !m.cm._nearestIbadah).length;
const btn = document.getElementById('btnBlankSpot');
const lbl = document.getElementById('blankSpotCount');
if (!btn || !lbl) return;
lbl.textContent = count + ' Blank Spot';
btn.style.display = count > 0 ? '' : 'none';
};
let _blankSpotFilterActive = false;
window._filterBlankSpot = function () {
_blankSpotFilterActive = !_blankSpotFilterActive;
const btn = document.getElementById('btnBlankSpot');
if (btn) btn.style.background = _blankSpotFilterActive ? 'rgba(248,81,73,.15)' : 'transparent';
Object.values(allMarkers).forEach(m => {
if (!m.cm) return; // publik — tidak ada marker
const show = !_blankSpotFilterActive || !m.cm._nearestIbadah;
if (show) {
if (!layerGroup.hasLayer(m.cm)) {
m.cm.addTo(layerGroup);
m.dragHandle.addTo(layerGroup);
}
} else {
layerGroup.removeLayer(m.cm);
layerGroup.removeLayer(m.dragHandle);
}
});
updatePendudukList();
refreshList();
};
// ── Reload dari server (dipanggil setelah recalculate massal) ────
window._pendudukReload = function () {
loadData();
};
// ── FlyTo penduduk marker (dipanggil dari popup ibadah KK list) ───
window._pendudukFlyTo = function (id) {
const entry = allMarkers[id];
if (!entry || !entry.cm) return; // publik tidak punya marker
map.closePopup();
map.flyTo(entry.cm.getLatLng(), Math.max(map.getZoom(), 17), { animate: true, duration: 0.8 });
setTimeout(() => entry.cm.openPopup(), 900);
};
// ── Verifikasi warga (admin only) ────────────────────────────────
window._verifikasiPenduduk = function (id, aksi, btnEl) {
const entry = allMarkers[id];
const label = aksi === 'approve' ? 'Terverifikasi' : 'Ditolak';
const nama = entry?.data?.nama_kk ? `"${entry.data.nama_kk}"` : `#${id}`;
const title = aksi === 'approve' ? 'Konfirmasi Verifikasi' : 'Konfirmasi Tolak';
const msg = `Ubah status verifikasi ${nama} menjadi <strong>${label}</strong>?`;
showDeleteConfirm(
`Ubah status verifikasi ${entry?.data?.nama_kk || '#'+id} menjadi ${label}?`,
{
type: 'warn',
iconName: aksi === 'approve' ? 'check-circle' : 'x-circle',
title,
btnLabel: aksi === 'approve' ? 'Ya, Verifikasi' : 'Ya, Tolak'
}
).then(confirmed => {
if (!confirmed) return;
if (btnEl) {
btnEl.disabled = true;
btnEl.innerHTML = '<i data-lucide="loader" class="animate-spin" style="width:12px;height:12px;vertical-align:middle;margin-right:4px;"></i>';
lucide.createIcons();
}
const fd = new FormData();
fd.append('id', id);
fd.append('aksi', aksi);
fetch('api/penduduk/verifikasi.php', { method: 'POST', body: appendCsrf(fd) })
.then(r => r.json())
.then(j => {
if (j.status !== 'success') throw new Error(j.message);
const lbl = aksi === 'approve' ? 'Terverifikasi' : 'Ditolak';
showToast(`Data #${id}${lbl}`, 'success');
if (entry) {
entry.data.status_verifikasi = j.status_verifikasi;
if (entry.cm) {
const ibadah = entry.cm._nearestIbadah;
entry.cm.bindPopup(buildInfoPopup(entry.data, ibadah), { maxWidth: 280 });
entry.cm.openPopup();
}
}
refreshList();
})
.catch(err => {
showToast(err.message || 'Gagal memperbarui verifikasi.', 'error');
if (btnEl) btnEl.disabled = false;
});
});
};
// ── Init ─────────────────────────────────────────────────────────
window.initPenduduk = function () {
if (active) { loadData(); return; }
active = true;
layerGroup.addTo(map);
map.on('click', onMapClick);
window._dlFocusFns['Penduduk'] = function (id) {
const entry = allMarkers[id];
if (!entry || !entry.cm) return; // publik tidak punya marker
map.setView(entry.cm.getLatLng(), Math.max(map.getZoom(), 17), { animate: true });
setTimeout(() => entry.cm.openPopup(), 350);
};
loadData();
};
// ── Toggle visibilitas layer ──────────────────────────────────────
window._pendudukToggleLayer = function (visible) {
if (visible) {
if (!map.hasLayer(layerGroup)) map.addLayer(layerGroup);
} else {
map.removeLayer(layerGroup);
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
}
};
})();
+210
View File
@@ -0,0 +1,210 @@
// modules/stats.js — Dashboard statistik role-aware (v1.0)
(function () {
let charts = { kategori: null };
window._statsUpdate = function () {
const modal = document.getElementById('modalStats');
if (!modal) return;
if (modal.classList.contains('show')) {
loadAndRender();
}
};
window._showDashboard = function () {
const modal = document.getElementById('modalStats');
if (!modal) return;
modal.classList.add('show');
loadAndRender();
};
window._hideDashboard = function () {
const modal = document.getElementById('modalStats');
if (!modal) return;
modal.classList.remove('show');
};
function loadAndRender() {
fetch('api/stats/ambil.php?_=' + Date.now(), { cache: 'no-store' })
.then(r => r.json())
.then(j => {
if (j.status !== 'success') return;
renderCards(j.data);
renderKategoriChart(j.data.kategori || []);
renderRekapTable(j.data.rekap_ibadah ?? null);
renderRekapKebutuhan(j.data.rekap_kebutuhan ?? null);
updateDonaturBadge(j.data.donatur_unread ?? 0);
})
.catch(err => console.error('[Stats]', err));
}
function set(id, val) {
const el = document.getElementById(id);
if (el) el.textContent = val;
}
function updateDonaturBadge(count) {
const badge = document.getElementById('donaturBadge');
if (!badge) return;
badge.textContent = count > 0 ? count : '';
badge.style.display = count > 0 ? '' : 'none';
}
function renderCards(d) {
set('statTotalIbadah', d.total_ibadah);
set('statTotalKK', d.total_kk);
set('statTotalJiwa', d.total_jiwa);
set('statJiwaTerjangkau', d.jiwa_terjangkau);
set('statJiwaBlankSpot', d.jiwa_blank_spot);
set('statPctCakupan', d.pct_cakupan + '%');
set('statKebutuhanOpen', d.kk_kebutuhan_open ?? 0);
const emptyMsg = document.getElementById('statsEmptyMsg');
if (emptyMsg) {
emptyMsg.style.display = (d.total_kk === 0 && d.total_ibadah === 0) ? 'block' : 'none';
}
}
function renderKategoriChart(rows) {
const el = document.getElementById('chartKategori');
if (!el) return;
const labels = rows.map(r => r.kategori);
const data = rows.map(r => parseInt(r.jml_jiwa));
const colors = {
'Sangat Miskin': '#f85149',
'Miskin' : '#e3b341',
'Hampir Miskin' : '#388bfd',
};
const bgColors = labels.map(l => (colors[l] || '#8b949e') + '99');
const bdColors = labels.map(l => colors[l] || '#8b949e');
if (charts.kategori) charts.kategori.destroy();
charts.kategori = new Chart(el.getContext('2d'), {
type: 'doughnut',
data: {
labels,
datasets: [{ data, backgroundColor: bgColors, borderColor: bdColors, borderWidth: 2, hoverOffset: 6 }]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { color: '#8b949e', font: { size: 11 }, padding: 14 }
}
}
}
});
}
function renderRekapTable(rows) {
const wrapper = document.getElementById('rekapTableWrapper');
if (!wrapper) return;
if (rows === null) { wrapper.style.display = 'none'; return; }
wrapper.style.display = '';
if (rows.length === 0) {
wrapper.innerHTML = '<p style="color:var(--c-muted);font-size:13px;text-align:center;padding:16px 0;">Belum ada data rumah ibadah.</p>';
return;
}
function esc(s) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(s ?? ''));
return d.innerHTML;
}
const tbody = rows.map(r => {
const pct = r.jml_kk > 0 ? Math.round(r.jml_ditangani / r.jml_kk * 100) : 0;
return `<tr>
<td style="padding:6px 10px">${esc(r.nama)}</td>
<td style="padding:6px 10px;color:var(--c-muted)">${esc(r.jenis)}</td>
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono)">${r.jml_kk}</td>
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono)">${r.jml_jiwa}</td>
<td style="padding:6px 10px;text-align:right">
<span style="color:${pct>=60?'#3fb950':'#e3b341'};font-weight:700;font-family:var(--font-mono)">${pct}%</span>
</td>
</tr>`;
}).join('');
wrapper.innerHTML = `
<div style="font-size:11px;font-weight:700;color:var(--c-muted);text-transform:uppercase;
letter-spacing:.6px;margin-bottom:10px;">Rekapitulasi per Rumah Ibadah</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:12px;">
<thead>
<tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:6px 10px;color:var(--c-muted);font-weight:600">Nama</th>
<th style="text-align:left;padding:6px 10px;color:var(--c-muted);font-weight:600">Jenis</th>
<th style="text-align:right;padding:6px 10px;color:var(--c-muted);font-weight:600">KK</th>
<th style="text-align:right;padding:6px 10px;color:var(--c-muted);font-weight:600">Jiwa</th>
<th style="text-align:right;padding:6px 10px;color:var(--c-muted);font-weight:600">% Ditangani</th>
</tr>
</thead>
<tbody>${tbody}</tbody>
</table>
</div>`;
}
function renderRekapKebutuhan(rows) {
const wrapper = document.getElementById('rekapKebutuhanWrapper');
if (!wrapper) return;
if (rows === null) { wrapper.style.display = 'none'; return; }
wrapper.style.display = '';
if (rows.length === 0) {
wrapper.innerHTML = '<p style="color:var(--c-muted);font-size:13px;text-align:center;padding:16px 0;">Belum ada kebutuhan tercatat.</p>';
return;
}
function esc(s) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(s ?? ''));
return d.innerHTML;
}
const ICON = {
'Sembako': 'shopping-cart',
'Biaya Sekolah': 'graduation-cap',
'Biaya Kesehatan': 'heart-pulse',
'Modal Usaha': 'briefcase',
'Renovasi Rumah': 'home',
'Perlengkapan Rumah': 'armchair',
'Pakaian': 'shirt',
'Lainnya': 'clipboard-list'
};
const tbody = rows.map(r => {
const iconName = ICON[r.kategori] || 'clipboard-list';
const iconHtml = `<i data-lucide="${iconName}" class="lucide-inline" style="width:14px;height:14px;vertical-align:middle;margin-right:6px;"></i>`;
return `<tr style="border-bottom:1px solid rgba(255,255,255,.05)">
<td style="padding:6px 10px;display:flex;align-items:center;gap:2px;">${iconHtml} ${esc(r.kategori)}</td>
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono);color:#ef4444">${r.belum}</td>
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono);color:#e3b341">${r.proses}</td>
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono);color:#3fb950">${r.terpenuhi}</td>
<td style="padding:6px 10px;text-align:right;font-family:var(--font-mono);color:var(--c-muted)">${r.total}</td>
</tr>`;
}).join('');
wrapper.innerHTML = `
<div style="font-size:11px;font-weight:700;color:var(--c-muted);text-transform:uppercase;
letter-spacing:.6px;margin-bottom:10px;">Rekapitulasi Kebutuhan per Kategori</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:12px;">
<thead>
<tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:6px 10px;color:var(--c-muted);font-weight:600">Kategori</th>
<th style="text-align:right;padding:6px 10px;color:#ef4444;font-weight:600">Belum</th>
<th style="text-align:right;padding:6px 10px;color:#e3b341;font-weight:600">Proses</th>
<th style="text-align:right;padding:6px 10px;color:#3fb950;font-weight:600">Terpenuhi</th>
<th style="text-align:right;padding:6px 10px;color:var(--c-muted);font-weight:600">Total</th>
</tr>
</thead>
<tbody>${tbody}</tbody>
</table>
</div>`;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
})();