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:
209
src/App.css
209
src/App.css
@@ -325,37 +325,6 @@ body {
|
|||||||
font-size: 0.875rem;
|
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 */
|
/* Filters - now uses .controls for consistency */
|
||||||
|
|
||||||
/* Stats Grid */
|
/* Stats Grid */
|
||||||
@@ -630,58 +599,6 @@ table tbody tr:hover {
|
|||||||
font-weight: 500;
|
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 Metrics */
|
||||||
.comparison-grid {
|
.comparison-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1895,129 +1812,3 @@ table tbody tr:hover {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
71
src/App.js
71
src/App.js
@@ -4,7 +4,6 @@ import Dashboard from './components/Dashboard';
|
|||||||
import Comparison from './components/Comparison';
|
import Comparison from './components/Comparison';
|
||||||
import Slides from './components/Slides';
|
import Slides from './components/Slides';
|
||||||
import { fetchData } from './services/dataService';
|
import { fetchData } from './services/dataService';
|
||||||
import { useLanguage } from './contexts/LanguageContext';
|
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function NavLink({ to, children }) {
|
function NavLink({ to, children }) {
|
||||||
@@ -18,7 +17,6 @@ function NavLink({ to, children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t, dir, switchLanguage } = useLanguage();
|
|
||||||
const [data, setData] = useState([]);
|
const [data, setData] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -26,9 +24,9 @@ function App() {
|
|||||||
const [dataSource, setDataSource] = useState('museums');
|
const [dataSource, setDataSource] = useState('museums');
|
||||||
|
|
||||||
const dataSources = [
|
const dataSources = [
|
||||||
{ id: 'museums', labelKey: 'dataSources.museums', enabled: true },
|
{ id: 'museums', label: 'Museums', enabled: true },
|
||||||
{ id: 'coffees', labelKey: 'dataSources.coffees', enabled: false },
|
{ id: 'coffees', label: 'Coffees', enabled: false },
|
||||||
{ id: 'ecommerce', labelKey: 'dataSources.ecommerce', enabled: false }
|
{ id: 'ecommerce', label: 'eCommerce', enabled: false }
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,26 +48,26 @@ function App() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="loading-container" dir={dir}>
|
<div className="loading-container">
|
||||||
<div className="loading-spinner"></div>
|
<div className="loading-spinner"></div>
|
||||||
<p>{t('app.loading')}</p>
|
<p>Loading data...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="error-container" dir={dir}>
|
<div className="error-container">
|
||||||
<h2>{t('app.error')}</h2>
|
<h2>Unable to load data</h2>
|
||||||
<p style={{maxWidth: '400px', textAlign: 'center', color: '#64748b'}}>{error}</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<div className="app" dir={dir}>
|
<div className="app">
|
||||||
<nav className="nav-bar">
|
<nav className="nav-bar">
|
||||||
<div className="nav-content">
|
<div className="nav-content">
|
||||||
<div className="nav-brand">
|
<div className="nav-brand">
|
||||||
@@ -88,7 +86,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
{dataSources.map(src => (
|
{dataSources.map(src => (
|
||||||
<option key={src.id} value={src.id} disabled={!src.enabled}>
|
<option key={src.id} value={src.id} disabled={!src.enabled}>
|
||||||
{t(src.labelKey)}{!src.enabled ? ` (${t('dataSources.soon')})` : ''}
|
{src.label}{!src.enabled ? ' (soon)' : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -102,7 +100,7 @@ function App() {
|
|||||||
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
||||||
<rect x="3" y="16" width="7" height="5" rx="1"/>
|
<rect x="3" y="16" width="7" height="5" rx="1"/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('nav.dashboard')}
|
Dashboard
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/comparison">
|
<NavLink to="/comparison">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<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="18 14 22 10 18 6"/>
|
||||||
<polyline points="6 10 2 14 6 18"/>
|
<polyline points="6 10 2 14 6 18"/>
|
||||||
</svg>
|
</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>
|
</NavLink>
|
||||||
<button
|
<button
|
||||||
className="nav-lang-toggle"
|
className={`nav-label-toggle ${showDataLabels ? 'active' : ''}`}
|
||||||
onClick={switchLanguage}
|
onClick={() => setShowDataLabels(!showDataLabels)}
|
||||||
title="Switch language"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} />} />
|
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} />} />
|
||||||
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} />} />
|
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} />} />
|
||||||
<Route path="/slides" element={<Slides data={data} />} />
|
<Route path="/slides" element={<Slides data={data} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
@@ -140,7 +149,7 @@ function App() {
|
|||||||
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
||||||
<rect x="3" y="16" width="7" height="5" rx="1"/>
|
<rect x="3" y="16" width="7" height="5" rx="1"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{t('nav.dashboard')}</span>
|
<span>Dashboard</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/comparison" className="mobile-nav-item">
|
<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">
|
<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="12" y1="20" x2="12" y2="4"/>
|
||||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||||
</svg>
|
</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>
|
</NavLink>
|
||||||
<button
|
<button
|
||||||
className="mobile-nav-item"
|
className={`mobile-nav-item ${showDataLabels ? 'active' : ''}`}
|
||||||
onClick={switchLanguage}
|
onClick={() => setShowDataLabels(!showDataLabels)}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<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"/>
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||||
<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"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span>{t('language.switch')}</span>
|
<span>Labels</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import JSZip from 'jszip';
|
|
||||||
|
|
||||||
// Wrapper component that adds PNG export to any chart
|
// 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 chartRef = useRef(null);
|
||||||
|
|
||||||
const exportAsPNG = () => {
|
const exportAsPNG = () => {
|
||||||
@@ -12,30 +11,21 @@ export function ExportableChart({ children, filename = 'chart', title = '', clas
|
|||||||
const canvas = chartContainer.querySelector('canvas');
|
const canvas = chartContainer.querySelector('canvas');
|
||||||
if (!canvas) return;
|
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 exportCanvas = document.createElement('canvas');
|
||||||
const ctx = exportCanvas.getContext('2d');
|
const ctx = exportCanvas.getContext('2d');
|
||||||
|
|
||||||
// Set dimensions with padding and title space
|
// Set dimensions with padding
|
||||||
const padding = 24;
|
const padding = 20;
|
||||||
const titleHeight = title ? 48 : 0;
|
|
||||||
exportCanvas.width = canvas.width + (padding * 2);
|
exportCanvas.width = canvas.width + (padding * 2);
|
||||||
exportCanvas.height = canvas.height + (padding * 2) + titleHeight;
|
exportCanvas.height = canvas.height + (padding * 2);
|
||||||
|
|
||||||
// Fill white background
|
// Fill white background
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
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
|
// Draw the chart
|
||||||
ctx.drawImage(canvas, padding, padding + titleHeight);
|
ctx.drawImage(canvas, padding, padding);
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
@@ -45,118 +35,23 @@ export function ExportableChart({ children, filename = 'chart', title = '', clas
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="exportable-chart-wrapper">
|
<div className={`exportable-chart ${className}`}>
|
||||||
{title && (
|
<button
|
||||||
<div className="chart-header-with-export">
|
className="chart-export-btn"
|
||||||
<h2>{title}</h2>
|
onClick={exportAsPNG}
|
||||||
<div className="chart-header-actions">
|
title="Download as PNG"
|
||||||
{controls}
|
>
|
||||||
<button
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
className="chart-export-btn visible"
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
onClick={exportAsPNG}
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
title="Download as PNG"
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
>
|
</svg>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
</button>
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
<div ref={chartRef} className="chart-canvas-wrapper">
|
||||||
<polyline points="7 10 12 15 17 10"/>
|
{children}
|
||||||
<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>
|
</div>
|
||||||
</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;
|
export default ExportableChart;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Line, Bar } from 'react-chartjs-2';
|
|||||||
import { EmptyState, FilterControls } from './shared';
|
import { EmptyState, FilterControls } from './shared';
|
||||||
import { ExportableChart } from './ChartExport';
|
import { ExportableChart } from './ChartExport';
|
||||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
|
||||||
import {
|
import {
|
||||||
filterDataByDateRange,
|
filterDataByDateRange,
|
||||||
calculateMetrics,
|
calculateMetrics,
|
||||||
@@ -40,8 +39,7 @@ const generatePresetDates = (year) => ({
|
|||||||
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
|
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
|
||||||
});
|
});
|
||||||
|
|
||||||
function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
function Comparison({ data, showDataLabels }) {
|
||||||
const { t } = useLanguage();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
// Get available years from data
|
// Get available years from data
|
||||||
@@ -142,8 +140,8 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const charts = [
|
const charts = [
|
||||||
{ id: 'timeseries', label: t('comparison.trend') },
|
{ id: 'timeseries', label: 'Trend' },
|
||||||
{ id: 'museum', label: t('comparison.byMuseum') }
|
{ id: 'museum', label: 'By Museum' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Touch swipe handlers
|
// Touch swipe handlers
|
||||||
@@ -167,16 +165,15 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const granularityOptions = [
|
const granularityOptions = [
|
||||||
{ value: 'day', label: t('time.daily') },
|
{ value: 'day', label: 'Daily' },
|
||||||
{ value: 'week', label: t('time.weekly') },
|
{ value: 'week', label: 'Weekly' }
|
||||||
{ value: 'month', label: t('time.monthly') }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const metricOptions = [
|
const metricOptions = [
|
||||||
{ value: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax', format: 'currency' },
|
{ value: 'revenue', label: 'Revenue', field: 'revenue_incl_tax', format: 'currency' },
|
||||||
{ value: 'visitors', label: t('metrics.visitors'), field: 'visits', format: 'number' },
|
{ value: 'visitors', label: 'Visitors', field: 'visits', format: 'number' },
|
||||||
{ value: 'tickets', label: t('metrics.tickets'), field: 'tickets', format: 'number' },
|
{ value: 'tickets', label: 'Tickets', field: 'tickets', format: 'number' },
|
||||||
{ value: 'avgRevenue', label: t('metrics.avgRevenue'), field: null, format: 'currency' }
|
{ value: 'avgRevenue', label: 'Avg Rev/Visitor', field: null, format: 'currency' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const getMetricValue = useCallback((rows, metric) => {
|
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 captureRateChange = captureRates ? calcChange(captureRates.prev || 0, captureRates.curr || 0) : null;
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{ title: t('metrics.revenue'), prev: prevMetrics.revenue, curr: currMetrics.revenue, change: revenueChange, isCurrency: true },
|
{ title: 'Revenue', prev: prevMetrics.revenue, curr: currMetrics.revenue, change: revenueChange, isCurrency: true },
|
||||||
{ title: t('metrics.visitors'), prev: prevMetrics.visitors, curr: currMetrics.visitors, change: visitorsChange },
|
{ title: 'Visitors', prev: prevMetrics.visitors, curr: currMetrics.visitors, change: visitorsChange },
|
||||||
{ title: t('metrics.tickets'), prev: prevMetrics.tickets, curr: currMetrics.tickets, change: ticketsChange },
|
{ title: 'Tickets', prev: prevMetrics.tickets, curr: currMetrics.tickets, change: ticketsChange },
|
||||||
{ title: t('metrics.avgRevenue'), prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: avgRevChange, isCurrency: true }
|
{ title: 'Avg Rev/Visitor', prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: avgRevChange, isCurrency: true }
|
||||||
];
|
];
|
||||||
if (pilgrimCounts) {
|
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) {
|
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;
|
return cards;
|
||||||
}, [prevMetrics, currMetrics, pilgrimCounts, captureRates, t]);
|
}, [prevMetrics, currMetrics, pilgrimCounts, captureRates]);
|
||||||
|
|
||||||
const handleCardTouchStart = (e) => {
|
const handleCardTouchStart = (e) => {
|
||||||
touchStartCard.current = e.touches[0].clientX;
|
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));
|
const daysDiff = Math.floor((rowDate - start) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
let key;
|
let key;
|
||||||
if (granularity === 'month') {
|
if (granularity === 'week') {
|
||||||
// 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') {
|
|
||||||
key = Math.floor(daysDiff / 7) + 1;
|
key = Math.floor(daysDiff / 7) + 1;
|
||||||
} else {
|
} else {
|
||||||
key = daysDiff + 1; // day number from start
|
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 currGrouped = groupByPeriod(currData, ranges.curr.start, chartMetric, chartGranularity);
|
||||||
const maxKey = Math.max(...Object.keys(prevGrouped).map(Number), ...Object.keys(currGrouped).map(Number), 1);
|
const maxKey = Math.max(...Object.keys(prevGrouped).map(Number), ...Object.keys(currGrouped).map(Number), 1);
|
||||||
|
|
||||||
const labels = Array.from({ length: maxKey }, (_, i) => {
|
const labels = Array.from({ length: maxKey }, (_, i) =>
|
||||||
if (chartGranularity === 'month') {
|
chartGranularity === 'week' ? `W${i + 1}` : `D${i + 1}`
|
||||||
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 prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
|
const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
|
||||||
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
|
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
|
||||||
@@ -436,54 +423,45 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
...baseOptions,
|
...baseOptions,
|
||||||
plugins: {
|
plugins: {
|
||||||
...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 (
|
return (
|
||||||
<div className="comparison" id="comparison-container">
|
<div className="comparison">
|
||||||
<div className="page-title-with-actions">
|
<div className="page-title">
|
||||||
<div className="page-title">
|
<h1>Period Comparison</h1>
|
||||||
<h1>{t('comparison.title')}</h1>
|
<p>Select a period and year — automatically compares with the same period in the previous year</p>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<FilterControls title={t('comparison.selectPeriod')} onReset={resetFilters}>
|
<FilterControls title="Select Period" onReset={resetFilters}>
|
||||||
<FilterControls.Row>
|
<FilterControls.Row>
|
||||||
<FilterControls.Group label={t('comparison.period')}>
|
<FilterControls.Group label="Period">
|
||||||
<select value={preset} onChange={e => setPreset(e.target.value)}>
|
<select value={preset} onChange={e => setPreset(e.target.value)}>
|
||||||
<option value="custom">{t('comparison.custom')}</option>
|
<option value="custom">Custom</option>
|
||||||
<option value="jan">{t('months.january')}</option>
|
<option value="jan">January</option>
|
||||||
<option value="feb">{t('months.february')}</option>
|
<option value="feb">February</option>
|
||||||
<option value="mar">{t('months.march')}</option>
|
<option value="mar">March</option>
|
||||||
<option value="apr">{t('months.april')}</option>
|
<option value="apr">April</option>
|
||||||
<option value="may">{t('months.may')}</option>
|
<option value="may">May</option>
|
||||||
<option value="jun">{t('months.june')}</option>
|
<option value="jun">June</option>
|
||||||
<option value="jul">{t('months.july')}</option>
|
<option value="jul">July</option>
|
||||||
<option value="aug">{t('months.august')}</option>
|
<option value="aug">August</option>
|
||||||
<option value="sep">{t('months.september')}</option>
|
<option value="sep">September</option>
|
||||||
<option value="oct">{t('months.october')}</option>
|
<option value="oct">October</option>
|
||||||
<option value="nov">{t('months.november')}</option>
|
<option value="nov">November</option>
|
||||||
<option value="dec">{t('months.december')}</option>
|
<option value="dec">December</option>
|
||||||
<option value="q1">{t('time.q1')}</option>
|
<option value="q1">Q1</option>
|
||||||
<option value="q2">{t('time.q2')}</option>
|
<option value="q2">Q2</option>
|
||||||
<option value="q3">{t('time.q3')}</option>
|
<option value="q3">Q3</option>
|
||||||
<option value="q4">{t('time.q4')}</option>
|
<option value="q4">Q4</option>
|
||||||
<option value="h1">{t('time.h1')}</option>
|
<option value="h1">H1</option>
|
||||||
<option value="h2">{t('time.h2')}</option>
|
<option value="h2">H2</option>
|
||||||
<option value="full">{t('time.fullYear')}</option>
|
<option value="full">Full Year</option>
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
{preset !== 'custom' && (
|
{preset !== 'custom' && (
|
||||||
<FilterControls.Group label={t('filters.year')}>
|
<FilterControls.Group label="Year">
|
||||||
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
|
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
|
||||||
{availableYears.map(y => (
|
{availableYears.map(y => (
|
||||||
<option key={y} value={y}>{y}</option>
|
<option key={y} value={y}>{y}</option>
|
||||||
@@ -493,55 +471,51 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
)}
|
)}
|
||||||
{preset === 'custom' && (
|
{preset === 'custom' && (
|
||||||
<>
|
<>
|
||||||
<FilterControls.Group label={t('comparison.from')}>
|
<FilterControls.Group label="From">
|
||||||
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('comparison.to')}>
|
<FilterControls.Group label="To">
|
||||||
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
|
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
|
||||||
</FilterControls.Group>
|
</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'})}>
|
<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>)}
|
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.museum')}>
|
<FilterControls.Group label="Museum">
|
||||||
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
|
<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>)}
|
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
</FilterControls.Row>
|
</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>
|
</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 ? (
|
{!hasData ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="📈"
|
icon="📈"
|
||||||
title={t('comparison.noData')}
|
title="No data for this period"
|
||||||
message={t('comparison.noDataMessage')}
|
message="No records found for the selected date range and filters."
|
||||||
action={resetFilters}
|
action={resetFilters}
|
||||||
actionLabel={t('filters.reset')}
|
actionLabel="Reset Filters"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Desktop: Grid layout */}
|
{/* Desktop: Grid layout */}
|
||||||
<div className="comparison-grid desktop-only" id="comparison-metrics">
|
<div className="comparison-grid desktop-only">
|
||||||
{metricCards.map((card, i) => (
|
{metricCards.map((card, i) => (
|
||||||
<MetricCard
|
<MetricCard
|
||||||
key={i}
|
key={i}
|
||||||
@@ -601,48 +575,22 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop: Show both charts */}
|
{/* Desktop: Show both charts */}
|
||||||
<div className="charts-grid desktop-only" id="comparison-charts">
|
<div className="charts-grid desktop-only">
|
||||||
<div className="chart-section">
|
<div className="chart-section">
|
||||||
<ExportableChart
|
<div className="chart-header">
|
||||||
filename="trend-comparison"
|
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} Trend</h2>
|
||||||
title={`${metricOptions.find(m => m.value === chartMetric)?.label} - ${t('comparison.trend')}`}
|
<div className="chart-selectors">
|
||||||
className="chart-container"
|
<div className="toggle-switch">
|
||||||
controls={
|
{granularityOptions.map(opt => (
|
||||||
<>
|
<button
|
||||||
<div className="toggle-switch">
|
key={opt.value}
|
||||||
{granularityOptions.map(opt => (
|
className={chartGranularity === opt.value ? 'active' : ''}
|
||||||
<button
|
onClick={() => setChartGranularity(opt.value)}
|
||||||
key={opt.value}
|
>
|
||||||
className={chartGranularity === opt.value ? 'active' : ''}
|
{opt.label}
|
||||||
onClick={() => setChartGranularity(opt.value)}
|
</button>
|
||||||
>
|
))}
|
||||||
{opt.label}
|
</div>
|
||||||
</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-metric-selector">
|
<div className="chart-metric-selector">
|
||||||
{metricOptions.map(opt => (
|
{metricOptions.map(opt => (
|
||||||
<button
|
<button
|
||||||
@@ -654,8 +602,30 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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} />
|
<Bar data={museumChart} options={chartOptions} />
|
||||||
</ExportableChart>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
@@ -674,7 +644,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-section">
|
<div className="chart-section">
|
||||||
<div className="chart-header">
|
<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">
|
<div className="toggle-switch">
|
||||||
{granularityOptions.map(opt => (
|
{granularityOptions.map(opt => (
|
||||||
<button
|
<button
|
||||||
@@ -708,7 +678,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-section">
|
<div className="chart-section">
|
||||||
<div className="chart-header">
|
<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>
|
||||||
<div className="chart-selectors-inline">
|
<div className="chart-selectors-inline">
|
||||||
<div className="chart-metric-selector">
|
<div className="chart-metric-selector">
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Line, Doughnut, Bar } from 'react-chartjs-2';
|
|||||||
import { Carousel, EmptyState, FilterControls, StatCard } from './shared';
|
import { Carousel, EmptyState, FilterControls, StatCard } from './shared';
|
||||||
import { ExportableChart } from './ChartExport';
|
import { ExportableChart } from './ChartExport';
|
||||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
|
||||||
import {
|
import {
|
||||||
filterData,
|
filterData,
|
||||||
calculateMetrics,
|
calculateMetrics,
|
||||||
@@ -29,8 +28,7 @@ const defaultFilters = {
|
|||||||
|
|
||||||
const filterKeys = ['year', 'district', 'museum', 'quarter'];
|
const filterKeys = ['year', 'district', 'museum', 'quarter'];
|
||||||
|
|
||||||
function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
function Dashboard({ data, showDataLabels }) {
|
||||||
const { t } = useLanguage();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
// Initialize filters from URL or defaults
|
// Initialize filters from URL or defaults
|
||||||
@@ -69,17 +67,17 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
|
|
||||||
// Stat cards for carousel
|
// Stat cards for carousel
|
||||||
const statCards = useMemo(() => [
|
const statCards = useMemo(() => [
|
||||||
{ title: t('metrics.totalRevenue'), value: formatCurrency(metrics.revenue), hasYoy: true },
|
{ title: 'Total Revenue', value: formatCurrency(metrics.revenue), hasYoy: true },
|
||||||
{ title: t('metrics.totalVisitors'), value: formatNumber(metrics.visitors) },
|
{ title: 'Total Visitors', value: formatNumber(metrics.visitors) },
|
||||||
{ title: t('metrics.totalTickets'), value: formatNumber(metrics.tickets) },
|
{ title: 'Total Tickets', value: formatNumber(metrics.tickets) },
|
||||||
{ title: t('metrics.avgRevenue'), value: formatCurrency(metrics.avgRevPerVisitor) }
|
{ title: 'Avg Rev/Visitor', value: formatCurrency(metrics.avgRevPerVisitor) }
|
||||||
], [metrics, t]);
|
], [metrics]);
|
||||||
|
|
||||||
// Chart carousel labels
|
// Chart carousel labels
|
||||||
const chartLabels = useMemo(() => {
|
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);
|
return filters.museum === 'all' ? labels : labels.filter((_, i) => i !== 1 && i !== 2);
|
||||||
}, [filters.museum, t]);
|
}, [filters.museum]);
|
||||||
|
|
||||||
// Dynamic lists from data
|
// Dynamic lists from data
|
||||||
const years = useMemo(() => getUniqueYears(data), [data]);
|
const years = useMemo(() => getUniqueYears(data), [data]);
|
||||||
@@ -250,7 +248,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
color: '#1e293b',
|
color: '#1e293b',
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
font: { size: 10, weight: 600 },
|
font: { size: 9, weight: 600 },
|
||||||
anchor: 'end',
|
anchor: 'end',
|
||||||
align: 'top',
|
align: 'top',
|
||||||
offset: 6
|
offset: 6
|
||||||
@@ -276,7 +274,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
color: '#1e293b',
|
color: '#1e293b',
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
font: { size: 10, weight: 600 },
|
font: { size: 9, weight: 600 },
|
||||||
anchor: 'start',
|
anchor: 'start',
|
||||||
align: 'bottom',
|
align: 'bottom',
|
||||||
offset: 6
|
offset: 6
|
||||||
@@ -316,59 +314,50 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard" id="dashboard-container">
|
<div className="dashboard">
|
||||||
<div className="page-title-with-actions">
|
<div className="page-title">
|
||||||
<div className="page-title">
|
<h1>Dashboard</h1>
|
||||||
<h1>{t('dashboard.title')}</h1>
|
<p>Real-time museum analytics from Google Sheets</p>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<FilterControls title={t('filters.title')} onReset={resetFilters}>
|
<FilterControls title="Filters" onReset={resetFilters}>
|
||||||
<FilterControls.Row>
|
<FilterControls.Row>
|
||||||
<FilterControls.Group label={t('filters.year')}>
|
<FilterControls.Group label="Year">
|
||||||
<select value={filters.year} onChange={e => setFilters({...filters, year: e.target.value})}>
|
<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>)}
|
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</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'})}>
|
<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>)}
|
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.museum')}>
|
<FilterControls.Group label="Museum">
|
||||||
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
|
<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>)}
|
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.quarter')}>
|
<FilterControls.Group label="Quarter">
|
||||||
<select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}>
|
<select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}>
|
||||||
<option value="all">{t('filters.allQuarters')}</option>
|
<option value="all">All Quarters</option>
|
||||||
<option value="1">{t('time.q1')}</option>
|
<option value="1">Q1</option>
|
||||||
<option value="2">{t('time.q2')}</option>
|
<option value="2">Q2</option>
|
||||||
<option value="3">{t('time.q3')}</option>
|
<option value="3">Q3</option>
|
||||||
<option value="4">{t('time.q4')}</option>
|
<option value="4">Q4</option>
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
</FilterControls.Row>
|
</FilterControls.Row>
|
||||||
</FilterControls>
|
</FilterControls>
|
||||||
|
|
||||||
{/* Desktop: Grid */}
|
{/* Desktop: Grid */}
|
||||||
<div className="stats-grid desktop-only" id="dashboard-stats">
|
<div className="stats-grid desktop-only">
|
||||||
<StatCard title={t('metrics.totalRevenue')} value={formatCurrency(metrics.revenue)} change={yoyChange} />
|
<StatCard title="Total Revenue" value={formatCurrency(metrics.revenue)} change={yoyChange} />
|
||||||
<StatCard title={t('metrics.totalVisitors')} value={formatNumber(metrics.visitors)} />
|
<StatCard title="Total Visitors" value={formatNumber(metrics.visitors)} />
|
||||||
<StatCard title={t('metrics.totalTickets')} value={formatNumber(metrics.tickets)} />
|
<StatCard title="Total Tickets" value={formatNumber(metrics.tickets)} />
|
||||||
<StatCard title={t('metrics.avgRevenuePerVisitor')} value={formatCurrency(metrics.avgRevPerVisitor)} />
|
<StatCard title="Avg Revenue/Visitor" value={formatCurrency(metrics.avgRevPerVisitor)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: Stats Carousel */}
|
{/* Mobile: Stats Carousel */}
|
||||||
@@ -392,28 +381,28 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
{!hasData ? (
|
{!hasData ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="📊"
|
icon="📊"
|
||||||
title={t('dashboard.noData')}
|
title="No data found"
|
||||||
message={t('dashboard.noDataMessage')}
|
message="No records match your current filters. Try adjusting your selection."
|
||||||
action={resetFilters}
|
action={resetFilters}
|
||||||
actionLabel={t('filters.reset')}
|
actionLabel="Reset Filters"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="chart-card full-width" style={{marginBottom: '16px'}} id="quarterly-table">
|
<div className="chart-card full-width" style={{marginBottom: '16px'}}>
|
||||||
<h2>{t('dashboard.quarterlyComparison')}</h2>
|
<h2>Quarterly Comparison: 2024 vs 2025</h2>
|
||||||
<div className="table-container">
|
<div className="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t('table.quarter')}</th>
|
<th>Quarter</th>
|
||||||
<th>{t('table.rev2024')}</th>
|
<th>Rev 2024</th>
|
||||||
<th>{t('table.rev2025')}</th>
|
<th>Rev 2025</th>
|
||||||
<th>{t('table.change')}</th>
|
<th>Change</th>
|
||||||
<th>{t('table.visitors2024')}</th>
|
<th>Visitors 2024</th>
|
||||||
<th>{t('table.visitors2025')}</th>
|
<th>Visitors 2025</th>
|
||||||
<th>{t('table.change')}</th>
|
<th>Change</th>
|
||||||
<th>{t('table.capture2024')}</th>
|
<th>Capture 2024</th>
|
||||||
<th>{t('table.capture2025')}</th>
|
<th>Capture 2025</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -440,58 +429,58 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop: Charts Grid */}
|
{/* Desktop: Charts Grid */}
|
||||||
<div className="charts-grid desktop-only" id="dashboard-charts">
|
<div className="charts-grid desktop-only">
|
||||||
<div className="chart-card full-width">
|
<div className="chart-card full-width">
|
||||||
<ExportableChart
|
<h2>Revenue Trends</h2>
|
||||||
filename="revenue-trend"
|
<div className="toggle-switch toggle-corner">
|
||||||
title={t('dashboard.revenueTrends')}
|
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>Daily</button>
|
||||||
className="chart-container"
|
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>Weekly</button>
|
||||||
controls={
|
</div>
|
||||||
<div className="toggle-switch">
|
<ExportableChart filename="revenue-trend" className="chart-container">
|
||||||
<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, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
|
<Line data={trendData} options={{...baseOptions, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
|
||||||
</ExportableChart>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filters.museum === 'all' && (
|
{filters.museum === 'all' && (
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<ExportableChart filename="visitors-by-museum" title={t('dashboard.visitorsByMuseum')} className="chart-container">
|
<h2>Visitors by Museum</h2>
|
||||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 16, font: {size: 13}}}}}} />
|
<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>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filters.museum === 'all' && (
|
{filters.museum === 'all' && (
|
||||||
<div className="chart-card half-width">
|
<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} />
|
<Bar data={museumData.revenue} options={baseOptions} />
|
||||||
</ExportableChart>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<ExportableChart filename="quarterly-yoy" title={t('dashboard.quarterlyRevenue')} className="chart-container">
|
<h2>Quarterly Revenue (YoY)</h2>
|
||||||
<Bar data={quarterlyYoYData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 13}}}}}} />
|
<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>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-card half-width">
|
<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'}} />
|
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||||
</ExportableChart>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-card full-width">
|
<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={{
|
<Line data={captureRateData} options={{
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
plugins: {
|
plugins: {
|
||||||
...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: {
|
tooltip: {
|
||||||
...baseOptions.plugins.tooltip,
|
...baseOptions.plugins.tooltip,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
@@ -510,17 +499,17 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
type: 'linear',
|
type: 'linear',
|
||||||
position: 'left',
|
position: 'left',
|
||||||
grid: { color: chartColors.grid },
|
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 },
|
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: {
|
y1: {
|
||||||
type: 'linear',
|
type: 'linear',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
grid: { drawOnChartArea: false },
|
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 },
|
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="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h2>{t('dashboard.revenueTrends')}</h2>
|
<h2>Revenue Trends</h2>
|
||||||
<div className="toggle-switch toggle-corner">
|
<div className="toggle-switch toggle-corner">
|
||||||
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>{t('time.daily')}</button>
|
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>Daily</button>
|
||||||
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button>
|
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>Weekly</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-container">
|
<div className="chart-container">
|
||||||
<Line data={trendData} options={{...baseOptions, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} />
|
<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' && (
|
{filters.museum === 'all' && (
|
||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h2>{t('dashboard.visitorsByMuseum')}</h2>
|
<h2>Visitors by Museum</h2>
|
||||||
<div className="chart-container">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -563,7 +552,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
{filters.museum === 'all' && (
|
{filters.museum === 'all' && (
|
||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h2>{t('dashboard.revenueByMuseum')}</h2>
|
<h2>Revenue by Museum</h2>
|
||||||
<div className="chart-container">
|
<div className="chart-container">
|
||||||
<Bar data={museumData.revenue} options={baseOptions} />
|
<Bar data={museumData.revenue} options={baseOptions} />
|
||||||
</div>
|
</div>
|
||||||
@@ -573,16 +562,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
|
|
||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h2>{t('dashboard.quarterlyRevenue')}</h2>
|
<h2>Quarterly Revenue (YoY)</h2>
|
||||||
<div className="chart-container">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h2>{t('dashboard.districtPerformance')}</h2>
|
<h2>District Performance</h2>
|
||||||
<div className="chart-container">
|
<div className="chart-container">
|
||||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||||
</div>
|
</div>
|
||||||
@@ -591,13 +580,13 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
|
|
||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h2>{t('dashboard.captureRateChart')}</h2>
|
<h2>Capture Rate vs Umrah Pilgrims</h2>
|
||||||
<div className="chart-container">
|
<div className="chart-container">
|
||||||
<Line data={captureRateData} options={{
|
<Line data={captureRateData} options={{
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
plugins: {
|
plugins: {
|
||||||
...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: {
|
tooltip: {
|
||||||
...baseOptions.plugins.tooltip,
|
...baseOptions.plugins.tooltip,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
@@ -616,14 +605,14 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
|||||||
type: 'linear',
|
type: 'linear',
|
||||||
position: 'left',
|
position: 'left',
|
||||||
grid: { color: chartColors.grid },
|
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 }
|
border: { display: false }
|
||||||
},
|
},
|
||||||
y1: {
|
y1: {
|
||||||
type: 'linear',
|
type: 'linear',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
grid: { drawOnChartArea: false },
|
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 }
|
border: { display: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { Line, Bar } from 'react-chartjs-2';
|
import { Line, Bar } from 'react-chartjs-2';
|
||||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
|
||||||
import {
|
import {
|
||||||
filterDataByDateRange,
|
filterDataByDateRange,
|
||||||
calculateMetrics,
|
calculateMetrics,
|
||||||
@@ -13,21 +12,20 @@ import {
|
|||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
|
|
||||||
function Slides({ data }) {
|
const CHART_TYPES = [
|
||||||
const { t } = useLanguage();
|
{ id: 'trend', label: 'Revenue Trend', icon: '📈' },
|
||||||
|
{ id: 'museum-bar', label: 'By Museum', icon: '📊' },
|
||||||
const CHART_TYPES = useMemo(() => [
|
{ id: 'kpi-cards', label: 'KPI Summary', icon: '🎯' },
|
||||||
{ id: 'trend', label: t('slides.revenueTrend'), icon: '📈' },
|
{ id: 'comparison', label: 'YoY Comparison', 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 METRICS = useMemo(() => [
|
const METRICS = [
|
||||||
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax' },
|
{ id: 'revenue', label: 'Revenue', field: 'revenue_incl_tax' },
|
||||||
{ id: 'visitors', label: t('metrics.visitors'), field: 'visits' },
|
{ id: 'visitors', label: 'Visitors', field: 'visits' },
|
||||||
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
|
{ id: 'tickets', label: 'Tickets', field: 'tickets' }
|
||||||
], [t]);
|
];
|
||||||
|
|
||||||
|
function Slides({ data }) {
|
||||||
const [slides, setSlides] = useState([]);
|
const [slides, setSlides] = useState([]);
|
||||||
const [editingSlide, setEditingSlide] = useState(null);
|
const [editingSlide, setEditingSlide] = useState(null);
|
||||||
const [previewMode, setPreviewMode] = useState(false);
|
const [previewMode, setPreviewMode] = useState(false);
|
||||||
@@ -173,7 +171,6 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
|||||||
currentSlide={currentPreviewSlide}
|
currentSlide={currentPreviewSlide}
|
||||||
setCurrentSlide={setCurrentPreviewSlide}
|
setCurrentSlide={setCurrentPreviewSlide}
|
||||||
onExit={() => setPreviewMode(false)}
|
onExit={() => setPreviewMode(false)}
|
||||||
metrics={METRICS}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -181,8 +178,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
|||||||
return (
|
return (
|
||||||
<div className="slides-builder">
|
<div className="slides-builder">
|
||||||
<div className="page-title">
|
<div className="page-title">
|
||||||
<h1>{t('slides.title')}</h1>
|
<h1>Presentation Builder</h1>
|
||||||
<p>{t('slides.subtitle')}</p>
|
<p>Create slides with charts and export as HTML or PDF</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="slides-toolbar">
|
<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">
|
<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"/>
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('slides.addSlide')}
|
Add Slide
|
||||||
</button>
|
</button>
|
||||||
{slides.length > 0 && (
|
{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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
{t('slides.preview')}
|
Preview
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-secondary" onClick={exportAsHTML}>
|
<button className="btn-secondary" onClick={exportAsHTML}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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"/>
|
<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>
|
</svg>
|
||||||
{t('slides.exportHtml')}
|
Export HTML
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -212,11 +209,11 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
|||||||
|
|
||||||
<div className="slides-workspace">
|
<div className="slides-workspace">
|
||||||
<div className="slides-list">
|
<div className="slides-list">
|
||||||
<h3>{t('slides.slidesCount')} ({slides.length})</h3>
|
<h3>Slides ({slides.length})</h3>
|
||||||
{slides.length === 0 ? (
|
{slides.length === 0 ? (
|
||||||
<div className="empty-slides">
|
<div className="empty-slides">
|
||||||
<p>{t('slides.noSlides')}</p>
|
<p>No slides yet</p>
|
||||||
<button onClick={addSlide}>{t('slides.addFirst')}</button>
|
<button onClick={addSlide}>Add your first slide</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="slides-thumbnails">
|
<div className="slides-thumbnails">
|
||||||
@@ -248,8 +245,6 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
|||||||
districts={districts}
|
districts={districts}
|
||||||
districtMuseumMap={districtMuseumMap}
|
districtMuseumMap={districtMuseumMap}
|
||||||
data={data}
|
data={data}
|
||||||
chartTypes={CHART_TYPES}
|
|
||||||
metrics={METRICS}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -257,8 +252,7 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, chartTypes, metrics }) {
|
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data }) {
|
||||||
const { t } = useLanguage();
|
|
||||||
const availableMuseums = useMemo(() =>
|
const availableMuseums = useMemo(() =>
|
||||||
getMuseumsForDistrict(districtMuseumMap, slide.district),
|
getMuseumsForDistrict(districtMuseumMap, slide.district),
|
||||||
[districtMuseumMap, slide.district]
|
[districtMuseumMap, slide.district]
|
||||||
@@ -267,19 +261,19 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
|||||||
return (
|
return (
|
||||||
<div className="slide-editor">
|
<div className="slide-editor">
|
||||||
<div className="editor-section">
|
<div className="editor-section">
|
||||||
<label>{t('slides.slideTitle')}</label>
|
<label>Slide Title</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={slide.title}
|
value={slide.title}
|
||||||
onChange={e => onUpdate({ title: e.target.value })}
|
onChange={e => onUpdate({ title: e.target.value })}
|
||||||
placeholder={t('slides.slideTitle')}
|
placeholder="Enter slide title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="editor-section">
|
<div className="editor-section">
|
||||||
<label>{t('slides.chartType')}</label>
|
<label>Chart Type</label>
|
||||||
<div className="chart-type-grid">
|
<div className="chart-type-grid">
|
||||||
{chartTypes.map(type => (
|
{CHART_TYPES.map(type => (
|
||||||
<button
|
<button
|
||||||
key={type.id}
|
key={type.id}
|
||||||
className={`chart-type-btn ${slide.chartType === type.id ? 'active' : ''}`}
|
className={`chart-type-btn ${slide.chartType === type.id ? 'active' : ''}`}
|
||||||
@@ -293,35 +287,35 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="editor-section">
|
<div className="editor-section">
|
||||||
<label>{t('slides.metric')}</label>
|
<label>Metric</label>
|
||||||
<select value={slide.metric} onChange={e => onUpdate({ metric: e.target.value })}>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="editor-row">
|
<div className="editor-row">
|
||||||
<div className="editor-section">
|
<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 })} />
|
<input type="date" value={slide.startDate} onChange={e => onUpdate({ startDate: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-section">
|
<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 })} />
|
<input type="date" value={slide.endDate} onChange={e => onUpdate({ endDate: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="editor-row">
|
<div className="editor-row">
|
||||||
<div className="editor-section">
|
<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' })}>
|
<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>)}
|
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-section">
|
<div className="editor-section">
|
||||||
<label>{t('filters.museum')}</label>
|
<label>Museum</label>
|
||||||
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
|
<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>)}
|
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,28 +329,20 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
|||||||
checked={slide.showComparison}
|
checked={slide.showComparison}
|
||||||
onChange={e => onUpdate({ showComparison: e.target.checked })}
|
onChange={e => onUpdate({ showComparison: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
{t('slides.showYoY')}
|
Show Year-over-Year Comparison
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="slide-preview-box">
|
<div className="slide-preview-box">
|
||||||
<h4>{t('slides.preview')}</h4>
|
<h4>Preview</h4>
|
||||||
<SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} metrics={metrics} />
|
<SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static field mapping for charts (Chart.js labels don't need i18n)
|
function SlidePreview({ slide, data, districts, districtMuseumMap }) {
|
||||||
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();
|
|
||||||
const filteredData = useMemo(() =>
|
const filteredData = useMemo(() =>
|
||||||
filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||||
district: slide.district,
|
district: slide.district,
|
||||||
@@ -365,7 +351,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
|
|||||||
[data, slide.startDate, slide.endDate, slide.district, slide.museum]
|
[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 baseOptions = useMemo(() => createBaseOptions(false), []);
|
||||||
|
|
||||||
const getMetricValue = useCallback((rows, metric) => {
|
const getMetricValue = useCallback((rows, metric) => {
|
||||||
@@ -383,11 +369,10 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sortedDates = Object.keys(grouped).sort();
|
const sortedDates = Object.keys(grouped).sort();
|
||||||
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
|
|
||||||
return {
|
return {
|
||||||
labels: sortedDates.map(d => d.substring(5)),
|
labels: sortedDates.map(d => d.substring(5)),
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: metricLabel,
|
label: METRICS.find(m => m.id === slide.metric)?.label,
|
||||||
data: sortedDates.map(d => getMetricValue(grouped[d], slide.metric)),
|
data: sortedDates.map(d => getMetricValue(grouped[d], slide.metric)),
|
||||||
borderColor: chartColors.primary,
|
borderColor: chartColors.primary,
|
||||||
backgroundColor: chartColors.primary + '20',
|
backgroundColor: chartColors.primary + '20',
|
||||||
@@ -395,7 +380,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
|
|||||||
tension: 0.4
|
tension: 0.4
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
}, [filteredData, slide.metric, getMetricValue, metrics]);
|
}, [filteredData, slide.metric, getMetricValue]);
|
||||||
|
|
||||||
const museumData = useMemo(() => {
|
const museumData = useMemo(() => {
|
||||||
const byMuseum = {};
|
const byMuseum = {};
|
||||||
@@ -406,32 +391,31 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const museums = Object.keys(byMuseum).sort();
|
const museums = Object.keys(byMuseum).sort();
|
||||||
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
|
|
||||||
return {
|
return {
|
||||||
labels: museums,
|
labels: museums,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: metricLabel,
|
label: METRICS.find(m => m.id === slide.metric)?.label,
|
||||||
data: museums.map(m => getMetricValue(byMuseum[m], slide.metric)),
|
data: museums.map(m => getMetricValue(byMuseum[m], slide.metric)),
|
||||||
backgroundColor: chartColors.primary,
|
backgroundColor: chartColors.primary,
|
||||||
borderRadius: 6
|
borderRadius: 6
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
}, [filteredData, slide.metric, getMetricValue, metrics]);
|
}, [filteredData, slide.metric, getMetricValue]);
|
||||||
|
|
||||||
if (slide.chartType === 'kpi-cards') {
|
if (slide.chartType === 'kpi-cards') {
|
||||||
return (
|
return (
|
||||||
<div className="preview-kpis">
|
<div className="preview-kpis">
|
||||||
<div className="preview-kpi">
|
<div className="preview-kpi">
|
||||||
<div className="kpi-value">{formatCompactCurrency(metricsData.revenue)}</div>
|
<div className="kpi-value">{formatCompactCurrency(metrics.revenue)}</div>
|
||||||
<div className="kpi-label">{t('metrics.revenue')}</div>
|
<div className="kpi-label">Revenue</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="preview-kpi">
|
<div className="preview-kpi">
|
||||||
<div className="kpi-value">{formatCompact(metricsData.visitors)}</div>
|
<div className="kpi-value">{formatCompact(metrics.visitors)}</div>
|
||||||
<div className="kpi-label">{t('metrics.visitors')}</div>
|
<div className="kpi-label">Visitors</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="preview-kpi">
|
<div className="preview-kpi">
|
||||||
<div className="kpi-value">{formatCompact(metricsData.tickets)}</div>
|
<div className="kpi-value">{formatCompact(metrics.tickets)}</div>
|
||||||
<div className="kpi-label">{t('metrics.tickets')}</div>
|
<div className="kpi-label">Tickets</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -452,8 +436,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide, setCurrentSlide, onExit, metrics }) {
|
function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide, setCurrentSlide, onExit }) {
|
||||||
const { t } = useLanguage();
|
|
||||||
const handleKeyDown = useCallback((e) => {
|
const handleKeyDown = useCallback((e) => {
|
||||||
if (e.key === 'ArrowRight' || e.key === ' ') {
|
if (e.key === 'ArrowRight' || e.key === ' ') {
|
||||||
setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1));
|
setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1));
|
||||||
@@ -476,7 +459,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
|
|||||||
<div className="preview-slide">
|
<div className="preview-slide">
|
||||||
<h1 className="preview-title">{slide?.title}</h1>
|
<h1 className="preview-title">{slide?.title}</h1>
|
||||||
<div className="preview-content">
|
<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>
|
||||||
<div className="preview-footer">
|
<div className="preview-footer">
|
||||||
<span>{currentSlide + 1} / {slides.length}</span>
|
<span>{currentSlide + 1} / {slides.length}</span>
|
||||||
@@ -485,7 +468,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
|
|||||||
<div className="preview-controls">
|
<div className="preview-controls">
|
||||||
<button onClick={() => setCurrentSlide(prev => Math.max(prev - 1, 0))} disabled={currentSlide === 0}>←</button>
|
<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={() => 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef, useCallback, useState } from 'react';
|
import React, { useRef, useCallback } from 'react';
|
||||||
|
|
||||||
function Carousel({
|
function Carousel({
|
||||||
children,
|
children,
|
||||||
@@ -8,57 +8,24 @@ function Carousel({
|
|||||||
showLabels = true,
|
showLabels = true,
|
||||||
className = ''
|
className = ''
|
||||||
}) {
|
}) {
|
||||||
const touchStartX = useRef(null);
|
const touchStart = useRef(null);
|
||||||
const touchStartY = useRef(null);
|
|
||||||
const trackRef = useRef(null);
|
|
||||||
const [dragOffset, setDragOffset] = useState(0);
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const itemCount = React.Children.count(children);
|
const itemCount = React.Children.count(children);
|
||||||
|
|
||||||
const SWIPE_THRESHOLD = 50;
|
|
||||||
|
|
||||||
const handleTouchStart = useCallback((e) => {
|
const handleTouchStart = useCallback((e) => {
|
||||||
touchStartX.current = e.touches[0].clientX;
|
touchStart.current = e.touches[0].clientX;
|
||||||
touchStartY.current = e.touches[0].clientY;
|
|
||||||
setIsDragging(true);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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) => {
|
const handleTouchEnd = useCallback((e) => {
|
||||||
if (!touchStartX.current) return;
|
if (!touchStart.current) return;
|
||||||
|
const diff = touchStart.current - e.changedTouches[0].clientX;
|
||||||
const diff = touchStartX.current - e.changedTouches[0].clientX;
|
if (Math.abs(diff) > 50) {
|
||||||
|
|
||||||
if (Math.abs(diff) > SWIPE_THRESHOLD) {
|
|
||||||
if (diff > 0 && activeIndex < itemCount - 1) {
|
if (diff > 0 && activeIndex < itemCount - 1) {
|
||||||
setActiveIndex(activeIndex + 1);
|
setActiveIndex(activeIndex + 1);
|
||||||
} else if (diff < 0 && activeIndex > 0) {
|
} else if (diff < 0 && activeIndex > 0) {
|
||||||
setActiveIndex(activeIndex - 1);
|
setActiveIndex(activeIndex - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
touchStart.current = null;
|
||||||
touchStartX.current = null;
|
|
||||||
touchStartY.current = null;
|
|
||||||
setDragOffset(0);
|
|
||||||
setIsDragging(false);
|
|
||||||
}, [activeIndex, setActiveIndex, itemCount]);
|
}, [activeIndex, setActiveIndex, itemCount]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e) => {
|
const handleKeyDown = useCallback((e) => {
|
||||||
@@ -69,41 +36,18 @@ function Carousel({
|
|||||||
}
|
}
|
||||||
}, [activeIndex, setActiveIndex, itemCount]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div className={`carousel ${className}`} onKeyDown={handleKeyDown} tabIndex={0}>
|
||||||
className={`carousel ${className}`}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
tabIndex={0}
|
|
||||||
role="region"
|
|
||||||
aria-label="Carousel"
|
|
||||||
>
|
|
||||||
<div className="carousel-container">
|
<div className="carousel-container">
|
||||||
<div className="carousel-viewport">
|
<div className="carousel-viewport">
|
||||||
<div
|
<div
|
||||||
ref={trackRef}
|
|
||||||
className="carousel-track"
|
className="carousel-track"
|
||||||
style={{
|
style={{ transform: `translateX(-${activeIndex * 100}%)` }}
|
||||||
transform: `translateX(${transform}%)`,
|
|
||||||
transition: isDragging ? 'none' : undefined
|
|
||||||
}}
|
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
{React.Children.map(children, (child, i) => (
|
{React.Children.map(children, (child, i) => (
|
||||||
<div
|
<div className="carousel-slide" key={i}>
|
||||||
className="carousel-slide"
|
|
||||||
key={i}
|
|
||||||
role="tabpanel"
|
|
||||||
aria-hidden={activeIndex !== i}
|
|
||||||
>
|
|
||||||
{child}
|
{child}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const chartColors = {
|
|||||||
export const createDataLabelConfig = (showDataLabels) => ({
|
export const createDataLabelConfig = (showDataLabels) => ({
|
||||||
display: showDataLabels,
|
display: showDataLabels,
|
||||||
color: '#1e293b',
|
color: '#1e293b',
|
||||||
font: { size: 11, weight: 600 },
|
font: { size: 10, weight: 600 },
|
||||||
anchor: 'end',
|
anchor: 'end',
|
||||||
align: 'end',
|
align: 'end',
|
||||||
offset: 4,
|
offset: 4,
|
||||||
@@ -74,19 +74,19 @@ export const createBaseOptions = (showDataLabels) => ({
|
|||||||
backgroundColor: '#1e293b',
|
backgroundColor: '#1e293b',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
cornerRadius: 8,
|
cornerRadius: 8,
|
||||||
titleFont: { size: 14 },
|
titleFont: { size: 12 },
|
||||||
bodyFont: { size: 13 }
|
bodyFont: { size: 11 }
|
||||||
},
|
},
|
||||||
datalabels: createDataLabelConfig(showDataLabels)
|
datalabels: createDataLabelConfig(showDataLabels)
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
grid: { display: false },
|
grid: { display: false },
|
||||||
ticks: { font: { size: 12 }, color: '#94a3b8' }
|
ticks: { font: { size: 10 }, color: '#94a3b8' }
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
grid: { color: chartColors.grid },
|
grid: { color: chartColors.grid },
|
||||||
ticks: { font: { size: 12 }, color: '#94a3b8' },
|
ticks: { font: { size: 10 }, color: '#94a3b8' },
|
||||||
border: { display: false }
|
border: { display: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { LanguageProvider } from './contexts/LanguageContext';
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<LanguageProvider>
|
<App />
|
||||||
<App />
|
|
||||||
</LanguageProvider>
|
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user