349 lines
17 KiB
JavaScript
349 lines
17 KiB
JavaScript
// ═══════════════════════════════════════════════════════════════════
|
||
// 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();
|
||
}
|
||
}
|
||
|