feat(dashboard): add data labels toggle, dual-axis capture rate chart, mobile bottom nav

- Global data labels toggle in header (works on Dashboard & Comparison pages)
- Labels show formatted values (K/M suffix, max 2 decimals) with white pill background
- Capture Rate chart now shows pilgrims as curved line on right Y-axis
- Revenue Trends toggle moved to top-right corner of chart container
- Mobile: bottom navigation bar with Dashboard, Compare, Labels toggle
- Mobile: top nav simplified to brand only, bottom nav is thumb-friendly
This commit is contained in:
fahed
2026-02-02 13:39:56 +03:00
commit 24fa601aec
17 changed files with 20716 additions and 0 deletions

740
src/components/Dashboard.js Normal file
View File

@@ -0,0 +1,740 @@
import React, { useState, useMemo, useRef } from 'react';
import { Line, Doughnut, Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import {
filterData,
calculateMetrics,
formatCurrency,
formatNumber,
groupByWeek,
groupByMuseum,
groupByDistrict,
umrahData,
getUniqueYears,
getUniqueDistricts,
getDistrictMuseumMap,
getMuseumsForDistrict
} from '../services/dataService';
ChartJS.register(
CategoryScale, LinearScale, PointElement, LineElement,
BarElement, ArcElement, Title, Tooltip, Legend, Filler,
ChartDataLabels
);
const chartColors = {
primary: '#2563eb',
secondary: '#7c3aed',
tertiary: '#0891b2',
muted: '#cbd5e1',
grid: '#f1f5f9'
};
function Dashboard({ data, showDataLabels }) {
const [filters, setFilters] = useState({
year: 'all',
district: 'all',
museum: 'all',
quarter: 'all'
});
const [filtersExpanded, setFiltersExpanded] = useState(true);
const [activeStatCard, setActiveStatCard] = useState(0);
const [activeChart, setActiveChart] = useState(0);
const [trendGranularity, setTrendGranularity] = useState('week');
// Touch handlers for carousels
const touchStartStat = useRef(null);
const touchStartChart = useRef(null);
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
const metrics = useMemo(() => calculateMetrics(filteredData), [filteredData]);
// Stat cards for carousel
const statCards = useMemo(() => [
{ title: 'Total Revenue', value: formatCurrency(metrics.revenue), hasYoy: true },
{ title: 'Total Visitors', value: formatNumber(metrics.visitors) },
{ title: 'Total Tickets', value: formatNumber(metrics.tickets) },
{ title: 'Avg Rev/Visitor', value: formatCurrency(metrics.avgRevPerVisitor) }
], [metrics]);
const handleStatTouchStart = (e) => { touchStartStat.current = e.touches[0].clientX; };
const handleStatTouchEnd = (e) => {
if (!touchStartStat.current) return;
const diff = touchStartStat.current - e.changedTouches[0].clientX;
if (Math.abs(diff) > 50) {
if (diff > 0 && activeStatCard < statCards.length - 1) setActiveStatCard(activeStatCard + 1);
else if (diff < 0 && activeStatCard > 0) setActiveStatCard(activeStatCard - 1);
}
touchStartStat.current = null;
};
// Chart carousel - define charts array
const dashboardCharts = useMemo(() => [
{ id: 'revenue-trend', label: 'Revenue Trend' },
{ id: 'visitors-museum', label: 'Visitors' },
{ id: 'revenue-museum', label: 'Revenue' },
{ id: 'quarterly-yoy', label: 'Quarterly' },
{ id: 'district', label: 'District' },
{ id: 'capture-rate', label: 'Capture Rate' }
], []);
const handleChartTouchStart = (e) => { touchStartChart.current = e.touches[0].clientX; };
const handleChartTouchEnd = (e) => {
if (!touchStartChart.current) return;
const diff = touchStartChart.current - e.changedTouches[0].clientX;
const maxCharts = filters.museum === 'all' ? dashboardCharts.length : dashboardCharts.length - 2;
if (Math.abs(diff) > 50) {
if (diff > 0 && activeChart < maxCharts - 1) setActiveChart(activeChart + 1);
else if (diff < 0 && activeChart > 0) setActiveChart(activeChart - 1);
}
touchStartChart.current = null;
};
// Dynamic lists from data
const years = useMemo(() => getUniqueYears(data), [data]);
const districts = useMemo(() => getUniqueDistricts(data), [data]);
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
const yoyChange = useMemo(() => {
if (filters.year === 'all') return null;
const prevYear = String(parseInt(filters.year) - 1);
const prevData = data.filter(row => row.year === prevYear);
if (prevData.length === 0) return null;
const prevMetrics = calculateMetrics(prevData);
return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null;
}, [data, filters.year, metrics.revenue]);
// Revenue trend data (weekly or daily)
const trendData = useMemo(() => {
const formatLabel = (dateStr) => {
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' });
};
if (trendGranularity === 'week') {
const grouped = groupByWeek(filteredData);
const weeks = Object.keys(grouped).filter(w => w).sort();
return {
labels: weeks.map(formatLabel),
datasets: [{
label: 'Revenue',
data: weeks.map(w => grouped[w].revenue),
borderColor: chartColors.primary,
backgroundColor: chartColors.primary + '10',
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 4
}]
};
} else {
// Daily granularity
const dailyData = {};
filteredData.forEach(row => {
const date = row.date;
if (!dailyData[date]) dailyData[date] = 0;
dailyData[date] += parseFloat(row.revenue_incl_tax || 0);
});
const days = Object.keys(dailyData).sort();
return {
labels: days.map(formatLabel),
datasets: [{
label: 'Revenue',
data: days.map(d => dailyData[d]),
borderColor: chartColors.primary,
backgroundColor: chartColors.primary + '10',
borderWidth: 1.5,
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 3
}]
};
}
}, [filteredData, trendGranularity]);
// Museum data
const museumData = useMemo(() => {
const grouped = groupByMuseum(filteredData);
const museums = Object.keys(grouped);
return {
visitors: {
labels: museums,
datasets: [{
data: museums.map(m => grouped[m].visitors),
backgroundColor: [chartColors.primary + 'cc', chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
borderWidth: 0
}]
},
revenue: {
labels: museums,
datasets: [{
data: museums.map(m => grouped[m].revenue),
backgroundColor: [chartColors.primary + 'cc', chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
borderRadius: 4
}]
}
};
}, [filteredData]);
// District data
const districtData = useMemo(() => {
const grouped = groupByDistrict(filteredData);
const districts = Object.keys(grouped);
return {
labels: districts,
datasets: [{
data: districts.map(d => grouped[d].revenue),
backgroundColor: [chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
borderRadius: 4
}]
};
}, [filteredData]);
// Quarterly YoY
const quarterlyYoYData = useMemo(() => {
const d2024 = data.filter(row => row.year === '2024');
const d2025 = data.filter(row => row.year === '2025');
const quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
return {
labels: quarters,
datasets: [
{
label: '2024',
data: quarters.map(q => d2024.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0)),
backgroundColor: chartColors.muted,
borderRadius: 4
},
{
label: '2025',
data: quarters.map(q => d2025.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0)),
backgroundColor: chartColors.primary,
borderRadius: 4
}
]
};
}, [data]);
// Capture rate
const captureRateData = useMemo(() => {
const labels = [];
const rates = [];
const pilgrimCounts = [];
[2024, 2025].forEach(year => {
[1, 2, 3, 4].forEach(q => {
const pilgrims = umrahData[year]?.[q];
if (!pilgrims) return;
let qData = data.filter(r => r.year === String(year) && r.quarter === String(q));
if (filters.district !== 'all') qData = qData.filter(r => r.district === filters.district);
if (filters.museum !== 'all') qData = qData.filter(r => r.museum_name === filters.museum);
const visitors = qData.reduce((s, r) => s + parseInt(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) => value.toFixed(2) + '%',
color: '#1e293b',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 3,
font: { size: 9, 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) => (value / 1000000).toFixed(2) + 'M',
color: '#1e293b',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 3,
font: { size: 9, weight: 600 },
anchor: 'start',
align: 'bottom',
offset: 6
}
}
]
};
}, [data, filters.district, filters.museum]);
// Quarterly table
const quarterlyTable = useMemo(() => {
const d2024 = data.filter(row => row.year === '2024');
const d2025 = data.filter(row => row.year === '2025');
return [1, 2, 3, 4].map(q => {
let q2024 = d2024.filter(r => r.quarter === String(q));
let q2025 = d2025.filter(r => r.quarter === String(q));
if (filters.district !== 'all') {
q2024 = q2024.filter(r => r.district === filters.district);
q2025 = q2025.filter(r => r.district === filters.district);
}
if (filters.museum !== 'all') {
q2024 = q2024.filter(r => r.museum_name === filters.museum);
q2025 = q2025.filter(r => r.museum_name === filters.museum);
}
const rev24 = q2024.reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0);
const rev25 = q2025.reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0);
const vis24 = q2024.reduce((s, r) => s + parseInt(r.visits || 0), 0);
const vis25 = q2025.reduce((s, r) => s + parseInt(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.museum]);
const dataLabelDefaults = {
display: showDataLabels,
color: '#1e293b',
font: { size: 10, weight: 600 },
anchor: 'end',
align: 'end',
offset: 4,
padding: 4,
backgroundColor: 'rgba(255, 255, 255, 0.85)',
borderRadius: 3,
formatter: (value) => {
if (value == null) return '';
if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
if (value >= 1000) return (value / 1000).toFixed(2) + 'K';
if (value < 100 && value > 0) return value.toFixed(2);
return Math.round(value).toLocaleString();
}
};
const baseOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { backgroundColor: '#1e293b', padding: 12, cornerRadius: 8, titleFont: { size: 12 }, bodyFont: { size: 11 } },
datalabels: dataLabelDefaults
},
scales: {
x: { grid: { display: false }, ticks: { font: { size: 10 }, color: '#94a3b8' } },
y: { grid: { color: chartColors.grid }, ticks: { font: { size: 10 }, color: '#94a3b8' }, border: { display: false } }
}
};
return (
<div className="dashboard">
<div className="page-title">
<h1>Dashboard</h1>
<p>Real-time museum analytics from Google Sheets</p>
</div>
<div className={`controls ${filtersExpanded ? 'expanded' : 'collapsed'}`}>
<div className="controls-header" onClick={() => setFiltersExpanded(!filtersExpanded)}>
<h3>Filters</h3>
<button className="controls-toggle">{filtersExpanded ? '▲ Hide' : '▼ Show'}</button>
</div>
<div className="controls-body">
<div className="control-row">
<div className="control-group">
<label>Year</label>
<select value={filters.year} onChange={e => setFilters({...filters, year: e.target.value})}>
<option value="all">All Years</option>
{years.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
<div className="control-group">
<label>District</label>
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
<option value="all">All Districts</option>
{districts.map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
<div className="control-group">
<label>Museum</label>
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
<option value="all">All Museums</option>
{availableMuseums.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
<div className="control-group">
<label>Quarter</label>
<select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}>
<option value="all">All Quarters</option>
<option value="1">Q1</option>
<option value="2">Q2</option>
<option value="3">Q3</option>
<option value="4">Q4</option>
</select>
</div>
</div>
</div>
</div>
{/* Desktop: Grid */}
<div className="stats-grid desktop-only">
<div className="stat-card">
<h3>Total Revenue</h3>
<div className="stat-value">{formatCurrency(metrics.revenue)}</div>
{yoyChange !== null && (
<div className={`stat-change ${yoyChange >= 0 ? 'positive' : 'negative'}`}>
{yoyChange >= 0 ? '↑' : '↓'} {Math.abs(yoyChange).toFixed(1)}% YoY
</div>
)}
</div>
<div className="stat-card">
<h3>Total Visitors</h3>
<div className="stat-value">{formatNumber(metrics.visitors)}</div>
</div>
<div className="stat-card">
<h3>Total Tickets</h3>
<div className="stat-value">{formatNumber(metrics.tickets)}</div>
</div>
<div className="stat-card">
<h3>Avg Revenue/Visitor</h3>
<div className="stat-value">{formatCurrency(metrics.avgRevPerVisitor)}</div>
</div>
</div>
{/* Mobile: Stats Carousel */}
<div className="stats-carousel mobile-only">
<div className="carousel-container">
<div className="carousel-viewport">
<div
className="carousel-track"
style={{ transform: `translateX(-${activeStatCard * 100}%)` }}
onTouchStart={handleStatTouchStart}
onTouchEnd={handleStatTouchEnd}
>
{statCards.map((card, i) => (
<div className="carousel-slide" key={i}>
<div className="stat-card">
<h3>{card.title}</h3>
<div className="stat-value">{card.value}</div>
{card.hasYoy && yoyChange !== null && (
<div className={`stat-change ${yoyChange >= 0 ? 'positive' : 'negative'}`}>
{yoyChange >= 0 ? '↑' : '↓'} {Math.abs(yoyChange).toFixed(1)}% YoY
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
<div className="carousel-dots labeled">
{statCards.map((card, i) => (
<button key={i} className={`carousel-dot ${activeStatCard === i ? 'active' : ''}`} onClick={() => setActiveStatCard(i)}>
<span className="dot-label">{card.title.replace('Total ', '').replace('Avg ', '')}</span>
</button>
))}
</div>
</div>
<div className="chart-card full-width" style={{marginBottom: '16px'}}>
<h2>Quarterly Comparison: 2024 vs 2025</h2>
<div className="table-container">
<table>
<thead>
<tr>
<th>Quarter</th>
<th>Rev 2024</th>
<th>Rev 2025</th>
<th>Change</th>
<th>Visitors 2024</th>
<th>Visitors 2025</th>
<th>Change</th>
<th>Capture 2024</th>
<th>Capture 2025</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">
<div className="chart-card full-width">
<h2>Revenue Trends</h2>
<div className="toggle-switch toggle-corner">
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>Daily</button>
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>Weekly</button>
</div>
<div className="chart-container">
<Line data={trendData} options={{...baseOptions, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
</div>
</div>
{filters.museum === 'all' && (
<div className="chart-card half-width">
<h2>Visitors by Museum</h2>
<div className="chart-container">
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 16, font: {size: 11}}}}}} />
</div>
</div>
)}
{filters.museum === 'all' && (
<div className="chart-card half-width">
<h2>Revenue by Museum</h2>
<div className="chart-container">
<Bar data={museumData.revenue} options={baseOptions} />
</div>
</div>
)}
<div className="chart-card half-width">
<h2>Quarterly Revenue (YoY)</h2>
<div 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: 11}}}}}} />
</div>
</div>
<div className="chart-card half-width">
<h2>District Performance</h2>
<div className="chart-container">
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
</div>
</div>
<div className="chart-card full-width">
<h2>Capture Rate vs Umrah Pilgrims</h2>
<div 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: 11 } } },
tooltip: {
...baseOptions.plugins.tooltip,
callbacks: {
label: (ctx) => {
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: 10 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
border: { display: false },
title: { display: true, text: 'Capture Rate (%)', font: { size: 10 }, color: chartColors.secondary }
},
y1: {
type: 'linear',
position: 'right',
grid: { drawOnChartArea: false },
ticks: { font: { size: 10 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
border: { display: false },
title: { display: true, text: 'Pilgrims', font: { size: 10 }, color: chartColors.tertiary }
}
}
}} />
</div>
</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}%)` }}
onTouchStart={handleChartTouchStart}
onTouchEnd={handleChartTouchEnd}
>
<div className="carousel-slide">
<div className="chart-card">
<h2>Revenue Trends</h2>
<div className="toggle-switch toggle-corner">
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>Daily</button>
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>Weekly</button>
</div>
<div className="chart-container">
<Line data={trendData} options={{...baseOptions, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} />
</div>
</div>
</div>
{filters.museum === 'all' && (
<div className="carousel-slide">
<div className="chart-card">
<h2>Visitors by Museum</h2>
<div className="chart-container">
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 12, font: {size: 10}}}}}} />
</div>
</div>
</div>
)}
{filters.museum === 'all' && (
<div className="carousel-slide">
<div className="chart-card">
<h2>Revenue by Museum</h2>
<div className="chart-container">
<Bar data={museumData.revenue} options={baseOptions} />
</div>
</div>
</div>
)}
<div className="carousel-slide">
<div className="chart-card">
<h2>Quarterly Revenue (YoY)</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: 10}}}}}} />
</div>
</div>
</div>
<div className="carousel-slide">
<div className="chart-card">
<h2>District Performance</h2>
<div className="chart-container">
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
</div>
</div>
</div>
<div className="carousel-slide">
<div className="chart-card">
<h2>Capture Rate vs Umrah Pilgrims</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: 9 } } },
tooltip: {
...baseOptions.plugins.tooltip,
callbacks: {
label: (ctx) => {
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: 9 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
border: { display: false }
},
y1: {
type: 'linear',
position: 'right',
grid: { drawOnChartArea: false },
ticks: { font: { size: 9 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
border: { display: false }
}
}
}} />
</div>
</div>
</div>
</div>
</div>
</div>
<div className="carousel-dots">
{(filters.museum === 'all' ? dashboardCharts : dashboardCharts.filter(c => !['visitors-museum', 'revenue-museum'].includes(c.id))).map((chart, i) => (
<button
key={chart.id}
className={`carousel-dot ${activeChart === i ? 'active' : ''}`}
onClick={() => setActiveChart(i)}
>
<span className="dot-label">{chart.label}</span>
</button>
))}
</div>
</div>
</div>
);
}
export default Dashboard;