mengubah sistem menjadi lebih terfokus ke poverty

This commit is contained in:
Abimanyu Ridho
2026-06-11 01:50:37 +07:00
parent 739e360018
commit 468541b1c8
46 changed files with 5753 additions and 2805 deletions
+152 -300
View File
@@ -1,84 +1,22 @@
'use strict';
const API_BASE = 'api';
const CENTER = [-0.0263, 109.3425];
const map = L.map('map', { zoomControl: false }).setView(CENTER, 13);
L.control.zoom({ position: 'topleft' }).addTo(map);
// ─── Map globals (only set on map page) ─────────────────────────────────────
let map = null;
let layers = null;
let layerVisible = null;
const baseLayers = {
'OSM Standard': L.tileLayer(
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{ attribution: '© OpenStreetMap', maxZoom: 19 }
),
'CartoDB Light': L.tileLayer(
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
{ attribution: '© CartoDB', maxZoom: 19 }
),
'Satellite (Esri)': L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{ attribution: '© Esri', maxZoom: 19 }
)
};
baseLayers['OSM Standard'].addTo(map);
L.control.layers(baseLayers, {}, { position: 'bottomright', collapsed: true }).addTo(map);
const layers = {
rumahIbadah: L.layerGroup().addTo(map),
ibadahRadius: L.layerGroup().addTo(map),
pendudukMiskin: L.layerGroup().addTo(map),
spbu24: L.layerGroup().addTo(map),
spbuNon24: L.layerGroup().addTo(map),
jalanNasional: L.layerGroup().addTo(map),
jalanProvinsi: L.layerGroup().addTo(map),
jalanKabupaten: L.layerGroup().addTo(map),
parsilSHM: L.layerGroup().addTo(map),
parsilHGB: L.layerGroup().addTo(map),
parsilHGU: L.layerGroup().addTo(map),
parsilHP: L.layerGroup().addTo(map)
};
const layerVisible = {
rumahIbadah: true, ibadahRadius: true, pendudukMiskin: true,
spbu24: true, spbuNon24: true,
jalanNasional: true, jalanProvinsi: true, jalanKabupaten: true,
parsilSHM: true, parsilHGB: true, parsilHGU: true, parsilHP: true
};
const searchEntries = [];
let searchActiveIndex = -1;
const searchPointZoom = 15;
const searchIntentPrefix = {
jalan: 'jalan',
spbu: 'spbu',
rumahibadah: 'ri',
pendudukmiskin: 'pm',
parsil: 'parsil'
};
const drawnItems = new L.FeatureGroup().addTo(map);
const drawControl = new L.Control.Draw({
draw: {
polyline: { shapeOptions: { color: '#2563eb', weight: 3 } },
polygon: { shapeOptions: { color: '#7c3aed', weight: 2, fillOpacity: 0.15 } },
rectangle: false, circle: false, circlemarker: false, marker: false
},
edit: { featureGroup: drawnItems, remove: false }
});
let pendingGeoJSON = null;
let pendingLength = null;
let pendingArea = null;
let currentTab = 'kemiskinan';
let geometryEditState = null;
let _mapClickCb = null;
// ─── Utilities ───────────────────────────────────────────────────────────────
function toast(msg, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const t = document.createElement('div');
t.className = `toast ${type}`;
t.textContent = msg;
document.getElementById('toast-container').appendChild(t);
setTimeout(() => t.remove(), 3000);
container.appendChild(t);
setTimeout(() => t.remove(), 3500);
}
function openModal(title, bodyHTML, footerHTML) {
@@ -92,54 +30,62 @@ function closeModal() {
document.getElementById('modal-overlay').classList.remove('open');
}
document.getElementById('modal-overlay').addEventListener('click', e => {
if (e.target.id === 'modal-overlay') closeModal();
});
const _modalOverlay = document.getElementById('modal-overlay');
if (_modalOverlay) {
_modalOverlay.addEventListener('click', e => {
if (e.target.id === 'modal-overlay') closeModal();
});
}
async function callAPI(ep, method = 'GET', body = null) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
try {
return await (await fetch(`${API_BASE}/${ep}`, opts)).json();
const res = await fetch(`${API_BASE}/${ep}`, opts);
if (res.status === 401) {
window.location.href = '?page=login';
return { status: 'error', message: 'Unauthorized' };
}
return await res.json();
} catch (err) {
return { status: 'error', message: 'Koneksi gagal' };
}
}
function showInstruction(msg) {
const el = document.getElementById('instruction-banner');
el.textContent = '🖱 ' + msg;
el.classList.add('show');
}
function hideInstruction() {
document.getElementById('instruction-banner').classList.remove('show');
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function showInstruction(msg) {
const el = document.getElementById('instruction-banner');
if (el) { el.textContent = '🖱 ' + msg; el.classList.add('show'); }
}
function hideInstruction() {
const el = document.getElementById('instruction-banner');
if (el) el.classList.remove('show');
}
// ─── Search ───────────────────────────────────────────────────────────────────
const searchEntries = [];
let searchActiveIndex = -1;
const searchPointZoom = 15;
const searchIntentPrefix = { rumahibadah: 'ri', pendudukmiskin: 'pm' };
function normalizeSearchText(text) {
return String(text || '')
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return String(text || '').toLowerCase().normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9\s]/g, ' ')
.replace(/\s+/g, ' ').trim();
}
function getSearchIntent(query) {
const q = normalizeSearchText(query);
if (!q) return null;
const aliases = [
{ key: 'jalan', terms: ['jalan', 'road', 'street'] },
{ key: 'spbu', terms: ['spbu', 'pom', 'bensin', 'bbm'] },
{ key: 'rumahibadah', terms: ['masjid', 'gereja', 'pura', 'vihara', 'klenteng', 'ibadah'] },
{ key: 'pendudukmiskin', terms: ['miskin', 'penduduk', 'warga', 'keluarga'] },
{ key: 'parsil', terms: ['parsil', 'tanah', 'sertifikat', 'bidang', 'lahan'] }
{ key: 'pendudukmiskin', terms: ['miskin', 'penduduk', 'warga', 'keluarga'] }
];
for (const alias of aliases) {
if (alias.terms.some(term => q.includes(term))) return alias.key;
@@ -167,36 +113,25 @@ function getSearchMatches(query) {
const pool = prefix
? searchEntries.filter(entry => entry.key.startsWith(prefix + ':'))
: searchEntries;
return pool
.map(entry => {
let score = 0;
const entryLabel = normalizeSearchText(entry.label);
const entryType = normalizeSearchText(entry.typeLabel);
const entryText = entry.searchText;
if (entry.searchText === q) score += 120;
if (entry.searchText.startsWith(q)) score += 80;
if (entryLabel.startsWith(q)) score += 90;
if (entryType.startsWith(q)) score += 60;
if (entry.searchText.includes(q)) score += 50;
const tokenHits = tokens.filter(token => entryText.includes(token) || entryLabel.includes(token)).length;
score += tokenHits * 12;
if (tokens.every(token => entryText.includes(token) || entryLabel.includes(token))) score += 40;
if (tokens[0] && (entryText.split(' ').some(word => word.startsWith(tokens[0])) || entryLabel.split(' ').some(word => word.startsWith(tokens[0])))) score += 15;
if (intent) {
if (entry.key.startsWith(intent + ':')) score += 120;
if (entryType.includes(intent)) score += 110;
if (intent === 'jalan' && entryLabel.startsWith('jalan')) score += 40;
if (intent === 'rumahibadah' && /(masjid|gereja|pura|vihara|klenteng)/.test(entryText)) score += 35;
if (intent === 'spbu' && /(spbu|pom|bensin|bbm)/.test(entryText)) score += 35;
if (intent === 'pendudukmiskin' && /(miskin|penduduk|warga)/.test(entryText)) score += 35;
if (intent === 'parsil' && /(parsil|tanah|sertifikat|bidang)/.test(entryText)) score += 35;
}
return { ...entry, score };
})
.filter(entry => entry.score > 0)
.sort((a, b) => b.score - a.score || a.label.localeCompare(b.label))
.slice(0, 10);
return pool.map(entry => {
let score = 0;
const entryLabel = normalizeSearchText(entry.label);
const entryType = normalizeSearchText(entry.typeLabel);
const entryText = entry.searchText;
if (entry.searchText === q) score += 120;
if (entry.searchText.startsWith(q)) score += 80;
if (entryLabel.startsWith(q)) score += 90;
if (entryType.startsWith(q)) score += 60;
if (entry.searchText.includes(q)) score += 50;
const tokenHits = tokens.filter(token => entryText.includes(token) || entryLabel.includes(token)).length;
score += tokenHits * 12;
if (tokens.every(token => entryText.includes(token) || entryLabel.includes(token))) score += 40;
if (intent) {
if (entry.key.startsWith(intent + ':')) score += 120;
if (entryType.includes(intent)) score += 110;
}
return { ...entry, score };
}).filter(e => e.score > 0).sort((a, b) => b.score - a.score || a.label.localeCompare(b.label)).slice(0, 10);
}
function renderSearchResults(query) {
@@ -209,31 +144,26 @@ function renderSearchResults(query) {
panel.innerHTML = '';
return;
}
if (searchActiveIndex < 0 || searchActiveIndex >= matches.length) searchActiveIndex = 0;
panel.innerHTML = `
<div class="search-results-head">
<div class="search-results-title">Rekomendasi</div>
</div>
<div class="search-results-list">
${matches.map((item, index) => `
<button type="button" class="search-result-item ${index === searchActiveIndex ? 'active' : ''}" data-index="${index}">
<div class="search-result-icon">${item.icon}</div>
<div class="search-result-info">
<div class="search-result-name">${esc(item.label)}</div>
<div class="search-result-sub">${esc(item.subtitle)}</div>
</div>
<div class="search-result-type">${esc(item.typeLabel)}</div>
</button>`).join('')}
</div>`;
<div class="search-results-head"><div class="search-results-title">Rekomendasi</div></div>
<div class="search-results-list">
${matches.map((item, index) => `
<button type="button" class="search-result-item ${index === searchActiveIndex ? 'active' : ''}" data-index="${index}">
<div class="search-result-icon">${item.icon}</div>
<div class="search-result-info">
<div class="search-result-name">${esc(item.label)}</div>
<div class="search-result-sub">${esc(item.subtitle)}</div>
</div>
<div class="search-result-type">${esc(item.typeLabel)}</div>
</button>`).join('')}
</div>`;
panel.classList.add('show');
}
function closeSearchResults() {
const panel = document.getElementById('search-results');
if (!panel) return;
panel.classList.remove('show');
if (panel) panel.classList.remove('show');
}
function selectSearchResult(item) {
@@ -248,34 +178,15 @@ function initSearchBar() {
const panel = document.getElementById('search-results');
const wrapper = document.getElementById('header-search');
if (!input || !panel || !wrapper) return;
input.addEventListener('input', () => {
searchActiveIndex = 0;
renderSearchResults(input.value);
});
input.addEventListener('input', () => { searchActiveIndex = 0; renderSearchResults(input.value); });
input.addEventListener('focus', () => renderSearchResults(input.value));
input.addEventListener('keydown', e => {
const matches = getSearchMatches(input.value);
if (e.key === 'Enter') {
e.preventDefault();
const active = matches[searchActiveIndex] || matches[0];
if (active) selectSearchResult(active);
return;
}
if (e.key === 'Enter') { e.preventDefault(); const active = matches[searchActiveIndex] || matches[0]; if (active) selectSearchResult(active); return; }
if (!matches.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
searchActiveIndex = Math.min(searchActiveIndex + 1, matches.length - 1);
renderSearchResults(input.value);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
searchActiveIndex = Math.max(searchActiveIndex - 1, 0);
renderSearchResults(input.value);
}
if (e.key === 'ArrowDown') { e.preventDefault(); searchActiveIndex = Math.min(searchActiveIndex + 1, matches.length - 1); renderSearchResults(input.value); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); searchActiveIndex = Math.max(searchActiveIndex - 1, 0); renderSearchResults(input.value); }
});
panel.addEventListener('mousedown', e => {
const item = e.target.closest('.search-result-item');
if (!item) return;
@@ -284,142 +195,108 @@ function initSearchBar() {
const selected = matches[Number(item.dataset.index)];
if (selected) selectSearchResult(selected);
});
document.addEventListener('click', e => {
if (!wrapper.contains(e.target)) closeSearchResults();
});
document.addEventListener('click', e => { if (!wrapper.contains(e.target)) closeSearchResults(); });
}
function onceMapClick(cb) {
if (_mapClickCb) map.off('click', _mapClickCb);
_mapClickCb = function (e) {
_mapClickCb = null;
cb(e.latlng.lat, e.latlng.lng);
// ─── Map-specific functions (only called when map page is active) ─────────────
function initMap() {
const CENTER = [-0.0263, 109.3425];
map = window._leafletMap = L.map('map', { zoomControl: false }).setView(CENTER, 13);
L.control.zoom({ position: 'topleft' }).addTo(map);
const baseLayers = {
'OSM Standard': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19 }),
'CartoDB Light': L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { attribution: '© CartoDB', maxZoom: 19 }),
'Satellite (Esri)': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: '© Esri', maxZoom: 19 })
};
baseLayers['OSM Standard'].addTo(map);
L.control.layers(baseLayers, {}, { position: 'bottomright', collapsed: true }).addTo(map);
layers = {
rumahIbadah: L.layerGroup().addTo(map),
ibadahRadius: L.layerGroup().addTo(map),
pendudukMiskin: L.layerGroup().addTo(map)
};
layerVisible = { rumahIbadah: true, ibadahRadius: true, pendudukMiskin: true };
let currentTab = 'rumah_ibadah';
window.currentTab = currentTab;
window._mapClickCb = null;
map.on('mousemove', e => {
const el = document.getElementById('coord-display');
if (el) el.textContent = `Lat: ${e.latlng.lat.toFixed(6)} | Lng: ${e.latlng.lng.toFixed(6)}`;
});
map.on('zoomend', () => {
const el = document.getElementById('status-zoom');
if (el) el.textContent = `Zoom: ${map.getZoom()}`;
});
initSearchBar();
return map;
}
let currentTab = 'rumah_ibadah';
let _mapClickCb = null;
function onceMapClick(cb) {
if (!map) return;
if (_mapClickCb) map.off('click', _mapClickCb);
_mapClickCb = function (e) { _mapClickCb = null; cb(e.latlng.lat, e.latlng.lng); };
map.once('click', _mapClickCb);
}
function cancelMapClick() {
if (_mapClickCb) {
map.off('click', _mapClickCb);
_mapClickCb = null;
}
if (map && _mapClickCb) { map.off('click', _mapClickCb); _mapClickCb = null; }
hideInstruction();
document.querySelectorAll('.btn-add.active').forEach(b => b.classList.remove('active'));
}
async function getAddressFromCoords(lat, lng) {
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lng}`);
if (!res.ok) return '';
const data = await res.json();
return data.display_name || '';
} catch (err) { return ''; }
}
function switchTab(tab) {
currentTab = tab;
window.currentTab = tab;
cancelMapClick();
document.querySelectorAll('.nav-tab').forEach((el, i) => {
el.classList.toggle('active', ['kemiskinan', 'spbu', 'jalan', 'parsil'][i] === tab);
document.querySelectorAll('.nav-tab').forEach(el => {
const onClickAttr = el.getAttribute('onclick') || '';
el.classList.toggle('active', onClickAttr.includes(`'${tab}'`));
});
try { map.removeControl(drawControl); } catch (e) { }
const titles = { kemiskinan: 'Ibadah & Kemiskinan', spbu: 'SPBU', jalan: 'Data Jalan', parsil: 'Parsil Tanah' };
document.getElementById('panel-title').textContent = titles[tab];
const titleEl = document.getElementById('panel-title');
if (titleEl) titleEl.textContent = tab === 'rumah_ibadah' ? 'Daftar Rumah Ibadah' : 'Daftar Penduduk Miskin';
renderPanel(tab);
if (tab === 'jalan' || tab === 'parsil') map.addControl(drawControl);
}
async function renderPanel(tab) {
const pb = document.getElementById('panel-body');
if (!pb) return;
pb.innerHTML = '<div class="loading"><span class="spinner"></span>Memuat data…</div>';
if (tab === 'kemiskinan') await renderKemiskinanPanel(pb);
if (tab === 'spbu') await renderSpbuPanel(pb);
if (tab === 'jalan') await renderJalanPanel(pb);
if (tab === 'parsil') await renderParsilPanel(pb);
}
async function preloadFeatureLayers() {
const sandbox = document.createElement('div');
try {
await Promise.all([
renderSpbuPanel(sandbox),
renderJalanPanel(sandbox),
renderParsilPanel(sandbox)
]);
} catch (err) {
console.warn('Gagal memuat sebagian layer awal:', err);
}
}
function findGeomLayer(type, id) {
const keys = type === 'jalan'
? ['jalanNasional', 'jalanProvinsi', 'jalanKabupaten']
: ['parsilSHM', 'parsilHGB', 'parsilHGU', 'parsilHP'];
for (const key of keys) {
let found = null;
layers[key].eachLayer(layer => {
if (layer._dataId === id) found = layer;
});
if (found) return found;
}
return null;
}
function getGeometryEditHandler() {
return drawControl?._toolbars?.edit?._modes?.edit?.handler || null;
}
function getGeometryGroupKey(type, data) {
return type === 'jalan' ? `jalan${data.status_jalan}` : `parsil${data.jenis_hak}`;
}
function cloneEditableLayer(type, sourceLayer) {
if (type === 'jalan') {
const coords = sourceLayer.getLatLngs().map(ll => [ll.lat, ll.lng]);
return L.polyline(coords, { ...sourceLayer.options });
}
const ring = sourceLayer.getLatLngs()[0].map(ll => [ll.lat, ll.lng]);
return L.polygon(ring, { ...sourceLayer.options });
}
function buildGeometryLayer(type, data) {
if (type === 'jalan') {
const cfg = jalanCfg[data.status_jalan] || jalanCfg.Kabupaten;
const coords = (data.geojson?.coordinates || []).map(c => [c[1], c[0]]);
return L.polyline(coords, { color: cfg.color, weight: cfg.weight, opacity: 0.9 });
}
const cfg = parsilCfg[data.jenis_hak] || parsilCfg.SHM;
const coords = ((data.geojson?.coordinates || [])[0] || []).map(c => [c[1], c[0]]);
return L.polygon(coords, { color: cfg.color, fillColor: cfg.color, fillOpacity: 0.18, weight: 2 });
}
function cancelGeometryEdit() {
const handler = getGeometryEditHandler();
if (handler) handler.disable();
drawnItems.clearLayers();
geometryEditState = null;
switchTab(currentTab);
}
function focusGeom(id, type) {
const keys = type === 'jalan'
? ['jalanNasional', 'jalanProvinsi', 'jalanKabupaten']
: ['parsilSHM', 'parsilHGB', 'parsilHGU', 'parsilHP'];
for (const k of keys) {
layers[k].eachLayer(l => {
if (l._dataId === id) {
map.flyToBounds(l.getBounds(), { padding: [50, 50], duration: 1 });
l.openPopup();
}
});
}
if (tab === 'rumah_ibadah') await renderRumahIbadahPanel(pb);
else if (tab === 'penduduk_miskin') await renderPendudukMiskinPanel(pb);
}
function focusMarkerById(layerKey, id, zoom = searchPointZoom) {
if (!layers || !map) return;
const layer = layers[layerKey];
if (!layer) return;
let target = null;
layer.eachLayer(marker => {
if (marker._dataId === id) target = marker;
});
layer.eachLayer(marker => { if (marker._dataId === id) target = marker; });
if (!target) return;
map.flyTo(target.getLatLng(), zoom, { duration: 1 });
map.once('moveend', () => target.openPopup());
}
function toggleLayer(key, el) {
if (!layers || !map) return;
layerVisible[key] = !layerVisible[key];
el.classList.toggle('checked', layerVisible[key]);
el.querySelector('.layer-check').textContent = layerVisible[key] ? '✓' : '';
@@ -427,36 +304,11 @@ function toggleLayer(key, el) {
}
function updateStatusCount() {
if (!layers || !map) return;
let total = 0;
Object.values(layers).forEach(lg => { if (map.hasLayer(lg)) lg.eachLayer(() => total++); });
document.getElementById('status-count').textContent = `Total fitur: ${total}`;
document.getElementById('status-zoom').textContent = `Zoom: ${map.getZoom()}`;
const countEl = document.getElementById('status-count');
if (countEl) countEl.textContent = `Total fitur: ${total}`;
const zoomEl = document.getElementById('status-zoom');
if (zoomEl) zoomEl.textContent = `Zoom: ${map.getZoom()}`;
}
map.on('mousemove', e => {
document.getElementById('coord-display').textContent = `Lat: ${e.latlng.lat.toFixed(6)} | Lng: ${e.latlng.lng.toFixed(6)}`;
});
map.on('zoomend', () => {
document.getElementById('status-zoom').textContent = `Zoom: ${map.getZoom()}`;
});
map.on(L.Draw.Event.CREATED, function (e) {
const { layer, layerType: type } = e;
if (type === 'polyline') {
const lls = layer.getLatLngs();
pendingLength = turf.length(turf.lineString(lls.map(c => [c.lng, c.lat])), { units: 'meters' });
pendingGeoJSON = { type: 'LineString', coordinates: lls.map(c => [c.lng, c.lat]) };
drawnItems.addLayer(layer);
openJalanForm();
}
if (type === 'polygon') {
const lls = layer.getLatLngs()[0];
const ring = [...lls.map(c => [c.lng, c.lat]), [lls[0].lng, lls[0].lat]];
pendingArea = turf.area(turf.polygon([ring]));
pendingGeoJSON = { type: 'Polygon', coordinates: [ring] };
drawnItems.addLayer(layer);
openParsilForm();
}
});
initSearchBar();
-220
View File
@@ -1,220 +0,0 @@
const jalanCfg = {
Nasional: { color: '#dc2626', weight: 5 },
Provinsi: { color: '#d97706', weight: 3.5 },
Kabupaten: { color: '#2563eb', weight: 2.5 }
};
function renderJalanPanel(pb) {
return callAPI('data_jalan.php').then(res => {
const data = res.data || [];
const cnt = k => data.filter(d => d.status_jalan === k).length;
pb.innerHTML = `
<div class="layer-section">
<div class="layer-section-title">Filter Layer</div>
${['Nasional', 'Provinsi', 'Kabupaten'].map(s => `
<div class="layer-item ${layerVisible['jalan' + s] ? 'checked' : ''}" onclick="toggleLayer('jalan${s}',this)">
<div class="layer-check">${layerVisible['jalan' + s] ? '✓' : ''}</div>
<div class="layer-dot" style="background:${jalanCfg[s].color};border-radius:2px;height:4px;width:18px;border-radius:2px"></div>
<div class="layer-label">Jalan ${s}</div>
<div class="layer-count">${cnt(s)}</div>
</div>`).join('')}
</div>
<div class="layer-section">
<div class="layer-section-title">Cara Menambahkan Data Jalan</div>
<div style="font-size:11px;color:var(--text2);line-height:1.9;padding:10px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius)">
1. Klik ikon <b style="color:var(--accent)">polyline</b> di toolbar kiri peta<br>
2. Klik titik-titik rute jalan di peta<br>
3. <b>Double-klik</b> untuk selesai menggambar<br>
4. Form simpan + panjang otomatis muncul
</div>
</div>
<div class="layer-section">
<div class="layer-section-title">Data Jalan (${data.length})</div>
<div class="data-list">
${data.length ? data.map(d => `
<div class="data-item" onclick="focusGeom(${d.id},'jalan')">
<div class="item-icon">🛣</div>
<div class="item-info">
<div class="item-name">${esc(d.nama_jalan)}</div>
<div class="item-sub">${(d.panjang_meter / 1000).toFixed(3)} km</div>
</div>
<div style="display:flex;align-items:center;gap:5px">
<div class="item-badge" style="background:${jalanCfg[d.status_jalan].color}18;color:${jalanCfg[d.status_jalan].color};border:1px solid ${jalanCfg[d.status_jalan].color}44">${d.status_jalan.toUpperCase()}</div>
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editJalan(${d.id})">✏</button>
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteJalan(${d.id})">✕</button>
</div>
</div>`).join('') : '<div class="loading">Belum ada data jalan</div>'}
</div>
</div>`;
loadJalanLayer(data);
registerSearchEntries('jalan', data.map(d => ({
key: `jalan:${d.id}`,
label: d.nama_jalan,
subtitle: `Jalan ${d.status_jalan} · ${(d.panjang_meter / 1000).toFixed(3)} km${d.keterangan ? ` · ${d.keterangan}` : ''}`,
typeLabel: 'Jalan',
icon: '🛣',
searchText: normalizeSearchText(`${d.nama_jalan} ${d.status_jalan} jalan`),
focus: () => focusGeom(d.id, 'jalan')
})));
updateStatusCount();
});
}
function loadJalanLayer(data) {
['Nasional', 'Provinsi', 'Kabupaten'].forEach(k => layers['jalan' + k].clearLayers());
data.forEach(d => {
const cfg = jalanCfg[d.status_jalan], coords = d.geojson.coordinates.map(c => [c[1], c[0]]);
const line = L.polyline(coords, { color: cfg.color, weight: cfg.weight, opacity: 0.9 })
.bindPopup(`<h4>🛣 ${esc(d.nama_jalan)}</h4>
<p><b>Status:</b> Jalan ${d.status_jalan}</p>
<p><b>Panjang:</b> ${(d.panjang_meter / 1000).toFixed(3)} km</p>
${d.keterangan ? `<p>${esc(d.keterangan)}</p>` : ''}
<div class="popup-actions"><button class="btn btn-ghost btn-sm" onclick="editJalan(${d.id})">✏ Edit</button><button class="btn btn-danger btn-sm" onclick="deleteJalan(${d.id})">✕ Hapus</button></div>`);
line._dataId = d.id;
line._groupKey = 'jalan' + d.status_jalan;
layers['jalan' + d.status_jalan].addLayer(line);
});
}
function openJalanForm() {
openModal('Simpan Data Jalan', `
<div class="form-calc">📏 Panjang: <b>${pendingLength.toFixed(1)} m</b> = ${(pendingLength / 1000).toFixed(3)} km </div>
<div class="form-group"><label class="form-label">Nama Jalan *</label>
<input class="form-input" id="jl-nama" placeholder="Jl. Ahmad Yani…"/></div>
<div class="form-group"><label class="form-label">Status Jalan *</label>
<select class="form-select" id="jl-status">
<option value="Nasional">Jalan Nasional</option>
<option value="Provinsi">Jalan Provinsi</option>
<option value="Kabupaten">Jalan Kabupaten</option>
</select></div>
<div class="form-group"><label class="form-label">Keterangan</label>
<input class="form-input" id="jl-ket" placeholder="Opsional…"/></div>`,
`<button class="btn btn-ghost" onclick="closeModal();drawnItems.clearLayers()">Batal</button>
<button class="btn btn-primary" onclick="saveJalan()">💾 Simpan</button>`);
}
async function saveJalan() {
const body = {
nama_jalan: document.getElementById('jl-nama').value.trim(),
status_jalan: document.getElementById('jl-status').value,
panjang_meter: pendingLength,
keterangan: document.getElementById('jl-ket').value,
geojson: pendingGeoJSON
};
if (!body.nama_jalan) return toast('Nama wajib diisi', 'error');
const res = await callAPI('data_jalan.php', 'POST', body);
if (res.status === 'success') {
toast(res.message, 'success');
closeModal();
drawnItems.clearLayers();
pendingGeoJSON = null;
pendingLength = null;
switchTab('jalan');
} else {
toast(res.message, 'error');
}
}
async function editJalan(id) {
map.closePopup();
const res = await callAPI(`data_jalan.php?id=${id}`);
const data = res.data;
const sourceLayer = findGeomLayer('jalan', id);
if (!data) return toast('Data jalan tidak ditemukan', 'error');
const groupKey = getGeometryGroupKey('jalan', data);
const editLayer = sourceLayer ? cloneEditableLayer('jalan', sourceLayer) : buildGeometryLayer('jalan', data);
editLayer._dataId = id;
editLayer._groupKey = groupKey;
if (sourceLayer) layers[groupKey].removeLayer(sourceLayer);
drawnItems.clearLayers();
drawnItems.addLayer(editLayer);
geometryEditState = { type: 'jalan', id, groupKey, sourceLayer, editLayer };
if (editLayer.editing && editLayer.editing.enable) {
editLayer.editing.enable();
} else {
const handler = getGeometryEditHandler();
if (!handler) {
drawnItems.clearLayers();
if (sourceLayer) layers[groupKey].addLayer(sourceLayer);
geometryEditState = null;
return toast('Mode edit geometri belum siap', 'error');
}
handler.enable();
}
map.fitBounds(editLayer.getBounds(), { padding: [50, 50], duration: 1 });
document.getElementById('panel-title').textContent = 'Edit Data Jalan';
document.getElementById('panel-body').innerHTML = `
<div class="layer-section">
<div class="layer-section-title">Edit Jalan</div>
<div style="font-size:11px;line-height:1.7;color:var(--text2);padding:10px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px">
Geser titik-titik garis di peta lalu simpan perubahan.
</div>
<div class="form-group">
<label class="form-label">Nama Jalan *</label>
<input class="form-input" id="jl-edit-nama" value="${esc(data.nama_jalan)}" />
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Status Jalan *</label>
<select class="form-select" id="jl-edit-status">
${['Nasional', 'Provinsi', 'Kabupaten'].map(s => `<option value="${s}"${data.status_jalan === s ? ' selected' : ''}>Jalan ${s}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label class="form-label">Panjang Otomatis</label>
<input class="form-input" id="jl-edit-panjang" value="${(data.panjang_meter / 1000).toFixed(3)} km" disabled />
</div>
</div>
<div class="form-group">
<label class="form-label">Keterangan</label>
<input class="form-input" id="jl-edit-ket" value="${esc(data.keterangan || '')}" />
</div>
</div>`;
document.getElementById('panel-body').insertAdjacentHTML('beforeend', `
<div style="display:flex;gap:8px">
<button class="btn btn-ghost" style="flex:1" onclick="cancelGeometryEdit()">Batal</button>
<button class="btn btn-primary" style="flex:1" onclick="saveJalanEdit(${id})">Simpan Perubahan</button>
</div>`);
}
async function saveJalanEdit(id) {
const state = geometryEditState;
if (!state || state.type !== 'jalan') return;
const layer = state.editLayer;
const coords = layer.getLatLngs().map(ll => [ll.lng, ll.lat]);
const panjang_meter = turf.length(turf.lineString(coords), { units: 'meters' });
const body = {
nama_jalan: document.getElementById('jl-edit-nama').value.trim(),
status_jalan: document.getElementById('jl-edit-status').value,
panjang_meter,
keterangan: document.getElementById('jl-edit-ket').value,
geojson: { type: 'LineString', coordinates: coords }
};
if (!body.nama_jalan) return toast('Nama wajib diisi', 'error');
const res = await callAPI(`data_jalan.php?id=${id}`, 'PUT', body);
if (res.status === 'success') {
toast(res.message, 'success');
if (layer.editing && layer.editing.disable) layer.editing.disable();
const handler = getGeometryEditHandler();
if (handler) handler.disable();
drawnItems.clearLayers();
geometryEditState = null;
switchTab('jalan');
} else {
toast(res.message, 'error');
}
}
async function deleteJalan(id) {
if (!confirm('Hapus?')) return;
const r = await callAPI(`data_jalan.php?id=${id}`, 'DELETE');
if (r.status === 'success') {
toast('Dihapus', 'success');
map.closePopup();
switchTab('jalan');
}
}
-451
View File
@@ -1,451 +0,0 @@
function renderKemiskinanPanel(pb) {
return Promise.all([callAPI('rumah_ibadah.php'), callAPI('penduduk_miskin.php')]).then(([riRes, pmRes]) => {
const ri = riRes.data || [];
const pm = pmRes.data || [];
pb.innerHTML = `
<div class="layer-section">
<div class="layer-section-title">Visibilitas Layer</div>
${mkLayerItem('rumahIbadah', '#f59e0b', 'Rumah Ibadah', ri.length)}
${mkLayerItem('ibadahRadius', '#fbbf24', 'Radius Jangkauan', ri.length, true)}
${mkLayerItem('pendudukMiskin', '#16a34a', 'Penduduk Miskin', pm.length)}
</div>
<div class="layer-section">
<div class="layer-section-title">Tambah Data</div>
<button class="btn-add" id="btn-add-ri" onclick="startAdd('ri')"> Tambah Rumah Ibadah</button>
<button class="btn-add" id="btn-add-pm" onclick="startAdd('pm')" style="margin-top:5px"> Tambah Penduduk Miskin</button>
</div>
<div class="layer-section">
<div class="layer-section-title">Rumah Ibadah (${ri.length})</div>
<div class="data-list">
${ri.length ? ri.map(r => `
<div class="data-item" onclick="map.flyTo([${r.latitude},${r.longitude}],17)">
<div class="item-icon">⛪</div>
<div class="item-info">
<div class="item-name">${esc(r.nama)}</div>
<div class="item-sub">${r.jenis} · R=${r.radius_meter}m</div>
</div>
<div style="display:flex;gap:4px">
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editRI(${r.id})">✏</button>
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteRI(${r.id})">✕</button>
</div>
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
</div>
</div>
<div class="layer-section">
<div class="layer-section-title">Penduduk Miskin (${pm.length})</div>
<div class="data-list">
${pm.length ? pm.map(p => `
<div class="data-item" onclick="map.flyTo([${p.latitude},${p.longitude}],17)">
<div class="item-icon">${pmEmoji(p)}</div>
<div class="item-info">
<div class="item-name">${esc(p.nama)}</div>
<div class="item-sub">${p.total_bantuan} bantuan · ${p.jumlah_anggota} jiwa</div>
</div>
<div class="item-badge ${p.status_proximity === 'dalam_radius' ? 'badge-green' : 'badge-red'}">
${p.status_proximity === 'dalam_radius' ? 'DALAM' : 'LUAR'}
</div>
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
</div>
</div>`;
loadRILayer(ri);
loadPMLayer(pm);
registerSearchEntries('ri', ri.map(r => ({
key: `ri:${r.id}`,
label: r.nama,
subtitle: `Rumah Ibadah · ${r.jenis}${r.alamat ? ` · ${r.alamat}` : ''}`,
typeLabel: 'Rumah Ibadah',
icon: '⛪',
searchText: normalizeSearchText(`${r.nama} ${r.jenis} rumah ibadah`),
focus: () => focusMarkerById('rumahIbadah', r.id)
})));
registerSearchEntries('pm', pm.map(p => ({
key: `pm:${p.id}`,
label: p.nama,
subtitle: `Penduduk Miskin · ${p.jumlah_anggota} jiwa${p.alamat ? ` · ${p.alamat}` : ''}`,
typeLabel: 'Penduduk Miskin',
icon: '👤',
searchText: normalizeSearchText(`${p.nama} ${p.nik || ''} penduduk miskin`),
focus: () => focusMarkerById('pendudukMiskin', p.id)
})));
updateStatusCount();
});
}
function mkLayerItem(key, color, label, count, isRing = false) {
const dot = isRing
? `<div class="layer-dot" style="background:transparent;border:2px solid ${color}"></div>`
: `<div class="layer-dot" style="background:${color}"></div>`;
return `<div class="layer-item ${layerVisible[key] ? 'checked' : ''}" onclick="toggleLayer('${key}',this)">
<div class="layer-check">${layerVisible[key] ? '✓' : ''}</div>${dot}
<div class="layer-label">${label}</div>
<div class="layer-count">${count}</div>
</div>`;
}
function pmEmoji(p) {
if (p.bantuan_pemberdayaan > 0 && p.bantuan_konsumtif > 0) return '🔗';
if (p.bantuan_pemberdayaan > 0) return '📚';
if (p.bantuan_konsumtif > 0) return '🛍';
return '👤';
}
function loadRILayer(data) {
layers.rumahIbadah.clearLayers();
layers.ibadahRadius.clearLayers();
data.forEach(r => {
const circle = L.circle([r.latitude, r.longitude], {
radius: r.radius_meter,
color: '#f59e0b', fillColor: '#fef3c7', fillOpacity: 0.18,
weight: 1.5, dashArray: '6,4'
});
layers.ibadahRadius.addLayer(circle);
const icon = L.divIcon({
className: '',
html: `<div style="width:32px;height:32px;background:#f59e0b;border:3px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 3px 10px rgba(0,0,0,.2);cursor:grab;">⛪</div>`,
iconSize: [32, 32], iconAnchor: [16, 16]
});
const marker = L.marker([r.latitude, r.longitude], { icon, draggable: true }).bindPopup(riPopup(r));
marker._dataId = r.id;
marker.on('dragend', async function (ev) {
const { lat, lng } = ev.target.getLatLng();
circle.setLatLng([lat, lng]);
const res = await callAPI(`rumah_ibadah.php?id=${r.id}`, 'PUT', {
nama: r.nama, jenis: r.jenis, alamat: r.alamat,
no_wa: r.no_wa, latitude: lat, longitude: lng,
radius_meter: r.radius_meter
});
if (res.status === 'success') {
r.latitude = lat;
r.longitude = lng;
toast(`📍 ${r.nama} dipindahkan & koordinat diperbarui`, 'success');
await recalcAllProximity();
} else {
toast('Gagal update lokasi: ' + res.message, 'error');
marker.setLatLng([r.latitude, r.longitude]);
}
});
layers.rumahIbadah.addLayer(marker);
});
}
function riPopup(r) {
return `<h4>⛪ ${esc(r.nama)}</h4>
<p><b>Jenis:</b> ${r.jenis}</p>
<p><b>Radius:</b> ${r.radius_meter} m</p>
${r.alamat ? `<p><b>Alamat:</b> ${esc(r.alamat)}</p>` : ''}
${r.no_wa ? `<p><b>WA:</b> ${r.no_wa}</p>` : ''}
<div class="popup-drag-hint">↔ Seret marker untuk pindahkan lokasi — auto-save</div>
<div class="popup-actions">
<button class="btn btn-ghost btn-sm" onclick="editRI(${r.id})">✏ Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteRI(${r.id})">✕ Hapus</button>
</div>`;
}
function loadPMLayer(data) {
layers.pendudukMiskin.clearLayers();
data.forEach(p => {
const inR = p.status_proximity === 'dalam_radius';
const color = inR ? '#16a34a' : '#dc2626';
const hasBoth = p.bantuan_pemberdayaan > 0 && p.bantuan_konsumtif > 0;
const hasPemb = p.bantuan_pemberdayaan > 0;
const hasKons = p.bantuan_konsumtif > 0;
let dot = '';
if (hasBoth || hasPemb) dot = `<div style="position:absolute;top:-3px;right:-3px;width:9px;height:9px;background:#2563eb;border:1.5px solid #fff;border-radius:50%"></div>`;
if (hasKons && !hasPemb) dot = `<div style="position:absolute;top:-3px;right:-3px;width:9px;height:9px;background:#f59e0b;border:1.5px solid #fff;border-radius:50%"></div>`;
const icon = L.divIcon({
className: '',
html: `<div style="position:relative"><div style="width:26px;height:26px;background:${color};border:3px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;box-shadow:0 3px 10px ${color}66;cursor:grab;">👤</div>${dot}</div>`,
iconSize: [26, 26], iconAnchor: [13, 13]
});
const marker = L.marker([p.latitude, p.longitude], { icon, draggable: true }).bindPopup(pmPopup(p));
marker._dataId = p.id;
marker.on('dragend', async function (ev) {
const { lat, lng } = ev.target.getLatLng();
const res = await callAPI(`penduduk_miskin.php?id=${p.id}`, 'PUT', {
nama: p.nama, no_wa: p.no_wa, nik: p.nik,
alamat: p.alamat, jumlah_anggota: p.jumlah_anggota,
latitude: lat, longitude: lng
});
if (res.status === 'success') {
const prox = res.data?.proximity;
const statusMsg = prox?.status === 'dalam_radius' ? '✅ Dalam radius' : '❌ Luar radius';
toast(`📍 ${p.nama} dipindahkan — ${statusMsg}`, 'success');
switchTab('kemiskinan');
} else {
toast('Gagal update: ' + res.message, 'error');
marker.setLatLng([p.latitude, p.longitude]);
}
});
layers.pendudukMiskin.addLayer(marker);
});
}
function pmPopup(p) {
const inR = p.status_proximity === 'dalam_radius';
return `<h4>👤 ${esc(p.nama)}</h4>
<div class="proximity-info ${inR ? 'dalam' : 'luar'}">
${inR ? `✅ Dalam radius — ${esc(p.nama_rumah_ibadah || '')}` : '❌ Di luar radius rumah ibadah'}
</div>
${p.no_wa ? `<p><b>WA:</b> ${p.no_wa}</p>` : ''}
${p.nik ? `<p><b>NIK:</b> ${p.nik}</p>` : ''}
${p.alamat ? `<p><b>Alamat:</b> ${esc(p.alamat)}</p>` : ''}
<p><b>Keluarga:</b> ${p.jumlah_anggota} jiwa</p>
<p><b>Bantuan:</b> 📚${p.bantuan_pemberdayaan} · 🛍${p.bantuan_konsumtif}</p>
<div class="popup-drag-hint">↔ Seret marker untuk pindahkan lokasi — auto-save</div>
<div class="popup-actions">
<button class="btn btn-primary btn-sm" onclick="openLogBantuan(${p.id},'${esc(p.nama)}')">📋 Log Bantuan</button>
<button class="btn btn-ghost btn-sm" onclick="editPM(${p.id})">✏ Edit</button>
<button class="btn btn-danger btn-sm" onclick="deletePM(${p.id})">✕</button>
</div>`;
}
function startAdd(type) {
cancelMapClick();
const btnId = type === 'ri' ? 'btn-add-ri' : 'btn-add-pm';
const label = type === 'ri' ? 'lokasi Rumah Ibadah' : 'lokasi Penduduk Miskin';
document.getElementById(btnId).classList.add('active');
showInstruction(`Klik di peta untuk menentukan ${label}`);
onceMapClick((lat, lng) => {
document.getElementById(btnId).classList.remove('active');
hideInstruction();
if (type === 'ri') openRIForm(null, lat, lng);
else openPMForm(null, lat, lng);
});
}
function openRIForm(data, lat, lng) {
const isEdit = !!data;
const fLat = isEdit ? data.latitude : lat;
const fLng = isEdit ? data.longitude : lng;
openModal(isEdit ? 'Edit Rumah Ibadah' : 'Tambah Rumah Ibadah', `
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
<div class="form-group">
<label class="form-label">Nama Rumah Ibadah *</label>
<input class="form-input" id="ri-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="Masjid Al-Ikhlas…"/>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Jenis *</label>
<select class="form-select" id="ri-jenis">
${['Masjid', 'Gereja', 'Pura', 'Vihara', 'Klenteng', 'Lainnya'].map(j =>
`<option value="${j}"${isEdit && data.jenis === j ? ' selected' : ''}>${j}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label class="form-label">Radius (meter) *</label>
<input class="form-input" id="ri-radius" type="number" value="${isEdit ? data.radius_meter : 500}" min="50" max="10000"/>
</div>
</div>
<div class="form-group">
<label class="form-label">Alamat</label>
<input class="form-input" id="ri-alamat" value="${esc(isEdit ? data.alamat || '' : '')}" placeholder="Jl. …"/>
</div>
<div class="form-group">
<label class="form-label">No. WhatsApp</label>
<input class="form-input" id="ri-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/>
</div>`,
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
<button class="btn btn-primary" onclick="saveRI(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
}
async function saveRI(id, lat, lng) {
const body = {
nama: document.getElementById('ri-nama').value.trim(),
jenis: document.getElementById('ri-jenis').value,
alamat: document.getElementById('ri-alamat').value.trim(),
no_wa: document.getElementById('ri-nowa').value.trim(),
latitude: lat,
longitude: lng,
radius_meter: parseInt(document.getElementById('ri-radius').value)
};
if (!body.nama) return toast('Nama wajib diisi', 'error');
const res = await callAPI(id ? `rumah_ibadah.php?id=${id}` : 'rumah_ibadah.php', id ? 'PUT' : 'POST', body);
if (res.status === 'success') {
toast(res.message, 'success');
closeModal();
await recalcAllProximity();
switchTab('kemiskinan');
} else {
toast(res.message, 'error');
}
}
function openPMForm(data, lat, lng) {
const isEdit = !!data;
const fLat = isEdit ? data.latitude : lat;
const fLng = isEdit ? data.longitude : lng;
openModal(isEdit ? 'Edit Penduduk Miskin' : 'Tambah Penduduk Miskin', `
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
<div class="form-group">
<label class="form-label">Nama Lengkap *</label>
<input class="form-input" id="pm-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="Ahmad Subarjo…"/>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">No. WhatsApp</label>
<input class="form-input" id="pm-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/>
</div>
<div class="form-group">
<label class="form-label">NIK</label>
<input class="form-input" id="pm-nik" value="${esc(isEdit ? data.nik || '' : '')}" placeholder="617101…"/>
</div>
</div>
<div class="form-group">
<label class="form-label">Alamat</label>
<input class="form-input" id="pm-alamat" value="${esc(isEdit ? data.alamat || '' : '')}" placeholder="Jl. …"/>
</div>
<div class="form-group">
<label class="form-label">Jumlah Anggota Keluarga</label>
<input class="form-input" id="pm-jml" type="number" value="${isEdit ? data.jumlah_anggota : 1}" min="1" max="20"/>
</div>`,
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
<button class="btn btn-primary" onclick="savePM(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
}
async function savePM(id, lat, lng) {
const body = {
nama: document.getElementById('pm-nama').value.trim(),
no_wa: document.getElementById('pm-nowa').value.trim(),
nik: document.getElementById('pm-nik').value.trim(),
alamat: document.getElementById('pm-alamat').value.trim(),
jumlah_anggota: parseInt(document.getElementById('pm-jml').value) || 1,
latitude: lat,
longitude: lng
};
if (!body.nama) return toast('Nama wajib diisi', 'error');
const res = await callAPI(id ? `penduduk_miskin.php?id=${id}` : 'penduduk_miskin.php', id ? 'PUT' : 'POST', body);
if (res.status === 'success') {
toast(res.message, 'success');
closeModal();
switchTab('kemiskinan');
} else {
toast(res.message, 'error');
}
}
async function editRI(id) {
map.closePopup();
const res = await callAPI(`rumah_ibadah.php?id=${id}`);
if (res.data) openRIForm(res.data, res.data.latitude, res.data.longitude);
}
async function deleteRI(id) {
if (!confirm('Hapus rumah ibadah ini?')) return;
const res = await callAPI(`rumah_ibadah.php?id=${id}`, 'DELETE');
if (res.status === 'success') {
toast('Dihapus', 'success');
map.closePopup();
await recalcAllProximity();
switchTab('kemiskinan');
}
}
async function editPM(id) {
map.closePopup();
const res = await callAPI(`penduduk_miskin.php?id=${id}`);
if (res.data) openPMForm(res.data, res.data.latitude, res.data.longitude);
}
async function deletePM(id) {
if (!confirm('Hapus data ini?')) return;
const res = await callAPI(`penduduk_miskin.php?id=${id}`, 'DELETE');
if (res.status === 'success') {
toast('Dihapus', 'success');
map.closePopup();
await recalcAllProximity();
switchTab('kemiskinan');
}
}
async function openLogBantuan(pmId, pmNama) {
map.closePopup();
const [logRes, riRes] = await Promise.all([
callAPI(`log_bantuan.php?penduduk_miskin_id=${pmId}`),
callAPI('rumah_ibadah.php')
]);
const logs = logRes.data || [];
const ris = riRes.data || [];
openModal(`Log Bantuan — ${pmNama}`, `
<div style="margin-bottom:16px">
<div class="layer-section-title">Riwayat Bantuan</div>
${logs.length ? logs.map(l => `
<div class="log-item ${l.tipe_bantuan === 'Pemberdayaan' ? 'pemberdayaan' : 'konsumtif'}">
<div class="log-type">${l.tipe_bantuan === 'Pemberdayaan' ? '📚 Pemberdayaan' : '🛍 Konsumtif'}</div>
<div class="log-detail">${esc(l.sub_kategori) || ''} ${l.keterangan ? '— ' + esc(l.keterangan) : ''}</div>
<div class="log-date">📅 ${l.tanggal_bantuan} · ${esc(l.nama_rumah_ibadah)}</div>
</div>`).join('') : '<div class="loading">Belum ada riwayat bantuan</div>'}
</div>
<div class="layer-section-title">Tambah Bantuan Baru</div>
<div class="form-group">
<label class="form-label">Dari Rumah Ibadah *</label>
<select class="form-select" id="lb-ri">
${ris.map(r => `<option value="${r.id}">${esc(r.nama)} (${r.jenis})</option>`).join('')}
</select>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Tipe Bantuan *</label>
<select class="form-select" id="lb-tipe">
<option value="Pemberdayaan">📚 Pemberdayaan (Pelatihan)</option>
<option value="Konsumtif">🛍 Konsumtif (Makan/Barang)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Tanggal *</label>
<input class="form-input" id="lb-tgl" type="date" value="${new Date().toISOString().slice(0, 10)}"/>
</div>
</div>
<div class="form-group">
<label class="form-label">Sub Kategori</label>
<input class="form-input" id="lb-sub" placeholder="Pelatihan menjahit / Sembako…"/>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Keterangan</label>
<input class="form-input" id="lb-ket" placeholder="Opsional"/>
</div>
<div class="form-group">
<label class="form-label">Nilai (Rp)</label>
<input class="form-input" id="lb-nilai" type="number" placeholder="0"/>
</div>
</div>`,
`<button class="btn btn-ghost" onclick="closeModal()">Tutup</button>
<button class="btn btn-primary" onclick="saveLog(${pmId})"> Tambah Log</button>`);
}
async function saveLog(pmId) {
const body = {
penduduk_miskin_id: pmId,
rumah_ibadah_id: parseInt(document.getElementById('lb-ri').value),
tipe_bantuan: document.getElementById('lb-tipe').value,
tanggal_bantuan: document.getElementById('lb-tgl').value,
sub_kategori: document.getElementById('lb-sub').value,
keterangan: document.getElementById('lb-ket').value,
nilai_bantuan: document.getElementById('lb-nilai').value || null
};
const res = await callAPI('log_bantuan.php', 'POST', body);
if (res.status === 'success') {
toast('Log dicatat!', 'success');
closeModal();
switchTab('kemiskinan');
} else {
toast(res.message, 'error');
}
}
async function recalcAllProximity() {
await callAPI('penduduk_miskin.php?action=recalc_all', 'POST');
}
-237
View File
@@ -1,237 +0,0 @@
const parsilCfg = {
SHM: { color: '#16a34a', label: 'Sertifikat Hak Milik' },
HGB: { color: '#2563eb', label: 'Hak Guna Bangunan' },
HGU: { color: '#d97706', label: 'Hak Guna Usaha' },
HP: { color: '#7c3aed', label: 'Hak Pakai' }
};
function renderParsilPanel(pb) {
return callAPI('data_parsil.php').then(res => {
const data = res.data || [];
const cnt = k => data.filter(d => d.jenis_hak === k).length;
pb.innerHTML = `
<div class="layer-section">
<div class="layer-section-title">Filter Layer</div>
${Object.entries(parsilCfg).map(([k, v]) => `
<div class="layer-item ${layerVisible['parsil' + k] ? 'checked' : ''}" onclick="toggleLayer('parsil${k}',this)">
<div class="layer-check">${layerVisible['parsil' + k] ? '✓' : ''}</div>
<div class="layer-dot" style="background:${v.color}"></div>
<div class="layer-label">${k}${v.label}</div>
<div class="layer-count">${cnt(k)}</div>
</div>`).join('')}
</div>
<div class="layer-section">
<div class="layer-section-title">Cara Menambahkan Data Parsil</div>
<div style="font-size:11px;color:var(--text2);line-height:1.9;padding:10px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius)">
1. Klik ikon <b style="color:var(--accent)">polygon</b> di toolbar kiri peta<br>
2. Klik titik-titik batas bidang tanah<br>
3. Klik titik awal / <b>double-klik</b> untuk menutup<br>
4. Form simpan + luas otomatis muncul
</div>
</div>
<div class="layer-section">
<div class="layer-section-title">Data Parsil (${data.length})</div>
<div class="data-list">
${data.length ? data.map(d => `
<div class="data-item" onclick="focusGeom(${d.id},'parsil')">
<div class="item-icon">📐</div>
<div class="item-info">
<div class="item-name">${esc(d.nama_pemilik)}</div>
<div class="item-sub">${d.luas_m2.toFixed(1)} m² · ${d.no_sertifikat || '—'}</div>
</div>
<div style="display:flex;align-items:center;gap:5px">
<div class="item-badge" style="background:${parsilCfg[d.jenis_hak].color}18;color:${parsilCfg[d.jenis_hak].color};border:1px solid ${parsilCfg[d.jenis_hak].color}44">${d.jenis_hak}</div>
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editParsil(${d.id})">✏</button>
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteParsil(${d.id})">✕</button>
</div>
</div>`).join('') : '<div class="loading">Belum ada data parsil</div>'}
</div>
</div>`;
loadParsilLayer(data);
registerSearchEntries('parsil', data.map(d => ({
key: `parsil:${d.id}`,
label: d.nama_pemilik,
subtitle: `Parsil ${d.jenis_hak} · ${d.luas_m2.toFixed(1)}${d.no_sertifikat ? ` · ${d.no_sertifikat}` : ''}`,
typeLabel: 'Parsil',
icon: '📐',
searchText: normalizeSearchText(`${d.nama_pemilik} ${d.jenis_hak} parsil tanah`),
focus: () => focusGeom(d.id, 'parsil')
})));
updateStatusCount();
});
}
function loadParsilLayer(data) {
['SHM', 'HGB', 'HGU', 'HP'].forEach(k => layers['parsil' + k].clearLayers());
data.forEach(d => {
const cfg = parsilCfg[d.jenis_hak], coords = d.geojson.coordinates[0].map(c => [c[1], c[0]]);
const poly = L.polygon(coords, { color: cfg.color, fillColor: cfg.color, fillOpacity: 0.18, weight: 2 })
.bindPopup(`<h4>📐 ${esc(d.nama_pemilik)}</h4>
<p><b>Jenis Hak:</b> ${d.jenis_hak}${cfg.label}</p>
<p><b>Luas:</b> ${d.luas_m2.toFixed(2)} m² (${(d.luas_m2 / 10000).toFixed(4)} ha)</p>
${d.no_sertifikat ? `<p><b>No. Sertifikat:</b> ${d.no_sertifikat}</p>` : ''}
${d.keterangan ? `<p>${esc(d.keterangan)}</p>` : ''}
<div class="popup-actions"><button class="btn btn-ghost btn-sm" onclick="editParsil(${d.id})">✏ Edit</button><button class="btn btn-danger btn-sm" onclick="deleteParsil(${d.id})">✕ Hapus</button></div>`);
poly._dataId = d.id;
poly._groupKey = 'parsil' + d.jenis_hak;
layers['parsil' + d.jenis_hak].addLayer(poly);
});
}
function openParsilForm() {
openModal('Simpan Parsil Tanah', `
<div class="form-calc">📐 Luas: <b>${pendingArea.toFixed(2)} m²</b> = ${(pendingArea / 10000).toFixed(4)} ha </div>
<div class="form-group"><label class="form-label">Nama Pemilik *</label>
<input class="form-input" id="ps-nama" placeholder="Budi Santoso…"/></div>
<div class="form-row">
<div class="form-group"><label class="form-label">Jenis Hak *</label>
<select class="form-select" id="ps-hak">
<option value="SHM">SHM — Hak Milik</option>
<option value="HGB">HGB — Guna Bangunan</option>
<option value="HGU">HGU — Guna Usaha</option>
<option value="HP">HP — Hak Pakai</option>
</select></div>
<div class="form-group"><label class="form-label">No. Sertifikat</label>
<input class="form-input" id="ps-sert" placeholder="00001/Kel/…"/></div>
</div>
<div class="form-group"><label class="form-label">Keterangan</label>
<input class="form-input" id="ps-ket" placeholder="Opsional…"/></div>`,
`<button class="btn btn-ghost" onclick="closeModal();drawnItems.clearLayers()">Batal</button>
<button class="btn btn-primary" onclick="saveParsil()">💾 Simpan</button>`);
}
async function saveParsil() {
const body = {
nama_pemilik: document.getElementById('ps-nama').value.trim(),
jenis_hak: document.getElementById('ps-hak').value,
no_sertifikat: document.getElementById('ps-sert').value,
luas_m2: pendingArea,
keterangan: document.getElementById('ps-ket').value,
geojson: pendingGeoJSON
};
if (!body.nama_pemilik) return toast('Nama wajib diisi', 'error');
const res = await callAPI('data_parsil.php', 'POST', body);
if (res.status === 'success') {
toast(res.message, 'success');
closeModal();
drawnItems.clearLayers();
pendingGeoJSON = null;
pendingArea = null;
switchTab('parsil');
} else {
toast(res.message, 'error');
}
}
async function editParsil(id) {
map.closePopup();
const res = await callAPI(`data_parsil.php?id=${id}`);
const data = res.data;
const sourceLayer = findGeomLayer('parsil', id);
if (!data) return toast('Data parsil tidak ditemukan', 'error');
const groupKey = getGeometryGroupKey('parsil', data);
const editLayer = sourceLayer ? cloneEditableLayer('parsil', sourceLayer) : buildGeometryLayer('parsil', data);
editLayer._dataId = id;
editLayer._groupKey = groupKey;
if (sourceLayer) layers[groupKey].removeLayer(sourceLayer);
drawnItems.clearLayers();
drawnItems.addLayer(editLayer);
geometryEditState = { type: 'parsil', id, groupKey, sourceLayer, editLayer };
if (editLayer.editing && editLayer.editing.enable) {
editLayer.editing.enable();
} else {
const handler = getGeometryEditHandler();
if (!handler) {
drawnItems.clearLayers();
if (sourceLayer) layers[groupKey].addLayer(sourceLayer);
geometryEditState = null;
return toast('Mode edit geometri belum siap', 'error');
}
handler.enable();
}
map.fitBounds(editLayer.getBounds(), { padding: [50, 50], duration: 1 });
document.getElementById('panel-title').textContent = 'Edit Parsil Tanah';
document.getElementById('panel-body').innerHTML = `
<div class="layer-section">
<div class="layer-section-title">Edit Parsil</div>
<div style="font-size:11px;line-height:1.7;color:var(--text2);padding:10px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px">
Geser titik-titik batas bidang di peta lalu simpan perubahan.
</div>
<div class="form-group">
<label class="form-label">Nama Pemilik *</label>
<input class="form-input" id="ps-edit-nama" value="${esc(data.nama_pemilik)}" />
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Jenis Hak *</label>
<select class="form-select" id="ps-edit-hak">
${Object.entries(parsilCfg).map(([k, v]) => `<option value="${k}"${data.jenis_hak === k ? ' selected' : ''}>${k}${v.label}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label class="form-label">Luas Otomatis</label>
<input class="form-input" id="ps-edit-luas" value="${data.luas_m2.toFixed(2)} m²" disabled />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">No. Sertifikat</label>
<input class="form-input" id="ps-edit-sert" value="${esc(data.no_sertifikat || '')}" />
</div>
<div class="form-group">
<label class="form-label">Keterangan</label>
<input class="form-input" id="ps-edit-ket" value="${esc(data.keterangan || '')}" />
</div>
</div>
</div>`;
document.getElementById('panel-body').insertAdjacentHTML('beforeend', `
<div style="display:flex;gap:8px">
<button class="btn btn-ghost" style="flex:1" onclick="cancelGeometryEdit()">Batal</button>
<button class="btn btn-primary" style="flex:1" onclick="saveParsilEdit(${id})">Simpan Perubahan</button>
</div>`);
}
async function saveParsilEdit(id) {
const state = geometryEditState;
if (!state || state.type !== 'parsil') return;
const layer = state.editLayer;
const ringLatLngs = layer.getLatLngs()[0];
const ring = ringLatLngs.map(ll => [ll.lng, ll.lat]);
ring.push([ringLatLngs[0].lng, ringLatLngs[0].lat]);
const luas_m2 = turf.area(turf.polygon([ring]));
const body = {
nama_pemilik: document.getElementById('ps-edit-nama').value.trim(),
jenis_hak: document.getElementById('ps-edit-hak').value,
no_sertifikat: document.getElementById('ps-edit-sert').value,
luas_m2,
keterangan: document.getElementById('ps-edit-ket').value,
geojson: { type: 'Polygon', coordinates: [ring] }
};
if (!body.nama_pemilik) return toast('Nama wajib diisi', 'error');
const res = await callAPI(`data_parsil.php?id=${id}`, 'PUT', body);
if (res.status === 'success') {
toast(res.message, 'success');
if (layer.editing && layer.editing.disable) layer.editing.disable();
const handler = getGeometryEditHandler();
if (handler) handler.disable();
drawnItems.clearLayers();
geometryEditState = null;
switchTab('parsil');
} else {
toast(res.message, 'error');
}
}
async function deleteParsil(id) {
if (!confirm('Hapus?')) return;
const r = await callAPI(`data_parsil.php?id=${id}`, 'DELETE');
if (r.status === 'success') {
toast('Dihapus', 'success');
map.closePopup();
switchTab('parsil');
}
}
-158
View File
@@ -1,158 +0,0 @@
function renderSpbuPanel(pb) {
return callAPI('spbu.php').then(res => {
const data = res.data || [];
const d24 = data.filter(s => s.buka_24jam);
const dn24 = data.filter(s => !s.buka_24jam);
pb.innerHTML = `
<div class="layer-section">
<div class="layer-section-title">Filter Layer</div>
${mkLayerItem('spbu24', '#16a34a', 'SPBU Buka 24 Jam', d24.length)}
${mkLayerItem('spbuNon24', '#94a3b8', 'SPBU Tidak 24 Jam', dn24.length)}
</div>
<div class="layer-section">
<div class="layer-section-title">Tambah Data</div>
<button class="btn-add" id="btn-add-spbu" onclick="startAddSPBU()"> Tambah SPBU</button>
</div>
<div class="layer-section">
<div class="layer-section-title">Daftar SPBU (${data.length})</div>
<div class="data-list">
${data.length ? data.map(s => `
<div class="data-item" onclick="map.flyTo([${s.latitude},${s.longitude}],17)">
<div class="item-icon">⛽</div>
<div class="item-info">
<div class="item-name">${esc(s.nama)}</div>
<div class="item-sub">${s.no_wa || '—'}</div>
</div>
<div style="display:flex;align-items:center;gap:5px">
<div class="item-badge ${s.buka_24jam ? 'badge-green' : 'badge-amber'}">${s.buka_24jam ? '24 JAM' : 'TERBATAS'}</div>
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editSPBU(${s.id})">✏</button>
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteSPBU(${s.id})">✕</button>
</div>
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
</div>
</div>`;
loadSPBULayer(data);
registerSearchEntries('spbu', data.map(s => ({
key: `spbu:${s.id}`,
label: s.nama,
subtitle: `SPBU · ${s.buka_24jam ? '24 Jam' : 'Tidak 24 Jam'}${s.alamat ? ` · ${s.alamat}` : ''}`,
typeLabel: 'SPBU',
icon: '⛽',
searchText: normalizeSearchText(`${s.nama} ${s.no_wa || ''} spbu`),
focus: () => focusMarkerById(s.buka_24jam ? 'spbu24' : 'spbuNon24', s.id)
})));
updateStatusCount();
});
}
function loadSPBULayer(data) {
layers.spbu24.clearLayers();
layers.spbuNon24.clearLayers();
data.forEach(s => {
const is24 = s.buka_24jam;
const color = is24 ? '#16a34a' : '#64748b';
const icon = L.divIcon({
className: '',
html: `<div style="width:30px;height:30px;background:${color};border:3px solid #fff;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 3px 10px rgba(0,0,0,.2);cursor:grab;">⛽</div>`,
iconSize: [30, 30], iconAnchor: [15, 15]
});
const marker = L.marker([s.latitude, s.longitude], { icon, draggable: true }).bindPopup(`<h4>⛽ ${esc(s.nama)}</h4>
<p><b>Status:</b> ${is24 ? '<span style="color:#16a34a;font-weight:600">✅ Buka 24 Jam</span>' : '⏰ Tidak 24 Jam'}</p>
${s.no_wa ? `<p><b>WA:</b> ${s.no_wa}</p>` : ''}
${s.alamat ? `<p><b>Alamat:</b> ${esc(s.alamat)}</p>` : ''}
<div class="popup-drag-hint">↔ Seret marker untuk pindahkan lokasi — auto-save</div>
<div class="popup-actions">
<button class="btn btn-ghost btn-sm" onclick="editSPBU(${s.id})">✏ Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteSPBU(${s.id})">✕ Hapus</button>
</div>`);
marker._dataId = s.id;
marker.on('dragend', async function (ev) {
const { lat, lng } = ev.target.getLatLng();
const res = await callAPI(`spbu.php?id=${s.id}`, 'PUT', {
nama: s.nama, no_wa: s.no_wa, alamat: s.alamat,
buka_24jam: s.buka_24jam, latitude: lat, longitude: lng
});
if (res.status === 'success') {
s.latitude = lat;
s.longitude = lng;
toast(`📍 ${s.nama} dipindahkan`, 'success');
} else {
toast('Gagal update: ' + res.message, 'error');
marker.setLatLng([s.latitude, s.longitude]);
}
});
is24 ? layers.spbu24.addLayer(marker) : layers.spbuNon24.addLayer(marker);
});
}
function startAddSPBU() {
cancelMapClick();
document.getElementById('btn-add-spbu').classList.add('active');
showInstruction('Klik di peta untuk menentukan lokasi SPBU');
onceMapClick((lat, lng) => {
document.getElementById('btn-add-spbu').classList.remove('active');
hideInstruction();
openSPBUForm(null, lat, lng);
});
}
function openSPBUForm(data, lat, lng) {
const isEdit = !!data;
const fLat = isEdit ? data.latitude : lat;
const fLng = isEdit ? data.longitude : lng;
openModal(isEdit ? 'Edit SPBU' : 'Tambah SPBU', `
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
<div class="form-group"><label class="form-label">Nama SPBU *</label>
<input class="form-input" id="spbu-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="SPBU 64.751.01"/></div>
<div class="form-group"><label class="form-label">No. WhatsApp</label>
<input class="form-input" id="spbu-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/></div>
<div class="form-group"><label class="form-label">Alamat</label>
<input class="form-input" id="spbu-alamat" value="${esc(isEdit ? data.alamat || '' : '')}" placeholder="Jl. …"/></div>
<div class="form-group"><label class="form-label">Status Operasional</label>
<select class="form-select" id="spbu-24">
<option value="1"${isEdit && data.buka_24jam ? ' selected' : ''}>✅ Buka 24 Jam</option>
<option value="0"${isEdit && !data.buka_24jam ? ' selected' : ''}>⏰ Tidak 24 Jam</option>
</select></div>`,
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
<button class="btn btn-primary" onclick="saveSPBU(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
}
async function saveSPBU(id, lat, lng) {
const body = {
nama: document.getElementById('spbu-nama').value.trim(),
no_wa: document.getElementById('spbu-nowa').value.trim(),
alamat: document.getElementById('spbu-alamat').value.trim(),
buka_24jam: document.getElementById('spbu-24').value === '1',
latitude: lat,
longitude: lng
};
if (!body.nama) return toast('Nama wajib diisi', 'error');
const res = await callAPI(id ? `spbu.php?id=${id}` : 'spbu.php', id ? 'PUT' : 'POST', body);
if (res.status === 'success') {
toast(res.message, 'success');
closeModal();
switchTab('spbu');
} else {
toast(res.message, 'error');
}
}
async function editSPBU(id) {
map.closePopup();
const r = await callAPI(`spbu.php?id=${id}`);
if (r.data) openSPBUForm(r.data, r.data.latitude, r.data.longitude);
}
async function deleteSPBU(id) {
if (!confirm('Hapus SPBU?')) return;
const r = await callAPI(`spbu.php?id=${id}`, 'DELETE');
if (r.status === 'success') {
toast('Dihapus', 'success');
map.closePopup();
switchTab('spbu');
}
}
+150
View File
@@ -0,0 +1,150 @@
function haversineDistance(lat1, lng1, lat2, lng2) {
const R = 6371000;
const toRad = d => d * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat/2)**2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
async function openLogBantuan(pmId, pmNama, pmLat, pmLng) {
map.closePopup();
const [logRes, riRes] = await Promise.all([
callAPI(`log_bantuan.php?penduduk_miskin_id=${pmId}`),
callAPI('rumah_ibadah.php')
]);
const logs = logRes.data || [];
const ris = riRes.data || [];
// --- Determine best default RI ---
let defaultRiId = ris.length > 0 ? ris[0].id : null;
if (pmLat != null && pmLng != null && ris.length > 0) {
// Find the RI whose circle the resident falls inside (if any), prefer that
const insideRis = ris.filter(r => {
const dist = haversineDistance(pmLat, pmLng, r.latitude, r.longitude);
return dist <= (r.radius_meter || 500);
});
if (insideRis.length > 0) {
// Among RIs covering this resident, pick the closest center
insideRis.sort((a, b) =>
haversineDistance(pmLat, pmLng, a.latitude, a.longitude) -
haversineDistance(pmLat, pmLng, b.latitude, b.longitude)
);
defaultRiId = insideRis[0].id;
} else {
// Not within any radius — pick nearest RI geometrically
const sorted = [...ris].sort((a, b) =>
haversineDistance(pmLat, pmLng, a.latitude, a.longitude) -
haversineDistance(pmLat, pmLng, b.latitude, b.longitude)
);
defaultRiId = sorted[0].id;
}
}
// Build RI options with distance label (admin only) or locked (petugas)
let riSelectHTML;
if (window.currentUser && window.currentUser.role === 'petugas_lapangan') {
// Petugas can only see their own RI
riSelectHTML = `<select class="form-select" id="lb-ri" disabled>
${ris.filter(r => String(r.id) === String(window.currentUser.rumah_ibadah_id))
.map(r => `<option value="${r.id}" selected>${esc(r.nama)} (${r.jenis})</option>`).join('')}
</select>`;
} else {
// Admin — show all, sorted by distance, with distance label if coords available
const sorted = (pmLat != null && pmLng != null)
? [...ris].sort((a, b) =>
haversineDistance(pmLat, pmLng, a.latitude, a.longitude) -
haversineDistance(pmLat, pmLng, b.latitude, b.longitude))
: ris;
riSelectHTML = `<select class="form-select" id="lb-ri">
${sorted.map(r => {
const dist = (pmLat != null && pmLng != null)
? Math.round(haversineDistance(pmLat, pmLng, r.latitude, r.longitude))
: null;
const distLabel = dist != null ? `${dist < 1000 ? dist + ' m' : (dist/1000).toFixed(1) + ' km'}` : '';
const isInside = dist != null && dist <= (r.radius_meter || 500);
const label = `${esc(r.nama)} (${r.jenis})${distLabel}${isInside ? ' ✅' : ''}`;
const selected = String(r.id) === String(defaultRiId) ? 'selected' : '';
return `<option value="${r.id}" ${selected}>${label}</option>`;
}).join('')}
</select>
${pmLat != null ? `<div style="font-size:11px;color:#64748b;margin-top:4px">✅ = dalam radius · Diurutkan berdasarkan jarak terdekat</div>` : ''}`;
}
openModal(`Log Bantuan — ${pmNama}`, `
<div style="margin-bottom:16px">
<div class="layer-section-title">Riwayat Bantuan</div>
${logs.length ? `
${logs.slice(0, 2).map(l => `
<div class="log-item ${l.tipe_bantuan === 'Pemberdayaan' ? 'pemberdayaan' : 'konsumtif'}">
<div class="log-type">${l.tipe_bantuan === 'Pemberdayaan' ? '📚 Pemberdayaan' : '🛍 Konsumtif'}</div>
<div class="log-detail">${esc(l.sub_kategori) || ''} ${l.keterangan ? '— ' + esc(l.keterangan) : ''}</div>
<div class="log-date">📅 ${l.tanggal_bantuan} · ${esc(l.nama_rumah_ibadah)}</div>
</div>`).join('')}
${logs.length > 2 ? `<div style="font-size:11px;color:#64748b;text-align:center;padding:6px 0;border-top:1px solid #f1f5f9;margin-top:4px">+${logs.length - 2} bantuan lainnya · Lihat detail lengkap di halaman Daftar Penduduk Miskin</div>` : ''}
` : '<div class="loading">Belum ada riwayat bantuan</div>'}
</div>
<div class="layer-section-title">Tambah Bantuan Baru</div>
<div class="form-group">
<label class="form-label">Dari Rumah Ibadah *</label>
${riSelectHTML}
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Tipe Bantuan *</label>
<select class="form-select" id="lb-tipe">
<option value="Pemberdayaan">📚 Pemberdayaan (Pelatihan)</option>
<option value="Konsumtif">🛍 Konsumtif (Makan/Barang)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Tanggal *</label>
<input class="form-input" id="lb-tgl" type="date" value="${new Date().toISOString().slice(0, 10)}"/>
</div>
</div>
<div class="form-group">
<label class="form-label">Sub Kategori</label>
<input class="form-input" id="lb-sub" placeholder="Pelatihan menjahit / Sembako…"/>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Keterangan</label>
<input class="form-input" id="lb-ket" placeholder="Opsional"/>
</div>
<div class="form-group">
<label class="form-label">Nilai (Rp)</label>
<input class="form-input" id="lb-nilai" type="number" placeholder="0"/>
</div>
</div>`,
`<button class="btn btn-ghost" onclick="closeModal()">Tutup</button>
<button class="btn btn-primary" onclick="saveLog(${pmId})"> Tambah Log</button>`);
}
async function saveLog(pmId) {
const body = {
penduduk_miskin_id: pmId,
rumah_ibadah_id: parseInt(document.getElementById('lb-ri').value),
tipe_bantuan: document.getElementById('lb-tipe').value,
tanggal_bantuan: document.getElementById('lb-tgl').value,
sub_kategori: document.getElementById('lb-sub').value,
keterangan: document.getElementById('lb-ket').value,
nilai_bantuan: document.getElementById('lb-nilai').value || null
};
const res = await callAPI('log_bantuan.php', 'POST', body);
if (res.status === 'success') {
toast('Log dicatat!', 'success');
closeModal();
switchTab('penduduk_miskin');
} else {
toast(res.message, 'error');
}
}
async function recalcAllProximity() {
await callAPI('penduduk_miskin.php?action=recalc_all', 'POST');
}
+225
View File
@@ -0,0 +1,225 @@
function startAdd(type) {
cancelMapClick();
const btnId = type === 'ri' ? 'btn-add-ri' : 'btn-add-pm';
const label = type === 'ri' ? 'lokasi Rumah Ibadah' : 'lokasi Penduduk Miskin';
document.getElementById(btnId).classList.add('active');
showInstruction(`Klik di peta untuk menentukan ${label}`);
onceMapClick((lat, lng) => {
document.getElementById(btnId).classList.remove('active');
hideInstruction();
if (type === 'ri') openRIForm(null, lat, lng);
else openPMForm(null, lat, lng);
});
}
function openRIForm(data, lat, lng) {
const isEdit = !!data;
const fLat = isEdit ? data.latitude : lat;
const fLng = isEdit ? data.longitude : lng;
const isAdmin = window.currentUser && window.currentUser.role === 'admin';
const accountFieldsHTML = isAdmin ? `
<div style="margin-top:16px; border-top: 1px solid var(--line); padding-top:14px;">
<div class="layer-section-title">Akun Rumah Ibadah</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Username Rumah Ibadah</label>
<input class="form-input" id="ri-username" value="${esc(isEdit ? data.username || '' : '')}" placeholder="username_rumah_ibadah" autocomplete="off" readonly style="background-color:#f1f5f9;cursor:not-allowed;color:#64748b;"/>
</div>
<div class="form-group">
<label class="form-label">Password Rumah Ibadah ${isEdit ? '(kosongkan jika tidak diubah)' : '*'}</label>
<input class="form-input" id="ri-password" type="password" placeholder="••••••••" autocomplete="new-password"/>
</div>
</div>
</div>
` : '';
openModal(isEdit ? 'Edit Rumah Ibadah' : 'Tambah Rumah Ibadah', `
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
<div class="form-group">
<label class="form-label">Nama Rumah Ibadah *</label>
<input class="form-input" id="ri-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="Masjid Al-Ikhlas…"/>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Jenis *</label>
<select class="form-select" id="ri-jenis">
${['Masjid', 'Gereja', 'Pura', 'Vihara', 'Klenteng', 'Lainnya'].map(j =>
`<option value="${j}"${isEdit && data.jenis === j ? ' selected' : ''}>${j}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label class="form-label">Radius (meter) *</label>
<input class="form-input" id="ri-radius" type="number" value="${isEdit ? data.radius_meter : 500}" min="50" max="10000"/>
</div>
</div>
<div class="form-group">
<label class="form-label">Alamat</label>
<input class="form-input" id="ri-alamat" value="${isEdit ? esc(data.alamat || '') : 'Memuat alamat otomatis...'}" placeholder="Jl. …"/>
</div>
<div class="form-group">
<label class="form-label">No. WhatsApp</label>
<input class="form-input" id="ri-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/>
</div>
${accountFieldsHTML}`,
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
<button class="btn btn-primary" onclick="saveRI(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
// Attach dynamic username locking listener
if (isAdmin) {
const namaEl = document.getElementById('ri-nama');
const usernameEl = document.getElementById('ri-username');
if (namaEl && usernameEl) {
namaEl.addEventListener('input', () => {
usernameEl.value = namaEl.value
.toLowerCase()
.replace(/[^a-z0-9]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
});
}
}
// Geocode address asynchronously
if (!isEdit) {
getAddressFromCoords(fLat, fLng).then(addr => {
const el = document.getElementById('ri-alamat');
if (el && el.value === 'Memuat alamat otomatis...') {
el.value = addr || '';
}
});
}
}
async function saveRI(id, lat, lng) {
const usernameEl = document.getElementById('ri-username');
const passwordEl = document.getElementById('ri-password');
const body = {
nama: document.getElementById('ri-nama').value.trim(),
jenis: document.getElementById('ri-jenis').value,
alamat: document.getElementById('ri-alamat').value.trim(),
no_wa: document.getElementById('ri-nowa').value.trim(),
latitude: lat,
longitude: lng,
radius_meter: parseInt(document.getElementById('ri-radius').value),
username: usernameEl ? usernameEl.value.trim() : '',
password: passwordEl ? passwordEl.value : ''
};
if (!body.nama) return toast('Nama wajib diisi', 'error');
// Validate account password for new Rumah Ibadah
const isAdmin = window.currentUser && window.currentUser.role === 'admin';
if (isAdmin && !id) {
if (!body.username) return toast('Username Rumah Ibadah wajib terisi', 'error');
if (!body.password) return toast('Password Rumah Ibadah wajib diisi', 'error');
}
const res = await callAPI(id ? `rumah_ibadah.php?id=${id}` : 'rumah_ibadah.php', id ? 'PUT' : 'POST', body);
if (res.status === 'success') {
toast(res.message, 'success');
closeModal();
await recalcAllProximity();
switchTab('rumah_ibadah');
} else {
toast(res.message, 'error');
}
}
function openPMForm(data, lat, lng) {
const isEdit = !!data;
const fLat = isEdit ? data.latitude : lat;
const fLng = isEdit ? data.longitude : lng;
openModal(isEdit ? 'Edit Penduduk Miskin' : 'Tambah Penduduk Miskin', `
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
<div class="form-group">
<label class="form-label">Nama Lengkap *</label>
<input class="form-input" id="pm-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="Ahmad Subarjo…"/>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">No. WhatsApp</label>
<input class="form-input" id="pm-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/>
</div>
<div class="form-group">
<label class="form-label">NIK</label>
<input class="form-input" id="pm-nik" value="${esc(isEdit ? data.nik || '' : '')}" placeholder="617101…"/>
</div>
</div>
<div class="form-group">
<label class="form-label">Alamat</label>
<input class="form-input" id="pm-alamat" value="${isEdit ? esc(data.alamat || '') : 'Memuat alamat otomatis...'}" placeholder="Jl. …"/>
</div>
<div class="form-group">
<label class="form-label">Jumlah Anggota Keluarga</label>
<input class="form-input" id="pm-jml" type="number" value="${isEdit ? data.jumlah_anggota : 1}" min="1" max="20"/>
</div>`,
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
<button class="btn btn-primary" onclick="savePM(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
// Geocode address asynchronously
if (!isEdit) {
getAddressFromCoords(fLat, fLng).then(addr => {
const el = document.getElementById('pm-alamat');
if (el && el.value === 'Memuat alamat otomatis...') {
el.value = addr || '';
}
});
}
}
async function savePM(id, lat, lng) {
const body = {
nama: document.getElementById('pm-nama').value.trim(),
no_wa: document.getElementById('pm-nowa').value.trim(),
nik: document.getElementById('pm-nik').value.trim(),
alamat: document.getElementById('pm-alamat').value.trim(),
jumlah_anggota: parseInt(document.getElementById('pm-jml').value) || 1,
latitude: lat,
longitude: lng
};
if (!body.nama) return toast('Nama wajib diisi', 'error');
const res = await callAPI(id ? `penduduk_miskin.php?id=${id}` : 'penduduk_miskin.php', id ? 'PUT' : 'POST', body);
if (res.status === 'success') {
toast(res.message, 'success');
closeModal();
switchTab('penduduk_miskin');
} else {
toast(res.message, 'error');
}
}
async function editRI(id) {
map.closePopup();
const res = await callAPI(`rumah_ibadah.php?id=${id}`);
if (res.data) openRIForm(res.data, res.data.latitude, res.data.longitude);
}
async function deleteRI(id) {
if (!confirm('Hapus rumah ibadah ini?')) return;
const res = await callAPI(`rumah_ibadah.php?id=${id}`, 'DELETE');
if (res.status === 'success') {
toast('Dihapus', 'success');
map.closePopup();
await recalcAllProximity();
switchTab('rumah_ibadah');
}
}
async function editPM(id) {
map.closePopup();
const res = await callAPI(`penduduk_miskin.php?id=${id}`);
if (res.data) openPMForm(res.data, res.data.latitude, res.data.longitude);
}
async function deletePM(id) {
if (!confirm('Hapus data ini?')) return;
const res = await callAPI(`penduduk_miskin.php?id=${id}`, 'DELETE');
if (res.status === 'success') {
toast('Dihapus', 'success');
map.closePopup();
await recalcAllProximity();
switchTab('penduduk_miskin');
}
}
// Haversine formula — returns distance in meters between two lat/lng points
+138
View File
@@ -0,0 +1,138 @@
function loadRILayer(data) {
layers.rumahIbadah.clearLayers();
layers.ibadahRadius.clearLayers();
data.forEach(r => {
const circle = L.circle([r.latitude, r.longitude], {
radius: r.radius_meter,
color: '#f59e0b', fillColor: '#fef3c7', fillOpacity: 0.18,
weight: 1.5, dashArray: '6,4'
});
layers.ibadahRadius.addLayer(circle);
const icon = L.divIcon({
className: '',
html: `<div style="width:32px;height:32px;background:#f59e0b;border:3px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 3px 10px rgba(0,0,0,.2);cursor:grab;">⛪</div>`,
iconSize: [32, 32], iconAnchor: [16, 16]
});
const marker = L.marker([r.latitude, r.longitude], {
icon,
draggable: (window.currentUser && window.currentUser.role === 'admin')
}).bindPopup(riPopup(r));
marker._dataId = r.id;
marker.on('dragend', async function (ev) {
const { lat, lng } = ev.target.getLatLng();
circle.setLatLng([lat, lng]);
const res = await callAPI(`rumah_ibadah.php?id=${r.id}`, 'PUT', {
nama: r.nama, jenis: r.jenis, alamat: r.alamat,
no_wa: r.no_wa, latitude: lat, longitude: lng,
radius_meter: r.radius_meter
});
if (res.status === 'success') {
r.latitude = lat;
r.longitude = lng;
toast(`📍 ${r.nama} dipindahkan & koordinat diperbarui`, 'success');
await recalcAllProximity();
switchTab(currentTab);
} else {
toast('Gagal update lokasi: ' + res.message, 'error');
marker.setLatLng([r.latitude, r.longitude]);
}
});
layers.rumahIbadah.addLayer(marker);
});
}
function riPopup(r) {
const canEdit = window.currentUser && (window.currentUser.role === 'admin' || (window.currentUser.role === 'petugas_lapangan' && String(window.currentUser.rumah_ibadah_id) === String(r.id)));
const isAdmin = window.currentUser && window.currentUser.role === 'admin';
return `<h4>⛪ ${esc(r.nama)}</h4>
<p><b>Jenis:</b> ${r.jenis}</p>
<p><b>Radius:</b> ${r.radius_meter} m</p>
${r.alamat ? `<p><b>Alamat:</b> ${esc(r.alamat)}</p>` : ''}
${r.no_wa ? `<p><b>WA:</b> ${r.no_wa}</p>` : ''}
<div class="popup-actions">
${canEdit ? `<button class="btn btn-ghost btn-sm" onclick="editRI(${r.id})">✏ Edit</button>` : ''}
${isAdmin ? `<button class="btn btn-danger btn-sm" onclick="deleteRI(${r.id})">✕ Hapus</button>` : ''}
</div>`;
}
function loadPMLayer(data) {
layers.pendudukMiskin.clearLayers();
data.forEach(p => {
const inR = p.status_proximity === 'dalam_radius';
const color = inR ? '#16a34a' : '#dc2626';
const hasBoth = p.bantuan_pemberdayaan > 0 && p.bantuan_konsumtif > 0;
const hasPemb = p.bantuan_pemberdayaan > 0;
const hasKons = p.bantuan_konsumtif > 0;
let dot = '';
if (hasBoth || hasPemb) dot = `<div style="position:absolute;top:-3px;right:-3px;width:9px;height:9px;background:#2563eb;border:1.5px solid #fff;border-radius:50%"></div>`;
if (hasKons && !hasPemb) dot = `<div style="position:absolute;top:-3px;right:-3px;width:9px;height:9px;background:#f59e0b;border:1.5px solid #fff;border-radius:50%"></div>`;
const icon = L.divIcon({
className: '',
html: `<div style="position:relative"><div style="width:26px;height:26px;background:${color};border:3px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;box-shadow:0 3px 10px ${color}66;cursor:grab;">👤</div>${dot}</div>`,
iconSize: [26, 26], iconAnchor: [13, 13]
});
const isAdmin = window.currentUser && window.currentUser.role === 'admin';
const isPetugas = window.currentUser && window.currentUser.role === 'petugas_lapangan';
const isResidentInRadiusOfPetugas = isPetugas && inR && String(p.rumah_ibadah_id) === String(window.currentUser.rumah_ibadah_id);
const canDrag = isAdmin || isResidentInRadiusOfPetugas;
const marker = L.marker([p.latitude, p.longitude], {
icon,
draggable: canDrag
}).bindPopup(pmPopup(p));
marker._dataId = p.id;
marker.on('dragend', async function (ev) {
const { lat, lng } = ev.target.getLatLng();
const res = await callAPI(`penduduk_miskin.php?id=${p.id}`, 'PUT', {
nama: p.nama, no_wa: p.no_wa, nik: p.nik,
alamat: p.alamat, jumlah_anggota: p.jumlah_anggota,
latitude: lat, longitude: lng
});
if (res.status === 'success') {
const prox = res.data?.proximity;
const statusMsg = prox?.status === 'dalam_radius' ? '✅ Dalam radius' : '❌ Luar radius';
toast(`📍 ${p.nama} dipindahkan — ${statusMsg}`, 'success');
switchTab('penduduk_miskin');
} else {
toast('Gagal update: ' + res.message, 'error');
marker.setLatLng([p.latitude, p.longitude]);
}
});
layers.pendudukMiskin.addLayer(marker);
});
}
function pmPopup(p) {
const inR = p.status_proximity === 'dalam_radius';
const isAdmin = window.currentUser && window.currentUser.role === 'admin';
const isPetugas = window.currentUser && window.currentUser.role === 'petugas_lapangan';
const isResidentInRadiusOfPetugas = isPetugas && inR && String(p.rumah_ibadah_id) === String(window.currentUser.rumah_ibadah_id);
const canEditOrLog = isAdmin || isResidentInRadiusOfPetugas;
return `<h4>👤 ${esc(p.nama)}</h4>
<div class="proximity-info ${inR ? 'dalam' : 'luar'}">
${inR ? `✅ Dalam radius — ${esc(p.nama_rumah_ibadah || '')}` : '❌ Di luar radius rumah ibadah'}
</div>
${p.no_wa ? `<p><b>WA:</b> ${p.no_wa}</p>` : ''}
${p.nik ? `<p><b>NIK:</b> ${p.nik}</p>` : ''}
${p.alamat ? `<p><b>Alamat:</b> ${esc(p.alamat)}</p>` : ''}
<p><b>Keluarga:</b> ${p.jumlah_anggota} jiwa</p>
<p><b>Bantuan:</b> 📚${p.bantuan_pemberdayaan} · 🛍${p.bantuan_konsumtif}</p>
<div class="popup-actions">
${canEditOrLog ? `<button class="btn btn-primary btn-sm" onclick="openLogBantuan(${p.id},'${esc(p.nama)}',${p.latitude},${p.longitude})">📋 Log Bantuan</button>` : ''}
${canEditOrLog ? `<button class="btn btn-ghost btn-sm" onclick="editPM(${p.id})">✏ Edit</button>` : ''}
${isAdmin ? `<button class="btn btn-danger btn-sm" onclick="deletePM(${p.id})">✕</button>` : ''}
</div>`;
}
+147
View File
@@ -0,0 +1,147 @@
function renderRumahIbadahPanel(pb) {
return Promise.all([callAPI('rumah_ibadah.php'), callAPI('penduduk_miskin.php')]).then(([riRes, pmRes]) => {
const ri = riRes.data || [];
const pm = pmRes.data || [];
pb.innerHTML = `
<div class="layer-section">
<div class="layer-section-title">Visibilitas Layer</div>
${mkLayerItem('rumahIbadah', '#f59e0b', 'Rumah Ibadah', ri.length)}
${mkLayerItem('ibadahRadius', '#fbbf24', 'Radius Jangkauan', ri.length, true)}
${mkLayerItem('pendudukMiskin', '#16a34a', 'Penduduk Miskin', pm.length)}
</div>
<div class="layer-section">
<div class="layer-section-title">Tambah Data</div>
<button class="btn-add admin-only" id="btn-add-ri" onclick="startAdd('ri')"> Tambah Rumah Ibadah</button>
</div>
<div class="layer-section">
<div class="layer-section-title">Rumah Ibadah (${ri.length})</div>
<div class="data-list">
${ri.length ? ri.map(r => `
<div class="data-item" onclick="map.flyTo([${r.latitude},${r.longitude}],17)">
<div class="item-icon">⛪</div>
<div class="item-info">
<div class="item-name">${esc(r.nama)}</div>
<div class="item-sub">${r.jenis} · R=${r.radius_meter}m</div>
</div>
<div style="display:flex;gap:4px">
${(window.currentUser && (window.currentUser.role === 'admin' || (window.currentUser.role === 'petugas_lapangan' && String(window.currentUser.rumah_ibadah_id) === String(r.id)))) ? `<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editRI(${r.id})">✏</button>` : ''}
${(window.currentUser && window.currentUser.role === 'admin') ? `<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteRI(${r.id})">✕</button>` : ''}
</div>
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
</div>
</div>`;
loadRILayer(ri);
loadPMLayer(pm);
registerSearchEntries('ri', ri.map(r => ({
key: `ri:${r.id}`,
label: r.nama,
subtitle: `Rumah Ibadah · ${r.jenis}${r.alamat ? ` · ${r.alamat}` : ''}`,
typeLabel: 'Rumah Ibadah',
icon: '⛪',
searchText: normalizeSearchText(`${r.nama} ${r.jenis} rumah ibadah`),
focus: () => focusMarkerById('rumahIbadah', r.id)
})));
registerSearchEntries('pm', pm.map(p => ({
key: `pm:${p.id}`,
label: p.nama,
subtitle: `Penduduk Miskin · ${p.jumlah_anggota} jiwa${p.alamat ? ` · ${p.alamat}` : ''}`,
typeLabel: 'Penduduk Miskin',
icon: '👤',
searchText: normalizeSearchText(`${p.nama} ${p.nik || ''} penduduk miskin`),
focus: () => focusMarkerById('pendudukMiskin', p.id)
})));
updateStatusCount();
});
}
function renderPendudukMiskinPanel(pb) {
return Promise.all([callAPI('rumah_ibadah.php'), callAPI('penduduk_miskin.php')]).then(([riRes, pmRes]) => {
const ri = riRes.data || [];
const pm = pmRes.data || [];
// Filter Penduduk Miskin for petugas_lapangan to only show those in their radius
let filteredPm = pm;
if (window.currentUser && window.currentUser.role === 'petugas_lapangan') {
const userRiId = String(window.currentUser.rumah_ibadah_id);
filteredPm = pm.filter(p => p.status_proximity === 'dalam_radius' && String(p.rumah_ibadah_id) === userRiId);
}
pb.innerHTML = `
<div class="layer-section">
<div class="layer-section-title">Visibilitas Layer</div>
${mkLayerItem('rumahIbadah', '#f59e0b', 'Rumah Ibadah', ri.length)}
${mkLayerItem('ibadahRadius', '#fbbf24', 'Radius Jangkauan', ri.length, true)}
${mkLayerItem('pendudukMiskin', '#16a34a', 'Penduduk Miskin', pm.length)}
</div>
<div class="layer-section">
<div class="layer-section-title">Tambah Data</div>
<button class="btn-add write-only" id="btn-add-pm" onclick="startAdd('pm')"> Tambah Penduduk Miskin</button>
</div>
<div class="layer-section">
<div class="layer-section-title">Penduduk Miskin (${filteredPm.length})</div>
<div class="data-list">
${filteredPm.length ? filteredPm.map(p => `
<div class="data-item" onclick="map.flyTo([${p.latitude},${p.longitude}],17)">
<div class="item-icon">${pmEmoji(p)}</div>
<div class="item-info">
<div class="item-name">${esc(p.nama)}</div>
<div class="item-sub">${p.total_bantuan} bantuan · ${p.jumlah_anggota} jiwa</div>
</div>
<div style="display:flex; align-items:center; gap:4px">
<div class="item-badge ${p.status_proximity === 'dalam_radius' ? 'badge-green' : 'badge-red'}" style="margin-right:4px">
${p.status_proximity === 'dalam_radius' ? 'DALAM' : 'LUAR'}
</div>
${(window.currentUser && (window.currentUser.role === 'admin' || (window.currentUser.role === 'petugas_lapangan' && p.status_proximity === 'dalam_radius' && String(p.rumah_ibadah_id) === String(window.currentUser.rumah_ibadah_id)))) ? `
<button class="btn btn-primary btn-sm" onclick="event.stopPropagation();openLogBantuan(${p.id},'${esc(p.nama)}',${p.latitude},${p.longitude})">📋</button>
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editPM(${p.id})">✏</button>
` : ''}
${(window.currentUser && window.currentUser.role === 'admin') ? `<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deletePM(${p.id})">✕</button>` : ''}
</div>
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
</div>
</div>`;
loadRILayer(ri);
loadPMLayer(pm);
registerSearchEntries('ri', ri.map(r => ({
key: `ri:${r.id}`,
label: r.nama,
subtitle: `Rumah Ibadah · ${r.jenis}${r.alamat ? ` · ${r.alamat}` : ''}`,
typeLabel: 'Rumah Ibadah',
icon: '⛪',
searchText: normalizeSearchText(`${r.nama} ${r.jenis} rumah ibadah`),
focus: () => focusMarkerById('rumahIbadah', r.id)
})));
registerSearchEntries('pm', pm.map(p => ({
key: `pm:${p.id}`,
label: p.nama,
subtitle: `Penduduk Miskin · ${p.jumlah_anggota} jiwa${p.alamat ? ` · ${p.alamat}` : ''}`,
typeLabel: 'Penduduk Miskin',
icon: '👤',
searchText: normalizeSearchText(`${p.nama} ${p.nik || ''} penduduk miskin`),
focus: () => focusMarkerById('pendudukMiskin', p.id)
})));
updateStatusCount();
});
}
function mkLayerItem(key, color, label, count, isRing = false) {
const dot = isRing
? `<div class="layer-dot" style="background:transparent;border:2px solid ${color}"></div>`
: `<div class="layer-dot" style="background:${color}"></div>`;
return `<div class="layer-item ${layerVisible[key] ? 'checked' : ''}" onclick="toggleLayer('${key}',this)">
<div class="layer-check">${layerVisible[key] ? '✓' : ''}</div>${dot}
<div class="layer-label">${label}</div>
<div class="layer-count">${count}</div>
</div>`;
}
function pmEmoji(p) {
if (p.bantuan_pemberdayaan > 0 && p.bantuan_konsumtif > 0) return '🔗';
if (p.bantuan_pemberdayaan > 0) return '📚';
if (p.bantuan_konsumtif > 0) return '🛍';
return '👤';
}
+337
View File
@@ -0,0 +1,337 @@
let allData = [];
let allRI = [];
let filteredData = [];
let sortField = 'nama';
let sortDir = 'asc';
const PAGE_SIZE = 20;
let currentPage = 1;
document.addEventListener('auth-ready', function (e) {
const user = e.detail;
document.body.classList.add('auth-loaded', 'role-' + user.role);
document.getElementById('user-display-name').textContent = user.username;
const roleMap = { 'admin': 'Admin', 'petugas_lapangan': 'Rumah Ibadah', 'guest': 'Guest' };
document.getElementById('user-display-role').textContent = roleMap[user.role] || user.role;
// Hide admin-nav for non-admin
document.querySelectorAll('.admin-nav').forEach(el => {
if (user.role !== 'admin') el.style.display = 'none';
});
loadData(user);
});
async function loadData(user) {
try {
const [pmRes, riRes] = await Promise.all([
fetch('api/penduduk_miskin.php'),
fetch('api/rumah_ibadah.php')
]);
const [pmData, riData] = await Promise.all([pmRes.json(), riRes.json()]);
allData = pmData.data || [];
allRI = riData.data || [];
// For petugas_lapangan: filter to only show residents in their radius
if (user && user.role === 'petugas_lapangan') {
const myRiId = String(user.rumah_ibadah_id);
allData = allData.filter(p => p.status_proximity === 'dalam_radius' && String(p.rumah_ibadah_id) === myRiId);
}
// Populate RI filter
const riFilter = document.getElementById('filter-ri');
allRI.forEach(r => {
const opt = document.createElement('option');
opt.value = r.id;
opt.textContent = r.nama;
riFilter.appendChild(opt);
});
updateSummary();
applyFilters();
} catch (err) {
document.getElementById('pm-tbody').innerHTML = `<tr><td colspan="7" style="text-align:center;padding:40px;color:#ef4444">Gagal memuat data: ${esc(err.message)}</td></tr>`;
}
}
function updateSummary() {
const total = allData.length;
const jiwa = allData.reduce((s, p) => s + (parseInt(p.jumlah_anggota, 10) || 0), 0);
const dalam = allData.filter(p => p.status_proximity === 'dalam_radius').length;
const luar = allData.filter(p => p.status_proximity !== 'dalam_radius').length;
const withBantuan = allData.filter(p => p.total_bantuan > 0).length;
document.getElementById('sum-total').textContent = total;
document.getElementById('sum-jiwa').textContent = jiwa;
document.getElementById('sum-dalam').textContent = dalam;
document.getElementById('sum-luar').textContent = luar;
document.getElementById('sum-bantuan').textContent = withBantuan;
}
function applyFilters() {
const query = document.getElementById('search-input').value.toLowerCase().trim();
const proximity = document.getElementById('filter-proximity').value;
const riId = document.getElementById('filter-ri').value;
const bantuan = document.getElementById('filter-bantuan').value;
filteredData = allData.filter(p => {
const matchQ = !query || p.nama.toLowerCase().includes(query) || (p.nik && p.nik.includes(query)) || (p.alamat && p.alamat.toLowerCase().includes(query)) || (p.nama_rumah_ibadah && p.nama_rumah_ibadah.toLowerCase().includes(query));
const matchProx = !proximity || p.status_proximity === proximity;
const matchRI = !riId || String(p.rumah_ibadah_id) === riId;
const matchBantuan = !bantuan || (bantuan === 'pernah' ? p.total_bantuan > 0 : p.total_bantuan === 0);
return matchQ && matchProx && matchRI && matchBantuan;
});
// Sort
filteredData.sort((a, b) => {
let va = a[sortField], vb = b[sortField];
if (typeof va === 'string') va = va.toLowerCase();
if (typeof vb === 'string') vb = vb.toLowerCase();
if (va < vb) return sortDir === 'asc' ? -1 : 1;
if (va > vb) return sortDir === 'asc' ? 1 : -1;
return 0;
});
currentPage = 1;
document.getElementById('filter-count').textContent = `${filteredData.length} data`;
renderTable();
}
function sortBy(field) {
if (sortField === field) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortDir = 'asc';
}
// Update sort icons
document.querySelectorAll('.pm-table thead th').forEach(th => {
th.classList.remove('sort-asc', 'sort-desc');
});
const ths = document.querySelectorAll('.pm-table thead th');
const fieldIdx = { 'nama': 0, 'nik': 1, 'jumlah_anggota': 2, 'total_bantuan': 5 };
if (fieldIdx[field] !== undefined) {
ths[fieldIdx[field]].classList.add(sortDir === 'asc' ? 'sort-asc' : 'sort-desc');
}
applyFilters();
}
function renderTable() {
const tbody = document.getElementById('pm-tbody');
const total = filteredData.length;
if (total === 0) {
tbody.innerHTML = `
<tr><td colspan="7">
<div class="empty-state">
<div class="empty-state-icon">🔍</div>
<h3>Tidak ada data</h3>
<p>Coba ubah kriteria pencarian atau filter.</p>
</div>
</td></tr>`;
document.getElementById('pagination').style.display = 'none';
return;
}
const totalPages = Math.ceil(total / PAGE_SIZE);
if (currentPage > totalPages) currentPage = totalPages;
const start = (currentPage - 1) * PAGE_SIZE;
const slice = filteredData.slice(start, start + PAGE_SIZE);
tbody.innerHTML = slice.map((p, idx) => {
const inR = p.status_proximity === 'dalam_radius';
const hasPemb = p.bantuan_pemberdayaan > 0;
const hasKons = p.bantuan_konsumtif > 0;
return `
<tr onclick="openDrawer(${p.id})">
<td>
<div class="cell-name">${esc(p.nama)}</div>
${p.alamat ? `<div class="cell-sub">📍 ${esc(p.alamat.substring(0, 50))}${p.alamat.length > 50 ? '…' : ''}</div>` : ''}
</td>
<td><span style="font-family:monospace;font-size:11px">${p.nik ? esc(p.nik) : '<span style="color:#c4c4c4">—</span>'}</span></td>
<td><strong>${p.jumlah_anggota}</strong> jiwa</td>
<td><span class="badge ${inR ? 'badge-dalam' : 'badge-luar'}">${inR ? '✅ Dalam' : '❌ Luar'}</span></td>
<td>
${p.nama_rumah_ibadah ? `<span style="font-size:12px">⛪ ${esc(p.nama_rumah_ibadah)}</span>` : '<span style="color:#c4c4c4;font-size:11px">—</span>'}
</td>
<td>
<div class="aid-pills">
${p.total_bantuan === 0 ? '<span style="color:#c4c4c4;font-size:11px">Belum ada</span>' : ''}
${hasPemb ? `<span class="badge badge-pemberdayaan">📚 ${p.bantuan_pemberdayaan}</span>` : ''}
${hasKons ? `<span class="badge badge-konsumtif">🛍️ ${p.bantuan_konsumtif}</span>` : ''}
</div>
</td>
<td onclick="event.stopPropagation()">
<div style="display:flex;gap:4px">
<button class="btn btn-ghost btn-sm" style="padding:5px 8px;font-size:11px" onclick="openDrawer(${p.id})">👁 Detail</button>
</div>
</td>
</tr>`;
}).join('');
// Pagination
const pag = document.getElementById('pagination');
const pagInfo = document.getElementById('pagination-info');
const pagBtns = document.getElementById('pagination-btns');
pag.style.display = 'flex';
pagInfo.textContent = `Menampilkan ${start + 1}${Math.min(start + PAGE_SIZE, total)} dari ${total} data`;
// Build page buttons
let btns = '';
btns += `<button class="pg-btn" onclick="goPage(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}></button>`;
const rangeStart = Math.max(1, currentPage - 2);
const rangeEnd = Math.min(totalPages, currentPage + 2);
if (rangeStart > 1) btns += `<button class="pg-btn" onclick="goPage(1)">1</button>${rangeStart > 2 ? '<span style="padding:0 4px;color:#94a3b8">…</span>' : ''}`;
for (let i = rangeStart; i <= rangeEnd; i++) {
btns += `<button class="pg-btn ${i === currentPage ? 'active' : ''}" onclick="goPage(${i})">${i}</button>`;
}
if (rangeEnd < totalPages) btns += `${rangeEnd < totalPages - 1 ? '<span style="padding:0 4px;color:#94a3b8">…</span>' : ''}<button class="pg-btn" onclick="goPage(${totalPages})">${totalPages}</button>`;
btns += `<button class="pg-btn" onclick="goPage(${currentPage + 1})" ${currentPage === totalPages ? 'disabled' : ''}></button>`;
pagBtns.innerHTML = btns;
}
function goPage(page) {
const totalPages = Math.ceil(filteredData.length / PAGE_SIZE);
if (page < 1 || page > totalPages) return;
currentPage = page;
renderTable();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function openDrawer(id) {
document.getElementById('detail-overlay').classList.add('open');
document.getElementById('drawer-body').innerHTML = `
<div class="loading-spinner-wrap">
<span class="spinner" style="width:20px;height:20px;border-width:3px;"></span>
<p style="margin-top:12px">Memuat detail…</p>
</div>`;
try {
const [pmRes, logRes] = await Promise.all([
fetch(`api/penduduk_miskin.php?id=${id}`),
fetch(`api/log_bantuan.php?penduduk_miskin_id=${id}`)
]);
const [pmData, logData] = await Promise.all([pmRes.json(), logRes.json()]);
if (pmData.status !== 'success') throw new Error(pmData.message);
renderDrawer(pmData.data, logData.data || []);
} catch (err) {
document.getElementById('drawer-body').innerHTML = `<p style="color:#ef4444">Gagal memuat: ${esc(err.message)}</p>`;
}
}
function renderDrawer(p, logs) {
document.getElementById('drawer-title').textContent = p.nama;
const inR = p.status_proximity === 'dalam_radius';
const isAdmin = window.currentUser && window.currentUser.role === 'admin';
const isPetugas = window.currentUser && window.currentUser.role === 'petugas_lapangan';
const isResidentInMyRadius = isPetugas && inR && String(p.rumah_ibadah_id) === String(window.currentUser.rumah_ibadah_id);
const canEdit = isAdmin || isResidentInMyRadius;
document.getElementById('drawer-body').innerHTML = `
<!-- Proximity status -->
<div class="proximity-card ${inR ? 'dalam' : 'luar'}">
${inR ? `✅ Dalam radius jangkauan <strong>${esc(p.nama_rumah_ibadah || 'rumah ibadah')}</strong>` : '❌ Di luar radius semua rumah ibadah'}
</div>
<!-- Basic info -->
<div class="section-heading">Informasi Pribadi</div>
<div>
<div class="info-row"><span class="info-label">Nama Lengkap</span><span class="info-val">${esc(p.nama)}</span></div>
<div class="info-row"><span class="info-label">NIK</span><span class="info-val" style="font-family:monospace">${p.nik ? esc(p.nik) : '—'}</span></div>
<div class="info-row"><span class="info-label">No. WhatsApp</span><span class="info-val">${p.no_wa ? esc(p.no_wa) : '—'}</span></div>
<div class="info-row"><span class="info-label">Jumlah Anggota</span><span class="info-val">${p.jumlah_anggota} jiwa</span></div>
<div class="info-row"><span class="info-label">Alamat</span><span class="info-val">${p.alamat ? esc(p.alamat) : '—'}</span></div>
<div class="info-row"><span class="info-label">Koordinat</span><span class="info-val" style="font-family:monospace;font-size:11px">${p.latitude.toFixed(6)}, ${p.longitude.toFixed(6)}</span></div>
<div class="info-row"><span class="info-label">Terdata Sejak</span><span class="info-val">${new Date(p.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })}</span></div>
</div>
<!-- Bantuan summary -->
<div class="section-heading">Ringkasan Bantuan</div>
<div>
${p.ringkasan_bantuan && p.ringkasan_bantuan.length ? p.ringkasan_bantuan.map(rb => `
<div class="info-row">
<span class="info-label">${rb.tipe_bantuan === 'Pemberdayaan' ? '📚 Pemberdayaan' : '🛍️ Konsumtif'}</span>
<span class="info-val">${rb.jumlah} kali</span>
</div>
`).join('') : '<div style="color:#94a3b8;font-size:13px;padding:8px 0">Belum pernah menerima bantuan.</div>'}
</div>
<!-- Action buttons -->
${canEdit ? `
<div style="margin-top:16px;display:flex;gap:8px">
<button class="btn btn-primary btn-sm" onclick="openLogBantuanFromDrawer(${p.id}, '${esc(p.nama)}')">📋 Catat Bantuan</button>
<button class="btn btn-ghost btn-sm" onclick="window.location.href='?page=map'">✏️ Edit di Peta</button>
</div>
` : ''}
<!-- Log history -->
${logs.length ? `
<div class="section-heading">Riwayat Bantuan (${logs.length})</div>
<div class="log-list">
${logs.map(l => `
<div class="log-entry">
<div class="log-type-dot ${l.tipe_bantuan === 'Pemberdayaan' ? 'pem' : 'kon'}"></div>
<div class="log-entry-info">
<div class="log-entry-name">${l.tipe_bantuan === 'Pemberdayaan' ? '📚' : '🛍️'} ${esc(l.tipe_bantuan)}${l.sub_kategori ? ' — ' + esc(l.sub_kategori) : ''}</div>
<div class="log-entry-detail">Dari: ${esc(l.nama_rumah_ibadah)}${l.nilai_bantuan ? ' · Rp' + new Intl.NumberFormat('id-ID').format(l.nilai_bantuan) : ''}${l.keterangan ? ' · ' + esc(l.keterangan) : ''}</div>
</div>
<div class="log-entry-date">${l.tanggal_bantuan}</div>
</div>
`).join('')}
</div>
` : ''}
`;
}
function closeDrawer(e) {
if (e && e.target !== document.getElementById('detail-overlay')) return;
document.getElementById('detail-overlay').classList.remove('open');
}
function openLogBantuanFromDrawer(pmId, pmNama) {
// Redirect to peta with log bantuan opened
window.location.href = '?page=map';
}
function exportCSV() {
if (!filteredData.length) return toast('Tidak ada data untuk diekspor', 'error');
const headers = ['Nama', 'NIK', 'No WA', 'Alamat', 'Jumlah Anggota', 'Status Proximity', 'Rumah Ibadah', 'Total Bantuan', 'Bantuan Pemberdayaan', 'Bantuan Konsumtif', 'Latitude', 'Longitude'];
const rows = filteredData.map(p => [
p.nama, p.nik || '', p.no_wa || '', p.alamat || '',
p.jumlah_anggota, p.status_proximity, p.nama_rumah_ibadah || '',
p.total_bantuan, p.bantuan_pemberdayaan, p.bantuan_konsumtif,
p.latitude, p.longitude
]);
const csv = [headers, ...rows].map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `penduduk_miskin_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
toast(`${filteredData.length} data berhasil diekspor`, 'success');
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function toast(msg, type = 'info') {
const t = document.createElement('div');
t.className = `toast ${type}`;
t.textContent = msg;
document.getElementById('toast-container').appendChild(t);
setTimeout(() => t.remove(), 3500);
}
async function handleLogout() {
if (!confirm('Apakah Anda yakin ingin keluar?')) return;
try {
const response = await fetch('api/logout.php');
const data = await response.json();
if (data.status === 'success') window.location.href = '?page=login';
} catch { alert('Gagal logout.'); }
}
+198
View File
@@ -0,0 +1,198 @@
document.addEventListener('auth-ready', function (e) {
const user = e.detail;
document.body.classList.add('auth-loaded');
// Update User Profile in Sidebar
document.getElementById('user-display-name').textContent = user.username;
// Format role name nicely
const roleMap = {
'admin': 'Admin',
'petugas_lapangan': 'Rumah Ibadah',
'guest': 'Guest'
};
document.getElementById('user-display-role').textContent = roleMap[user.role] || user.role;
// Show admin-only nav items
if (user.role === 'admin') {
document.getElementById('nav-akun').style.display = 'flex';
}
// Update Greeting
document.getElementById('greeting-title').textContent = `Selamat Datang, ${user.username}!`;
// Load stats data
loadDashboardStats();
});
// Format current date nicely in Indonesian
const dateOpts = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
document.getElementById('current-date').textContent = new Date().toLocaleDateString('id-ID', dateOpts);
async function handleLogout() {
if (!confirm('Apakah Anda yakin ingin keluar?')) return;
try {
const response = await fetch('api/logout.php');
const data = await response.json();
if (data.status === 'success') {
window.location.href = '?page=login';
}
} catch (err) {
alert('Gagal melakukan logout.');
}
}
async function loadDashboardStats() {
try {
const response = await fetch('api/dashboard_stats.php');
const result = await response.json();
if (result.status === 'success') {
const stats = result.data;
renderStatsCards(stats);
renderProximityChart(stats.proximity);
renderBantuanChart(stats.tipe_bantuan);
renderBantuanTable(stats.bantuan_terbaru);
renderRiTable(stats.ri_teraktif);
}
} catch (err) {
console.error('Gagal memuat statistik dashboard:', err);
}
}
function renderStatsCards(stats) {
// 1. Penduduk Miskin
document.getElementById('pm-total-kk').textContent = `${stats.penduduk_miskin.total_kk} KK`;
document.getElementById('pm-total-jiwa').textContent = `${stats.penduduk_miskin.total_jiwa} Jiwa terdata`;
// 2. Rumah Ibadah
document.getElementById('ri-total').textContent = stats.rumah_ibadah.total;
// 5. Log Bantuan
document.getElementById('lb-total-count').textContent = `${stats.log_bantuan.total_count} kali`;
const rpBantuan = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(stats.log_bantuan.total_nilai || 0);
document.getElementById('lb-total-nilai').textContent = `Total ${rpBantuan}`;
}
let proximityChart = null;
function renderProximityChart(proximity) {
const ctx = document.getElementById('chart-proximity').getContext('2d');
if (proximityChart) proximityChart.destroy();
proximityChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Dalam Radius (Dalam Jangkauan)', 'Luar Radius (Di Luar Jangkauan)'],
datasets: [{
data: [proximity.dalam_radius, proximity.luar_radius],
backgroundColor: ['#10b981', '#f43f5e'],
borderWidth: 4,
borderColor: '#ffffff',
hoverOffset: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
boxWidth: 12,
font: { family: 'Inter', size: 11, weight: '600' },
color: '#475569'
}
}
},
cutout: '70%'
}
});
}
let bantuanChart = null;
function renderBantuanChart(tipeBantuan) {
const ctx = document.getElementById('chart-bantuan-tipe').getContext('2d');
if (bantuanChart) bantuanChart.destroy();
bantuanChart = new Chart(ctx, {
type: 'bar',
data: {
labels: ['📚 Pemberdayaan (Pelatihan)', '🛍️ Konsumtif (Makan/Barang)'],
datasets: [{
label: 'Penyaluran Bantuan',
data: [tipeBantuan.Pemberdayaan, tipeBantuan.Konsumtif],
backgroundColor: ['#2563eb', '#f59e0b'],
borderRadius: 8,
barThickness: 36
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: { stepSize: 1, color: '#64748b', font: { family: 'Inter' } },
grid: { color: '#f1f5f9' }
},
x: {
ticks: { color: '#475569', font: { family: 'Inter', weight: '600' } },
grid: { display: false }
}
},
plugins: {
legend: {
display: false
}
}
}
});
}
function renderBantuanTable(bantuanList) {
const tbody = document.getElementById('table-bantuan-body');
if (!bantuanList || bantuanList.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="loading">Belum ada riwayat bantuan</td></tr>';
return;
}
tbody.innerHTML = bantuanList.map(b => {
const rp = b.nilai_bantuan ? new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(b.nilai_bantuan) : '—';
const badgeClass = b.tipe_bantuan === 'Pemberdayaan' ? 'pemberdayaan' : 'konsumtif';
return `
<tr>
<td><strong>${b.tanggal_bantuan}</strong></td>
<td>${escapeHTML(b.nama_penduduk)}</td>
<td><span class="db-badge ${badgeClass}">${b.tipe_bantuan}</span></td>
<td>${escapeHTML(b.sub_kategori || '—')}</td>
<td><strong>${rp}</strong></td>
<td>${escapeHTML(b.nama_rumah_ibadah)}</td>
</tr>
`;
}).join('');
}
function renderRiTable(riList) {
const tbody = document.getElementById('table-ri-body');
if (!riList || riList.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="loading">Belum ada data penyaluran</td></tr>';
return;
}
tbody.innerHTML = riList.map(r => {
const rp = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(r.nilai);
return `
<tr>
<td><strong>${escapeHTML(r.nama)}</strong></td>
<td>${r.count} kali</td>
<td><strong>${rp}</strong></td>
</tr>
`;
}).join('');
}
function escapeHTML(str) {
if (str == null) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+416
View File
@@ -0,0 +1,416 @@
let allData = [];
document.addEventListener('auth-ready', function(e) {
const user = e.detail;
document.body.classList.add('auth-loaded', 'role-admin');
document.getElementById('user-display-name').textContent = user.username;
document.getElementById('user-display-role').textContent = 'Admin';
loadData();
});
async function loadData() {
try {
const res = await fetch('api/manajemen_akun.php');
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
allData = data.data || [];
renderCards(allData);
} catch (err) {
document.getElementById('ri-grid').innerHTML = `
<div class="empty-state" style="grid-column:1/-1">
<div class="empty-state-icon">⚠️</div>
<h3>Gagal memuat data</h3>
<p>${esc(err.message)}</p>
</div>`;
}
}
function renderCards(data) {
const grid = document.getElementById('ri-grid');
document.getElementById('filter-count').textContent = `${data.length} rumah ibadah`;
if (!data.length) {
grid.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🏛️</div>
<h3>Belum ada rumah ibadah</h3>
<p>Tambahkan rumah ibadah melalui halaman Peta Interaktif.</p>
</div>`;
return;
}
grid.innerHTML = data.map(r => {
const hasAccount = !!r.username;
const rpFormatted = new Intl.NumberFormat('id-ID', { notation: 'compact', maximumFractionDigits: 1 }).format(r.total_nilai_bantuan);
return `
<div class="ri-card" onclick="openDrawer(${r.id})" data-jenis="${esc(r.jenis)}" data-account="${hasAccount ? 'active' : 'inactive'}" data-nama="${esc(r.nama.toLowerCase())}">
<span class="ri-account-status ${hasAccount ? 'active' : 'inactive'}">${hasAccount ? '● Akun Aktif' : '○ Tanpa Akun'}</span>
<div class="ri-card-header">
<div class="ri-avatar">${riEmoji(r.jenis)}</div>
<div class="ri-card-meta">
<div class="ri-card-name">${esc(r.nama)}</div>
<span class="ri-card-type">${esc(r.jenis)}</span>
</div>
</div>
<div class="ri-card-stats">
<div class="stat-mini">
<div class="stat-mini-val">${r.pm_dalam_radius}</div>
<div class="stat-mini-label">Warga Binaan</div>
</div>
<div class="stat-mini">
<div class="stat-mini-val">${r.total_penyaluran}</div>
<div class="stat-mini-label">Penyaluran</div>
</div>
<div class="stat-mini">
<div class="stat-mini-val">${r.total_nilai_bantuan > 0 ? rpFormatted : '—'}</div>
<div class="stat-mini-label">Total Nilai</div>
</div>
</div>
<div class="ri-card-account">
${hasAccount
? `<span>👤</span> <span class="username">${esc(r.username)}</span>`
: `<span class="no-account">Belum ada akun terdaftar</span>`}
</div>
<div class="ri-card-footer">
<div class="ri-card-alamat">📍 ${r.alamat ? esc(r.alamat) : 'Alamat belum diisi'}</div>
<div class="ri-card-actions" onclick="event.stopPropagation()">
<button class="btn-icon" title="Edit Rumah Ibadah" onclick="goToEdit(${r.id})">✏️</button>
${!hasAccount
? `<button class="btn-icon" title="Buat Akun" onclick="openBuatAkun(${r.id}, '${esc(r.nama)}')">🔑</button>`
: `<button class="btn-icon" title="Reset Password" onclick="openResetPassword(${r.id}, '${esc(r.username)}')">🔐</button>`}
${hasAccount ? `<button class="btn-icon danger" title="Hapus Akun" onclick="hapusAkun(${r.id}, '${esc(r.nama)}')">🗑️</button>` : ''}
</div>
</div>
</div>`;
}).join('');
}
function filterCards() {
const query = document.getElementById('search-input').value.toLowerCase().trim();
const jenis = document.getElementById('filter-jenis').value;
const akun = document.getElementById('filter-akun').value;
const filtered = allData.filter(r => {
const matchQuery = !query || r.nama.toLowerCase().includes(query) || r.jenis.toLowerCase().includes(query) || (r.alamat && r.alamat.toLowerCase().includes(query));
const matchJenis = !jenis || r.jenis === jenis;
const matchAkun = !akun || (akun === 'active' ? !!r.username : !r.username);
return matchQuery && matchJenis && matchAkun;
});
renderCards(filtered);
}
async function openDrawer(id) {
pwVisible = false; // Reset password visibility on each open
document.getElementById('detail-overlay').classList.add('open');
document.getElementById('drawer-body').innerHTML = `
<div class="loading-spinner-wrap">
<span class="spinner" style="width:20px;height:20px;border-width:3px;"></span>
<p style="margin-top:12px">Memuat detail…</p>
</div>`;
try {
const res = await fetch(`api/manajemen_akun.php?id=${id}`);
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
renderDrawer(data.data);
} catch (err) {
document.getElementById('drawer-body').innerHTML = `<p style="color:#ef4444">Gagal memuat: ${esc(err.message)}</p>`;
}
}
function renderDrawer(r) {
document.getElementById('drawer-title').textContent = r.nama;
const rp = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(r.total_nilai_bantuan);
document.getElementById('drawer-body').innerHTML = `
<!-- KPI boxes -->
<div class="kpi-row">
<div class="kpi-box">
<div class="kpi-val green">${r.pm_dalam_radius}</div>
<div class="kpi-label">Warga Binaan</div>
</div>
<div class="kpi-box">
<div class="kpi-val accent">${r.total_penyaluran}</div>
<div class="kpi-label">Total Penyaluran</div>
</div>
<div class="kpi-box">
<div class="kpi-val">${r.jumlah_penerima_unik}</div>
<div class="kpi-label">Penerima Unik</div>
</div>
<div class="kpi-box">
<div class="kpi-val" style="font-size:15px">${rp}</div>
<div class="kpi-label">Total Nilai</div>
</div>
</div>
<!-- Info dasar -->
<div class="section-heading">Informasi Rumah Ibadah</div>
<div>
<div class="info-row"><span class="info-label">Jenis</span><span class="info-val">${riEmoji(r.jenis)} ${esc(r.jenis)}</span></div>
<div class="info-row"><span class="info-label">Radius Jangkauan</span><span class="info-val">${r.radius_meter} meter</span></div>
<div class="info-row"><span class="info-label">Alamat</span><span class="info-val" style="max-width:260px">${r.alamat ? esc(r.alamat) : '—'}</span></div>
<div class="info-row"><span class="info-label">No. WhatsApp</span><span class="info-val">${r.no_wa ? esc(r.no_wa) : '—'}</span></div>
<div class="info-row"><span class="info-label">Koordinat</span><span class="info-val" style="font-family:monospace;font-size:11px">${r.latitude.toFixed(6)}, ${r.longitude.toFixed(6)}</span></div>
<div class="info-row"><span class="info-label">Bantuan Pemberdayaan</span><span class="info-val">📚 ${r.bantuan_pemberdayaan} kali</span></div>
<div class="info-row"><span class="info-label">Bantuan Konsumtif</span><span class="info-val">🛍️ ${r.bantuan_konsumtif} kali</span></div>
<div class="info-row"><span class="info-label">Penyaluran Terakhir</span><span class="info-val">${r.tanggal_bantuan_terakhir || '—'}</span></div>
<div class="info-row"><span class="info-label">Terdaftar Sejak</span><span class="info-val">${new Date(r.created_at).toLocaleDateString('id-ID', {day:'numeric',month:'long',year:'numeric'})}</span></div>
</div>
<!-- Info akun -->
<div class="section-heading">Informasi Akun</div>
<div>
${r.username ? `
<div class="info-row"><span class="info-label">Username</span><span class="info-val" style="font-family:monospace">${esc(r.username)}</span></div>
<div class="info-row"><span class="info-label">Password</span>
<span class="info-val" style="display:flex;align-items:center;gap:8px">
<span id="pw-display" style="font-family:monospace;letter-spacing:.1em">${r.password_plain ? '••••••••' : '<em style=color:#94a3b8>Tidak diketahui</em>'}</span>
${r.password_plain ? `<button onclick="togglePw('${esc(r.password_plain)}')" id="pw-toggle-btn" style="font-size:11px;padding:3px 8px;border:1px solid #e2e8f0;border-radius:6px;background:#f8fafc;cursor:pointer;color:#475569;transition:all .15s" title="Tampilkan/Sembunyikan Password">👁 Lihat</button>` : ''}
</span>
</div>
<div class="info-row"><span class="info-label">Status</span><span class="info-val" style="color:#15803d">● Aktif</span></div>
<div class="info-row"><span class="info-label">Akun Dibuat</span><span class="info-val">${r.akun_dibuat ? new Date(r.akun_dibuat).toLocaleDateString('id-ID',{day:'numeric',month:'long',year:'numeric'}) : '—'}</span></div>
<div style="margin-top:12px;display:flex;gap:8px">
<button class="btn btn-ghost btn-sm" onclick="openResetPassword(${r.id}, '${esc(r.username)}')">🔐 Reset Password</button>
<button class="btn btn-danger btn-sm" onclick="hapusAkun(${r.id}, '${esc(r.nama)}')">🗑️ Hapus Akun</button>
</div>
` : `
<div style="padding:16px;background:#f8fafc;border-radius:12px;text-align:center;color:#94a3b8;font-size:13px">
Belum ada akun terdaftar untuk rumah ibadah ini.
</div>
<div style="margin-top:12px">
<button class="btn btn-primary btn-sm" onclick="openBuatAkun(${r.id}, '${esc(r.nama)}')">🔑 Buat Akun</button>
</div>
`}
</div>
<!-- Riwayat bantuan terbaru -->
${r.riwayat_bantuan && r.riwayat_bantuan.length ? `
<div class="section-heading">Riwayat Penyaluran Terbaru</div>
<div class="log-list">
${r.riwayat_bantuan.map(l => `
<div class="log-entry">
<div class="log-type-dot ${l.tipe_bantuan === 'Pemberdayaan' ? 'pem' : 'kon'}"></div>
<div class="log-entry-info">
<div class="log-entry-name">${esc(l.nama_penerima)}</div>
<div class="log-entry-detail">${l.tipe_bantuan === 'Pemberdayaan' ? '📚' : '🛍️'} ${esc(l.tipe_bantuan)}${l.sub_kategori ? ' · ' + esc(l.sub_kategori) : ''}${l.nilai_bantuan ? ' · Rp' + new Intl.NumberFormat('id-ID').format(l.nilai_bantuan) : ''}</div>
</div>
<div class="log-entry-date">${l.tanggal_bantuan}</div>
</div>
`).join('')}
</div>
` : ''}
`;
}
function closeDrawer(e) {
if (e && e.target !== document.getElementById('detail-overlay')) return;
document.getElementById('detail-overlay').classList.remove('open');
}
let pwVisible = false;
function togglePw(plainPassword) {
const display = document.getElementById('pw-display');
const btn = document.getElementById('pw-toggle-btn');
if (!display || !btn) return;
pwVisible = !pwVisible;
if (pwVisible) {
display.textContent = plainPassword;
display.style.color = '#0f172a';
btn.textContent = '🙈 Sembunyikan';
btn.style.background = '#f0fdf4';
btn.style.borderColor = '#bbf7d0';
btn.style.color = '#15803d';
} else {
display.textContent = '••••••••';
display.style.color = '';
btn.textContent = '👁 Lihat';
btn.style.background = '#f8fafc';
btn.style.borderColor = '#e2e8f0';
btn.style.color = '#475569';
}
}
function openResetPassword(id, username) {
openModal('Reset Password Akun', `
<div class="form-group">
<label class="form-label">Username</label>
<input class="form-input" value="${esc(username)}" readonly style="background:#f8fafc;color:#64748b">
</div>
<div class="form-group">
<label class="form-label">Password Baru *</label>
<input class="form-input" id="new-password" type="password" placeholder="Min. 6 karakter" autocomplete="new-password">
</div>
<div class="form-group">
<label class="form-label">Konfirmasi Password *</label>
<input class="form-input" id="confirm-password" type="password" placeholder="Ulangi password baru" autocomplete="new-password">
</div>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
<button class="btn btn-primary" onclick="doResetPassword(${id})">🔐 Simpan Password</button>
`);
}
async function doResetPassword(riId) {
const pw = document.getElementById('new-password').value;
const conf = document.getElementById('confirm-password').value;
if (!pw || pw.length < 6) return toast('Password minimal 6 karakter', 'error');
if (pw !== conf) return toast('Konfirmasi password tidak cocok', 'error');
// Find the data — cast ID to number to avoid string vs number mismatch
const ri = allData.find(r => Number(r.id) === Number(riId));
if (!ri) {
toast('Data rumah ibadah tidak ditemukan di cache, coba refresh halaman', 'error');
return;
}
const btn = document.querySelector('#modal-footer .btn-primary');
if (btn) { btn.disabled = true; btn.textContent = 'Menyimpan…'; }
try {
const res = await fetch(`api/rumah_ibadah.php?id=${riId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nama: ri.nama, jenis: ri.jenis, alamat: ri.alamat,
no_wa: ri.no_wa, latitude: ri.latitude, longitude: ri.longitude,
radius_meter: ri.radius_meter, username: ri.username, password: pw
})
});
const data = await res.json();
if (data.status === 'success') {
toast('Password berhasil direset!', 'success');
closeModal();
await loadData(); // Refresh allData
openDrawer(riId); // Reopen drawer with fresh data
} else {
toast(data.message || 'Gagal reset password', 'error');
}
} catch (err) {
toast('Terjadi kesalahan: ' + err.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = '🔐 Simpan Password'; }
}
}
function openBuatAkun(id, namaRI) {
const sanitized = namaRI.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '');
openModal('Buat Akun Rumah Ibadah', `
<div class="form-group">
<label class="form-label">Nama Rumah Ibadah</label>
<input class="form-input" value="${esc(namaRI)}" readonly style="background:#f8fafc;color:#64748b">
</div>
<div class="form-group">
<label class="form-label">Username (otomatis)</label>
<input class="form-input" id="buat-username" value="${esc(sanitized)}" readonly style="background:#f1f5f9;color:#64748b;font-family:monospace">
</div>
<div class="form-group">
<label class="form-label">Password *</label>
<input class="form-input" id="buat-password" type="password" placeholder="Min. 6 karakter" autocomplete="new-password">
</div>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
<button class="btn btn-primary" onclick="doBuatAkun(${id})">🔑 Buat Akun</button>
`);
}
async function doBuatAkun(riId) {
const username = document.getElementById('buat-username').value;
const password = document.getElementById('buat-password').value;
if (!password || password.length < 6) return toast('Password minimal 6 karakter', 'error');
// Cast ID to number to avoid string vs number mismatch from PHP JSON
const ri = allData.find(r => Number(r.id) === Number(riId));
if (!ri) {
toast('Data rumah ibadah tidak ditemukan di cache, coba refresh halaman', 'error');
return;
}
const btn = document.querySelector('#modal-footer .btn-primary');
if (btn) { btn.disabled = true; btn.textContent = 'Membuat…'; }
try {
const res = await fetch(`api/rumah_ibadah.php?id=${riId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nama: ri.nama, jenis: ri.jenis, alamat: ri.alamat,
no_wa: ri.no_wa, latitude: ri.latitude, longitude: ri.longitude,
radius_meter: ri.radius_meter, username, password
})
});
const data = await res.json();
if (data.status === 'success') {
toast('Akun berhasil dibuat!', 'success');
closeModal();
await loadData(); // Refresh allData
openDrawer(riId); // Reopen drawer with new account info
} else {
toast(data.message || 'Gagal membuat akun', 'error');
}
} catch (err) {
toast('Terjadi kesalahan: ' + err.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = '🔑 Buat Akun'; }
}
}
async function hapusAkun(id, nama) {
if (!confirm(`Hapus akun login untuk "${nama}"?\nRumah ibadah tidak akan dihapus, hanya akun loginnya.`)) return;
const res = await fetch(`api/manajemen_akun.php?id=${id}`, { method: 'DELETE' });
const data = await res.json();
if (data.status === 'success') {
toast(data.message, 'success');
document.getElementById('detail-overlay').classList.remove('open');
await loadData();
} else {
toast(data.message || 'Gagal menghapus akun', 'error');
}
}
function goToEdit(id) {
window.location.href = `?page=map`;
}
function riEmoji(jenis) {
const map = { 'Masjid': '🕌', 'Gereja': '⛪', 'Pura': '🛕', 'Vihara': '🏯', 'Klenteng': '🏮', 'Lainnya': '🏛️' };
return map[jenis] || '🏛️';
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function toast(msg, type = 'info') {
const t = document.createElement('div');
t.className = `toast ${type}`;
t.textContent = msg;
document.getElementById('toast-container').appendChild(t);
setTimeout(() => t.remove(), 3500);
}
function openModal(title, bodyHTML, footerHTML) {
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-body').innerHTML = bodyHTML;
document.getElementById('modal-footer').innerHTML = footerHTML;
document.getElementById('modal-overlay').classList.add('open');
}
function closeModal() {
document.getElementById('modal-overlay').classList.remove('open');
}
document.getElementById('modal-overlay').addEventListener('click', e => {
if (e.target.id === 'modal-overlay') closeModal();
});
async function handleLogout() {
if (!confirm('Apakah Anda yakin ingin keluar?')) return;
try {
const response = await fetch('api/logout.php');
const data = await response.json();
if (data.status === 'success') window.location.href = '?page=login';
} catch { alert('Gagal logout.'); }
}