All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
- Page title: "HiHala Data - Museums" -> "HiHala Data" - Meta description: updated to "Event analytics" - Multi-select dropdown: fix inherited uppercase, wider to fit labels - Multi-select arrow: smooth CSS rotation instead of swapping characters - Chart colors: 10-color palette for events/channels (was 3) - Remove unused ArcElement (Doughnut) from Chart.js registration (-5KB) - District chart uses dynamic palette instead of hardcoded 2 colors Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
754 lines
32 KiB
TypeScript
754 lines
32 KiB
TypeScript
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<Filters>(() => {
|
|
const initial: Filters = { ...defaultFilters };
|
|
filterKeys.forEach(key => {
|
|
const value = searchParams.get(key);
|
|
if (value) (initial as Record<string, unknown>)[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<string, unknown>)[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<string, number> = {};
|
|
filteredData.forEach(row => {
|
|
const date = row.date;
|
|
if (!dailyData[date]) dailyData[date] = 0;
|
|
dailyData[date] += Number((row as unknown as Record<string, unknown>)[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 (
|
|
<div className="dashboard" id="dashboard-container">
|
|
<div className="page-title-with-actions">
|
|
<div className="page-title">
|
|
<h1>{t('dashboard.title')}</h1>
|
|
<p>{t('dashboard.subtitle')}</p>
|
|
</div>
|
|
<div className="header-toggles">
|
|
<div className="toggle-with-label">
|
|
<span className="toggle-text">{t('nav.vat') || 'VAT'}</span>
|
|
<div className="toggle-switch">
|
|
<button className={!includeVAT ? 'active' : ''} onClick={() => setIncludeVAT(false)}>{t('toggle.excl') || 'Excl'}</button>
|
|
<button className={includeVAT ? 'active' : ''} onClick={() => setIncludeVAT(true)}>{t('toggle.incl') || 'Incl'}</button>
|
|
</div>
|
|
</div>
|
|
<div className="toggle-with-label">
|
|
<span className="toggle-text">{t('nav.labels')}</span>
|
|
<div className="toggle-switch">
|
|
<button className={!showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(false)}>{t('toggle.off')}</button>
|
|
<button className={showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(true)}>{t('toggle.on')}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<FilterControls title={t('filters.title')} onReset={resetFilters}>
|
|
<FilterControls.Row>
|
|
<FilterControls.Group label={t('filters.year')}>
|
|
<select value={filters.year} onChange={e => setFilters({...filters, year: e.target.value})}>
|
|
<option value="all">{t('filters.allYears')}</option>
|
|
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
|
</select>
|
|
</FilterControls.Group>
|
|
<FilterControls.Group label={t('filters.district')}>
|
|
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: []})}>
|
|
<option value="all">{t('filters.allDistricts')}</option>
|
|
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
|
</select>
|
|
</FilterControls.Group>
|
|
<FilterControls.Group label={t('filters.channel')}>
|
|
<MultiSelect
|
|
options={channels}
|
|
selected={filters.channel}
|
|
onChange={channel => setFilters({...filters, channel})}
|
|
allLabel={t('filters.allChannels')}
|
|
/>
|
|
</FilterControls.Group>
|
|
<FilterControls.Group label={t('filters.museum')}>
|
|
<MultiSelect
|
|
options={availableMuseums}
|
|
selected={filters.museum}
|
|
onChange={museum => setFilters({...filters, museum})}
|
|
allLabel={t('filters.allMuseums')}
|
|
/>
|
|
</FilterControls.Group>
|
|
<FilterControls.Group label={t('filters.quarter')}>
|
|
<select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}>
|
|
<option value="all">{t('filters.allQuarters')}</option>
|
|
<option value="1">{t('time.q1')}</option>
|
|
<option value="2">{t('time.q2')}</option>
|
|
<option value="3">{t('time.q3')}</option>
|
|
<option value="4">{t('time.q4')}</option>
|
|
</select>
|
|
</FilterControls.Group>
|
|
</FilterControls.Row>
|
|
</FilterControls>
|
|
|
|
{/* Desktop: Grid */}
|
|
<div className="stats-grid desktop-only" id="dashboard-stats">
|
|
<StatCard title={t('metrics.totalRevenue')} value={formatCurrency(metrics.revenue)} change={yoyChange} />
|
|
<StatCard title={t('metrics.totalVisitors')} value={formatNumber(metrics.visitors)} />
|
|
<StatCard title={t('metrics.totalTickets')} value={formatNumber(metrics.tickets)} />
|
|
<StatCard title={t('metrics.avgRevenuePerVisitor')} value={formatCurrency(metrics.avgRevPerVisitor)} />
|
|
</div>
|
|
|
|
{/* Mobile: Stats Carousel */}
|
|
<div className="stats-carousel mobile-only">
|
|
<Carousel
|
|
activeIndex={activeStatCard}
|
|
setActiveIndex={setActiveStatCard}
|
|
labels={statCards.map(c => c.title.replace('Total ', '').replace('Avg ', ''))}
|
|
>
|
|
{statCards.map((card, i) => (
|
|
<StatCard
|
|
key={i}
|
|
title={card.title}
|
|
value={card.value}
|
|
change={card.hasYoy ? yoyChange : null}
|
|
/>
|
|
))}
|
|
</Carousel>
|
|
</div>
|
|
|
|
{!hasData ? (
|
|
<EmptyState
|
|
icon="📊"
|
|
title={t('dashboard.noData')}
|
|
message={t('dashboard.noDataMessage')}
|
|
action={resetFilters}
|
|
actionLabel={t('filters.reset')}
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className="chart-card full-width" style={{marginBottom: '16px'}} id="quarterly-table">
|
|
<h2>{t('dashboard.quarterlyComparison')}</h2>
|
|
<div className="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>{t('table.quarter')}</th>
|
|
<th>{t('table.rev2024')}</th>
|
|
<th>{t('table.rev2025')}</th>
|
|
<th>{t('table.change')}</th>
|
|
<th>{t('table.visitors2024')}</th>
|
|
<th>{t('table.visitors2025')}</th>
|
|
<th>{t('table.change')}</th>
|
|
<th>{t('table.capture2024')}</th>
|
|
<th>{t('table.capture2025')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{quarterlyTable.map(row => (
|
|
<tr key={row.q}>
|
|
<td className="bold">Q{row.q}</td>
|
|
<td className="muted">{formatCurrency(row.rev24)}</td>
|
|
<td className="bold">{formatCurrency(row.rev25)}</td>
|
|
<td className={row.revChg >= 0 ? 'positive' : 'negative'}>
|
|
{row.revChg >= 0 ? '+' : ''}{row.revChg.toFixed(1)}%
|
|
</td>
|
|
<td className="muted">{formatNumber(row.vis24)}</td>
|
|
<td className="bold">{formatNumber(row.vis25)}</td>
|
|
<td className={row.visChg >= 0 ? 'positive' : 'negative'}>
|
|
{row.visChg >= 0 ? '+' : ''}{row.visChg.toFixed(1)}%
|
|
</td>
|
|
<td className="muted">{row.cap24 ? row.cap24.toFixed(2) + '%' : '—'}</td>
|
|
<td className="purple bold">{row.cap25 ? row.cap25.toFixed(2) + '%' : '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop: Charts Grid */}
|
|
<div className="charts-grid desktop-only" id="dashboard-charts">
|
|
<div className="chart-card full-width">
|
|
<ExportableChart
|
|
filename="revenue-trend"
|
|
title={t('dashboard.revenueTrends')}
|
|
className="chart-container"
|
|
controls={
|
|
<div className="toggle-switch">
|
|
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>{t('time.daily')}</button>
|
|
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button>
|
|
</div>
|
|
}
|
|
>
|
|
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 13}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
|
|
</ExportableChart>
|
|
</div>
|
|
|
|
<div className="chart-card half-width">
|
|
<ExportableChart filename="visitors-by-event" title={t('dashboard.visitorsByMuseum')} className="chart-container">
|
|
<Bar data={museumData.visitors} options={{...baseOptions, indexAxis: 'y'}} />
|
|
</ExportableChart>
|
|
</div>
|
|
|
|
<div className="chart-card half-width">
|
|
<ExportableChart filename="revenue-by-event" title={t('dashboard.revenueByMuseum')} className="chart-container">
|
|
<Bar data={museumData.revenue} options={{...baseOptions, indexAxis: 'y'}} />
|
|
</ExportableChart>
|
|
</div>
|
|
|
|
<div className="chart-card half-width">
|
|
<ExportableChart filename="quarterly-yoy" title={t('dashboard.quarterlyRevenue')} className="chart-container">
|
|
<Bar data={quarterlyYoYData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 13}}}}}} />
|
|
</ExportableChart>
|
|
</div>
|
|
|
|
<div className="chart-card half-width">
|
|
<ExportableChart filename="channel-performance" title={t('dashboard.channelPerformance')} className="chart-container">
|
|
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
|
|
</ExportableChart>
|
|
</div>
|
|
|
|
<div className="chart-card half-width">
|
|
<ExportableChart filename="district-performance" title={t('dashboard.districtPerformance')} className="chart-container">
|
|
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
|
</ExportableChart>
|
|
</div>
|
|
|
|
<div className="chart-card full-width">
|
|
<ExportableChart filename="capture-rate" title={t('dashboard.captureRateChart')} className="chart-container">
|
|
<Line data={captureRateData} options={{
|
|
...baseOptions,
|
|
plugins: {
|
|
...baseOptions.plugins,
|
|
legend: { display: true, position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } },
|
|
tooltip: {
|
|
...baseOptions.plugins.tooltip,
|
|
callbacks: {
|
|
label: (ctx: { dataset: { label?: string }; parsed: { y: number } }) => {
|
|
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 }
|
|
}
|
|
}
|
|
}} />
|
|
</ExportableChart>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile: Charts Carousel */}
|
|
<div className="charts-carousel mobile-only">
|
|
<div className="carousel-container">
|
|
<div className="carousel-viewport">
|
|
<div
|
|
className="carousel-track"
|
|
style={{ transform: `translateX(-${activeChart * 100}%)` }}
|
|
>
|
|
<div className="carousel-slide">
|
|
<div className="chart-card">
|
|
<h2>{t('dashboard.revenueTrends')}</h2>
|
|
<div className="toggle-switch toggle-corner">
|
|
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>{t('time.daily')}</button>
|
|
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button>
|
|
</div>
|
|
<div className="chart-container">
|
|
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 10, padding: 8, font: {size: 12}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="carousel-slide">
|
|
<div className="chart-card">
|
|
<h2>{t('dashboard.visitorsByMuseum')}</h2>
|
|
<div className="chart-container">
|
|
<Bar data={museumData.visitors} options={{...baseOptions, indexAxis: 'y'}} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="carousel-slide">
|
|
<div className="chart-card">
|
|
<h2>{t('dashboard.revenueByMuseum')}</h2>
|
|
<div className="chart-container">
|
|
<Bar data={museumData.revenue} options={{...baseOptions, indexAxis: 'y'}} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="carousel-slide">
|
|
<div className="chart-card">
|
|
<h2>{t('dashboard.quarterlyRevenue')}</h2>
|
|
<div className="chart-container">
|
|
<Bar data={quarterlyYoYData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 8, font: {size: 12}}}}}} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="carousel-slide">
|
|
<div className="chart-card">
|
|
<h2>{t('dashboard.channelPerformance')}</h2>
|
|
<div className="chart-container">
|
|
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="carousel-slide">
|
|
<div className="chart-card">
|
|
<h2>{t('dashboard.districtPerformance')}</h2>
|
|
<div className="chart-container">
|
|
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="carousel-slide">
|
|
<div className="chart-card">
|
|
<h2>{t('dashboard.captureRateChart')}</h2>
|
|
<div className="chart-container">
|
|
<Line data={captureRateData} options={{
|
|
...baseOptions,
|
|
plugins: {
|
|
...baseOptions.plugins,
|
|
legend: { display: true, position: 'top', align: 'end', labels: { boxWidth: 10, padding: 8, font: { size: 13 } } },
|
|
tooltip: {
|
|
...baseOptions.plugins.tooltip,
|
|
callbacks: {
|
|
label: (ctx: { dataset: { label?: string }; parsed: { y: number } }) => {
|
|
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 }
|
|
}
|
|
}
|
|
}} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="carousel-dots">
|
|
{chartLabels.map((label, i) => (
|
|
<button
|
|
key={label}
|
|
className={`carousel-dot ${activeChart === i ? 'active' : ''}`}
|
|
onClick={() => setActiveChart(i)}
|
|
>
|
|
<span className="dot-label">{label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Dashboard;
|