Restore working state from f17e19f (before mobile overhaul)

Reverting all my changes that broke the desktop layout.
Starting fresh for mobile improvements.
This commit is contained in:
fahed
2026-02-03 15:29:03 +03:00
parent 222d583847
commit b2fcb16d12
9 changed files with 323 additions and 739 deletions

View File

@@ -325,37 +325,6 @@ body {
font-size: 0.875rem;
}
/* Page Title with Actions (Labels toggle) */
.page-title-with-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.page-title-with-actions .page-title {
margin-bottom: 0;
}
.page-title-with-actions .toggle-with-label {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
}
.toggle-with-label .toggle-text {
font-size: 0.6875rem;
font-weight: 500;
color: var(--text-muted);
}
.page-title-with-actions .toggle-switch button {
padding: 3px 8px;
font-size: 0.6875rem;
}
/* Filters - now uses .controls for consistency */
/* Stats Grid */
@@ -630,58 +599,6 @@ table tbody tr:hover {
font-weight: 500;
}
/* Period Display Banner */
.period-display-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
background: var(--bg);
padding: 20px 32px;
border-radius: 12px;
margin-bottom: 24px;
}
.period-display-banner .period-box {
text-align: center;
min-width: 180px;
}
.period-display-banner .period-box.prev {
color: var(--text-secondary);
}
.period-display-banner .period-box.curr {
color: var(--text-primary);
}
.period-display-banner .period-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 4px;
}
.period-display-banner .period-value {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 2px;
}
.period-display-banner .period-dates {
font-size: 0.8125rem;
color: var(--text-muted);
}
.period-display-banner .period-vs {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
padding: 0 8px;
}
/* Comparison Metrics */
.comparison-grid {
display: grid;
@@ -1895,129 +1812,3 @@ table tbody tr:hover {
width: 100%;
height: 100%;
}
/* ========================================
MOBILE UX ENHANCEMENTS
All styles below ONLY apply to mobile
======================================== */
@media (max-width: 768px) {
/* Better touch targets (min 44px for accessibility) */
.mobile-nav-item {
min-height: 44px;
min-width: 56px;
}
.carousel-dot {
min-height: 32px;
}
.toggle-switch button {
min-height: 28px;
}
.control-group select,
.control-group input[type="date"] {
min-height: 44px;
}
/* Smoother carousel transitions */
.carousel-track {
transition: transform 350ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* Touch feedback */
.carousel-dot:active,
.mobile-nav-item:active,
.stat-card:active {
transform: scale(0.96);
transition: transform 100ms ease;
}
/* Bottom nav active indicator */
.mobile-nav-item.active {
position: relative;
}
.mobile-nav-item.active::after {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 3px;
background: var(--primary);
border-radius: 0 0 3px 3px;
}
/* Ensure chart title doesn't overlap with toggle */
.charts-carousel .chart-card h2 {
padding-right: 85px;
}
/* Period banner stacks on mobile */
.period-display-banner {
flex-direction: column;
gap: 12px;
padding: 16px;
}
.period-display-banner .period-box {
min-width: unset;
}
.period-display-banner .period-value {
font-size: 1.25rem;
}
/* Table scroll hint */
.table-container {
position: relative;
}
.table-container::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 16px;
background: linear-gradient(to left, var(--surface), transparent);
pointer-events: none;
}
}
/* Extra small screens */
@media (max-width: 375px) {
.dashboard,
.comparison {
padding: 12px;
padding-bottom: 80px;
}
.page-title h1 {
font-size: 1.125rem;
}
.stats-carousel .stat-value {
font-size: 1.375rem;
}
.charts-carousel .chart-container {
height: 200px;
}
.carousel-dot .dot-label {
font-size: 0.5625rem;
}
.mobile-nav-item {
font-size: 0.5625rem;
}
.mobile-nav-item svg {
width: 20px;
height: 20px;
}
}

View File

@@ -4,7 +4,6 @@ import Dashboard from './components/Dashboard';
import Comparison from './components/Comparison';
import Slides from './components/Slides';
import { fetchData } from './services/dataService';
import { useLanguage } from './contexts/LanguageContext';
import './App.css';
function NavLink({ to, children }) {
@@ -18,7 +17,6 @@ function NavLink({ to, children }) {
}
function App() {
const { t, dir, switchLanguage } = useLanguage();
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -26,9 +24,9 @@ function App() {
const [dataSource, setDataSource] = useState('museums');
const dataSources = [
{ id: 'museums', labelKey: 'dataSources.museums', enabled: true },
{ id: 'coffees', labelKey: 'dataSources.coffees', enabled: false },
{ id: 'ecommerce', labelKey: 'dataSources.ecommerce', enabled: false }
{ id: 'museums', label: 'Museums', enabled: true },
{ id: 'coffees', label: 'Coffees', enabled: false },
{ id: 'ecommerce', label: 'eCommerce', enabled: false }
];
useEffect(() => {
@@ -50,26 +48,26 @@ function App() {
if (loading) {
return (
<div className="loading-container" dir={dir}>
<div className="loading-container">
<div className="loading-spinner"></div>
<p>{t('app.loading')}</p>
<p>Loading data...</p>
</div>
);
}
if (error) {
return (
<div className="error-container" dir={dir}>
<h2>{t('app.error')}</h2>
<div className="error-container">
<h2>Unable to load data</h2>
<p style={{maxWidth: '400px', textAlign: 'center', color: '#64748b'}}>{error}</p>
<button onClick={() => window.location.reload()}>{t('app.retry')}</button>
<button onClick={() => window.location.reload()}>Retry</button>
</div>
);
}
return (
<Router>
<div className="app" dir={dir}>
<div className="app">
<nav className="nav-bar">
<div className="nav-content">
<div className="nav-brand">
@@ -88,7 +86,7 @@ function App() {
>
{dataSources.map(src => (
<option key={src.id} value={src.id} disabled={!src.enabled}>
{t(src.labelKey)}{!src.enabled ? ` (${t('dataSources.soon')})` : ''}
{src.label}{!src.enabled ? ' (soon)' : ''}
</option>
))}
</select>
@@ -102,7 +100,7 @@ function App() {
<rect x="14" y="12" width="7" height="9" rx="1"/>
<rect x="3" y="16" width="7" height="5" rx="1"/>
</svg>
{t('nav.dashboard')}
Dashboard
</NavLink>
<NavLink to="/comparison">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -112,22 +110,33 @@ function App() {
<polyline points="18 14 22 10 18 6"/>
<polyline points="6 10 2 14 6 18"/>
</svg>
{t('nav.comparison')}
Comparison
</NavLink>
<NavLink to="/slides">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
Slides
</NavLink>
<button
className="nav-lang-toggle"
onClick={switchLanguage}
title="Switch language"
className={`nav-label-toggle ${showDataLabels ? 'active' : ''}`}
onClick={() => setShowDataLabels(!showDataLabels)}
title="Show values on charts"
>
{t('language.switch')}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
{showDataLabels ? 'Labels On' : 'Labels Off'}
</button>
</div>
</div>
</nav>
<Routes>
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} />} />
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} />} />
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} />} />
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} />} />
<Route path="/slides" element={<Slides data={data} />} />
</Routes>
@@ -140,7 +149,7 @@ function App() {
<rect x="14" y="12" width="7" height="9" rx="1"/>
<rect x="3" y="16" width="7" height="5" rx="1"/>
</svg>
<span>{t('nav.dashboard')}</span>
<span>Dashboard</span>
</NavLink>
<NavLink to="/comparison" className="mobile-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -148,18 +157,24 @@ function App() {
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
<span>{t('nav.compare')}</span>
<span>Compare</span>
</NavLink>
<NavLink to="/slides" className="mobile-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<span>Slides</span>
</NavLink>
<button
className="mobile-nav-item"
onClick={switchLanguage}
className={`mobile-nav-item ${showDataLabels ? 'active' : ''}`}
onClick={() => setShowDataLabels(!showDataLabels)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
<span>{t('language.switch')}</span>
<span>Labels</span>
</button>
</nav>
</div>

View File

@@ -1,8 +1,7 @@
import React, { useRef } from 'react';
import JSZip from 'jszip';
// Wrapper component that adds PNG export to any chart
export function ExportableChart({ children, filename = 'chart', title = '', className = '', controls = null }) {
export function ExportableChart({ children, filename = 'chart', className = '' }) {
const chartRef = useRef(null);
const exportAsPNG = () => {
@@ -12,30 +11,21 @@ export function ExportableChart({ children, filename = 'chart', title = '', clas
const canvas = chartContainer.querySelector('canvas');
if (!canvas) return;
// Create a new canvas with white background and title
// Create a new canvas with white background
const exportCanvas = document.createElement('canvas');
const ctx = exportCanvas.getContext('2d');
// Set dimensions with padding and title space
const padding = 24;
const titleHeight = title ? 48 : 0;
// Set dimensions with padding
const padding = 20;
exportCanvas.width = canvas.width + (padding * 2);
exportCanvas.height = canvas.height + (padding * 2) + titleHeight;
exportCanvas.height = canvas.height + (padding * 2);
// Fill white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
// Draw title if provided (left-aligned, matching on-screen style)
if (title) {
ctx.fillStyle = '#1e293b';
ctx.font = '600 20px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(title, padding, padding + 24);
}
// Draw the chart
ctx.drawImage(canvas, padding, padding + titleHeight);
ctx.drawImage(canvas, padding, padding);
// Export
const link = document.createElement('a');
@@ -45,118 +35,23 @@ export function ExportableChart({ children, filename = 'chart', title = '', clas
};
return (
<div className="exportable-chart-wrapper">
{title && (
<div className="chart-header-with-export">
<h2>{title}</h2>
<div className="chart-header-actions">
{controls}
<button
className="chart-export-btn visible"
onClick={exportAsPNG}
title="Download as PNG"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
</div>
</div>
)}
{!title && controls && <div className="chart-controls">{controls}</div>}
<div className={`exportable-chart ${className}`}>
<div ref={chartRef} className="chart-canvas-wrapper">
{children}
</div>
<div className={`exportable-chart ${className}`}>
<button
className="chart-export-btn"
onClick={exportAsPNG}
title="Download as PNG"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<div ref={chartRef} className="chart-canvas-wrapper">
{children}
</div>
</div>
);
}
// Utility function to export all charts from a container as a ZIP
export async function exportAllCharts(containerSelector, zipFilename = 'charts') {
const container = document.querySelector(containerSelector);
if (!container) return;
const zip = new JSZip();
const chartWrappers = container.querySelectorAll('.exportable-chart-wrapper');
for (let i = 0; i < chartWrappers.length; i++) {
const wrapper = chartWrappers[i];
const canvas = wrapper.querySelector('canvas');
const titleEl = wrapper.querySelector('.chart-header-with-export h2');
const title = titleEl?.textContent || `chart-${i + 1}`;
if (!canvas) continue;
// Create export canvas with white background and title
const exportCanvas = document.createElement('canvas');
const ctx = exportCanvas.getContext('2d');
const padding = 32;
const titleHeight = 56;
exportCanvas.width = canvas.width + (padding * 2);
exportCanvas.height = canvas.height + (padding * 2) + titleHeight;
// White background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
// Draw title
ctx.fillStyle = '#1e293b';
ctx.font = '600 24px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(title, padding, padding + 28);
// Draw chart
ctx.drawImage(canvas, padding, padding + titleHeight);
// Convert to blob and add to zip
const dataUrl = exportCanvas.toDataURL('image/png', 1.0);
const base64Data = dataUrl.split(',')[1];
const safeFilename = title.replace(/[^a-zA-Z0-9\u0600-\u06FF\s-]/g, '').replace(/\s+/g, '-');
zip.file(`${String(i + 1).padStart(2, '0')}-${safeFilename}.png`, base64Data, { base64: true });
}
// Generate and download ZIP
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${zipFilename}-${new Date().toISOString().split('T')[0]}.zip`;
link.click();
URL.revokeObjectURL(url);
}
// Button component for exporting all charts
export function ExportAllButton({ containerSelector, zipFilename, label, loadingLabel }) {
const [exporting, setExporting] = React.useState(false);
const handleExport = async () => {
setExporting(true);
try {
await exportAllCharts(containerSelector, zipFilename);
} finally {
setExporting(false);
}
};
return (
<button
className="btn-export-all"
onClick={handleExport}
disabled={exporting}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
{exporting ? loadingLabel : label}
</button>
);
}
export default ExportableChart;

View File

@@ -4,7 +4,6 @@ import { Line, Bar } from 'react-chartjs-2';
import { EmptyState, FilterControls } from './shared';
import { ExportableChart } from './ChartExport';
import { chartColors, createBaseOptions } from '../config/chartConfig';
import { useLanguage } from '../contexts/LanguageContext';
import {
filterDataByDateRange,
calculateMetrics,
@@ -40,8 +39,7 @@ const generatePresetDates = (year) => ({
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
});
function Comparison({ data, showDataLabels, setShowDataLabels }) {
const { t } = useLanguage();
function Comparison({ data, showDataLabels }) {
const [searchParams, setSearchParams] = useSearchParams();
// Get available years from data
@@ -142,8 +140,8 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
};
const charts = [
{ id: 'timeseries', label: t('comparison.trend') },
{ id: 'museum', label: t('comparison.byMuseum') }
{ id: 'timeseries', label: 'Trend' },
{ id: 'museum', label: 'By Museum' }
];
// Touch swipe handlers
@@ -167,16 +165,15 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
};
const granularityOptions = [
{ value: 'day', label: t('time.daily') },
{ value: 'week', label: t('time.weekly') },
{ value: 'month', label: t('time.monthly') }
{ value: 'day', label: 'Daily' },
{ value: 'week', label: 'Weekly' }
];
const metricOptions = [
{ value: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax', format: 'currency' },
{ value: 'visitors', label: t('metrics.visitors'), field: 'visits', format: 'number' },
{ value: 'tickets', label: t('metrics.tickets'), field: 'tickets', format: 'number' },
{ value: 'avgRevenue', label: t('metrics.avgRevenue'), field: null, format: 'currency' }
{ value: 'revenue', label: 'Revenue', field: 'revenue_incl_tax', format: 'currency' },
{ value: 'visitors', label: 'Visitors', field: 'visits', format: 'number' },
{ value: 'tickets', label: 'Tickets', field: 'tickets', format: 'number' },
{ value: 'avgRevenue', label: 'Avg Rev/Visitor', field: null, format: 'currency' }
];
const getMetricValue = useCallback((rows, metric) => {
@@ -274,19 +271,19 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
const captureRateChange = captureRates ? calcChange(captureRates.prev || 0, captureRates.curr || 0) : null;
const cards = [
{ title: t('metrics.revenue'), prev: prevMetrics.revenue, curr: currMetrics.revenue, change: revenueChange, isCurrency: true },
{ title: t('metrics.visitors'), prev: prevMetrics.visitors, curr: currMetrics.visitors, change: visitorsChange },
{ title: t('metrics.tickets'), prev: prevMetrics.tickets, curr: currMetrics.tickets, change: ticketsChange },
{ title: t('metrics.avgRevenue'), prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: avgRevChange, isCurrency: true }
{ title: 'Revenue', prev: prevMetrics.revenue, curr: currMetrics.revenue, change: revenueChange, isCurrency: true },
{ title: 'Visitors', prev: prevMetrics.visitors, curr: currMetrics.visitors, change: visitorsChange },
{ title: 'Tickets', prev: prevMetrics.tickets, curr: currMetrics.tickets, change: ticketsChange },
{ title: 'Avg Rev/Visitor', prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: avgRevChange, isCurrency: true }
];
if (pilgrimCounts) {
cards.push({ title: t('metrics.pilgrims'), prev: pilgrimCounts.prev, curr: pilgrimCounts.curr, change: pilgrimsChange, pendingMessage: t('comparison.pendingData') });
cards.push({ title: 'Pilgrims', prev: pilgrimCounts.prev, curr: pilgrimCounts.curr, change: pilgrimsChange, pendingMessage: 'Data not published yet' });
}
if (captureRates) {
cards.push({ title: t('metrics.captureRate'), prev: captureRates.prev, curr: captureRates.curr, change: captureRateChange, isPercent: true, pendingMessage: t('comparison.pendingData') });
cards.push({ title: 'Capture Rate', prev: captureRates.prev, curr: captureRates.curr, change: captureRateChange, isPercent: true, pendingMessage: 'Data not published yet' });
}
return cards;
}, [prevMetrics, currMetrics, pilgrimCounts, captureRates, t]);
}, [prevMetrics, currMetrics, pilgrimCounts, captureRates]);
const handleCardTouchStart = (e) => {
touchStartCard.current = e.touches[0].clientX;
@@ -341,11 +338,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
const daysDiff = Math.floor((rowDate - start) / (1000 * 60 * 60 * 24));
let key;
if (granularity === 'month') {
// Group by month number (relative to start)
const monthsDiff = (rowDate.getFullYear() - start.getFullYear()) * 12 + (rowDate.getMonth() - start.getMonth());
key = monthsDiff + 1;
} else if (granularity === 'week') {
if (granularity === 'week') {
key = Math.floor(daysDiff / 7) + 1;
} else {
key = daysDiff + 1; // day number from start
@@ -366,15 +359,9 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
const currGrouped = groupByPeriod(currData, ranges.curr.start, chartMetric, chartGranularity);
const maxKey = Math.max(...Object.keys(prevGrouped).map(Number), ...Object.keys(currGrouped).map(Number), 1);
const labels = Array.from({ length: maxKey }, (_, i) => {
if (chartGranularity === 'month') {
const startDate = new Date(ranges.curr.start);
const monthNum = ((startDate.getMonth() + i) % 12) + 1;
return String(monthNum);
}
if (chartGranularity === 'week') return `W${i + 1}`;
return `D${i + 1}`;
});
const labels = Array.from({ length: maxKey }, (_, i) =>
chartGranularity === 'week' ? `W${i + 1}` : `D${i + 1}`
);
const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
@@ -436,54 +423,45 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
...baseOptions,
plugins: {
...baseOptions.plugins,
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } }
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 11 } } }
}
};
return (
<div className="comparison" id="comparison-container">
<div className="page-title-with-actions">
<div className="page-title">
<h1>{t('comparison.title')}</h1>
<p>{t('comparison.subtitle')}</p>
</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 className="comparison">
<div className="page-title">
<h1>Period Comparison</h1>
<p>Select a period and year automatically compares with the same period in the previous year</p>
</div>
<FilterControls title={t('comparison.selectPeriod')} onReset={resetFilters}>
<FilterControls title="Select Period" onReset={resetFilters}>
<FilterControls.Row>
<FilterControls.Group label={t('comparison.period')}>
<FilterControls.Group label="Period">
<select value={preset} onChange={e => setPreset(e.target.value)}>
<option value="custom">{t('comparison.custom')}</option>
<option value="jan">{t('months.january')}</option>
<option value="feb">{t('months.february')}</option>
<option value="mar">{t('months.march')}</option>
<option value="apr">{t('months.april')}</option>
<option value="may">{t('months.may')}</option>
<option value="jun">{t('months.june')}</option>
<option value="jul">{t('months.july')}</option>
<option value="aug">{t('months.august')}</option>
<option value="sep">{t('months.september')}</option>
<option value="oct">{t('months.october')}</option>
<option value="nov">{t('months.november')}</option>
<option value="dec">{t('months.december')}</option>
<option value="q1">{t('time.q1')}</option>
<option value="q2">{t('time.q2')}</option>
<option value="q3">{t('time.q3')}</option>
<option value="q4">{t('time.q4')}</option>
<option value="h1">{t('time.h1')}</option>
<option value="h2">{t('time.h2')}</option>
<option value="full">{t('time.fullYear')}</option>
<option value="custom">Custom</option>
<option value="jan">January</option>
<option value="feb">February</option>
<option value="mar">March</option>
<option value="apr">April</option>
<option value="may">May</option>
<option value="jun">June</option>
<option value="jul">July</option>
<option value="aug">August</option>
<option value="sep">September</option>
<option value="oct">October</option>
<option value="nov">November</option>
<option value="dec">December</option>
<option value="q1">Q1</option>
<option value="q2">Q2</option>
<option value="q3">Q3</option>
<option value="q4">Q4</option>
<option value="h1">H1</option>
<option value="h2">H2</option>
<option value="full">Full Year</option>
</select>
</FilterControls.Group>
{preset !== 'custom' && (
<FilterControls.Group label={t('filters.year')}>
<FilterControls.Group label="Year">
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
{availableYears.map(y => (
<option key={y} value={y}>{y}</option>
@@ -493,55 +471,51 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
)}
{preset === 'custom' && (
<>
<FilterControls.Group label={t('comparison.from')}>
<FilterControls.Group label="From">
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
</FilterControls.Group>
<FilterControls.Group label={t('comparison.to')}>
<FilterControls.Group label="To">
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
</FilterControls.Group>
</>
)}
<FilterControls.Group label={t('filters.district')}>
<FilterControls.Group label="District">
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
<option value="all">{t('filters.allDistricts')}</option>
<option value="all">All Districts</option>
{districts.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</FilterControls.Group>
<FilterControls.Group label={t('filters.museum')}>
<FilterControls.Group label="Museum">
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
<option value="all">{t('filters.allMuseums')}</option>
<option value="all">All Museums</option>
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</FilterControls.Group>
</FilterControls.Row>
<div className="period-display">
<div className="period-box">
<div className="label">{getPeriodLabel(ranges.prev.start, ranges.prev.end)}</div>
<div className="dates">{formatDate(ranges.prev.start)} {formatDate(ranges.prev.end)}</div>
</div>
<div className="period-box">
<div className="label">{getPeriodLabel(ranges.curr.start, ranges.curr.end)}</div>
<div className="dates">{formatDate(ranges.curr.start)} {formatDate(ranges.curr.end)}</div>
</div>
</div>
</FilterControls>
<div className="period-display-banner" id="comparison-period">
<div className="period-box prev">
<div className="period-label">{t('comparison.previousPeriod')}</div>
<div className="period-value">{getPeriodLabel(ranges.prev.start, ranges.prev.end)}</div>
<div className="period-dates">{formatDate(ranges.prev.start)} {formatDate(ranges.prev.end)}</div>
</div>
<div className="period-vs">{t('comparison.vs')}</div>
<div className="period-box curr">
<div className="period-label">{t('comparison.currentPeriod')}</div>
<div className="period-value">{getPeriodLabel(ranges.curr.start, ranges.curr.end)}</div>
<div className="period-dates">{formatDate(ranges.curr.start)} {formatDate(ranges.curr.end)}</div>
</div>
</div>
{!hasData ? (
<EmptyState
icon="📈"
title={t('comparison.noData')}
message={t('comparison.noDataMessage')}
title="No data for this period"
message="No records found for the selected date range and filters."
action={resetFilters}
actionLabel={t('filters.reset')}
actionLabel="Reset Filters"
/>
) : (
<>
{/* Desktop: Grid layout */}
<div className="comparison-grid desktop-only" id="comparison-metrics">
<div className="comparison-grid desktop-only">
{metricCards.map((card, i) => (
<MetricCard
key={i}
@@ -601,48 +575,22 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
</div>
{/* Desktop: Show both charts */}
<div className="charts-grid desktop-only" id="comparison-charts">
<div className="charts-grid desktop-only">
<div className="chart-section">
<ExportableChart
filename="trend-comparison"
title={`${metricOptions.find(m => m.value === chartMetric)?.label} - ${t('comparison.trend')}`}
className="chart-container"
controls={
<>
<div className="toggle-switch">
{granularityOptions.map(opt => (
<button
key={opt.value}
className={chartGranularity === opt.value ? 'active' : ''}
onClick={() => setChartGranularity(opt.value)}
>
{opt.label}
</button>
))}
</div>
<div className="chart-metric-selector">
{metricOptions.map(opt => (
<button
key={opt.value}
className={chartMetric === opt.value ? 'active' : ''}
onClick={() => setChartMetric(opt.value)}
>
{opt.label}
</button>
))}
</div>
</>
}
>
<Line data={timeSeriesChart} options={chartOptions} />
</ExportableChart>
</div>
<div className="chart-section">
<ExportableChart
filename="museum-comparison"
title={`${metricOptions.find(m => m.value === chartMetric)?.label} - ${t('comparison.byMuseum')}`}
className="chart-container"
controls={
<div className="chart-header">
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} Trend</h2>
<div className="chart-selectors">
<div className="toggle-switch">
{granularityOptions.map(opt => (
<button
key={opt.value}
className={chartGranularity === opt.value ? 'active' : ''}
onClick={() => setChartGranularity(opt.value)}
>
{opt.label}
</button>
))}
</div>
<div className="chart-metric-selector">
{metricOptions.map(opt => (
<button
@@ -654,8 +602,30 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
</button>
))}
</div>
}
>
</div>
</div>
<ExportableChart filename="trend-comparison" className="chart-container">
<Line data={timeSeriesChart} options={chartOptions} />
</ExportableChart>
</div>
<div className="chart-section">
<div className="chart-header">
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} by Museum</h2>
<div className="chart-selectors">
<div className="chart-metric-selector">
{metricOptions.map(opt => (
<button
key={opt.value}
className={chartMetric === opt.value ? 'active' : ''}
onClick={() => setChartMetric(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
<ExportableChart filename="museum-comparison" className="chart-container">
<Bar data={museumChart} options={chartOptions} />
</ExportableChart>
</div>
@@ -674,7 +644,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
<div className="carousel-slide">
<div className="chart-section">
<div className="chart-header">
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} - {t('comparison.trend')}</h2>
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} Trend</h2>
<div className="toggle-switch">
{granularityOptions.map(opt => (
<button
@@ -708,7 +678,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
<div className="carousel-slide">
<div className="chart-section">
<div className="chart-header">
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} - {t('comparison.byMuseum')}</h2>
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} by Museum</h2>
</div>
<div className="chart-selectors-inline">
<div className="chart-metric-selector">

View File

@@ -4,7 +4,6 @@ import { Line, Doughnut, Bar } from 'react-chartjs-2';
import { Carousel, EmptyState, FilterControls, StatCard } from './shared';
import { ExportableChart } from './ChartExport';
import { chartColors, createBaseOptions } from '../config/chartConfig';
import { useLanguage } from '../contexts/LanguageContext';
import {
filterData,
calculateMetrics,
@@ -29,8 +28,7 @@ const defaultFilters = {
const filterKeys = ['year', 'district', 'museum', 'quarter'];
function Dashboard({ data, showDataLabels, setShowDataLabels }) {
const { t } = useLanguage();
function Dashboard({ data, showDataLabels }) {
const [searchParams, setSearchParams] = useSearchParams();
// Initialize filters from URL or defaults
@@ -69,17 +67,17 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
// 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]);
{ 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]);
// Chart carousel labels
const chartLabels = useMemo(() => {
const labels = [t('charts.revenueTrend'), t('charts.visitors'), t('charts.revenue'), t('charts.quarterly'), t('charts.district'), t('charts.captureRate')];
const labels = ['Revenue Trend', 'Visitors', 'Revenue', 'Quarterly', 'District', 'Capture Rate'];
return filters.museum === 'all' ? labels : labels.filter((_, i) => i !== 1 && i !== 2);
}, [filters.museum, t]);
}, [filters.museum]);
// Dynamic lists from data
const years = useMemo(() => getUniqueYears(data), [data]);
@@ -250,7 +248,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
color: '#1e293b',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 3,
font: { size: 10, weight: 600 },
font: { size: 9, weight: 600 },
anchor: 'end',
align: 'top',
offset: 6
@@ -276,7 +274,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
color: '#1e293b',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 3,
font: { size: 10, weight: 600 },
font: { size: 9, weight: 600 },
anchor: 'start',
align: 'bottom',
offset: 6
@@ -316,59 +314,50 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
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="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 className="dashboard">
<div className="page-title">
<h1>Dashboard</h1>
<p>Real-time museum analytics from Google Sheets</p>
</div>
<FilterControls title={t('filters.title')} onReset={resetFilters}>
<FilterControls title="Filters" onReset={resetFilters}>
<FilterControls.Row>
<FilterControls.Group label={t('filters.year')}>
<FilterControls.Group label="Year">
<select value={filters.year} onChange={e => setFilters({...filters, year: e.target.value})}>
<option value="all">{t('filters.allYears')}</option>
<option value="all">All Years</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</FilterControls.Group>
<FilterControls.Group label={t('filters.district')}>
<FilterControls.Group label="District">
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
<option value="all">{t('filters.allDistricts')}</option>
<option value="all">All Districts</option>
{districts.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</FilterControls.Group>
<FilterControls.Group label={t('filters.museum')}>
<FilterControls.Group label="Museum">
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
<option value="all">{t('filters.allMuseums')}</option>
<option value="all">All Museums</option>
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</FilterControls.Group>
<FilterControls.Group label={t('filters.quarter')}>
<FilterControls.Group label="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>
<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>
</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 className="stats-grid desktop-only">
<StatCard title="Total Revenue" value={formatCurrency(metrics.revenue)} change={yoyChange} />
<StatCard title="Total Visitors" value={formatNumber(metrics.visitors)} />
<StatCard title="Total Tickets" value={formatNumber(metrics.tickets)} />
<StatCard title="Avg Revenue/Visitor" value={formatCurrency(metrics.avgRevPerVisitor)} />
</div>
{/* Mobile: Stats Carousel */}
@@ -392,28 +381,28 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
{!hasData ? (
<EmptyState
icon="📊"
title={t('dashboard.noData')}
message={t('dashboard.noDataMessage')}
title="No data found"
message="No records match your current filters. Try adjusting your selection."
action={resetFilters}
actionLabel={t('filters.reset')}
actionLabel="Reset Filters"
/>
) : (
<>
<div className="chart-card full-width" style={{marginBottom: '16px'}} id="quarterly-table">
<h2>{t('dashboard.quarterlyComparison')}</h2>
<div className="chart-card full-width" style={{marginBottom: '16px'}}>
<h2>Quarterly Comparison: 2024 vs 2025</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>
<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>
@@ -440,58 +429,58 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
</div>
{/* Desktop: Charts Grid */}
<div className="charts-grid desktop-only" id="dashboard-charts">
<div className="charts-grid desktop-only">
<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>
}
>
<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>
<ExportableChart filename="revenue-trend" 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}}}}} />
</ExportableChart>
</div>
{filters.museum === 'all' && (
<div className="chart-card half-width">
<ExportableChart filename="visitors-by-museum" title={t('dashboard.visitorsByMuseum')} className="chart-container">
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 16, font: {size: 13}}}}}} />
<h2>Visitors by Museum</h2>
<ExportableChart filename="visitors-by-museum" 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}}}}}} />
</ExportableChart>
</div>
)}
{filters.museum === 'all' && (
<div className="chart-card half-width">
<ExportableChart filename="revenue-by-museum" title={t('dashboard.revenueByMuseum')} className="chart-container">
<h2>Revenue by Museum</h2>
<ExportableChart filename="revenue-by-museum" className="chart-container">
<Bar data={museumData.revenue} options={baseOptions} />
</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}}}}}} />
<h2>Quarterly Revenue (YoY)</h2>
<ExportableChart filename="quarterly-yoy" 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}}}}}} />
</ExportableChart>
</div>
<div className="chart-card half-width">
<ExportableChart filename="district-performance" title={t('dashboard.districtPerformance')} className="chart-container">
<h2>District Performance</h2>
<ExportableChart filename="district-performance" 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">
<h2>Capture Rate vs Umrah Pilgrims</h2>
<ExportableChart filename="capture-rate" 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 } } },
legend: { display: true, position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 11 } } },
tooltip: {
...baseOptions.plugins.tooltip,
callbacks: {
@@ -510,17 +499,17 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
type: 'linear',
position: 'left',
grid: { color: chartColors.grid },
ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
ticks: { font: { size: 10 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
border: { display: false },
title: { display: true, text: 'Capture Rate (%)', font: { size: 12 }, color: chartColors.secondary }
title: { display: true, text: 'Capture Rate (%)', font: { size: 10 }, color: chartColors.secondary }
},
y1: {
type: 'linear',
position: 'right',
grid: { drawOnChartArea: false },
ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
ticks: { font: { size: 10 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
border: { display: false },
title: { display: true, text: 'Pilgrims', font: { size: 12 }, color: chartColors.tertiary }
title: { display: true, text: 'Pilgrims', font: { size: 10 }, color: chartColors.tertiary }
}
}
}} />
@@ -538,10 +527,10 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
>
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.revenueTrends')}</h2>
<h2>Revenue Trends</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>
<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}}}}} />
@@ -552,9 +541,9 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
{filters.museum === 'all' && (
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.visitorsByMuseum')}</h2>
<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: 12}}}}}} />
<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>
@@ -563,7 +552,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
{filters.museum === 'all' && (
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.revenueByMuseum')}</h2>
<h2>Revenue by Museum</h2>
<div className="chart-container">
<Bar data={museumData.revenue} options={baseOptions} />
</div>
@@ -573,16 +562,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.quarterlyRevenue')}</h2>
<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: 12}}}}}} />
<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>{t('dashboard.districtPerformance')}</h2>
<h2>District Performance</h2>
<div className="chart-container">
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
</div>
@@ -591,13 +580,13 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.captureRateChart')}</h2>
<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: 13 } } },
legend: { display: true, position: 'top', align: 'end', labels: { boxWidth: 10, padding: 8, font: { size: 9 } } },
tooltip: {
...baseOptions.plugins.tooltip,
callbacks: {
@@ -616,14 +605,14 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
type: 'linear',
position: 'left',
grid: { color: chartColors.grid },
ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
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: 13 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
ticks: { font: { size: 9 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
border: { display: false }
}
}

View File

@@ -1,7 +1,6 @@
import React, { useState, useMemo, useCallback } from 'react';
import { Line, Bar } from 'react-chartjs-2';
import { chartColors, createBaseOptions } from '../config/chartConfig';
import { useLanguage } from '../contexts/LanguageContext';
import {
filterDataByDateRange,
calculateMetrics,
@@ -13,21 +12,20 @@ import {
} from '../services/dataService';
import JSZip from 'jszip';
function Slides({ data }) {
const { t } = useLanguage();
const CHART_TYPES = useMemo(() => [
{ id: 'trend', label: t('slides.revenueTrend'), icon: '📈' },
{ id: 'museum-bar', label: t('slides.byMuseum'), icon: '📊' },
{ id: 'kpi-cards', label: t('slides.kpiSummary'), icon: '🎯' },
{ id: 'comparison', label: t('slides.yoyComparison'), icon: '⚖️' }
], [t]);
const CHART_TYPES = [
{ id: 'trend', label: 'Revenue Trend', icon: '📈' },
{ id: 'museum-bar', label: 'By Museum', icon: '📊' },
{ id: 'kpi-cards', label: 'KPI Summary', icon: '🎯' },
{ id: 'comparison', label: 'YoY Comparison', icon: '⚖️' }
];
const METRICS = useMemo(() => [
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax' },
{ id: 'visitors', label: t('metrics.visitors'), field: 'visits' },
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
], [t]);
const METRICS = [
{ id: 'revenue', label: 'Revenue', field: 'revenue_incl_tax' },
{ id: 'visitors', label: 'Visitors', field: 'visits' },
{ id: 'tickets', label: 'Tickets', field: 'tickets' }
];
function Slides({ data }) {
const [slides, setSlides] = useState([]);
const [editingSlide, setEditingSlide] = useState(null);
const [previewMode, setPreviewMode] = useState(false);
@@ -173,7 +171,6 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
currentSlide={currentPreviewSlide}
setCurrentSlide={setCurrentPreviewSlide}
onExit={() => setPreviewMode(false)}
metrics={METRICS}
/>
);
}
@@ -181,8 +178,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
return (
<div className="slides-builder">
<div className="page-title">
<h1>{t('slides.title')}</h1>
<p>{t('slides.subtitle')}</p>
<h1>Presentation Builder</h1>
<p>Create slides with charts and export as HTML or PDF</p>
</div>
<div className="slides-toolbar">
@@ -190,7 +187,7 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{t('slides.addSlide')}
Add Slide
</button>
{slides.length > 0 && (
<>
@@ -198,13 +195,13 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
{t('slides.preview')}
Preview
</button>
<button className="btn-secondary" onClick={exportAsHTML}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
{t('slides.exportHtml')}
Export HTML
</button>
</>
)}
@@ -212,11 +209,11 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
<div className="slides-workspace">
<div className="slides-list">
<h3>{t('slides.slidesCount')} ({slides.length})</h3>
<h3>Slides ({slides.length})</h3>
{slides.length === 0 ? (
<div className="empty-slides">
<p>{t('slides.noSlides')}</p>
<button onClick={addSlide}>{t('slides.addFirst')}</button>
<p>No slides yet</p>
<button onClick={addSlide}>Add your first slide</button>
</div>
) : (
<div className="slides-thumbnails">
@@ -248,8 +245,6 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
districts={districts}
districtMuseumMap={districtMuseumMap}
data={data}
chartTypes={CHART_TYPES}
metrics={METRICS}
/>
)}
</div>
@@ -257,8 +252,7 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
);
}
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, chartTypes, metrics }) {
const { t } = useLanguage();
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data }) {
const availableMuseums = useMemo(() =>
getMuseumsForDistrict(districtMuseumMap, slide.district),
[districtMuseumMap, slide.district]
@@ -267,19 +261,19 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
return (
<div className="slide-editor">
<div className="editor-section">
<label>{t('slides.slideTitle')}</label>
<label>Slide Title</label>
<input
type="text"
value={slide.title}
onChange={e => onUpdate({ title: e.target.value })}
placeholder={t('slides.slideTitle')}
placeholder="Enter slide title"
/>
</div>
<div className="editor-section">
<label>{t('slides.chartType')}</label>
<label>Chart Type</label>
<div className="chart-type-grid">
{chartTypes.map(type => (
{CHART_TYPES.map(type => (
<button
key={type.id}
className={`chart-type-btn ${slide.chartType === type.id ? 'active' : ''}`}
@@ -293,35 +287,35 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
</div>
<div className="editor-section">
<label>{t('slides.metric')}</label>
<label>Metric</label>
<select value={slide.metric} onChange={e => onUpdate({ metric: e.target.value })}>
{metrics.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
{METRICS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</div>
<div className="editor-row">
<div className="editor-section">
<label>{t('slides.startDate')}</label>
<label>Start Date</label>
<input type="date" value={slide.startDate} onChange={e => onUpdate({ startDate: e.target.value })} />
</div>
<div className="editor-section">
<label>{t('slides.endDate')}</label>
<label>End Date</label>
<input type="date" value={slide.endDate} onChange={e => onUpdate({ endDate: e.target.value })} />
</div>
</div>
<div className="editor-row">
<div className="editor-section">
<label>{t('filters.district')}</label>
<label>District</label>
<select value={slide.district} onChange={e => onUpdate({ district: e.target.value, museum: 'all' })}>
<option value="all">{t('filters.allDistricts')}</option>
<option value="all">All Districts</option>
{districts.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
<div className="editor-section">
<label>{t('filters.museum')}</label>
<label>Museum</label>
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
<option value="all">{t('filters.allMuseums')}</option>
<option value="all">All Museums</option>
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
@@ -335,28 +329,20 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
checked={slide.showComparison}
onChange={e => onUpdate({ showComparison: e.target.checked })}
/>
{t('slides.showYoY')}
Show Year-over-Year Comparison
</label>
</div>
)}
<div className="slide-preview-box">
<h4>{t('slides.preview')}</h4>
<SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} metrics={metrics} />
<h4>Preview</h4>
<SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} />
</div>
</div>
);
}
// Static field mapping for charts (Chart.js labels don't need i18n)
const METRIC_FIELDS = {
revenue: { field: 'revenue_incl_tax', label: 'Revenue' },
visitors: { field: 'visits', label: 'Visitors' },
tickets: { field: 'tickets', label: 'Tickets' }
};
function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
const { t } = useLanguage();
function SlidePreview({ slide, data, districts, districtMuseumMap }) {
const filteredData = useMemo(() =>
filterDataByDateRange(data, slide.startDate, slide.endDate, {
district: slide.district,
@@ -365,7 +351,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
[data, slide.startDate, slide.endDate, slide.district, slide.museum]
);
const metricsData = useMemo(() => calculateMetrics(filteredData), [filteredData]);
const metrics = useMemo(() => calculateMetrics(filteredData), [filteredData]);
const baseOptions = useMemo(() => createBaseOptions(false), []);
const getMetricValue = useCallback((rows, metric) => {
@@ -383,11 +369,10 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
});
const sortedDates = Object.keys(grouped).sort();
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
return {
labels: sortedDates.map(d => d.substring(5)),
datasets: [{
label: metricLabel,
label: METRICS.find(m => m.id === slide.metric)?.label,
data: sortedDates.map(d => getMetricValue(grouped[d], slide.metric)),
borderColor: chartColors.primary,
backgroundColor: chartColors.primary + '20',
@@ -395,7 +380,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
tension: 0.4
}]
};
}, [filteredData, slide.metric, getMetricValue, metrics]);
}, [filteredData, slide.metric, getMetricValue]);
const museumData = useMemo(() => {
const byMuseum = {};
@@ -406,32 +391,31 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
});
const museums = Object.keys(byMuseum).sort();
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
return {
labels: museums,
datasets: [{
label: metricLabel,
label: METRICS.find(m => m.id === slide.metric)?.label,
data: museums.map(m => getMetricValue(byMuseum[m], slide.metric)),
backgroundColor: chartColors.primary,
borderRadius: 6
}]
};
}, [filteredData, slide.metric, getMetricValue, metrics]);
}, [filteredData, slide.metric, getMetricValue]);
if (slide.chartType === 'kpi-cards') {
return (
<div className="preview-kpis">
<div className="preview-kpi">
<div className="kpi-value">{formatCompactCurrency(metricsData.revenue)}</div>
<div className="kpi-label">{t('metrics.revenue')}</div>
<div className="kpi-value">{formatCompactCurrency(metrics.revenue)}</div>
<div className="kpi-label">Revenue</div>
</div>
<div className="preview-kpi">
<div className="kpi-value">{formatCompact(metricsData.visitors)}</div>
<div className="kpi-label">{t('metrics.visitors')}</div>
<div className="kpi-value">{formatCompact(metrics.visitors)}</div>
<div className="kpi-label">Visitors</div>
</div>
<div className="preview-kpi">
<div className="kpi-value">{formatCompact(metricsData.tickets)}</div>
<div className="kpi-label">{t('metrics.tickets')}</div>
<div className="kpi-value">{formatCompact(metrics.tickets)}</div>
<div className="kpi-label">Tickets</div>
</div>
</div>
);
@@ -452,8 +436,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
);
}
function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide, setCurrentSlide, onExit, metrics }) {
const { t } = useLanguage();
function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide, setCurrentSlide, onExit }) {
const handleKeyDown = useCallback((e) => {
if (e.key === 'ArrowRight' || e.key === ' ') {
setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1));
@@ -476,7 +459,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
<div className="preview-slide">
<h1 className="preview-title">{slide?.title}</h1>
<div className="preview-content">
{slide && <SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} metrics={metrics} />}
{slide && <SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} />}
</div>
<div className="preview-footer">
<span>{currentSlide + 1} / {slides.length}</span>
@@ -485,7 +468,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
<div className="preview-controls">
<button onClick={() => setCurrentSlide(prev => Math.max(prev - 1, 0))} disabled={currentSlide === 0}></button>
<button onClick={() => setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1))} disabled={currentSlide === slides.length - 1}></button>
<button onClick={onExit}>{t('slides.exit')}</button>
<button onClick={onExit}>Exit</button>
</div>
</div>
);

View File

@@ -1,4 +1,4 @@
import React, { useRef, useCallback, useState } from 'react';
import React, { useRef, useCallback } from 'react';
function Carousel({
children,
@@ -8,57 +8,24 @@ function Carousel({
showLabels = true,
className = ''
}) {
const touchStartX = useRef(null);
const touchStartY = useRef(null);
const trackRef = useRef(null);
const [dragOffset, setDragOffset] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const touchStart = useRef(null);
const itemCount = React.Children.count(children);
const SWIPE_THRESHOLD = 50;
const handleTouchStart = useCallback((e) => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
setIsDragging(true);
touchStart.current = e.touches[0].clientX;
}, []);
const handleTouchMove = useCallback((e) => {
if (!touchStartX.current || !isDragging) return;
const currentX = e.touches[0].clientX;
const currentY = e.touches[0].clientY;
const diffX = currentX - touchStartX.current;
const diffY = Math.abs(currentY - touchStartY.current);
// Only drag horizontally if not scrolling vertically
if (Math.abs(diffX) > diffY) {
// Add resistance at edges
let offset = diffX;
if ((activeIndex === 0 && diffX > 0) || (activeIndex === itemCount - 1 && diffX < 0)) {
offset = diffX * 0.25;
}
setDragOffset(offset);
}
}, [isDragging, activeIndex, itemCount]);
const handleTouchEnd = useCallback((e) => {
if (!touchStartX.current) return;
const diff = touchStartX.current - e.changedTouches[0].clientX;
if (Math.abs(diff) > SWIPE_THRESHOLD) {
if (!touchStart.current) return;
const diff = touchStart.current - e.changedTouches[0].clientX;
if (Math.abs(diff) > 50) {
if (diff > 0 && activeIndex < itemCount - 1) {
setActiveIndex(activeIndex + 1);
} else if (diff < 0 && activeIndex > 0) {
setActiveIndex(activeIndex - 1);
}
}
touchStartX.current = null;
touchStartY.current = null;
setDragOffset(0);
setIsDragging(false);
touchStart.current = null;
}, [activeIndex, setActiveIndex, itemCount]);
const handleKeyDown = useCallback((e) => {
@@ -69,41 +36,18 @@ function Carousel({
}
}, [activeIndex, setActiveIndex, itemCount]);
// Calculate transform with drag offset
const baseTransform = -(activeIndex * 100);
const dragPercent = trackRef.current
? (dragOffset / trackRef.current.offsetWidth) * 100
: 0;
const transform = baseTransform + dragPercent;
return (
<div
className={`carousel ${className}`}
onKeyDown={handleKeyDown}
tabIndex={0}
role="region"
aria-label="Carousel"
>
<div className={`carousel ${className}`} onKeyDown={handleKeyDown} tabIndex={0}>
<div className="carousel-container">
<div className="carousel-viewport">
<div
ref={trackRef}
className="carousel-track"
style={{
transform: `translateX(${transform}%)`,
transition: isDragging ? 'none' : undefined
}}
style={{ transform: `translateX(-${activeIndex * 100}%)` }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{React.Children.map(children, (child, i) => (
<div
className="carousel-slide"
key={i}
role="tabpanel"
aria-hidden={activeIndex !== i}
>
<div className="carousel-slide" key={i}>
{child}
</div>
))}

View File

@@ -41,7 +41,7 @@ export const chartColors = {
export const createDataLabelConfig = (showDataLabels) => ({
display: showDataLabels,
color: '#1e293b',
font: { size: 11, weight: 600 },
font: { size: 10, weight: 600 },
anchor: 'end',
align: 'end',
offset: 4,
@@ -74,19 +74,19 @@ export const createBaseOptions = (showDataLabels) => ({
backgroundColor: '#1e293b',
padding: 12,
cornerRadius: 8,
titleFont: { size: 14 },
bodyFont: { size: 13 }
titleFont: { size: 12 },
bodyFont: { size: 11 }
},
datalabels: createDataLabelConfig(showDataLabels)
},
scales: {
x: {
grid: { display: false },
ticks: { font: { size: 12 }, color: '#94a3b8' }
ticks: { font: { size: 10 }, color: '#94a3b8' }
},
y: {
grid: { color: chartColors.grid },
ticks: { font: { size: 12 }, color: '#94a3b8' },
ticks: { font: { size: 10 }, color: '#94a3b8' },
border: { display: false }
}
}

View File

@@ -1,13 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { LanguageProvider } from './contexts/LanguageContext';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<LanguageProvider>
<App />
</LanguageProvider>
<App />
</React.StrictMode>
);