first commit
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user