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

349 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ═══════════════════════════════════════════════════════════════════
// MAIN APPLICATION — Router, State, Clock, Shared Utilities
// ═══════════════════════════════════════════════════════════════════
// ─── INITIALIZATION ──────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initTheme();
initClock();
updateIssuesBadge();
renderNotifications();
navigateTo('dashboard');
// Close dropdowns on outside click
document.addEventListener('click', (e) => {
if (!e.target.closest('.topbar-actions-area')) {
document.getElementById('notif-dropdown').classList.add('hidden');
}
});
});
// ─── ROUTER ──────────────────────────────────────────────────────
const PAGES = {
dashboard: { title: 'Overview Nasional', sub: 'Pemantauan real-time seluruh program strategis pemerintah', render: renderDashboardPage },
programs: { title: 'Detail Program', sub: 'Capaian dan status per program strategis pemerintah', render: renderProgramsPage },
issues: { title: 'Manajemen Issue', sub: 'Pantau, tindak lanjut, dan selesaikan issue di setiap daerah', render: renderIssuesPage },
survey: { title: 'Survey Lapangan', sub: 'Hasil verifikasi dan temuan survei lapangan per daerah', render: renderSurveyPage },
escalasi: { title: 'Eskalasi Issue', sub: 'Pipeline eskalasi berjenjang: Daerah → KSP → Kementerian → APH', render: renderEscalasiPage },
integration: { title: 'Arsitektur Platform Terintegrasi', sub: 'Hubungan sistem pemetaan, manajemen survei, dan tracking logistik KSP', render: renderIntegrationPage }
};
function navigateTo(page) {
APP_STATE.currentPage = page;
// Update nav items
document.querySelectorAll('.nav-item[id^="nav-"]').forEach(el => {
if (!el.id.startsWith('nav-filter')) el.classList.remove('active');
});
const navEl = document.getElementById(`nav-${page}`);
if (navEl) navEl.classList.add('active');
// Update topbar
const cfg = PAGES[page];
document.getElementById('topbar-title').textContent = cfg.title;
document.getElementById('topbar-sub').textContent = cfg.sub;
// Switch view
document.querySelectorAll('.page-view').forEach(v => v.classList.remove('active'));
document.getElementById(`view-${page}`).classList.add('active');
// Render page
cfg.render();
}
function setNavProgramFilter(programId, el) {
APP_STATE.currentProgram = programId;
// Remove active state from all sidebar filter items
document.querySelectorAll('[id^="nav-filter-"]').forEach(e => e.classList.remove('active'));
if (el) el.classList.add('active');
if (programId === 'all') {
// "Semua Program" → kembali ke Overview Nasional
navigateTo('dashboard');
} else {
// Program spesifik → buka halaman Detail Program dengan tab yang sesuai
_activeProgTab = programId; // set tab aktif di programs.js (global scope)
navigateTo('programs');
}
}
// ─── CLOCK ───────────────────────────────────────────────────────
function initClock() {
updateClock();
setInterval(updateClock, 1000);
}
function updateClock() {
const now = new Date();
const time = now.toLocaleTimeString('id-ID', { timeZone: 'Asia/Jakarta', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
const jkt = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Jakarta' }));
const days = ['Minggu','Senin','Selasa','Rabu','Kamis','Jumat','Sabtu'];
const months = ['Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agt','Sep','Okt','Nov','Des'];
document.getElementById('clock-time').textContent = time + ' WIB';
document.getElementById('clock-date').textContent = `${days[jkt.getDay()]}, ${jkt.getDate()} ${months[jkt.getMonth()]} ${jkt.getFullYear()}`;
}
// ─── MODAL CONTROLS ──────────────────────────────────────────────
function openModal(id) { document.getElementById(id).classList.add('open'); }
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
// ─── NOTIFICATIONS ───────────────────────────────────────────────
function toggleNotifDropdown() {
document.getElementById('notif-dropdown').classList.toggle('hidden');
}
function updateIssuesBadge() {
const openCount = APP_STATE.issues.filter(i => i.status === 'open' || i.status === 'in_progress').length;
const badge = document.getElementById('issues-badge');
badge.textContent = openCount;
badge.style.display = openCount > 0 ? 'block' : 'none';
}
function renderNotifications() {
const notifs = APP_STATE.notifications;
const unread = notifs.filter(n => !n.read).length;
document.getElementById('notif-dot').style.display = unread > 0 ? 'block' : 'none';
const list = document.getElementById('notif-list');
if (!notifs.length) { list.innerHTML = '<div style="padding:16px;text-align:center;font-size:12px;color:var(--text-3);">Tidak ada notifikasi</div>'; return; }
const typeColor = { critical: 'var(--red)', high: 'var(--orange)', info: 'var(--blue)', success: 'var(--green)' };
list.innerHTML = notifs.map(n => `
<div class="notif-item ${n.read ? '' : 'unread'}" onclick="markNotifRead('${n.id}')">
<div class="notif-dot-indicator" style="background:${typeColor[n.type] || 'var(--border-mid)'}"></div>
<div style="flex:1;">
<div class="notif-msg">${n.msg}</div>
<div class="notif-time">${n.time}</div>
</div>
</div>
`).join('');
}
function markNotifRead(id) {
const n = APP_STATE.notifications.find(n => n.id === id);
if (n) n.read = true;
renderNotifications();
}
function markAllRead() {
APP_STATE.notifications.forEach(n => n.read = true);
renderNotifications();
}
// ─── TOAST ───────────────────────────────────────────────────────
function showToast(title, msg, type = 'info') {
const icons = { info: '️', success: '✅', warning: '⚠️', error: '❌' };
const el = document.createElement('div');
el.className = 'toast';
el.innerHTML = `<div class="toast-icon">${icons[type]}</div><div class="toast-body"><div class="toast-title">${title}</div>${msg ? `<div class="toast-msg">${msg}</div>` : ''}</div>`;
document.getElementById('toast-container').appendChild(el);
requestAnimationFrame(() => el.classList.add('show'));
setTimeout(() => { el.classList.remove('show'); setTimeout(() => el.remove(), 300); }, 3500);
}
// ─── ISSUE HELPERS ────────────────────────────────────────────────
function getSevBadge(sev) {
const m = { critical:'Kritis', high:'Tinggi', medium:'Sedang', low:'Rendah' };
return `<span class="sev-${sev}">${m[sev] || sev}</span>`;
}
function getStatBadge(st) {
const m = { open:'Terbuka', in_progress:'Diproses', resolved:'Selesai', closed:'Ditutup' };
return `<span class="stat-${st}">${m[st] || st}</span>`;
}
function getProgramBadge(pid) {
const p = getProgramById(pid);
if (!p) return '';
return `<span class="badge badge-${p.badge}">${p.icon} ${p.shortName}</span>`;
}
function getTlIcon(type) {
return { created:'🔴', assigned:'🔵', verified:'🟡', survey:'🟣', escalated:'🟠', resolved:'✅' }[type] || '⚪';
}
function formatDate(ds) {
if (!ds) return '—';
const d = new Date(ds);
return d.toLocaleDateString('id-ID', { day:'numeric', month:'short', year:'numeric' });
}
// ─── OPEN ISSUE DETAIL ────────────────────────────────────────────
function openIssueDetail(issueId) {
const issue = APP_STATE.issues.find(i => i.id === issueId);
if (!issue) return;
const prog = getProgramById(issue.programId);
document.getElementById('md-issue-id').textContent = issue.id;
document.getElementById('md-issue-title').textContent = issue.judul;
const surveys = APP_STATE.surveys.filter(s => issue.surveyIds.includes(s.id));
const escalations = APP_STATE.escalations.filter(e => issue.escalationIds.includes(e.id));
document.getElementById('md-issue-body').innerHTML = `
<div class="flex gap-2 items-center mb-3" style="flex-wrap:wrap;">
${getProgramBadge(issue.programId)}
${getSevBadge(issue.severity)}
${getStatBadge(issue.status)}
<span class="text-sm text-muted">📍 ${issue.daerah}, ${issue.provinsi}</span>
<span class="text-sm text-muted">📅 ${formatDate(issue.createdAt)}</span>
</div>
<div class="card card-p mb-3" style="background:var(--bg);">
<div style="font-size:12px;color:var(--text-2);line-height:1.7;">${issue.deskripsi}</div>
</div>
<div class="flex gap-2 mb-3 text-sm">
<span class="text-muted">👤 Ditugaskan ke:</span>
<span style="color:var(--text-1);font-weight:500;">${issue.assignee || 'Belum ditugaskan'}</span>
</div>
${escalations.length ? `
<div class="mb-3">
<div style="font-size:11px;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px;">Eskalasi</div>
${escalations.map(e => `
<div class="card card-p-sm mb-2" style="border-left:3px solid ${ESCALATION_LEVELS[e.level]?.color || 'var(--border)'};">
<div class="flex justify-between items-center mb-1">
<span style="font-size:12px;font-weight:600;color:var(--text-1);">${e.levelName}</span>
<span class="badge ${e.status==='resolved'?'badge-green':e.status==='approved'?'badge-blue':'badge-amber'}">${e.status==='resolved'?'Selesai':e.status==='approved'?'Disetujui':'Menunggu'}</span>
</div>
<div style="font-size:11px;color:var(--text-2);">Ditujukan ke: <strong>${e.assignTo}</strong></div>
<div style="font-size:11px;color:var(--text-3);margin-top:4px;">${e.notes}</div>
</div>
`).join('')}
</div>` : ''}
${surveys.length ? `
<div class="mb-3">
<div style="font-size:11px;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px;">Survey Lapangan Terkait</div>
${surveys.map(s => `
<div class="card card-p-sm mb-2">
<div class="flex justify-between items-center mb-1">
<span style="font-size:12px;font-weight:600;color:var(--text-1);">${s.namaOfficer}${s.tanggalSurvey}</span>
<span class="status-${s.status}">${{draft:'Draft',submitted:'Diajukan',verified:'Diverifikasi'}[s.status]}</span>
</div>
<div style="font-size:11px;color:var(--text-2);margin-top:4px;">${s.temuanUtama}</div>
${s.rekomendasi ? `<div style="font-size:11px;color:var(--amber);margin-top:6px;">💡 ${s.rekomendasi}</div>` : ''}
</div>
`).join('')}
</div>` : ''}
<div>
<div style="font-size:11px;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:10px;">Riwayat Aktivitas</div>
<div class="timeline">
${issue.timeline.map(t => `
<div class="tl-item">
<div class="tl-dot-wrap">
<div class="tl-dot ${t.type}">${getTlIcon(t.type)}</div>
<div class="tl-line"></div>
</div>
<div class="tl-content">
<div class="tl-action">${t.action}</div>
<div class="tl-meta"><span>${t.actor}</span><span>·</span><span>${t.time}</span></div>
</div>
</div>
`).join('')}
</div>
</div>
`;
const footer = document.getElementById('md-issue-footer');
let actionBtns = '';
if (issue.status !== 'resolved' && issue.status !== 'closed') {
actionBtns = `
<button class="btn btn-secondary btn-sm" onclick="issueAction('${issue.id}','assign_survey')">📋 Buat Survey</button>
<button class="btn btn-secondary btn-sm" onclick="issueAction('${issue.id}','escalate')">🔺 Eskalasi</button>
<button class="btn btn-success btn-sm" onclick="issueAction('${issue.id}','resolve')">✅ Selesaikan</button>
`;
}
footer.innerHTML = `<button class="btn btn-secondary" onclick="closeModal('issue-detail-modal')">Tutup</button>${actionBtns}`;
openModal('issue-detail-modal');
}
function issueAction(issueId, action) {
const issue = APP_STATE.issues.find(i => i.id === issueId);
if (!issue) return;
const ts = new Date().toLocaleTimeString('id-ID') + ' WIB';
if (action === 'assign_survey') {
closeModal('issue-detail-modal');
openNewSurveyModal(issueId);
return;
}
if (action === 'escalate') {
const levels = ['L1','L2','L3','L4'];
const lastEsc = APP_STATE.escalations.filter(e => issue.escalationIds.includes(e.id)).pop();
const nextLevel = lastEsc ? levels[Math.min(levels.indexOf(lastEsc.level)+1, 3)] : 'L2';
const newEsc = {
id: `ESC-${Date.now().toString().slice(-4)}`, issueId: issue.id, programId: issue.programId,
level: nextLevel, levelName: ESCALATION_LEVELS[nextLevel].label,
assignTo: 'Tim KSP Pengawasan', notes: `Eskalasi dari issue ${issue.id}`,
status: 'pending', createdBy: 'Admin KSP',
createdAt: new Date().toISOString().split('T')[0], resolvedAt: null,
daerah: issue.daerah, provinsi: issue.provinsi
};
APP_STATE.escalations.push(newEsc);
issue.escalationIds.push(newEsc.id);
issue.timeline.push({ time: ts, action: `Eskalasi ke ${nextLevel} (${ESCALATION_LEVELS[nextLevel].label}) diajukan`, actor: 'Admin KSP', type: 'escalated' });
issue.status = 'in_progress';
showToast('Eskalasi Dibuat', `Issue ${issue.id} dieksalasi ke ${nextLevel}`, 'success');
}
if (action === 'resolve') {
issue.status = 'resolved';
issue.updatedAt = new Date().toISOString().split('T')[0];
issue.timeline.push({ time: ts, action: 'Issue dinyatakan selesai oleh Admin KSP', actor: 'Admin KSP', type: 'resolved' });
// Update location issue count
const loc = APP_STATE.locs.find(l => l.name === issue.daerah);
if (loc && loc.issueCount > 0) { loc.issueCount--; if (issue.severity === 'critical' && loc.criticalCount > 0) loc.criticalCount--; }
showToast('Issue Diselesaikan', `${issue.id} berhasil ditandai selesai`, 'success');
}
closeModal('issue-detail-modal');
updateIssuesBadge();
if (APP_STATE.currentPage === 'issues') renderIssuesPage();
if (APP_STATE.currentPage === 'escalasi') renderEscalasiPage();
}
// ─── NEW ISSUE FORM ───────────────────────────────────────────────
function openNewIssueModal() { openModal('new-issue-modal'); }
function submitNewIssue(e) {
e.preventDefault();
const id = `ISS-${String(APP_STATE.issues.length + 1).padStart(3, '0')}`;
const now = new Date().toISOString().split('T')[0];
const ts = new Date().toLocaleTimeString('id-ID') + ' WIB';
const issue = {
id, programId: document.getElementById('ni-program').value,
daerah: document.getElementById('ni-daerah').value,
provinsi: document.getElementById('ni-provinsi').value,
judul: document.getElementById('ni-judul').value,
deskripsi: document.getElementById('ni-deskripsi').value,
severity: document.getElementById('ni-severity').value,
status: 'open',
assignee: document.getElementById('ni-assignee').value || 'Belum ditugaskan',
createdAt: now, updatedAt: now,
surveyIds: [], escalationIds: [],
timeline: [{ time: ts, action: 'Issue baru dibuat oleh Admin KSP', actor: 'Admin KSP', type: 'created' }]
};
APP_STATE.issues.push(issue);
APP_STATE.notifications.unshift({ id: `N${Date.now()}`, type: issue.severity === 'critical' ? 'critical' : 'high', msg: `Issue baru: ${issue.judul}`, time: 'Baru saja', read: false });
document.getElementById('new-issue-form').reset();
closeModal('new-issue-modal');
updateIssuesBadge();
renderNotifications();
if (APP_STATE.currentPage === 'issues') renderIssuesPage();
showToast('Issue Ditambahkan', `${id} berhasil dibuat`, 'success');
}
// ─── THEME TOGGLE ─────────────────────────────────────────────────
function initTheme() {
const saved = localStorage.getItem('ksp-theme');
if (saved === 'light') {
document.body.classList.add('light-theme');
const btn = document.getElementById('theme-toggle-btn');
if (btn) btn.textContent = '🌙';
}
}
function toggleTheme() {
const isLight = document.body.classList.toggle('light-theme');
const btn = document.getElementById('theme-toggle-btn');
if (btn) {
btn.textContent = isLight ? '🌙' : '☀️';
}
localStorage.setItem('ksp-theme', isLight ? 'light' : 'dark');
showToast('Tema Diubah', `Berhasil beralih ke Mode ${isLight ? 'Terang' : 'Gelap'}`, 'success');
// Reload map tiles if dashboard map is active
if (typeof updateMapTileLayers === 'function' && APP_STATE.mapInstance) {
updateMapTileLayers();
}
}