Files
C_UAS_WEBGIS_AndrieWijaya_D…/WebgisPovertyMapping/modules/penduduk.js
T
2026-06-11 18:14:21 +07:00

976 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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; }
}
};
})();