Files
ksp/pages/dashboard.js
2026-09-16 10:38:01 +07:00

237 lines
11 KiB
JavaScript

// ═══════════════════════════════════════════════════════════════════
// DASHBOARD PAGE — National Overview with Map + KPIs
// ═══════════════════════════════════════════════════════════════════
let _mapInit = false;
let _mapFilter = 'all';
function renderDashboardPage() {
renderKPIStrip();
renderDashSidePanel();
initLeafletMap();
}
// ─── KPI STRIP ───────────────────────────────────────────────────
function renderKPIStrip() {
const issues = APP_STATE.issues;
const locs = APP_STATE.locs;
const openIssues = issues.filter(i => i.status === 'open').length;
const inProg = issues.filter(i => i.status === 'in_progress').length;
const criticalIssues= issues.filter(i => i.severity === 'critical' && i.status !== 'resolved').length;
const totalLocs = locs.length;
const totalMeals = locs.reduce((s,l) => s + (l.mbg?.meals || 0), 0);
const totalKop = locs.reduce((s,l) => s + (l.koperasi?.units || 0), 0);
const openEsc = APP_STATE.escalations.filter(e => e.status !== 'resolved').length;
document.getElementById('kpi-strip').innerHTML = `
<div class="kpi-grid">
<div class="kpi-card" style="--kpi-color:var(--green);" onclick="navigateTo('programs')">
<div class="kpi-icon">🗂️</div>
<div class="kpi-label">Program Aktif</div>
<div class="kpi-value">5</div>
<div class="kpi-sub up">↑ Semua berjalan normal</div>
</div>
<div class="kpi-card" style="--kpi-color:var(--blue);" onclick="navigateTo('programs')">
<div class="kpi-icon">📍</div>
<div class="kpi-label">Titik Pemantauan</div>
<div class="kpi-value">${totalLocs}</div>
<div class="kpi-sub">Kota/Kabupaten terpantau</div>
</div>
<div class="kpi-card" style="--kpi-color:var(--amber);" onclick="navigateTo('issues')">
<div class="kpi-icon">⚠️</div>
<div class="kpi-label">Issue Terbuka</div>
<div class="kpi-value red">${openIssues + inProg}</div>
<div class="kpi-sub down">↑ ${criticalIssues} kritis · ${inProg} diproses</div>
</div>
<div class="kpi-card" style="--kpi-color:var(--green);">
<div class="kpi-icon">🍱</div>
<div class="kpi-label">Porsi MBG / Hari</div>
<div class="kpi-value">${(totalMeals/1000000).toFixed(1)}M</div>
<div class="kpi-sub up">↑ 64,3% dari target nasional</div>
</div>
<div class="kpi-card" style="--kpi-color:var(--orange);" onclick="navigateTo('escalasi')">
<div class="kpi-icon">🔺</div>
<div class="kpi-label">Eskalasi Aktif</div>
<div class="kpi-value">${openEsc}</div>
<div class="kpi-sub warn">Butuh tindak lanjut segera</div>
</div>
</div>
`;
}
// ─── SIDE PANEL ───────────────────────────────────────────────────
function renderDashSidePanel() {
const flaggedIssues = APP_STATE.issues
.filter(i => i.status !== 'resolved' && i.status !== 'closed')
.sort((a,b) => { const o = {critical:0,high:1,medium:2,low:3}; return o[a.severity]-o[b.severity]; })
.slice(0,8);
const progRows = PROGRAMS.map(p => {
const pct = Math.round((p.capaian / p.target) * 100);
const issCount = APP_STATE.issues.filter(i => i.programId === p.id && i.status !== 'resolved').length;
return `
<div class="stat-pair" onclick="navigateTo('programs')" style="cursor:pointer;">
<div style="display:flex;align-items:center;gap:8px;flex:1;">
<span>${p.icon}</span>
<div>
<div style="font-size:12px;font-weight:500;color:var(--text-1);">${p.shortName}</div>
<div class="progress-bar progress-sm" style="width:100px;margin-top:3px;">
<div class="progress-fill" style="width:${pct}%;background:${p.color};"></div>
</div>
</div>
</div>
<div style="text-align:right;">
<div style="font-size:13px;font-weight:700;color:${p.color};">${pct}%</div>
${issCount > 0 ? `<div style="font-size:10px;color:var(--red);">${issCount} issue</div>` : '<div style="font-size:10px;color:var(--green);">✓ Normal</div>'}
</div>
</div>
`;
}).join('');
document.getElementById('dash-side-panel').innerHTML = `
<!-- Program Progress -->
<div class="card card-p">
<div style="font-size:12px;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:12px;">Capaian Program</div>
${progRows}
</div>
<!-- Critical Alerts -->
<div class="card card-p" style="flex:1;min-height:0;display:flex;flex-direction:column;">
<div style="font-size:12px;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:10px;">
⚠️ Issue Aktif (${flaggedIssues.length})
</div>
<div style="flex:1;overflow-y:auto;">
${flaggedIssues.length === 0
? `<div class="empty-state" style="padding:20px 0;"><div class="empty-icon" style="font-size:28px;">✅</div><div class="empty-desc">Tidak ada issue aktif</div></div>`
: flaggedIssues.map(i => `
<div class="alert-item ${i.severity}" onclick="openIssueDetail('${i.id}')">
<div class="flex justify-between items-center mb-1">
${getProgramBadge(i.programId)}
${getSevBadge(i.severity)}
</div>
<div class="alert-title">${i.judul}</div>
<div class="alert-meta">
<span>📍 ${i.daerah}, ${i.provinsi}</span>
<span>${getStatBadge(i.status)}</span>
</div>
</div>
`).join('')
}
</div>
</div>
`;
}
let _tileGroup = null;
function initLeafletMap() {
if (_mapInit) { renderMapMarkers(); return; }
_mapInit = true;
const map = L.map('leaflet-map', { zoomControl: false, attributionControl: false }).setView([-2.5, 118], 5);
_tileGroup = L.layerGroup().addTo(map);
L.control.zoom({ position: 'bottomright' }).addTo(map);
APP_STATE.mapInstance = map;
updateMapTileLayers();
renderMapMarkers();
}
function updateMapTileLayers() {
const map = APP_STATE.mapInstance;
if (!map || !_tileGroup) return;
_tileGroup.clearLayers();
const isLight = document.body.classList.contains('light-theme');
if (isLight) {
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', { maxZoom:14, subdomains:'abcd' }).addTo(_tileGroup);
} else {
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', { maxZoom:14, subdomains:'abcd' }).addTo(_tileGroup);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}{r}.png', { maxZoom:14, subdomains:'abcd', opacity:0.5 }).addTo(_tileGroup);
}
}
function applyMapFilter(f, el) {
_mapFilter = f;
document.querySelectorAll('.map-btn').forEach(b => b.classList.remove('active'));
el.classList.add('active');
renderMapMarkers();
}
const PROG_COLORS = { mbg:'#22c55e', koperasi:'#3b82f6', psn:'#f59e0b', hilirisasi:'#a855f7', rumah:'#f97316' };
function getLocColor(loc) {
if (loc.criticalCount > 0 || loc.issueCount > 1) return '#ef4444';
if (loc.issueCount > 0) return '#f97316';
if (loc.programs.length > 1) return '#e2e8f0';
return PROG_COLORS[loc.programs[0]] || '#8FAABE';
}
function renderMapMarkers() {
const map = APP_STATE.mapInstance;
if (!map) return;
Object.values(APP_STATE.mapMarkers).forEach(m => map.removeLayer(m));
APP_STATE.mapMarkers = {};
const filtered = APP_STATE.locs.filter(loc => {
if (_mapFilter === 'all') return true;
if (_mapFilter === 'issues') return loc.issueCount > 0;
return loc.programs.includes(_mapFilter);
});
filtered.forEach(loc => {
const color = getLocColor(loc);
const hasIssue = loc.issueCount > 0;
const size = loc.programs.length > 1 ? 14 : 11;
const label = hasIssue ? '⚠' : loc.programs.length > 1 ? '★' : loc.programs[0]?.charAt(0).toUpperCase();
const icon = L.divIcon({
className: '',
html: `<div style="
width:${size*2}px;height:${size*2}px;border-radius:50%;
background:${color};border:2px solid rgba(255,255,255,0.9);
display:flex;align-items:center;justify-content:center;
font-size:${size < 13 ? 8 : 9}px;font-weight:800;color:#09090b;
box-shadow:0 0 8px ${color}88;cursor:pointer;
font-family:Inter,sans-serif;
${hasIssue ? 'animation:pulse-ring 2s infinite;' : ''}
">${label}</div>`,
iconSize: [size*2, size*2],
iconAnchor: [size, size]
});
const progInfo = loc.programs.map(pid => {
const d = loc[pid];
if (!d) return '';
if (pid === 'mbg') return `<div class="tt-row"><span>🍱 Porsi/Hari</span><span class="tt-val">${(d.meals||0).toLocaleString()}</span></div>`;
if (pid === 'koperasi') return `<div class="tt-row"><span>🏪 Anggota</span><span class="tt-val">${(d.members||0).toLocaleString()}</span></div>`;
if (pid === 'psn') return `<div class="tt-row"><span>🏗️ Progress PSN</span><span class="tt-val">${d.progress}%</span></div>`;
if (pid === 'hilirisasi') return `<div class="tt-row"><span>⚙️ ${d.commodity}</span><span class="tt-val">${d.progress}%</span></div>`;
if (pid === 'rumah') return `<div class="tt-row"><span>🏠 Unit Selesai</span><span class="tt-val">${(d.completed||0).toLocaleString()}</span></div>`;
return '';
}).join('');
const m = L.marker([loc.lat, loc.lng], { icon });
m.bindTooltip(`
<div class="tt-title">${loc.name}</div>
<div class="tt-sub">${loc.province}</div>
${progInfo}
${loc.issueCount > 0 ? `<div class="tt-row"><span style="color:var(--red)">⚠ Issue</span><span class="tt-val" style="color:var(--red)">${loc.issueCount} aktif</span></div>` : ''}
`, { className:'custom-tooltip', direction:'top', offset:[0,-4] });
m.on('click', () => openLocIssues(loc));
m.addTo(map);
APP_STATE.mapMarkers[loc.id] = m;
});
}
function openLocIssues(loc) {
const locIssues = APP_STATE.issues.filter(i => i.daerah === loc.name && i.status !== 'resolved');
if (locIssues.length > 0) {
openIssueDetail(locIssues[0].id);
} else {
showToast(loc.name, `${loc.province}${loc.programs.length} program aktif, tidak ada issue`, 'info');
}
}