// 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
? `
Rumah Ibadah Terdekat
${escapeHTML(ibadah.nama)}
${ibadah.jarak}m · Radius ${ibadah.radius || 500}m · ${ibadah.jenis || ''}
`
: `
Status Wilayah
Di luar semua radius ibadah (Blank Spot)
`;
const nikHtml = (data.nik && window.APP_USER && window.APP_USER.loggedIn)
? `NIK: ${escapeHTML(data.nik)}
` : '';
const catatanHtml = (data.catatan && window.APP_USER && window.APP_USER.loggedIn)
? `
Catatan Kondisi
${escapeHTML(data.catatan)}
` : '';
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 =>
``
).join('')
: `Menunggu verifikasi admin`;
const statusSection = isOp
? `
Status Bantuan
${escapeHTML(st)}
${statusBtns}
` : '';
// ── Badge & aksi verifikasi ───────────────────────────────
const svClr = { Terverifikasi: '#22c55e', Pending: '#e3b341', Ditolak: '#ef4444' }[sv] || '#8b949e';
const svIcon = {
Terverifikasi: '',
Pending: '',
Ditolak: ''
}[sv] || '';
const verifBadge = (isAdmin || isOp)
? `
${svIcon}${sv}
` : '';
const verifActions = (isAdmin && sv === 'Pending')
? `
` : '';
const actionBtns = isAdmin
? `
` : '';
const kebutuhanSection = (window.APP_USER && window.APP_USER.loggedIn && typeof window._kebutuhanBuildSection === 'function')
? window._kebutuhanBuildSection(data.id, data.kebutuhan_open, sv)
: '';
return ``;
}
// ── 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: '',
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 = 'Belum ada data.
';
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 '' +
'' +
'' + c.key + '' +
'' +
n + ' KK · ' + j + ' jiwa' +
'' +
'
';
}).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 = ' 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 = ' 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 = ' 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 = ' 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
? ` Dalam radius ${escapeHTML(ibadahPreview.nama)} (${ibadahPreview.jarak}m)`
: ` Di luar semua radius ibadah (Blank Spot)`;
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(``)
.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 = ' 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 = ' ' + escapeHTML(err.message);
status.className = 'form-status error';
btn.disabled = false;
btn.innerHTML = ' 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
? '| Belum ada riwayat. |
'
: 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 `
| ${escapeHTML(tgl)} |
${escapeHTML(baru)} |
${escapeHTML(lama)} |
${escapeHTML(r.user_nama||'—')} |
`;
}).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 = `
| Waktu |
Status Baru |
Sebelumnya |
Oleh |
${tbody}
`;
})
.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 ${label}?`;
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 = '';
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; }
}
};
})();