first commit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// KSP NATIONAL MONITORING DASHBOARD — DATA LAYER (DUMMY DATA)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── PROGRAM DEFINITIONS ────────────────────────────────────────────
|
||||
const PROGRAMS = [
|
||||
{
|
||||
id: 'mbg', name: 'Makan Bergizi Gratis', shortName: 'MBG', icon: '🍱',
|
||||
color: '#22c55e', badge: 'green',
|
||||
description: 'Pemberian makanan bergizi gratis untuk siswa SD–SMA seluruh Indonesia',
|
||||
budget: 71000, realisasi: 38420, budgetUnit: 'Miliar Rp',
|
||||
target: 82500000, capaian: 64300000, targetUnit: 'penerima',
|
||||
ministry: 'Badan Gizi Nasional', startDate: '2025-01-06', status: 'active',
|
||||
kpiLabel: ['Sekolah Aktif', 'Porsi/Hari', 'Kabupaten/Kota'],
|
||||
kpiValue: ['284.220', '17,4 Juta', '514']
|
||||
},
|
||||
{
|
||||
id: 'koperasi', name: 'Koperasi Desa Merah Putih', shortName: 'Koperasi', icon: '🏪',
|
||||
color: '#3b82f6', badge: 'blue',
|
||||
description: 'Pembentukan dan penguatan koperasi di setiap desa/kelurahan Indonesia',
|
||||
budget: 40000, realisasi: 18600, budgetUnit: 'Miliar Rp',
|
||||
target: 80000, capaian: 52400, targetUnit: 'unit koperasi',
|
||||
ministry: 'KemenKopUKM', startDate: '2025-03-01', status: 'active',
|
||||
kpiLabel: ['Unit Aktif', 'Total Anggota', 'Modal Tersalur'],
|
||||
kpiValue: ['52.400', '6,2 Juta', 'Rp 18,6T']
|
||||
},
|
||||
{
|
||||
id: 'psn', name: 'Proyek Strategis Nasional', shortName: 'PSN', icon: '🏗️',
|
||||
color: '#f59e0b', badge: 'amber',
|
||||
description: 'Percepatan pembangunan infrastruktur strategis nasional lintas sektor',
|
||||
budget: 185000, realisasi: 112000, budgetUnit: 'Miliar Rp',
|
||||
target: 218, capaian: 142, targetUnit: 'proyek',
|
||||
ministry: 'Kemenko Perekonomian', startDate: '2024-10-20', status: 'active',
|
||||
kpiLabel: ['Proyek Selesai', 'Proyek Berjalan', 'Investasi Masuk'],
|
||||
kpiValue: ['142', '58', 'Rp 892T']
|
||||
},
|
||||
{
|
||||
id: 'hilirisasi', name: 'Hilirisasi Industri', shortName: 'Hilirisasi', icon: '⚙️',
|
||||
color: '#a855f7', badge: 'purple',
|
||||
description: 'Transformasi nilai tambah komoditas dalam negeri melalui industrialisasi',
|
||||
budget: 68000, realisasi: 29800, budgetUnit: 'Miliar Rp',
|
||||
target: 21, capaian: 11, targetUnit: 'komoditas',
|
||||
ministry: 'Kemenperin', startDate: '2025-01-01', status: 'active',
|
||||
kpiLabel: ['Komoditas Aktif', 'Pabrik Baru', 'Nilai Ekspor'],
|
||||
kpiValue: ['11', '428', 'Rp 142T']
|
||||
},
|
||||
{
|
||||
id: 'rumah', name: 'Rumah Rakyat', shortName: 'Rumah', icon: '🏠',
|
||||
color: '#f97316', badge: 'orange',
|
||||
description: 'Program 3 juta rumah untuk Masyarakat Berpenghasilan Rendah (MBR)',
|
||||
budget: 53000, realisasi: 21400, budgetUnit: 'Miliar Rp',
|
||||
target: 3000000, capaian: 842000, targetUnit: 'unit rumah',
|
||||
ministry: 'Kemen PUPR', startDate: '2025-02-01', status: 'active',
|
||||
kpiLabel: ['Unit Selesai', 'Sedang Dibangun', 'KPR Disetujui'],
|
||||
kpiValue: ['842.000', '1,24 Juta', '620.000']
|
||||
}
|
||||
];
|
||||
|
||||
// ─── LOCATIONS ───────────────────────────────────────────────────────
|
||||
const INITIAL_LOCS = [
|
||||
{
|
||||
id: 0, name: 'Medan', province: 'Sumatra Utara', lat: 3.595, lng: 98.672,
|
||||
programs: ['mbg', 'koperasi'],
|
||||
mbg: { schools: 84, meals: 62400, coverage: 94, supplier: 'CV Berkah Pangan Utara', status: 'operational', students: 58200 },
|
||||
koperasi: { units: 42, members: 8200, capital: 6.8, activeLoans: 312, status: 'active' },
|
||||
psn: null, hilirisasi: null, rumah: null,
|
||||
issueCount: 1, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 1, name: 'Pekanbaru', province: 'Riau', lat: 0.507, lng: 101.448,
|
||||
programs: ['mbg'],
|
||||
mbg: { schools: 42, meals: 28900, coverage: 88, supplier: 'PT Mitra Gizi Riau', status: 'operational', students: 24600 },
|
||||
koperasi: null, psn: null, hilirisasi: null, rumah: null,
|
||||
issueCount: 0, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 2, name: 'Palembang', province: 'Sumatra Selatan', lat: -2.990, lng: 104.756,
|
||||
programs: ['mbg', 'koperasi', 'psn'],
|
||||
mbg: { schools: 128, meals: 96000, coverage: 91, supplier: 'Koperasi Sriwijaya Pangan', status: 'operational', students: 82000 },
|
||||
koperasi: { units: 68, members: 14200, capital: 11.2, activeLoans: 520, status: 'active' },
|
||||
psn: { projectName: 'Jalan Tol Palembang–Betung', progress: 72, contractor: 'PT Waskita Karya', value: 4800 },
|
||||
hilirisasi: null, rumah: null, issueCount: 0, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 3, name: 'Samarinda', province: 'Kalimantan Timur', lat: -0.502, lng: 117.153,
|
||||
programs: ['koperasi'],
|
||||
mbg: null,
|
||||
koperasi: { units: 28, members: 4820, capital: 6.8, activeLoans: 312, status: 'warning' },
|
||||
psn: null, hilirisasi: null, rumah: null, issueCount: 2, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 4, name: 'Balikpapan', province: 'Kalimantan Timur', lat: -1.268, lng: 116.829,
|
||||
programs: ['koperasi', 'hilirisasi', 'psn'],
|
||||
mbg: null,
|
||||
koperasi: { units: 18, members: 2340, capital: 3.2, activeLoans: 148, status: 'active' },
|
||||
psn: { projectName: 'Kawasan Industri Kariangau', progress: 58, contractor: 'PT PP (Persero)', value: 12400 },
|
||||
hilirisasi: { commodity: 'Batu Bara', stage: 'Coal to Chemical', progress: 45, value: 8200 },
|
||||
rumah: null, issueCount: 0, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 5, name: 'Makassar', province: 'Sulawesi Selatan', lat: -5.147, lng: 119.432,
|
||||
programs: ['mbg', 'koperasi', 'psn'],
|
||||
mbg: { schools: 156, meals: 118000, coverage: 93, supplier: 'PT Sulsel Gizi Prima', status: 'operational', students: 104000 },
|
||||
koperasi: { units: 52, members: 6200, capital: 8.4, activeLoans: 480, status: 'active' },
|
||||
psn: { projectName: 'Makassar New Port Phase 2', progress: 84, contractor: 'PT Pelindo IV', value: 9600 },
|
||||
hilirisasi: null, rumah: null, issueCount: 0, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 6, name: 'Surabaya', province: 'Jawa Timur', lat: -7.250, lng: 112.750,
|
||||
programs: ['mbg', 'koperasi', 'psn', 'rumah'],
|
||||
mbg: { schools: 218, meals: 182000, coverage: 98, supplier: 'PT Gizi Jatim', status: 'operational', students: 162000 },
|
||||
koperasi: { units: 96, members: 8420, capital: 12.4, activeLoans: 640, status: 'active' },
|
||||
psn: { projectName: 'LRT Surabaya', progress: 38, contractor: 'PT Adhi Karya', value: 31000 },
|
||||
rumah: { units: 12400, completed: 8200, progress: 66, contractor: 'Perum Perumnas', budget: 1240 },
|
||||
hilirisasi: null, issueCount: 2, criticalCount: 1
|
||||
},
|
||||
{
|
||||
id: 7, name: 'Bandung', province: 'Jawa Barat', lat: -6.914, lng: 107.609,
|
||||
programs: ['mbg', 'koperasi', 'rumah'],
|
||||
mbg: { schools: 310, meals: 245000, coverage: 99, supplier: 'Koperasi Jabar Pangan', status: 'operational', students: 220000 },
|
||||
koperasi: { units: 128, members: 11200, capital: 18.6, activeLoans: 890, status: 'active' },
|
||||
rumah: { units: 18600, completed: 14800, progress: 80, contractor: 'PT Ciputra', budget: 1860 },
|
||||
psn: null, hilirisasi: null, issueCount: 0, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 8, name: 'Semarang', province: 'Jawa Tengah', lat: -6.967, lng: 110.419,
|
||||
programs: ['mbg', 'koperasi'],
|
||||
mbg: { schools: 196, meals: 158000, coverage: 97, supplier: 'UD Pangan Maju Jateng', status: 'operational', students: 142000 },
|
||||
koperasi: { units: 84, members: 6800, capital: 9.1, activeLoans: 520, status: 'active' },
|
||||
psn: null, hilirisasi: null, rumah: null, issueCount: 0, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 9, name: 'Yogyakarta', province: 'DI Yogyakarta', lat: -7.797, lng: 110.370,
|
||||
programs: ['mbg', 'koperasi'],
|
||||
mbg: { schools: 144, meals: 104000, coverage: 99, supplier: 'Koperasi UGM Pangan', status: 'operational', students: 96000 },
|
||||
koperasi: { units: 62, members: 4200, capital: 6.0, activeLoans: 320, status: 'active' },
|
||||
psn: null, hilirisasi: null, rumah: null, issueCount: 0, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 10, name: 'Denpasar', province: 'Bali', lat: -8.670, lng: 115.213,
|
||||
programs: ['mbg', 'koperasi', 'psn'],
|
||||
mbg: { schools: 88, meals: 62000, coverage: 96, supplier: 'UD Boga Bali', status: 'operational', students: 56000 },
|
||||
koperasi: { units: 36, members: 3600, capital: 5.2, activeLoans: 240, status: 'active' },
|
||||
psn: { projectName: 'Shortcut Mengwitani–Singaraja', progress: 91, contractor: 'PT Hutama Karya', value: 2800 },
|
||||
hilirisasi: null, rumah: null, issueCount: 0, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 11, name: 'Palu', province: 'Sulawesi Tengah', lat: -0.900, lng: 119.877,
|
||||
programs: ['koperasi'],
|
||||
mbg: null,
|
||||
koperasi: { units: 14, members: 1840, capital: 2.1, activeLoans: 0, status: 'inactive' },
|
||||
psn: null, hilirisasi: null, rumah: null, issueCount: 1, criticalCount: 1
|
||||
},
|
||||
{
|
||||
id: 12, name: 'Manokwari', province: 'Papua Barat', lat: -0.861, lng: 134.062,
|
||||
programs: ['mbg'],
|
||||
mbg: { schools: 28, meals: 3200, coverage: 74, supplier: 'Vendor Tidak Terverifikasi', status: 'under_review', students: 18400 },
|
||||
koperasi: null, psn: null, hilirisasi: null, rumah: null, issueCount: 1, criticalCount: 1
|
||||
},
|
||||
{
|
||||
id: 13, name: 'Kupang', province: 'NTT', lat: -10.174, lng: 123.607,
|
||||
programs: ['mbg', 'rumah'],
|
||||
mbg: { schools: 64, meals: 42000, coverage: 82, supplier: 'PT NTT Sejahtera', status: 'under_review', students: 32000 },
|
||||
rumah: { units: 4200, completed: 1800, progress: 43, contractor: 'CV NTT Bangun', budget: 420 },
|
||||
koperasi: null, psn: null, hilirisasi: null, issueCount: 2, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 14, name: 'Jayapura', province: 'Papua', lat: -2.538, lng: 140.718,
|
||||
programs: ['mbg', 'koperasi', 'psn'],
|
||||
mbg: { schools: 52, meals: 36000, coverage: 78, supplier: 'Koperasi Papua Sehat', status: 'operational', students: 28000 },
|
||||
koperasi: { units: 22, members: 1820, capital: 2.6, activeLoans: 88, status: 'active' },
|
||||
psn: { projectName: 'Trans Papua Jalan Nasional', progress: 28, contractor: 'PT Brantas Abipraya', value: 18600 },
|
||||
hilirisasi: null, rumah: null, issueCount: 0, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 15, name: 'Pontianak', province: 'Kalimantan Barat', lat: -0.026, lng: 109.343,
|
||||
programs: ['koperasi', 'hilirisasi'],
|
||||
mbg: null,
|
||||
koperasi: { units: 32, members: 3120, capital: 4.0, activeLoans: 198, status: 'active' },
|
||||
hilirisasi: { commodity: 'Kelapa Sawit', stage: 'Oleochemical', progress: 68, value: 6400 },
|
||||
psn: null, rumah: null, issueCount: 1, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 16, name: 'Ambon', province: 'Maluku', lat: -3.695, lng: 128.181,
|
||||
programs: ['mbg'],
|
||||
mbg: { schools: 38, meals: 24500, coverage: 85, supplier: 'PT Maluku Pangan Mandiri', status: 'operational', students: 21000 },
|
||||
koperasi: null, psn: null, hilirisasi: null, rumah: null, issueCount: 0, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 17, name: 'Mataram', province: 'NTB', lat: -8.582, lng: 116.117,
|
||||
programs: ['mbg', 'koperasi'],
|
||||
mbg: { schools: 76, meals: 54000, coverage: 92, supplier: 'Koperasi Lombok Sejahtera', status: 'operational', students: 49000 },
|
||||
koperasi: { units: 44, members: 2850, capital: 3.8, activeLoans: 154, status: 'active' },
|
||||
psn: null, hilirisasi: null, rumah: null, issueCount: 0, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 18, name: 'Manado', province: 'Sulawesi Utara', lat: 1.475, lng: 124.843,
|
||||
programs: ['koperasi', 'hilirisasi'],
|
||||
mbg: null,
|
||||
koperasi: { units: 24, members: 1950, capital: 2.9, activeLoans: 112, status: 'active' },
|
||||
hilirisasi: { commodity: 'Nikel', stage: 'Nickel Matte Processing', progress: 72, value: 14200 },
|
||||
psn: null, rumah: null, issueCount: 0, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 19, name: 'Padang', province: 'Sumatra Barat', lat: -0.947, lng: 100.417,
|
||||
programs: ['mbg'],
|
||||
mbg: { schools: 98, meals: 72000, coverage: 96, supplier: 'PT Minang Gizi Utama', status: 'operational', students: 67000 },
|
||||
koperasi: null, psn: null, hilirisasi: null, rumah: null, issueCount: 0, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 20, name: 'Solo', province: 'Jawa Tengah', lat: -7.576, lng: 110.824,
|
||||
programs: ['mbg', 'rumah'],
|
||||
mbg: { schools: 112, meals: 89000, coverage: 97, supplier: 'CV Solo Gizi Prima', status: 'operational', students: 81200 },
|
||||
rumah: { units: 8400, completed: 6200, progress: 74, contractor: 'Perum Perumnas', budget: 840 },
|
||||
koperasi: null, psn: null, hilirisasi: null, issueCount: 0, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 21, name: 'Malang', province: 'Jawa Timur', lat: -7.983, lng: 112.621,
|
||||
programs: ['mbg', 'koperasi'],
|
||||
mbg: { schools: 168, meals: 124000, coverage: 95, supplier: 'PT Malang Sejahtera', status: 'operational', students: 112000 },
|
||||
koperasi: { units: 72, members: 5600, capital: 7.8, activeLoans: 420, status: 'active' },
|
||||
psn: null, hilirisasi: null, rumah: null, issueCount: 1, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 22, name: 'Banjarmasin', province: 'Kalimantan Selatan', lat: -3.320, lng: 114.592,
|
||||
programs: ['mbg', 'koperasi', 'hilirisasi'],
|
||||
mbg: { schools: 88, meals: 64000, coverage: 90, supplier: 'PT Kalsel Pangan', status: 'operational', students: 58000 },
|
||||
koperasi: { units: 38, members: 3400, capital: 4.6, activeLoans: 210, status: 'active' },
|
||||
hilirisasi: { commodity: 'Batu Bara', stage: 'Coal Gasification', progress: 32, value: 5800 },
|
||||
psn: null, rumah: null, issueCount: 1, criticalCount: 1
|
||||
},
|
||||
{
|
||||
id: 23, name: 'Bengkulu', province: 'Bengkulu', lat: -3.800, lng: 102.267,
|
||||
programs: ['mbg', 'rumah'],
|
||||
mbg: { schools: 54, meals: 38000, coverage: 84, supplier: 'Koperasi Bengkulu Mandiri', status: 'operational', students: 34000 },
|
||||
rumah: { units: 2800, completed: 980, progress: 35, contractor: 'CV Bumi Bengkulu', budget: 280 },
|
||||
koperasi: null, psn: null, hilirisasi: null, issueCount: 1, criticalCount: 0
|
||||
},
|
||||
{
|
||||
id: 24, name: 'Banda Aceh', province: 'Aceh', lat: 5.548, lng: 95.323,
|
||||
programs: ['mbg', 'koperasi'],
|
||||
mbg: { schools: 72, meals: 52000, coverage: 88, supplier: 'Koperasi Aceh Sejahtera', status: 'operational', students: 48000 },
|
||||
koperasi: { units: 36, members: 2800, capital: 3.4, activeLoans: 162, status: 'active' },
|
||||
psn: null, hilirisasi: null, rumah: null, issueCount: 0, criticalCount: 0
|
||||
}
|
||||
];
|
||||
|
||||
// ─── ISSUES ─────────────────────────────────────────────────────────
|
||||
const INITIAL_ISSUES = [
|
||||
{
|
||||
id: 'ISS-001', programId: 'mbg',
|
||||
daerah: 'Manokwari', provinsi: 'Papua Barat',
|
||||
judul: 'Ketidaksesuaian Laporan Distribusi MBG — Vendor Tidak Terverifikasi',
|
||||
deskripsi: '3.200 porsi makanan dilaporkan terdistribusi namun tidak ada satu pun konfirmasi pengiriman dari pihak vendor. Data harian sistem SIMBG menunjukkan gap yang signifikan. Vendor yang terdaftar tidak ditemukan di database resmi BGN.',
|
||||
severity: 'critical', status: 'in_progress',
|
||||
assignee: 'Tim Verifikasi KSP — Region Timur',
|
||||
createdAt: '2026-06-15', updatedAt: '2026-06-22',
|
||||
surveyIds: ['SRV-001'], escalationIds: ['ESC-001'],
|
||||
timeline: [
|
||||
{ time: '2026-06-15 08:00', action: 'Issue terdeteksi oleh sistem monitoring SIMBG', actor: 'Sistem Otomatis', type: 'created' },
|
||||
{ time: '2026-06-16 09:30', action: 'Issue diverifikasi manual oleh analis senior', actor: 'Budi Santoso', type: 'verified' },
|
||||
{ time: '2026-06-17 14:00', action: 'Tim investigasi lapangan ditugaskan ke Manokwari', actor: 'KSP Div. Pengawasan', type: 'assigned' },
|
||||
{ time: '2026-06-18 10:00', action: 'Survey lapangan dilaksanakan — temuan kritis', actor: 'Hendra Wijaya', type: 'survey' },
|
||||
{ time: '2026-06-20 16:00', action: 'Eskalasi ke Level 3 (BGN) diajukan', actor: 'KSP Div. Pengawasan', type: 'escalated' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ISS-002', programId: 'koperasi',
|
||||
daerah: 'Palu', provinsi: 'Sulawesi Tengah',
|
||||
judul: 'Koperasi Fiktif — Modal Disalurkan Tanpa Aktivitas Bisnis 42 Hari',
|
||||
deskripsi: 'Modal Rp 2,1 Miliar telah disalurkan ke 3 unit koperasi di Palu namun tidak ada aktivitas bisnis yang tercatat selama 42 hari berturut-turut. Alamat fisik koperasi tidak dapat ditemukan saat verifikasi lapangan.',
|
||||
severity: 'critical', status: 'in_progress',
|
||||
assignee: 'Tim Investigasi Khusus KSP',
|
||||
createdAt: '2026-06-10', updatedAt: '2026-06-23',
|
||||
surveyIds: ['SRV-002'], escalationIds: ['ESC-002'],
|
||||
timeline: [
|
||||
{ time: '2026-06-10 07:00', action: 'Anomali zero-activity terdeteksi (42 hari berturut-turut)', actor: 'Sistem Otomatis', type: 'created' },
|
||||
{ time: '2026-06-11 11:00', action: 'Cross-referensi data NIK anggota dilakukan', actor: 'Analis Senior', type: 'verified' },
|
||||
{ time: '2026-06-13 09:00', action: 'Tim Investigasi Khusus ditugaskan oleh Deputi KSP', actor: 'Deputi KSP', type: 'assigned' },
|
||||
{ time: '2026-06-15 08:00', action: 'Survey lapangan: alamat koperasi tidak ditemukan', actor: 'Ratna Dewi', type: 'survey' },
|
||||
{ time: '2026-06-18 14:00', action: 'Eskalasi L4 ke Kejaksaan Agung & POLRI', actor: 'KSP / Deputi', type: 'escalated' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ISS-003', programId: 'mbg',
|
||||
daerah: 'Kupang', provinsi: 'NTT',
|
||||
judul: 'Lonjakan Penerima MBG 340% Tanpa Dasar Data Kemendikbud',
|
||||
deskripsi: 'Jumlah penerima MBG di Kupang melonjak 340% dalam 14 hari (dari 4.200 menjadi 18.500) tanpa ada perubahan data jumlah siswa dari Kemendikbud. Tidak ada penambahan sekolah baru yang terverifikasi.',
|
||||
severity: 'high', status: 'open',
|
||||
assignee: 'Belum ditugaskan',
|
||||
createdAt: '2026-06-20', updatedAt: '2026-06-20',
|
||||
surveyIds: [], escalationIds: [],
|
||||
timeline: [
|
||||
{ time: '2026-06-20 13:00', action: 'Anomali lonjakan data NIK penerima terdeteksi otomatis', actor: 'Sistem Otomatis', type: 'created' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ISS-004', programId: 'koperasi',
|
||||
daerah: 'Samarinda', provinsi: 'Kalimantan Timur',
|
||||
judul: '2 Unit Koperasi Terdaftar di Alamat Fisik Identik',
|
||||
deskripsi: 'Dua unit koperasi ditemukan terdaftar dengan alamat fisik yang persis sama: Jl. Pattimura No.14, Samarinda. Verifikasi lapangan menunjukkan lokasi tersebut bukan bangunan komersial melainkan rumah tinggal warga biasa.',
|
||||
severity: 'high', status: 'open',
|
||||
assignee: 'Belum ditugaskan',
|
||||
createdAt: '2026-06-18', updatedAt: '2026-06-18',
|
||||
surveyIds: [], escalationIds: [],
|
||||
timeline: [
|
||||
{ time: '2026-06-18 10:00', action: 'Duplikasi alamat koperasi terdeteksi saat rekonsiliasi data', actor: 'Sistem Otomatis', type: 'created' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ISS-005', programId: 'mbg',
|
||||
daerah: 'Surabaya', provinsi: 'Jawa Timur',
|
||||
judul: 'Keterlambatan Pembayaran Vendor MBG Lebih dari 60 Hari',
|
||||
deskripsi: 'PT Gizi Jatim selaku vendor utama MBG Surabaya mengalami keterlambatan pembayaran dari pemda lebih dari 60 hari. Vendor mengancam hentikan pengiriman jika tidak ada kepastian pembayaran dalam 7 hari. Berpotensi ganggu 182.000 porsi/hari.',
|
||||
severity: 'critical', status: 'in_progress',
|
||||
assignee: 'Tim Koordinasi KSP–Kemenkeu',
|
||||
createdAt: '2026-06-19', updatedAt: '2026-06-24',
|
||||
surveyIds: [], escalationIds: ['ESC-003'],
|
||||
timeline: [
|
||||
{ time: '2026-06-19 09:00', action: 'Laporan keterlambatan pembayaran masuk dari vendor', actor: 'PT Gizi Jatim', type: 'created' },
|
||||
{ time: '2026-06-20 14:00', action: 'Verifikasi tagihan vendor oleh tim keuangan daerah', actor: 'Kemenkeu Region Jatim', type: 'verified' },
|
||||
{ time: '2026-06-22 10:00', action: 'Eskalasi L2 ke Kemenkeu Pusat diajukan', actor: 'KSP Div. Koordinasi', type: 'escalated' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ISS-006', programId: 'psn',
|
||||
daerah: 'Surabaya', provinsi: 'Jawa Timur',
|
||||
judul: 'Progress LRT Surabaya Terlambat 28% dari Jadwal Kuartal 2',
|
||||
deskripsi: 'Proyek LRT Surabaya baru mencapai progress 38% sementara target Q2-2026 seharusnya 66%. Keterlambatan karena pembebasan lahan belum tuntas di 3 segmen utama. Risiko keterlambatan selesai bertambah 18 bulan.',
|
||||
severity: 'high', status: 'in_progress',
|
||||
assignee: 'Tim Monev PSN Jawa Timur',
|
||||
createdAt: '2026-06-14', updatedAt: '2026-06-21',
|
||||
surveyIds: ['SRV-003'], escalationIds: [],
|
||||
timeline: [
|
||||
{ time: '2026-06-14 11:00', action: 'Review progress Q2 menunjukkan keterlambatan signifikan', actor: 'Tim Monev PSN', type: 'created' },
|
||||
{ time: '2026-06-16 09:00', action: 'Rapat koordinasi dengan PT Adhi Karya & BPJT', actor: 'KSP + BPJT', type: 'verified' },
|
||||
{ time: '2026-06-21 14:00', action: 'Site visit dan assessment lahan oleh tim lapangan', actor: 'Tim PSN Jatim', type: 'survey' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ISS-007', programId: 'rumah',
|
||||
daerah: 'Kupang', provinsi: 'NTT',
|
||||
judul: 'Kualitas Konstruksi Rumah Rakyat Tidak Memenuhi Standar SNI',
|
||||
deskripsi: 'Inspeksi lapangan menemukan 480 unit rumah yang telah "diselesaikan" memiliki kualitas di bawah standar SNI: dinding retak, pondasi tidak memenuhi spesifikasi, material berbeda dari kontrak. Berpotensi membahayakan penghuni.',
|
||||
severity: 'high', status: 'open',
|
||||
assignee: 'Belum ditugaskan',
|
||||
createdAt: '2026-06-21', updatedAt: '2026-06-21',
|
||||
surveyIds: [], escalationIds: [],
|
||||
timeline: [
|
||||
{ time: '2026-06-21 14:00', action: 'Laporan inspeksi kualitas diterima dari surveyor PUPR', actor: 'Tim Inspeksi PUPR', type: 'created' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ISS-008', programId: 'koperasi',
|
||||
daerah: 'Pontianak', provinsi: 'Kalimantan Barat',
|
||||
judul: 'Anggota Koperasi Terdaftar Ganda di 3 Unit Berbeda',
|
||||
deskripsi: 'Analisis data menemukan 840 orang yang sama terdaftar sebagai anggota aktif di 3 unit koperasi berbeda di Pontianak secara bersamaan. Melanggar aturan keanggotaan dan menyebabkan triple-counting laporan kinerja.',
|
||||
severity: 'medium', status: 'open',
|
||||
assignee: 'Belum ditugaskan',
|
||||
createdAt: '2026-06-22', updatedAt: '2026-06-22',
|
||||
surveyIds: [], escalationIds: [],
|
||||
timeline: [
|
||||
{ time: '2026-06-22 09:00', action: 'Duplikasi data anggota terdeteksi dalam rekonsiliasi SIKOPNAS', actor: 'Sistem Otomatis', type: 'created' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ISS-009', programId: 'mbg',
|
||||
daerah: 'Malang', provinsi: 'Jawa Timur',
|
||||
judul: 'Kandungan Gizi Menu MBG di Bawah Standar Minimum BGN',
|
||||
deskripsi: 'Uji laboratorium terhadap 24 sampel makanan MBG di 8 sekolah Malang menunjukkan kandungan protein rata-rata hanya 42% dari standar minimum BGN. Beberapa sampel mengandung pengawet yang tidak diizinkan.',
|
||||
severity: 'medium', status: 'in_progress',
|
||||
assignee: 'BGN Lab & Tim Verifikasi',
|
||||
createdAt: '2026-06-17', updatedAt: '2026-06-22',
|
||||
surveyIds: ['SRV-004'], escalationIds: [],
|
||||
timeline: [
|
||||
{ time: '2026-06-17 10:00', action: 'Hasil uji lab diterima: kualitas gizi di bawah standar', actor: 'BGN Lab Jatim', type: 'created' },
|
||||
{ time: '2026-06-18 09:00', action: 'Verifikasi lapangan ke 8 sekolah oleh tim gizi', actor: 'Tim Gizi BGN', type: 'verified' },
|
||||
{ time: '2026-06-22 14:00', action: 'Surat peringatan resmi dikirimkan ke vendor', actor: 'BGN + KSP', type: 'assigned' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ISS-010', programId: 'hilirisasi',
|
||||
daerah: 'Banjarmasin', provinsi: 'Kalimantan Selatan',
|
||||
judul: 'Proyek Coal Gasification Terhenti — Izin Lingkungan Dicabut KLHK',
|
||||
deskripsi: 'Proyek hilirisasi batu bara Coal Gasification terhenti karena KLHK mencabut izin lingkungan setelah ditemukan AMDAL awal menggunakan data palsu. Progress terhenti di 32% dan investasi Rp 5,8T terancam mangkrak.',
|
||||
severity: 'critical', status: 'open',
|
||||
assignee: 'Belum ditugaskan',
|
||||
createdAt: '2026-06-23', updatedAt: '2026-06-23',
|
||||
surveyIds: [], escalationIds: [],
|
||||
timeline: [
|
||||
{ time: '2026-06-23 08:00', action: 'Keputusan pencabutan izin lingkungan KLHK diterima', actor: 'KLHK', type: 'created' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ISS-011', programId: 'rumah',
|
||||
daerah: 'Bengkulu', provinsi: 'Bengkulu',
|
||||
judul: 'Realisasi Rumah Rakyat Hanya 35% — Jauh di Bawah Target Semester 1',
|
||||
deskripsi: 'Target pembangunan 2.800 unit rumah rakyat di Bengkulu baru terealisasi 980 unit (35%) akhir semester 1. Audit menunjukkan keterlambatan administrasi kontrak sejak awal sebagai penyebab utama, bukan cuaca.',
|
||||
severity: 'medium', status: 'in_progress',
|
||||
assignee: 'Tim Monev PUPR Bengkulu',
|
||||
createdAt: '2026-06-15', updatedAt: '2026-06-20',
|
||||
surveyIds: [], escalationIds: [],
|
||||
timeline: [
|
||||
{ time: '2026-06-15 09:00', action: 'Evaluasi semester 1: realisasi jauh di bawah target', actor: 'PUPR Bengkulu', type: 'created' },
|
||||
{ time: '2026-06-18 11:00', action: 'Rapat koordinasi dengan CV Bumi Bengkulu dilaksanakan', actor: 'Tim Monev PUPR', type: 'assigned' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ISS-012', programId: 'mbg',
|
||||
daerah: 'Medan', provinsi: 'Sumatra Utara',
|
||||
judul: 'Gap Data Distribusi 2 Sekolah — 8 Hari Berturut-turut',
|
||||
deskripsi: '2 sekolah di Medan Kota melaporkan nol distribusi makanan selama 8 hari sekolah berturut-turut, sementara log pengiriman supplier menunjukkan pengiriman berhasil.',
|
||||
severity: 'medium', status: 'resolved',
|
||||
assignee: 'Tim Verifikasi KSP Sumatra Utara',
|
||||
createdAt: '2026-06-05', updatedAt: '2026-06-15',
|
||||
surveyIds: ['SRV-005'], escalationIds: [],
|
||||
timeline: [
|
||||
{ time: '2026-06-05 08:00', action: 'Gap distribusi terdeteksi oleh sistem SIMBG', actor: 'Sistem Otomatis', type: 'created' },
|
||||
{ time: '2026-06-07 10:00', action: 'Verifikasi dengan kepala sekolah dilakukan', actor: 'Tim KSP Sumut', type: 'verified' },
|
||||
{ time: '2026-06-10 09:00', action: 'Survey lapangan ke 2 sekolah bermasalah', actor: 'Andi Kusuma', type: 'survey' },
|
||||
{ time: '2026-06-15 14:00', action: 'RESOLVED: sistem pencatatan sekolah error, data telah diperbaiki', actor: 'Tim IT BGN', type: 'resolved' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ISS-013', programId: 'koperasi',
|
||||
daerah: 'Samarinda', provinsi: 'Kalimantan Timur',
|
||||
judul: '1.240 NIK Anggota Koperasi Tidak Valid dalam DTSEN',
|
||||
deskripsi: 'Rekonsiliasi data koperasi Samarinda dengan DTSEN menemukan 1.240 NIK anggota tidak valid: 820 NIK tidak terdaftar di Dukcapil dan 420 NIK merupakan duplikasi. Berpotensi menyebabkan penyaluran modal ke penerima yang salah.',
|
||||
severity: 'high', status: 'in_progress',
|
||||
assignee: 'Tim Rekonsiliasi Data KemenKopUKM',
|
||||
createdAt: '2026-06-12', updatedAt: '2026-06-22',
|
||||
surveyIds: [], escalationIds: [],
|
||||
timeline: [
|
||||
{ time: '2026-06-12 11:00', action: 'Rekonsiliasi otomatis SIKOPNAS×DTSEN menemukan anomali NIK', actor: 'Sistem Otomatis', type: 'created' },
|
||||
{ time: '2026-06-14 09:00', action: 'Tim verifikasi data ditugaskan oleh KemenKopUKM', actor: 'KemenKopUKM', type: 'assigned' },
|
||||
{ time: '2026-06-22 14:00', action: 'Proses pembersihan data berlangsung (840/1.240 NIK selesai)', actor: 'Tim Rekonsiliasi', type: 'verified' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// ─── SURVEYS ─────────────────────────────────────────────────────────
|
||||
const INITIAL_SURVEYS = [
|
||||
{
|
||||
id: 'SRV-001', issueId: 'ISS-001', programId: 'mbg',
|
||||
namaOfficer: 'Hendra Wijaya', jabatan: 'Analis Senior KSP',
|
||||
tanggalSurvey: '2026-06-18', daerah: 'Manokwari', provinsi: 'Papua Barat',
|
||||
status: 'verified',
|
||||
temuanUtama: 'Sistem pencatatan vendor tidak sinkron dengan SIMBG. Terdapat gap 3 hari tanpa upload data. Gudang vendor ditemukan kosong saat dikunjungi. Tidak ada jejak pengiriman fisik.',
|
||||
rekomendasi: 'Audit menyeluruh terhadap vendor dan integrasi langsung sistem vendor ke SIMBG. Pertimbangkan penggantian vendor segera. Laporkan ke BGN untuk tindakan administratif.',
|
||||
skorKeparahan: 1, verifiedBy: 'Deputi Pengawasan KSP', createdAt: '2026-06-18'
|
||||
},
|
||||
{
|
||||
id: 'SRV-002', issueId: 'ISS-002', programId: 'koperasi',
|
||||
namaOfficer: 'Ratna Dewi Puspita', jabatan: 'Investigator KSP',
|
||||
tanggalSurvey: '2026-06-15', daerah: 'Palu', provinsi: 'Sulawesi Tengah',
|
||||
status: 'verified',
|
||||
temuanUtama: 'Ketiga alamat koperasi tidak ditemukan. Lokasi 1: warung tutup & kosong. Lokasi 2: tanah kosong berpagar. Lokasi 3: rumah warga yang tidak mengetahui adanya koperasi. Nomor pengurus tidak aktif semua.',
|
||||
rekomendasi: 'Pembekuan segera pencairan modal. Laporkan ke POLRI dan Kejaksaan untuk tindak pidana penipuan. Cabut izin operasional ketiga koperasi fiktif.',
|
||||
skorKeparahan: 1, verifiedBy: 'Deputi Pengawasan KSP', createdAt: '2026-06-15'
|
||||
},
|
||||
{
|
||||
id: 'SRV-003', issueId: 'ISS-006', programId: 'psn',
|
||||
namaOfficer: 'Dimas Prasetyo', jabatan: 'Inspektor PSN KSP',
|
||||
tanggalSurvey: '2026-06-21', daerah: 'Surabaya', provinsi: 'Jawa Timur',
|
||||
status: 'submitted',
|
||||
temuanUtama: 'Pembebasan lahan Segmen 2 (6,2 km) belum tuntas — 14 bidang masih sengketa. Segmen 3 terhenti total karena dispute tarif ganti rugi. Alat berat idle sejak 45 hari. Kontraktor mengklaim force majeure.',
|
||||
rekomendasi: 'Percepat mediasi lahan dengan BPN dan Pemda. Kaji ulang klaim force majeure. Pertimbangkan denda keterlambatan. Evaluasi kapasitas kontraktor.',
|
||||
skorKeparahan: 3, verifiedBy: null, createdAt: '2026-06-21'
|
||||
},
|
||||
{
|
||||
id: 'SRV-004', issueId: 'ISS-009', programId: 'mbg',
|
||||
namaOfficer: 'Dr. Siti Rahmawati', jabatan: 'Ahli Gizi Nasional BGN',
|
||||
tanggalSurvey: '2026-06-18', daerah: 'Malang', provinsi: 'Jawa Timur',
|
||||
status: 'verified',
|
||||
temuanUtama: 'Dari 24 sampel: protein rata-rata 10,5g (standar minimum 25g). 8 sampel mengandung Natrium Benzoat >50mg/kg (batas aman). Dapur produksi vendor tidak memiliki sertifikasi HACCP. Bahan baku menggunakan ayam broiler kualitas C.',
|
||||
rekomendasi: 'Ganti vendor segera. Wajibkan sertifikasi HACCP. Lakukan uji lab random setiap bulan. Pertimbangkan blacklist vendor saat ini dari program MBG.',
|
||||
skorKeparahan: 2, verifiedBy: 'Kepala BGN Jatim', createdAt: '2026-06-18'
|
||||
},
|
||||
{
|
||||
id: 'SRV-005', issueId: 'ISS-012', programId: 'mbg',
|
||||
namaOfficer: 'Andi Kusuma', jabatan: 'Analis KSP Wilayah Sumatra',
|
||||
tanggalSurvey: '2026-06-10', daerah: 'Medan', provinsi: 'Sumatra Utara',
|
||||
status: 'verified',
|
||||
temuanUtama: 'Sistem pencatatan sekolah menggunakan aplikasi lama yang mengalami bug saat update versi baru. Data tidak ter-upload ke SIMBG secara otomatis. Makanan tetap diterima dan terdistribusi kepada siswa secara normal.',
|
||||
rekomendasi: 'Update sistem pencatatan di semua sekolah. Training ulang operator. Pasang monitoring real-time untuk deteksi dini gap pencatatan.',
|
||||
skorKeparahan: 4, verifiedBy: 'Tim KSP Sumatra Utara', createdAt: '2026-06-10'
|
||||
}
|
||||
];
|
||||
|
||||
// ─── ESCALATIONS ─────────────────────────────────────────────────────
|
||||
const INITIAL_ESCALATIONS = [
|
||||
{
|
||||
id: 'ESC-001', issueId: 'ISS-001', programId: 'mbg',
|
||||
level: 'L3', levelName: 'Eskalasi Kementerian/Lembaga',
|
||||
assignTo: 'Badan Gizi Nasional (BGN)',
|
||||
notes: 'Issue memerlukan intervensi langsung BGN untuk verifikasi vendor dan audit rantai pasok. KSP merekomendasikan penghentian sementara vendor hingga investigasi selesai.',
|
||||
status: 'pending', createdBy: 'KSP Div. Pengawasan',
|
||||
createdAt: '2026-06-20', resolvedAt: null,
|
||||
daerah: 'Manokwari', provinsi: 'Papua Barat'
|
||||
},
|
||||
{
|
||||
id: 'ESC-002', issueId: 'ISS-002', programId: 'koperasi',
|
||||
level: 'L4', levelName: 'Eskalasi Penegakan Hukum',
|
||||
assignTo: 'Kejaksaan Agung & POLRI',
|
||||
notes: 'Temuan lapangan menunjukkan indikasi tindak pidana: pendirian koperasi fiktif untuk menyerap dana pemerintah. Diperlukan penyelidikan pidana oleh aparat penegak hukum.',
|
||||
status: 'approved', createdBy: 'Deputi KSP',
|
||||
createdAt: '2026-06-18', resolvedAt: null,
|
||||
daerah: 'Palu', provinsi: 'Sulawesi Tengah'
|
||||
},
|
||||
{
|
||||
id: 'ESC-003', issueId: 'ISS-005', programId: 'mbg',
|
||||
level: 'L2', levelName: 'Eskalasi Internal KSP',
|
||||
assignTo: 'Deputi Koordinasi KSP + Kemenkeu Pusat',
|
||||
notes: 'Diperlukan percepatan pencairan pembayaran ke vendor MBG Surabaya untuk mencegah penghentian layanan yang berdampak pada 182.000 penerima manfaat.',
|
||||
status: 'resolved', createdBy: 'KSP Div. Koordinasi',
|
||||
createdAt: '2026-06-22', resolvedAt: '2026-06-24',
|
||||
daerah: 'Surabaya', provinsi: 'Jawa Timur'
|
||||
}
|
||||
];
|
||||
|
||||
// ─── NOTIFICATION FEED ────────────────────────────────────────────────
|
||||
const INITIAL_NOTIFICATIONS = [
|
||||
{ id: 'N1', type: 'critical', msg: 'ISS-001: Vendor MBG Manokwari tidak terverifikasi — perlu tindakan segera', time: '10 menit lalu', read: false },
|
||||
{ id: 'N2', type: 'critical', msg: 'ISS-002: Koperasi fiktif Palu — eskalasi ke Kejaksaan telah disetujui', time: '1 jam lalu', read: false },
|
||||
{ id: 'N3', type: 'high', msg: 'ISS-010: Izin lingkungan proyek Banjarmasin dicabut KLHK', time: '3 jam lalu', read: false },
|
||||
{ id: 'N4', type: 'info', msg: 'SRV-003: Survey PSN Surabaya menunggu verifikasi', time: '5 jam lalu', read: true },
|
||||
{ id: 'N5', type: 'success', msg: 'ISS-012: Issue distribusi MBG Medan berhasil diselesaikan', time: 'Kemarin', read: true }
|
||||
];
|
||||
|
||||
// ─── HELPER UTILITIES ─────────────────────────────────────────────────
|
||||
function getProgramById(id) { return PROGRAMS.find(p => p.id === id); }
|
||||
function getIssueById(id) { return APP_STATE.issues.find(i => i.id === id); }
|
||||
function getSurveyById(id) { return APP_STATE.surveys.find(s => s.id === id); }
|
||||
function getEscalationById(id) { return APP_STATE.escalations.find(e => e.id === id); }
|
||||
|
||||
const SEVERITY_CONFIG = {
|
||||
critical: { label: 'Kritis', color: '#ef4444', bg: 'rgba(239,68,68,0.12)', icon: '🔴' },
|
||||
high: { label: 'Tinggi', color: '#f97316', bg: 'rgba(249,115,22,0.12)', icon: '🟠' },
|
||||
medium: { label: 'Sedang', color: '#f59e0b', bg: 'rgba(245,158,11,0.12)', icon: '🟡' },
|
||||
low: { label: 'Rendah', color: '#22c55e', bg: 'rgba(34,197,94,0.12)', icon: '🟢' }
|
||||
};
|
||||
const STATUS_CONFIG = {
|
||||
open: { label: 'Terbuka', color: '#ef4444', bg: 'rgba(239,68,68,0.10)' },
|
||||
in_progress: { label: 'Diproses', color: '#3b82f6', bg: 'rgba(59,130,246,0.10)' },
|
||||
resolved: { label: 'Selesai', color: '#22c55e', bg: 'rgba(34,197,94,0.10)' },
|
||||
closed: { label: 'Ditutup', color: '#71717a', bg: 'rgba(113,113,122,0.10)' }
|
||||
};
|
||||
const ESCALATION_LEVELS = {
|
||||
L1: { label: 'L1 — Daerah', color: '#22c55e' },
|
||||
L2: { label: 'L2 — KSP Internal', color: '#3b82f6' },
|
||||
L3: { label: 'L3 — Kementerian', color: '#f59e0b' },
|
||||
L4: { label: 'L4 — Penegakan Hukum', color: '#ef4444' }
|
||||
};
|
||||
|
||||
// ─── GLOBAL APP STATE ─────────────────────────────────────────────────
|
||||
const APP_STATE = {
|
||||
locs: JSON.parse(JSON.stringify(INITIAL_LOCS)),
|
||||
issues: JSON.parse(JSON.stringify(INITIAL_ISSUES)),
|
||||
surveys: JSON.parse(JSON.stringify(INITIAL_SURVEYS)),
|
||||
escalations: JSON.parse(JSON.stringify(INITIAL_ESCALATIONS)),
|
||||
notifications: JSON.parse(JSON.stringify(INITIAL_NOTIFICATIONS)),
|
||||
currentPage: 'dashboard',
|
||||
currentProgram: 'all',
|
||||
issueFilter: { program: 'all', severity: 'all', status: 'all', search: '' },
|
||||
surveyFilter: { program: 'all', status: 'all' },
|
||||
selectedIssueId: null,
|
||||
mapInstance: null,
|
||||
mapMarkers: {},
|
||||
chartInstances: {}
|
||||
};
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KSP — Dashboard Pengawasan Program Nasional</title>
|
||||
<meta name="description" content="Dashboard monitoring dan pengawasan program-program strategis pemerintahan: MBG, Koperasi Merah Putih, PSN, Hilirisasi, dan Rumah Rakyat.">
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
|
||||
<!-- Design System -->
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app-layout">
|
||||
|
||||
<!-- ═══ SIDEBAR ═════════════════════════════════════════════════ -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<div class="logo-icon">🏛️</div>
|
||||
<div class="logo-text">
|
||||
<div class="logo-title">KANTOR STAF PRESIDEN</div>
|
||||
<div class="logo-sub">Dashboard Pengawasan Nasional</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-section-label">Navigasi Utama</div>
|
||||
|
||||
<div class="nav-item active" id="nav-dashboard" onclick="navigateTo('dashboard')">
|
||||
<span class="nav-item-icon">📊</span>
|
||||
<span class="nav-item-label">Overview Nasional</span>
|
||||
</div>
|
||||
<div class="nav-item" id="nav-programs" onclick="navigateTo('programs')">
|
||||
<span class="nav-item-icon">🗂️</span>
|
||||
<span class="nav-item-label">Detail Program</span>
|
||||
</div>
|
||||
<div class="nav-item" id="nav-issues" onclick="navigateTo('issues')">
|
||||
<span class="nav-item-icon">⚠️</span>
|
||||
<span class="nav-item-label">Manajemen Issue</span>
|
||||
<span class="nav-badge" id="issues-badge">0</span>
|
||||
</div>
|
||||
<div class="nav-item" id="nav-survey" onclick="navigateTo('survey')">
|
||||
<span class="nav-item-icon">📋</span>
|
||||
<span class="nav-item-label">Survey Lapangan</span>
|
||||
</div>
|
||||
<div class="nav-item" id="nav-escalasi" onclick="navigateTo('escalasi')">
|
||||
<span class="nav-item-icon">🔺</span>
|
||||
<span class="nav-item-label">Eskalasi</span>
|
||||
</div>
|
||||
<div class="nav-item" id="nav-integration" onclick="navigateTo('integration')">
|
||||
<span class="nav-item-icon">🧩</span>
|
||||
<span class="nav-item-label">Arsitektur Platform</span>
|
||||
</div>
|
||||
|
||||
<div class="nav-section-label" style="margin-top:8px;">Filter Program</div>
|
||||
|
||||
<div class="nav-item" id="nav-filter-all" onclick="setNavProgramFilter('all', this)">
|
||||
<span class="nav-item-icon">🌐</span>
|
||||
<span class="nav-item-label">Semua Program</span>
|
||||
</div>
|
||||
<div class="nav-item" id="nav-filter-mbg" onclick="setNavProgramFilter('mbg', this)">
|
||||
<span class="nav-item-icon">🍱</span>
|
||||
<span class="nav-item-label">Makan Bergizi Gratis</span>
|
||||
</div>
|
||||
<div class="nav-item" id="nav-filter-koperasi" onclick="setNavProgramFilter('koperasi', this)">
|
||||
<span class="nav-item-icon">🏪</span>
|
||||
<span class="nav-item-label">Koperasi Merah Putih</span>
|
||||
</div>
|
||||
<div class="nav-item" id="nav-filter-psn" onclick="setNavProgramFilter('psn', this)">
|
||||
<span class="nav-item-icon">🏗️</span>
|
||||
<span class="nav-item-label">Proyek Strategis (PSN)</span>
|
||||
</div>
|
||||
<div class="nav-item" id="nav-filter-hilirisasi" onclick="setNavProgramFilter('hilirisasi', this)">
|
||||
<span class="nav-item-icon">⚙️</span>
|
||||
<span class="nav-item-label">Hilirisasi Industri</span>
|
||||
</div>
|
||||
<div class="nav-item" id="nav-filter-rumah" onclick="setNavProgramFilter('rumah', this)">
|
||||
<span class="nav-item-icon">🏠</span>
|
||||
<span class="nav-item-label">Rumah Rakyat</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-user">
|
||||
<div class="user-avatar">KSP</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name">Admin KSP</div>
|
||||
<div class="user-role">Div. Pengawasan Nasional</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ═══ MAIN AREA ════════════════════════════════════════════════ -->
|
||||
<div class="main-area">
|
||||
|
||||
<!-- TOPBAR -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div>
|
||||
<div class="page-title" id="topbar-title">Overview Nasional</div>
|
||||
<div class="page-subtitle" id="topbar-sub">Pemantauan real-time seluruh program strategis pemerintah</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right" style="gap:10px;">
|
||||
<div class="live-pill">
|
||||
<div class="live-dot"></div>
|
||||
<span class="live-text">LIVE DATA</span>
|
||||
</div>
|
||||
<div class="clock-widget" id="clock-widget">
|
||||
<div class="clock-time" id="clock-time">00:00:00</div>
|
||||
<div class="clock-date" id="clock-date">Senin, 1 Jan 2026</div>
|
||||
</div>
|
||||
<div class="topbar-actions-area" style="position:relative; display:flex; gap:8px;">
|
||||
<button class="notif-btn" id="theme-toggle-btn" onclick="toggleTheme()" style="font-size:14px;" title="Ubah Tema">
|
||||
☀️
|
||||
</button>
|
||||
<button class="notif-btn" id="notif-btn" onclick="toggleNotifDropdown()">
|
||||
🔔
|
||||
<div class="notif-dot" id="notif-dot"></div>
|
||||
</button>
|
||||
<!-- Notification Dropdown -->
|
||||
<div class="notif-dropdown hidden" id="notif-dropdown">
|
||||
<div class="notif-header flex justify-between items-center">
|
||||
<span>Notifikasi</span>
|
||||
<button class="btn btn-xs btn-secondary" onclick="markAllRead()">Tandai dibaca</button>
|
||||
</div>
|
||||
<div id="notif-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ═══ PAGE VIEWS ═══════════════════════════════════════════ -->
|
||||
<main class="page-content">
|
||||
|
||||
<!-- DASHBOARD PAGE -->
|
||||
<div class="page-view active" id="view-dashboard">
|
||||
<!-- KPI Strip -->
|
||||
<div style="padding:16px 24px 0;" id="kpi-strip"></div>
|
||||
<!-- Map + Side Panel -->
|
||||
<div class="dash-body">
|
||||
<div class="dash-map-col">
|
||||
<div class="map-wrapper" style="min-height:0;">
|
||||
<div id="leaflet-map"></div>
|
||||
<div class="map-controls" id="map-controls">
|
||||
<button class="map-btn active" onclick="applyMapFilter('all',this)">Semua</button>
|
||||
<button class="map-btn" onclick="applyMapFilter('mbg',this)">🍱 MBG</button>
|
||||
<button class="map-btn" onclick="applyMapFilter('koperasi',this)">🏪 Koperasi</button>
|
||||
<button class="map-btn" onclick="applyMapFilter('psn',this)">🏗️ PSN</button>
|
||||
<button class="map-btn" onclick="applyMapFilter('hilirisasi',this)">⚙️ Hilirisasi</button>
|
||||
<button class="map-btn" onclick="applyMapFilter('rumah',this)">🏠 Rumah</button>
|
||||
<button class="map-btn" onclick="applyMapFilter('issues',this)">⚠️ Bermasalah</button>
|
||||
</div>
|
||||
<div class="map-legend">
|
||||
<div class="map-legend-title">Legenda</div>
|
||||
<div class="legend-row"><div class="legend-dot" style="background:#22c55e"></div>MBG Aktif</div>
|
||||
<div class="legend-row"><div class="legend-dot" style="background:#3b82f6"></div>Koperasi</div>
|
||||
<div class="legend-row"><div class="legend-dot" style="background:#f59e0b"></div>PSN</div>
|
||||
<div class="legend-row"><div class="legend-dot" style="background:#a855f7"></div>Hilirisasi</div>
|
||||
<div class="legend-row"><div class="legend-dot" style="background:#f97316"></div>Rumah Rakyat</div>
|
||||
<div class="legend-row"><div class="legend-dot" style="background:#ef4444"></div>Ada Masalah</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-side-col" id="dash-side-panel"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PROGRAMS PAGE -->
|
||||
<div class="page-view" id="view-programs">
|
||||
<div class="program-tabs" id="prog-tabs"></div>
|
||||
<div id="prog-bodies"></div>
|
||||
</div>
|
||||
|
||||
<!-- ISSUES PAGE -->
|
||||
<div class="page-view" id="view-issues">
|
||||
<div class="issues-filters">
|
||||
<div class="search-bar" style="flex:1;max-width:300px;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
<input type="text" id="issues-search" placeholder="Cari issue, daerah, program..." oninput="applyIssueFilters()">
|
||||
</div>
|
||||
<select class="form-select" style="width:auto;" id="issues-filter-program" onchange="applyIssueFilters()">
|
||||
<option value="all">Semua Program</option>
|
||||
<option value="mbg">MBG</option>
|
||||
<option value="koperasi">Koperasi</option>
|
||||
<option value="psn">PSN</option>
|
||||
<option value="hilirisasi">Hilirisasi</option>
|
||||
<option value="rumah">Rumah Rakyat</option>
|
||||
</select>
|
||||
<select class="form-select" style="width:auto;" id="issues-filter-severity" onchange="applyIssueFilters()">
|
||||
<option value="all">Semua Tingkat</option>
|
||||
<option value="critical">Kritis</option>
|
||||
<option value="high">Tinggi</option>
|
||||
<option value="medium">Sedang</option>
|
||||
<option value="low">Rendah</option>
|
||||
</select>
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" id="kanban-view-btn" onclick="switchIssueView('kanban')">Kanban</button>
|
||||
<button class="view-btn" id="list-view-btn" onclick="switchIssueView('list')">Tabel</button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick="openNewIssueModal()">+ Tambah Issue</button>
|
||||
</div>
|
||||
<div class="issues-body" id="issues-body"></div>
|
||||
</div>
|
||||
|
||||
<!-- SURVEY PAGE -->
|
||||
<div class="page-view" id="view-survey">
|
||||
<div class="issues-filters" style="gap:12px;">
|
||||
<div class="search-bar" style="flex:1;max-width:300px;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
<input type="text" id="survey-search" placeholder="Cari survey, officer, daerah..." oninput="renderSurveyPage()">
|
||||
</div>
|
||||
<select class="form-select" style="width:auto;" id="survey-filter-status" onchange="renderSurveyPage()">
|
||||
<option value="all">Semua Status</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="submitted">Diajukan</option>
|
||||
<option value="verified">Diverifikasi</option>
|
||||
</select>
|
||||
<select class="form-select" style="width:auto;" id="survey-filter-program" onchange="renderSurveyPage()">
|
||||
<option value="all">Semua Program</option>
|
||||
<option value="mbg">MBG</option>
|
||||
<option value="koperasi">Koperasi</option>
|
||||
<option value="psn">PSN</option>
|
||||
<option value="hilirisasi">Hilirisasi</option>
|
||||
<option value="rumah">Rumah Rakyat</option>
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" onclick="openNewSurveyModal()">+ Buat Survey</button>
|
||||
</div>
|
||||
<div id="survey-body" style="flex:1;overflow-y:auto;padding:16px 24px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- ESCALASI PAGE -->
|
||||
<div class="page-view" id="view-escalasi">
|
||||
<div style="flex:1;overflow-y:auto;" id="escalasi-body"></div>
|
||||
</div>
|
||||
|
||||
<!-- INTEGRATION INFOGRAPHIC PAGE -->
|
||||
<div class="page-view" id="view-integration">
|
||||
<div style="flex:1;overflow-y:auto;" id="integration-body"></div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ MODALS ══════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Issue Detail Modal -->
|
||||
<div class="modal-overlay" id="issue-detail-modal">
|
||||
<div class="modal-box wide">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<div style="font-size:11px;color:var(--text-3);font-family:monospace;margin-bottom:4px;" id="md-issue-id">ISS-000</div>
|
||||
<div class="modal-title" id="md-issue-title">—</div>
|
||||
</div>
|
||||
<button class="modal-close" onclick="closeModal('issue-detail-modal')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="md-issue-body"></div>
|
||||
<div class="modal-footer" id="md-issue-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Issue Modal -->
|
||||
<div class="modal-overlay" id="new-issue-modal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Tambah Issue Baru</div>
|
||||
<button class="modal-close" onclick="closeModal('new-issue-modal')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="new-issue-form" onsubmit="submitNewIssue(event)">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Program</label>
|
||||
<select class="form-select" id="ni-program" required>
|
||||
<option value="">Pilih Program</option>
|
||||
<option value="mbg">Makan Bergizi Gratis</option>
|
||||
<option value="koperasi">Koperasi Merah Putih</option>
|
||||
<option value="psn">PSN</option>
|
||||
<option value="hilirisasi">Hilirisasi Industri</option>
|
||||
<option value="rumah">Rumah Rakyat</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tingkat Keparahan</label>
|
||||
<select class="form-select" id="ni-severity" required>
|
||||
<option value="critical">🔴 Kritis</option>
|
||||
<option value="high">🟠 Tinggi</option>
|
||||
<option value="medium" selected>🟡 Sedang</option>
|
||||
<option value="low">🟢 Rendah</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Kota/Kabupaten</label>
|
||||
<input type="text" class="form-input" id="ni-daerah" placeholder="Contoh: Bandung" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Provinsi</label>
|
||||
<input type="text" class="form-input" id="ni-provinsi" placeholder="Contoh: Jawa Barat" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Judul Issue</label>
|
||||
<input type="text" class="form-input" id="ni-judul" placeholder="Deskripsi singkat masalah yang ditemukan" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Deskripsi Detail</label>
|
||||
<textarea class="form-textarea" id="ni-deskripsi" rows="4" placeholder="Jelaskan temuan secara lengkap..." required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Penugasan Awal (opsional)</label>
|
||||
<input type="text" class="form-input" id="ni-assignee" placeholder="Nama tim atau individu yang bertugas">
|
||||
</div>
|
||||
<div class="modal-footer" style="padding:0;border:none;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('new-issue-modal')">Batal</button>
|
||||
<button type="submit" class="btn btn-primary">Simpan Issue</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Survey Detail Modal -->
|
||||
<div class="modal-overlay" id="survey-detail-modal">
|
||||
<div class="modal-box wide">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title" id="sd-title">Detail Survey</div>
|
||||
<button class="modal-close" onclick="closeModal('survey-detail-modal')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="sd-body"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('survey-detail-modal')">Tutup</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Survey Modal -->
|
||||
<div class="modal-overlay" id="new-survey-modal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Buat Survey Lapangan Baru</div>
|
||||
<button class="modal-close" onclick="closeModal('new-survey-modal')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="new-survey-form" onsubmit="submitNewSurvey(event)">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Terkait Issue</label>
|
||||
<select class="form-select" id="ns-issue" required>
|
||||
<option value="">Pilih Issue</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Program</label>
|
||||
<select class="form-select" id="ns-program">
|
||||
<option value="mbg">MBG</option>
|
||||
<option value="koperasi">Koperasi</option>
|
||||
<option value="psn">PSN</option>
|
||||
<option value="hilirisasi">Hilirisasi</option>
|
||||
<option value="rumah">Rumah Rakyat</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nama Officer</label>
|
||||
<input type="text" class="form-input" id="ns-officer" placeholder="Nama lengkap petugas" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Jabatan</label>
|
||||
<input type="text" class="form-input" id="ns-jabatan" placeholder="Jabatan/posisi" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tanggal Survey</label>
|
||||
<input type="date" class="form-input" id="ns-tanggal" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Daerah</label>
|
||||
<input type="text" class="form-input" id="ns-daerah" placeholder="Kota/Kabupaten" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Temuan Utama</label>
|
||||
<textarea class="form-textarea" id="ns-temuan" rows="3" placeholder="Apa yang ditemukan di lapangan?" required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Rekomendasi Tindak Lanjut</label>
|
||||
<textarea class="form-textarea" id="ns-rekomendasi" rows="3" placeholder="Apa rekomendasi yang diberikan?" required></textarea>
|
||||
</div>
|
||||
<div class="modal-footer" style="padding:0;border:none;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('new-survey-modal')">Batal</button>
|
||||
<button type="submit" class="btn btn-primary">Kirim Survey</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="data.js"></script>
|
||||
<script src="pages/dashboard.js"></script>
|
||||
<script src="pages/programs.js"></script>
|
||||
<script src="pages/issues.js"></script>
|
||||
<script src="pages/survey.js"></script>
|
||||
<script src="pages/escalasi.js"></script>
|
||||
<script src="pages/integration.js"></script>
|
||||
<script src="app.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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');
|
||||
}
|
||||
+629
@@ -0,0 +1,629 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
KSP MONITORING DASHBOARD — DESIGN SYSTEM (Shadcn-inspired)
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
/* ─── CSS VARIABLES ─────────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #09090b;
|
||||
--bg2: #0d0d10;
|
||||
--card: #18181b;
|
||||
--card-hover: #1e1e22;
|
||||
--sidebar: #111113;
|
||||
--border: #27272a;
|
||||
--border-mid: #3f3f46;
|
||||
--text-1: #fafafa;
|
||||
--text-2: #a1a1aa;
|
||||
--text-3: #71717a;
|
||||
--blue: #3b82f6;
|
||||
--blue-d: #2563eb;
|
||||
--blue-soft: rgba(59,130,246,0.12);
|
||||
--green: #22c55e;
|
||||
--green-soft: rgba(34,197,94,0.12);
|
||||
--amber: #f59e0b;
|
||||
--amber-soft: rgba(245,158,11,0.12);
|
||||
--red: #ef4444;
|
||||
--red-soft: rgba(239,68,68,0.12);
|
||||
--purple: #a855f7;
|
||||
--purple-soft: rgba(168,85,247,0.12);
|
||||
--orange: #f97316;
|
||||
--orange-soft: rgba(249,115,22,0.12);
|
||||
--r: 6px;
|
||||
--r-sm: 4px;
|
||||
--r-md: 8px;
|
||||
--r-lg: 12px;
|
||||
--r-xl: 16px;
|
||||
--sh: 0 4px 16px rgba(0,0,0,0.4);
|
||||
--sh-lg: 0 8px 32px rgba(0,0,0,0.5);
|
||||
--sidebar-w: 240px;
|
||||
--topbar-h: 60px;
|
||||
--ease: cubic-bezier(0.4,0,0.2,1);
|
||||
}
|
||||
|
||||
/* ─── LIGHT THEME VARIABLES ──────────────────────────────────────── */
|
||||
.light-theme {
|
||||
--bg: #fafafa;
|
||||
--bg2: #f4f4f5;
|
||||
--card: #ffffff;
|
||||
--card-hover: #f4f4f5;
|
||||
--sidebar: #f4f4f5;
|
||||
--border: #e4e4e7;
|
||||
--border-mid: #d4d4d8;
|
||||
--text-1: #09090b;
|
||||
--text-2: #27272a;
|
||||
--text-3: #71717a;
|
||||
--blue-soft: rgba(59,130,246,0.08);
|
||||
--green-soft: rgba(34,197,94,0.08);
|
||||
--amber-soft: rgba(245,158,11,0.08);
|
||||
--red-soft: rgba(239,68,68,0.08);
|
||||
--purple-soft: rgba(168,85,247,0.08);
|
||||
--orange-soft: rgba(249,115,22,0.08);
|
||||
--sh: 0 4px 16px rgba(0,0,0,0.05);
|
||||
--sh-lg: 0 8px 32px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* ─── RESET ─────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; font-family: 'Inter', system-ui, sans-serif; background: var(--bg); color: var(--text-1); font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; overflow: hidden; }
|
||||
body, .sidebar, .main-area, .topbar, .card, .kpi-card, .btn-secondary, .form-input, .form-select, .form-textarea, .search-bar, table, th, td, .prog-stat-card, .nav-item {
|
||||
transition: background-color 0.25s var(--ease), border-color 0.25s var(--ease), color 0.25s var(--ease), box-shadow 0.25s var(--ease);
|
||||
}
|
||||
a { text-decoration: none; color: inherit; }
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
input, select, textarea { font-family: inherit; }
|
||||
ul, ol { list-style: none; }
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-mid); border-radius: 99px; }
|
||||
|
||||
/* ─── APP LAYOUT ─────────────────────────────────────────────────── */
|
||||
.app-layout { display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
/* ─── SIDEBAR ───────────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
width: var(--sidebar-w); min-width: var(--sidebar-w);
|
||||
background: var(--sidebar); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; height: 100vh; overflow: hidden;
|
||||
position: relative; z-index: 10;
|
||||
}
|
||||
.sidebar-logo {
|
||||
padding: 18px 16px 14px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 34px; height: 34px; border-radius: var(--r-md);
|
||||
background: linear-gradient(135deg, #1d4ed8, #3b82f6);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 16px; flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.4);
|
||||
}
|
||||
.logo-text { flex: 1; overflow: hidden; }
|
||||
.logo-title { font-size: 11px; font-weight: 700; color: var(--text-1); letter-spacing: 0.5px; line-height: 1.2; }
|
||||
.logo-sub { font-size: 10px; color: var(--text-3); margin-top: 1px; }
|
||||
|
||||
.sidebar-nav { flex: 1; padding: 12px 8px; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; }
|
||||
.nav-section-label { font-size: 10px; font-weight: 600; color: var(--text-3); letter-spacing: 0.8px; text-transform: uppercase; padding: 8px 8px 4px; margin-top: 4px; }
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: var(--r); color: var(--text-2);
|
||||
font-size: 13px; font-weight: 500; cursor: pointer;
|
||||
transition: all 0.15s var(--ease); position: relative;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.nav-item:hover { background: rgba(255,255,255,0.04); color: var(--text-1); }
|
||||
.nav-item.active {
|
||||
background: var(--blue-soft); color: var(--blue);
|
||||
border-color: rgba(59,130,246,0.2);
|
||||
}
|
||||
.nav-item-icon { font-size: 15px; width: 20px; text-align: center; flex-shrink: 0; }
|
||||
.nav-item-label { flex: 1; }
|
||||
.nav-badge {
|
||||
background: var(--red); color: #fff; font-size: 10px; font-weight: 700;
|
||||
padding: 1px 6px; border-radius: 99px; min-width: 18px; text-align: center;
|
||||
animation: pulse-badge 2s infinite;
|
||||
}
|
||||
@keyframes pulse-badge {
|
||||
0%, 100% { opacity: 1; } 50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.sidebar-footer { padding: 12px 8px 16px; border-top: 1px solid var(--border); }
|
||||
.sidebar-user {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: var(--r);
|
||||
cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
.sidebar-user:hover { background: rgba(255,255,255,0.04); }
|
||||
.user-avatar {
|
||||
width: 30px; height: 30px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, #1e40af, #7c3aed);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; font-weight: 700; color: #fff; flex-shrink: 0;
|
||||
}
|
||||
.user-info { flex: 1; overflow: hidden; }
|
||||
.user-name { font-size: 12px; font-weight: 600; color: var(--text-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.user-role { font-size: 10px; color: var(--text-3); }
|
||||
|
||||
/* ─── MAIN AREA ─────────────────────────────────────────────────── */
|
||||
.main-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
||||
|
||||
/* ─── TOPBAR ────────────────────────────────────────────────────── */
|
||||
.topbar {
|
||||
height: var(--topbar-h); min-height: var(--topbar-h);
|
||||
background: var(--bg2); border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 24px; gap: 16px;
|
||||
}
|
||||
.topbar-left { display: flex; align-items: center; gap: 12px; }
|
||||
.page-title { font-size: 15px; font-weight: 600; color: var(--text-1); }
|
||||
.page-subtitle { font-size: 12px; color: var(--text-3); margin-top: 1px; }
|
||||
.topbar-right { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.clock-widget { text-align: right; }
|
||||
.clock-time { font-size: 13px; font-weight: 700; color: var(--text-1); font-variant-numeric: tabular-nums; letter-spacing: 0.5px; }
|
||||
.clock-date { font-size: 10px; color: var(--text-3); }
|
||||
|
||||
.live-pill {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
background: rgba(34,197,94,0.08); border: 1px solid rgba(34,197,94,0.2);
|
||||
padding: 4px 10px; border-radius: 99px;
|
||||
}
|
||||
.live-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; background: var(--green);
|
||||
box-shadow: 0 0 6px var(--green); animation: blink 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
.live-text { font-size: 10px; font-weight: 700; color: var(--green); letter-spacing: 0.6px; }
|
||||
|
||||
.notif-btn {
|
||||
position: relative; width: 36px; height: 36px; border-radius: var(--r);
|
||||
background: transparent; border: 1px solid var(--border); color: var(--text-2);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 16px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.notif-btn:hover { background: rgba(255,255,255,0.04); border-color: var(--border-mid); color: var(--text-1); }
|
||||
.notif-dot {
|
||||
position: absolute; top: 6px; right: 6px; width: 8px; height: 8px;
|
||||
background: var(--red); border-radius: 50%; border: 1.5px solid var(--bg2);
|
||||
}
|
||||
|
||||
/* ─── PAGE CONTENT ──────────────────────────────────────────────── */
|
||||
.page-content { flex: 1; overflow: hidden; position: relative; }
|
||||
.page-view { height: 100%; display: none; overflow: hidden; }
|
||||
.page-view.active { display: flex; flex-direction: column; }
|
||||
|
||||
/* ─── SCROLLABLE CONTAINERS ─────────────────────────────────────── */
|
||||
.scroll-area { flex: 1; overflow-y: auto; padding: 20px 24px; }
|
||||
|
||||
/* ─── SECTION HEADER ────────────────────────────────────────────── */
|
||||
.section-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px; gap: 12px;
|
||||
}
|
||||
.section-title { font-size: 15px; font-weight: 600; color: var(--text-1); }
|
||||
.section-sub { font-size: 12px; color: var(--text-3); margin-top: 2px; }
|
||||
|
||||
/* ─── CARDS ─────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: var(--r-md); transition: border-color 0.15s;
|
||||
}
|
||||
.card:hover { border-color: var(--border-mid); }
|
||||
.card-p { padding: 16px; }
|
||||
.card-p-sm { padding: 12px; }
|
||||
|
||||
/* ─── KPI CARDS ─────────────────────────────────────────────────── */
|
||||
.kpi-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 20px; }
|
||||
.kpi-card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: var(--r-md);
|
||||
padding: 16px; cursor: pointer; transition: all 0.2s var(--ease); position: relative; overflow: hidden;
|
||||
}
|
||||
.kpi-card::before {
|
||||
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
|
||||
background: var(--kpi-color, var(--blue)); opacity: 0.6;
|
||||
}
|
||||
.kpi-card:hover { border-color: var(--border-mid); background: var(--card-hover); transform: translateY(-1px); box-shadow: var(--sh); }
|
||||
.kpi-card.active { border-color: var(--kpi-color, var(--blue)); background: var(--card-hover); }
|
||||
.kpi-label { font-size: 11px; font-weight: 500; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 8px; }
|
||||
.kpi-value { font-size: 24px; font-weight: 800; color: var(--text-1); line-height: 1; margin-bottom: 6px; letter-spacing: -0.5px; }
|
||||
.kpi-value.red { color: var(--red); }
|
||||
.kpi-sub { font-size: 11px; color: var(--text-3); display: flex; align-items: center; gap: 4px; }
|
||||
.kpi-sub.up { color: var(--green); }
|
||||
.kpi-sub.down { color: var(--red); }
|
||||
.kpi-sub.warn { color: var(--amber); }
|
||||
.kpi-icon { position: absolute; top: 14px; right: 14px; font-size: 20px; opacity: 0.15; }
|
||||
|
||||
/* ─── PROGRAM BADGES ────────────────────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600;
|
||||
border: 1px solid transparent; white-space: nowrap;
|
||||
}
|
||||
.badge-green { background: var(--green-soft); color: var(--green); border-color: rgba(34,197,94,0.2); }
|
||||
.badge-blue { background: var(--blue-soft); color: var(--blue); border-color: rgba(59,130,246,0.2); }
|
||||
.badge-amber { background: var(--amber-soft); color: var(--amber); border-color: rgba(245,158,11,0.2); }
|
||||
.badge-purple { background: var(--purple-soft); color: var(--purple); border-color: rgba(168,85,247,0.2); }
|
||||
.badge-orange { background: var(--orange-soft); color: var(--orange); border-color: rgba(249,115,22,0.2); }
|
||||
.badge-red { background: var(--red-soft); color: var(--red); border-color: rgba(239,68,68,0.2); }
|
||||
.badge-zinc { background: rgba(113,113,122,0.1); color: var(--text-2); border-color: var(--border); }
|
||||
|
||||
/* ─── SEVERITY + STATUS BADGES ──────────────────────────────────── */
|
||||
.sev-critical { background: var(--red-soft); color: var(--red); border: 1px solid rgba(239,68,68,0.25); padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600; }
|
||||
.sev-high { background: var(--orange-soft); color: var(--orange); border: 1px solid rgba(249,115,22,0.25); padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600; }
|
||||
.sev-medium { background: var(--amber-soft); color: var(--amber); border: 1px solid rgba(245,158,11,0.25); padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600; }
|
||||
.sev-low { background: var(--green-soft); color: var(--green); border: 1px solid rgba(34,197,94,0.25); padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600; }
|
||||
|
||||
.stat-open { background: var(--red-soft); color: var(--red); border: 1px solid rgba(239,68,68,0.2); padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600; }
|
||||
.stat-in_progress { background: var(--blue-soft); color: var(--blue); border: 1px solid rgba(59,130,246,0.2); padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600; }
|
||||
.stat-resolved { background: var(--green-soft); color: var(--green); border: 1px solid rgba(34,197,94,0.2); padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600; }
|
||||
.stat-closed { background: rgba(113,113,122,0.1); color: var(--text-3); border: 1px solid var(--border); padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600; }
|
||||
|
||||
/* ─── BUTTONS ───────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 7px 14px; border-radius: var(--r); font-size: 13px; font-weight: 500;
|
||||
border: 1px solid transparent; cursor: pointer; transition: all 0.15s var(--ease);
|
||||
white-space: nowrap; user-select: none;
|
||||
}
|
||||
.btn-primary { background: var(--blue); color: #fff; }
|
||||
.btn-primary:hover { background: var(--blue-d); }
|
||||
.btn-secondary { background: rgba(255,255,255,0.05); color: var(--text-2); border-color: var(--border); }
|
||||
.btn-secondary:hover { background: rgba(255,255,255,0.08); border-color: var(--border-mid); color: var(--text-1); }
|
||||
.btn-destructive { background: var(--red-soft); color: var(--red); border-color: rgba(239,68,68,0.3); }
|
||||
.btn-destructive:hover { background: rgba(239,68,68,0.2); }
|
||||
.btn-success { background: var(--green-soft); color: var(--green); border-color: rgba(34,197,94,0.3); }
|
||||
.btn-success:hover { background: rgba(34,197,94,0.2); }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.btn-xs { padding: 2px 8px; font-size: 11px; border-radius: var(--r-sm); }
|
||||
.btn-icon { padding: 7px; width: 34px; height: 34px; }
|
||||
|
||||
/* ─── FORMS ─────────────────────────────────────────────────────── */
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-label { font-size: 12px; font-weight: 500; color: var(--text-2); }
|
||||
.form-input, .form-select, .form-textarea {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--r);
|
||||
color: var(--text-1); font-size: 13px; padding: 8px 12px; font-family: inherit;
|
||||
transition: border-color 0.15s; outline: none; width: 100%;
|
||||
}
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(59,130,246,0.12); }
|
||||
.form-input::placeholder { color: var(--text-3); }
|
||||
.form-select option { background: #1a1a1e; }
|
||||
.form-textarea { resize: vertical; min-height: 80px; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
|
||||
/* ─── SEARCH BAR ────────────────────────────────────────────────── */
|
||||
.search-bar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--r);
|
||||
padding: 0 12px; transition: border-color 0.15s;
|
||||
}
|
||||
.search-bar:focus-within { border-color: var(--blue); box-shadow: 0 0 0 2px var(--blue-soft); }
|
||||
.search-bar svg { color: var(--text-3); flex-shrink: 0; }
|
||||
.search-bar input { flex: 1; background: transparent; border: none; outline: none; color: var(--text-1); font-size: 13px; padding: 8px 0; font-family: inherit; }
|
||||
.search-bar input::placeholder { color: var(--text-3); }
|
||||
|
||||
/* ─── FILTERS ROW ───────────────────────────────────────────────── */
|
||||
.filters-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.filter-pill {
|
||||
padding: 5px 12px; border-radius: 99px; font-size: 12px; font-weight: 500;
|
||||
border: 1px solid var(--border); background: transparent; color: var(--text-2);
|
||||
cursor: pointer; transition: all 0.15s; white-space: nowrap;
|
||||
}
|
||||
.filter-pill:hover { border-color: var(--border-mid); color: var(--text-1); }
|
||||
.filter-pill.active { background: var(--blue-soft); border-color: rgba(59,130,246,0.35); color: var(--blue); }
|
||||
.filter-sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
|
||||
|
||||
/* ─── TABLE ─────────────────────────────────────────────────────── */
|
||||
.table-wrap { overflow-x: auto; border-radius: var(--r-md); border: 1px solid var(--border); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th { background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--border); padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; }
|
||||
tbody tr { border-bottom: 1px solid var(--border); transition: background 0.1s; }
|
||||
tbody tr:last-child { border-bottom: none; }
|
||||
tbody tr:hover { background: rgba(255,255,255,0.025); }
|
||||
tbody td { padding: 12px 14px; font-size: 13px; color: var(--text-1); vertical-align: middle; }
|
||||
.td-muted { color: var(--text-3); font-size: 12px; }
|
||||
|
||||
/* ─── PROGRESS BAR ──────────────────────────────────────────────── */
|
||||
.progress-bar { height: 4px; background: var(--border); border-radius: 99px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; border-radius: 99px; transition: width 0.6s var(--ease); }
|
||||
.progress-lg { height: 6px; }
|
||||
.progress-sm { height: 3px; }
|
||||
|
||||
/* ─── DASHBOARD MAP LAYOUT ──────────────────────────────────────── */
|
||||
.dash-body { display: flex; gap: 16px; flex: 1; min-height: 0; padding: 0 24px 20px; }
|
||||
.dash-map-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.dash-side-col { width: 320px; min-width: 320px; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; }
|
||||
#map { flex: 1; border-radius: var(--r-lg); min-height: 300px; border: 1px solid var(--border); overflow: hidden; }
|
||||
.map-wrapper { flex: 1; position: relative; border-radius: var(--r-lg); overflow: hidden; border: 1px solid var(--border); }
|
||||
#leaflet-map { width: 100%; height: 100%; }
|
||||
.map-controls {
|
||||
position: absolute; top: 12px; left: 12px; z-index: 400;
|
||||
display: flex; gap: 6px; flex-wrap: wrap; max-width: calc(100% - 24px);
|
||||
}
|
||||
.map-btn {
|
||||
padding: 5px 10px; border-radius: var(--r); font-size: 11px; font-weight: 600;
|
||||
background: rgba(9,9,11,0.85); color: var(--text-2); border: 1px solid var(--border);
|
||||
backdrop-filter: blur(8px); cursor: pointer; transition: all 0.15s; white-space: nowrap;
|
||||
}
|
||||
.map-btn:hover { border-color: var(--border-mid); color: var(--text-1); }
|
||||
.map-btn.active { background: var(--blue-soft); border-color: rgba(59,130,246,0.4); color: var(--blue); }
|
||||
.map-legend {
|
||||
position: absolute; bottom: 12px; left: 12px; z-index: 400;
|
||||
background: rgba(9,9,11,0.88); border: 1px solid var(--border);
|
||||
border-radius: var(--r-md); padding: 10px 12px; backdrop-filter: blur(8px);
|
||||
}
|
||||
.map-legend-title { font-size: 10px; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
|
||||
.legend-row { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-2); margin-bottom: 4px; }
|
||||
.legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
/* ─── ALERT ITEMS ───────────────────────────────────────────────── */
|
||||
.alert-item {
|
||||
padding: 12px; border-radius: var(--r); border-left: 3px solid var(--border);
|
||||
background: rgba(255,255,255,0.02); margin-bottom: 8px; cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.alert-item:hover { background: rgba(255,255,255,0.04); }
|
||||
.alert-item.critical { border-left-color: var(--red); }
|
||||
.alert-item.high { border-left-color: var(--orange); }
|
||||
.alert-item.medium { border-left-color: var(--amber); }
|
||||
.alert-item.low { border-left-color: var(--green); }
|
||||
.alert-title { font-size: 12px; font-weight: 600; color: var(--text-1); margin-bottom: 4px; line-height: 1.4; }
|
||||
.alert-meta { font-size: 11px; color: var(--text-3); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* ─── ISSUE KANBAN ──────────────────────────────────────────────── */
|
||||
.issues-layout { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.issues-filters { padding: 16px 24px 12px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; flex-wrap: wrap; background: var(--bg2); }
|
||||
.issues-body { flex: 1; overflow: hidden; }
|
||||
.kanban-view { display: flex; height: 100%; gap: 0; overflow-x: auto; }
|
||||
.kanban-col { flex: 1; min-width: 280px; max-width: 400px; display: flex; flex-direction: column; border-right: 1px solid var(--border); overflow: hidden; }
|
||||
.kanban-col:last-child { border-right: none; }
|
||||
.kanban-col-header { padding: 14px 16px 12px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; background: rgba(255,255,255,0.01); flex-shrink: 0; }
|
||||
.kanban-col-title { font-size: 13px; font-weight: 600; color: var(--text-1); display: flex; align-items: center; gap: 8px; }
|
||||
.col-count { font-size: 11px; color: var(--text-3); background: var(--border); padding: 1px 7px; border-radius: 99px; font-weight: 600; }
|
||||
.kanban-cards { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.issue-card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: var(--r-md);
|
||||
padding: 12px; cursor: pointer; transition: all 0.15s var(--ease);
|
||||
border-top: 2px solid transparent;
|
||||
}
|
||||
.issue-card:hover { border-color: var(--border-mid); background: var(--card-hover); transform: translateY(-1px); box-shadow: var(--sh); }
|
||||
.issue-card.critical-card { border-top-color: var(--red); }
|
||||
.issue-card.high-card { border-top-color: var(--orange); }
|
||||
.issue-card.medium-card { border-top-color: var(--amber); }
|
||||
.issue-card.low-card { border-top-color: var(--green); }
|
||||
.ic-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
|
||||
.ic-id { font-size: 10px; color: var(--text-3); font-family: monospace; font-weight: 600; }
|
||||
.ic-title { font-size: 12px; font-weight: 600; color: var(--text-1); line-height: 1.4; margin-bottom: 6px; }
|
||||
.ic-desc { font-size: 11px; color: var(--text-3); line-height: 1.5; margin-bottom: 8px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.ic-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 4px; }
|
||||
.ic-meta { font-size: 10px; color: var(--text-3); }
|
||||
.ic-assignee { font-size: 10px; color: var(--text-3); max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ─── LIST VIEW ─────────────────────────────────────────────────── */
|
||||
.list-view { padding: 0 24px 20px; overflow-y: auto; height: 100%; }
|
||||
|
||||
/* ─── MODAL ─────────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.7); backdrop-filter: blur(4px);
|
||||
z-index: 1000; display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0; pointer-events: none; transition: opacity 0.2s;
|
||||
}
|
||||
.modal-overlay.open { opacity: 1; pointer-events: all; }
|
||||
.modal-box {
|
||||
background: var(--card); border: 1px solid var(--border-mid); border-radius: var(--r-xl);
|
||||
width: 600px; max-width: 95vw; max-height: 90vh; display: flex; flex-direction: column;
|
||||
box-shadow: var(--sh-lg); transform: scale(0.97) translateY(8px); transition: transform 0.2s;
|
||||
}
|
||||
.modal-overlay.open .modal-box { transform: scale(1) translateY(0); }
|
||||
.modal-header { padding: 18px 20px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-shrink: 0; }
|
||||
.modal-title { font-size: 15px; font-weight: 700; color: var(--text-1); line-height: 1.4; }
|
||||
.modal-close { width: 28px; height: 28px; border-radius: var(--r); background: rgba(255,255,255,0.05); border: 1px solid var(--border); color: var(--text-2); display: flex; align-items: center; justify-content: center; font-size: 16px; cursor: pointer; flex-shrink: 0; transition: all 0.15s; }
|
||||
.modal-close:hover { background: rgba(255,255,255,0.08); color: var(--text-1); }
|
||||
.modal-body { flex: 1; overflow-y: auto; padding: 18px 20px; }
|
||||
.modal-footer { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
/* ─── WIDE MODAL ────────────────────────────────────────────────── */
|
||||
.modal-box.wide { width: 760px; }
|
||||
|
||||
/* ─── TIMELINE ──────────────────────────────────────────────────── */
|
||||
.timeline { display: flex; flex-direction: column; gap: 0; }
|
||||
.tl-item { display: flex; gap: 12px; position: relative; padding-bottom: 16px; }
|
||||
.tl-item:last-child { padding-bottom: 0; }
|
||||
.tl-item:last-child .tl-line { display: none; }
|
||||
.tl-dot-wrap { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; }
|
||||
.tl-dot { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; border: 2px solid var(--border); background: var(--card); flex-shrink: 0; }
|
||||
.tl-dot.created { background: rgba(239,68,68,0.15); border-color: var(--red); }
|
||||
.tl-dot.assigned { background: rgba(59,130,246,0.15); border-color: var(--blue); }
|
||||
.tl-dot.verified { background: rgba(245,158,11,0.15); border-color: var(--amber); }
|
||||
.tl-dot.survey { background: rgba(168,85,247,0.15); border-color: var(--purple); }
|
||||
.tl-dot.escalated { background: rgba(249,115,22,0.15); border-color: var(--orange); }
|
||||
.tl-dot.resolved { background: rgba(34,197,94,0.15); border-color: var(--green); }
|
||||
.tl-line { flex: 1; width: 1px; background: var(--border); margin: 4px 0; min-height: 12px; }
|
||||
.tl-content { flex: 1; padding-top: 4px; }
|
||||
.tl-action { font-size: 12px; color: var(--text-1); font-weight: 500; line-height: 1.4; }
|
||||
.tl-meta { font-size: 11px; color: var(--text-3); margin-top: 3px; display: flex; gap: 6px; }
|
||||
|
||||
/* ─── ESCALATION PIPELINE ───────────────────────────────────────── */
|
||||
.esc-pipeline { display: flex; align-items: center; gap: 0; margin-bottom: 20px; overflow-x: auto; padding-bottom: 4px; }
|
||||
.esc-step {
|
||||
flex: 1; min-width: 140px; text-align: center; position: relative;
|
||||
padding: 14px 12px; background: var(--card); border: 1px solid var(--border);
|
||||
border-right: none; first-child { border-radius: var(--r-md) 0 0 var(--r-md); }
|
||||
}
|
||||
.esc-step:first-child { border-radius: var(--r-md) 0 0 var(--r-md); }
|
||||
.esc-step:last-child { border-radius: 0 var(--r-md) var(--r-md) 0; border-right: 1px solid var(--border); }
|
||||
.esc-step.active { background: var(--blue-soft); border-color: rgba(59,130,246,0.3); }
|
||||
.esc-step-num { width: 28px; height: 28px; border-radius: 50%; background: var(--border); color: var(--text-2); font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; margin: 0 auto 8px; }
|
||||
.esc-step.active .esc-step-num { background: var(--blue); color: #fff; }
|
||||
.esc-step-label { font-size: 11px; font-weight: 600; color: var(--text-2); }
|
||||
.esc-step.active .esc-step-label { color: var(--blue); }
|
||||
|
||||
/* ─── SURVEY CARD ───────────────────────────────────────────────── */
|
||||
.survey-card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: var(--r-md);
|
||||
padding: 16px; margin-bottom: 10px; cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.survey-card:hover { border-color: var(--border-mid); background: var(--card-hover); }
|
||||
.sc-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||
.sc-id { font-size: 10px; color: var(--text-3); font-family: monospace; font-weight: 600; }
|
||||
.sc-officer { font-size: 13px; font-weight: 600; color: var(--text-1); }
|
||||
.sc-jabatan { font-size: 11px; color: var(--text-3); }
|
||||
.sc-temuan { font-size: 12px; color: var(--text-2); line-height: 1.5; margin: 8px 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.sc-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
|
||||
.sc-meta { font-size: 11px; color: var(--text-3); display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* ─── SURVEY STATUS BADGE ───────────────────────────────────────── */
|
||||
.status-draft { background: rgba(113,113,122,0.1); color: var(--text-3); border: 1px solid var(--border); padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600; }
|
||||
.status-submitted { background: var(--blue-soft); color: var(--blue); border: 1px solid rgba(59,130,246,0.2); padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600; }
|
||||
.status-verified { background: var(--green-soft); color: var(--green); border: 1px solid rgba(34,197,94,0.2); padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600; }
|
||||
|
||||
/* ─── PROGRAM PAGE ──────────────────────────────────────────────── */
|
||||
.program-tabs { display: flex; gap: 4px; padding: 16px 24px 0; background: var(--bg2); border-bottom: 1px solid var(--border); overflow-x: auto; }
|
||||
.prog-tab {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 8px 14px; border-radius: var(--r) var(--r) 0 0;
|
||||
font-size: 13px; font-weight: 500; color: var(--text-2);
|
||||
cursor: pointer; transition: all 0.15s; border: 1px solid transparent;
|
||||
border-bottom: none; white-space: nowrap; margin-bottom: -1px;
|
||||
background: transparent;
|
||||
}
|
||||
.prog-tab:hover { color: var(--text-1); background: rgba(255,255,255,0.03); }
|
||||
.prog-tab.active { background: var(--card); border-color: var(--border); border-bottom-color: var(--card); color: var(--text-1); }
|
||||
.prog-body { flex: 1; overflow: hidden; display: none; }
|
||||
.prog-body.active { display: flex; flex-direction: column; }
|
||||
.prog-scroll { flex: 1; overflow-y: auto; padding: 20px 24px; }
|
||||
|
||||
/* ─── STAT PAIR ─────────────────────────────────────────────────── */
|
||||
.stat-pair { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||||
.stat-pair:last-child { border-bottom: none; }
|
||||
.stat-key { font-size: 12px; color: var(--text-3); }
|
||||
.stat-val { font-size: 12px; color: var(--text-1); font-weight: 500; text-align: right; }
|
||||
|
||||
/* ─── EMPTY STATE ───────────────────────────────────────────────── */
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
|
||||
.empty-icon { font-size: 40px; margin-bottom: 12px; opacity: 0.4; }
|
||||
.empty-title { font-size: 14px; font-weight: 600; color: var(--text-2); margin-bottom: 6px; }
|
||||
.empty-desc { font-size: 12px; color: var(--text-3); max-width: 280px; line-height: 1.6; }
|
||||
|
||||
/* ─── CHART CONTAINER ───────────────────────────────────────────── */
|
||||
.chart-wrap { position: relative; height: 180px; }
|
||||
|
||||
/* ─── DIVIDER ───────────────────────────────────────────────────── */
|
||||
.divider { height: 1px; background: var(--border); margin: 14px 0; }
|
||||
|
||||
/* ─── TOOLTIP ───────────────────────────────────────────────────── */
|
||||
.custom-tooltip .leaflet-tooltip {
|
||||
background: var(--card) !important; border: 1px solid var(--border) !important;
|
||||
border-radius: var(--r-md) !important; color: var(--text-1) !important;
|
||||
font-family: 'Inter', sans-serif !important; font-size: 11px !important;
|
||||
padding: 8px 12px !important; backdrop-filter: blur(8px) !important; box-shadow: var(--sh) !important;
|
||||
}
|
||||
.tt-title { font-size: 12px; font-weight: 700; color: var(--text-1); margin-bottom: 4px; }
|
||||
.tt-sub { font-size: 10px; color: var(--text-3); margin-bottom: 6px; }
|
||||
.tt-row { display: flex; justify-content: space-between; gap: 16px; font-size: 11px; color: var(--text-2); padding: 2px 0; }
|
||||
.tt-val { color: var(--text-1); font-weight: 600; }
|
||||
|
||||
/* ─── NOTIFICATION DROPDOWN ─────────────────────────────────────── */
|
||||
.notif-dropdown {
|
||||
position: absolute; top: calc(100% + 8px); right: 0; width: 320px; z-index: 100;
|
||||
background: var(--card); border: 1px solid var(--border-mid); border-radius: var(--r-lg);
|
||||
box-shadow: var(--sh-lg); overflow: hidden;
|
||||
}
|
||||
.notif-header { padding: 12px 14px; border-bottom: 1px solid var(--border); font-size: 12px; font-weight: 600; color: var(--text-1); }
|
||||
.notif-item { padding: 10px 14px; border-bottom: 1px solid var(--border); cursor: pointer; transition: background 0.1s; display: flex; gap: 10px; }
|
||||
.notif-item:last-child { border-bottom: none; }
|
||||
.notif-item:hover { background: rgba(255,255,255,0.03); }
|
||||
.notif-item.unread { background: rgba(59,130,246,0.04); }
|
||||
.notif-dot-indicator { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; margin-top: 4px; }
|
||||
.notif-msg { font-size: 12px; color: var(--text-2); line-height: 1.4; flex: 1; }
|
||||
.notif-time { font-size: 10px; color: var(--text-3); margin-top: 3px; }
|
||||
|
||||
/* ─── TOPBAR ACTIONS ────────────────────────────────────────────── */
|
||||
.topbar-actions-area { position: relative; }
|
||||
|
||||
/* ─── PROGRAM STAT GRID ─────────────────────────────────────────── */
|
||||
.prog-stat-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 16px; }
|
||||
.prog-stat-card { background: var(--bg); border: 1px solid var(--border); border-radius: var(--r-md); padding: 12px; }
|
||||
.prog-stat-val { font-size: 20px; font-weight: 800; color: var(--text-1); letter-spacing: -0.5px; margin-bottom: 4px; }
|
||||
.prog-stat-lbl { font-size: 11px; color: var(--text-3); }
|
||||
|
||||
/* ─── TOAST NOTIFICATION ────────────────────────────────────────── */
|
||||
.toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 2000; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
|
||||
.toast {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
background: var(--card); border: 1px solid var(--border-mid); border-radius: var(--r-md);
|
||||
padding: 12px 14px; box-shadow: var(--sh-lg); max-width: 340px;
|
||||
pointer-events: all; transform: translateX(110%); transition: transform 0.3s var(--ease);
|
||||
}
|
||||
.toast.show { transform: translateX(0); }
|
||||
.toast-icon { font-size: 16px; flex-shrink: 0; }
|
||||
.toast-body { flex: 1; }
|
||||
.toast-title { font-size: 13px; font-weight: 600; color: var(--text-1); }
|
||||
.toast-msg { font-size: 12px; color: var(--text-3); margin-top: 2px; }
|
||||
|
||||
/* ─── LEAFLET OVERRIDES ─────────────────────────────────────────── */
|
||||
.leaflet-container { background: var(--bg) !important; }
|
||||
.leaflet-control-zoom { border: 1px solid var(--border) !important; border-radius: var(--r-md) !important; overflow: hidden; }
|
||||
.leaflet-control-zoom a { background: rgba(9,9,11,0.9) !important; color: var(--text-2) !important; border-color: var(--border) !important; }
|
||||
.leaflet-control-zoom a:hover { background: var(--card) !important; color: var(--text-1) !important; }
|
||||
.leaflet-bar a:first-child { border-radius: var(--r-md) var(--r-md) 0 0 !important; }
|
||||
.leaflet-bar a:last-child { border-radius: 0 0 var(--r-md) var(--r-md) !important; }
|
||||
|
||||
/* ─── MARKER PULSE ──────────────────────────────────────────────── */
|
||||
.marker-pulse { position: relative; }
|
||||
.marker-pulse::after {
|
||||
content: ''; position: absolute; inset: -3px; border-radius: 50%;
|
||||
border: 2px solid currentColor; opacity: 0; animation: pulse-ring 2s ease-out infinite;
|
||||
}
|
||||
@keyframes pulse-ring { 0% { inset: -3px; opacity: 0.5; } 100% { inset: -10px; opacity: 0; } }
|
||||
|
||||
/* ─── PROGRAM PROGRESS ──────────────────────────────────────────── */
|
||||
.prog-overview-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 20px; }
|
||||
.prog-overview-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--r-md); padding: 14px; transition: all 0.15s; cursor: pointer; }
|
||||
.prog-overview-card:hover { border-color: var(--border-mid); transform: translateY(-1px); box-shadow: var(--sh); }
|
||||
.poc-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.poc-icon { font-size: 20px; }
|
||||
.poc-name { font-size: 13px; font-weight: 600; color: var(--text-1); }
|
||||
.poc-ministry { font-size: 10px; color: var(--text-3); margin-top: 1px; }
|
||||
.poc-progress { margin-bottom: 8px; }
|
||||
.poc-pct { font-size: 20px; font-weight: 800; color: var(--text-1); letter-spacing: -0.5px; }
|
||||
.poc-target { font-size: 11px; color: var(--text-3); margin-bottom: 6px; }
|
||||
.poc-footer { display: flex; align-items: center; justify-content: space-between; }
|
||||
.poc-budget { font-size: 11px; color: var(--text-2); }
|
||||
.poc-issues { font-size: 11px; color: var(--red); font-weight: 600; }
|
||||
|
||||
/* ─── UTILITY ───────────────────────────────────────────────────── */
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
.mb-2 { margin-bottom: 8px; }
|
||||
.mb-3 { margin-bottom: 12px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-3 { margin-top: 12px; }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-xs { font-size: 11px; }
|
||||
.font-bold { font-weight: 700; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.text-muted { color: var(--text-3); }
|
||||
.text-secondary { color: var(--text-2); }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.w-full { width: 100%; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ─── ANIMATIONS ────────────────────────────────────────────────── */
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes slideIn { from { transform: translateX(-10px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
.fade-in { animation: fadeIn 0.25s var(--ease) forwards; }
|
||||
.slide-in { animation: slideIn 0.2s var(--ease) forwards; }
|
||||
|
||||
/* ─── VIEW TOGGLE ───────────────────────────────────────────────── */
|
||||
.view-toggle { display: flex; border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; }
|
||||
.view-btn { padding: 5px 12px; font-size: 12px; font-weight: 500; color: var(--text-2); background: transparent; border: none; cursor: pointer; transition: all 0.15s; }
|
||||
.view-btn.active { background: var(--blue-soft); color: var(--blue); }
|
||||
.view-btn:hover:not(.active) { color: var(--text-1); background: rgba(255,255,255,0.04); }
|
||||
Reference in New Issue
Block a user