chore: prepare docker webgis deployment
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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 · <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
|
||||
·
|
||||
<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>
|
||||
·
|
||||
<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);
|
||||
}
|
||||
});
|
||||
};
|
||||
})();
|
||||
@@ -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'));
|
||||
};
|
||||
})();
|
||||
@@ -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; }
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user