Files
Retribusi/public/dashboard/js/dashboard.js
BTekno Dev 768a1e146c Feat: Tambah animasi counter di KPI cards dan animasi naik turun di chart trending harian
- Counter animation dari angka terkecil ke target dengan easing smooth
- Chart animation naik dari bawah tanpa animasi horizontal
- Loading overlay dengan spinner animation
- Fade animation untuk card values saat update
2026-01-02 00:12:03 +07:00

1342 lines
49 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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';
// Helper function untuk mendapatkan tanggal hari ini dalam timezone Indonesia (UTC+7)
// Menggunakan Intl.DateTimeFormat untuk mendapatkan tanggal yang konsisten di semua browser
function getTodayIndonesia() {
const now = new Date();
// Format tanggal dalam timezone Asia/Jakarta (UTC+7)
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Jakarta',
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
// Format: YYYY-MM-DD
return formatter.format(now);
}
// 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 '';
}
// State akan di-set ke hari ini saat DOMContentLoaded
const state = {
date: '', // Akan di-set ke hari ini saat DOMContentLoaded
locationCode: '',
gateCode: ''
};
// Function untuk auto-detect tanggal terakhir yang ada data
async function getLastAvailableDate() {
try {
// Coba ambil data hari ini dulu (gunakan timezone Indonesia UTC+7)
const today = getTodayIndonesia();
const todayData = await apiGetSummary({ date: today });
console.log('[Dashboard] getLastAvailableDate - today data:', todayData);
// Handle jika response masih wrapped (seharusnya sudah di-unwrap oleh api.js)
let todaySummary = todayData;
if (todayData && typeof todayData === 'object' && 'data' in todayData && !('total_count' in todayData)) {
todaySummary = todayData.data || {};
}
// Jika hari ini ada data, return hari ini
if (todaySummary && (todaySummary.total_count > 0 || todaySummary.total_amount > 0)) {
console.log('[Dashboard] Using today:', today);
return today;
}
// Jika tidak, coba kemarin (gunakan timezone Indonesia UTC+7)
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Jakarta',
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
const yesterdayDate = new Date(now.getTime() - (24 * 60 * 60 * 1000));
const yesterdayStr = formatter.format(yesterdayDate);
const yesterdayData = await apiGetSummary({ date: yesterdayStr });
console.log('[Dashboard] getLastAvailableDate - yesterday data:', yesterdayData);
// Handle jika response masih wrapped
let yesterdaySummary = yesterdayData;
if (yesterdayData && typeof yesterdayData === 'object' && 'data' in yesterdayData && !('total_count' in yesterdayData)) {
yesterdaySummary = yesterdayData.data || {};
}
if (yesterdaySummary && (yesterdaySummary.total_count > 0 || yesterdaySummary.total_amount > 0)) {
console.log('[Dashboard] Using yesterday:', yesterdayStr);
return yesterdayStr;
}
// Jika tidak ada data kemarin, cek 7 hari terakhir (gunakan timezone Indonesia UTC+7)
// Reuse 'now' dan 'formatter' yang sudah dideklarasikan di atas
for (let i = 2; i <= 7; i++) {
const prevDate = new Date(now.getTime() - (i * 24 * 60 * 60 * 1000));
const prevDateStr = formatter.format(prevDate);
const prevData = await apiGetSummary({ date: prevDateStr });
console.log(`[Dashboard] getLastAvailableDate - ${i} days ago (${prevDateStr}) data:`, prevData);
// Handle jika response masih wrapped
let prevSummary = prevData;
if (prevData && typeof prevData === 'object' && 'data' in prevData && !('total_count' in prevData)) {
prevSummary = prevData.data || {};
}
if (prevSummary && (prevSummary.total_count > 0 || prevSummary.total_amount > 0)) {
console.log(`[Dashboard] Using ${i} days ago:`, prevDateStr);
return prevDateStr;
}
}
// Jika tidak ada data sama sekali, tetap return hari ini
// User bisa pilih tanggal manual jika perlu
console.log('[Dashboard] No data found in last 7 days, using today:', today);
return today;
} catch (error) {
console.error('[Dashboard] Error getting last available date:', error);
// Fallback ke hari ini (selalu gunakan hari ini, bukan hardcoded date)
// Gunakan timezone Indonesia UTC+7
return getTodayIndonesia();
}
}
// 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;
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();
// Gunakan timezone Asia/Jakarta untuk konsistensi
el.textContent = d.toLocaleDateString('id-ID', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: '2-digit',
timeZone: 'Asia/Jakarta'
});
}
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);
}
}
// Counter animation function
function animateCounter(element, targetValue, formatter, duration = 1500) {
if (!element) return;
// Get current value from element text
const currentText = element.textContent || '0';
let currentValue = 0;
// Parse current value based on formatter type
if (formatter === formatCurrency) {
// Remove "Rp ", dots, and spaces, then parse
const cleaned = currentText.replace(/Rp\s?/g, '').replace(/\./g, '').replace(/\s/g, '');
currentValue = parseInt(cleaned) || 0;
} else {
// Remove dots and spaces for number format
const cleaned = currentText.replace(/\./g, '').replace(/\s/g, '');
currentValue = parseInt(cleaned) || 0;
}
const target = targetValue || 0;
const startValue = currentValue;
const difference = target - startValue;
const startTime = performance.now();
// Add updating class for fade effect
element.classList.add('updating');
function updateCounter(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing function (easeOutCubic) for smooth animation
const easeOutCubic = 1 - Math.pow(1 - progress, 3);
const current = Math.floor(startValue + (difference * easeOutCubic));
element.textContent = formatter(current);
if (progress < 1) {
requestAnimationFrame(updateCounter);
} else {
// Ensure final value is exact
element.textContent = formatter(target);
element.classList.remove('updating');
}
}
requestAnimationFrame(updateCounter);
}
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');
// Use counter animation for all values
animateCounter(amountEl, totalAmount || 0, formatCurrency, 1500);
animateCounter(personEl, personCount || 0, formatNumber, 1200);
animateCounter(motorEl, motorCount || 0, formatNumber, 1200);
animateCounter(carEl, carCount || 0, formatNumber, 1200);
}
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');
// 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
});
try {
const [summaryResp, hourlyResp, byCategoryResp] = await Promise.all([
apiGetSummary({
date: state.date,
locationCode: state.locationCode,
gateCode: state.gateCode
}),
// pakai summary hourly untuk chart: data hari ini per jam
apiGetSummaryHourly({
date: state.date,
location_code: state.locationCode,
gate_code: state.gateCode
}),
apiGetByCategory({
date: state.date,
locationCode: state.locationCode,
gateCode: state.gateCode
})
]);
// Kartu KPI: pakai total_count & total_amount dari summary endpoint
// Struktur summaryResp setelah di-unwrap: { total_count, total_amount, active_gates, active_locations }
console.log('[Dashboard] Summary response raw:', summaryResp);
console.log('[Dashboard] 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
});
console.log('[Dashboard] By Category response raw:', byCategoryResp);
console.log('[Dashboard] By Category response keys:', Object.keys(byCategoryResp || {}));
console.log('[Dashboard] State date:', state.date);
// Handle jika response masih wrapped
let summary = summaryResp || {};
if (summaryResp && typeof summaryResp === 'object' && 'data' in summaryResp && !('total_count' in summaryResp)) {
summary = summaryResp.data || {};
}
const totalAmount = summary.total_amount || 0;
console.log('[Dashboard] Parsed summary:', { totalAmount, summary });
// Hitung per kategori dari byCategoryResp
let personCount = 0;
let motorCount = 0;
let carCount = 0;
if (byCategoryResp) {
let categoryData = [];
// Handle jika response masih wrapped
let byCategory = byCategoryResp;
if (byCategoryResp && typeof byCategoryResp === 'object' && 'data' in byCategoryResp && !('labels' in byCategoryResp)) {
byCategory = byCategoryResp.data || byCategoryResp;
}
// Handle struktur response dengan data array (sesuai spec)
if (Array.isArray(byCategory)) {
categoryData = byCategory;
}
// Handle struktur response dengan labels & series (format chart)
else if (byCategory.labels && byCategory.series) {
const labels = byCategory.labels || [];
const counts = byCategory.series.total_count || [];
labels.forEach((label, index) => {
categoryData.push({
category: label,
total_count: counts[index] || 0
});
});
}
console.log('[Dashboard] Category data parsed:', categoryData);
// Extract data per kategori
categoryData.forEach(item => {
const count = item.total_count || 0;
if (item.category === 'person_walk') {
personCount = count;
} else if (item.category === 'motor') {
motorCount = count;
} else if (item.category === 'car') {
carCount = count;
}
});
}
console.log('[Dashboard] Final counts:', { personCount, motorCount, carCount, totalAmount });
console.log('[Dashboard] Summary processed:', {
date: state.date,
locationCode: state.locationCode,
gateCode: state.gateCode,
totalAmount,
personCount,
motorCount,
carCount
});
// Pastikan nilai tidak undefined
const finalTotalAmount = totalAmount || 0;
const finalPersonCount = personCount || 0;
const finalMotorCount = motorCount || 0;
const finalCarCount = carCount || 0;
console.log('[Dashboard] Rendering summary with values:', {
totalAmount: finalTotalAmount,
personCount: finalPersonCount,
motorCount: finalMotorCount,
carCount: finalCarCount
});
renderSummary({
totalAmount: finalTotalAmount,
personCount: finalPersonCount,
motorCount: finalMotorCount,
carCount: finalCarCount
});
// Daily chart data → tampilkan data hari ini per jam
// Struktur response: { labels: ["00","01",...], series: { total_count: [...], total_amount: [...] } }
// ATAU: { data: [{ hour: 0, total_count: 0, total_amount: 0 }, ...] }
let labels = [];
let totalCounts = [];
let totalAmounts = [];
console.log('[Dashboard] Hourly response raw:', hourlyResp);
// Handle jika response masih wrapped
let hourly = hourlyResp;
if (hourlyResp && typeof hourlyResp === 'object' && 'data' in hourlyResp && !('labels' in hourlyResp)) {
hourly = hourlyResp.data || hourlyResp;
}
// Handle struktur response dengan labels & series
if (hourly && hourly.labels && hourly.series) {
labels = hourly.labels.map(h => String(h).padStart(2, '0') + ':00'); // "00" -> "00:00", "1" -> "01:00"
totalCounts = hourly.series.total_count || [];
totalAmounts = hourly.series.total_amount || [];
console.log('[Dashboard] Hourly data from labels & series:', { labelsCount: labels.length, countsCount: totalCounts.length });
}
// Handle struktur response dengan data array (sesuai spec)
else if (hourly && Array.isArray(hourly.data)) {
// Generate 24 jam (0-23)
labels = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
totalCounts = Array(24).fill(0);
totalAmounts = Array(24).fill(0);
// Map data dari response ke array per jam
hourly.data.forEach(item => {
const hour = item.hour || 0;
if (hour >= 0 && hour < 24) {
totalCounts[hour] = item.total_count || 0;
totalAmounts[hour] = item.total_amount || 0;
}
});
}
// Jika response kosong/null, tetap buat chart dengan data 0
else {
console.warn('[Dashboard] Hourly response tidak sesuai format, menggunakan data kosong');
labels = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
totalCounts = Array(24).fill(0);
totalAmounts = Array(24).fill(0);
}
// 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);
console.log('[Dashboard] Hourly data processed:', {
date: state.date,
labels: labels.length,
counts: totalCounts.length,
amounts: totalAmounts.length,
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'
});
// 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)');
}
// 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) {
// Set data baru (updateDailyChart sudah handle reset dan validasi)
updateDailyChart({
labels,
counts: totalCounts,
amounts: totalAmounts
});
console.log('[Dashboard] Daily chart updated successfully for date:', state.date);
} 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) {
// Set data baru (updateCategoryChart sudah handle reset dan validasi)
updateCategoryChart({
labels: catLabels,
values: catValues
});
console.log('[Dashboard] Category chart updated successfully for date:', state.date);
} 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');
}
}
// 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;
}
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('[Dashboard] Gates dengan camera loaded:', Object.keys(gatesCache).length, gatesCache);
} 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 });
let locations = [];
if (Array.isArray(response)) {
locations = response;
} else if (response && Array.isArray(response.data)) {
locations = response.data;
}
locationsCache = {};
locations.forEach(loc => {
const code = loc.code || loc.location_code || '';
locationsCache[code] = {
name: loc.name || loc.label || code
};
});
console.log('[Dashboard] Locations loaded for camera:', Object.keys(locationsCache).length, locationsCache);
} catch (err) {
console.error('[Dashboard] Error loading locations for camera:', err);
}
}
function getCameraForLocation(locationCode) {
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;
}
function showVideoPanel(locationCode) {
const videoSection = document.getElementById('video-section');
const videoPanelTitle = document.getElementById('video-panel-title');
const camera = getCameraForLocation(locationCode);
console.log('[Dashboard] showVideoPanel:', {
locationCode,
camera,
gatesCacheKeys: Object.keys(gatesCache),
locationsCacheKeys: Object.keys(locationsCache)
});
if (camera && camera.url && videoSection && videoPanelTitle) {
videoSection.style.display = 'block';
const displayName = camera.gate_name ? `${camera.name} - ${camera.gate_name}` : camera.name;
videoPanelTitle.textContent = displayName;
currentVideoUrl = camera.url;
console.log('[Dashboard] Video panel shown:', { displayName, url: currentVideoUrl });
// Auto-stop video kalau lokasi berubah
if (isVideoPlaying) {
stopVideo();
}
} else {
if (videoSection) videoSection.style.display = 'none';
if (isVideoPlaying) {
stopVideo();
}
currentVideoUrl = null;
console.log('[Dashboard] Video panel hidden - no camera for location:', locationCode);
}
}
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) {
// 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);
}
dateInput.addEventListener('change', () => {
// 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('active');
}
const categoryChartInstance = getCategoryChart();
if (categoryChartInstance) {
categoryChartInstance.data.labels = [];
categoryChartInstance.data.datasets[0].data = [];
categoryChartInstance.update('active');
}
}
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);
}
});
}
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 = '';
// Reload gates untuk update camera cache
await loadGates();
await loadGatesForCamera(); // Reload camera URLs
// 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();
});
}
// Video toggle button akan di-setup di DOMContentLoaded setelah load camera data
}
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()) {
// Cek apakah sudah di login page untuk mencegah redirect loop
const currentPath = window.location.pathname.toLowerCase();
const isLoginPage = currentPath.includes('index.html') ||
currentPath.includes('index.php') ||
currentPath === '/' ||
currentPath === '/index.html' ||
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
window.location.href = '../index.html';
}
return;
}
// Set default date ke hari ini (selalu update ke hari ini setiap kali page load)
// Gunakan timezone Indonesia (UTC+7) untuk mendapatkan tanggal lokal yang benar
const today = getTodayIndonesia();
state.date = today;
const now = new Date();
console.log('[Dashboard] Default date set to today (Indonesia timezone):', state.date, 'UTC time:', now.toISOString());
// Set dateInput value SECARA LANGSUNG untuk override browser cache/autofill
// Lakukan ini SEBELUM setupFilters() untuk memastikan value ter-set dengan benar
const dateInput = document.getElementById('filter-date');
if (dateInput) {
// 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
dateInput.value = today;
dateInput.setAttribute('value', today);
dateInput.defaultValue = today;
// 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);
console.log('[Dashboard] Date input set to:', today, 'actual value:', dateInput.value);
}
setTopbarDate();
initCharts();
// Setup filters SETELAH state.date dan dateInput.value sudah di-set
setupFilters();
await loadLocations();
await loadGates();
// Load locations dan gates untuk camera URL dari database
await loadLocationsForCamera();
await loadGatesForCamera();
// Setup video toggle button SETELAH load camera data
const toggleBtn = document.getElementById('video-toggle');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
if (isVideoPlaying) {
stopVideo();
} else {
startVideo();
}
});
}
// Cek lokasi awal untuk show/hide video
showVideoPanel(state.locationCode);
await loadSummaryAndCharts();
// 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);
}
});