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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user