// public/dashboard/js/realtime.js // Realtime dashboard (SSE + fallback snapshot) import { apiGetRealtimeSnapshot } from './api.js'; import { API_CONFIG } from './config.js'; const REALTIME_STREAM_URL = `${API_CONFIG.BASE_URL}/retribusi/v1/realtime/stream`; class RealtimeManager { constructor() { this.eventSource = null; this.snapshotTimer = null; this.isConnected = false; } init() { // Mulai SSE, kalau gagal pakai fallback polling snapshot this.startSSE(); this.startSnapshotFallback(); } startSSE() { try { const token = localStorage.getItem('token') || ''; // SSE tidak support custom headers, jadi token harus di query string // TAPI sesuai spec, parameter yang benar adalah 'last_id', bukan 'token' // Token tetap dikirim via Authorization header jika backend support // Untuk SSE, kita pakai query string karena EventSource tidak support custom headers const params = new URLSearchParams(); // Jika ada last_id dari state, tambahkan // params.append('last_id', this.lastEventId || ''); // Token tetap di query string untuk SSE (limitation EventSource) if (token) { // Backend harus handle token dari query string atau implement custom SSE handler // Untuk sekarang, kita tetap pakai query string karena EventSource limitation params.append('token', token); } const url = `${REALTIME_STREAM_URL}?${params.toString()}`; console.log('[Realtime] Connect SSE:', url); // EventSource tidak support custom headers, jadi token harus di query string // Backend harus handle ini atau implement custom SSE handler dengan fetch + stream this.eventSource = new EventSource(url); this.eventSource.onopen = () => { console.log('[Realtime] SSE opened'); this.isConnected = true; }; this.eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); console.log('[Realtime] SSE event:', data); // Backend kirim realtime_events, bisa berupa: // - event baru (entry baru) // - snapshot agregat (total_count_today, by_category, dll) // Sesuaikan dengan struktur yang backend kirim via SSE // Jika backend kirim snapshot via SSE, parse sama seperti fetchSnapshot() if (data.total_count_today !== undefined || data.by_category) { const personCat = (data.by_category || []).find(c => c.category === 'person_walk') || { total_count: 0 }; const motorCat = (data.by_category || []).find(c => c.category === 'motor') || { total_count: 0 }; const carCat = (data.by_category || []).find(c => c.category === 'car') || { total_count: 0 }; const kpiData = { totalPeople: personCat.total_count || 0, totalVehicles: (motorCat.total_count || 0) + (carCat.total_count || 0), totalCount: data.total_count_today || 0, totalAmount: data.total_amount_today || 0 }; window.dispatchEvent(new CustomEvent('realtime:snapshot', { detail: kpiData })); } } catch (e) { console.warn('[Realtime] gagal parse event data', e, event.data); } }; this.eventSource.onerror = (err) => { console.error('[Realtime] SSE error', err); this.isConnected = false; // Biarkan browser auto-reconnect, kalau tetap gagal nanti fallback snapshot yang jalan }; } catch (e) { console.error('[Realtime] tidak bisa inisialisasi SSE', e); this.isConnected = false; } } async fetchSnapshot() { try { // Struktur response setelah di-unwrap: { total_count_today, total_amount_today, by_gate, by_category } // Gunakan timezone Indonesia UTC+7 untuk mendapatkan tanggal lokal yang benar const now = new Date(); // Format tanggal dalam timezone Asia/Jakarta (UTC+7) const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Jakarta', year: 'numeric', month: '2-digit', day: '2-digit' }); const today = formatter.format(now); const snapshot = await apiGetRealtimeSnapshot({ date: today, location_code: '' // bisa diambil dari state dashboard jika perlu }); console.log('[Realtime] snapshot:', snapshot); // Parse data snapshot sesuai struktur resmi const parsed = { totalCount: snapshot.total_count_today || 0, totalAmount: snapshot.total_amount_today || 0, byGate: Array.isArray(snapshot.by_gate) ? snapshot.by_gate : [], byCategory: Array.isArray(snapshot.by_category) ? snapshot.by_category : [] }; // Hitung total orang & kendaraan dari by_category const personCat = parsed.byCategory.find(c => c.category === 'person_walk') || { total_count: 0 }; const motorCat = parsed.byCategory.find(c => c.category === 'motor') || { total_count: 0 }; const carCat = parsed.byCategory.find(c => c.category === 'car') || { total_count: 0 }; const kpiData = { totalPeople: personCat.total_count || 0, totalVehicles: (motorCat.total_count || 0) + (carCat.total_count || 0), totalCount: parsed.totalCount, totalAmount: parsed.totalAmount }; // Dispatch event untuk update dashboard real-time window.dispatchEvent(new CustomEvent('realtime:snapshot', { detail: kpiData })); } catch (e) { console.error('[Realtime] gagal ambil snapshot', e); } } startSnapshotFallback() { // Polling ringan tiap 5 detik, hanya kalau SSE belum stable if (this.snapshotTimer) clearInterval(this.snapshotTimer); this.snapshotTimer = setInterval(() => { if (!this.isConnected) { this.fetchSnapshot(); } }, 5000); } stop() { if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } if (this.snapshotTimer) { clearInterval(this.snapshotTimer); this.snapshotTimer = null; } this.isConnected = false; } } export const Realtime = new RealtimeManager(); // Auto-init saat dashboard dibuka document.addEventListener('DOMContentLoaded', () => { Realtime.init(); });