237 lines
11 KiB
JavaScript
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');
|
|
}
|
|
}
|