Initial commit: Retribusi frontend dengan dashboard, event logs, dan settings

This commit is contained in:
mwpn
2025-12-18 11:21:40 +07:00
commit b3573ed390
35 changed files with 7368 additions and 0 deletions

View File

@@ -0,0 +1,434 @@
/* public/dashboard/css/app.css
* Enterprise-style admin dashboard: clean, minimal, responsive.
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-color: #f3f4f6;
color: #111827;
}
body {
min-height: 100vh;
}
a {
color: inherit;
text-decoration: none;
}
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Top bar */
.topbar {
position: sticky;
top: 0;
z-index: 10;
background-color: #ffffff;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.topbar-title {
font-size: 1.15rem;
font-weight: 600;
color: #111827;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.85rem;
color: #6b7280;
}
.topbar-link {
padding: 0.4rem 0.85rem;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
background-color: #f9fafb;
font-size: 0.85rem;
font-weight: 500;
color: #374151;
transition: all 0.15s ease;
cursor: pointer;
}
.topbar-link:hover {
background-color: #f3f4f6;
border-color: #d1d5db;
color: #111827;
}
/* Layout container */
.container {
width: 100%;
max-width: 1200px;
margin: 1.5rem auto 2rem;
padding: 0 1.25rem;
flex: 1;
}
/* Login layout */
.login-root {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #f3f4f6;
}
.login-card {
width: 100%;
max-width: 380px;
background-color: #ffffff;
border-radius: 0.75rem;
padding: 2rem 2.25rem;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.08);
border: 1px solid #e5e7eb;
}
.login-header {
margin-bottom: 1.75rem;
}
.login-title {
margin: 0 0 0.35rem;
font-size: 1.35rem;
font-weight: 600;
}
.login-subtitle {
margin: 0;
font-size: 0.85rem;
color: #6b7280;
}
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 0.35rem;
color: #374151;
}
.form-input {
width: 100%;
border-radius: 0.55rem;
border: 1px solid #d1d5db;
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
background-color: #ffffff;
color: #111827;
}
.form-input:focus {
outline: none;
border-color: #111827;
box-shadow: 0 0 0 1px #1118271a;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.6rem;
border: none;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease, box-shadow 0.15s ease, transform 0.05s ease;
}
.btn-primary {
width: 100%;
padding: 0.55rem 1rem;
background-color: #111827;
color: white;
}
.btn-primary:hover {
background-color: #000000;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: default;
}
.error-banner {
display: none;
margin-bottom: 0.75rem;
padding: 0.5rem 0.7rem;
border-radius: 0.5rem;
background-color: #fef2f2;
color: #b91c1c;
font-size: 0.8rem;
border: 1px solid #fecaca;
}
.error-banner.visible {
display: block;
}
/* Filters */
.filters {
background-color: #ffffff;
border-radius: 0.9rem;
border: 1px solid #e5e7eb;
padding: 1rem 1.1rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 0.75rem 1rem;
margin-bottom: 1.5rem;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filter-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
color: #6b7280;
}
.filter-control {
border-radius: 0.55rem;
border: 1px solid #d1d5db;
padding: 0.45rem 0.65rem;
font-size: 0.85rem;
background-color: #ffffff;
color: #111827;
}
.filter-control:focus {
outline: none;
border-color: #111827;
box-shadow: 0 0 0 1px #1118271a;
}
/* Summary cards */
.summary-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.25rem;
margin-bottom: 2rem;
}
@media (max-width: 1200px) {
.summary-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.summary-grid {
grid-template-columns: 1fr;
}
}
.card {
position: relative;
background-color: #ffffff;
border-radius: 1rem;
border: 1px solid #e5e7eb;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: #d1d5db;
}
.card-primary {
border-left: 4px solid #3b82f6;
}
.card-success {
border-left: 4px solid #10b981;
}
.card-info {
border-left: 4px solid #3b82f6;
}
.card-warning {
border-left: 4px solid #f59e0b;
}
.card-danger {
border-left: 4px solid #ef4444;
}
.card-secondary {
border-left: 4px solid #8b5cf6;
}
.card-icon {
font-size: 2rem;
line-height: 1;
flex-shrink: 0;
}
.card-content {
flex: 1;
min-width: 0;
}
.card-title {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
margin-bottom: 0.5rem;
font-weight: 500;
}
.card-value {
font-size: 1.75rem;
font-weight: 700;
color: #111827;
line-height: 1.2;
margin-bottom: 0.25rem;
}
.card-subtext {
margin-top: 0.25rem;
font-size: 0.75rem;
color: #9ca3af;
line-height: 1.4;
}
.card-loading-overlay {
position: absolute;
inset: 0;
border-radius: inherit;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
color: #6b7280;
display: none;
}
.card-loading-overlay.visible {
display: flex;
}
/* Charts & tables layout */
.content-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1.2fr);
gap: 1.2rem;
}
@media (max-width: 900px) {
.content-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.panel {
background-color: #ffffff;
border-radius: 1rem;
border: 1px solid #e5e7eb;
padding: 1.25rem 1.5rem 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.panel-title {
font-size: 1rem;
font-weight: 600;
color: #111827;
margin: 0;
}
.panel-body {
margin-top: 0.5rem;
}
.chart-container {
position: relative;
width: 100%;
height: 300px;
margin-top: 0.5rem;
}
/* Simple table styles (for future extension) */
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.table th,
.table td {
padding: 0.4rem 0.5rem;
border-bottom: 1px solid #e5e7eb;
}
.table th {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6b7280;
text-align: left;
}
.table td {
color: #111827;
}
.text-right {
text-align: right;
}
.empty-state {
padding: 0.6rem 0.5rem;
font-size: 0.8rem;
color: #9ca3af;
}
.error-text {
font-size: 0.8rem;
color: #b91c1c;
}

View File

@@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dashboard - Btekno Retribusi Admin</title>
<link rel="stylesheet" href="css/app.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<style>
.video-panel {
background: #fff;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
overflow: hidden;
}
.video-panel-header {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.video-panel-title {
font-weight: 600;
font-size: 0.95rem;
color: #111827;
}
.video-toggle {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: #fff;
color: #374151;
cursor: pointer;
}
.video-toggle:hover {
background: #f9fafb;
}
.video-container {
position: relative;
width: 100%;
aspect-ratio: 16/9;
background: #000;
}
.video-container video {
width: 100%;
height: 100%;
object-fit: contain;
}
.video-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #9ca3af;
font-size: 0.875rem;
}
</style>
</head>
<body>
<div class="page">
<header class="topbar">
<div class="topbar-title">Dashboard Retribusi</div>
<div class="topbar-actions">
<span id="topbar-date" style="font-size:0.85rem;color:#6b7280;"></span>
<a href="event.html" class="topbar-link">Events</a>
<a href="settings.html" class="topbar-link">Pengaturan</a>
<button id="logout-button" class="topbar-link">
Logout
</button>
</div>
</header>
<main class="container">
<section class="filters">
<div class="filter-group">
<label for="filter-date" class="filter-label">Tanggal</label>
<input id="filter-date" type="date" class="filter-control" />
</div>
<div class="filter-group">
<label for="filter-location" class="filter-label">Lokasi</label>
<select id="filter-location" class="filter-control">
<option value="">Semua Lokasi</option>
</select>
</div>
<div class="filter-group">
<label for="filter-gate" class="filter-label">Gate</label>
<select id="filter-gate" class="filter-control">
<option value="">Semua Gate</option>
</select>
</div>
</section>
<div id="summary-error" class="error-text" style="margin-bottom:0.5rem; display:none;"></div>
<section class="summary-grid">
<article class="card card-success">
<div class="card-content">
<div class="card-title">Total Pendapatan</div>
<div id="card-total-amount" class="card-value">Rp 0</div>
<div class="card-subtext">Nilai bruto sebelum pemotongan</div>
</div>
<div id="summary-loading" class="card-loading-overlay">Memuat data...</div>
</article>
<article class="card card-info">
<div class="card-content">
<div class="card-title">Jumlah Orang</div>
<div id="card-person-count" class="card-value">0</div>
<div class="card-subtext">Person walk (pejalan kaki)</div>
</div>
</article>
<article class="card card-warning">
<div class="card-content">
<div class="card-title">Jumlah Motor</div>
<div id="card-motor-count" class="card-value">0</div>
<div class="card-subtext">Kendaraan roda dua</div>
</div>
</article>
<article class="card card-danger">
<div class="card-content">
<div class="card-title">Jumlah Mobil</div>
<div id="card-car-count" class="card-value">0</div>
<div class="card-subtext">Kendaraan roda empat</div>
</div>
</article>
</section>
<section class="content-grid">
<article class="panel">
<div class="panel-header">
<h2 class="panel-title">Trend Harian</h2>
</div>
<div class="panel-body">
<div class="chart-container">
<canvas id="daily-chart"></canvas>
</div>
</div>
</article>
<article class="panel">
<div class="panel-header">
<h2 class="panel-title">Per Kategori</h2>
</div>
<div class="panel-body">
<div class="chart-container" style="height:240px;">
<canvas id="category-chart"></canvas>
</div>
</div>
</article>
</section>
<section id="video-section" style="display:none; margin-top:1.5rem;">
<article class="video-panel">
<div class="video-panel-header">
<div id="video-panel-title" class="video-panel-title">Live Camera</div>
<button id="video-toggle" class="video-toggle">Hidupkan</button>
</div>
<div class="video-container">
<video id="video-player" controls style="display:none;"></video>
<div id="video-placeholder" class="video-placeholder">
Kamera tidak aktif
</div>
</div>
</article>
</section>
</main>
</div>
<script type="module">
import { Auth } from './js/auth.js';
import './js/dashboard.js';
import './js/realtime.js';
document.getElementById('logout-button')?.addEventListener('click', () => {
Auth.logout();
});
</script>
</body>
</html>

847
public/dashboard/event.html Normal file
View File

@@ -0,0 +1,847 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Events - Btekno Retribusi Admin</title>
<link rel="stylesheet" href="css/app.css" />
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<style>
.events-layout {
display: grid;
grid-template-columns: 1fr 400px;
gap: 1.5rem;
}
.events-layout.video-hidden {
grid-template-columns: 1fr;
}
@media (max-width: 1024px) {
.events-layout {
grid-template-columns: 1fr;
}
}
.video-panel {
background: #fff;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
overflow: hidden;
}
.video-panel-header {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.video-panel-title {
font-weight: 600;
font-size: 0.95rem;
color: #111827;
}
.video-toggle {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: #fff;
color: #374151;
cursor: pointer;
}
.video-toggle:hover {
background: #f9fafb;
}
.video-container {
position: relative;
width: 100%;
aspect-ratio: 16/9;
background: #000;
}
.video-container video {
width: 100%;
height: 100%;
object-fit: contain;
}
.video-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #9ca3af;
font-size: 0.875rem;
}
.events-table {
width: 100%;
border-collapse: collapse;
}
.events-table thead {
background: #f9fafb;
border-bottom: 2px solid #e5e7eb;
}
.events-table th {
padding: 0.75rem;
text-align: left;
font-weight: 600;
font-size: 0.875rem;
color: #374151;
}
.events-table td {
padding: 0.75rem;
border-bottom: 1px solid #e5e7eb;
font-size: 0.875rem;
color: #111827;
}
.events-table tbody tr:hover {
background: #f9fafb;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.badge-person {
background: #dbeafe;
color: #1e40af;
}
.badge-motor {
background: #fef3c7;
color: #92400e;
}
.badge-car {
background: #e0e7ff;
color: #3730a3;
}
.loading-overlay {
display: none;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
align-items: center;
justify-content: center;
z-index: 10;
}
.loading-overlay.visible {
display: flex;
}
</style>
</head>
<body>
<div class="page">
<header class="topbar">
<div class="topbar-title">Events</div>
<div class="topbar-actions">
<a href="dashboard.html" class="topbar-link">Dashboard</a>
<a href="settings.html" class="topbar-link">Pengaturan</a>
<button id="logout-button" class="topbar-link" style="border-radius:0.5rem;border-color:#d1d5db;">
Logout
</button>
</div>
</header>
<main class="container">
<section class="filters">
<div class="filter-group">
<label for="filter-date" class="filter-label">Tanggal</label>
<input id="filter-date" type="date" class="filter-control" />
</div>
<div class="filter-group">
<label for="filter-location" class="filter-label">Lokasi</label>
<select id="filter-location" class="filter-control">
<option value="">Semua Lokasi</option>
</select>
</div>
<div class="filter-group">
<label for="filter-gate" class="filter-label">Gate</label>
<select id="filter-gate" class="filter-control">
<option value="">Semua Gate</option>
</select>
</div>
</section>
<div id="events-error" class="error-text" style="margin-bottom:0.5rem; display:none;"></div>
<div id="events-layout" class="events-layout">
<div>
<article class="panel">
<div class="panel-header">
<h2 class="panel-title">Daftar Events</h2>
</div>
<div class="panel-body" style="position:relative;">
<div id="events-loading" class="loading-overlay">
<div>Memuat data...</div>
</div>
<div style="overflow-x:auto;">
<table class="events-table">
<thead>
<tr>
<th>Waktu</th>
<th>Lokasi</th>
<th>Gate</th>
<th>Kategori</th>
<th>Jumlah</th>
<th>Pendapatan</th>
</tr>
</thead>
<tbody id="events-tbody">
<tr>
<td colspan="6" style="text-align:center;padding:2rem;color:#6b7280;">
Memuat data...
</td>
</tr>
</tbody>
</table>
</div>
</div>
</article>
</div>
<div id="video-panel-container" style="display:none;">
<article class="video-panel">
<div class="video-panel-header">
<div id="video-panel-title" class="video-panel-title"></div>
<button id="video-toggle" class="video-toggle">Hidupkan</button>
</div>
<div class="video-container">
<video id="video-player" controls style="display:none;"></video>
<div id="video-placeholder" class="video-placeholder">
Kamera tidak aktif
</div>
</div>
</article>
</div>
</div>
</main>
</div>
<script type="module">
import { Auth } from './js/auth.js';
import { apiGetLocations, apiGetGates, apiGetEntryEvents, API_CONFIG } from './js/api.js';
import './js/realtime.js';
// Helper untuk build query string
function buildQuery(params = {}) {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
search.append(key, value);
}
});
const qs = search.toString();
return qs ? `?${qs}` : '';
}
// Check auth
if (!Auth.isAuthenticated()) {
window.location.href = '../index.php';
}
// Logout handler
document.getElementById('logout-button')?.addEventListener('click', () => {
Auth.logout();
});
// Video HLS setup - menggunakan URL kamera dari database
let gatesCache = {}; // Cache untuk gates dengan camera URL
let locationsCache = {}; // Cache untuk locations
let hls = null;
let isVideoPlaying = false;
let currentVideoUrl = null;
const videoPanelContainer = document.getElementById('video-panel-container');
const videoPanelTitle = document.getElementById('video-panel-title');
const videoEl = document.getElementById('video-player');
const placeholderEl = document.getElementById('video-placeholder');
const toggleBtn = document.getElementById('video-toggle');
// Load gates untuk mendapatkan camera URL dari database
async function loadGatesForCamera() {
try {
const response = await apiGetGates(null, { limit: 1000 });
let gates = [];
if (Array.isArray(response)) {
gates = response;
} else if (response && Array.isArray(response.data)) {
gates = response.data;
}
// Build cache: location_code -> { url, name, gate_name }
gatesCache = {};
gates.forEach(gate => {
const locationCode = gate.location_code || '';
const camera = gate.camera || null;
// Hanya simpan gate yang punya camera URL
if (camera && camera.trim() !== '') {
// Jika sudah ada, gunakan yang pertama (atau bisa dipilih gate tertentu)
if (!gatesCache[locationCode]) {
const locationName = locationsCache[locationCode]?.name || locationCode;
gatesCache[locationCode] = {
url: camera.trim(),
name: locationName,
gate_name: gate.name || gate.gate_code || '',
location_code: locationCode
};
}
}
});
console.log('[Video] Gates dengan camera loaded:', Object.keys(gatesCache).length);
} catch (err) {
console.error('[Video] Error loading gates:', err);
}
}
// Load locations untuk mendapatkan nama lokasi
async function loadLocationsForCamera() {
try {
const response = await apiGetLocations({ limit: 1000 });
let locations = [];
if (Array.isArray(response)) {
locations = response;
} else if (response && Array.isArray(response.data)) {
locations = response.data;
}
// Build cache: location_code -> { name }
locationsCache = {};
locations.forEach(loc => {
const code = loc.code || loc.location_code || '';
locationsCache[code] = {
name: loc.name || loc.label || code
};
});
} catch (err) {
console.error('[Video] Error loading locations:', err);
}
}
function getCameraForLocation(locationCode) {
if (!locationCode) return null;
return gatesCache[locationCode] || null;
}
function showVideoPanel(locationCode) {
const eventsLayout = document.getElementById('events-layout');
const camera = getCameraForLocation(locationCode);
if (camera && camera.url) {
videoPanelContainer.style.display = 'block';
const displayName = camera.gate_name ? `${camera.name} - ${camera.gate_name}` : camera.name;
videoPanelTitle.textContent = displayName;
currentVideoUrl = camera.url;
if (eventsLayout) eventsLayout.classList.remove('video-hidden');
// Auto-stop video kalau lokasi berubah
if (isVideoPlaying) {
stopVideo();
}
} else {
videoPanelContainer.style.display = 'none';
if (eventsLayout) eventsLayout.classList.add('video-hidden');
if (isVideoPlaying) {
stopVideo();
}
currentVideoUrl = null;
}
}
function initVideo() {
if (!currentVideoUrl) {
console.warn('[Video] Tidak ada URL video untuk lokasi ini');
return;
}
if (Hls.isSupported()) {
hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90
});
hls.loadSource(currentVideoUrl);
hls.attachMedia(videoEl);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('[Video] HLS manifest parsed');
});
hls.on(Hls.Events.ERROR, (event, data) => {
console.error('[Video] HLS error:', data);
if (data.fatal) {
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
console.log('[Video] Network error, retrying...');
hls.startLoad();
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
console.log('[Video] Media error, recovering...');
hls.recoverMediaError();
} else {
console.error('[Video] Fatal error, destroying HLS');
hls.destroy();
hls = null;
stopVideo();
}
}
});
} else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS support (Safari)
videoEl.src = currentVideoUrl;
} else {
console.error('[Video] HLS tidak didukung di browser ini');
}
}
function startVideo() {
if (!currentVideoUrl) {
console.warn('[Video] Tidak ada URL video untuk lokasi ini');
return;
}
if (!hls && Hls.isSupported()) {
initVideo();
}
videoEl.style.display = 'block';
placeholderEl.style.display = 'none';
toggleBtn.textContent = 'Matikan';
isVideoPlaying = true;
if (videoEl.src || (hls && hls.media)) {
videoEl.play().catch(e => console.error('[Video] Play error:', e));
}
}
function stopVideo() {
if (hls) {
hls.destroy();
hls = null;
}
videoEl.pause();
videoEl.src = '';
videoEl.style.display = 'none';
placeholderEl.style.display = 'flex';
toggleBtn.textContent = 'Hidupkan';
isVideoPlaying = false;
}
toggleBtn.addEventListener('click', () => {
if (isVideoPlaying) {
stopVideo();
} else {
startVideo();
}
});
// Events table logic
const state = {
date: new Date().toISOString().split('T')[0],
locationCode: '',
gateCode: '',
category: '',
page: 1,
limit: 20,
total: 0,
totalPages: 1
};
// Tariff cache: map dari location_code|gate_code|category ke price
let tariffsMap = {};
async function loadTariffs() {
try {
const url = `${API_CONFIG.BASE_URL}/retribusi/v1/frontend/tariffs?limit=1000`;
const token = localStorage.getItem('token') || '';
console.log('[Events] Loading tariffs from:', url);
const res = await fetch(url, {
headers: {
'Content-Type': 'application/json',
'X-API-KEY': API_CONFIG.API_KEY,
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
if (!res.ok) {
const errorText = await res.text();
console.warn('[Events] Failed to load tariffs:', res.status, errorText);
return;
}
const response = await res.json();
console.log('[Events] Tariffs API response:', response);
let tariffs = [];
if (response && response.success && Array.isArray(response.data)) {
tariffs = response.data;
} else if (Array.isArray(response)) {
tariffs = response;
} else if (response && Array.isArray(response.data)) {
tariffs = response.data;
}
// Build map: location_code|gate_code|category -> price
tariffsMap = {};
tariffs.forEach(tariff => {
// Handle berbagai format field name
const locationCode = tariff.location_code || tariff.locationCode || '';
const gateCode = tariff.gate_code || tariff.gateCode || '';
const category = tariff.category || '';
const price = tariff.price || 0;
if (locationCode && gateCode && category) {
const key = `${locationCode}|${gateCode}|${category}`;
tariffsMap[key] = parseInt(price, 10);
console.log('[Events] Tariff mapped:', key, '->', tariffsMap[key], 'from tariff:', tariff);
} else {
console.warn('[Events] Invalid tariff data:', tariff);
}
});
console.log('[Events] Loaded tariffs:', Object.keys(tariffsMap).length, 'tariffs');
console.log('[Events] Tariffs map keys:', Object.keys(tariffsMap));
console.log('[Events] Sample tariffs map:', Object.fromEntries(Object.entries(tariffsMap).slice(0, 3)));
} catch (err) {
console.error('[Events] Error loading tariffs:', err);
}
}
function getTariffPrice(locationCode, gateCode, category) {
if (!locationCode || !gateCode || !category) {
return 0;
}
const key = `${locationCode}|${gateCode}|${category}`;
const price = tariffsMap[key] || 0;
return price;
}
async function loadLocations() {
const select = document.getElementById('filter-location');
if (!select) return;
select.innerHTML = '<option value="">Semua Lokasi</option>';
try {
const data = await apiGetLocations({ limit: 100 }); // Ambil semua lokasi
// Handle pagination response: { data: [...], total, page, limit }
// atau langsung array: [...]
let items = [];
if (Array.isArray(data)) {
items = data;
} else if (data && Array.isArray(data.data)) {
items = data.data;
}
items.forEach(loc => {
const opt = document.createElement('option');
opt.value = loc.code || loc.location_code || '';
opt.textContent = loc.name || loc.label || opt.value;
select.appendChild(opt);
});
} catch (err) {
console.error('loadLocations error', err);
}
}
async function loadGates() {
const select = document.getElementById('filter-gate');
if (!select) return;
select.innerHTML = '<option value="">Semua Gate</option>';
if (!state.locationCode) return;
try {
const data = await apiGetGates(state.locationCode);
const items = Array.isArray(data) ? data : (Array.isArray(data.data) ? data.data : []);
items.forEach(g => {
const opt = document.createElement('option');
opt.value = g.code || g.gate_code || '';
opt.textContent = g.name || g.label || opt.value;
select.appendChild(opt);
});
} catch (err) {
console.error('loadGates error', err);
}
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleString('id-ID', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
function formatCurrency(value) {
return 'Rp ' + new Intl.NumberFormat('id-ID').format(value || 0);
}
function getCategoryBadge(category) {
const map = {
'person_walk': { text: 'Orang', class: 'badge-person' },
'motor': { text: 'Motor', class: 'badge-motor' },
'car': { text: 'Mobil', class: 'badge-car' }
};
const item = map[category] || { text: category, class: '' };
return `<span class="badge ${item.class}">${item.text}</span>`;
}
function renderEvents(events) {
const tbody = document.getElementById('events-tbody');
if (!tbody) return;
if (!events || events.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:2rem;color:#6b7280;">Tidak ada data</td></tr>';
return;
}
tbody.innerHTML = events.map(event => {
// Mapping untuk struktur response dari entry_events table
// Fields: id, location_code, gate_code, category, event_time, source_ip, created_at
const timestamp = event.event_time || event.created_at || event.timestamp || event.date || event.time || '-';
const location = event.location_code || event.location || '-';
const gate = event.gate_code || event.gate || '-';
const category = event.category || event.type || '-';
// Entry events adalah individual events, jadi count = 1
const count = 1; // Setiap row adalah 1 event
// Hitung amount dari tariff price
const tariffPrice = getTariffPrice(location, gate, category);
const amount = tariffPrice; // 1 event * price = price
// Debug: log jika amount masih 0
if (amount === 0 && location !== '-' && gate !== '-' && category !== '-') {
const key = `${location}|${gate}|${category}`;
console.warn('[Events] Zero amount for:', { location, gate, category, key, tariffsMapKeys: Object.keys(tariffsMap) });
}
return `
<tr>
<td>${formatDateTime(timestamp)}</td>
<td>${location}</td>
<td>${gate}</td>
<td>${getCategoryBadge(category)}</td>
<td>${count}</td>
<td>${formatCurrency(amount)}</td>
</tr>
`;
}).join('');
}
async function loadEvents() {
const loadingEl = document.getElementById('events-loading');
const errorEl = document.getElementById('events-error');
const tbody = document.getElementById('events-tbody');
if (loadingEl) loadingEl.classList.add('visible');
if (errorEl) {
errorEl.style.display = 'none';
errorEl.textContent = '';
}
if (tbody) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:2rem;color:#6b7280;">Memuat data...</td></tr>';
}
try {
// Sesuai spec: start_date, end_date (bukan date saja)
// Jika state.date ada, gunakan sebagai start_date dan end_date
const params = {
page: state.page || 1,
limit: state.limit || 20,
location_code: state.locationCode || undefined,
gate_code: state.gateCode || undefined,
category: state.category || undefined
};
// Jika ada date filter, set start_date dan end_date
if (state.date) {
params.start_date = state.date;
params.end_date = state.date; // Same day
}
// Hapus undefined values
Object.keys(params).forEach(key => {
if (params[key] === undefined || params[key] === '') {
delete params[key];
}
});
// Request langsung untuk dapat full response dengan meta
const url = `${API_CONFIG.BASE_URL}/retribusi/v1/frontend/entry-events${buildQuery(params)}`;
const token = localStorage.getItem('token') || '';
console.log('[Events] Requesting:', url);
console.log('[Events] Params:', params);
const res = await fetch(url, {
headers: {
'Content-Type': 'application/json',
'X-API-KEY': API_CONFIG.API_KEY,
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
if (!res.ok) {
const errorText = await res.text();
console.error('[Events] API error:', res.status, errorText);
throw new Error(`HTTP ${res.status}: ${errorText}`);
}
const rawResponse = await res.json();
console.log('[Events] Raw API response:', rawResponse);
// Handle response format: { success: true, data: [...], meta: { page, limit, total, pages } }
let events = [];
let total = 0;
let currentPage = 1;
let totalPages = 1;
if (rawResponse && rawResponse.success && Array.isArray(rawResponse.data)) {
// Format dengan success dan meta
events = rawResponse.data;
if (rawResponse.meta) {
total = rawResponse.meta.total || events.length;
currentPage = rawResponse.meta.page || 1;
totalPages = rawResponse.meta.pages || Math.ceil(total / (rawResponse.meta.limit || 20));
}
} else if (Array.isArray(rawResponse)) {
// Langsung array
events = rawResponse;
total = events.length;
} else if (rawResponse && Array.isArray(rawResponse.data)) {
// Format tanpa success
events = rawResponse.data;
total = rawResponse.total || events.length;
currentPage = rawResponse.page || 1;
totalPages = rawResponse.total_pages || rawResponse.pages || Math.ceil(total / (rawResponse.limit || 20));
} else {
console.warn('[Events] Unexpected response format:', rawResponse);
events = [];
}
console.log('[Events] Parsed events:', events.length, 'items', { total, currentPage, totalPages });
eventsCache = events;
state.page = currentPage;
state.total = total;
state.totalPages = totalPages;
renderEvents(events);
} catch (err) {
console.error('loadEvents error', err);
if (errorEl) {
errorEl.textContent = err.message || 'Gagal memuat data events';
errorEl.style.display = 'block';
}
renderEvents([]);
} finally {
if (loadingEl) loadingEl.classList.remove('visible');
}
}
function setupFilters() {
const dateInput = document.getElementById('filter-date');
if (dateInput) {
dateInput.value = state.date;
dateInput.addEventListener('change', () => {
state.date = dateInput.value || state.date;
state.page = 1; // Reset to first page
loadEvents();
});
}
const locationSelect = document.getElementById('filter-location');
if (locationSelect) {
locationSelect.addEventListener('change', async () => {
state.locationCode = locationSelect.value;
state.gateCode = '';
state.page = 1; // Reset to first page
const gateSelect = document.getElementById('filter-gate');
if (gateSelect) gateSelect.value = '';
// Reload gates untuk update camera cache
await loadGates();
await loadGatesForCamera(); // Reload camera URLs
// Show/hide video panel berdasarkan lokasi
showVideoPanel(state.locationCode);
loadEvents();
});
}
const gateSelect = document.getElementById('filter-gate');
if (gateSelect) {
gateSelect.addEventListener('change', () => {
state.gateCode = gateSelect.value;
state.page = 1; // Reset to first page
loadEvents();
});
}
// Category filter (jika ada)
const categorySelect = document.getElementById('filter-category');
if (categorySelect) {
categorySelect.addEventListener('change', () => {
state.category = categorySelect.value;
state.page = 1; // Reset to first page
loadEvents();
});
}
}
// Listen to realtime events - update summary cards dengan data snapshot
let eventsCache = [];
let lastSnapshotTime = null;
function formatNumber(value) {
return new Intl.NumberFormat('id-ID').format(value || 0);
}
// formatCurrency sudah didefinisikan di atas, tidak perlu duplikat
window.addEventListener('realtime:snapshot', (e) => {
console.log('[Events] Realtime snapshot:', e.detail);
const snapshot = e.detail;
lastSnapshotTime = new Date();
// Snapshot hanya untuk logging, tidak perlu render ke UI
// Tabel events akan di-update dari endpoint events yang benar
});
// Update UI untuk show bahwa realtime aktif
function updateRealtimeStatus() {
const errorEl = document.getElementById('events-error');
if (lastSnapshotTime) {
const secondsAgo = Math.floor((new Date() - lastSnapshotTime) / 1000);
// Status realtime hanya di console, tidak perlu tampil di UI
// Supaya UI tetap fokus ke tabel events
}
}
// Update status setiap detik
setInterval(updateRealtimeStatus, 1000);
// Init
document.addEventListener('DOMContentLoaded', async () => {
setupFilters();
await loadLocations();
await loadGates();
await loadTariffs(); // Load tariffs untuk hitung amount
// Load locations dan gates untuk camera URL dari database
await loadLocationsForCamera();
await loadGatesForCamera();
// Cek lokasi awal untuk show/hide video
showVideoPanel(state.locationCode);
await loadEvents();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,80 @@
# Konfigurasi API Base URL
## Lokasi File
- **Config**: `public/dashboard/js/config.js`
- **Digunakan oleh**: `api.js`, `realtime.js`
## Cara Kerja
File `config.js` akan auto-detect environment berdasarkan hostname:
### Development Lokal
- Jika hostname = `localhost`, `127.0.0.1`, atau IP lokal (`192.168.x.x`)
- Base URL default: `http://localhost/api-btekno/public`
- **Sesuaikan** dengan path API backend di Laragon/XAMPP Anda
### Production
- Jika hostname bukan localhost
- Base URL: `https://api.btekno.cloud`
## Cara Mengubah Base URL
### Opsi 1: Edit `config.js` (Recommended)
Edit file `public/dashboard/js/config.js`:
```javascript
function getApiBaseUrl() {
const hostname = window.location.hostname;
// Development lokal
if (hostname === 'localhost' || hostname === '127.0.0.1') {
// GANTI INI sesuai path API backend Anda
return 'http://localhost/api-btekno/public';
// Atau jika pakai virtual host:
// return 'http://api.retribusi.test';
}
// Production
return 'https://api.btekno.cloud';
}
```
### Opsi 2: Hardcode (Tidak Recommended)
Jika ingin hardcode, edit langsung di `config.js`:
```javascript
export const API_CONFIG = {
BASE_URL: 'http://localhost/api-btekno/public', // Ganti ini
API_KEY: 'POKOKEIKISEKOYOLO',
TIMEOUT: 30000
};
```
## Contoh Path API Backend
### Laragon
- Jika API di: `C:\laragon\www\RETRIBUSI_BAPENDA\api-btekno\public`
- Base URL: `http://localhost/api-btekno/public`
- Atau buat virtual host: `http://api.retribusi.test`
### XAMPP
- Jika API di: `C:\xampp\htdocs\api-btekno\public`
- Base URL: `http://localhost/api-btekno/public`
### Production
- Base URL: `https://api.btekno.cloud`
## API Key
API Key juga bisa diubah di `config.js`:
```javascript
API_KEY: 'POKOKEIKISEKOYOLO' // Sesuaikan dengan RETRIBUSI_API_KEY di backend .env
```
## Debugging
Untuk melihat Base URL yang digunakan, buka browser console. Akan muncul log:
```
API Base URL: http://localhost/api-btekno/public
```

176
public/dashboard/js/api.js Normal file
View File

@@ -0,0 +1,176 @@
// public/dashboard/js/api.js
// Centralized REST API client for Btekno Retribusi Admin Dashboard
import { API_CONFIG } from './config.js';
// Export API_CONFIG untuk digunakan di file lain
export { API_CONFIG };
const API_BASE_URL = API_CONFIG.BASE_URL;
function getToken() {
return localStorage.getItem('token') || '';
}
async function apiRequest(path, options = {}) {
const url = path.startsWith('http') ? path : `${API_BASE_URL}${path}`;
const headers = {
'Content-Type': 'application/json',
// X-API-KEY dari konfigurasi backend (RETRIBUSI_API_KEY)
'X-API-KEY': API_CONFIG.API_KEY,
...(options.headers || {})
};
const token = getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const config = {
method: options.method || 'GET',
headers,
body: options.body ? JSON.stringify(options.body) : null
};
try {
const res = await fetch(url, config);
if (res.status === 401) {
// Unauthorized → clear token & redirect to login
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '../index.php';
throw new Error('Unauthorized');
}
const text = await res.text();
let json;
try {
json = text ? JSON.parse(text) : {};
} catch (e) {
json = { raw: text };
}
if (!res.ok) {
const msg = json.message || json.error || `HTTP ${res.status}`;
throw new Error(msg);
}
// Some endpoints might wrap data as { success, data, ... }
if (json && Object.prototype.hasOwnProperty.call(json, 'success') &&
Object.prototype.hasOwnProperty.call(json, 'data')) {
return json.data;
}
return json;
} catch (err) {
console.error('API error', { url, error: err });
throw err;
}
}
// Helper untuk build query string dari object params
function buildQuery(params = {}) {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
search.append(key, value);
}
});
const qs = search.toString();
return qs ? `?${qs}` : '';
}
// Typed helpers
export async function apiLogin(username, password) {
return apiRequest('/auth/v1/login', {
method: 'POST',
body: { username, password }
});
}
export async function apiGetLocations(params = {}) {
// Handle pagination: { page, limit }
const qs = buildQuery(params);
return apiRequest(`/retribusi/v1/frontend/locations${qs}`);
}
export async function apiGetGates(locationCode, params = {}) {
// Handle pagination: { page, limit, location_code }
const queryParams = { ...params };
if (locationCode) queryParams.location_code = locationCode;
const qs = buildQuery(queryParams);
return apiRequest(`/retribusi/v1/frontend/gates${qs}`);
}
export async function apiGetSummary({ date, locationCode, gateCode }) {
const qs = buildQuery({
date,
location_code: locationCode,
gate_code: gateCode
});
return apiRequest(`/retribusi/v1/dashboard/summary${qs}`);
}
export async function apiGetDaily({ startDate, endDate, locationCode }) {
const qs = buildQuery({
start_date: startDate,
end_date: endDate,
location_code: locationCode
});
return apiRequest(`/retribusi/v1/dashboard/daily${qs}`);
}
export async function apiGetByCategory({ date, locationCode, gateCode }) {
const qs = buildQuery({
date,
location_code: locationCode,
gate_code: gateCode
});
return apiRequest(`/retribusi/v1/dashboard/by-category${qs}`);
}
// Ringkasan global harian (daily_summary)
export async function apiGetSummaryDaily(params = {}) {
const qs = buildQuery(params);
return apiRequest(`/retribusi/v1/summary/daily${qs}`);
}
// Ringkasan per jam (hourly_summary)
export async function apiGetSummaryHourly(params = {}) {
const qs = buildQuery(params);
return apiRequest(`/retribusi/v1/summary/hourly${qs}`);
}
// Snapshot realtime (untuk panel live / TV wall)
export async function apiGetRealtimeSnapshot(params = {}) {
const qs = buildQuery(params);
return apiRequest(`/retribusi/v1/realtime/snapshot${qs}`);
}
// Entry events list (raw events dari mesin YOLO)
// GET /retribusi/v1/frontend/entry-events
// Parameters: page, limit, location_code, gate_code, category, start_date, end_date
export async function apiGetEntryEvents(params = {}) {
const qs = buildQuery(params);
return apiRequest(`/retribusi/v1/frontend/entry-events${qs}`);
}
// Realtime events list (history untuk SSE)
// GET /retribusi/v1/realtime/events
// Parameters: page, limit, location_code, gate_code, category, start_date, end_date
export async function apiGetRealtimeEvents(params = {}) {
const qs = buildQuery(params);
return apiRequest(`/retribusi/v1/realtime/events${qs}`);
}
// Alias untuk backward compatibility
export async function apiGetEvents(params = {}) {
return apiGetEntryEvents(params);
}
// Catatan: realtime SSE /retribusi/v1/realtime/stream akan diakses langsung via EventSource,
// bukan lewat fetch/apiRequest karena menggunakan Server-Sent Events (SSE).

View File

@@ -0,0 +1,86 @@
// public/dashboard/js/auth.js
// Handles login flow and auth helpers (JWT in localStorage)
import { apiLogin } from './api.js';
const TOKEN_KEY = 'token';
const USER_KEY = 'user';
export const Auth = {
isAuthenticated() {
return !!localStorage.getItem(TOKEN_KEY);
},
saveToken(token) {
localStorage.setItem(TOKEN_KEY, token);
},
saveUser(user) {
localStorage.setItem(USER_KEY, JSON.stringify(user || {}));
},
logout() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
window.location.href = '../index.php';
}
};
async function handleLoginSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
const usernameInput = form.querySelector('#username');
const passwordInput = form.querySelector('#password');
const errorBox = document.getElementById('login-error');
const submitBtn = form.querySelector('button[type="submit"]');
if (errorBox) {
errorBox.classList.remove('visible');
errorBox.textContent = '';
}
submitBtn.disabled = true;
submitBtn.textContent = 'Masuk...';
try {
const username = usernameInput.value.trim();
const password = passwordInput.value;
const data = await apiLogin(username, password);
const token = data.token;
const user = data.user;
if (!token) {
throw new Error('Token tidak ditemukan dalam response login.');
}
Auth.saveToken(token);
Auth.saveUser(user);
window.location.href = 'dashboard.html';
} catch (err) {
console.error('Login failed', err);
if (errorBox) {
errorBox.textContent = err.message || 'Login gagal. Silakan coba lagi.';
errorBox.classList.add('visible');
}
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Login';
}
}
// Attach events on login page only
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('login-form');
if (form) {
if (Auth.isAuthenticated()) {
window.location.href = 'dashboard.html';
return;
}
form.addEventListener('submit', handleLoginSubmit);
}
});

View File

@@ -0,0 +1,193 @@
// public/dashboard/js/charts.js
// Chart.js helpers: create & update charts without recreating canvases.
let dailyLineChart = null;
let categoryChart = null;
// Export chart instances untuk akses dari dashboard.js
export function getDailyChart() {
return dailyLineChart;
}
export function getCategoryChart() {
return categoryChart;
}
export function initDailyChart(ctx) {
if (dailyLineChart) return dailyLineChart;
dailyLineChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'Jumlah',
data: [],
borderColor: '#111827',
backgroundColor: 'rgba(17, 24, 39, 0.06)',
borderWidth: 2,
tension: 0.35,
fill: true,
pointRadius: 2.5,
pointBackgroundColor: '#111827'
},
{
label: 'Pendapatan',
data: [],
borderColor: '#6b7280',
backgroundColor: 'rgba(156, 163, 175, 0.08)',
borderWidth: 2,
tension: 0.35,
fill: true,
pointRadius: 2.5,
pointBackgroundColor: '#6b7280',
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
scales: {
y: {
beginAtZero: true,
grid: { color: '#e5e7eb' },
ticks: { font: { size: 11 } }
},
y1: {
beginAtZero: true,
position: 'right',
grid: { drawOnChartArea: false },
ticks: {
font: { size: 11 },
callback: value => 'Rp ' + new Intl.NumberFormat('id-ID').format(value)
}
},
x: {
grid: { display: false },
ticks: { font: { size: 11 } }
}
},
plugins: {
legend: {
position: 'bottom',
labels: {
boxWidth: 14,
boxHeight: 4
}
}
}
}
});
return dailyLineChart;
}
export function updateDailyChart({ labels, counts, amounts }) {
if (!dailyLineChart) {
console.warn('[Charts] Daily chart belum di-init, skip update');
// Try to init if canvas exists
const canvas = document.getElementById('daily-chart');
if (canvas) {
console.log('[Charts] Attempting to init daily chart from update function...');
initDailyChart(canvas.getContext('2d'));
} else {
console.error('[Charts] Daily chart canvas not found!');
return;
}
}
if (!dailyLineChart) {
console.error('[Charts] Failed to init daily chart');
return;
}
dailyLineChart.data.labels = labels || [];
dailyLineChart.data.datasets[0].data = counts || [];
dailyLineChart.data.datasets[1].data = amounts || [];
dailyLineChart.update();
console.log('[Charts] Daily chart updated:', { labelsCount: labels?.length, countsCount: counts?.length, amountsCount: amounts?.length });
}
export function initCategoryChart(ctx) {
if (categoryChart) return categoryChart;
categoryChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: [],
datasets: [
{
label: 'Per Kategori',
data: [],
backgroundColor: ['#111827', '#4b5563', '#9ca3af'],
borderWidth: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
plugins: {
legend: {
position: 'bottom',
labels: {
boxWidth: 14,
boxHeight: 6
}
}
}
}
});
return categoryChart;
}
export function updateCategoryChart({ labels, values }) {
if (!categoryChart) {
console.warn('[Charts] Category chart belum di-init, skip update');
// Try to init if canvas exists
const canvas = document.getElementById('category-chart');
if (canvas) {
console.log('[Charts] Attempting to init category chart from update function...');
initCategoryChart(canvas.getContext('2d'));
} else {
console.error('[Charts] Category chart canvas not found!');
return;
}
}
if (!categoryChart) {
console.error('[Charts] Failed to init category chart');
return;
}
// Pastikan labels dan values tidak kosong
const finalLabels = labels && labels.length > 0 ? labels : ['Orang', 'Motor', 'Mobil'];
const finalValues = values && values.length > 0 ? values : [0, 0, 0];
// Pastikan length sama
const minLength = Math.min(finalLabels.length, finalValues.length);
const safeLabels = finalLabels.slice(0, minLength);
const safeValues = finalValues.slice(0, minLength);
categoryChart.data.labels = safeLabels;
categoryChart.data.datasets[0].data = safeValues;
// Update chart dengan mode 'none' untuk animasi halus
categoryChart.update('none');
console.log('[Charts] Category chart updated:', {
labels: safeLabels,
values: safeValues,
total: safeValues.reduce((a, b) => a + b, 0)
});
}

View File

@@ -0,0 +1,38 @@
// public/dashboard/js/config.js
// Konfigurasi API Base URL untuk frontend
// Auto-detect environment berdasarkan hostname
function getApiBaseUrl() {
const hostname = window.location.hostname;
// Development lokal (Laragon/XAMPP)
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname.startsWith('192.168.')) {
// Untuk PHP built-in server (port 8000)
// return 'http://localhost:8000';
// Untuk Laragon/Apache (path-based)
// return 'http://localhost/api-btekno/public';
// Untuk Laragon virtual host
// return 'http://api.retribusi.test';
// Default: PHP built-in server
return 'http://localhost:8000';
}
// Production
return 'https://api.btekno.cloud';
}
// Export config
export const API_CONFIG = {
BASE_URL: getApiBaseUrl(),
API_KEY: 'POKOKEIKISEKOYOLO', // Sesuaikan dengan RETRIBUSI_API_KEY di backend
TIMEOUT: 30000 // 30 detik
};
// Untuk debugging
if (typeof window !== 'undefined') {
console.log('API Base URL:', API_CONFIG.BASE_URL);
}

View File

@@ -0,0 +1,723 @@
// public/dashboard/js/dashboard.js
// Main dashboard logic: filters, KPI cards, charts.
import { Auth } from './auth.js';
import {
apiGetLocations,
apiGetGates,
apiGetSummary,
apiGetDaily,
apiGetByCategory,
apiGetSummaryHourly
} from './api.js';
import {
initDailyChart,
initCategoryChart,
updateDailyChart,
updateCategoryChart,
getDailyChart,
getCategoryChart
} from './charts.js';
// Default date: selalu hari ini (tidak auto-detect ke tanggal lama)
const state = {
date: new Date().toISOString().split('T')[0], // Default: hari ini
locationCode: '',
gateCode: ''
};
// Function untuk auto-detect tanggal terakhir yang ada data
async function getLastAvailableDate() {
try {
// Coba ambil data hari ini dulu
const today = new Date().toISOString().split('T')[0];
const todayData = await apiGetSummary({ date: today });
console.log('[Dashboard] getLastAvailableDate - today data:', todayData);
// Handle jika response masih wrapped (seharusnya sudah di-unwrap oleh api.js)
let todaySummary = todayData;
if (todayData && typeof todayData === 'object' && 'data' in todayData && !('total_count' in todayData)) {
todaySummary = todayData.data || {};
}
// Jika hari ini ada data, return hari ini
if (todaySummary && (todaySummary.total_count > 0 || todaySummary.total_amount > 0)) {
console.log('[Dashboard] Using today:', today);
return today;
}
// Jika tidak, coba kemarin
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toISOString().split('T')[0];
const yesterdayData = await apiGetSummary({ date: yesterdayStr });
console.log('[Dashboard] getLastAvailableDate - yesterday data:', yesterdayData);
// Handle jika response masih wrapped
let yesterdaySummary = yesterdayData;
if (yesterdayData && typeof yesterdayData === 'object' && 'data' in yesterdayData && !('total_count' in yesterdayData)) {
yesterdaySummary = yesterdayData.data || {};
}
if (yesterdaySummary && (yesterdaySummary.total_count > 0 || yesterdaySummary.total_amount > 0)) {
console.log('[Dashboard] Using yesterday:', yesterdayStr);
return yesterdayStr;
}
// Jika tidak ada data kemarin, cek 7 hari terakhir
for (let i = 2; i <= 7; i++) {
const prevDate = new Date();
prevDate.setDate(prevDate.getDate() - i);
const prevDateStr = prevDate.toISOString().split('T')[0];
const prevData = await apiGetSummary({ date: prevDateStr });
console.log(`[Dashboard] getLastAvailableDate - ${i} days ago (${prevDateStr}) data:`, prevData);
// Handle jika response masih wrapped
let prevSummary = prevData;
if (prevData && typeof prevData === 'object' && 'data' in prevData && !('total_count' in prevData)) {
prevSummary = prevData.data || {};
}
if (prevSummary && (prevSummary.total_count > 0 || prevSummary.total_amount > 0)) {
console.log(`[Dashboard] Using ${i} days ago:`, prevDateStr);
return prevDateStr;
}
}
// Jika tidak ada data sama sekali, cari tanggal terakhir yang ada data dari API
// Coba query langsung ke API untuk dapat list tanggal yang ada data
// Untuk sementara, return tanggal terakhir yang diketahui ada data (2025-12-16)
// Atau bisa return null dan biarkan user pilih manual
console.log('[Dashboard] No data found in last 7 days');
// Cek tanggal 15 dan 14 juga (karena kita tahu ada data di sana)
const knownDates = ['2025-12-16', '2025-12-15', '2025-12-14'];
for (const knownDate of knownDates) {
const knownData = await apiGetSummary({ date: knownDate });
let knownSummary = knownData;
if (knownData && typeof knownData === 'object' && 'data' in knownData && !('total_count' in knownData)) {
knownSummary = knownData.data || {};
}
if (knownSummary && (knownSummary.total_count > 0 || knownSummary.total_amount > 0)) {
console.log('[Dashboard] Found data in known dates, using:', knownDate);
return knownDate;
}
}
// Default: tetap hari ini (meskipun tidak ada data)
console.log('[Dashboard] No data found anywhere, using today:', today);
return today;
} catch (error) {
console.error('[Dashboard] Error getting last available date:', error);
// Fallback ke tanggal yang pasti ada data
return '2025-12-16';
}
}
// Video HLS setup - mapping lokasi ke URL camera
const LOCATION_CAMERAS = {
'kerkof_01': {
url: 'https://kerkof.btekno.cloud/cam1/index.m3u8',
name: 'Kerkof'
}
};
let hls = null;
let isVideoPlaying = false;
let currentVideoUrl = null;
function formatNumber(value) {
return new Intl.NumberFormat('id-ID').format(value || 0);
}
function formatCurrency(value) {
return 'Rp ' + new Intl.NumberFormat('id-ID').format(value || 0);
}
function setTopbarDate() {
const el = document.getElementById('topbar-date');
if (!el) return;
const d = new Date();
el.textContent = d.toLocaleDateString('id-ID', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: '2-digit'
});
}
async function loadLocations() {
const select = document.getElementById('filter-location');
if (!select) return;
select.innerHTML = '<option value="">Semua Lokasi</option>';
try {
const data = await apiGetLocations({ limit: 100 }); // Ambil semua lokasi
// Handle pagination response: { data: [...], total, page, limit }
// atau langsung array: [...]
let items = [];
if (Array.isArray(data)) {
items = data;
} else if (data && Array.isArray(data.data)) {
items = data.data;
}
items.forEach(loc => {
const opt = document.createElement('option');
opt.value = loc.code || loc.location_code || '';
opt.textContent = loc.name || loc.label || opt.value;
select.appendChild(opt);
});
} catch (err) {
console.error('loadLocations error', err);
}
}
async function loadGates() {
const select = document.getElementById('filter-gate');
if (!select) return;
select.innerHTML = '<option value="">Semua Gate</option>';
if (!state.locationCode) return;
try {
const data = await apiGetGates(state.locationCode, { limit: 100 }); // Ambil semua gate
// Handle pagination response: { data: [...], total, page, limit }
// atau langsung array: [...]
let items = [];
if (Array.isArray(data)) {
items = data;
} else if (data && Array.isArray(data.data)) {
items = data.data;
}
items.forEach(g => {
const opt = document.createElement('option');
opt.value = g.code || g.gate_code || '';
opt.textContent = g.name || g.label || opt.value;
select.appendChild(opt);
});
} catch (err) {
console.error('loadGates error', err);
}
}
function renderSummary({ totalAmount, personCount, motorCount, carCount }) {
const amountEl = document.getElementById('card-total-amount');
const personEl = document.getElementById('card-person-count');
const motorEl = document.getElementById('card-motor-count');
const carEl = document.getElementById('card-car-count');
if (amountEl) amountEl.textContent = formatCurrency(totalAmount || 0);
if (personEl) personEl.textContent = formatNumber(personCount || 0);
if (motorEl) motorEl.textContent = formatNumber(motorCount || 0);
if (carEl) carEl.textContent = formatNumber(carCount || 0);
}
function showError(message) {
const el = document.getElementById('summary-error');
if (!el) return;
el.textContent = message;
el.classList.remove('hidden');
setTimeout(() => {
el.classList.add('hidden');
}, 4000);
}
async function loadSummaryAndCharts() {
const loadingOverlay = document.getElementById('summary-loading');
if (loadingOverlay) loadingOverlay.classList.add('visible');
try {
const [summaryResp, hourlyResp, byCategoryResp] = await Promise.all([
apiGetSummary({
date: state.date,
locationCode: state.locationCode,
gateCode: state.gateCode
}),
// pakai summary hourly untuk chart: data hari ini per jam
apiGetSummaryHourly({
date: state.date,
location_code: state.locationCode,
gate_code: state.gateCode
}),
apiGetByCategory({
date: state.date,
locationCode: state.locationCode,
gateCode: state.gateCode
})
]);
// Kartu KPI: pakai total_count & total_amount dari summary endpoint
// Struktur summaryResp setelah di-unwrap: { total_count, total_amount, active_gates, active_locations }
console.log('[Dashboard] Summary response raw:', summaryResp);
console.log('[Dashboard] By Category response raw:', byCategoryResp);
console.log('[Dashboard] State date:', state.date);
// Handle jika response masih wrapped
let summary = summaryResp || {};
if (summaryResp && typeof summaryResp === 'object' && 'data' in summaryResp && !('total_count' in summaryResp)) {
summary = summaryResp.data || {};
}
const totalAmount = summary.total_amount || 0;
console.log('[Dashboard] Parsed summary:', { totalAmount, summary });
// Hitung per kategori dari byCategoryResp
let personCount = 0;
let motorCount = 0;
let carCount = 0;
if (byCategoryResp) {
let categoryData = [];
// Handle jika response masih wrapped
let byCategory = byCategoryResp;
if (byCategoryResp && typeof byCategoryResp === 'object' && 'data' in byCategoryResp && !('labels' in byCategoryResp)) {
byCategory = byCategoryResp.data || byCategoryResp;
}
// Handle struktur response dengan data array (sesuai spec)
if (Array.isArray(byCategory)) {
categoryData = byCategory;
}
// Handle struktur response dengan labels & series (format chart)
else if (byCategory.labels && byCategory.series) {
const labels = byCategory.labels || [];
const counts = byCategory.series.total_count || [];
labels.forEach((label, index) => {
categoryData.push({
category: label,
total_count: counts[index] || 0
});
});
}
console.log('[Dashboard] Category data parsed:', categoryData);
// Extract data per kategori
categoryData.forEach(item => {
const count = item.total_count || 0;
if (item.category === 'person_walk') {
personCount = count;
} else if (item.category === 'motor') {
motorCount = count;
} else if (item.category === 'car') {
carCount = count;
}
});
}
console.log('[Dashboard] Final counts:', { personCount, motorCount, carCount, totalAmount });
console.log('[Dashboard] Summary processed:', {
date: state.date,
locationCode: state.locationCode,
gateCode: state.gateCode,
totalAmount,
personCount,
motorCount,
carCount
});
// Pastikan nilai tidak undefined
const finalTotalAmount = totalAmount || 0;
const finalPersonCount = personCount || 0;
const finalMotorCount = motorCount || 0;
const finalCarCount = carCount || 0;
console.log('[Dashboard] Rendering summary with values:', {
totalAmount: finalTotalAmount,
personCount: finalPersonCount,
motorCount: finalMotorCount,
carCount: finalCarCount
});
renderSummary({
totalAmount: finalTotalAmount,
personCount: finalPersonCount,
motorCount: finalMotorCount,
carCount: finalCarCount
});
// Daily chart data → tampilkan data hari ini per jam
// Struktur response: { labels: ["00","01",...], series: { total_count: [...], total_amount: [...] } }
// ATAU: { data: [{ hour: 0, total_count: 0, total_amount: 0 }, ...] }
let labels = [];
let totalCounts = [];
let totalAmounts = [];
console.log('[Dashboard] Hourly response raw:', hourlyResp);
// Handle jika response masih wrapped
let hourly = hourlyResp;
if (hourlyResp && typeof hourlyResp === 'object' && 'data' in hourlyResp && !('labels' in hourlyResp)) {
hourly = hourlyResp.data || hourlyResp;
}
// Handle struktur response dengan labels & series
if (hourly && hourly.labels && hourly.series) {
labels = hourly.labels.map(h => String(h).padStart(2, '0') + ':00'); // "00" -> "00:00", "1" -> "01:00"
totalCounts = hourly.series.total_count || [];
totalAmounts = hourly.series.total_amount || [];
console.log('[Dashboard] Hourly data from labels & series:', { labelsCount: labels.length, countsCount: totalCounts.length });
}
// Handle struktur response dengan data array (sesuai spec)
else if (hourly && Array.isArray(hourly.data)) {
// Generate 24 jam (0-23)
labels = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
totalCounts = Array(24).fill(0);
totalAmounts = Array(24).fill(0);
// Map data dari response ke array per jam
hourly.data.forEach(item => {
const hour = item.hour || 0;
if (hour >= 0 && hour < 24) {
totalCounts[hour] = item.total_count || 0;
totalAmounts[hour] = item.total_amount || 0;
}
});
}
// Jika response kosong/null, tetap buat chart dengan data 0
else {
console.warn('[Dashboard] Hourly response tidak sesuai format, menggunakan data kosong');
labels = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
totalCounts = Array(24).fill(0);
totalAmounts = Array(24).fill(0);
}
console.log('[Dashboard] Hourly data processed:', {
date: state.date,
labels: labels.length,
counts: totalCounts.length,
amounts: totalAmounts.length,
totalCount: totalCounts.reduce((a, b) => a + b, 0),
totalAmount: totalAmounts.reduce((a, b) => a + b, 0)
});
// Pastikan chart sudah di-init sebelum update
const dailyCanvas = document.getElementById('daily-chart');
const currentDailyChart = getDailyChart();
if (dailyCanvas && !currentDailyChart) {
console.log('[Dashboard] Daily chart belum di-init, initializing...');
initDailyChart(dailyCanvas.getContext('2d'));
}
// Selalu update chart, meskipun data kosong (agar user tahu memang tidak ada data)
const dailyChartInstance = getDailyChart();
if (dailyChartInstance) {
updateDailyChart({
labels,
counts: totalCounts,
amounts: totalAmounts
});
console.log('[Dashboard] Daily chart updated successfully');
} else {
console.error('[Dashboard] Daily chart tidak bisa di-update, chart belum di-init! Canvas:', dailyCanvas);
}
// Category chart data → pakai total_count per kategori
// Struktur response: { labels: ["person_walk","motor","car"], series: { total_count: [33,12,2], total_amount: [...] } }
// ATAU: { data: [{ category: "person_walk", total_count: 33, total_amount: 66000 }, ...] }
let catLabels = [];
let catValues = [];
console.log('[Dashboard] Category response raw:', byCategoryResp);
// Handle jika response masih wrapped
let byCategory = byCategoryResp;
if (byCategoryResp && typeof byCategoryResp === 'object' && 'data' in byCategoryResp && !('labels' in byCategoryResp)) {
byCategory = byCategoryResp.data || byCategoryResp;
}
// Handle struktur response dengan labels & series
if (byCategory && byCategory.labels && byCategory.series) {
const categoryLabels = {
'person_walk': 'Orang',
'motor': 'Motor',
'car': 'Mobil'
};
const rawLabels = byCategory.labels || [];
catLabels = rawLabels.map(label => categoryLabels[label] || label);
catValues = byCategory.series.total_count || [];
console.log('[Dashboard] Category data from labels & series:', { catLabels, catValues });
}
// Handle struktur response dengan data array (sesuai spec)
else if (byCategory && Array.isArray(byCategory.data)) {
// Default categories sesuai spec
const defaultCategories = ['person_walk', 'motor', 'car'];
const categoryLabels = {
'person_walk': 'Orang',
'motor': 'Motor',
'car': 'Mobil'
};
catLabels = defaultCategories.map(cat => categoryLabels[cat] || cat);
catValues = defaultCategories.map(cat => {
const item = byCategory.data.find(d => d.category === cat);
return item ? (item.total_count || 0) : 0;
});
console.log('[Dashboard] Category data from array:', { catLabels, catValues });
}
// Jika response kosong/null, tetap buat chart dengan data 0
else {
console.warn('[Dashboard] Category response tidak sesuai format, menggunakan data kosong');
catLabels = ['Orang', 'Motor', 'Mobil'];
catValues = [0, 0, 0];
}
console.log('[Dashboard] Category data processed:', {
date: state.date,
labels: catLabels,
values: catValues,
total: catValues.reduce((a, b) => a + b, 0)
});
// Pastikan chart sudah di-init sebelum update
const categoryCanvas = document.getElementById('category-chart');
const categoryChartInstance = getCategoryChart();
if (categoryCanvas && !categoryChartInstance) {
console.log('[Dashboard] Initializing category chart...');
initCategoryChart(categoryCanvas.getContext('2d'));
}
// Selalu update chart, meskipun data kosong (agar user tahu memang tidak ada data)
const currentCategoryChart = getCategoryChart();
if (currentCategoryChart) {
updateCategoryChart({
labels: catLabels,
values: catValues
});
console.log('[Dashboard] Category chart updated successfully');
} else {
console.error('[Dashboard] Category chart tidak bisa di-update, chart belum di-init!');
}
} catch (err) {
console.error('loadSummaryAndCharts error', err);
showError(err.message || 'Gagal memuat data dashboard');
} finally {
if (loadingOverlay) loadingOverlay.classList.remove('visible');
}
}
function getCameraForLocation(locationCode) {
if (!locationCode) return null;
return LOCATION_CAMERAS[locationCode] || null;
}
function showVideoPanel(locationCode) {
const videoSection = document.getElementById('video-section');
const videoPanelTitle = document.getElementById('video-panel-title');
const camera = getCameraForLocation(locationCode);
if (camera && videoSection && videoPanelTitle) {
videoSection.style.display = 'block';
videoPanelTitle.textContent = `Live Camera - ${camera.name}`;
currentVideoUrl = camera.url;
// Auto-stop video kalau lokasi berubah
if (isVideoPlaying) {
stopVideo();
}
} else {
if (videoSection) videoSection.style.display = 'none';
if (isVideoPlaying) {
stopVideo();
}
currentVideoUrl = null;
}
}
function initVideo() {
if (!currentVideoUrl || typeof Hls === 'undefined') {
console.warn('[Dashboard] Video URL atau HLS.js tidak tersedia');
return;
}
const videoEl = document.getElementById('video-player');
if (!videoEl) return;
if (Hls.isSupported()) {
hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90
});
hls.loadSource(currentVideoUrl);
hls.attachMedia(videoEl);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('[Dashboard] HLS manifest parsed');
});
hls.on(Hls.Events.ERROR, (event, data) => {
console.error('[Dashboard] HLS error:', data);
if (data.fatal) {
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
console.log('[Dashboard] Network error, retrying...');
hls.startLoad();
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
console.log('[Dashboard] Media error, recovering...');
hls.recoverMediaError();
} else {
console.error('[Dashboard] Fatal error, destroying HLS');
hls.destroy();
hls = null;
stopVideo();
}
}
});
} else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS support (Safari)
videoEl.src = currentVideoUrl;
} else {
console.error('[Dashboard] HLS tidak didukung di browser ini');
}
}
function startVideo() {
const videoEl = document.getElementById('video-player');
const placeholderEl = document.getElementById('video-placeholder');
const toggleBtn = document.getElementById('video-toggle');
if (!videoEl || !currentVideoUrl) {
console.warn('[Dashboard] Video element atau URL tidak tersedia');
return;
}
if (!hls && typeof Hls !== 'undefined' && Hls.isSupported()) {
initVideo();
}
if (videoEl) {
videoEl.style.display = 'block';
if (placeholderEl) placeholderEl.style.display = 'none';
}
if (toggleBtn) toggleBtn.textContent = 'Matikan';
isVideoPlaying = true;
if (videoEl && (videoEl.src || (hls && hls.media))) {
videoEl.play().catch(e => console.error('[Dashboard] Play error:', e));
}
}
function stopVideo() {
const videoEl = document.getElementById('video-player');
const placeholderEl = document.getElementById('video-placeholder');
const toggleBtn = document.getElementById('video-toggle');
if (hls) {
hls.destroy();
hls = null;
}
if (videoEl) {
videoEl.pause();
videoEl.src = '';
videoEl.style.display = 'none';
}
if (placeholderEl) placeholderEl.style.display = 'flex';
if (toggleBtn) toggleBtn.textContent = 'Hidupkan';
isVideoPlaying = false;
}
function setupFilters() {
const dateInput = document.getElementById('filter-date');
if (dateInput) {
dateInput.value = state.date;
dateInput.addEventListener('change', () => {
state.date = dateInput.value || state.date;
loadSummaryAndCharts();
});
}
const locationSelect = document.getElementById('filter-location');
if (locationSelect) {
locationSelect.addEventListener('change', async () => {
state.locationCode = locationSelect.value;
state.gateCode = '';
const gateSelect = document.getElementById('filter-gate');
if (gateSelect) gateSelect.value = '';
// Show/hide video panel berdasarkan lokasi
showVideoPanel(state.locationCode);
await loadGates();
loadSummaryAndCharts();
});
}
const gateSelect = document.getElementById('filter-gate');
if (gateSelect) {
gateSelect.addEventListener('change', () => {
state.gateCode = gateSelect.value;
loadSummaryAndCharts();
});
}
// Setup video toggle button
const toggleBtn = document.getElementById('video-toggle');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
if (isVideoPlaying) {
stopVideo();
} else {
startVideo();
}
});
}
}
function initCharts() {
console.log('[Dashboard] Initializing charts...');
const dailyCanvas = document.getElementById('daily-chart');
const categoryCanvas = document.getElementById('category-chart');
if (dailyCanvas) {
console.log('[Dashboard] Found daily chart canvas, initializing...');
initDailyChart(dailyCanvas.getContext('2d'));
console.log('[Dashboard] Daily chart initialized:', getDailyChart() !== null);
} else {
console.error('[Dashboard] Daily chart canvas not found!');
}
if (categoryCanvas) {
console.log('[Dashboard] Found category chart canvas, initializing...');
initCategoryChart(categoryCanvas.getContext('2d'));
console.log('[Dashboard] Category chart initialized:', getCategoryChart() !== null);
} else {
console.error('[Dashboard] Category chart canvas not found!');
}
}
document.addEventListener('DOMContentLoaded', async () => {
// Require auth
if (!Auth.isAuthenticated()) {
window.location.href = '../index.php';
return;
}
// Set default date ke hari ini (jangan auto-detect ke tanggal lama)
const today = new Date().toISOString().split('T')[0];
state.date = today;
const dateInput = document.getElementById('filter-date');
if (dateInput) {
dateInput.value = state.date;
console.log('[Dashboard] Default date set to today:', state.date);
}
setTopbarDate();
initCharts();
setupFilters();
await loadLocations();
await loadGates();
// Cek lokasi awal untuk show/hide video
showVideoPanel(state.locationCode);
await loadSummaryAndCharts();
});

View File

@@ -0,0 +1,160 @@
// 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 }
const snapshot = await apiGetRealtimeSnapshot({
date: new Date().toISOString().split('T')[0],
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();
});

File diff suppressed because it is too large Load Diff

5
public/index.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
// Redirect ke login page di root
header('Location: ../index.php');
exit;