Files
hihala-dashboard/src/components/Dashboard.tsx
fahed db6a6ac609
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
feat: season filter + chart bands on Dashboard and Comparison
Dashboard:
- Season dropdown filter (filters data by season date range)
- Revenue trend chart shows colored annotation bands for each season
- All downstream memos use season-filtered data

Comparison:
- Season presets in period selector (optgroup)
- Auto-compares with same season from previous hijri year if defined
- Season preset persists start/end dates in URL

Added chartjs-plugin-annotation for chart bands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:10:49 +03:00

807 lines
34 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, Season } from '../types';
const defaultFilters: Filters = {
year: 'all',
district: 'all',
channel: [],
museum: [],
quarter: 'all'
};
const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter'];
function Dashboard({ data, seasons, 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 [selectedSeason, setSelectedSeason] = useState<string>('');
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
const seasonFilteredData = useMemo(() => {
if (!selectedSeason) return filteredData;
const season = seasons.find(s => String(s.Id) === selectedSeason);
if (!season) return filteredData;
return filteredData.filter(row => row.date >= season.StartDate && row.date <= season.EndDate);
}, [filteredData, selectedSeason, seasons]);
const metrics = useMemo(() => calculateMetrics(seasonFilteredData, includeVAT), [seasonFilteredData, includeVAT]);
const hasData = seasonFilteredData.length > 0;
const resetFilters = () => {
setFilters(defaultFilters);
setSelectedSeason('');
};
// 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(seasonFilteredData, includeVAT);
const weeks = Object.keys(grouped).filter(w => w).sort();
const revenueValues = weeks.map(w => grouped[w].revenue);
return {
labels: weeks.map(formatLabel),
rawDates: weeks,
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> = {};
seasonFilteredData.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),
rawDates: days,
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)]
};
}
}, [seasonFilteredData, trendGranularity, includeVAT]);
// Museum data
const museumData = useMemo(() => {
const grouped = groupByMuseum(seasonFilteredData, 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
}]
}
};
}, [seasonFilteredData, includeVAT]);
// Channel data
const channelData = useMemo(() => {
const grouped = groupByChannel(seasonFilteredData, 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
}]
};
}, [seasonFilteredData, includeVAT]);
// District data
const districtData = useMemo(() => {
const grouped = groupByDistrict(seasonFilteredData, 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
}]
};
}, [seasonFilteredData, 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]);
// Season annotation bands for revenue trend chart
const seasonAnnotations = useMemo(() => {
const raw = trendData.rawDates;
if (!seasons.length || !raw?.length) return {};
const annotations: Record<string, unknown> = {};
seasons.forEach((s, i) => {
const startIdx = raw.findIndex(d => d >= s.StartDate);
const endIdx = raw.length - 1 - [...raw].reverse().findIndex(d => d <= s.EndDate);
if (startIdx === -1 || endIdx < startIdx) return;
annotations[`season${i}`] = {
type: 'box',
xMin: startIdx - 0.5,
xMax: endIdx + 0.5,
backgroundColor: s.Color + '20',
borderColor: s.Color + '40',
borderWidth: 1,
label: {
display: true,
content: `${s.Name} ${s.HijriYear}`,
position: 'start',
color: s.Color,
font: { size: 10, weight: '600' },
padding: 4
}
};
});
return annotations;
}, [seasons, trendData.rawDates]);
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.Group label={t('filters.season')}>
<select value={selectedSeason} onChange={e => setSelectedSeason(e.target.value)}>
<option value="">{t('filters.allSeasons')}</option>
{seasons.map(s => (
<option key={s.Id} value={String(s.Id)}>
{s.Name} {s.HijriYear}
</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, annotation: { annotations: seasonAnnotations }, 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, annotation: { annotations: seasonAnnotations }, 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;