2025-12-18 11:21:40 +07:00
|
|
|
<!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()) {
|
2025-12-18 13:25:50 +07:00
|
|
|
const currentPath = window.location.pathname.toLowerCase();
|
2025-12-18 13:36:36 +07:00
|
|
|
const isLoginPage = currentPath.includes('index.html') ||
|
|
|
|
|
currentPath.includes('index.php') ||
|
2025-12-18 13:25:50 +07:00
|
|
|
currentPath === '/' ||
|
2025-12-18 13:36:36 +07:00
|
|
|
currentPath === '/index.html' ||
|
2025-12-18 13:25:50 +07:00
|
|
|
currentPath === '/index.php';
|
|
|
|
|
// Hanya redirect jika belum di login page
|
|
|
|
|
if (!isLoginPage) {
|
2025-12-18 13:36:36 +07:00
|
|
|
window.location.href = '../index.html';
|
2025-12-18 13:25:50 +07:00
|
|
|
}
|
2025-12-18 11:21:40 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
2025-12-19 05:13:03 +07:00
|
|
|
// State akan di-set ke hari ini saat DOMContentLoaded
|
2025-12-18 11:21:40 +07:00
|
|
|
const state = {
|
2025-12-19 05:13:03 +07:00
|
|
|
date: '', // Akan di-set ke hari ini saat DOMContentLoaded
|
2025-12-18 11:21:40 +07:00
|
|
|
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 () => {
|
2025-12-19 05:13:03 +07:00
|
|
|
// Set default date ke hari ini (selalu update ke hari ini setiap kali page load)
|
|
|
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
|
state.date = today;
|
|
|
|
|
console.log('[Events] Default date set to today:', state.date);
|
|
|
|
|
|
2025-12-19 05:17:46 +07:00
|
|
|
// Set dateInput value SECARA LANGSUNG untuk override browser cache/autofill
|
|
|
|
|
const dateInput = document.getElementById('filter-date');
|
|
|
|
|
if (dateInput) {
|
|
|
|
|
dateInput.value = today;
|
|
|
|
|
dateInput.setAttribute('value', today); // Force set attribute juga
|
|
|
|
|
console.log('[Events] Date input set to:', today, 'actual value:', dateInput.value);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-19 05:13:03 +07:00
|
|
|
// Setup filters SETELAH state.date sudah di-set
|
2025-12-18 11:21:40 +07:00
|
|
|
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>
|