diff --git a/public/dashboard/js/README_CONFIG.md b/public/dashboard/js/README_CONFIG.md index ed7d9de..30807d3 100644 --- a/public/dashboard/js/README_CONFIG.md +++ b/public/dashboard/js/README_CONFIG.md @@ -6,16 +6,17 @@ ## Cara Kerja -File `config.js` akan auto-detect environment berdasarkan hostname: +File `config.js` akan **auto-detect** environment berdasarkan hostname: -### Development Lokal -- Jika hostname = `localhost`, `127.0.0.1`, atau IP lokal (`192.168.x.x`) -- Base URL default: `http://localhost/api-btekno/public` -- **Sesuaikan** dengan path API backend di Laragon/XAMPP Anda +### Development Lokal (Auto-detect) +- Jika hostname = `localhost`, `127.0.0.1`, atau IP lokal (`192.168.x.x`, `10.x.x.x`, `172.x.x.x`) +- Base URL: `http://localhost/api-btekno/public` +- **Otomatis** menggunakan API lokal saat development -### Production -- Jika hostname bukan localhost +### Production (Auto-detect) +- Jika hostname bukan localhost/IP lokal - Base URL: `https://api.btekno.cloud` +- **Otomatis** menggunakan API produksi saat di production ## Cara Mengubah Base URL diff --git a/public/dashboard/js/api.js b/public/dashboard/js/api.js index b4133c7..6872ef2 100644 --- a/public/dashboard/js/api.js +++ b/public/dashboard/js/api.js @@ -67,6 +67,19 @@ async function apiRequest(path, options = {}) { json = { raw: text }; } + // Log response body untuk debug (khusus untuk summary endpoint) + if (url.includes('/dashboard/summary') || url.includes('/summary/hourly') || url.includes('/by-category')) { + console.log('[API] Response body for', url, ':', { + textLength: text.length, + jsonKeys: Object.keys(json || {}), + jsonPreview: JSON.stringify(json).substring(0, 500), + hasSuccess: json && 'success' in json, + hasData: json && 'data' in json, + totalCount: json?.total_count ?? json?.data?.total_count, + totalAmount: json?.total_amount ?? json?.data?.total_amount + }); + } + if (!res.ok) { const msg = json.message || json.error || `HTTP ${res.status}`; throw new Error(msg); @@ -103,7 +116,48 @@ function buildQuery(params = {}) { const search = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { - search.append(key, value); + // Pastikan value adalah string dan sudah di-encode dengan benar + let stringValue = String(value).trim(); + + // Validasi khusus untuk parameter date: harus format YYYY-MM-DD + if (key === 'date' || key === 'start_date' || key === 'end_date') { + // Validasi format tanggal + if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) { + console.error('[API] buildQuery - Invalid date format:', stringValue); + // Coba normalisasi jika mungkin + const dateObj = new Date(stringValue); + if (!isNaN(dateObj.getTime())) { + const year = dateObj.getFullYear(); + const month = String(dateObj.getMonth() + 1).padStart(2, '0'); + const day = String(dateObj.getDate()).padStart(2, '0'); + stringValue = `${year}-${month}-${day}`; + console.warn('[API] buildQuery - Date normalized to:', stringValue); + } else { + console.error('[API] buildQuery - Cannot normalize date, skipping:', stringValue); + return; // Skip parameter ini jika tidak valid + } + } + + // Validasi bahwa tanggal valid (tidak ada 2025-13-45) + const [year, month, day] = stringValue.split('-').map(Number); + const dateObj = new Date(year, month - 1, day); + if (dateObj.getFullYear() !== year || dateObj.getMonth() !== month - 1 || dateObj.getDate() !== day) { + console.error('[API] buildQuery - Invalid date values:', { year, month, day }); + return; // Skip parameter ini jika tidak valid + } + + console.log('[API] buildQuery - Date param validated:', { + key, + originalValue: value, + stringValue: stringValue, + encoded: stringValue, // URLSearchParams akan encode otomatis + year: year, + month: month, + day: day + }); + } + + search.append(key, stringValue); } }); const qs = search.toString(); @@ -139,7 +193,9 @@ export async function apiGetSummary({ date, locationCode, gateCode }) { location_code: locationCode, gate_code: gateCode }); - return apiRequest(`/retribusi/v1/dashboard/summary${qs}`); + const url = `/retribusi/v1/dashboard/summary${qs}`; + console.log('[API] apiGetSummary - URL:', url, 'Params:', { date, locationCode, gateCode }); + return apiRequest(url); } export async function apiGetDaily({ startDate, endDate, locationCode }) { @@ -157,7 +213,9 @@ export async function apiGetByCategory({ date, locationCode, gateCode }) { location_code: locationCode, gate_code: gateCode }); - return apiRequest(`/retribusi/v1/dashboard/by-category${qs}`); + const url = `/retribusi/v1/dashboard/by-category${qs}`; + console.log('[API] apiGetByCategory - URL:', url, 'Params:', { date, locationCode, gateCode }); + return apiRequest(url); } // Ringkasan global harian (daily_summary) @@ -169,7 +227,9 @@ export async function apiGetSummaryDaily(params = {}) { // Ringkasan per jam (hourly_summary) export async function apiGetSummaryHourly(params = {}) { const qs = buildQuery(params); - return apiRequest(`/retribusi/v1/summary/hourly${qs}`); + const url = `/retribusi/v1/summary/hourly${qs}`; + console.log('[API] apiGetSummaryHourly - URL:', url, 'Params:', params); + return apiRequest(url); } // Snapshot realtime (untuk panel live / TV wall) @@ -202,3 +262,55 @@ export async function apiGetEvents(params = {}) { // Catatan: realtime SSE /retribusi/v1/realtime/stream akan diakses langsung via EventSource, // bukan lewat fetch/apiRequest karena menggunakan Server-Sent Events (SSE). +// Test function untuk debug - bandingkan URL yang dihasilkan untuk tanggal berbeda +// Bisa dipanggil dari browser console: window.testDateUrls('2025-12-31', '2026-01-01') +if (typeof window !== 'undefined') { + window.testDateUrls = function(date1, date2) { + console.log('=== TEST: Membandingkan URL untuk tanggal berbeda ==='); + + const testParams = { + date: date1 || '2025-12-31', + locationCode: '', + gateCode: '' + }; + + const testParams2 = { + date: date2 || '2026-01-01', + locationCode: '', + gateCode: '' + }; + + console.log('\n--- Tanggal 1:', testParams.date, '---'); + const qs1 = buildQuery(testParams); + console.log('Query string:', qs1); + console.log('Full URL:', `${API_CONFIG.BASE_URL}/retribusi/v1/dashboard/summary${qs1}`); + + console.log('\n--- Tanggal 2:', testParams2.date, '---'); + const qs2 = buildQuery(testParams2); + console.log('Query string:', qs2); + console.log('Full URL:', `${API_CONFIG.BASE_URL}/retribusi/v1/dashboard/summary${qs2}`); + + console.log('\n--- Perbandingan ---'); + console.log('Query string sama?', qs1 === qs2); + console.log('Date param sama?', testParams.date === testParams2.date); + console.log('Date length sama?', testParams.date.length === testParams2.date.length); + + // Test URLSearchParams parsing + const params1 = new URLSearchParams(qs1.substring(1)); + const params2 = new URLSearchParams(qs2.substring(1)); + console.log('Parsed date 1:', params1.get('date')); + console.log('Parsed date 2:', params2.get('date')); + console.log('Parsed dates sama?', params1.get('date') === params2.get('date')); + + return { + date1: testParams.date, + date2: testParams2.date, + url1: `${API_CONFIG.BASE_URL}/retribusi/v1/dashboard/summary${qs1}`, + url2: `${API_CONFIG.BASE_URL}/retribusi/v1/dashboard/summary${qs2}`, + qs1, + qs2, + areEqual: qs1 === qs2 + }; + }; +} + diff --git a/public/dashboard/js/charts.js b/public/dashboard/js/charts.js index 2f70d97..f64871c 100644 --- a/public/dashboard/js/charts.js +++ b/public/dashboard/js/charts.js @@ -107,11 +107,41 @@ export function updateDailyChart({ labels, counts, amounts }) { return; } - dailyLineChart.data.labels = labels || []; - dailyLineChart.data.datasets[0].data = counts || []; - dailyLineChart.data.datasets[1].data = amounts || []; - dailyLineChart.update(); - console.log('[Charts] Daily chart updated:', { labelsCount: labels?.length, countsCount: counts?.length, amountsCount: amounts?.length }); + // Pastikan data arrays memiliki length yang sama + const safeLabels = labels || []; + const safeCounts = counts || []; + const safeAmounts = amounts || []; + + // Pastikan semua array memiliki length yang sama (24 jam) + const maxLength = Math.max(safeLabels.length, safeCounts.length, safeAmounts.length, 24); + const finalLabels = Array.from({ length: maxLength }, (_, i) => { + if (i < safeLabels.length) return safeLabels[i]; + return `${String(i).padStart(2, '0')}:00`; + }); + const finalCounts = Array.from({ length: maxLength }, (_, i) => { + if (i < safeCounts.length) return Number(safeCounts[i]) || 0; + return 0; + }); + const finalAmounts = Array.from({ length: maxLength }, (_, i) => { + if (i < safeAmounts.length) return Number(safeAmounts[i]) || 0; + return 0; + }); + + // Update chart data + dailyLineChart.data.labels = finalLabels; + dailyLineChart.data.datasets[0].data = finalCounts; + dailyLineChart.data.datasets[1].data = finalAmounts; + + // Update chart dengan mode 'none' untuk menghindari animasi yang tidak perlu + dailyLineChart.update('none'); + + console.log('[Charts] Daily chart updated:', { + labelsCount: finalLabels.length, + countsCount: finalCounts.length, + amountsCount: finalAmounts.length, + totalCount: finalCounts.reduce((a, b) => a + b, 0), + totalAmount: finalAmounts.reduce((a, b) => a + b, 0) + }); } export function initCategoryChart(ctx) { @@ -169,18 +199,29 @@ export function updateCategoryChart({ labels, values }) { } // Pastikan labels dan values tidak kosong - const finalLabels = labels && labels.length > 0 ? labels : ['Orang', 'Motor', 'Mobil']; - const finalValues = values && values.length > 0 ? values : [0, 0, 0]; + // Default categories: Orang, Motor, Mobil + const defaultLabels = ['Orang', 'Motor', 'Mobil']; + const defaultValues = [0, 0, 0]; - // Pastikan length sama - const minLength = Math.min(finalLabels.length, finalValues.length); - const safeLabels = finalLabels.slice(0, minLength); - const safeValues = finalValues.slice(0, minLength); + const finalLabels = labels && labels.length > 0 ? labels : defaultLabels; + const finalValues = values && values.length > 0 ? values : defaultValues; + // Pastikan length sama (minimal 3 untuk 3 kategori) + const maxLength = Math.max(finalLabels.length, finalValues.length, 3); + const safeLabels = Array.from({ length: maxLength }, (_, i) => { + if (i < finalLabels.length) return finalLabels[i]; + return defaultLabels[i] || `Kategori ${i + 1}`; + }); + const safeValues = Array.from({ length: maxLength }, (_, i) => { + if (i < finalValues.length) return Number(finalValues[i]) || 0; + return 0; + }); + + // Update chart data categoryChart.data.labels = safeLabels; categoryChart.data.datasets[0].data = safeValues; - // Update chart dengan mode 'none' untuk animasi halus + // Update chart dengan mode 'none' untuk menghindari animasi yang tidak perlu categoryChart.update('none'); console.log('[Charts] Category chart updated:', { diff --git a/public/dashboard/js/config.js b/public/dashboard/js/config.js index 47a3512..2e4a867 100644 --- a/public/dashboard/js/config.js +++ b/public/dashboard/js/config.js @@ -1,26 +1,47 @@ // public/dashboard/js/config.js // Konfigurasi API Base URL untuk frontend -// Auto-detect environment berdasarkan hostname +// FORCE LOCAL MODE - Set ke true untuk force menggunakan API lokal +const FORCE_LOCAL_MODE = true; // Set ke false untuk auto-detect + +// Auto-detect API Base URL berdasarkan hostname function getApiBaseUrl() { - const hostname = window.location.hostname; - - // Development lokal (Laragon/XAMPP) - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname.startsWith('192.168.')) { - // Untuk PHP built-in server (port 8000) - // return 'http://localhost:8000'; - - // Untuk Laragon/Apache (path-based) - // return 'http://localhost/api-btekno/public'; - - // Untuk Laragon virtual host - // return 'http://api.retribusi.test'; - - // Default: PHP built-in server - return 'http://localhost:8000'; + // Force local mode untuk testing + if (FORCE_LOCAL_MODE) { + console.log('[Config] FORCE_LOCAL_MODE enabled, using local API'); + return 'http://localhost/api-btekno/public'; } - // Production + // Jika di localhost atau 127.0.0.1, gunakan API lokal + if (typeof window !== 'undefined') { + const hostname = window.location.hostname; + const port = window.location.port; + const protocol = window.location.protocol; + + console.log('[Config] Detecting environment:', { hostname, port, protocol, fullUrl: window.location.href }); + + const isLocal = hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '' || + hostname.startsWith('192.168.') || + hostname.startsWith('10.') || + hostname.startsWith('172.') || + protocol === 'file:'; // File protocol (file://) juga dianggap local + + console.log('[Config] Is Local:', isLocal); + + if (isLocal) { + // API lokal - sesuaikan dengan konfigurasi server lokal Anda + // Untuk Laragon, biasanya: http://localhost/api-btekno/public + // Atau jika menggunakan PHP built-in server: http://localhost:8000 + const localApiUrl = 'http://localhost/api-btekno/public'; + console.log('[Config] Using local API:', localApiUrl); + return localApiUrl; + } + } + + // Default: API produksi + console.log('[Config] Using production API'); return 'https://api.btekno.cloud'; } @@ -34,5 +55,12 @@ export const API_CONFIG = { // Untuk debugging if (typeof window !== 'undefined') { console.log('API Base URL:', API_CONFIG.BASE_URL); + console.log('Hostname:', window.location.hostname); + console.log('Is Local:', window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1' || + window.location.hostname === '' || + window.location.hostname.startsWith('192.168.') || + window.location.hostname.startsWith('10.') || + window.location.hostname.startsWith('172.')); } diff --git a/public/dashboard/js/dashboard.js b/public/dashboard/js/dashboard.js index 127a4f2..00dddce 100644 --- a/public/dashboard/js/dashboard.js +++ b/public/dashboard/js/dashboard.js @@ -34,6 +34,93 @@ function getTodayIndonesia() { 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 @@ -233,6 +320,71 @@ 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({ @@ -256,7 +408,15 @@ async function loadSummaryAndCharts() { // 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 @@ -393,15 +553,185 @@ async function loadSummaryAndCharts() { 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: totalCounts.reduce((a, b) => a + b, 0), - totalAmount: totalAmounts.reduce((a, b) => a + b, 0) + 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(); @@ -413,12 +743,13 @@ async function loadSummaryAndCharts() { // 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'); + 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); } @@ -490,11 +821,12 @@ async function loadSummaryAndCharts() { // 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'); + 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!'); } @@ -712,8 +1044,97 @@ function setupFilters() { dateInput.setAttribute('value', state.date); } dateInput.addEventListener('change', () => { - state.date = dateInput.value || state.date; - loadSummaryAndCharts(); + // 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); + } }); } diff --git a/public/index.html b/public/index.html index e4ac2f4..f0590b7 100644 --- a/public/index.html +++ b/public/index.html @@ -111,8 +111,12 @@ // Tambahkan info lebih detail untuk debugging if (error.message === 'Failed to fetch' || error.message.includes('fetch')) { - errorMessage = 'Gagal terhubung ke server API. Pastikan backend API sudah running di ' + - (await import('./dashboard/js/config.js')).then(m => m.API_CONFIG.BASE_URL).catch(() => 'http://localhost:8000'); + try { + const { API_CONFIG } = await import('./dashboard/js/config.js'); + errorMessage = 'Gagal terhubung ke server API. Pastikan backend API sudah running di ' + API_CONFIG.BASE_URL; + } catch (e) { + errorMessage = 'Gagal terhubung ke server API. Pastikan backend API sudah running di https://api.btekno.cloud'; + } } errorDiv.textContent = errorMessage;