2025-12-18 11:21:40 +07:00
|
|
|
|
// 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';
|
|
|
|
|
|
|
2025-12-19 05:37:24 +07:00
|
|
|
|
// Helper function untuk mendapatkan tanggal hari ini dalam timezone Indonesia (UTC+7)
|
2025-12-19 05:44:55 +07:00
|
|
|
|
// Menggunakan Intl.DateTimeFormat untuk mendapatkan tanggal yang konsisten di semua browser
|
2025-12-19 05:37:24 +07:00
|
|
|
|
function getTodayIndonesia() {
|
|
|
|
|
|
const now = new Date();
|
2025-12-19 05:44:55 +07:00
|
|
|
|
// Format tanggal dalam timezone Asia/Jakarta (UTC+7)
|
|
|
|
|
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
|
|
|
|
timeZone: 'Asia/Jakarta',
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
|
day: '2-digit'
|
|
|
|
|
|
});
|
|
|
|
|
|
// Format: YYYY-MM-DD
|
|
|
|
|
|
return formatter.format(now);
|
2025-12-19 05:37:24 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 23:38:42 +07:00
|
|
|
|
// Helper function untuk normalisasi format tanggal ke YYYY-MM-DD
|
|
|
|
|
|
// Memastikan format konsisten terlepas dari input browser
|
|
|
|
|
|
function normalizeDate(dateString) {
|
|
|
|
|
|
if (!dateString) return '';
|
|
|
|
|
|
|
|
|
|
|
|
// Log input untuk debug
|
|
|
|
|
|
console.log('[Dashboard] normalizeDate - Input:', {
|
|
|
|
|
|
value: dateString,
|
|
|
|
|
|
type: typeof dateString,
|
|
|
|
|
|
length: dateString.length,
|
|
|
|
|
|
charCodes: dateString.split('').map(c => c.charCodeAt(0))
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Jika sudah format YYYY-MM-DD, validasi dan return
|
|
|
|
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
|
|
|
|
|
// Validasi bahwa tanggal valid (misal tidak ada 2025-13-45)
|
|
|
|
|
|
const [year, month, day] = dateString.split('-').map(Number);
|
|
|
|
|
|
const date = new Date(year, month - 1, day);
|
|
|
|
|
|
if (date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day) {
|
|
|
|
|
|
console.log('[Dashboard] normalizeDate - Valid YYYY-MM-DD format:', dateString);
|
|
|
|
|
|
return dateString;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.warn('[Dashboard] normalizeDate - Invalid date values:', { year, month, day });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Jika format lain, coba parse dengan berbagai cara
|
|
|
|
|
|
let date;
|
|
|
|
|
|
|
|
|
|
|
|
// Coba parse sebagai ISO string dulu
|
|
|
|
|
|
date = new Date(dateString);
|
|
|
|
|
|
if (!isNaN(date.getTime())) {
|
|
|
|
|
|
// Format ke YYYY-MM-DD menggunakan timezone Indonesia
|
|
|
|
|
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
|
|
|
|
timeZone: 'Asia/Jakarta',
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
|
day: '2-digit'
|
|
|
|
|
|
});
|
|
|
|
|
|
const normalized = formatter.format(date);
|
|
|
|
|
|
console.log('[Dashboard] normalizeDate - Parsed and normalized:', dateString, '->', normalized);
|
|
|
|
|
|
return normalized;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Jika masih gagal, coba parse manual (untuk format DD/MM/YYYY atau MM/DD/YYYY)
|
|
|
|
|
|
const parts = dateString.split(/[-\/]/);
|
|
|
|
|
|
if (parts.length === 3) {
|
|
|
|
|
|
let year, month, day;
|
|
|
|
|
|
// Coba deteksi format: jika bagian pertama > 12, kemungkinan DD/MM/YYYY
|
|
|
|
|
|
if (parseInt(parts[0]) > 12) {
|
|
|
|
|
|
// DD/MM/YYYY
|
|
|
|
|
|
day = parseInt(parts[0]);
|
|
|
|
|
|
month = parseInt(parts[1]);
|
|
|
|
|
|
year = parseInt(parts[2]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// MM/DD/YYYY atau YYYY-MM-DD
|
|
|
|
|
|
if (parts[0].length === 4) {
|
|
|
|
|
|
// YYYY-MM-DD
|
|
|
|
|
|
year = parseInt(parts[0]);
|
|
|
|
|
|
month = parseInt(parts[1]);
|
|
|
|
|
|
day = parseInt(parts[2]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// MM/DD/YYYY
|
|
|
|
|
|
month = parseInt(parts[0]);
|
|
|
|
|
|
day = parseInt(parts[1]);
|
|
|
|
|
|
year = parseInt(parts[2]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
date = new Date(year, month - 1, day);
|
|
|
|
|
|
if (!isNaN(date.getTime())) {
|
|
|
|
|
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
|
|
|
|
timeZone: 'Asia/Jakarta',
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
|
day: '2-digit'
|
|
|
|
|
|
});
|
|
|
|
|
|
const normalized = formatter.format(date);
|
|
|
|
|
|
console.log('[Dashboard] normalizeDate - Manual parse:', dateString, '->', normalized);
|
|
|
|
|
|
return normalized;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.warn('[Dashboard] normalizeDate - Failed to parse:', dateString);
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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: ''
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Function untuk auto-detect tanggal terakhir yang ada data
|
|
|
|
|
|
async function getLastAvailableDate() {
|
|
|
|
|
|
try {
|
2025-12-19 05:37:24 +07:00
|
|
|
|
// Coba ambil data hari ini dulu (gunakan timezone Indonesia UTC+7)
|
|
|
|
|
|
const today = getTodayIndonesia();
|
2025-12-18 11:21:40 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 05:37:24 +07:00
|
|
|
|
// Jika tidak, coba kemarin (gunakan timezone Indonesia UTC+7)
|
|
|
|
|
|
const now = new Date();
|
2025-12-19 05:45:21 +07:00
|
|
|
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
|
|
|
|
timeZone: 'Asia/Jakarta',
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
|
day: '2-digit'
|
|
|
|
|
|
});
|
2025-12-19 05:54:55 +07:00
|
|
|
|
|
|
|
|
|
|
const yesterdayDate = new Date(now.getTime() - (24 * 60 * 60 * 1000));
|
2025-12-19 05:45:21 +07:00
|
|
|
|
const yesterdayStr = formatter.format(yesterdayDate);
|
2025-12-18 11:21:40 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 05:37:24 +07:00
|
|
|
|
// Jika tidak ada data kemarin, cek 7 hari terakhir (gunakan timezone Indonesia UTC+7)
|
2025-12-19 05:54:55 +07:00
|
|
|
|
// Reuse 'now' dan 'formatter' yang sudah dideklarasikan di atas
|
2025-12-18 11:21:40 +07:00
|
|
|
|
for (let i = 2; i <= 7; i++) {
|
2025-12-19 05:44:55 +07:00
|
|
|
|
const prevDate = new Date(now.getTime() - (i * 24 * 60 * 60 * 1000));
|
|
|
|
|
|
const prevDateStr = formatter.format(prevDate);
|
2025-12-18 11:21:40 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 05:15:14 +07:00
|
|
|
|
// Jika tidak ada data sama sekali, tetap return hari ini
|
|
|
|
|
|
// User bisa pilih tanggal manual jika perlu
|
|
|
|
|
|
console.log('[Dashboard] No data found in last 7 days, using today:', today);
|
2025-12-18 11:21:40 +07:00
|
|
|
|
return today;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[Dashboard] Error getting last available date:', error);
|
2025-12-19 05:15:14 +07:00
|
|
|
|
// Fallback ke hari ini (selalu gunakan hari ini, bukan hardcoded date)
|
2025-12-19 05:37:24 +07:00
|
|
|
|
// Gunakan timezone Indonesia UTC+7
|
|
|
|
|
|
return getTodayIndonesia();
|
2025-12-18 11:21:40 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:47:26 +07:00
|
|
|
|
// Video HLS setup - menggunakan URL kamera dari database
|
|
|
|
|
|
let gatesCache = {}; // Cache untuk gates dengan camera URL
|
|
|
|
|
|
let locationsCache = {}; // Cache untuk locations
|
2025-12-18 11:21:40 +07:00
|
|
|
|
|
|
|
|
|
|
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();
|
2025-12-19 05:45:58 +07:00
|
|
|
|
// Gunakan timezone Asia/Jakarta untuk konsistensi
|
2025-12-18 11:21:40 +07:00
|
|
|
|
el.textContent = d.toLocaleDateString('id-ID', {
|
|
|
|
|
|
weekday: 'short',
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: 'short',
|
2025-12-19 05:45:58 +07:00
|
|
|
|
day: '2-digit',
|
|
|
|
|
|
timeZone: 'Asia/Jakarta'
|
2025-12-18 11:21:40 +07:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
|
2026-01-01 23:38:42 +07:00
|
|
|
|
// Validasi dan normalisasi tanggal sebelum request
|
|
|
|
|
|
if (!state.date || !/^\d{4}-\d{2}-\d{2}$/.test(state.date)) {
|
|
|
|
|
|
console.error('[Dashboard] Invalid date format:', state.date);
|
|
|
|
|
|
const today = getTodayIndonesia();
|
|
|
|
|
|
state.date = today;
|
|
|
|
|
|
const dateInput = document.getElementById('filter-date');
|
|
|
|
|
|
if (dateInput) {
|
|
|
|
|
|
dateInput.value = today;
|
|
|
|
|
|
dateInput.setAttribute('value', today);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Pastikan format tanggal konsisten (YYYY-MM-DD)
|
|
|
|
|
|
const normalizedDate = normalizeDate(state.date);
|
|
|
|
|
|
if (normalizedDate && normalizedDate !== state.date) {
|
|
|
|
|
|
console.warn('[Dashboard] Date normalized:', state.date, '->', normalizedDate);
|
|
|
|
|
|
state.date = normalizedDate;
|
|
|
|
|
|
const dateInput = document.getElementById('filter-date');
|
|
|
|
|
|
if (dateInput) {
|
|
|
|
|
|
dateInput.value = normalizedDate;
|
|
|
|
|
|
dateInput.setAttribute('value', normalizedDate);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Parse tanggal untuk deteksi transisi tahun/bulan
|
|
|
|
|
|
const [year, month, day] = state.date.split('-').map(Number);
|
|
|
|
|
|
// Deteksi transisi: hari pertama bulan (day === 1)
|
|
|
|
|
|
// Ini berlaku untuk semua bulan, termasuk transisi tahun (1 Januari)
|
|
|
|
|
|
const isMonthTransition = day === 1;
|
|
|
|
|
|
// Deteksi khusus untuk transisi tahun (1 Januari)
|
|
|
|
|
|
const isYearTransition = month === 1 && day === 1;
|
|
|
|
|
|
|
|
|
|
|
|
if (isMonthTransition) {
|
|
|
|
|
|
const transitionType = isYearTransition ? 'Tahun' : 'Bulan';
|
|
|
|
|
|
console.log('[Dashboard] ⚠️ Transisi terdeteksi:', {
|
|
|
|
|
|
date: state.date,
|
|
|
|
|
|
year,
|
|
|
|
|
|
month,
|
|
|
|
|
|
day,
|
|
|
|
|
|
isYearTransition,
|
|
|
|
|
|
isMonthTransition,
|
|
|
|
|
|
transitionType,
|
|
|
|
|
|
note: `Transisi ${transitionType} - Data hourly mungkin belum ter-aggregate dengan benar di backend`
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Reset data sementara sebelum load (untuk menghindari data lama terlihat)
|
|
|
|
|
|
renderSummary({
|
|
|
|
|
|
totalAmount: 0,
|
|
|
|
|
|
personCount: 0,
|
|
|
|
|
|
motorCount: 0,
|
|
|
|
|
|
carCount: 0
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Log detail tanggal yang akan dikirim ke API
|
|
|
|
|
|
console.log('[Dashboard] loadSummaryAndCharts - Request params:', {
|
|
|
|
|
|
date: state.date,
|
|
|
|
|
|
dateType: typeof state.date,
|
|
|
|
|
|
dateLength: state.date ? state.date.length : 0,
|
|
|
|
|
|
dateValid: /^\d{4}-\d{2}-\d{2}$/.test(state.date),
|
|
|
|
|
|
locationCode: state.locationCode,
|
|
|
|
|
|
gateCode: state.gateCode,
|
|
|
|
|
|
dateInputValue: document.getElementById('filter-date')?.value
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-12-18 11:21:40 +07:00
|
|
|
|
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);
|
2026-01-01 23:38:42 +07:00
|
|
|
|
console.log('[Dashboard] Summary response keys:', Object.keys(summaryResp || {}));
|
|
|
|
|
|
console.log('[Dashboard] Summary response values:', {
|
|
|
|
|
|
total_count: summaryResp?.total_count,
|
|
|
|
|
|
total_amount: summaryResp?.total_amount,
|
|
|
|
|
|
active_gates: summaryResp?.active_gates,
|
|
|
|
|
|
active_locations: summaryResp?.active_locations
|
|
|
|
|
|
});
|
2025-12-18 11:21:40 +07:00
|
|
|
|
console.log('[Dashboard] By Category response raw:', byCategoryResp);
|
2026-01-01 23:38:42 +07:00
|
|
|
|
console.log('[Dashboard] By Category response keys:', Object.keys(byCategoryResp || {}));
|
2025-12-18 11:21:40 +07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 23:38:42 +07:00
|
|
|
|
// Hitung total dari hourly data untuk validasi
|
|
|
|
|
|
const hourlyTotalCount = totalCounts.reduce((sum, val) => sum + (Number(val) || 0), 0);
|
|
|
|
|
|
const hourlyTotalAmount = totalAmounts.reduce((sum, val) => sum + (Number(val) || 0), 0);
|
|
|
|
|
|
|
2025-12-18 11:21:40 +07:00
|
|
|
|
console.log('[Dashboard] Hourly data processed:', {
|
|
|
|
|
|
date: state.date,
|
|
|
|
|
|
labels: labels.length,
|
|
|
|
|
|
counts: totalCounts.length,
|
|
|
|
|
|
amounts: totalAmounts.length,
|
2026-01-01 23:38:42 +07:00
|
|
|
|
totalCount: hourlyTotalCount,
|
|
|
|
|
|
totalAmount: hourlyTotalAmount
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Validasi: Bandingkan total dari hourly dengan summary
|
|
|
|
|
|
const summaryTotalCount = summary.total_count || 0;
|
|
|
|
|
|
const summaryTotalAmount = summary.total_amount || 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Parse tanggal untuk deteksi transisi tahun/bulan
|
|
|
|
|
|
const [year, month, day] = state.date.split('-').map(Number);
|
|
|
|
|
|
// Deteksi transisi: hari pertama bulan (day === 1)
|
|
|
|
|
|
// Ini berlaku untuk semua bulan, termasuk transisi tahun (1 Januari)
|
|
|
|
|
|
const isMonthTransition = day === 1;
|
|
|
|
|
|
// Deteksi khusus untuk transisi tahun (1 Januari)
|
|
|
|
|
|
const isYearTransition = month === 1 && day === 1;
|
|
|
|
|
|
|
|
|
|
|
|
// Log detail untuk debugging
|
|
|
|
|
|
console.log('[Dashboard] Data validation:', {
|
|
|
|
|
|
date: state.date,
|
|
|
|
|
|
year,
|
|
|
|
|
|
month,
|
|
|
|
|
|
day,
|
|
|
|
|
|
isYearTransition,
|
|
|
|
|
|
isMonthTransition,
|
|
|
|
|
|
summaryTotalCount,
|
|
|
|
|
|
summaryTotalAmount,
|
|
|
|
|
|
hourlyTotalCount,
|
|
|
|
|
|
hourlyTotalAmount,
|
|
|
|
|
|
countDifference: Math.abs(hourlyTotalCount - summaryTotalCount),
|
|
|
|
|
|
amountDifference: Math.abs(hourlyTotalAmount - summaryTotalAmount),
|
|
|
|
|
|
ratio: hourlyTotalCount > 0 && summaryTotalCount > 0
|
|
|
|
|
|
? (hourlyTotalCount / summaryTotalCount).toFixed(2)
|
|
|
|
|
|
: 'N/A'
|
2025-12-18 11:21:40 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-01 23:38:42 +07:00
|
|
|
|
// Flag untuk menandai apakah hourly data valid
|
|
|
|
|
|
let hourlyDataValid = true;
|
|
|
|
|
|
let useHourlyData = true;
|
|
|
|
|
|
|
|
|
|
|
|
if (hourlyTotalCount > 0 && summaryTotalCount > 0) {
|
|
|
|
|
|
const countDiff = Math.abs(hourlyTotalCount - summaryTotalCount);
|
|
|
|
|
|
const amountDiff = Math.abs(hourlyTotalAmount - summaryTotalAmount);
|
|
|
|
|
|
const maxCount = Math.max(hourlyTotalCount, summaryTotalCount);
|
|
|
|
|
|
const maxAmount = Math.max(hourlyTotalAmount, summaryTotalAmount);
|
|
|
|
|
|
const countDiffPercent = maxCount > 0
|
|
|
|
|
|
? Number(((countDiff / maxCount) * 100).toFixed(2))
|
|
|
|
|
|
: 0;
|
|
|
|
|
|
const amountDiffPercent = maxAmount > 0
|
|
|
|
|
|
? Number(((amountDiff / maxAmount) * 100).toFixed(2))
|
|
|
|
|
|
: 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Hitung ratio untuk deteksi data yang sangat tidak proporsional
|
|
|
|
|
|
const countRatio = hourlyTotalCount > 0 && summaryTotalCount > 0
|
|
|
|
|
|
? hourlyTotalCount / summaryTotalCount
|
|
|
|
|
|
: 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Deteksi khusus untuk transisi tahun/bulan: jika hourly jauh lebih besar dari summary
|
|
|
|
|
|
// Kemungkinan hourly data dari tanggal lain atau semua data
|
|
|
|
|
|
// Threshold lebih ketat untuk transisi tahun (20% vs 50%)
|
|
|
|
|
|
const threshold = (isYearTransition || isMonthTransition) ? 20 : 50;
|
|
|
|
|
|
const ratioThreshold = 2.0; // Jika hourly > 2x summary, kemungkinan salah
|
|
|
|
|
|
|
|
|
|
|
|
// Jika perbedaan sangat besar atau ratio tidak proporsional, kemungkinan hourly data salah
|
|
|
|
|
|
if (countDiffPercent > threshold || amountDiffPercent > threshold || countRatio > ratioThreshold) {
|
|
|
|
|
|
hourlyDataValid = false;
|
|
|
|
|
|
useHourlyData = false;
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[Dashboard] ❌ Hourly data TIDAK VALID - perbedaan terlalu besar:', {
|
|
|
|
|
|
date: state.date,
|
|
|
|
|
|
isYearTransition,
|
|
|
|
|
|
isMonthTransition,
|
|
|
|
|
|
summaryTotalCount,
|
|
|
|
|
|
hourlyTotalCount,
|
|
|
|
|
|
countDiffPercent: countDiffPercent + '%',
|
|
|
|
|
|
amountDiffPercent: amountDiffPercent + '%',
|
|
|
|
|
|
countRatio: countRatio.toFixed(2),
|
|
|
|
|
|
threshold: threshold + '%',
|
|
|
|
|
|
action: 'Mengabaikan hourly data, menggunakan data kosong untuk chart'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Reset hourly data ke kosong karena tidak valid
|
|
|
|
|
|
labels = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
|
|
|
|
|
|
totalCounts = Array(24).fill(0);
|
|
|
|
|
|
totalAmounts = Array(24).fill(0);
|
|
|
|
|
|
|
|
|
|
|
|
// Tampilkan error di UI dengan info lebih detail
|
|
|
|
|
|
const transitionInfo = isYearTransition
|
|
|
|
|
|
? ' (Transisi Tahun - Data mungkin belum ter-aggregate)'
|
|
|
|
|
|
: isMonthTransition
|
|
|
|
|
|
? ' (Transisi Bulan - Data mungkin belum ter-aggregate)'
|
|
|
|
|
|
: '';
|
|
|
|
|
|
const errorMsg = `⚠️ PERINGATAN: Data hourly tidak valid untuk tanggal ${state.date}${transitionInfo}\n` +
|
|
|
|
|
|
`Summary: ${summaryTotalCount} events (Rp ${new Intl.NumberFormat('id-ID').format(summaryTotalAmount || 0)})\n` +
|
|
|
|
|
|
`Hourly: ${hourlyTotalCount} events (Rp ${new Intl.NumberFormat('id-ID').format(hourlyTotalAmount || 0)})\n` +
|
|
|
|
|
|
`Perbedaan: ${countDiffPercent.toFixed(2)}% (Ratio: ${countRatio.toFixed(2)}x) - Kemungkinan data hourly dari tanggal lain.\n` +
|
|
|
|
|
|
`Chart hourly akan kosong. Silakan hubungi administrator untuk memperbaiki backend API atau trigger aggregation manual.`;
|
|
|
|
|
|
showError(errorMsg);
|
|
|
|
|
|
} else if (countDiffPercent > 5 || amountDiffPercent > 5) { // Threshold 5% untuk deteksi lebih sensitif
|
|
|
|
|
|
const warningMsg = `⚠️ DATA INCONSISTENCY DETECTED untuk tanggal ${state.date}:\n` +
|
|
|
|
|
|
`- Summary: ${summaryTotalCount} events (Rp ${new Intl.NumberFormat('id-ID').format(summaryTotalAmount || 0)})\n` +
|
|
|
|
|
|
`- Hourly: ${hourlyTotalCount} events (Rp ${new Intl.NumberFormat('id-ID').format(hourlyTotalAmount || 0)})\n` +
|
|
|
|
|
|
`- Perbedaan: ${countDiff} events (${countDiffPercent.toFixed(2)}%), Rp ${new Intl.NumberFormat('id-ID').format(amountDiff)} (${amountDiffPercent.toFixed(2)}%)\n` +
|
|
|
|
|
|
`- Catatan: Menggunakan data Summary sebagai sumber utama. Perbedaan kecil mungkin normal karena timing aggregation.`;
|
|
|
|
|
|
|
|
|
|
|
|
const warningData = {
|
|
|
|
|
|
date: state.date,
|
|
|
|
|
|
summaryTotalCount,
|
|
|
|
|
|
summaryTotalAmount,
|
|
|
|
|
|
hourlyTotalCount,
|
|
|
|
|
|
hourlyTotalAmount,
|
|
|
|
|
|
countDifference: countDiff,
|
|
|
|
|
|
countDifferencePercent: countDiffPercent + '%',
|
|
|
|
|
|
amountDifference: amountDiff,
|
|
|
|
|
|
amountDifferencePercent: amountDiffPercent + '%',
|
|
|
|
|
|
message: 'Summary dan Hourly data tidak konsisten. Menggunakan Summary sebagai sumber utama.'
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
console.warn('[Dashboard] ⚠️ DATA INCONSISTENCY DETECTED:', warningData);
|
|
|
|
|
|
console.warn('[Dashboard]', warningMsg);
|
|
|
|
|
|
|
|
|
|
|
|
// Tampilkan warning di UI hanya jika perbedaan sangat besar (>30%)
|
|
|
|
|
|
// Perbedaan kecil (<30%) mungkin normal karena timing aggregation atau rounding
|
|
|
|
|
|
if (countDiffPercent > 30 || amountDiffPercent > 30) {
|
|
|
|
|
|
const errorMsg = `⚠️ PERINGATAN: Data tidak konsisten untuk tanggal ${state.date}\n` +
|
|
|
|
|
|
`Summary: ${summaryTotalCount} events (Rp ${new Intl.NumberFormat('id-ID').format(summaryTotalAmount || 0)})\n` +
|
|
|
|
|
|
`Hourly: ${hourlyTotalCount} events (Rp ${new Intl.NumberFormat('id-ID').format(hourlyTotalAmount || 0)})\n` +
|
|
|
|
|
|
`Selisih: ${countDiffPercent.toFixed(2)}% events, ${amountDiffPercent.toFixed(2)}% amount\n` +
|
|
|
|
|
|
`Silakan hubungi administrator jika perbedaan ini tidak normal.`;
|
|
|
|
|
|
showError(errorMsg);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log('[Dashboard] ✅ Data konsisten antara Summary dan Hourly:', {
|
|
|
|
|
|
date: state.date,
|
|
|
|
|
|
summaryTotalCount,
|
|
|
|
|
|
hourlyTotalCount,
|
|
|
|
|
|
difference: countDiff,
|
|
|
|
|
|
differencePercent: countDiffPercent + '%'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (hourlyTotalCount === 0 && summaryTotalCount === 0) {
|
|
|
|
|
|
console.log('[Dashboard] ℹ️ Tidak ada data untuk tanggal', state.date);
|
|
|
|
|
|
useHourlyData = true; // Data kosong adalah valid
|
|
|
|
|
|
} else if (hourlyTotalCount > 0 && summaryTotalCount === 0) {
|
|
|
|
|
|
// Jika hourly ada data tapi summary kosong, kemungkinan summary belum ter-aggregate
|
|
|
|
|
|
// Tapi kita tetap gunakan hourly data dengan warning
|
|
|
|
|
|
console.warn('[Dashboard] ⚠️ Summary kosong tapi Hourly ada data:', {
|
|
|
|
|
|
date: state.date,
|
|
|
|
|
|
hourlyTotalCount,
|
|
|
|
|
|
message: 'Summary endpoint tidak mengembalikan data, tapi Hourly endpoint ada data. Menggunakan data Hourly dengan hati-hati.'
|
|
|
|
|
|
});
|
|
|
|
|
|
useHourlyData = true;
|
|
|
|
|
|
hourlyDataValid = false; // Mark as potentially invalid
|
|
|
|
|
|
} else if (hourlyTotalCount === 0 && summaryTotalCount > 0) {
|
|
|
|
|
|
console.warn('[Dashboard] ⚠️ Hourly kosong tapi Summary ada data:', {
|
|
|
|
|
|
date: state.date,
|
|
|
|
|
|
summaryTotalCount,
|
|
|
|
|
|
message: 'Hourly endpoint tidak mengembalikan data, tapi Summary endpoint ada data. Chart akan kosong.'
|
|
|
|
|
|
});
|
|
|
|
|
|
useHourlyData = true; // Data kosong adalah valid (tidak ada data per jam)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Log final decision
|
|
|
|
|
|
if (!useHourlyData) {
|
|
|
|
|
|
console.log('[Dashboard] Decision: Mengabaikan hourly data karena tidak valid, menggunakan data kosong untuk chart');
|
|
|
|
|
|
} else if (!hourlyDataValid) {
|
|
|
|
|
|
console.log('[Dashboard] Decision: Menggunakan hourly data dengan peringatan (potentially invalid)');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log('[Dashboard] Decision: Menggunakan hourly data (valid)');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 11:21:40 +07:00
|
|
|
|
// 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) {
|
2026-01-01 23:38:42 +07:00
|
|
|
|
// Set data baru (updateDailyChart sudah handle reset dan validasi)
|
2025-12-18 11:21:40 +07:00
|
|
|
|
updateDailyChart({
|
|
|
|
|
|
labels,
|
|
|
|
|
|
counts: totalCounts,
|
|
|
|
|
|
amounts: totalAmounts
|
|
|
|
|
|
});
|
2026-01-01 23:38:42 +07:00
|
|
|
|
console.log('[Dashboard] Daily chart updated successfully for date:', state.date);
|
2025-12-18 11:21:40 +07:00
|
|
|
|
} 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) {
|
2026-01-01 23:38:42 +07:00
|
|
|
|
// Set data baru (updateCategoryChart sudah handle reset dan validasi)
|
2025-12-18 11:21:40 +07:00
|
|
|
|
updateCategoryChart({
|
|
|
|
|
|
labels: catLabels,
|
|
|
|
|
|
values: catValues
|
|
|
|
|
|
});
|
2026-01-01 23:38:42 +07:00
|
|
|
|
console.log('[Dashboard] Category chart updated successfully for date:', state.date);
|
2025-12-18 11:21:40 +07:00
|
|
|
|
} 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');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:47:26 +07:00
|
|
|
|
// Load gates untuk mendapatkan camera URL dari database
|
|
|
|
|
|
async function loadGatesForCamera() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await apiGetGates(null, { limit: 1000 });
|
2025-12-19 05:08:43 +07:00
|
|
|
|
let gates = [];
|
|
|
|
|
|
if (Array.isArray(response)) {
|
|
|
|
|
|
gates = response;
|
|
|
|
|
|
} else if (response && Array.isArray(response.data)) {
|
|
|
|
|
|
gates = response.data;
|
|
|
|
|
|
}
|
2025-12-18 13:47:26 +07:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-12-19 05:08:43 +07:00
|
|
|
|
console.log('[Dashboard] Gates dengan camera loaded:', Object.keys(gatesCache).length, gatesCache);
|
2025-12-18 13:47:26 +07:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('[Dashboard] Error loading gates for camera:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load locations untuk mendapatkan nama lokasi
|
|
|
|
|
|
async function loadLocationsForCamera() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await apiGetLocations({ limit: 1000 });
|
2025-12-19 05:09:09 +07:00
|
|
|
|
let locations = [];
|
|
|
|
|
|
if (Array.isArray(response)) {
|
|
|
|
|
|
locations = response;
|
|
|
|
|
|
} else if (response && Array.isArray(response.data)) {
|
|
|
|
|
|
locations = response.data;
|
|
|
|
|
|
}
|
2025-12-18 13:47:26 +07:00
|
|
|
|
|
|
|
|
|
|
locationsCache = {};
|
|
|
|
|
|
locations.forEach(loc => {
|
|
|
|
|
|
const code = loc.code || loc.location_code || '';
|
|
|
|
|
|
locationsCache[code] = {
|
|
|
|
|
|
name: loc.name || loc.label || code
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
2025-12-19 05:09:09 +07:00
|
|
|
|
|
|
|
|
|
|
console.log('[Dashboard] Locations loaded for camera:', Object.keys(locationsCache).length, locationsCache);
|
2025-12-18 13:47:26 +07:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('[Dashboard] Error loading locations for camera:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 11:21:40 +07:00
|
|
|
|
function getCameraForLocation(locationCode) {
|
2025-12-19 05:09:09 +07:00
|
|
|
|
if (!locationCode) {
|
|
|
|
|
|
console.log('[Dashboard] getCameraForLocation: no locationCode');
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const camera = gatesCache[locationCode] || null;
|
|
|
|
|
|
console.log('[Dashboard] getCameraForLocation:', { locationCode, found: !!camera, gatesCacheKeys: Object.keys(gatesCache) });
|
|
|
|
|
|
return camera;
|
2025-12-18 11:21:40 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showVideoPanel(locationCode) {
|
|
|
|
|
|
const videoSection = document.getElementById('video-section');
|
|
|
|
|
|
const videoPanelTitle = document.getElementById('video-panel-title');
|
|
|
|
|
|
const camera = getCameraForLocation(locationCode);
|
|
|
|
|
|
|
2025-12-18 13:50:11 +07:00
|
|
|
|
console.log('[Dashboard] showVideoPanel:', {
|
|
|
|
|
|
locationCode,
|
|
|
|
|
|
camera,
|
|
|
|
|
|
gatesCacheKeys: Object.keys(gatesCache),
|
|
|
|
|
|
locationsCacheKeys: Object.keys(locationsCache)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-12-18 13:47:26 +07:00
|
|
|
|
if (camera && camera.url && videoSection && videoPanelTitle) {
|
2025-12-18 11:21:40 +07:00
|
|
|
|
videoSection.style.display = 'block';
|
2025-12-18 13:47:26 +07:00
|
|
|
|
const displayName = camera.gate_name ? `${camera.name} - ${camera.gate_name}` : camera.name;
|
|
|
|
|
|
videoPanelTitle.textContent = displayName;
|
2025-12-18 11:21:40 +07:00
|
|
|
|
currentVideoUrl = camera.url;
|
2025-12-18 13:50:11 +07:00
|
|
|
|
console.log('[Dashboard] Video panel shown:', { displayName, url: currentVideoUrl });
|
2025-12-18 11:21:40 +07:00
|
|
|
|
// Auto-stop video kalau lokasi berubah
|
|
|
|
|
|
if (isVideoPlaying) {
|
|
|
|
|
|
stopVideo();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (videoSection) videoSection.style.display = 'none';
|
|
|
|
|
|
if (isVideoPlaying) {
|
|
|
|
|
|
stopVideo();
|
|
|
|
|
|
}
|
|
|
|
|
|
currentVideoUrl = null;
|
2025-12-18 13:50:11 +07:00
|
|
|
|
console.log('[Dashboard] Video panel hidden - no camera for location:', locationCode);
|
2025-12-18 11:21:40 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2025-12-19 05:29:19 +07:00
|
|
|
|
// Jangan override value yang sudah di-set di DOMContentLoaded
|
|
|
|
|
|
// Hanya set jika value masih kosong atau berbeda dengan state.date
|
|
|
|
|
|
if (!dateInput.value || dateInput.value !== state.date) {
|
|
|
|
|
|
dateInput.value = state.date;
|
|
|
|
|
|
dateInput.setAttribute('value', state.date);
|
|
|
|
|
|
}
|
2025-12-18 11:21:40 +07:00
|
|
|
|
dateInput.addEventListener('change', () => {
|
2026-01-01 23:38:42 +07:00
|
|
|
|
// Normalisasi tanggal yang dipilih user untuk memastikan format YYYY-MM-DD
|
|
|
|
|
|
const originalValue = dateInput.value;
|
|
|
|
|
|
const selectedDate = normalizeDate(originalValue);
|
|
|
|
|
|
|
|
|
|
|
|
if (selectedDate) {
|
|
|
|
|
|
// Validasi bahwa tanggal valid (cek apakah tanggal yang dipilih sama dengan yang dinormalisasi)
|
|
|
|
|
|
const [year, month, day] = selectedDate.split('-').map(Number);
|
|
|
|
|
|
const dateObj = new Date(year, month - 1, day);
|
|
|
|
|
|
const isValidDate = dateObj.getFullYear() === year &&
|
|
|
|
|
|
dateObj.getMonth() === month - 1 &&
|
|
|
|
|
|
dateObj.getDate() === day;
|
|
|
|
|
|
|
|
|
|
|
|
if (!isValidDate) {
|
|
|
|
|
|
console.warn('[Dashboard] Invalid date after normalization:', selectedDate);
|
|
|
|
|
|
dateInput.value = state.date;
|
|
|
|
|
|
dateInput.setAttribute('value', state.date);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cek apakah tanggal benar-benar berubah
|
|
|
|
|
|
const dateChanged = selectedDate !== state.date;
|
|
|
|
|
|
|
|
|
|
|
|
// Deteksi transisi tahun/bulan
|
|
|
|
|
|
// Deteksi transisi: hari pertama bulan (day === 1)
|
|
|
|
|
|
const isMonthTransition = day === 1;
|
|
|
|
|
|
// Deteksi khusus untuk transisi tahun (1 Januari)
|
|
|
|
|
|
const isYearTransition = month === 1 && day === 1;
|
|
|
|
|
|
const wasYearTransition = state.date && (() => {
|
|
|
|
|
|
const [prevYear, prevMonth, prevDay] = state.date.split('-').map(Number);
|
|
|
|
|
|
return prevYear === 2026 && prevMonth === 1 && prevDay === 1;
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
|
|
if (dateChanged) {
|
|
|
|
|
|
console.log('[Dashboard] Date changed by user:', {
|
|
|
|
|
|
original: originalValue,
|
|
|
|
|
|
normalized: selectedDate,
|
|
|
|
|
|
previousDate: state.date,
|
|
|
|
|
|
year: year,
|
|
|
|
|
|
month: month,
|
|
|
|
|
|
day: day,
|
|
|
|
|
|
isYearTransition,
|
|
|
|
|
|
isMonthTransition,
|
|
|
|
|
|
wasYearTransition,
|
|
|
|
|
|
note: 'Resetting all data before loading new date'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Reset semua data sebelum load tanggal baru (penting untuk transisi tahun/bulan)
|
|
|
|
|
|
renderSummary({
|
|
|
|
|
|
totalAmount: 0,
|
|
|
|
|
|
personCount: 0,
|
|
|
|
|
|
motorCount: 0,
|
|
|
|
|
|
carCount: 0
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Reset chart juga
|
|
|
|
|
|
const dailyChartInstance = getDailyChart();
|
|
|
|
|
|
if (dailyChartInstance) {
|
|
|
|
|
|
dailyChartInstance.data.labels = [];
|
|
|
|
|
|
dailyChartInstance.data.datasets[0].data = [];
|
|
|
|
|
|
dailyChartInstance.data.datasets[1].data = [];
|
|
|
|
|
|
dailyChartInstance.update('none');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const categoryChartInstance = getCategoryChart();
|
|
|
|
|
|
if (categoryChartInstance) {
|
|
|
|
|
|
categoryChartInstance.data.labels = [];
|
|
|
|
|
|
categoryChartInstance.data.datasets[0].data = [];
|
|
|
|
|
|
categoryChartInstance.update('none');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
state.date = selectedDate;
|
|
|
|
|
|
// Pastikan dateInput juga menggunakan format yang sudah dinormalisasi
|
|
|
|
|
|
if (dateInput.value !== selectedDate) {
|
|
|
|
|
|
dateInput.value = selectedDate;
|
|
|
|
|
|
dateInput.setAttribute('value', selectedDate);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Hanya reload jika tanggal benar-benar berubah
|
|
|
|
|
|
if (dateChanged) {
|
|
|
|
|
|
// Small delay untuk memastikan UI sudah di-reset
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
loadSummaryAndCharts();
|
|
|
|
|
|
}, 50);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.warn('[Dashboard] Invalid date selected, keeping current date:', state.date);
|
|
|
|
|
|
// Reset ke state.date jika invalid
|
|
|
|
|
|
dateInput.value = state.date;
|
|
|
|
|
|
dateInput.setAttribute('value', state.date);
|
|
|
|
|
|
}
|
2025-12-18 11:21:40 +07:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 = '';
|
|
|
|
|
|
|
2025-12-18 13:47:26 +07:00
|
|
|
|
// Reload gates untuk update camera cache
|
|
|
|
|
|
await loadGates();
|
|
|
|
|
|
await loadGatesForCamera(); // Reload camera URLs
|
|
|
|
|
|
|
2025-12-18 11:21:40 +07:00
|
|
|
|
// Show/hide video panel berdasarkan lokasi
|
|
|
|
|
|
showVideoPanel(state.locationCode);
|
|
|
|
|
|
|
|
|
|
|
|
loadSummaryAndCharts();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const gateSelect = document.getElementById('filter-gate');
|
|
|
|
|
|
if (gateSelect) {
|
|
|
|
|
|
gateSelect.addEventListener('change', () => {
|
|
|
|
|
|
state.gateCode = gateSelect.value;
|
|
|
|
|
|
loadSummaryAndCharts();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:49:47 +07:00
|
|
|
|
// Video toggle button akan di-setup di DOMContentLoaded setelah load camera data
|
2025-12-18 11:21:40 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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()) {
|
2025-12-18 11:37:54 +07:00
|
|
|
|
// Cek apakah sudah di login page untuk mencegah redirect loop
|
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' ||
|
|
|
|
|
|
currentPath.endsWith('/') ||
|
|
|
|
|
|
currentPath === '';
|
|
|
|
|
|
|
|
|
|
|
|
// JANGAN redirect jika sudah di login page atau root
|
|
|
|
|
|
if (!isLoginPage && currentPath.includes('dashboard')) {
|
|
|
|
|
|
// Hanya redirect jika benar-benar di halaman dashboard
|
2025-12-18 13:36:36 +07:00
|
|
|
|
window.location.href = '../index.html';
|
2025-12-18 11:37:54 +07:00
|
|
|
|
}
|
2025-12-18 11:21:40 +07:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 05:13:03 +07:00
|
|
|
|
// Set default date ke hari ini (selalu update ke hari ini setiap kali page load)
|
2025-12-19 05:36:42 +07:00
|
|
|
|
// Gunakan timezone Indonesia (UTC+7) untuk mendapatkan tanggal lokal yang benar
|
2025-12-19 05:37:24 +07:00
|
|
|
|
const today = getTodayIndonesia();
|
2025-12-18 11:21:40 +07:00
|
|
|
|
state.date = today;
|
2025-12-19 05:37:24 +07:00
|
|
|
|
const now = new Date();
|
2025-12-19 05:36:42 +07:00
|
|
|
|
console.log('[Dashboard] Default date set to today (Indonesia timezone):', state.date, 'UTC time:', now.toISOString());
|
2025-12-18 11:21:40 +07:00
|
|
|
|
|
2025-12-19 05:17:46 +07:00
|
|
|
|
// Set dateInput value SECARA LANGSUNG untuk override browser cache/autofill
|
2025-12-19 05:29:39 +07:00
|
|
|
|
// Lakukan ini SEBELUM setupFilters() untuk memastikan value ter-set dengan benar
|
2025-12-19 05:17:46 +07:00
|
|
|
|
const dateInput = document.getElementById('filter-date');
|
|
|
|
|
|
if (dateInput) {
|
2025-12-19 05:31:38 +07:00
|
|
|
|
// AGGRESIF: Remove value dulu, lalu set lagi untuk force override browser cache
|
|
|
|
|
|
dateInput.removeAttribute('value');
|
|
|
|
|
|
dateInput.value = '';
|
|
|
|
|
|
|
|
|
|
|
|
// Set value multiple times dengan berbagai cara untuk memastikan
|
2025-12-19 05:17:46 +07:00
|
|
|
|
dateInput.value = today;
|
2025-12-19 05:29:39 +07:00
|
|
|
|
dateInput.setAttribute('value', today);
|
|
|
|
|
|
dateInput.defaultValue = today;
|
2025-12-19 05:31:38 +07:00
|
|
|
|
|
|
|
|
|
|
// Force update dengan requestAnimationFrame untuk memastikan DOM sudah ready
|
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
|
dateInput.value = today;
|
|
|
|
|
|
dateInput.setAttribute('value', today);
|
|
|
|
|
|
console.log('[Dashboard] Date input force set (RAF):', today, 'actual value:', dateInput.value);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Set lagi setelah semua async operations selesai
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (dateInput.value !== today) {
|
|
|
|
|
|
dateInput.value = today;
|
|
|
|
|
|
dateInput.setAttribute('value', today);
|
|
|
|
|
|
console.log('[Dashboard] Date input force set (timeout):', today, 'actual value:', dateInput.value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
|
2025-12-19 05:17:46 +07:00
|
|
|
|
console.log('[Dashboard] Date input set to:', today, 'actual value:', dateInput.value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 11:21:40 +07:00
|
|
|
|
setTopbarDate();
|
|
|
|
|
|
initCharts();
|
2025-12-19 05:13:03 +07:00
|
|
|
|
|
2025-12-19 05:29:39 +07:00
|
|
|
|
// Setup filters SETELAH state.date dan dateInput.value sudah di-set
|
2025-12-18 11:21:40 +07:00
|
|
|
|
setupFilters();
|
|
|
|
|
|
await loadLocations();
|
|
|
|
|
|
await loadGates();
|
|
|
|
|
|
|
2025-12-18 13:47:26 +07:00
|
|
|
|
// Load locations dan gates untuk camera URL dari database
|
|
|
|
|
|
await loadLocationsForCamera();
|
|
|
|
|
|
await loadGatesForCamera();
|
|
|
|
|
|
|
2025-12-18 13:49:47 +07:00
|
|
|
|
// Setup video toggle button SETELAH load camera data
|
|
|
|
|
|
const toggleBtn = document.getElementById('video-toggle');
|
|
|
|
|
|
if (toggleBtn) {
|
|
|
|
|
|
toggleBtn.addEventListener('click', () => {
|
|
|
|
|
|
if (isVideoPlaying) {
|
|
|
|
|
|
stopVideo();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
startVideo();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 11:21:40 +07:00
|
|
|
|
// Cek lokasi awal untuk show/hide video
|
|
|
|
|
|
showVideoPanel(state.locationCode);
|
|
|
|
|
|
|
|
|
|
|
|
await loadSummaryAndCharts();
|
2025-12-19 05:31:56 +07:00
|
|
|
|
|
|
|
|
|
|
// FINAL CHECK: Pastikan dateInput masih hari ini setelah semua async operations
|
|
|
|
|
|
const finalDateInput = document.getElementById('filter-date');
|
|
|
|
|
|
if (finalDateInput && finalDateInput.value !== state.date) {
|
|
|
|
|
|
console.warn('[Dashboard] Date input value changed after async operations, resetting to:', state.date);
|
|
|
|
|
|
finalDateInput.value = state.date;
|
|
|
|
|
|
finalDateInput.setAttribute('value', state.date);
|
|
|
|
|
|
}
|
2025-12-18 11:21:40 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|