import React, { useState, useMemo, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Line, Bar } from 'react-chartjs-2'; import { Carousel, EmptyState, FilterControls, MultiSelect, StatCard } from './shared'; import { ExportableChart } from './ChartExport'; import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig'; import { useLanguage } from '../contexts/LanguageContext'; import { filterData, calculateMetrics, formatCurrency, formatNumber, groupByWeek, groupByMuseum, groupByChannel, umrahData, fetchPilgrimStats, getUniqueYears, getUniqueChannels, getUniqueMuseums, getUniqueDistricts, getMuseumsForDistrict, groupByDistrict } from '../services/dataService'; import type { DashboardProps, Filters, MuseumRecord } from '../types'; const defaultFilters: Filters = { year: 'all', district: 'all', channel: [], museum: [], quarter: 'all' }; const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter']; function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) { const { t } = useLanguage(); const [searchParams, setSearchParams] = useSearchParams(); const [pilgrimLoaded, setPilgrimLoaded] = useState(false); // Fetch pilgrim stats from NocoDB on mount useEffect(() => { fetchPilgrimStats().then(() => setPilgrimLoaded(true)); }, []); // Initialize filters from URL or defaults const [filters, setFiltersState] = useState(() => { const initial: Filters = { ...defaultFilters }; filterKeys.forEach(key => { const value = searchParams.get(key); if (value) (initial as Record)[key] = value; }); const museumParam = searchParams.get('museum'); if (museumParam) initial.museum = museumParam.split(',').filter(Boolean); const channelParam = searchParams.get('channel'); if (channelParam) initial.channel = channelParam.split(',').filter(Boolean); return initial; }); // Update both state and URL const setFilters = (newFilters: Filters | ((prev: Filters) => Filters)) => { const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters; setFiltersState(updated); const params = new URLSearchParams(); filterKeys.forEach(key => { const val = (updated as Record)[key] as string; if (val && val !== 'all') { params.set(key, val); } }); if (updated.museum.length > 0) params.set('museum', updated.museum.join(',')); if (updated.channel.length > 0) params.set('channel', updated.channel.join(',')); setSearchParams(params, { replace: true }); }; const [activeStatCard, setActiveStatCard] = useState(0); const [activeChart, setActiveChart] = useState(0); const [trendGranularity, setTrendGranularity] = useState('week'); const filteredData = useMemo(() => filterData(data, filters), [data, filters]); const metrics = useMemo(() => calculateMetrics(filteredData, includeVAT), [filteredData, includeVAT]); const hasData = filteredData.length > 0; const resetFilters = () => setFilters(defaultFilters); // Stat cards for carousel const statCards = useMemo(() => [ { title: t('metrics.totalRevenue'), value: formatCurrency(metrics.revenue), hasYoy: true }, { title: t('metrics.totalVisitors'), value: formatNumber(metrics.visitors) }, { title: t('metrics.totalTickets'), value: formatNumber(metrics.tickets) }, { title: t('metrics.avgRevenue'), value: formatCurrency(metrics.avgRevPerVisitor) } ], [metrics, t]); // Chart carousel labels const chartLabels = useMemo(() => { return [t('charts.revenueTrend'), t('charts.visitors'), t('charts.revenue'), t('charts.quarterly'), t('charts.channel'), t('charts.district'), t('charts.captureRate')]; }, [t]); // Dynamic lists from data const years = useMemo(() => getUniqueYears(data), [data]); const districts = useMemo(() => getUniqueDistricts(data), [data]); const channels = useMemo(() => getUniqueChannels(data), [data]); const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]); const yoyChange = useMemo(() => { if (filters.year === 'all') return null; const prevYear = String(parseInt(filters.year) - 1); const prevData = data.filter((row: MuseumRecord) => row.year === prevYear); if (prevData.length === 0) return null; const prevMetrics = calculateMetrics(prevData, includeVAT); return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null; }, [data, filters.year, metrics.revenue, includeVAT]); // Revenue trend data (weekly or daily) const trendData = useMemo(() => { const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; const formatLabel = (dateStr: string) => { if (!dateStr) return ''; const [year, month, day] = dateStr.split('-').map(Number); const d = new Date(year, month - 1, day); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }; // Linear regression helper const linearRegression = (values: number[]) => { const n = values.length; if (n < 2) return values; let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; for (let i = 0; i < n; i++) { sumX += i; sumY += values[i]; sumXY += i * values[i]; sumX2 += i * i; } const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); const intercept = (sumY - slope * sumX) / n; return values.map((_, i) => slope * i + intercept); }; const trendlineDataset = (values: number[]) => ({ label: 'Trend', data: linearRegression(values), borderColor: chartColors.secondary, borderWidth: 2, borderDash: [6, 4], tension: 0, fill: false, pointRadius: 0, pointHoverRadius: 0, datalabels: { display: false } }); if (trendGranularity === 'week') { const grouped = groupByWeek(filteredData, includeVAT); const weeks = Object.keys(grouped).filter(w => w).sort(); const revenueValues = weeks.map(w => grouped[w].revenue); return { labels: weeks.map(formatLabel), datasets: [{ label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)', data: revenueValues, borderColor: chartColors.primary, backgroundColor: chartColors.primary + '10', borderWidth: 2, tension: 0.4, fill: true, pointRadius: 0, pointHoverRadius: 4 }, trendlineDataset(revenueValues)] }; } else { // Daily granularity const dailyData: Record = {}; filteredData.forEach(row => { const date = row.date; if (!dailyData[date]) dailyData[date] = 0; dailyData[date] += Number((row as unknown as Record)[revenueField] || 0); }); const days = Object.keys(dailyData).sort(); const revenueValues = days.map(d => dailyData[d]); return { labels: days.map(formatLabel), datasets: [{ label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)', data: revenueValues, borderColor: chartColors.primary, backgroundColor: chartColors.primary + '10', borderWidth: 1.5, tension: 0.4, fill: true, pointRadius: 0, pointHoverRadius: 3 }, trendlineDataset(revenueValues)] }; } }, [filteredData, trendGranularity, includeVAT]); // Museum data const museumData = useMemo(() => { const grouped = groupByMuseum(filteredData, includeVAT); const museums = Object.keys(grouped); return { visitors: { labels: museums, datasets: [{ data: museums.map(m => grouped[m].visitors), backgroundColor: museums.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'), borderWidth: 0, borderRadius: 4 }] }, revenue: { labels: museums, datasets: [{ data: museums.map(m => grouped[m].revenue), backgroundColor: museums.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'), borderRadius: 4 }] } }; }, [filteredData, includeVAT]); // Channel data const channelData = useMemo(() => { const grouped = groupByChannel(filteredData, includeVAT); const channels = Object.keys(grouped); return { labels: channels, datasets: [{ data: channels.map(d => grouped[d].revenue), backgroundColor: channels.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'), borderRadius: 4 }] }; }, [filteredData, includeVAT]); // District data const districtData = useMemo(() => { const grouped = groupByDistrict(filteredData, includeVAT); const districtNames = Object.keys(grouped); return { labels: districtNames, datasets: [{ data: districtNames.map(d => grouped[d].revenue), backgroundColor: districtNames.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'), borderRadius: 4 }] }; }, [filteredData, includeVAT]); // Quarterly YoY const quarterlyYoYData = useMemo(() => { const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; const d2024 = data.filter((row: MuseumRecord) => row.year === '2024'); const d2025 = data.filter((row: MuseumRecord) => row.year === '2025'); const quarters = ['Q1', 'Q2', 'Q3', 'Q4']; return { labels: quarters, datasets: [ { label: '2024', data: quarters.map(q => d2024.filter((r: MuseumRecord) => r.quarter === q.slice(1)).reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0)), backgroundColor: chartColors.muted, borderRadius: 4 }, { label: '2025', data: quarters.map(q => d2025.filter((r: MuseumRecord) => r.quarter === q.slice(1)).reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0)), backgroundColor: chartColors.primary, borderRadius: 4 } ] }; }, [data, includeVAT]); // Capture rate const captureRateData = useMemo(() => { const labels: string[] = []; const rates: number[] = []; const pilgrimCounts: number[] = []; [2024, 2025].forEach(year => { [1, 2, 3, 4].forEach(q => { const pilgrims = umrahData[year]?.[q]; if (!pilgrims) return; let qData = data.filter((r: MuseumRecord) => r.year === String(year) && r.quarter === String(q)); if (filters.district !== 'all') qData = qData.filter((r: MuseumRecord) => r.district === filters.district); if (filters.channel.length > 0) qData = qData.filter((r: MuseumRecord) => filters.channel.includes(r.channel)); if (filters.museum.length > 0) qData = qData.filter((r: MuseumRecord) => filters.museum.includes(r.museum_name)); const visitors = qData.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0); labels.push(`Q${q} ${year}`); rates.push((visitors / pilgrims * 100)); pilgrimCounts.push(pilgrims); }); }); return { labels, datasets: [ { label: 'Capture Rate (%)', data: rates, borderColor: chartColors.secondary, backgroundColor: chartColors.secondary + '10', borderWidth: 2, tension: 0.4, fill: true, pointRadius: 4, pointBackgroundColor: '#fff', pointBorderColor: chartColors.secondary, pointBorderWidth: 2, yAxisID: 'y', datalabels: { display: showDataLabels, formatter: (value: number) => value.toFixed(2) + '%', color: '#1e293b', backgroundColor: 'rgba(255, 255, 255, 0.9)', borderRadius: 3, font: { size: 10, weight: 600 }, anchor: 'end', align: 'top', offset: 6 } }, { label: 'Pilgrims', data: pilgrimCounts, borderColor: chartColors.tertiary, backgroundColor: chartColors.tertiary + '10', borderWidth: 2, tension: 0.4, fill: true, pointRadius: 4, pointBackgroundColor: '#fff', pointBorderColor: chartColors.tertiary, pointBorderWidth: 2, yAxisID: 'y1', order: 1, datalabels: { display: showDataLabels, formatter: (value: number) => (value / 1000000).toFixed(2) + 'M', color: '#1e293b', backgroundColor: 'rgba(255, 255, 255, 0.9)', borderRadius: 3, font: { size: 10, weight: 600 }, anchor: 'start', align: 'bottom', offset: 6 } } ] }; }, [data, filters.district, filters.channel, filters.museum, showDataLabels]); // Quarterly table const quarterlyTable = useMemo(() => { const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; const d2024 = data.filter((row: MuseumRecord) => row.year === '2024'); const d2025 = data.filter((row: MuseumRecord) => row.year === '2025'); return [1, 2, 3, 4].map(q => { let q2024 = d2024.filter((r: MuseumRecord) => r.quarter === String(q)); let q2025 = d2025.filter((r: MuseumRecord) => r.quarter === String(q)); if (filters.district !== 'all') { q2024 = q2024.filter((r: MuseumRecord) => r.district === filters.district); q2025 = q2025.filter((r: MuseumRecord) => r.district === filters.district); } if (filters.channel.length > 0) { q2024 = q2024.filter((r: MuseumRecord) => filters.channel.includes(r.channel)); q2025 = q2025.filter((r: MuseumRecord) => filters.channel.includes(r.channel)); } if (filters.museum.length > 0) { q2024 = q2024.filter((r: MuseumRecord) => filters.museum.includes(r.museum_name)); q2025 = q2025.filter((r: MuseumRecord) => filters.museum.includes(r.museum_name)); } const rev24 = q2024.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0); const rev25 = q2025.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0); const vis24 = q2024.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0); const vis25 = q2025.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0); const revChg = rev24 > 0 ? ((rev25 - rev24) / rev24 * 100) : 0; const visChg = vis24 > 0 ? ((vis25 - vis24) / vis24 * 100) : 0; const cap24 = umrahData[2024][q] ? (vis24 / umrahData[2024][q] * 100) : null; const cap25 = umrahData[2025][q] ? (vis25 / umrahData[2025][q] * 100) : null; return { q, rev24, rev25, revChg, vis24, vis25, visChg, cap24, cap25 }; }); }, [data, filters.district, filters.channel, filters.museum, includeVAT]); const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]); return (

{t('dashboard.title')}

{t('dashboard.subtitle')}

{t('nav.vat') || 'VAT'}
{t('nav.labels')}
setFilters({...filters, channel})} allLabel={t('filters.allChannels')} /> setFilters({...filters, museum})} allLabel={t('filters.allMuseums')} /> {/* Desktop: Grid */}
{/* Mobile: Stats Carousel */}
c.title.replace('Total ', '').replace('Avg ', ''))} > {statCards.map((card, i) => ( ))}
{!hasData ? ( ) : ( <>

{t('dashboard.quarterlyComparison')}

{quarterlyTable.map(row => ( ))}
{t('table.quarter')} {t('table.rev2024')} {t('table.rev2025')} {t('table.change')} {t('table.visitors2024')} {t('table.visitors2025')} {t('table.change')} {t('table.capture2024')} {t('table.capture2025')}
Q{row.q} {formatCurrency(row.rev24)} {formatCurrency(row.rev25)} = 0 ? 'positive' : 'negative'}> {row.revChg >= 0 ? '+' : ''}{row.revChg.toFixed(1)}% {formatNumber(row.vis24)} {formatNumber(row.vis25)} = 0 ? 'positive' : 'negative'}> {row.visChg >= 0 ? '+' : ''}{row.visChg.toFixed(1)}% {row.cap24 ? row.cap24.toFixed(2) + '%' : '—'} {row.cap25 ? row.cap25.toFixed(2) + '%' : '—'}
{/* Desktop: Charts Grid */}
} >
{ if (ctx.dataset.label === 'Capture Rate (%)') { return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`; } return `Pilgrims: ${ctx.parsed.y.toLocaleString()}`; } } } }, scales: { x: baseOptions.scales.x, y: { type: 'linear', position: 'left', grid: { color: chartColors.grid }, ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v: number | string) => Number(v).toFixed(1) + '%' }, border: { display: false }, title: { display: true, text: 'Capture Rate (%)', font: { size: 12 }, color: chartColors.secondary } }, y1: { type: 'linear', position: 'right', grid: { drawOnChartArea: false }, ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v: number | string) => (Number(v) / 1000000).toFixed(0) + 'M' }, border: { display: false }, title: { display: true, text: 'Pilgrims', font: { size: 12 }, color: chartColors.tertiary } } } }} />
{/* Mobile: Charts Carousel */}

{t('dashboard.revenueTrends')}

{t('dashboard.visitorsByMuseum')}

{t('dashboard.revenueByMuseum')}

{t('dashboard.quarterlyRevenue')}

{t('dashboard.channelPerformance')}

{t('dashboard.districtPerformance')}

{t('dashboard.captureRateChart')}

{ if (ctx.dataset.label === 'Capture Rate (%)') { return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`; } return `Pilgrims: ${ctx.parsed.y.toLocaleString()}`; } } } }, scales: { x: baseOptions.scales.x, y: { type: 'linear', position: 'left', grid: { color: chartColors.grid }, ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v: number | string) => Number(v).toFixed(1) + '%' }, border: { display: false } }, y1: { type: 'linear', position: 'right', grid: { drawOnChartArea: false }, ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v: number | string) => (Number(v) / 1000000).toFixed(0) + 'M' }, border: { display: false } } } }} />
{chartLabels.map((label, i) => ( ))}
)} ); } export default Dashboard;