first commit

This commit is contained in:
2026-09-16 10:38:01 +07:00
commit c20afe6543
10 changed files with 3307 additions and 0 deletions
+348
View File
@@ -0,0 +1,348 @@
// ═══════════════════════════════════════════════════════════════════
// 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();
}
}