first commit

This commit is contained in:
2026-09-16 10:38:01 +07:00
commit c20afe6543
10 changed files with 3307 additions and 0 deletions
+236
View File
@@ -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');
}
}
+195
View File
@@ -0,0 +1,195 @@
// ═══════════════════════════════════════════════════════════════════
// ESCALASI PAGE — Escalation pipeline and management
// ═══════════════════════════════════════════════════════════════════
function renderEscalasiPage() {
const escs = APP_STATE.escalations;
const active = escs.filter(e => e.status !== 'resolved');
const resolved = escs.filter(e => e.status === 'resolved');
const levelCounts = { L1:0, L2:0, L3:0, L4:0 };
active.forEach(e => { if (levelCounts[e.level] !== undefined) levelCounts[e.level]++; });
document.getElementById('escalasi-body').innerHTML = `
<div class="scroll-area">
<!-- Header -->
<div class="section-header mb-4">
<div>
<div class="section-title">Pipeline Eskalasi</div>
<div class="section-sub">Manajemen eskalasi berjenjang: Daerah → KSP → Kementerian → Penegakan Hukum</div>
</div>
<button class="btn btn-primary btn-sm" onclick="openNewEscalasiModal()">+ Buat Eskalasi</button>
</div>
<!-- Pipeline Steps -->
<div class="esc-pipeline mb-4">
${['L1','L2','L3','L4'].map((lv, idx) => {
const cfg = ESCALATION_LEVELS[lv];
const desc = {
L1: 'Tim / Dinas Daerah',
L2: 'KSP Internal',
L3: 'Kementerian / Lembaga',
L4: 'Kejaksaan / POLRI / BPK'
}[lv];
return `
<div class="esc-step ${levelCounts[lv] > 0 ? 'active' : ''}">
<div class="esc-step-num">${levelCounts[lv] > 0 ? levelCounts[lv] : idx+1}</div>
<div class="esc-step-label">${cfg.label.replace(lv+' — ','')}</div>
<div style="font-size:10px;color:var(--text-3);margin-top:3px;">${desc}</div>
</div>
`;
}).join('')}
</div>
<!-- Stats -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:20px;">
<div class="card card-p-sm" style="text-align:center;border-color:rgba(239,68,68,0.2);">
<div style="font-size:22px;font-weight:800;color:var(--red);">${active.length}</div>
<div style="font-size:11px;color:var(--text-3);">Eskalasi Aktif</div>
</div>
<div class="card card-p-sm" style="text-align:center;border-color:rgba(245,158,11,0.2);">
<div style="font-size:22px;font-weight:800;color:var(--amber);">${escs.filter(e=>e.status==='pending').length}</div>
<div style="font-size:11px;color:var(--text-3);">Menunggu Persetujuan</div>
</div>
<div class="card card-p-sm" style="text-align:center;border-color:rgba(59,130,246,0.2);">
<div style="font-size:22px;font-weight:800;color:var(--blue);">${escs.filter(e=>e.status==='approved').length}</div>
<div style="font-size:11px;color:var(--text-3);">Disetujui</div>
</div>
<div class="card card-p-sm" style="text-align:center;border-color:rgba(34,197,94,0.2);">
<div style="font-size:22px;font-weight:800;color:var(--green);">${resolved.length}</div>
<div style="font-size:11px;color:var(--text-3);">Selesai</div>
</div>
</div>
<!-- Active Escalations -->
<div style="font-size:13px;font-weight:600;color:var(--text-1);margin-bottom:12px;">⚡ Eskalasi Aktif</div>
${active.length === 0
? `<div class="empty-state card" style="padding:32px;"><div class="empty-icon">✅</div><div class="empty-title">Tidak ada eskalasi aktif</div><div class="empty-desc">Semua eskalasi telah diselesaikan</div></div>`
: active.map(e => renderEscCard(e, false)).join('')
}
${resolved.length > 0 ? `
<div class="divider mt-4 mb-3"></div>
<div style="font-size:13px;font-weight:600;color:var(--text-1);margin-bottom:12px;">✅ Riwayat Eskalasi Selesai</div>
${resolved.map(e => renderEscCard(e, true)).join('')}
` : ''}
</div>
`;
}
function renderEscCard(esc, isResolved) {
const issue = APP_STATE.issues.find(i => i.id === esc.issueId);
const lvCfg = ESCALATION_LEVELS[esc.level];
const statusMap = { pending: { label: 'Menunggu', color: 'var(--amber)', bg: 'var(--amber-soft)' }, approved: { label: 'Disetujui', color: 'var(--blue)', bg: 'var(--blue-soft)' }, rejected: { label: 'Ditolak', color: 'var(--red)', bg: 'var(--red-soft)' }, resolved: { label: 'Selesai', color: 'var(--green)', bg: 'var(--green-soft)' } };
const st = statusMap[esc.status] || statusMap.pending;
return `
<div class="card card-p mb-3 ${isResolved ? '' : ''}" style="${!isResolved ? `border-left:3px solid ${lvCfg?.color || 'var(--border)'};` : 'opacity:0.7;'}">
<div class="flex justify-between items-start mb-3">
<div style="flex:1;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap;">
<span style="font-size:10px;font-family:monospace;color:var(--text-3);font-weight:600;">${esc.id}</span>
<span style="background:${lvCfg?.color || 'var(--border)'}22;color:${lvCfg?.color || 'var(--text-2)'};border:1px solid ${lvCfg?.color || 'var(--border)'}44;padding:2px 8px;border-radius:99px;font-size:11px;font-weight:700;">${esc.level}</span>
<span style="font-size:11px;font-weight:600;color:${st.color};background:${st.bg};padding:2px 8px;border-radius:99px;">${st.label}</span>
${getProgramBadge(esc.programId)}
</div>
<div style="font-size:13px;font-weight:600;color:var(--text-1);margin-bottom:2px;">${esc.levelName}</div>
<div style="font-size:12px;color:var(--text-3);">Ditujukan ke: <strong style="color:var(--text-2);">${esc.assignTo}</strong></div>
</div>
<div style="text-align:right;flex-shrink:0;">
<div style="font-size:11px;color:var(--text-3);">📅 ${formatDate(esc.createdAt)}</div>
${esc.resolvedAt ? `<div style="font-size:11px;color:var(--green);">✅ ${formatDate(esc.resolvedAt)}</div>` : ''}
</div>
</div>
${issue ? `
<div class="card card-p-sm mb-2" style="background:var(--bg);cursor:pointer;" onclick="openIssueDetail('${issue.id}')">
<div style="font-size:10px;color:var(--text-3);margin-bottom:3px;">ISSUE TERKAIT — ${esc.daerah}, ${esc.provinsi}</div>
<div style="font-size:12px;font-weight:500;color:var(--text-1);">${issue.id}: ${issue.judul.slice(0,70)}${issue.judul.length>70?'...':''}</div>
</div>
` : ''}
<div style="font-size:12px;color:var(--text-2);line-height:1.6;margin-bottom:10px;">${esc.notes}</div>
<div class="flex justify-between items-center">
<div style="font-size:11px;color:var(--text-3);">Dibuat oleh: ${esc.createdBy}</div>
${!isResolved ? `
<div style="display:flex;gap:6px;">
${esc.status === 'pending' ? `
<button class="btn btn-success btn-xs" onclick="approveEscalation('${esc.id}')">✓ Setujui</button>
<button class="btn btn-destructive btn-xs" onclick="rejectEscalation('${esc.id}')">✕ Tolak</button>
` : ''}
${esc.status === 'approved' ? `
<button class="btn btn-success btn-xs" onclick="resolveEscalation('${esc.id}')">✅ Selesaikan</button>
` : ''}
</div>
` : ''}
</div>
</div>
`;
}
function approveEscalation(id) {
const e = APP_STATE.escalations.find(e => e.id === id);
if (!e) return;
e.status = 'approved';
showToast('Eskalasi Disetujui', `${id} telah disetujui dan diteruskan`, 'success');
renderEscalasiPage();
}
function rejectEscalation(id) {
const e = APP_STATE.escalations.find(e => e.id === id);
if (!e) return;
e.status = 'rejected';
showToast('Eskalasi Ditolak', `${id} ditolak`, 'warning');
renderEscalasiPage();
}
function resolveEscalation(id) {
const e = APP_STATE.escalations.find(e => e.id === id);
if (!e) return;
e.status = 'resolved';
e.resolvedAt = new Date().toISOString().split('T')[0];
showToast('Eskalasi Diselesaikan', `${id} berhasil diselesaikan`, 'success');
renderEscalasiPage();
updateIssuesBadge();
}
function openNewEscalasiModal() {
// Simple prompt-based creation
const issueList = APP_STATE.issues
.filter(i => i.status !== 'resolved')
.map(i => `${i.id}: ${i.judul.slice(0,50)}`).join('\n');
const issueId = prompt(`Masukkan ID issue untuk dieskalasi:\n\nIssue aktif:\n${issueList}`);
if (!issueId) return;
const issue = APP_STATE.issues.find(i => i.id.toUpperCase() === issueId.toUpperCase().trim());
if (!issue) { showToast('Issue Tidak Ditemukan', `ID ${issueId} tidak ditemukan`, 'error'); return; }
const levelInput = prompt('Pilih level eskalasi:\nL1 - Daerah\nL2 - KSP Internal\nL3 - Kementerian/Lembaga\nL4 - Penegakan Hukum', 'L2');
const level = (levelInput || 'L2').toUpperCase().trim();
if (!ESCALATION_LEVELS[level]) { showToast('Level Tidak Valid', 'Pilih L1, L2, L3, atau L4', 'error'); return; }
const assignTo = prompt('Ditujukan kepada:', 'KSP Div. Pengawasan');
const notes = prompt('Catatan eskalasi:', `Eskalasi dari issue ${issue.id}`);
const id = `ESC-${Date.now().toString().slice(-4)}`;
const now = new Date().toISOString().split('T')[0];
const ts = new Date().toLocaleTimeString('id-ID') + ' WIB';
const newEsc = {
id, issueId: issue.id, programId: issue.programId,
level, levelName: ESCALATION_LEVELS[level].label,
assignTo: assignTo || 'KSP Div. Pengawasan',
notes: notes || `Eskalasi dari issue ${issue.id}`,
status: 'pending', createdBy: 'Admin KSP',
createdAt: now, resolvedAt: null,
daerah: issue.daerah, provinsi: issue.provinsi
};
APP_STATE.escalations.push(newEsc);
issue.escalationIds.push(id);
issue.timeline.push({ time: ts, action: `Eskalasi ${level} dibuat ke ${assignTo}`, actor: 'Admin KSP', type: 'escalated' });
issue.status = 'in_progress';
showToast('Eskalasi Dibuat', `${id} berhasil dibuat`, 'success');
renderEscalasiPage();
}
+243
View File
@@ -0,0 +1,243 @@
// ═══════════════════════════════════════════════════════════════════
// INTEGRATION PAGE — Infographics for OSMAP, OSPRO, and OSLOG
// ═══════════════════════════════════════════════════════════════════
let _integrationInterval = null;
function renderIntegrationPage() {
const container = document.getElementById('integration-body');
container.innerHTML = `
<div class="scroll-area">
<!-- Intro Section -->
<div class="card card-p mb-4" style="background: linear-gradient(135deg, rgba(59,130,246,0.08) 0%, transparent 100%);">
<div style="font-size:18px; font-weight:700; color:var(--text-1); margin-bottom:6px;">Ekosistem Monitoring KSP</div>
<div style="font-size:13px; color:var(--text-2); line-height:1.6; max-width:760px;">
Sistem Pengawasan Nasional Kantor Staf Presiden didukung oleh tiga platform modular utama yang saling terintegrasi:
<strong>OSMAP</strong> untuk analisis spasial dan pemetaan,
<strong>OSPRO</strong> untuk manajemen survei lapangan dan eskalasi, serta
<strong>OSLOG</strong> untuk pelacakan distribusi logistik dan pergerakan personel.
</div>
</div>
<!-- 3-Platform Grid -->
<div style="display:grid; grid-template-columns: repeat(3, 1fr); gap:16px; margin-bottom:20px;">
<!-- OSMAP Card -->
<div class="card card-p" style="display:flex; flex-direction:column; justify-content:space-between; border-top:3px solid var(--blue);">
<div>
<div class="flex items-center gap-2 mb-2">
<span style="font-size:24px;">🌐</span>
<div>
<div style="font-size:14px; font-weight:700; color:var(--text-1);">OSMAP</div>
<div style="font-size:10px; color:var(--blue); font-weight:600; text-transform:uppercase;">Map Engine & GIS</div>
</div>
</div>
<div style="font-size:12px; color:var(--text-3); line-height:1.5; margin-bottom:12px;">
Engine pemetaan wilayah untuk memvisualisasikan cakupan sebaran program strategis, heatmap laporan issue, dan data batas administratif secara nasional.
</div>
<div class="divider" style="margin:10px 0;"></div>
<div style="font-size:11px; font-weight:700; color:var(--text-3); text-transform:uppercase; margin-bottom:8px; letter-spacing:0.5px;">Layer Terpasang</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<div class="flex items-center justify-between text-xs">
<span class="text-secondary">🗺️ Map Tiles (CartoDB Dark)</span>
<span style="color:var(--green); font-weight:600;">✓ Aktif</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-secondary">📍 Titik Program (GeoJSON)</span>
<span style="color:var(--green); font-weight:600;">✓ Sinkron</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-secondary">🔴 Heatmap Wilayah Kritis</span>
<span style="color:var(--green); font-weight:600;">✓ Aktif</span>
</div>
</div>
</div>
<div style="margin-top:16px;">
<button class="btn btn-secondary btn-xs w-full" onclick="navigateTo('dashboard')">Lihat Peta Dashboard</button>
</div>
</div>
<!-- OSPRO Card -->
<div class="card card-p" style="display:flex; flex-direction:column; justify-content:space-between; border-top:3px solid var(--purple);">
<div>
<div class="flex items-center gap-2 mb-2">
<span style="font-size:24px;">⚡</span>
<div>
<div style="font-size:14px; font-weight:700; color:var(--text-1);">OSPRO</div>
<div style="font-size:10px; color:var(--purple); font-weight:600; text-transform:uppercase;">Task & Survey Engine</div>
</div>
</div>
<div style="font-size:12px; color:var(--text-3); line-height:1.5; margin-bottom:12px;">
Sistem penugasan tim lapangan secara terstruktur dari deteksi anomali hingga pelaporan rekomendasi survei di lokasi bermasalah.
</div>
<div class="divider" style="margin:10px 0;"></div>
<div style="font-size:11px; font-weight:700; color:var(--text-3); text-transform:uppercase; margin-bottom:8px; letter-spacing:0.5px;">Statistik Tugas</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px;">
<div class="card card-p-sm text-center" style="background:var(--bg);">
<div style="font-size:16px; font-weight:800; color:var(--purple);" id="ospro-active-tasks">0</div>
<div style="font-size:9px; color:var(--text-3);">Tugas Aktif</div>
</div>
<div class="card card-p-sm text-center" style="background:var(--bg);">
<div style="font-size:16px; font-weight:800; color:var(--green);" id="ospro-verified-tasks">0</div>
<div style="font-size:9px; color:var(--text-3);">Tugas Selesai</div>
</div>
</div>
</div>
<div style="margin-top:16px;">
<button class="btn btn-secondary btn-xs w-full" onclick="navigateTo('survey')">Lihat Survey Lapangan</button>
</div>
</div>
<!-- OSLOG Card -->
<div class="card card-p" style="display:flex; flex-direction:column; justify-content:space-between; border-top:3px solid var(--orange);">
<div>
<div class="flex items-center gap-2 mb-2">
<span style="font-size:24px;">🚚</span>
<div>
<div style="font-size:14px; font-weight:700; color:var(--text-1);">OSLOG</div>
<div style="font-size:10px; color:var(--orange); font-weight:600; text-transform:uppercase;">Tracking & Logistics</div>
</div>
</div>
<div style="font-size:12px; color:var(--text-3); line-height:1.5; margin-bottom:12px;">
Solusi pelacakan real-time untuk logistik program (distribusi menu MBG) dan lokasi terkini GPS petugas survei yang sedang berjalan.
</div>
<div class="divider" style="margin:10px 0;"></div>
<div style="font-size:11px; font-weight:700; color:var(--text-3); text-transform:uppercase; margin-bottom:8px; letter-spacing:0.5px;">Pelacakan Aktif</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<div class="flex items-center justify-between text-xs">
<span class="text-secondary">📦 Pengiriman MBG Aktif</span>
<span style="color:var(--orange); font-weight:700;">14 Armada</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-secondary">👤 GPS Surveyor Aktif</span>
<span style="color:var(--blue); font-weight:700;">8 Personel</span>
</div>
</div>
</div>
<div style="margin-top:16px;">
<button class="btn btn-secondary btn-xs w-full" onclick="showToast('OSLOG Integration','Data GPS terhubung ke Core Map Engine KSP','info')">Cek Konektivitas GPS</button>
</div>
</div>
</div>
<!-- Infographic & Timeline Flow OSPRO -->
<div class="card card-p mb-4">
<div style="font-size:13px; font-weight:600; color:var(--text-1); margin-bottom:16px;">📋 Alur Penugasan OSPRO (Survey & Escalation Timeline)</div>
<div style="display:flex; align-items:center; justify-content:space-between; position:relative; overflow-x:auto; padding:12px 0;">
<!-- Progress background line -->
<div style="position:absolute; top:36px; left:5%; right:5%; height:2px; background:var(--border); z-index:1;"></div>
<!-- Step 1 -->
<div style="text-align:center; width:22%; position:relative; z-index:2;">
<div style="width:48px; height:48px; border-radius:50%; background:var(--red-soft); border:2px solid var(--red); color:var(--red); display:flex; align-items:center; justify-content:center; margin:0 auto 10px; font-size:18px;">⚠️</div>
<div style="font-size:12px; font-weight:700; color:var(--text-1);">1. Deteksi Issue</div>
<div style="font-size:10px; color:var(--text-3); margin-top:2px;">Anomali terdeteksi SIMBG / Pengaduan</div>
</div>
<!-- Step 2 -->
<div style="text-align:center; width:22%; position:relative; z-index:2;">
<div style="width:48px; height:48px; border-radius:50%; background:var(--blue-soft); border:2px solid var(--blue); color:var(--blue); display:flex; align-items:center; justify-content:center; margin:0 auto 10px; font-size:18px;">👤</div>
<div style="font-size:12px; font-weight:700; color:var(--text-1);">2. Penugasan OSPRO</div>
<div style="font-size:10px; color:var(--text-3); margin-top:2px;">Delegasi tugas ke investigator lapangan</div>
</div>
<!-- Step 3 -->
<div style="text-align:center; width:22%; position:relative; z-index:2;">
<div style="width:48px; height:48px; border-radius:50%; background:var(--purple-soft); border:2px solid var(--purple); color:var(--purple); display:flex; align-items:center; justify-content:center; margin:0 auto 10px; font-size:18px;">🏃</div>
<div style="font-size:12px; font-weight:700; color:var(--text-1);">3. Survei Lapangan</div>
<div style="font-size:10px; color:var(--text-3); margin-top:2px;">Verifikasi fisik, foto, & entri form OSPRO</div>
</div>
<!-- Step 4 -->
<div style="text-align:center; width:22%; position:relative; z-index:2;">
<div style="width:48px; height:48px; border-radius:50%; background:var(--green-soft); border:2px solid var(--green); color:var(--green); display:flex; align-items:center; justify-content:center; margin:0 auto 10px; font-size:18px;">✅</div>
<div style="font-size:12px; font-weight:700; color:var(--text-1);">4. Verifikasi KSP</div>
<div style="font-size:10px; color:var(--text-3); margin-top:2px;">Validasi laporan & eskalasi tindak lanjut</div>
</div>
</div>
</div>
<!-- OSLOG Live Tracking Activity Feed -->
<div class="card card-p" style="display:flex; flex-direction:column;">
<div class="flex justify-between items-center mb-3">
<div style="font-size:13px; font-weight:600; color:var(--text-1);">🚚 Log Aktivitas OSLOG (Simulasi Live Feed GPS)</div>
<div class="live-pill" style="padding: 2px 8px;">
<div class="live-dot" style="background:var(--orange); box-shadow: 0 0 6px var(--orange);"></div>
<span class="live-text" style="color:var(--orange);">SINKRONISASI GPS</span>
</div>
</div>
<div style="display:flex; flex-direction:column; gap:8px;" id="oslog-feed-container">
<!-- Active log feeds rendered dynamically below -->
</div>
</div>
</div>
`;
// Update counters initially
document.getElementById('ospro-active-tasks').textContent = APP_STATE.surveys.filter(s => s.status !== 'verified').length;
document.getElementById('ospro-verified-tasks').textContent = APP_STATE.surveys.filter(s => s.status === 'verified').length;
// Render dummy live activity feed for OSLOG
renderOslogLogs();
// Set interval to update GPS log simulation every 3 seconds
if (_integrationInterval) clearInterval(_integrationInterval);
_integrationInterval = setInterval(() => {
if (APP_STATE.currentPage === 'integration') {
renderOslogLogs();
}
}, 3500);
}
// OSLOG dummy activity data
const OSLOG_LOGS = [
{ device: 'Truk MBG #08 (Medan)', action: 'Pengiriman 840 porsi menu Makan Bergizi selesai di SD N 2 Medan', time: 'Baru saja', type: 'success' },
{ device: 'Surveyor Hendra (Manokwari)', action: 'GPS terdeteksi di lokasi issue ISS-001 (Kec. Manokwari Timur)', time: '1 menit lalu', type: 'location' },
{ device: 'Armada Koperasi #02 (Palu)', action: 'Pengantaran paket modal Koperasi selesai di Kantor Kelurahan Palu', time: '3 menit lalu', type: 'success' },
{ device: 'Surveyor Ratna (Palu)', action: 'Aktivitas survei OSPRO dimulai untuk issue koperasi fiktif Palu', time: '5 menit lalu', type: 'task' },
{ device: 'Truk MBG #12 (Surabaya)', action: 'Truk bergerak meninggalkan Gudang Utama menuju Rute R-04', time: '8 menit lalu', type: 'transit' },
{ device: 'Surveyor Dimas (Surabaya)', action: 'Akurasi GPS terverifikasi stabil (±3m) di kawasan LRT Surabaya', time: '12 menit lalu', type: 'location' }
];
function renderOslogLogs() {
const container = document.getElementById('oslog-feed-container');
if (!container) return;
// Randomly rotate one of the logs to make it look "live"
const logsToRender = [...OSLOG_LOGS];
const shuffled = logsToRender.sort(() => 0.5 - Math.random()).slice(0, 4);
const getLogIcon = (type) => {
switch (type) {
case 'success': return '✅';
case 'location': return '📍';
case 'task': return '📋';
case 'transit': return '🚚';
default: return '⚪';
}
};
container.innerHTML = shuffled.map(log => `
<div class="card card-p-sm slide-in" style="background:var(--bg); border-color:var(--border); display:flex; justify-content:space-between; align-items:center;">
<div style="display:flex; align-items:center; gap:10px;">
<span style="font-size:16px;">${getLogIcon(log.type)}</span>
<div>
<strong style="font-size:12px; color:var(--text-1);">${log.device}</strong>
<div style="font-size:11px; color:var(--text-2); margin-top:2px;">${log.action}</div>
</div>
</div>
<span style="font-size:10px; color:var(--text-3); font-variant-numeric:tabular-nums;">${log.time}</span>
</div>
`).join('');
}
+151
View File
@@ -0,0 +1,151 @@
// ═══════════════════════════════════════════════════════════════════
// ISSUES PAGE — Kanban + List view for issue management
// ═══════════════════════════════════════════════════════════════════
let _issueView = 'kanban'; // 'kanban' | 'list'
function renderIssuesPage() {
const filtered = getFilteredIssues();
if (_issueView === 'kanban') renderKanbanView(filtered);
else renderListView(filtered);
}
function applyIssueFilters() {
renderIssuesPage();
}
function switchIssueView(view) {
_issueView = view;
document.getElementById('kanban-view-btn').classList.toggle('active', view === 'kanban');
document.getElementById('list-view-btn').classList.toggle('active', view === 'list');
renderIssuesPage();
}
function getFilteredIssues() {
const search = (document.getElementById('issues-search')?.value || '').toLowerCase().trim();
const program = document.getElementById('issues-filter-program')?.value || 'all';
const severity= document.getElementById('issues-filter-severity')?.value || 'all';
return APP_STATE.issues.filter(i => {
const matchSearch = !search || i.judul.toLowerCase().includes(search) || i.daerah.toLowerCase().includes(search) || i.provinsi.toLowerCase().includes(search) || i.id.toLowerCase().includes(search);
const matchProg = program === 'all' || i.programId === program;
const matchSev = severity === 'all' || i.severity === severity;
return matchSearch && matchProg && matchSev;
});
}
// ─── KANBAN VIEW ─────────────────────────────────────────────────
function renderKanbanView(issues) {
const cols = [
{ key: 'open', label: 'Terbuka', icon: '🔴', color: 'var(--red)' },
{ key: 'in_progress', label: 'Diproses', icon: '🔵', color: 'var(--blue)' },
{ key: 'resolved', label: 'Selesai', icon: '✅', color: 'var(--green)' }
];
document.getElementById('issues-body').innerHTML = `
<div class="kanban-view">
${cols.map(col => {
const colIssues = issues.filter(i => i.status === col.key);
return `
<div class="kanban-col">
<div class="kanban-col-header">
<div class="kanban-col-title">
<span>${col.icon}</span>
<span>${col.label}</span>
</div>
<span class="col-count">${colIssues.length}</span>
</div>
<div class="kanban-cards">
${colIssues.length === 0
? `<div class="empty-state" style="padding:32px 16px;"><div class="empty-icon" style="font-size:24px;opacity:0.3;">📭</div><div class="empty-desc" style="font-size:11px;">Tidak ada issue</div></div>`
: colIssues.map(i => renderIssueCard(i)).join('')
}
</div>
</div>
`;
}).join('')}
</div>
`;
}
function renderIssueCard(issue) {
const prog = getProgramById(issue.programId);
const survCount = issue.surveyIds.length;
const escCount = issue.escalationIds.length;
return `
<div class="issue-card ${issue.severity}-card fade-in" onclick="openIssueDetail('${issue.id}')">
<div class="ic-header">
<span class="ic-id">${issue.id}</span>
${getSevBadge(issue.severity)}
</div>
<div class="flex gap-2 items-center mb-2">
${getProgramBadge(issue.programId)}
<span style="font-size:10px;color:var(--text-3);">📍 ${issue.daerah}</span>
</div>
<div class="ic-title">${issue.judul}</div>
<div class="ic-desc">${issue.deskripsi}</div>
<div class="ic-footer">
<div style="display:flex;gap:8px;align-items:center;">
${survCount > 0 ? `<span style="font-size:10px;color:var(--purple);">📋 ${survCount} survey</span>` : ''}
${escCount > 0 ? `<span style="font-size:10px;color:var(--orange);">🔺 ${escCount} eskalasi</span>` : ''}
</div>
<div class="ic-meta">${formatDate(issue.createdAt)}</div>
</div>
<div style="margin-top:8px;padding-top:8px;border-top:1px solid var(--border);font-size:10px;color:var(--text-3);" class="truncate">
👤 ${issue.assignee}
</div>
</div>
`;
}
// ─── LIST / TABLE VIEW ────────────────────────────────────────────
function renderListView(issues) {
document.getElementById('issues-body').innerHTML = `
<div class="list-view">
${issues.length === 0
? `<div class="empty-state"><div class="empty-icon">🔍</div><div class="empty-title">Tidak ada issue</div><div class="empty-desc">Tidak ada issue yang cocok dengan filter yang dipilih</div></div>`
: `<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Judul Issue</th>
<th>Program</th>
<th>Daerah</th>
<th>Keparahan</th>
<th>Status</th>
<th>Ditugaskan ke</th>
<th>Tanggal</th>
<th></th>
</tr>
</thead>
<tbody>
${issues.map(i => `
<tr style="cursor:pointer;" onclick="openIssueDetail('${i.id}')">
<td><span style="font-family:monospace;font-size:11px;color:var(--text-3);">${i.id}</span></td>
<td style="max-width:240px;">
<div style="font-size:12px;font-weight:500;color:var(--text-1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:220px;" title="${i.judul}">${i.judul}</div>
</td>
<td>${getProgramBadge(i.programId)}</td>
<td>
<div style="font-size:12px;font-weight:500;">${i.daerah}</div>
<div class="td-muted">${i.provinsi}</div>
</td>
<td>${getSevBadge(i.severity)}</td>
<td>${getStatBadge(i.status)}</td>
<td class="td-muted truncate" style="max-width:140px;">${i.assignee}</td>
<td class="td-muted">${formatDate(i.createdAt)}</td>
<td>
<div style="display:flex;gap:4px;">
${i.surveyIds.length > 0 ? `<span title="${i.surveyIds.length} survey" style="font-size:12px;">📋</span>` : ''}
${i.escalationIds.length > 0 ? `<span title="${i.escalationIds.length} eskalasi" style="font-size:12px;">🔺</span>` : ''}
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>`
}
</div>
`;
}
+247
View File
@@ -0,0 +1,247 @@
// ═══════════════════════════════════════════════════════════════════
// PROGRAMS PAGE — Detail per program with stats and province breakdown
// ═══════════════════════════════════════════════════════════════════
let _activeProgTab = 'mbg';
function renderProgramsPage() {
renderProgTabs();
renderProgBody(_activeProgTab);
}
function renderProgTabs() {
document.getElementById('prog-tabs').innerHTML = PROGRAMS.map(p => `
<button class="prog-tab ${p.id === _activeProgTab ? 'active' : ''}"
onclick="switchProgTab('${p.id}')">
${p.icon} ${p.shortName}
</button>
`).join('');
}
function switchProgTab(id) {
_activeProgTab = id;
renderProgTabs();
renderProgBody(id);
}
function renderProgBody(pid) {
const p = getProgramById(pid);
if (!p) return;
const pct = Math.round((p.capaian / p.target) * 100);
const budgetPct = Math.round((p.realisasi / p.budget) * 100);
const activeIssues = APP_STATE.issues.filter(i => i.programId === pid && i.status !== 'resolved' && i.status !== 'closed');
const critCount = activeIssues.filter(i => i.severity === 'critical').length;
const highCount = activeIssues.filter(i => i.severity === 'high').length;
// Location data for this program
const progLocs = APP_STATE.locs.filter(l => l.programs.includes(pid));
// Generate province stats
const provinceData = {};
progLocs.forEach(l => {
const prov = l.province;
if (!provinceData[prov]) provinceData[prov] = { locs: 0, issues: 0 };
provinceData[prov].locs++;
provinceData[prov].issues += l.issueCount;
});
const provinceRows = Object.entries(provinceData)
.sort((a,b) => b[1].issues - a[1].issues)
.slice(0, 10)
.map(([prov, data]) => {
const locList = progLocs.filter(l => l.province === prov);
let prog = 90; // default coverage
if (pid === 'mbg') prog = Math.round(locList.reduce((s,l) => s + (l.mbg?.coverage || 0), 0) / locList.length);
else if (pid === 'koperasi') prog = 75;
return `
<tr>
<td><span style="font-weight:500;">${prov}</span></td>
<td>${data.locs} titik</td>
<td>
<div style="display:flex;align-items:center;gap:8px;">
<div class="progress-bar" style="width:80px;"><div class="progress-fill" style="width:${prog}%;background:${p.color};"></div></div>
<span style="font-size:12px;color:${p.color};font-weight:600;">${prog}%</span>
</div>
</td>
<td>${data.issues > 0 ? `<span style="color:var(--red);font-weight:600;">${data.issues} issue</span>` : '<span style="color:var(--green);">✓</span>'}</td>
<td>
<button class="btn btn-xs btn-secondary" onclick="filterIssuesByProvince('${prov}','${pid}')">Lihat Issue</button>
</td>
</tr>
`;
}).join('');
document.getElementById('prog-bodies').innerHTML = `
<div class="prog-body active" style="flex:1;overflow:hidden;">
<div class="prog-scroll">
<!-- Header -->
<div class="card card-p mb-4" style="background:linear-gradient(135deg, ${p.color}15, transparent);border-color:${p.color}33;">
<div style="display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap;">
<div style="font-size:42px;line-height:1;">${p.icon}</div>
<div style="flex:1;min-width:200px;">
<div style="font-size:18px;font-weight:800;color:var(--text-1);margin-bottom:4px;">${p.name}</div>
<div style="font-size:12px;color:var(--text-3);margin-bottom:12px;">📋 ${p.ministry} · Mulai ${p.startDate}</div>
<div style="font-size:13px;color:var(--text-2);line-height:1.6;">${p.description}</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;">
<div style="font-size:36px;font-weight:800;color:${p.color};">${pct}%</div>
<div style="font-size:11px;color:var(--text-3);">Capaian Target</div>
<span class="badge badge-${p.badge}">🟢 Aktif</span>
</div>
</div>
<div class="progress-bar progress-lg mt-3">
<div class="progress-fill" style="width:${pct}%;background:${p.color};"></div>
</div>
<div style="display:flex;justify-content:space-between;margin-top:6px;">
<div style="font-size:11px;color:var(--text-3);">Realisasi: ${p.capaian.toLocaleString()} ${p.targetUnit}</div>
<div style="font-size:11px;color:var(--text-3);">Target: ${p.target.toLocaleString()} ${p.targetUnit}</div>
</div>
</div>
<!-- KPI Grid -->
<div class="prog-stat-grid mb-4">
${p.kpiLabel.map((lbl, i) => `
<div class="prog-stat-card">
<div class="prog-stat-val" style="color:${p.color};">${p.kpiValue[i]}</div>
<div class="prog-stat-lbl">${lbl}</div>
</div>
`).join('')}
<div class="prog-stat-card">
<div class="prog-stat-val">Rp ${p.realisasi.toLocaleString()}M</div>
<div class="prog-stat-lbl">Realisasi Anggaran</div>
</div>
<div class="prog-stat-card">
<div class="prog-stat-val">${budgetPct}%</div>
<div class="prog-stat-lbl">Serapan Anggaran</div>
</div>
<div class="prog-stat-card">
<div class="prog-stat-val" style="color:${activeIssues.length > 0 ? 'var(--red)' : 'var(--green)'};">${activeIssues.length}</div>
<div class="prog-stat-lbl">Issue Aktif</div>
</div>
</div>
<!-- Issues summary -->
${activeIssues.length > 0 ? `
<div class="card card-p mb-4" style="border-color:rgba(239,68,68,0.2);">
<div class="flex justify-between items-center mb-3">
<div style="font-size:13px;font-weight:600;color:var(--text-1);">⚠️ Issue Aktif (${activeIssues.length})</div>
<button class="btn btn-sm btn-secondary" onclick="filterIssuesByProgram('${pid}')">Lihat Semua</button>
</div>
${activeIssues.slice(0,3).map(i => `
<div class="alert-item ${i.severity}" onclick="openIssueDetail('${i.id}')">
<div class="flex gap-2 items-center mb-1">${getSevBadge(i.severity)} ${getStatBadge(i.status)}</div>
<div class="alert-title">${i.judul}</div>
<div class="alert-meta"><span>📍 ${i.daerah}, ${i.provinsi}</span></div>
</div>
`).join('')}
</div>` : ''}
<!-- Province Breakdown Table -->
<div class="card mb-4">
<div style="padding:14px 16px 10px;border-bottom:1px solid var(--border);font-size:13px;font-weight:600;color:var(--text-1);">
Distribusi per Provinsi (${progLocs.length} titik pemantauan)
</div>
<div class="table-wrap" style="border:none;border-radius:0;">
<table>
<thead>
<tr>
<th>Provinsi</th>
<th>Titik Aktif</th>
<th>Capaian</th>
<th>Status Issue</th>
<th></th>
</tr>
</thead>
<tbody>${provinceRows || '<tr><td colspan="5" style="text-align:center;padding:20px;color:var(--text-3);">Tidak ada data</td></tr>'}</tbody>
</table>
</div>
</div>
<!-- Location cards -->
<div style="font-size:13px;font-weight:600;color:var(--text-1);margin-bottom:12px;">
Lokasi Pemantauan (${progLocs.length} titik)
</div>
${progLocs.length === 0
? `<div class="empty-state card" style="padding:32px;"><div class="empty-icon">📍</div><div class="empty-title">Belum ada titik pemantauan</div><div class="empty-desc">Data lokasi untuk program ${p.shortName} belum tersedia</div></div>`
: `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px;margin-bottom:20px;">
${progLocs.slice(0, 12).map(loc => {
const d = loc[pid];
const hasIssue = loc.issueCount > 0;
const relIssue = APP_STATE.issues.find(i => i.daerah === loc.name && i.programId === pid && i.status !== 'resolved');
const onclickAction = (hasIssue && relIssue)
? `openIssueDetail('${relIssue.id}')`
: `showToast('${loc.name.replace(/'/g,"\\'")}', '${loc.province.replace(/'/g,"\\'")} — kondisi normal', 'info')`;
return `
<div class="card card-p-sm" style="${hasIssue ? 'border-color:rgba(239,68,68,0.3);' : ''}cursor:pointer;" onclick="${onclickAction}">
<div class="flex justify-between items-center mb-1">
<span style="font-size:12px;font-weight:600;color:var(--text-1);">${loc.name}</span>
${hasIssue ? '<span style="color:var(--red);font-size:11px;">⚠ Issue</span>' : '<span style="color:var(--green);font-size:11px;">✓</span>'}
</div>
<div style="font-size:10px;color:var(--text-3);margin-bottom:6px;">${loc.province}</div>
${pid === 'mbg' && d ? `
<div class="stat-pair" style="padding:4px 0;">
<span class="stat-key text-xs">Porsi/Hari</span>
<span class="stat-val text-xs">${(d.meals||0).toLocaleString()}</span>
</div>
<div style="margin-top:4px;"><div class="progress-bar progress-sm"><div class="progress-fill" style="width:${d.coverage||0}%;background:${p.color};"></div></div></div>
` : ''}
${pid === 'koperasi' && d ? `
<div class="stat-pair" style="padding:4px 0;">
<span class="stat-key text-xs">Anggota</span>
<span class="stat-val text-xs">${(d.members||0).toLocaleString()}</span>
</div>
<span class="badge badge-${d.status === 'active' ? 'green' : d.status === 'inactive' ? 'red' : 'amber'}" style="font-size:10px;">${d.status}</span>
` : ''}
${pid === 'psn' && d ? `
<div class="stat-pair" style="padding:4px 0;">
<span class="stat-key text-xs" title="${d.projectName||''}">${(d.projectName||'—').slice(0,22)}…</span>
<span class="stat-val text-xs" style="color:${p.color};">${d.progress}%</span>
</div>
<div style="margin-top:4px;"><div class="progress-bar progress-sm"><div class="progress-fill" style="width:${d.progress}%;background:${p.color};"></div></div></div>
` : ''}
${pid === 'hilirisasi' && d ? `
<div class="stat-pair" style="padding:4px 0;">
<span class="stat-key text-xs">${d.commodity||'—'}</span>
<span class="stat-val text-xs" style="color:${p.color};">${d.progress}%</span>
</div>
<div style="font-size:10px;color:var(--text-3);margin-top:2px;">${d.stage||''}</div>
<div style="margin-top:4px;"><div class="progress-bar progress-sm"><div class="progress-fill" style="width:${d.progress}%;background:${p.color};"></div></div></div>
` : ''}
${pid === 'rumah' && d ? `
<div class="stat-pair" style="padding:4px 0;">
<span class="stat-key text-xs">Unit Selesai</span>
<span class="stat-val text-xs">${(d.completed||0).toLocaleString()}</span>
</div>
<div style="font-size:10px;color:var(--text-3);margin-top:2px;">Target: ${(d.units||0).toLocaleString()} unit</div>
<div style="margin-top:4px;"><div class="progress-bar progress-sm"><div class="progress-fill" style="width:${d.progress||0}%;background:${p.color};"></div></div></div>
` : ''}
</div>
`;
}).join('')}
</div>`
}
</div>
</div>
`;
}
function filterIssuesByProgram(pid) {
APP_STATE.issueFilter.program = pid;
navigateTo('issues');
setTimeout(() => {
const sel = document.getElementById('issues-filter-program');
if (sel) { sel.value = pid; applyIssueFilters(); }
}, 100);
}
function filterIssuesByProvince(prov, pid) {
APP_STATE.issueFilter.program = pid;
navigateTo('issues');
setTimeout(() => {
const sel = document.getElementById('issues-filter-program');
const search = document.getElementById('issues-search');
if (sel) sel.value = pid;
if (search) search.value = prov;
applyIssueFilters();
}, 100);
}
+259
View File
@@ -0,0 +1,259 @@
// ═══════════════════════════════════════════════════════════════════
// SURVEY PAGE — Lapangan survey management
// ═══════════════════════════════════════════════════════════════════
function renderSurveyPage() {
const search = (document.getElementById('survey-search')?.value || '').toLowerCase().trim();
const statusF = document.getElementById('survey-filter-status')?.value || 'all';
const programF = document.getElementById('survey-filter-program')?.value || 'all';
const filtered = APP_STATE.surveys.filter(s => {
const matchS = !search || s.namaOfficer.toLowerCase().includes(search) || s.daerah.toLowerCase().includes(search) || s.issueId.toLowerCase().includes(search) || s.temuanUtama.toLowerCase().includes(search);
const matchSt = statusF === 'all' || s.status === statusF;
const matchP = programF === 'all' || s.programId === programF;
return matchS && matchSt && matchP;
});
const stats = {
total: APP_STATE.surveys.length,
verified: APP_STATE.surveys.filter(s => s.status === 'verified').length,
submitted: APP_STATE.surveys.filter(s => s.status === 'submitted').length,
draft: APP_STATE.surveys.filter(s => s.status === 'draft').length
};
document.getElementById('survey-body').innerHTML = `
<!-- Stats Row -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px;">
<div class="card card-p-sm" style="text-align:center;">
<div style="font-size:22px;font-weight:800;color:var(--text-1);margin-bottom:4px;">${stats.total}</div>
<div style="font-size:11px;color:var(--text-3);">Total Survey</div>
</div>
<div class="card card-p-sm" style="text-align:center;">
<div style="font-size:22px;font-weight:800;color:var(--green);margin-bottom:4px;">${stats.verified}</div>
<div style="font-size:11px;color:var(--text-3);">Diverifikasi</div>
</div>
<div class="card card-p-sm" style="text-align:center;">
<div style="font-size:22px;font-weight:800;color:var(--blue);margin-bottom:4px;">${stats.submitted}</div>
<div style="font-size:11px;color:var(--text-3);">Menunggu Verifikasi</div>
</div>
<div class="card card-p-sm" style="text-align:center;">
<div style="font-size:22px;font-weight:800;color:var(--text-3);margin-bottom:4px;">${stats.draft}</div>
<div style="font-size:11px;color:var(--text-3);">Draft</div>
</div>
</div>
<!-- Survey List -->
${filtered.length === 0
? `<div class="empty-state"><div class="empty-icon">📋</div><div class="empty-title">Tidak ada survey</div><div class="empty-desc">Tidak ada survey yang cocok dengan filter yang dipilih</div></div>`
: filtered.map(s => renderSurveyCard(s)).join('')
}
`;
}
function renderSurveyCard(survey) {
const issue = APP_STATE.issues.find(i => i.id === survey.issueId);
const prog = getProgramById(survey.programId);
const skorColors = ['','var(--red)','var(--orange)','var(--amber)','var(--green)','var(--blue)'];
const skorLabel = ['','Sangat Kritis','Kritis','Perlu Perhatian','Minor','Normal'];
return `
<div class="survey-card fade-in" onclick="openSurveyDetail('${survey.id}')">
<div class="sc-header">
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<span class="sc-id">${survey.id}</span>
${getProgramBadge(survey.programId)}
${issue ? getSevBadge(issue.severity) : ''}
</div>
<div class="sc-officer">${survey.namaOfficer}</div>
<div class="sc-jabatan">${survey.jabatan}</div>
</div>
<span class="status-${survey.status}">${{draft:'Draft',submitted:'Diajukan',verified:'Diverifikasi'}[survey.status]}</span>
</div>
<div class="divider" style="margin:8px 0;"></div>
<div style="display:flex;gap:16px;align-items:flex-start;">
<div style="flex:1;">
<div style="font-size:10px;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:0.4px;margin-bottom:4px;">Temuan Utama</div>
<div class="sc-temuan">${survey.temuanUtama}</div>
</div>
</div>
${survey.rekomendasi ? `
<div style="background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.2);border-radius:var(--r);padding:8px 10px;margin-top:8px;">
<div style="font-size:10px;font-weight:700;color:var(--amber);margin-bottom:3px;">💡 Rekomendasi</div>
<div style="font-size:11px;color:var(--text-2);line-height:1.5;">${survey.rekomendasi.slice(0,120)}${survey.rekomendasi.length > 120 ? '...' : ''}</div>
</div>
` : ''}
<div class="sc-footer mt-2">
<div class="sc-meta">
<span>📍 ${survey.daerah}, ${survey.provinsi}</span>
<span>·</span>
<span>📅 ${formatDate(survey.tanggalSurvey)}</span>
${survey.issueId ? `<span>·</span><span style="font-family:monospace;font-size:10px;">${survey.issueId}</span>` : ''}
</div>
<div style="display:flex;align-items:center;gap:6px;">
${survey.skorKeparahan ? `
<div style="display:flex;align-items:center;gap:4px;">
<div style="width:8px;height:8px;border-radius:50%;background:${skorColors[survey.skorKeparahan]};"></div>
<span style="font-size:10px;color:${skorColors[survey.skorKeparahan]};font-weight:600;">${skorLabel[survey.skorKeparahan]}</span>
</div>
` : ''}
${survey.verifiedBy ? `<span style="font-size:10px;color:var(--green);">✓ ${survey.verifiedBy}</span>` : ''}
</div>
</div>
</div>
`;
}
function openSurveyDetail(surveyId) {
const s = APP_STATE.surveys.find(sv => sv.id === surveyId);
if (!s) return;
const issue = APP_STATE.issues.find(i => i.id === s.issueId);
const prog = getProgramById(s.programId);
const skorColors = ['','var(--red)','var(--orange)','var(--amber)','var(--green)','var(--blue)'];
const skorLabel = ['','Sangat Kritis — Tindakan Segera','Kritis','Perlu Perhatian Khusus','Minor — Tetap Perlu Ditangani','Kondisi Normal'];
document.getElementById('sd-title').textContent = `Survey Lapangan — ${s.id}`;
document.getElementById('sd-body').innerHTML = `
<div class="flex gap-3 items-center mb-4" style="flex-wrap:wrap;">
${getProgramBadge(s.programId)}
<span class="status-${s.status}">${{draft:'Draft',submitted:'Diajukan',verified:'Diverifikasi'}[s.status]}</span>
${issue ? getSevBadge(issue.severity) : ''}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;">
<div>
<div style="font-size:10px;color:var(--text-3);margin-bottom:4px;">OFFICER SURVEI</div>
<div style="font-size:14px;font-weight:700;color:var(--text-1);">${s.namaOfficer}</div>
<div style="font-size:12px;color:var(--text-3);">${s.jabatan}</div>
</div>
<div>
<div style="font-size:10px;color:var(--text-3);margin-bottom:4px;">DETAIL SURVEI</div>
<div style="font-size:12px;color:var(--text-2);">📅 ${formatDate(s.tanggalSurvey)}</div>
<div style="font-size:12px;color:var(--text-2);">📍 ${s.daerah}, ${s.provinsi}</div>
</div>
</div>
${issue ? `
<div class="card card-p-sm mb-3" style="background:var(--bg);cursor:pointer;" onclick="closeModal('survey-detail-modal');openIssueDetail('${issue.id}')">
<div style="font-size:10px;color:var(--text-3);margin-bottom:4px;">ISSUE TERKAIT</div>
<div style="font-size:12px;font-weight:600;color:var(--text-1);">${issue.id}${issue.judul.slice(0,60)}${issue.judul.length>60?'...':''}</div>
<div style="font-size:11px;color:var(--blue);margin-top:4px;">Klik untuk lihat detail issue →</div>
</div>
` : ''}
<div class="card card-p mb-3" style="background:var(--bg);">
<div style="font-size:11px;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px;">📋 Temuan Utama</div>
<div style="font-size:13px;color:var(--text-1);line-height:1.7;">${s.temuanUtama}</div>
</div>
${s.rekomendasi ? `
<div class="card card-p mb-3" style="background:rgba(245,158,11,0.05);border-color:rgba(245,158,11,0.2);">
<div style="font-size:11px;font-weight:700;color:var(--amber);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px;">💡 Rekomendasi Tindak Lanjut</div>
<div style="font-size:13px;color:var(--text-2);line-height:1.7;">${s.rekomendasi}</div>
</div>
` : ''}
${s.skorKeparahan ? `
<div class="card card-p mb-3" style="background:var(--bg);border-color:${skorColors[s.skorKeparahan]}33;">
<div style="font-size:11px;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px;">Penilaian Keparahan</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="width:40px;height:40px;border-radius:50%;background:${skorColors[s.skorKeparahan]}22;border:2px solid ${skorColors[s.skorKeparahan]};display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:800;color:${skorColors[s.skorKeparahan]};">${s.skorKeparahan}</div>
<div>
<div style="font-size:13px;font-weight:600;color:${skorColors[s.skorKeparahan]};">${skorLabel[s.skorKeparahan]}</div>
<div style="font-size:11px;color:var(--text-3);">Skala 1 (paling parah) — 5 (normal)</div>
</div>
</div>
</div>
` : ''}
${s.verifiedBy ? `
<div style="display:flex;align-items:center;gap:8px;padding:10px;border-radius:var(--r);background:var(--green-soft);border:1px solid rgba(34,197,94,0.2);">
<span style="font-size:16px;">✅</span>
<div>
<div style="font-size:12px;font-weight:600;color:var(--green);">Diverifikasi oleh ${s.verifiedBy}</div>
<div style="font-size:11px;color:var(--text-3);">Survey ini telah divalidasi dan dapat dijadikan dasar tindak lanjut</div>
</div>
</div>
` : `
<div style="display:flex;align-items:center;gap:8px;padding:10px;border-radius:var(--r);background:var(--amber-soft);border:1px solid rgba(245,158,11,0.2);">
<span style="font-size:16px;">⏳</span>
<div>
<div style="font-size:12px;font-weight:600;color:var(--amber);">Menunggu Verifikasi</div>
<div style="font-size:11px;color:var(--text-3);">Survey belum diverifikasi oleh atasan</div>
</div>
<button class="btn btn-success btn-sm" onclick="verifySurvey('${s.id}')">Verifikasi</button>
</div>
`}
`;
openModal('survey-detail-modal');
}
function verifySurvey(surveyId) {
const s = APP_STATE.surveys.find(sv => sv.id === surveyId);
if (!s) return;
s.status = 'verified';
s.verifiedBy = 'Admin KSP';
showToast('Survey Diverifikasi', `${surveyId} berhasil diverifikasi`, 'success');
closeModal('survey-detail-modal');
renderSurveyPage();
}
// ─── NEW SURVEY MODAL ─────────────────────────────────────────────
function openNewSurveyModal(prefillIssueId) {
// Populate issue select
const issueSelect = document.getElementById('ns-issue');
const openIssues = APP_STATE.issues.filter(i => i.status !== 'resolved' && i.status !== 'closed');
issueSelect.innerHTML = '<option value="">Pilih Issue Terkait</option>' +
openIssues.map(i => `<option value="${i.id}" ${i.id === prefillIssueId ? 'selected' : ''}>${i.id}${i.judul.slice(0,40)}...</option>`).join('');
// Set today's date
const today = new Date().toISOString().split('T')[0];
document.getElementById('ns-tanggal').value = today;
if (prefillIssueId) {
const issue = APP_STATE.issues.find(i => i.id === prefillIssueId);
if (issue) {
document.getElementById('ns-program').value = issue.programId;
document.getElementById('ns-daerah').value = issue.daerah;
}
}
openModal('new-survey-modal');
}
function submitNewSurvey(e) {
e.preventDefault();
const issueId = document.getElementById('ns-issue').value;
const issue = APP_STATE.issues.find(i => i.id === issueId);
const id = `SRV-${String(APP_STATE.surveys.length + 1).padStart(3,'0')}`;
const now = new Date().toISOString().split('T')[0];
const ts = new Date().toLocaleTimeString('id-ID') + ' WIB';
const newSurvey = {
id, issueId: issueId || null,
programId: document.getElementById('ns-program').value,
namaOfficer: document.getElementById('ns-officer').value,
jabatan: document.getElementById('ns-jabatan').value,
tanggalSurvey:document.getElementById('ns-tanggal').value,
daerah: document.getElementById('ns-daerah').value,
provinsi: issue?.provinsi || '',
status: 'submitted',
temuanUtama: document.getElementById('ns-temuan').value,
rekomendasi: document.getElementById('ns-rekomendasi').value,
skorKeparahan: null, verifiedBy: null, createdAt: now
};
APP_STATE.surveys.push(newSurvey);
// Update issue if linked
if (issue && !issue.surveyIds.includes(id)) {
issue.surveyIds.push(id);
issue.timeline.push({ time: ts, action: `Survey lapangan dibuat oleh ${newSurvey.namaOfficer}`, actor: newSurvey.namaOfficer, type: 'survey' });
issue.status = 'in_progress';
}
document.getElementById('new-survey-form').reset();
closeModal('new-survey-modal');
updateIssuesBadge();
if (APP_STATE.currentPage === 'survey') renderSurveyPage();
showToast('Survey Dibuat', `${id} berhasil diajukan`, 'success');
}