refactor: rename Demo components to canonical names and purge dead code
Deploy HiHala Dashboard / deploy (push) Successful in 9s

- DashboardDemo → Dashboard, PeriodSelectorDemo → Comparison (these were the real active routes)
- Delete old Dashboard, Comparison, NavDemo, Slides, ChartExport (replaced / unused)
- Delete 8 unused shared components: DateRangePicker, PeriodPicker, FilterControls, MultiSelect, Carousel, ChartCard, EmptyState, StatCard, ToggleSwitch
- Fix date picker stay-open behavior: selections now update draft state only; Apply/Cancel buttons commit or discard
- shared/index.tsx now only exports LoadingSkeleton

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-04-23 17:07:39 +03:00
parent c8c3465233
commit 36df0065ed
20 changed files with 1398 additions and 5539 deletions
+1
View File
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { fetchSales, isConfigured } from '../services/erpClient';
import { etl } from '../config';
const router = Router();
+45
View File
@@ -116,6 +116,16 @@
box-sizing: border-box;
}
/* Prevent horizontal overflow at the root level */
html, body {
overflow-x: hidden;
}
main {
min-width: 0;
overflow-x: hidden;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
@@ -1089,6 +1099,41 @@ table tbody tr:hover {
flex-shrink: 0;
}
.drp-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.drp-cancel, .drp-apply {
padding: 7px 16px;
border-radius: 7px;
font-size: 0.825rem;
font-weight: 600;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.drp-cancel {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
}
.drp-cancel:hover {
background: var(--bg-secondary);
}
.drp-apply {
background: var(--accent);
border: 1px solid transparent;
color: #fff;
}
.drp-apply:hover {
opacity: 0.88;
}
/* Right-align panel when near viewport edge */
@media (max-width: 480px) {
.drp-panel {
+4 -4
View File
@@ -2,8 +2,8 @@ import React, { useState, useEffect, useCallback, useMemo, ReactNode, lazy, Susp
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
const Settings = lazy(() => import('./components/Settings'));
const PeriodSelectorDemo = lazy(() => import('./components/PeriodSelectorDemo'));
const DashboardDemo = lazy(() => import('./components/DashboardDemo'));
const Comparison = lazy(() => import('./components/Comparison'));
const Dashboard = lazy(() => import('./components/Dashboard'));
import Login from './components/Login';
import LoadingSkeleton from './components/shared/LoadingSkeleton';
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService';
@@ -301,8 +301,8 @@ function App() {
<main>
<Suspense fallback={<LoadingSkeleton />}>
<Routes>
<Route path="/" element={<DashboardDemo data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
<Route path="/comparison" element={<PeriodSelectorDemo data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
<Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
</Routes>
</Suspense>
-184
View File
@@ -1,184 +0,0 @@
import React, { useRef, useState, ReactNode } from 'react';
import JSZip from 'jszip';
interface ExportableChartProps {
children: ReactNode;
filename?: string;
title?: string;
className?: string;
controls?: ReactNode;
}
// Wrapper component that adds PNG export to any chart
export function ExportableChart({
children,
filename = 'chart',
title = '',
className = '',
controls = null
}: ExportableChartProps) {
const chartRef = useRef<HTMLDivElement>(null);
const exportAsPNG = () => {
const chartContainer = chartRef.current;
if (!chartContainer) return;
const canvas = chartContainer.querySelector('canvas');
if (!canvas) return;
// Create a new canvas with white background and title
const exportCanvas = document.createElement('canvas');
const ctx = exportCanvas.getContext('2d');
if (!ctx) return;
// Set dimensions with padding and title space
const padding = 24;
const titleHeight = title ? 48 : 0;
exportCanvas.width = canvas.width + (padding * 2);
exportCanvas.height = canvas.height + (padding * 2) + titleHeight;
// Fill white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
// Draw title if provided (left-aligned, matching on-screen style)
if (title) {
ctx.fillStyle = '#1e293b';
ctx.font = '600 20px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(title, padding, padding + 24);
}
// Draw the chart
ctx.drawImage(canvas, padding, padding + titleHeight);
// Export
const link = document.createElement('a');
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.png`;
link.href = exportCanvas.toDataURL('image/png', 1.0);
link.click();
};
return (
<div className="exportable-chart-wrapper">
{/* Download button - positioned absolutely in corner */}
<button
className="chart-export-btn visible"
onClick={exportAsPNG}
title="Download as PNG"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
{title && (
<div className="chart-header-with-export">
<h2>{title}</h2>
{controls && <div className="chart-header-actions">{controls}</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>
);
}
// Utility function to export all charts from a container as a ZIP
export async function exportAllCharts(containerSelector: string, zipFilename: string = 'charts'): Promise<void> {
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');
if (!ctx) continue;
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);
}
interface ExportAllButtonProps {
containerSelector: string;
zipFilename?: string;
label: string;
loadingLabel: string;
}
// Button component for exporting all charts
export function ExportAllButton({ containerSelector, zipFilename = 'charts', label, loadingLabel }: ExportAllButtonProps) {
const [exporting, setExporting] = 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;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-713
View File
@@ -1,713 +0,0 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Line, Bar, Pie } from 'react-chartjs-2';
import {
filterDataByDateRange, calculateMetrics, formatCurrency, formatNumber,
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
groupByMuseum, groupByChannel, groupByDistrict,
umrahData,
} from '../services/dataService';
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
import type { MuseumRecord, Season } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
interface Props {
data: MuseumRecord[];
seasons: Season[];
includeVAT: boolean;
setIncludeVAT: (v: boolean) => void;
allowedMuseums: string[] | null;
allowedChannels: string[] | null;
lang?: 'en' | 'ar';
}
// ─── language config ──────────────────────────────────────────────
interface LC {
dir: 'ltr' | 'rtl';
fontImport: string;
bodyFont: string;
displayFont: string;
monoFont: string;
monthFull: string[];
monthShort: string[];
periods: Record<string, string>;
fullYearLabel: (y: number) => string;
dateRangeSep: string;
backLink: string;
backTo: string;
pageTitle: string;
pageSub: string;
changePeriod: string;
close: string;
filter: string;
allDistricts: string; allChannels: string; allMuseums: string;
countDistricts: (n: number) => string;
countChannels: (n: number) => string;
countMuseums: (n: number) => string;
reset: string;
exclVAT: string; inclVAT: string;
keyMetrics: string;
revenue: string; visitors: string; tickets: string; avgRev: string;
pilgrims: string; captureRate: string;
charts: string;
trendTitle: string; museumTitle: string; channelTitle: string; districtTitle: string;
daily: string; weekly: string; monthly: string;
newLabel: string;
clearSel: string;
monthSection: string; periodSection: string;
from: string; to: string;
vsLabel: string;
barLabel: string; pieLabel: string;
absLabel: string; pctLabel: string;
}
const EN: LC = {
dir: 'ltr',
fontImport: `@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&display=swap');`,
bodyFont: "'Outfit', sans-serif",
displayFont: "'DM Serif Display', serif",
monoFont: "ui-monospace, 'Cascadia Code', monospace",
monthFull: ['January','February','March','April','May','June','July','August','September','October','November','December'],
monthShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
periods: { q1:'Q1', q2:'Q2', q3:'Q3', q4:'Q4', h1:'H1', h2:'H2', full:'Full Year' },
fullYearLabel: (y) => String(y),
dateRangeSep: '→',
backLink: 'Back to Dashboard', backTo: '/',
pageTitle: 'Overview', pageSub: 'Museum performance at a glance.',
changePeriod: 'Change period', close: 'Close',
filter: 'Filter',
allDistricts: 'All districts', allChannels: 'All channels', allMuseums: 'All museums',
countDistricts: (n) => `${n} districts`,
countChannels: (n) => `${n} channels`,
countMuseums: (n) => `${n} museums`,
reset: 'Reset', exclVAT: 'Excl. VAT', inclVAT: 'Incl. VAT',
keyMetrics: 'Key Metrics',
revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets',
avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %',
charts: 'Charts',
trendTitle: 'Trend over time', museumTitle: 'By museum',
channelTitle: 'By channel', districtTitle: 'By district',
daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
newLabel: 'New', clearSel: 'Clear selection',
monthSection: 'Month', periodSection: 'Quarter · Half · Year',
from: 'From', to: 'To', vsLabel: 'vs',
barLabel: 'Bar', pieLabel: 'Pie', absLabel: '#', pctLabel: '%',
};
const AR: LC = {
dir: 'rtl',
fontImport: `@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');`,
bodyFont: "'IBM Plex Sans Arabic', sans-serif",
displayFont: "'IBM Plex Sans Arabic', sans-serif",
monoFont: "'IBM Plex Sans Arabic', sans-serif",
monthFull: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'],
monthShort: ['ينا','فبر','مار','أبر','ماي','يون','يول','أغس','سبت','أكت','نوف','ديس'],
periods: { q1:'ر١', q2:'ر٢', q3:'ر٣', q4:'ر٤', h1:'ن١', h2:'ن٢', full:'السنة' },
fullYearLabel: (y) => `${y} كاملاً`,
dateRangeSep: '',
backLink: 'العودة إلى لوحة التحكم', backTo: '/ar',
pageTitle: 'نظرة عامة', pageSub: 'أداء المتاحف في لمحة.',
changePeriod: 'تغيير الفترة', close: 'إغلاق',
filter: 'تصفية',
allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف',
countDistricts: (n) => `${n} مناطق`,
countChannels: (n) => `${n} قنوات`,
countMuseums: (n) => `${n} متاحف`,
reset: 'إعادة ضبط', exclVAT: 'بدون ضريبة', inclVAT: 'مع ضريبة',
keyMetrics: 'المؤشرات الرئيسية',
revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر',
avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %',
charts: 'المخططات',
trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف',
channelTitle: 'حسب القناة', districtTitle: 'حسب المنطقة',
daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري',
newLabel: 'جديد', clearSel: 'مسح التحديد',
monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة',
from: 'من', to: 'إلى', vsLabel: 'مقابل',
barLabel: 'أعمدة', pieLabel: 'دائرة', absLabel: '#', pctLabel: '%',
};
// ─── date helpers ─────────────────────────────────────────────────
const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
function isLeap(y: number) { return (y%4===0 && y%100!==0) || y%400===0; }
function makePresets(y: number): Record<string, { start: string; end: string }> {
const feb = isLeap(y) ? 29 : 28;
return {
jan:{start:`${y}-01-01`,end:`${y}-01-31`}, feb:{start:`${y}-02-01`,end:`${y}-02-${String(feb).padStart(2,'0')}`},
mar:{start:`${y}-03-01`,end:`${y}-03-31`}, apr:{start:`${y}-04-01`,end:`${y}-04-30`},
may:{start:`${y}-05-01`,end:`${y}-05-31`}, jun:{start:`${y}-06-01`,end:`${y}-06-30`},
jul:{start:`${y}-07-01`,end:`${y}-07-31`}, aug:{start:`${y}-08-01`,end:`${y}-08-31`},
sep:{start:`${y}-09-01`,end:`${y}-09-30`}, oct:{start:`${y}-10-01`,end:`${y}-10-31`},
nov:{start:`${y}-11-01`,end:`${y}-11-30`}, dec:{start:`${y}-12-01`,end:`${y}-12-31`},
q1:{start:`${y}-01-01`,end:`${y}-03-31`}, q2:{start:`${y}-04-01`,end:`${y}-06-30`},
q3:{start:`${y}-07-01`,end:`${y}-09-30`}, q4:{start:`${y}-10-01`,end:`${y}-12-31`},
h1:{start:`${y}-01-01`,end:`${y}-06-30`}, h2:{start:`${y}-07-01`,end:`${y}-12-31`},
full:{start:`${y}-01-01`,end:`${y}-12-31`},
};
}
function guessPreset(start: string, end: string) {
const year = parseInt(start.slice(0,4));
const presets = makePresets(year);
for (const [key, r] of Object.entries(presets)) {
if (r.start === start && r.end === end) return { key, year };
}
return null;
}
function periodNameL(start: string, end: string, L: LC): string {
const year = parseInt(start.slice(0,4));
const g = guessPreset(start, end);
if (!g) {
const fmt = (d: string) => { const [,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]}`; };
const ey = parseInt(end.slice(0,4));
return year === ey ? `${fmt(start)} ${fmt(end)} ${year}` : `${fmt(start)} ${year} ${fmt(end)} ${ey}`;
}
const mi = MONTH_KEYS.indexOf(g.key);
if (mi >= 0) return `${L.monthFull[mi]} ${g.year}`;
if (g.key === 'full') return L.fullYearLabel(g.year);
return `${L.periods[g.key] ?? g.key.toUpperCase()} ${g.year}`;
}
function dateRangeTextL(start: string, end: string, L: LC): string {
const fmt = (d: string) => { const [y,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]} ${y}`; };
return `${fmt(start)} ${L.dateRangeSep} ${fmt(end)}`;
}
function currentMonth() {
const now = new Date(); const y = now.getFullYear(), m = now.getMonth()+1;
const p = (n: number) => String(n).padStart(2,'0');
return { start: `${y}-${p(m)}-01`, end: `${y}-${p(m)}-${p(new Date(y, m, 0).getDate())}` };
}
function shiftYear(s: string) { return s.replace(/^(\d{4})/, (_,y) => String(parseInt(y)-1)); }
// ─── inline picker ────────────────────────────────────────────────
function InlinePicker({ start, end, onChange, availableYears, L }: {
start: string; end: string; onChange: (s: string, e: string) => void;
availableYears: number[]; L: LC;
}) {
const g = guessPreset(start, end);
const [year, setYear] = useState(g?.year ?? parseInt(start.slice(0,4)));
const [active, setActive] = useState<string|null>(g?.key ?? null);
const minY = Math.min(...availableYears), maxY = Math.max(...availableYears);
const pick = (key: string) => { const r = makePresets(year)[key]; if (!r) return; setActive(key); onChange(r.start, r.end); };
const shift = (d: number) => {
const ny = year+d; if (ny < minY || ny > maxY) return; setYear(ny);
if (active && makePresets(ny)[active]) onChange(makePresets(ny)[active].start, makePresets(ny)[active].end);
};
return (
<div className="alt-picker">
<div className="alt-picker-year">
<button type="button" onClick={() => shift(L.dir==='rtl' ? 1 : -1)} disabled={L.dir==='rtl' ? year>=maxY : year<=minY} className="alt-yr-btn">
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M5.5 9.5L1.5 5.5L5.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
<span className="alt-yr-val">{year}</span>
<button type="button" onClick={() => shift(L.dir==='rtl' ? -1 : 1)} disabled={L.dir==='rtl' ? year<=minY : year>=maxY} className="alt-yr-btn">
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M1.5 1.5L5.5 5.5L1.5 9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
</div>
<p className="alt-picker-section">{L.monthSection}</p>
<div className="alt-chips">
{MONTH_KEYS.map((k,i) => (
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.monthShort[i]}</button>
))}
</div>
<p className="alt-picker-section">{L.periodSection}</p>
<div className="alt-chips">
{['q1','q2','q3','q4','h1','h2'].map(k => (
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.periods[k]}</button>
))}
<button type="button" className={`alt-chip alt-chip-wide${active==='full'?' alt-chip-on':''}`} onClick={() => pick('full')}>{L.periods.full}</button>
</div>
<div className="alt-picker-div" />
<div className="alt-custom">
<div className="alt-custom-f"><label>{L.from}</label><input type="date" value={start} onChange={e => { setActive(null); onChange(e.target.value, end); }} /></div>
<span className="alt-custom-arrow">{L.dateRangeSep}</span>
<div className="alt-custom-f"><label>{L.to}</label><input type="date" value={end} onChange={e => { setActive(null); onChange(start, e.target.value); }} /></div>
</div>
</div>
);
}
// ─── period hero ──────────────────────────────────────────────────
function PeriodHero({ start, end, onChange, availableYears, L }: {
start: string; end: string; onChange: (s: string, e: string) => void;
availableYears: number[]; L: LC;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onM = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
const onK = (e: KeyboardEvent) => { if (e.key==='Escape') setOpen(false); };
document.addEventListener('mousedown', onM); document.addEventListener('keydown', onK);
return () => { document.removeEventListener('mousedown', onM); document.removeEventListener('keydown', onK); };
}, [open]);
return (
<div ref={ref} className="dalt-hero">
<div className="dalt-hero-inner">
<div>
<div className="dalt-hero-name">{periodNameL(start, end, L)}</div>
<div className="dalt-hero-range">{dateRangeTextL(start, end, L)}</div>
</div>
<button type="button" className="dalt-hero-btn" onClick={() => setOpen(v => !v)} aria-expanded={open}>
{open ? L.close : L.changePeriod}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ transform: open?'rotate(180deg)':'none', transition:'transform 0.2s' }}>
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
{open && <InlinePicker start={start} end={end} onChange={(s,e) => { onChange(s,e); setOpen(false); }} availableYears={availableYears} L={L} />}
</div>
);
}
// ─── multi-select ─────────────────────────────────────────────────
function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
value: string[]; options: string[];
onChange: (vals: string[]) => void;
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
}, [open]);
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v!==opt) : [...value, opt]);
const label = value.length===0 ? allLabel : value.length===1 ? value[0] : countLabel(value.length);
return (
<div ref={ref} className="altms">
<button type="button" className={`altms-trigger${value.length>0?' altms-trigger--active':''}`} onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox">
<span className="altms-label">{label}</span>
<svg className={`altms-chevron${open?' altms-chevron--open':''}`} width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{open && (
<div className="altms-dropdown" role="listbox" aria-multiselectable="true">
<div className="altms-list">
{options.map(opt => (
<label key={opt} className={`altms-option${value.includes(opt)?' altms-option--checked':''}`}>
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
<span className="altms-opt-label">{opt}</span>
</label>
))}
</div>
{value.length>0 && <button type="button" className="altms-clear" onClick={() => { onChange([]); setOpen(false); }}>{clearLabel}</button>}
</div>
)}
</div>
);
}
// ─── metric card ──────────────────────────────────────────────────
function MetricCard({ title, curr, prev, isCurrency, newLabel }: {
title: string; curr: number; prev: number; isCurrency?: boolean; newLabel?: string;
}) {
const fmt = (n: number) => isCurrency ? formatCurrency(n) : formatNumber(n);
const change = prev===0 ? (curr>0 ? Infinity : 0) : ((curr-prev)/prev*100);
const isPos = change>0, isNeg = change<0;
return (
<div className="alt-metric">
<p className="alt-metric-title">{title}</p>
<div className="alt-metric-value">{fmt(curr)}</div>
<div className="alt-metric-footer">
{isFinite(change)
? <span className={`alt-change ${isPos?'alt-change--up':isNeg?'alt-change--down':'alt-change--flat'}`}>{isPos?'▲':isNeg?'▼':'—'} {Math.abs(change).toFixed(1)}%</span>
: <span className="alt-change alt-change--up">{newLabel??'New'}</span>}
<span className="alt-metric-prev">{fmt(prev)}</span>
</div>
</div>
);
}
// ─── main page ────────────────────────────────────────────────────
export default function DashboardDemo({ data, seasons: _seasons, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: Props) {
const { lang: activeLang, setLanguage } = useLanguage();
const L = activeLang === 'ar' ? AR : EN;
const curr = currentMonth();
const [start, setStart] = useState(curr.start);
const [end, setEnd] = useState(curr.end);
const [selDistricts, setSelDistricts] = useState<string[]>([]);
const [selChannels, setSelChannels] = useState<string[]>([]);
const [selMuseums, setSelMuseums] = useState<string[]>([]);
const [metric, setMetric] = useState('revenue');
const [gran, setGran] = useState('week');
const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar');
const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie');
const [districtChartType, setDistrictChartType] = useState<'bar'|'pie'>('pie');
const [museumDisplayMode, setMuseumDisplayMode] = useState<'absolute'|'percent'>('absolute');
const [channelDisplayMode, setChannelDisplayMode] = useState<'absolute'|'percent'>('absolute');
const [districtDisplayMode, setDistrictDisplayMode] = useState<'absolute'|'percent'>('absolute');
const perm = useMemo(() => {
if (!allowedMuseums || !allowedChannels) return [];
let d = data;
if (allowedMuseums.length) d = d.filter(r => allowedMuseums.includes(r.museum_name));
if (allowedChannels.length) d = d.filter(r => allowedChannels.includes(r.channel));
return d;
}, [data, allowedMuseums, allowedChannels]);
const availableYears = useMemo(() => {
const s = new Set<number>(); perm.forEach(r => r.date && s.add(parseInt(r.date.slice(0,4))));
const a = Array.from(s).sort((a,b) => b-a); return a.length ? a : [new Date().getFullYear()];
}, [perm]);
const allDistricts = useMemo(() => getUniqueDistricts(perm), [perm]);
const allChannels = useMemo(() => getUniqueChannels(perm), [perm]);
const allMuseums = useMemo(() => getUniqueMuseums(perm), [perm]);
const applyFilters = useCallback((rows: MuseumRecord[]) => {
let d = rows;
if (selChannels.length) d = d.filter(r => selChannels.includes(r.channel));
if (selMuseums.length) d = d.filter(r => selMuseums.includes(r.museum_name));
if (selDistricts.length) d = d.filter(r => selDistricts.includes(r.district));
return d;
}, [selDistricts, selChannels, selMuseums]);
const filteredData = useMemo(() => applyFilters(filterDataByDateRange(perm, start, end, {})), [perm, start, end, applyFilters]);
const prevStart = shiftYear(start), prevEnd = shiftYear(end);
const prevData = useMemo(() => applyFilters(filterDataByDateRange(perm, prevStart, prevEnd, {})), [perm, prevStart, prevEnd, applyFilters]);
const currM = useMemo(() => calculateMetrics(filteredData, includeVAT), [filteredData, includeVAT]);
const prevM = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]);
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const getVal = useCallback((rows: MuseumRecord[], m: string) => {
if (m==='avgRevenue') {
const rev = rows.reduce((s,r) => s + parseFloat(String((r as any)[revenueField]||0)), 0);
const vis = rows.reduce((s,r) => s + parseInt(String(r.visits||0)), 0);
return vis>0 ? rev/vis : 0;
}
const f: Record<string,string> = { revenue: revenueField, visitors:'visits', tickets:'tickets' };
return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0);
}, [revenueField]);
const trendData = useMemo(() => {
const group = (rows: MuseumRecord[], ps: string) => {
const s = new Date(ps); const acc: Record<number, MuseumRecord[]> = {};
rows.forEach(r => {
if (!r.date) return;
const diff = Math.floor((new Date(r.date).getTime() - s.getTime()) / 86400000);
const key = gran==='month' ? Math.floor(diff/30)+1 : gran==='week' ? Math.floor(diff/7)+1 : diff+1;
if (!acc[key]) acc[key]=[]; acc[key].push(r);
});
const res: Record<number,number> = {};
Object.entries(acc).forEach(([k,v]) => res[Number(k)] = getVal(v, metric)); return res;
};
const pg = group(prevData, prevStart), cg = group(filteredData, start);
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
const labels = Array.from({length:maxK}, (_,i) =>
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(new Date(start).getMonth()+i)%12] : `D${i+1}`
);
const prevYear = parseInt(start.slice(0,4))-1;
return {
labels,
datasets: [
{ label:`${prevYear}`, data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:1.5, tension:0.4, pointRadius:0, borderDash:[4,3] },
{ label:start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'18', borderWidth:2.5, tension:0.4, fill:true, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.primary },
]
};
}, [filteredData, prevData, prevStart, start, metric, gran, getVal, L]);
const museumData = useMemo(() => {
const g = groupByMuseum(filteredData, includeVAT);
const getM = (d: typeof g[string]) => metric==='visitors' ? d.visitors : metric==='tickets' ? d.tickets : d.revenue;
const entries = Object.entries(g).sort((a,b) => getM(b[1]) - getM(a[1]));
return { labels:entries.map(([k]) => k), datasets:[{ label:metric, data:entries.map(([,v]) => getM(v)), backgroundColor:chartPalette, borderRadius:4 }] };
}, [filteredData, includeVAT, metric]);
const channelData = useMemo(() => {
const g = groupByChannel(filteredData, includeVAT);
const getM = (d: typeof g[string]) => metric==='visitors' ? d.visitors : metric==='tickets' ? d.tickets : d.revenue;
const entries = Object.entries(g).sort((a,b) => getM(b[1]) - getM(a[1]));
return { labels:entries.map(([k]) => k), datasets:[{ label:metric, data:entries.map(([,v]) => getM(v)), backgroundColor:chartPalette, borderRadius:4 }] };
}, [filteredData, includeVAT, metric]);
const districtData = useMemo(() => {
const g = groupByDistrict(filteredData, includeVAT);
const getM = (d: typeof g[string]) => metric==='visitors' ? d.visitors : metric==='tickets' ? d.tickets : d.revenue;
const entries = Object.entries(g).sort((a,b) => getM(b[1]) - getM(a[1]));
return { labels:entries.map(([k]) => k), datasets:[{ label:metric, data:entries.map(([,v]) => getM(v)), backgroundColor:chartPalette.map(c => c+'cc'), borderRadius:4 }] };
}, [filteredData, includeVAT, metric]);
const toPercent = (chartData: any) => {
const total = chartData.datasets[0].data.reduce((s: number, v: number) => s+v, 0);
if (total===0) return chartData;
return { ...chartData, datasets: [{ ...chartData.datasets[0], data: chartData.datasets[0].data.map((v: number) => parseFloat(((v/total)*100).toFixed(1))) }] };
};
const museumDisplay = useMemo(() => museumDisplayMode==='percent' ? toPercent(museumData) : museumData, [museumData, museumDisplayMode]);
const channelDisplay = useMemo(() => channelDisplayMode==='percent' ? toPercent(channelData) : channelData, [channelData, channelDisplayMode]);
const districtDisplay = useMemo(() => districtDisplayMode==='percent' ? toPercent(districtData) : districtData, [districtData, districtDisplayMode]);
const estimatePilgrims = useCallback((s: string, e: string) => {
const sd=new Date(s), ed=new Date(e); let total=0, has=false;
for (let y=sd.getFullYear(); y<=ed.getFullYear(); y++) {
for (let q=1; q<=4; q++) {
const qs=new Date(y,(q-1)*3,1), qe=new Date(y,q*3,0);
if (qe<sd||qs>ed) continue;
const p=umrahData[y]?.[q]; if (!p) continue;
const os=new Date(Math.max(qs.getTime(),sd.getTime())), oe=new Date(Math.min(qe.getTime(),ed.getTime()));
total+=p*((oe.getTime()-os.getTime())/86400000+1)/((qe.getTime()-qs.getTime())/86400000+1); has=true;
}
}
return has ? Math.round(total) : null;
}, []);
const currPilgrims = useMemo(() => estimatePilgrims(start, end), [start, end, estimatePilgrims]);
const prevPilgrims = useMemo(() => estimatePilgrims(prevStart, prevEnd), [prevStart, prevEnd, estimatePilgrims]);
const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null;
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
const baseOpts = useMemo(() => createBaseOptions(false), []);
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:10, padding:10, font:{ size:11 } } } } };
const barHorizOpts: any = { ...chartOpts, indexAxis:'y', plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
const barNoLegend: any = { ...chartOpts, plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
const pieOptions: any = useMemo(() => ({
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display:true, position:'right', labels:{ boxWidth:12, padding:10, font:{ size:11 }, color:'#64748b' } },
tooltip: baseOpts.plugins.tooltip,
datalabels: { display:false },
}
}), [baseOpts]);
const metricOpts = [{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors }, { value:'tickets', label:L.tickets }];
const granOpts = [{ value:'day', label:L.daily }, { value:'week', label:L.weekly }, { value:'month', label:L.monthly }];
const hasFilters = selDistricts.length>0 || selChannels.length>0 || selMuseums.length>0;
return (
<div className="alt-page" dir={L.dir}>
<style>{`
${L.fontImport}
.alt-page { max-width:1100px; margin:0 auto; padding:48px 24px 80px; font-family:${L.bodyFont}; }
/* ── header ── */
.alt-back { display:inline-flex; align-items:center; gap:6px; font-size:.8125rem; color:var(--text-muted); text-decoration:none; margin-bottom:28px; transition:color .15s; }
.alt-back:hover { color:var(--accent); }
.alt-page-title { font-family:${L.displayFont}; font-size:2.25rem; font-weight:400; color:var(--text-primary); margin:0 0 6px; letter-spacing:-.03em; line-height:1.15; }
.alt-page-sub { font-size:.9375rem; color:var(--text-muted); margin:0 0 40px; font-weight:300; }
/* ── hero ── */
.dalt-hero { border:1px solid var(--border); border-radius:var(--radius); background:var(--surface); overflow:hidden; margin-bottom:24px; }
.dalt-hero-inner { display:flex; align-items:center; justify-content:space-between; padding:24px 28px; gap:16px; flex-wrap:wrap; }
.dalt-hero-name { font-family:${L.displayFont}; font-size:2.5rem; font-weight:400; color:var(--text-primary); line-height:1; letter-spacing:-.025em; margin-bottom:6px; }
.dalt-hero-range { font-family:${L.monoFont}; font-size:.875rem; color:var(--text-muted); letter-spacing:.01em; }
.dalt-hero-btn { display:inline-flex; align-items:center; gap:5px; font-family:${L.bodyFont}; font-size:.8125rem; font-weight:500; color:var(--text-muted); background:none; border:1px solid var(--border); border-radius:8px; padding:7px 12px; cursor:pointer; transition:color .15s,border-color .15s; white-space:nowrap; }
.dalt-hero-btn:hover { color:var(--accent); border-color:var(--accent); }
/* ── picker ── */
.alt-picker { border-top:1px solid var(--border); padding:16px 24px 20px; background:var(--bg); animation:altPickIn 180ms cubic-bezier(.16,1,.3,1); }
@keyframes altPickIn { from{opacity:0;transform:translateY(-8px)} to{opacity:1;transform:translateY(0)} }
.alt-picker-year { display:flex; align-items:center; gap:16px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--border); }
.alt-yr-val { font-family:${L.displayFont}; font-size:1.25rem; color:var(--text-primary); min-width:50px; text-align:center; }
.alt-yr-btn { width:28px; height:28px; display:flex; align-items:center; justify-content:center; border:1px solid var(--border); border-radius:7px; background:var(--surface); color:var(--text-secondary); cursor:pointer; transition:background .12s,border-color .12s,color .12s; }
.alt-yr-btn:hover:not(:disabled) { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
.alt-yr-btn:disabled { opacity:.3; cursor:not-allowed; }
.alt-picker-section { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:10px 0 6px; }
.alt-chips { display:flex; flex-wrap:wrap; gap:5px; margin-bottom:4px; }
.alt-chip { font-family:${L.bodyFont}; padding:4px 9px; border:1px solid var(--border); border-radius:6px; background:var(--surface); color:var(--text-secondary); font-size:.8rem; font-weight:500; cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
.alt-chip:hover { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
.alt-chip-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; font-weight:600!important; }
.alt-chip-wide { padding-left:14px; padding-right:14px; }
.alt-picker-div { height:1px; background:var(--border); margin:12px 0 10px; }
.alt-custom { display:flex; align-items:flex-end; gap:8px; }
.alt-custom-f { flex:1; display:flex; flex-direction:column; gap:4px; }
.alt-custom-f label { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--text-muted); }
.alt-custom-f input[type="date"] { padding:7px 9px; border:1px solid var(--border); border-radius:7px; font-size:.825rem; background:var(--surface); color:var(--text-primary); width:100%; }
.alt-custom-f input[type="date"]:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 2px rgba(37,99,235,.12); }
.alt-custom-arrow { font-size:.75rem; color:var(--text-muted); padding-bottom:9px; flex-shrink:0; }
/* ── multi-select ── */
.altms { position:relative; }
.altms-trigger { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border:1px solid var(--border); border-radius:8px; background:var(--surface); color:var(--text-secondary); font-family:${L.bodyFont}; font-size:.875rem; cursor:pointer; transition:border-color .15s,color .15s,background .15s; white-space:nowrap; }
.altms-trigger:hover { border-color:var(--accent); color:var(--accent); }
.altms-trigger--active { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
.altms-label { max-width:140px; overflow:hidden; text-overflow:ellipsis; }
.altms-chevron { transition:transform .18s; flex-shrink:0; color:currentColor; }
.altms-chevron--open { transform:rotate(180deg); }
.altms-dropdown { position:absolute; top:calc(100% + 6px); left:0; z-index:200; min-width:200px; background:var(--surface); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.12); overflow:hidden; animation:altPickIn 140ms cubic-bezier(.16,1,.3,1); }
[dir="rtl"] .altms-dropdown { left:auto; right:0; }
.altms-list { max-height:220px; overflow-y:auto; padding:6px; display:flex; flex-direction:column; gap:2px; }
.altms-option { display:flex; align-items:center; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; transition:background .1s; }
.altms-option:hover { background:var(--bg); }
.altms-option--checked { background:var(--accent-light); }
.altms-check { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
.altms-check-box { width:16px; height:16px; border:1.5px solid var(--border); border-radius:4px; display:flex; align-items:center; justify-content:center; flex-shrink:0; transition:background .1s,border-color .1s; }
.altms-option--checked .altms-check-box { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
.altms-opt-label { font-family:${L.bodyFont}; font-size:.875rem; color:var(--text-primary); }
.altms-clear { width:100%; padding:8px 14px; border-top:1px solid var(--border); background:none; border-left:none; border-right:none; border-bottom:none; font-family:${L.bodyFont}; font-size:.8125rem; color:var(--danger); cursor:pointer; text-align:start; transition:background .1s; }
.altms-clear:hover { background:var(--danger-light); }
/* ── filter bar ── */
.alt-filter-bar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:32px; padding:14px 20px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); }
.alt-filter-label { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); white-space:nowrap; }
.alt-filter-sep { width:1px; height:20px; background:var(--border); flex-shrink:0; }
.alt-vat-toggle { margin-inline-start:auto; display:flex; align-items:center; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
.alt-vat-opt { font-family:${L.bodyFont}; font-size:.75rem; font-weight:500; padding:5px 10px; background:var(--surface); color:var(--text-muted); cursor:pointer; border:none; transition:background .1s,color .1s; }
.alt-vat-opt--on { background:var(--accent); color:var(--text-inverse); }
.alt-filter-reset { font-size:.8125rem; color:var(--text-muted); background:none; border:none; cursor:pointer; padding:4px 6px; transition:color .15s; font-family:${L.bodyFont}; }
.alt-filter-reset:hover { color:var(--danger); }
/* ── metrics ── */
.alt-metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:1px; background:var(--border); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; margin-bottom:40px; }
.alt-metric { background:var(--surface); padding:24px 22px; }
.alt-metric-title { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:0 0 12px; }
.alt-metric-value { font-family:${L.displayFont}; font-size:1.875rem; font-weight:400; color:var(--text-primary); line-height:1; margin-bottom:10px; letter-spacing:-.02em; }
.alt-metric-footer { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.alt-change { font-size:.75rem; font-weight:600; padding:2px 8px; border-radius:20px; white-space:nowrap; font-family:${L.bodyFont}; }
.alt-change--up { background:var(--success-light); color:var(--success); }
.alt-change--down { background:var(--danger-light); color:var(--danger); }
.alt-change--flat { background:var(--muted-light); color:var(--text-muted); }
.alt-metric-prev { font-size:.75rem; color:var(--text-muted); font-family:${L.monoFont}; }
/* ── section heading ── */
.alt-section-heading { display:flex; align-items:center; gap:12px; margin:0 0 20px; }
.alt-section-heading h2 { font-family:${L.displayFont}; font-size:1.375rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; }
.alt-section-heading::after { content:''; flex:1; height:1px; background:var(--border); }
/* ── charts ── */
.dalt-charts-grid { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
.dalt-chart-full { grid-column:1/-1; }
.alt-chart-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:24px 24px 20px; }
.alt-chart-header { display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:20px; gap:12px; flex-wrap:wrap; }
.alt-chart-title { font-family:${L.displayFont}; font-size:1.25rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; font-style:italic; }
.alt-chart-controls { display:flex; gap:5px; flex-wrap:wrap; }
.alt-ctrl { font-family:${L.bodyFont}; font-size:.75rem; font-weight:500; padding:4px 10px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text-secondary); cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
.alt-ctrl:hover { border-color:var(--accent); color:var(--accent); }
.alt-ctrl-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; }
.alt-ctrl-sep { width:1px; height:20px; background:var(--border); align-self:center; }
.alt-chart-wrap { position:relative; height:260px; overflow:hidden; direction:ltr; }
.alt-chart-wrap--tall { height:320px; }
/* ── responsive ── */
@media (max-width:700px) {
.dalt-hero-name { font-size:1.875rem; }
.dalt-charts-grid { grid-template-columns:1fr; }
.dalt-chart-full { grid-column:auto; }
.alt-metrics { grid-template-columns:1fr 1fr; }
.alt-page-title { font-size:1.75rem; }
.altms-label { max-width:100px; }
}
`}</style>
<h1 className="alt-page-title">{L.pageTitle}</h1>
<p className="alt-page-sub">{L.pageSub}</p>
<PeriodHero start={start} end={end} onChange={(s,e) => { setStart(s); setEnd(e); }} availableYears={availableYears} L={L} />
<div className="alt-filter-bar">
<span className="alt-filter-label">{L.filter}</span>
<div className="alt-filter-sep" />
<AltMultiSelect value={selDistricts} options={allDistricts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
<AltMultiSelect value={selChannels} options={allChannels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
<AltMultiSelect value={selMuseums} options={allMuseums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} />
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
<div className="alt-vat-toggle">
<button type="button" className={`alt-vat-opt${!includeVAT?' alt-vat-opt--on':''}`} onClick={() => setIncludeVAT(false)}>{L.exclVAT}</button>
<button type="button" className={`alt-vat-opt${includeVAT ?' alt-vat-opt--on':''}`} onClick={() => setIncludeVAT(true)}>{L.inclVAT}</button>
</div>
<div className="alt-vat-toggle">
<button type="button" className={`alt-vat-opt${activeLang==='en'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('en')}>EN</button>
<button type="button" className={`alt-vat-opt${activeLang==='ar'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('ar')}>AR</button>
</div>
</div>
<div className="alt-section-heading"><h2>{L.keyMetrics}</h2></div>
<div className="alt-metrics">
<MetricCard title={L.revenue} curr={currM.revenue} prev={prevM.revenue} isCurrency newLabel={L.newLabel} />
<MetricCard title={L.visitors} curr={currM.visitors} prev={prevM.visitors} newLabel={L.newLabel} />
<MetricCard title={L.tickets} curr={currM.tickets} prev={prevM.tickets} newLabel={L.newLabel} />
<MetricCard title={L.avgRev} curr={currM.avgRevPerVisitor} prev={prevM.avgRevPerVisitor} isCurrency newLabel={L.newLabel} />
{currPilgrims!==null && prevPilgrims!==null &&
<MetricCard title={L.pilgrims} curr={currPilgrims} prev={prevPilgrims} newLabel={L.newLabel} />}
{currCapture!==null && prevCapture!==null &&
<MetricCard title={L.captureRate} curr={parseFloat(currCapture.toFixed(2))} prev={parseFloat((prevCapture??0).toFixed(2))} newLabel={L.newLabel} />}
</div>
<div className="alt-section-heading"><h2>{L.charts}</h2></div>
<div className="dalt-charts-grid">
<div className="alt-chart-card dalt-chart-full">
<div className="alt-chart-header">
<h3 className="alt-chart-title">{L.trendTitle}</h3>
<div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" />
{granOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
</div>
</div>
<div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={chartOpts} /></div>
</div>
<div className="alt-chart-card">
<div className="alt-chart-header">
<h3 className="alt-chart-title">{L.museumTitle}</h3>
<div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${museumChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('bar')}>{L.barLabel}</button>
<button type="button" className={`alt-ctrl${museumChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('pie')}>{L.pieLabel}</button>
<div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${museumDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('absolute')}>{L.absLabel}</button>
<button type="button" className={`alt-ctrl${museumDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('percent')}>{L.pctLabel}</button>
</div>
</div>
<div className="alt-chart-wrap alt-chart-wrap--tall">
{museumChartType==='pie' ? <Pie data={museumDisplay} options={pieOptions} /> : <Bar data={museumDisplay} options={barHorizOpts} />}
</div>
</div>
<div className="alt-chart-card">
<div className="alt-chart-header">
<h3 className="alt-chart-title">{L.channelTitle}</h3>
<div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${channelChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('bar')}>{L.barLabel}</button>
<button type="button" className={`alt-ctrl${channelChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('pie')}>{L.pieLabel}</button>
<div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${channelDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('absolute')}>{L.absLabel}</button>
<button type="button" className={`alt-ctrl${channelDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('percent')}>{L.pctLabel}</button>
</div>
</div>
<div className="alt-chart-wrap">
{channelChartType==='pie' ? <Pie data={channelDisplay} options={pieOptions} /> : <Bar data={channelDisplay} options={barNoLegend} />}
</div>
</div>
<div className="alt-chart-card dalt-chart-full">
<div className="alt-chart-header">
<h3 className="alt-chart-title">{L.districtTitle}</h3>
<div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${districtChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('bar')}>{L.barLabel}</button>
<button type="button" className={`alt-ctrl${districtChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('pie')}>{L.pieLabel}</button>
<div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${districtDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('absolute')}>{L.absLabel}</button>
<button type="button" className={`alt-ctrl${districtDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('percent')}>{L.pctLabel}</button>
</div>
</div>
<div className="alt-chart-wrap">
{districtChartType==='pie' ? <Pie data={districtDisplay} options={pieOptions} /> : <Bar data={districtDisplay} options={barNoLegend} />}
</div>
</div>
</div>
</div>
);
}
-606
View File
@@ -1,606 +0,0 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import type { MuseumRecord, Season } from '../types';
import DashboardDemo from './DashboardDemo';
interface NavDemoProps {
data: MuseumRecord[];
seasons: Season[];
includeVAT: boolean;
setIncludeVAT: (v: boolean) => void;
allowedMuseums: string[] | null;
allowedChannels: string[] | null;
userRole?: string;
theme?: string;
toggleTheme?: () => void;
switchLanguage?: () => void;
handleRefresh?: () => void;
refreshing?: boolean;
}
// ── Icons ────────────────────────────────────────────────────────────────────
const IconDashboard = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="9" rx="1"/>
<rect x="14" y="3" width="7" height="5" rx="1"/>
<rect x="14" y="12" width="7" height="9" rx="1"/>
<rect x="3" y="16" width="7" height="5" rx="1"/>
</svg>
);
const IconCompare = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
<polyline points="18 14 22 10 18 6"/><polyline points="6 10 2 14 6 18"/>
</svg>
);
const IconSettings = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
);
const IconStar = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
);
const IconGlobe = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
);
const IconSun = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
);
const IconMoon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
);
const IconSystem = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 0 0 20V2z"/>
</svg>
);
const IconLang = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
);
const IconRefresh = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
);
const IconMenu = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
);
const IconChevronLeft = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
);
const IconChevronRight = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
);
// ── Styles ────────────────────────────────────────────────────────────────────
const CSS = `
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Outfit:wght@300;400;500;600;700&display=swap');
.nd-layout {
display: flex;
height: 100vh;
overflow: hidden;
font-family: 'Outfit', sans-serif;
}
/* ── Sidebar ── */
.nd-sidebar {
width: 240px;
min-width: 240px;
height: 100vh;
background: #0f172a;
color: #94a3b8;
display: flex;
flex-direction: column;
transition: width 0.25s cubic-bezier(0.4,0,0.2,1), min-width 0.25s cubic-bezier(0.4,0,0.2,1);
overflow: hidden;
position: relative;
z-index: 100;
flex-shrink: 0;
}
.nd-sidebar.nd-collapsed {
width: 60px;
min-width: 60px;
}
/* Brand */
.nd-brand {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 14px 18px;
border-bottom: 1px solid rgba(148,163,184,0.08);
flex-shrink: 0;
min-height: 72px;
}
.nd-brand-monogram {
width: 34px;
height: 34px;
min-width: 34px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
font-family: 'DM Serif Display', serif;
font-size: 13px;
color: #fff;
letter-spacing: -0.5px;
box-shadow: 0 2px 8px rgba(99,102,241,0.4);
flex-shrink: 0;
}
.nd-brand-text {
overflow: hidden;
white-space: nowrap;
transition: opacity 0.2s, transform 0.2s;
transform-origin: left;
}
.nd-brand-name {
font-family: 'DM Serif Display', serif;
font-size: 16px;
color: #f1f5f9;
line-height: 1.1;
letter-spacing: 0.1px;
}
.nd-brand-sub {
font-family: 'Outfit', sans-serif;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
color: #6366f1;
margin-top: 2px;
}
/* Collapse when sidebar is collapsed */
.nd-collapsed .nd-brand-text,
.nd-collapsed .nd-section-label,
.nd-collapsed .nd-item-label,
.nd-collapsed .nd-ds-select,
.nd-collapsed .nd-util-label {
opacity: 0;
pointer-events: none;
max-width: 0;
overflow: hidden;
}
/* Collapse toggle button */
.nd-collapse-btn {
position: absolute;
top: 22px;
right: -13px;
width: 26px;
height: 26px;
background: #1e293b;
border: 1px solid rgba(148,163,184,0.15);
border-radius: 50%;
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
z-index: 101;
}
.nd-collapse-btn:hover {
background: #6366f1;
color: #fff;
border-color: #6366f1;
}
/* Nav scroll area */
.nd-nav {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 0;
}
.nd-nav::-webkit-scrollbar { width: 3px; }
.nd-nav::-webkit-scrollbar-track { background: transparent; }
.nd-nav::-webkit-scrollbar-thumb { background: rgba(148,163,184,0.15); border-radius: 2px; }
/* Section */
.nd-section { margin-bottom: 2px; }
.nd-section-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #334155;
padding: 10px 16px 4px;
white-space: nowrap;
transition: opacity 0.15s;
}
.nd-divider {
height: 1px;
background: rgba(148,163,184,0.07);
margin: 6px 0;
}
/* Nav item */
.nd-item {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 16px;
font-size: 13px;
font-weight: 400;
color: #64748b;
text-decoration: none;
transition: background 0.12s, color 0.12s, border-color 0.12s;
position: relative;
white-space: nowrap;
border-left: 3px solid transparent;
line-height: 1;
}
.nd-item:hover {
background: rgba(148,163,184,0.07);
color: #94a3b8;
}
.nd-item.nd-active {
background: rgba(99,102,241,0.1);
color: #a5b4fc;
border-left-color: #6366f1;
font-weight: 500;
}
.nd-item-icon {
width: 16px;
height: 16px;
min-width: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.65;
}
.nd-item.nd-active .nd-item-icon { opacity: 1; }
.nd-item-label {
transition: opacity 0.15s;
overflow: hidden;
}
/* Collapsed item tooltip */
.nd-collapsed .nd-item {
justify-content: center;
padding: 10px;
border-left: none;
border-radius: 0;
}
.nd-collapsed .nd-item.nd-active {
background: rgba(99,102,241,0.15);
}
/* Bottom utilities */
.nd-bottom {
flex-shrink: 0;
border-top: 1px solid rgba(148,163,184,0.08);
padding: 10px 0 8px;
}
.nd-bottom-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 14px;
}
.nd-util-btn {
width: 32px;
height: 32px;
min-width: 32px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(148,163,184,0.1);
border-radius: 7px;
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
flex-shrink: 0;
}
.nd-util-btn:hover {
background: rgba(99,102,241,0.15);
color: #a5b4fc;
border-color: rgba(99,102,241,0.3);
}
.nd-util-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.nd-util-btn.nd-refreshing svg {
animation: nd-spin 1s linear infinite;
}
@keyframes nd-spin { to { transform: rotate(360deg); } }
.nd-util-label {
font-size: 11.5px;
color: #475569;
transition: opacity 0.15s;
white-space: nowrap;
overflow: hidden;
}
/* ── Main content ── */
.nd-content {
flex: 1;
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
position: relative;
min-width: 0;
}
/* ── Mobile hamburger ── */
.nd-hamburger {
display: none;
position: fixed;
top: 14px;
left: 14px;
z-index: 200;
width: 40px;
height: 40px;
background: #0f172a;
border: 1px solid rgba(148,163,184,0.15);
border-radius: 10px;
color: #94a3b8;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
/* ── Overlay ── */
.nd-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.55);
z-index: 99;
backdrop-filter: blur(2px);
}
/* ── Mobile ── */
@media (max-width: 768px) {
.nd-sidebar {
position: fixed;
left: 0;
top: 0;
height: 100vh;
transform: translateX(-100%);
transition: transform 0.25s cubic-bezier(0.4,0,0.2,1);
z-index: 100;
width: 260px !important;
min-width: 260px !important;
}
.nd-sidebar.nd-open { transform: translateX(0); }
.nd-content { width: 100%; }
.nd-hamburger { display: flex; }
.nd-overlay.nd-visible { display: block; }
.nd-collapse-btn { display: none; }
}
`;
// ── Component ─────────────────────────────────────────────────────────────────
export default function NavDemo({
data,
seasons,
includeVAT,
setIncludeVAT,
allowedMuseums,
allowedChannels,
userRole = 'viewer',
theme = 'light',
toggleTheme,
switchLanguage,
handleRefresh,
refreshing = false,
}: NavDemoProps) {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const { pathname } = useLocation();
const ThemeIcon = theme === 'dark' ? <IconSun /> : theme === 'light' ? <IconMoon /> : <IconSystem />;
const navSections = [
{
label: 'Navigate',
items: [
{ to: '/', label: 'Dashboard', icon: <IconDashboard /> },
{ to: '/comparison', label: 'Comparison', icon: <IconCompare /> },
...(userRole === 'admin'
? [{ to: '/settings', label: 'Settings', icon: <IconSettings /> }]
: []
),
],
},
{
label: 'Arabic',
items: [
{ to: '/ar', label: 'نظرة عامة (AR)', icon: <IconGlobe /> },
{ to: '/comparison-ar', label: 'مقارنة (AR)', icon: <IconGlobe /> },
],
},
];
return (
<>
<style>{CSS}</style>
<div className="nd-layout">
{/* Mobile backdrop */}
<div
className={`nd-overlay ${mobileOpen ? 'nd-visible' : ''}`}
onClick={() => setMobileOpen(false)}
/>
{/* Sidebar */}
<aside className={`nd-sidebar ${collapsed ? 'nd-collapsed' : ''} ${mobileOpen ? 'nd-open' : ''}`}>
{/* Brand */}
<div className="nd-brand">
<div className="nd-brand-monogram">HH</div>
<div className="nd-brand-text">
<div className="nd-brand-name">HiHala</div>
<div className="nd-brand-sub">Data</div>
</div>
</div>
{/* Collapse toggle (desktop only) */}
<button
className="nd-collapse-btn"
onClick={() => setCollapsed(c => !c)}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? <IconChevronRight /> : <IconChevronLeft />}
</button>
{/* Nav sections */}
<nav className="nd-nav" aria-label="Sidebar navigation">
{navSections.map(section => (
<div key={section.label} className="nd-section">
<div className="nd-section-label">{section.label}</div>
{section.items.map(item => (
<Link
key={item.to}
to={item.to}
className={`nd-item ${pathname === item.to ? 'nd-active' : ''}`}
onClick={() => setMobileOpen(false)}
title={collapsed ? item.label : undefined}
>
<span className="nd-item-icon">{item.icon}</span>
<span className="nd-item-label">{item.label}</span>
</Link>
))}
<div className="nd-divider" />
</div>
))}
</nav>
{/* Bottom utilities */}
<div className="nd-bottom">
<div className="nd-bottom-row">
{toggleTheme && (
<button
className="nd-util-btn"
onClick={toggleTheme}
title={`Theme: ${theme}`}
aria-label={`Current theme: ${theme}. Click to toggle.`}
>
{ThemeIcon}
</button>
)}
{switchLanguage && (
<button
className="nd-util-btn"
onClick={switchLanguage}
title="Switch language"
aria-label="Switch language"
>
<IconLang />
</button>
)}
{handleRefresh && (
<button
className={`nd-util-btn ${refreshing ? 'nd-refreshing' : ''}`}
onClick={handleRefresh}
disabled={refreshing}
title="Refresh data"
aria-label="Refresh data"
>
<IconRefresh />
</button>
)}
</div>
</div>
</aside>
{/* Mobile hamburger */}
<button
className="nd-hamburger"
onClick={() => setMobileOpen(true)}
aria-label="Open navigation"
>
<IconMenu />
</button>
{/* Main content — DashboardDemo embedded */}
<main className="nd-content">
<DashboardDemo
data={data}
seasons={seasons}
includeVAT={includeVAT}
setIncludeVAT={setIncludeVAT}
allowedMuseums={allowedMuseums}
allowedChannels={allowedChannels}
/>
</main>
</div>
</>
);
}
-671
View File
@@ -1,671 +0,0 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Line, Bar } from 'react-chartjs-2';
import {
filterDataByDateRange, calculateMetrics, formatCurrency, formatNumber,
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
umrahData
} from '../services/dataService';
import { chartColors, createBaseOptions } from '../config/chartConfig';
import type { MuseumRecord, Season } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
interface Props {
data: MuseumRecord[];
seasons: Season[];
includeVAT: boolean;
allowedMuseums: string[] | null;
allowedChannels: string[] | null;
lang?: 'en' | 'ar';
}
// ─── language config ──────────────────────────────────────────────
interface LC {
dir: 'ltr' | 'rtl';
fontImport: string;
bodyFont: string;
displayFont: string;
monoFont: string;
monthFull: string[];
monthShort: string[];
periods: Record<string, string>;
fullYearLabel: (y: number) => string;
dateRangeSep: string;
backLink: string;
backTo: string;
pageTitle: string;
pageSub: string;
currentRole: string; previousRole: string;
currentHint: string; previousHint: string;
changePeriod: string; close: string;
vs: string;
filter: string;
allDistricts: string; allChannels: string; allMuseums: string;
countDistricts: (n: number) => string;
countChannels: (n: number) => string;
countMuseums: (n: number) => string;
reset: string;
keyMetrics: string;
revenue: string; visitors: string; tickets: string; avgRev: string;
pilgrims: string; captureRate: string;
trendTitle: string; museumTitle: string;
daily: string; weekly: string; monthly: string;
newLabel: string; clearSel: string;
monthSection: string; periodSection: string;
from: string; to: string;
}
const EN: LC = {
dir: 'ltr',
fontImport: `@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&display=swap');`,
bodyFont: "'Outfit', sans-serif",
displayFont: "'DM Serif Display', serif",
monoFont: "ui-monospace, 'Cascadia Code', monospace",
monthFull: ['January','February','March','April','May','June','July','August','September','October','November','December'],
monthShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
periods: { q1:'Q1', q2:'Q2', q3:'Q3', q4:'Q4', h1:'H1', h2:'H2', full:'Full Year' },
fullYearLabel: (y) => String(y),
dateRangeSep: '→',
backLink: '← Overview', backTo: '/',
pageTitle: 'Period Comparison', pageSub: 'Compare any two periods side by side.',
currentRole: 'This period', previousRole: 'Compared to',
currentHint: 'primary', previousHint: 'auto year 1',
changePeriod: 'Change period', close: 'Close',
vs: 'vs',
filter: 'Filter',
allDistricts: 'All districts', allChannels: 'All channels', allMuseums: 'All museums',
countDistricts: (n) => `${n} districts`,
countChannels: (n) => `${n} channels`,
countMuseums: (n) => `${n} museums`,
reset: 'Reset',
keyMetrics: 'Key Metrics',
revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets',
avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %',
trendTitle: 'Trend over time', museumTitle: 'By museum',
daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
newLabel: 'New', clearSel: 'Clear selection',
monthSection: 'Month', periodSection: 'Quarter · Half · Year',
from: 'From', to: 'To',
};
const AR: LC = {
dir: 'rtl',
fontImport: `@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');`,
bodyFont: "'IBM Plex Sans Arabic', sans-serif",
displayFont: "'IBM Plex Sans Arabic', sans-serif",
monoFont: "'IBM Plex Sans Arabic', sans-serif",
monthFull: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'],
monthShort: ['ينا','فبر','مار','أبر','ماي','يون','يول','أغس','سبت','أكت','نوف','ديس'],
periods: { q1:'ر١', q2:'ر٢', q3:'ر٣', q4:'ر٤', h1:'ن١', h2:'ن٢', full:'السنة' },
fullYearLabel: (y) => `${y} كاملاً`,
dateRangeSep: '',
backLink: '← نظرة عامة', backTo: '/ar',
pageTitle: 'مقارنة الفترات', pageSub: 'قارن بين فترتين زمنيتين.',
currentRole: 'الفترة الحالية', previousRole: 'مقارنةً بـ',
currentHint: 'رئيسية', previousHint: 'تلقائياً −١ سنة',
changePeriod: 'تغيير الفترة', close: 'إغلاق',
vs: 'مقابل',
filter: 'تصفية',
allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف',
countDistricts: (n) => `${n} مناطق`,
countChannels: (n) => `${n} قنوات`,
countMuseums: (n) => `${n} متاحف`,
reset: 'إعادة ضبط',
keyMetrics: 'المؤشرات الرئيسية',
revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر',
avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %',
trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف',
daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري',
newLabel: 'جديد', clearSel: 'مسح التحديد',
monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة',
from: 'من', to: 'إلى',
};
// ─── date helpers ─────────────────────────────────────────────────
const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
function isLeap(y: number) { return (y%4===0 && y%100!==0) || y%400===0; }
function makePresets(y: number): Record<string, { start: string; end: string }> {
const feb = isLeap(y) ? 29 : 28;
return {
jan:{start:`${y}-01-01`,end:`${y}-01-31`}, feb:{start:`${y}-02-01`,end:`${y}-02-${String(feb).padStart(2,'0')}`},
mar:{start:`${y}-03-01`,end:`${y}-03-31`}, apr:{start:`${y}-04-01`,end:`${y}-04-30`},
may:{start:`${y}-05-01`,end:`${y}-05-31`}, jun:{start:`${y}-06-01`,end:`${y}-06-30`},
jul:{start:`${y}-07-01`,end:`${y}-07-31`}, aug:{start:`${y}-08-01`,end:`${y}-08-31`},
sep:{start:`${y}-09-01`,end:`${y}-09-30`}, oct:{start:`${y}-10-01`,end:`${y}-10-31`},
nov:{start:`${y}-11-01`,end:`${y}-11-30`}, dec:{start:`${y}-12-01`,end:`${y}-12-31`},
q1:{start:`${y}-01-01`,end:`${y}-03-31`}, q2:{start:`${y}-04-01`,end:`${y}-06-30`},
q3:{start:`${y}-07-01`,end:`${y}-09-30`}, q4:{start:`${y}-10-01`,end:`${y}-12-31`},
h1:{start:`${y}-01-01`,end:`${y}-06-30`}, h2:{start:`${y}-07-01`,end:`${y}-12-31`},
full:{start:`${y}-01-01`,end:`${y}-12-31`},
};
}
function guessPreset(start: string, end: string) {
const year = parseInt(start.slice(0,4));
const presets = makePresets(year);
for (const [key, r] of Object.entries(presets)) {
if (r.start===start && r.end===end) return { key, year };
}
return null;
}
function periodNameL(start: string, end: string, L: LC): string {
const year = parseInt(start.slice(0,4));
const g = guessPreset(start, end);
if (!g) {
const fmt = (d: string) => { const [,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]}`; };
const ey = parseInt(end.slice(0,4));
return year===ey ? `${fmt(start)} ${fmt(end)} ${year}` : `${fmt(start)} ${year} ${fmt(end)} ${ey}`;
}
const mi = MONTH_KEYS.indexOf(g.key);
if (mi >= 0) return `${L.monthFull[mi]} ${g.year}`;
if (g.key==='full') return L.fullYearLabel(g.year);
return `${L.periods[g.key]??g.key.toUpperCase()} ${g.year}`;
}
function dateRangeTextL(start: string, end: string, L: LC): string {
const fmt = (d: string) => { const [y,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]} ${y}`; };
return `${fmt(start)} ${L.dateRangeSep} ${fmt(end)}`;
}
function currentMonth() {
const now = new Date(); const y=now.getFullYear(), m=now.getMonth()+1;
const p = (n: number) => String(n).padStart(2,'0');
return { start:`${y}-${p(m)}-01`, end:`${y}-${p(m)}-${p(new Date(y,m,0).getDate())}` };
}
function shiftYear(s: string) { return s.replace(/^(\d{4})/, (_,y) => String(parseInt(y)-1)); }
// ─── inline picker ────────────────────────────────────────────────
function InlinePicker({ start, end, onChange, availableYears, L }: {
start: string; end: string; onChange: (s: string, e: string) => void;
availableYears: number[]; L: LC;
}) {
const g = guessPreset(start, end);
const [year, setYear] = useState(g?.year ?? parseInt(start.slice(0,4)));
const [active, setActive] = useState<string|null>(g?.key ?? null);
const minY = Math.min(...availableYears), maxY = Math.max(...availableYears);
const pick = (key: string) => { const r=makePresets(year)[key]; if(!r) return; setActive(key); onChange(r.start, r.end); };
const shift = (d: number) => {
const ny=year+d; if(ny<minY||ny>maxY) return; setYear(ny);
if(active && makePresets(ny)[active]) onChange(makePresets(ny)[active].start, makePresets(ny)[active].end);
};
return (
<div className="alt-picker">
<div className="alt-picker-year">
<button type="button" onClick={() => shift(L.dir==='rtl' ? 1 : -1)} disabled={L.dir==='rtl' ? year>=maxY : year<=minY} className="alt-yr-btn">
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M5.5 9.5L1.5 5.5L5.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
<span className="alt-yr-val">{year}</span>
<button type="button" onClick={() => shift(L.dir==='rtl' ? -1 : 1)} disabled={L.dir==='rtl' ? year<=minY : year>=maxY} className="alt-yr-btn">
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M1.5 1.5L5.5 5.5L1.5 9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
</div>
<p className="alt-picker-section">{L.monthSection}</p>
<div className="alt-chips">
{MONTH_KEYS.map((k,i) => (
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.monthShort[i]}</button>
))}
</div>
<p className="alt-picker-section">{L.periodSection}</p>
<div className="alt-chips">
{['q1','q2','q3','q4','h1','h2'].map(k => (
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.periods[k]}</button>
))}
<button type="button" className={`alt-chip alt-chip-wide${active==='full'?' alt-chip-on':''}`} onClick={() => pick('full')}>{L.periods.full}</button>
</div>
<div className="alt-picker-div" />
<div className="alt-custom">
<div className="alt-custom-f"><label>{L.from}</label><input type="date" value={start} onChange={e => { setActive(null); onChange(e.target.value, end); }} /></div>
<span className="alt-custom-arrow">{L.dateRangeSep}</span>
<div className="alt-custom-f"><label>{L.to}</label><input type="date" value={end} onChange={e => { setActive(null); onChange(start, e.target.value); }} /></div>
</div>
</div>
);
}
// ─── period card ──────────────────────────────────────────────────
function PeriodCard({ role, hint, start, end, variant, onChange, availableYears, L }: {
role: string; hint: string; start: string; end: string;
variant: 'current'|'previous';
onChange: (s: string, e: string) => void;
availableYears: number[]; L: LC;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onM = (e: MouseEvent) => { if(ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
const onK = (e: KeyboardEvent) => { if(e.key==='Escape') setOpen(false); };
document.addEventListener('mousedown', onM); document.addEventListener('keydown', onK);
return () => { document.removeEventListener('mousedown', onM); document.removeEventListener('keydown', onK); };
}, [open]);
return (
<div ref={ref} className={`alt-card alt-card--${variant}${open?' alt-card--open':''}`}>
<div className="alt-card-bar" />
<div className="alt-card-body">
<div className="alt-role-row">
<span className="alt-role">{role}</span>
<span className="alt-role-hint">{hint}</span>
</div>
<div className="alt-period-name">{periodNameL(start, end, L)}</div>
<div className="alt-date-range">{dateRangeTextL(start, end, L)}</div>
<button type="button" className="alt-change-btn" onClick={() => setOpen(v => !v)} aria-expanded={open}>
{open ? L.close : L.changePeriod}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ transform:open?'rotate(180deg)':'none', transition:'transform 0.2s' }}>
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
{open && <InlinePicker start={start} end={end} onChange={(s,e) => { onChange(s,e); setOpen(false); }} availableYears={availableYears} L={L} />}
</div>
);
}
// ─── multi-select ─────────────────────────────────────────────────
function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
value: string[]; options: string[];
onChange: (vals: string[]) => void;
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const h = (e: MouseEvent) => { if(ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
}, [open]);
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v!==opt) : [...value, opt]);
const label = value.length===0 ? allLabel : value.length===1 ? value[0] : countLabel(value.length);
return (
<div ref={ref} className="altms">
<button type="button" className={`altms-trigger${value.length>0?' altms-trigger--active':''}`} onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox">
<span className="altms-label">{label}</span>
<svg className={`altms-chevron${open?' altms-chevron--open':''}`} width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{open && (
<div className="altms-dropdown" role="listbox" aria-multiselectable="true">
<div className="altms-list">
{options.map(opt => (
<label key={opt} className={`altms-option${value.includes(opt)?' altms-option--checked':''}`}>
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
<span className="altms-opt-label">{opt}</span>
</label>
))}
</div>
{value.length>0 && <button type="button" className="altms-clear" onClick={() => { onChange([]); setOpen(false); }}>{clearLabel}</button>}
</div>
)}
</div>
);
}
// ─── metric card ──────────────────────────────────────────────────
function MetricCard({ title, curr, prev, isCurrency, newLabel }: {
title: string; curr: number; prev: number; isCurrency?: boolean; newLabel?: string;
}) {
const fmt = (n: number) => isCurrency ? formatCurrency(n) : formatNumber(n);
const change = prev===0 ? (curr>0 ? Infinity : 0) : ((curr-prev)/prev*100);
const isPos = change>0, isNeg = change<0;
return (
<div className="alt-metric">
<p className="alt-metric-title">{title}</p>
<div className="alt-metric-value">{fmt(curr)}</div>
<div className="alt-metric-footer">
{isFinite(change)
? <span className={`alt-change ${isPos?'alt-change--up':isNeg?'alt-change--down':'alt-change--flat'}`}>{isPos?'▲':isNeg?'▼':'—'} {Math.abs(change).toFixed(1)}%</span>
: <span className="alt-change alt-change--up">{newLabel??'New'}</span>}
<span className="alt-metric-prev">{fmt(prev)}</span>
</div>
</div>
);
}
// ─── main page ────────────────────────────────────────────────────
export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels }: Props) {
const { lang: activeLang, setLanguage } = useLanguage();
const L = activeLang === 'ar' ? AR : EN;
const curr = currentMonth();
const [currStart, setCurrStart] = useState(curr.start);
const [currEnd, setCurrEnd] = useState(curr.end);
const [prevStart, setPrevStart] = useState(() => shiftYear(curr.start));
const [prevEnd, setPrevEnd] = useState(() => shiftYear(curr.end));
const [selDistricts, setSelDistricts] = useState<string[]>([]);
const [selChannels, setSelChannels] = useState<string[]>([]);
const [selMuseums, setSelMuseums] = useState<string[]>([]);
const [metric, setMetric] = useState('revenue');
const [gran, setGran] = useState('week');
const perm = useMemo(() => {
if (!allowedMuseums || !allowedChannels) return [];
let d = data;
if (allowedMuseums.length) d = d.filter(r => allowedMuseums.includes(r.museum_name));
if (allowedChannels.length) d = d.filter(r => allowedChannels.includes(r.channel));
return d;
}, [data, allowedMuseums, allowedChannels]);
const availableYears = useMemo(() => {
const s = new Set<number>(); perm.forEach(r => r.date && s.add(parseInt(r.date.slice(0,4))));
const a = Array.from(s).sort((a,b) => b-a); return a.length ? a : [new Date().getFullYear()];
}, [perm]);
const handleCurr = (s: string, e: string) => { setCurrStart(s); setCurrEnd(e); setPrevStart(shiftYear(s)); setPrevEnd(shiftYear(e)); };
const applyFilters = useCallback((rows: MuseumRecord[]) => {
let d = rows;
if (selChannels.length) d = d.filter(r => selChannels.includes(r.channel));
if (selMuseums.length) d = d.filter(r => selMuseums.includes(r.museum_name));
if (selDistricts.length) d = d.filter(r => selDistricts.includes(r.district));
return d;
}, [selDistricts, selChannels, selMuseums]);
const currData = useMemo(() => applyFilters(filterDataByDateRange(perm, currStart, currEnd, {})), [perm, currStart, currEnd, applyFilters]);
const prevData = useMemo(() => applyFilters(filterDataByDateRange(perm, prevStart, prevEnd, {})), [perm, prevStart, prevEnd, applyFilters]);
const currM = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]);
const prevM = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]);
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const getVal = useCallback((rows: MuseumRecord[], m: string) => {
if (m==='avgRevenue') {
const rev = rows.reduce((s,r) => s + parseFloat(String((r as any)[revenueField]||0)), 0);
const vis = rows.reduce((s,r) => s + parseInt(String(r.visits||0)), 0);
return vis>0 ? rev/vis : 0;
}
const f: Record<string,string> = { revenue:revenueField, visitors:'visits', tickets:'tickets' };
return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0);
}, [revenueField]);
const channels = useMemo(() => getUniqueChannels(perm), [perm]);
const districts = useMemo(() => getUniqueDistricts(perm), [perm]);
const museums = useMemo(() => getUniqueMuseums(perm), [perm]);
const periodLabel = (s: string, e: string) => {
const sy=s.slice(0,4), ey=e.slice(0,4);
return sy===ey ? sy : `${L.monthShort[parseInt(s.slice(5,7))-1]} '${sy.slice(-2)}${L.monthShort[parseInt(e.slice(5,7))-1]} '${ey.slice(-2)}`;
};
const trendData = useMemo(() => {
const group = (rows: MuseumRecord[], ps: string) => {
const s=new Date(ps); const acc: Record<number,MuseumRecord[]> = {};
rows.forEach(r => {
if (!r.date) return;
const diff = Math.floor((new Date(r.date).getTime() - s.getTime()) / 86400000);
const key = gran==='month' ? Math.floor(diff/30)+1 : gran==='week' ? Math.floor(diff/7)+1 : diff+1;
if (!acc[key]) acc[key]=[]; acc[key].push(r);
});
const res: Record<number,number> = {};
Object.entries(acc).forEach(([k,v]) => res[Number(k)] = getVal(v, metric)); return res;
};
const pg = group(prevData, prevStart), cg = group(currData, currStart);
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
const labels = Array.from({length:maxK}, (_,i) =>
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(new Date(currStart).getMonth()+i)%12] : `D${i+1}`
);
return {
labels,
datasets: [
{ label:periodLabel(prevStart,prevEnd), data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:2, tension:0.4, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.muted },
{ label:periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'15', borderWidth:2, tension:0.4, fill:true, pointRadius:gran==='week'?4:2, pointBackgroundColor:chartColors.primary },
]
};
}, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L]);
const museumData = useMemo(() => {
const all = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[];
const pb: Record<string,number>={}, cb: Record<string,number>={};
all.forEach(m => { pb[m]=getVal(prevData.filter(r => r.museum_name===m), metric); cb[m]=getVal(currData.filter(r => r.museum_name===m), metric); });
const active = all.filter(m => pb[m]>0 || cb[m]>0);
return {
labels: active,
datasets: [
{ label:periodLabel(prevStart,prevEnd), data:active.map(m => pb[m]), backgroundColor:chartColors.muted+'cc', borderRadius:4 },
{ label:periodLabel(currStart,currEnd), data:active.map(m => cb[m]), backgroundColor:chartColors.primary, borderRadius:4 },
]
};
}, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal]);
const baseOpts = useMemo(() => createBaseOptions(false), []);
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } };
const metricOpts = [
{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors },
{ value:'tickets', label:L.tickets }, { value:'avgRevenue', label:L.avgRev },
];
const granOpts = [{ value:'day', label:L.daily }, { value:'week', label:L.weekly }, { value:'month', label:L.monthly }];
const estimatePilgrims = useCallback((s: string, e: string) => {
const sd=new Date(s), ed=new Date(e); let total=0, has=false;
for (let y=sd.getFullYear(); y<=ed.getFullYear(); y++) {
for (let q=1; q<=4; q++) {
const qs=new Date(y,(q-1)*3,1), qe=new Date(y,q*3,0);
if (qe<sd||qs>ed) continue;
const p=umrahData[y]?.[q]; if(!p) continue;
const os=new Date(Math.max(qs.getTime(),sd.getTime())), oe=new Date(Math.min(qe.getTime(),ed.getTime()));
total+=p*((oe.getTime()-os.getTime())/86400000+1)/((qe.getTime()-qs.getTime())/86400000+1); has=true;
}
}
return has ? Math.round(total) : null;
}, []);
const currPilgrims = useMemo(() => estimatePilgrims(currStart, currEnd), [currStart, currEnd, estimatePilgrims]);
const prevPilgrims = useMemo(() => estimatePilgrims(prevStart, prevEnd), [prevStart, prevEnd, estimatePilgrims]);
const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null;
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
const hasFilters = selDistricts.length>0 || selChannels.length>0 || selMuseums.length>0;
return (
<div className="alt-page" dir={L.dir}>
<style>{`
${L.fontImport}
.alt-page { max-width:1100px; margin:0 auto; padding:48px 24px 80px; font-family:${L.bodyFont}; }
/* ── header ── */
.alt-back { display:inline-flex; align-items:center; gap:6px; font-size:.8125rem; color:var(--text-muted); text-decoration:none; margin-bottom:28px; transition:color .15s; }
.alt-back:hover { color:var(--accent); }
.alt-page-title { font-family:${L.displayFont}; font-size:2.25rem; font-weight:400; color:var(--text-primary); margin:0 0 6px; letter-spacing:-.03em; line-height:1.15; }
.alt-page-sub { font-size:.9375rem; color:var(--text-muted); margin:0 0 40px; font-weight:300; }
/* ── period row ── */
.alt-period-row { display:grid; grid-template-columns:1fr auto 1fr; align-items:stretch; margin-bottom:32px; }
.alt-vs { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:0 20px; position:relative; }
.alt-vs-line { position:absolute; top:0; bottom:0; left:50%; width:1px; background:var(--border); }
.alt-vs-badge { font-family:${L.displayFont}; font-size:.9rem; font-style:italic; color:var(--text-muted); background:var(--bg); padding:6px 10px; border:1px solid var(--border); border-radius:20px; position:relative; z-index:1; }
/* ── period card ── */
.alt-card { border:1px solid var(--border); border-radius:var(--radius); background:var(--surface); overflow:hidden; transition:border-color .2s,box-shadow .2s; display:flex; flex-direction:column; }
.alt-card--current { border-radius:var(--radius) 0 0 var(--radius); }
.alt-card--previous { border-radius:0 var(--radius) var(--radius) 0; }
[dir="rtl"] .alt-card--current { border-radius:0 var(--radius) var(--radius) 0; }
[dir="rtl"] .alt-card--previous { border-radius:var(--radius) 0 0 var(--radius); }
.alt-card:hover { box-shadow:var(--shadow); }
.alt-card--current:hover,.alt-card--current.alt-card--open { border-color:var(--accent); }
.alt-card--previous:hover,.alt-card--previous.alt-card--open { border-color:#94a3b8; }
.alt-card-bar { height:3px; width:100%; }
.alt-card--current .alt-card-bar { background:var(--accent); }
.alt-card--previous .alt-card-bar { background:#94a3b8; }
.alt-card-body { padding:24px 28px 20px; flex:1; }
.alt-role-row { display:flex; align-items:baseline; gap:8px; margin-bottom:12px; }
.alt-role { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.1em; }
.alt-card--current .alt-role { color:var(--accent); }
.alt-card--previous .alt-role { color:#64748b; }
.alt-role-hint { font-size:.75rem; color:var(--text-muted); font-weight:300; }
.alt-period-name { font-family:${L.displayFont}; font-size:2.25rem; font-weight:400; color:var(--text-primary); line-height:1.1; letter-spacing:-.02em; margin-bottom:8px; }
.alt-date-range { font-family:${L.monoFont}; font-size:.8125rem; color:var(--text-muted); letter-spacing:.01em; margin-bottom:20px; }
.alt-change-btn { display:inline-flex; align-items:center; gap:5px; font-family:${L.bodyFont}; font-size:.8125rem; font-weight:500; color:var(--text-muted); background:none; border:none; padding:0; cursor:pointer; transition:color .15s; }
.alt-card--current .alt-change-btn:hover { color:var(--accent); }
.alt-card--previous .alt-change-btn:hover { color:var(--text-primary); }
/* ── picker ── */
.alt-picker { border-top:1px solid var(--border); padding:16px 24px 20px; background:var(--bg); animation:altPickIn 180ms cubic-bezier(.16,1,.3,1); }
@keyframes altPickIn { from{opacity:0;transform:translateY(-8px)} to{opacity:1;transform:translateY(0)} }
.alt-picker-year { display:flex; align-items:center; gap:16px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--border); }
.alt-yr-val { font-family:${L.displayFont}; font-size:1.25rem; color:var(--text-primary); min-width:50px; text-align:center; }
.alt-yr-btn { width:28px; height:28px; display:flex; align-items:center; justify-content:center; border:1px solid var(--border); border-radius:7px; background:var(--surface); color:var(--text-secondary); cursor:pointer; transition:background .12s,border-color .12s,color .12s; }
.alt-yr-btn:hover:not(:disabled) { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
.alt-yr-btn:disabled { opacity:.3; cursor:not-allowed; }
.alt-picker-section { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:10px 0 6px; }
.alt-chips { display:flex; flex-wrap:wrap; gap:5px; margin-bottom:4px; }
.alt-chip { font-family:${L.bodyFont}; padding:4px 9px; border:1px solid var(--border); border-radius:6px; background:var(--surface); color:var(--text-secondary); font-size:.8rem; font-weight:500; cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
.alt-chip:hover { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
.alt-chip-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; font-weight:600!important; }
.alt-chip-wide { padding-left:14px; padding-right:14px; }
.alt-picker-div { height:1px; background:var(--border); margin:12px 0 10px; }
.alt-custom { display:flex; align-items:flex-end; gap:8px; }
.alt-custom-f { flex:1; display:flex; flex-direction:column; gap:4px; }
.alt-custom-f label { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--text-muted); }
.alt-custom-f input[type="date"] { padding:7px 9px; border:1px solid var(--border); border-radius:7px; font-size:.825rem; background:var(--surface); color:var(--text-primary); width:100%; }
.alt-custom-f input[type="date"]:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 2px rgba(37,99,235,.12); }
.alt-custom-arrow { font-size:.75rem; color:var(--text-muted); padding-bottom:9px; flex-shrink:0; }
/* ── multi-select ── */
.altms { position:relative; }
.altms-trigger { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border:1px solid var(--border); border-radius:8px; background:var(--surface); color:var(--text-secondary); font-family:${L.bodyFont}; font-size:.875rem; cursor:pointer; transition:border-color .15s,color .15s,background .15s; white-space:nowrap; }
.altms-trigger:hover { border-color:var(--accent); color:var(--accent); }
.altms-trigger--active { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
.altms-label { max-width:140px; overflow:hidden; text-overflow:ellipsis; }
.altms-chevron { transition:transform .18s; flex-shrink:0; }
.altms-chevron--open { transform:rotate(180deg); }
.altms-dropdown { position:absolute; top:calc(100% + 6px); left:0; z-index:200; min-width:200px; background:var(--surface); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.12); overflow:hidden; animation:altPickIn 140ms cubic-bezier(.16,1,.3,1); }
[dir="rtl"] .altms-dropdown { left:auto; right:0; }
.altms-list { max-height:220px; overflow-y:auto; padding:6px; display:flex; flex-direction:column; gap:2px; }
.altms-option { display:flex; align-items:center; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; transition:background .1s; }
.altms-option:hover { background:var(--bg); }
.altms-option--checked { background:var(--accent-light); }
.altms-check { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
.altms-check-box { width:16px; height:16px; border:1.5px solid var(--border); border-radius:4px; display:flex; align-items:center; justify-content:center; flex-shrink:0; transition:background .1s,border-color .1s; }
.altms-option--checked .altms-check-box { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
.altms-opt-label { font-family:${L.bodyFont}; font-size:.875rem; color:var(--text-primary); }
.altms-clear { width:100%; padding:8px 14px; border-top:1px solid var(--border); background:none; border-left:none; border-right:none; border-bottom:none; font-family:${L.bodyFont}; font-size:.8125rem; color:var(--danger); cursor:pointer; text-align:start; transition:background .1s; }
.altms-clear:hover { background:var(--danger-light); }
/* ── filter bar ── */
.alt-filter-bar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:36px; padding:14px 20px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); }
.alt-filter-label { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); white-space:nowrap; }
.alt-filter-sep { width:1px; height:20px; background:var(--border); flex-shrink:0; }
.alt-filter-reset { margin-inline-start:auto; font-size:.8125rem; color:var(--text-muted); background:none; border:none; cursor:pointer; padding:4px 6px; transition:color .15s; font-family:${L.bodyFont}; }
.alt-filter-reset:hover { color:var(--danger); }
/* ── metrics ── */
.alt-metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:1px; background:var(--border); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; margin-bottom:40px; }
.alt-metric { background:var(--surface); padding:24px 22px; }
.alt-metric-title { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:0 0 12px; }
.alt-metric-value { font-family:${L.displayFont}; font-size:1.875rem; font-weight:400; color:var(--text-primary); line-height:1; margin-bottom:10px; letter-spacing:-.02em; }
.alt-metric-footer { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.alt-change { font-size:.75rem; font-weight:600; padding:2px 8px; border-radius:20px; white-space:nowrap; font-family:${L.bodyFont}; }
.alt-change--up { background:var(--success-light); color:var(--success); }
.alt-change--down { background:var(--danger-light); color:var(--danger); }
.alt-change--flat { background:var(--muted-light); color:var(--text-muted); }
.alt-metric-prev { font-size:.75rem; color:var(--text-muted); font-family:${L.monoFont}; }
/* ── charts ── */
.alt-charts { display:grid; grid-template-columns:1fr; gap:24px; }
.alt-chart-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:28px 28px 24px; }
.alt-chart-header { display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:24px; gap:16px; flex-wrap:wrap; }
.alt-chart-title { font-family:${L.displayFont}; font-size:1.375rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; font-style:italic; }
.alt-chart-controls { display:flex; gap:6px; flex-wrap:wrap; }
.alt-ctrl { font-family:${L.bodyFont}; font-size:.75rem; font-weight:500; padding:4px 10px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text-secondary); cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
.alt-ctrl:hover { border-color:var(--accent); color:var(--accent); }
.alt-ctrl-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; }
.alt-ctrl-sep { width:1px; height:20px; background:var(--border); align-self:center; }
.alt-chart-wrap { position:relative; height:280px; overflow:hidden; direction:ltr; }
/* ── section heading ── */
.alt-section-heading { display:flex; align-items:center; gap:12px; margin:0 0 20px; }
.alt-section-heading h2 { font-family:${L.displayFont}; font-size:1.375rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; }
.alt-section-heading::after { content:''; flex:1; height:1px; background:var(--border); }
/* ── responsive ── */
@media (max-width:680px) {
.alt-period-row { grid-template-columns:1fr; }
.alt-card--current,.alt-card--previous { border-radius:var(--radius); }
.alt-vs { flex-direction:row; padding:10px 0; }
.alt-vs-line { position:static; width:100%; height:1px; }
.alt-period-name { font-size:1.75rem; }
.alt-metrics { grid-template-columns:1fr 1fr; }
.alt-page-title { font-size:1.75rem; }
.alt-chart-header { flex-direction:column; }
}
`}</style>
<Link to={L.backTo} className="alt-back">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{ transform: L.dir==='rtl' ? 'scaleX(-1)' : undefined }}>
<path d="M9 2L4 7L9 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
{L.backLink}
</Link>
<h1 className="alt-page-title">{L.pageTitle}</h1>
<p className="alt-page-sub">{L.pageSub}</p>
<div className="alt-period-row">
<PeriodCard role={L.currentRole} hint={L.currentHint} start={currStart} end={currEnd} variant="current"
onChange={handleCurr} availableYears={availableYears} L={L} />
<div className="alt-vs">
<div className="alt-vs-line" />
<span className="alt-vs-badge">{L.vs}</span>
</div>
<PeriodCard role={L.previousRole} hint={L.previousHint} start={prevStart} end={prevEnd} variant="previous"
onChange={(s,e) => { setPrevStart(s); setPrevEnd(e); }} availableYears={availableYears} L={L} />
</div>
<div className="alt-filter-bar">
<span className="alt-filter-label">{L.filter}</span>
<div className="alt-filter-sep" />
<AltMultiSelect value={selDistricts} options={districts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
<AltMultiSelect value={selChannels} options={channels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
<AltMultiSelect value={selMuseums} options={museums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} />
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
<div className="alt-vat-toggle" style={{ marginInlineStart: 'auto' }}>
<button type="button" className={`alt-vat-opt${activeLang==='en'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('en')}>EN</button>
<button type="button" className={`alt-vat-opt${activeLang==='ar'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('ar')}>AR</button>
</div>
</div>
<div className="alt-section-heading"><h2>{L.keyMetrics}</h2></div>
<div className="alt-metrics">
<MetricCard title={L.revenue} curr={currM.revenue} prev={prevM.revenue} isCurrency newLabel={L.newLabel} />
<MetricCard title={L.visitors} curr={currM.visitors} prev={prevM.visitors} newLabel={L.newLabel} />
<MetricCard title={L.tickets} curr={currM.tickets} prev={prevM.tickets} newLabel={L.newLabel} />
<MetricCard title={L.avgRev} curr={currM.avgRevPerVisitor} prev={prevM.avgRevPerVisitor} isCurrency newLabel={L.newLabel} />
{currPilgrims!==null && prevPilgrims!==null &&
<MetricCard title={L.pilgrims} curr={currPilgrims} prev={prevPilgrims} newLabel={L.newLabel} />}
{currCapture!==null && prevCapture!==null &&
<MetricCard title={L.captureRate} curr={parseFloat(currCapture.toFixed(2))} prev={parseFloat((prevCapture??0).toFixed(2))} newLabel={L.newLabel} />}
</div>
<div className="alt-charts">
<div className="alt-chart-card">
<div className="alt-chart-header">
<h3 className="alt-chart-title">{L.trendTitle}</h3>
<div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" />
{granOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
</div>
</div>
<div className="alt-chart-wrap"><Line data={trendData} options={chartOpts} /></div>
</div>
<div className="alt-chart-card">
<div className="alt-chart-header">
<h3 className="alt-chart-title">{L.museumTitle}</h3>
<div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
</div>
</div>
<div className="alt-chart-wrap"><Bar data={museumData} options={chartOpts} /></div>
</div>
</div>
</div>
);
}
-643
View File
@@ -1,643 +0,0 @@
import React, { useState, useMemo, useCallback } from 'react';
import { Line, Bar } from 'react-chartjs-2';
import { chartColors, createBaseOptions } from '../config/chartConfig';
import { useLanguage } from '../contexts/LanguageContext';
import {
filterDataByDateRange,
calculateMetrics,
formatCompact,
formatCompactCurrency,
getUniqueChannels,
getUniqueMuseums
} from '../services/dataService';
import JSZip from 'jszip';
import type {
MuseumRecord,
SlideConfig,
ChartTypeOption,
MetricOption,
MetricFieldInfo,
SlidesProps
} from '../types';
interface SlideEditorProps {
slide: SlideConfig;
onUpdate: (updates: Partial<SlideConfig>) => void;
channels: string[];
museums: string[];
data: MuseumRecord[];
chartTypes: ChartTypeOption[];
metrics: MetricOption[];
}
interface SlidePreviewProps {
slide: SlideConfig;
data: MuseumRecord[];
channels: string[];
museums: string[];
metrics: MetricOption[];
}
interface PreviewModeProps {
slides: SlideConfig[];
data: MuseumRecord[];
channels: string[];
museums: string[];
currentSlide: number;
setCurrentSlide: React.Dispatch<React.SetStateAction<number>>;
onExit: () => void;
metrics: MetricOption[];
}
function Slides({ data }: SlidesProps) {
const { t } = useLanguage();
const CHART_TYPES: ChartTypeOption[] = useMemo(() => [
{ id: 'trend', label: t('slides.revenueTrend'), icon: '📈' },
{ id: 'museum-bar', label: t('slides.byMuseum'), icon: '📊' },
{ id: 'kpi-cards', label: t('slides.kpiSummary'), icon: '🎯' },
{ id: 'comparison', label: t('slides.yoyComparison'), icon: '⚖️' }
], [t]);
const METRICS: MetricOption[] = useMemo(() => [
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_gross' },
{ id: 'visitors', label: t('metrics.visitors'), field: 'visits' },
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
], [t]);
const [slides, setSlides] = useState<SlideConfig[]>([]);
const [editingSlide, setEditingSlide] = useState<number | null>(null);
const [previewMode, setPreviewMode] = useState(false);
const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0);
const channels = useMemo(() => getUniqueChannels(data), [data]);
const museums = useMemo(() => getUniqueMuseums(data), [data]);
const defaultSlideConfig: Omit<SlideConfig, 'id'> = {
title: 'Slide Title',
chartType: 'trend',
metric: 'revenue',
startDate: '2026-01-01',
endDate: '2026-01-31',
channel: 'all',
museum: 'all',
showComparison: false
};
const addSlide = () => {
const newSlide: SlideConfig = {
id: Date.now(),
...defaultSlideConfig,
title: `Slide ${slides.length + 1}`
};
setSlides([...slides, newSlide]);
setEditingSlide(newSlide.id);
};
const updateSlide = (id: number, updates: Partial<SlideConfig>) => {
setSlides(slides.map(s => s.id === id ? { ...s, ...updates } : s));
};
const removeSlide = (id: number) => {
setSlides(slides.filter(s => s.id !== id));
if (editingSlide === id) setEditingSlide(null);
};
const moveSlide = (id: number, direction: number) => {
const index = slides.findIndex(s => s.id === id);
if ((direction === -1 && index === 0) || (direction === 1 && index === slides.length - 1)) return;
const newSlides = [...slides];
[newSlides[index], newSlides[index + direction]] = [newSlides[index + direction], newSlides[index]];
setSlides(newSlides);
};
const duplicateSlide = (id: number) => {
const slide = slides.find(s => s.id === id);
if (slide) {
const newSlide: SlideConfig = { ...slide, id: Date.now(), title: `${slide.title} (copy)` };
const index = slides.findIndex(s => s.id === id);
const newSlides = [...slides];
newSlides.splice(index + 1, 0, newSlide);
setSlides(newSlides);
}
};
const exportAsHTML = async () => {
const zip = new JSZip();
// Generate HTML for each slide
const slidesHTML = slides.map((slide, index) => {
return generateSlideHTML(slide, index, data);
}).join('\n');
const fullHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HiHala Data Presentation</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f172a; }
.slide {
width: 100vw; height: 100vh;
display: flex; flex-direction: column;
justify-content: center; align-items: center;
padding: 60px; background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
page-break-after: always;
}
.slide-title {
color: #f8fafc; font-size: 2.5rem; font-weight: 600;
margin-bottom: 40px; text-align: center;
}
.slide-subtitle {
color: #94a3b8; font-size: 1.1rem; margin-bottom: 30px;
}
.chart-container {
width: 100%; max-width: 900px; height: 400px;
background: rgba(255,255,255,0.03); border-radius: 16px;
padding: 30px;
}
.kpi-grid {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: 30px; width: 100%; max-width: 900px;
}
.kpi-card {
background: rgba(255,255,255,0.05); border-radius: 16px;
padding: 30px; text-align: center;
}
.kpi-value { color: #3b82f6; font-size: 2.5rem; font-weight: 700; }
.kpi-label { color: #94a3b8; font-size: 1rem; margin-top: 8px; }
.logo { position: absolute; bottom: 30px; right: 40px; opacity: 0.6; }
.logo svg { height: 30px; }
.slide-number {
position: absolute; bottom: 30px; left: 40px;
color: #475569; font-size: 0.9rem;
}
@media print {
.slide { page-break-after: always; }
}
</style>
</head>
<body>
${slidesHTML}
<script>
// Chart.js initialization scripts will be here
${generateChartScripts(slides, data)}
</script>
</body>
</html>`;
zip.file('presentation.html', fullHTML);
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'hihala-presentation.zip';
a.click();
URL.revokeObjectURL(url);
};
if (previewMode) {
return (
<PreviewMode
slides={slides}
data={data}
channels={channels}
museums={museums}
currentSlide={currentPreviewSlide}
setCurrentSlide={setCurrentPreviewSlide}
onExit={() => setPreviewMode(false)}
metrics={METRICS}
/>
);
}
return (
<div className="slides-builder">
<div className="page-title">
<h1>{t('slides.title')}</h1>
<p>{t('slides.subtitle')}</p>
</div>
<div className="slides-toolbar">
<button className="btn-primary" onClick={addSlide}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{t('slides.addSlide')}
</button>
{slides.length > 0 && (
<>
<button className="btn-secondary" onClick={() => setPreviewMode(true)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
{t('slides.preview')}
</button>
<button className="btn-secondary" onClick={exportAsHTML}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
{t('slides.exportHtml')}
</button>
</>
)}
</div>
<div className="slides-workspace">
<div className="slides-list">
<h3>{t('slides.slidesCount')} ({slides.length})</h3>
{slides.length === 0 ? (
<div className="empty-slides">
<p>{t('slides.noSlides')}</p>
<button onClick={addSlide}>{t('slides.addFirst')}</button>
</div>
) : (
<div className="slides-thumbnails">
{slides.map((slide, index) => (
<div
key={slide.id}
className={`slide-thumbnail ${editingSlide === slide.id ? 'active' : ''}`}
onClick={() => setEditingSlide(slide.id)}
>
<div className="slide-number">{index + 1}</div>
<div className="slide-icon">{CHART_TYPES.find(c => c.id === slide.chartType)?.icon}</div>
<div className="slide-title-preview">{slide.title}</div>
<div className="slide-actions">
<button onClick={(e) => { e.stopPropagation(); moveSlide(slide.id, -1); }} disabled={index === 0}></button>
<button onClick={(e) => { e.stopPropagation(); moveSlide(slide.id, 1); }} disabled={index === slides.length - 1}></button>
<button onClick={(e) => { e.stopPropagation(); duplicateSlide(slide.id); }}></button>
<button onClick={(e) => { e.stopPropagation(); removeSlide(slide.id); }} className="delete">×</button>
</div>
</div>
))}
</div>
)}
</div>
{editingSlide && (
<SlideEditor
slide={slides.find(s => s.id === editingSlide)!}
onUpdate={(updates) => updateSlide(editingSlide, updates)}
channels={channels}
museums={museums}
data={data}
chartTypes={CHART_TYPES}
metrics={METRICS}
/>
)}
</div>
</div>
);
}
function SlideEditor({ slide, onUpdate, channels, museums, data, chartTypes, metrics }: SlideEditorProps) {
const { t } = useLanguage();
return (
<div className="slide-editor">
<div className="editor-section">
<label>{t('slides.slideTitle')}</label>
<input
type="text"
value={slide.title}
onChange={e => onUpdate({ title: e.target.value })}
placeholder={t('slides.slideTitle')}
/>
</div>
<div className="editor-section">
<label>{t('slides.chartType')}</label>
<div className="chart-type-grid">
{chartTypes.map((type: ChartTypeOption) => (
<button
key={type.id}
className={`chart-type-btn ${slide.chartType === type.id ? 'active' : ''}`}
onClick={() => onUpdate({ chartType: type.id })}
>
<span className="chart-icon">{type.icon}</span>
<span>{type.label}</span>
</button>
))}
</div>
</div>
<div className="editor-section">
<label>{t('slides.metric')}</label>
<select value={slide.metric} onChange={e => onUpdate({ metric: e.target.value })}>
{metrics.map((m: MetricOption) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</div>
<div className="editor-row">
<div className="editor-section">
<label>{t('slides.startDate')}</label>
<input type="date" value={slide.startDate} onChange={e => onUpdate({ startDate: e.target.value })} />
</div>
<div className="editor-section">
<label>{t('slides.endDate')}</label>
<input type="date" value={slide.endDate} onChange={e => onUpdate({ endDate: e.target.value })} />
</div>
</div>
<div className="editor-row">
<div className="editor-section">
<label>{t('filters.channel')}</label>
<select value={slide.channel} onChange={e => onUpdate({ channel: e.target.value, museum: 'all' })}>
<option value="all">{t('filters.allChannels')}</option>
{channels.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
</div>
<div className="editor-section">
<label>{t('filters.museum')}</label>
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
<option value="all">{t('filters.allMuseums')}</option>
{museums.map((m: string) => <option key={m} value={m}>{m}</option>)}
</select>
</div>
</div>
{slide.chartType === 'comparison' && (
<div className="editor-section">
<label>
<input
type="checkbox"
checked={slide.showComparison}
onChange={e => onUpdate({ showComparison: e.target.checked })}
/>
{t('slides.showYoY')}
</label>
</div>
)}
<div className="slide-preview-box">
<h4>{t('slides.preview')}</h4>
<SlidePreview slide={slide} data={data} channels={channels} museums={museums} metrics={metrics} />
</div>
</div>
);
}
// Static field mapping for charts (Chart.js labels don't need i18n)
const METRIC_FIELDS: Record<string, MetricFieldInfo> = {
revenue: { field: 'revenue_gross', label: 'Revenue' },
visitors: { field: 'visits', label: 'Visitors' },
tickets: { field: 'tickets', label: 'Tickets' }
};
function SlidePreview({ slide, data, channels, museums, metrics }: SlidePreviewProps) {
const { t } = useLanguage();
const filteredData = useMemo(() =>
filterDataByDateRange(data, slide.startDate, slide.endDate, {
channel: slide.channel ? [slide.channel] : [],
museum: slide.museum ? [slide.museum] : []
}),
[data, slide.startDate, slide.endDate, slide.channel, slide.museum]
);
const metricsData = useMemo(() => calculateMetrics(filteredData), [filteredData]);
const baseOptions = useMemo(() => createBaseOptions(false), []);
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
const fieldMap: Record<string, string> = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' };
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as unknown as Record<string, unknown>)[fieldMap[metric]] || 0)), 0);
}, []);
const trendData = useMemo(() => {
const grouped: Record<string, MuseumRecord[]> = {};
filteredData.forEach(row => {
if (!row.date) return;
const weekStart = row.date.substring(0, 10);
if (!grouped[weekStart]) grouped[weekStart] = [];
grouped[weekStart].push(row);
});
const sortedDates = Object.keys(grouped).sort();
const metricLabel = metrics?.find((m: MetricOption) => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
return {
labels: sortedDates.map(d => d.substring(5)),
datasets: [{
label: metricLabel,
data: sortedDates.map(d => getMetricValue(grouped[d], slide.metric)),
borderColor: chartColors.primary,
backgroundColor: chartColors.primary + '20',
fill: true,
tension: 0.4
}]
};
}, [filteredData, slide.metric, getMetricValue, metrics]);
const museumData = useMemo(() => {
const byMuseum: Record<string, MuseumRecord[]> = {};
filteredData.forEach(row => {
if (!row.museum_name) return;
if (!byMuseum[row.museum_name]) byMuseum[row.museum_name] = [];
byMuseum[row.museum_name].push(row);
});
const museums = Object.keys(byMuseum).sort();
const metricLabel = metrics?.find((m: MetricOption) => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
return {
labels: museums,
datasets: [{
label: metricLabel,
data: museums.map(m => getMetricValue(byMuseum[m], slide.metric)),
backgroundColor: chartColors.primary,
borderRadius: 6
}]
};
}, [filteredData, slide.metric, getMetricValue, metrics]);
if (slide.chartType === 'kpi-cards') {
return (
<div className="preview-kpis">
<div className="preview-kpi">
<div className="kpi-value">{formatCompactCurrency(metricsData.revenue)}</div>
<div className="kpi-label">{t('metrics.revenue')}</div>
</div>
<div className="preview-kpi">
<div className="kpi-value">{formatCompact(metricsData.visitors)}</div>
<div className="kpi-label">{t('metrics.visitors')}</div>
</div>
<div className="preview-kpi">
<div className="kpi-value">{formatCompact(metricsData.tickets)}</div>
<div className="kpi-label">{t('metrics.tickets')}</div>
</div>
</div>
);
}
if (slide.chartType === 'museum-bar') {
return (
<div className="preview-chart">
<Bar data={museumData} options={{ ...baseOptions, indexAxis: 'y' }} />
</div>
);
}
return (
<div className="preview-chart">
<Line data={trendData} options={baseOptions} />
</div>
);
}
function PreviewMode({ slides, data, channels, museums, currentSlide, setCurrentSlide, onExit, metrics }: PreviewModeProps) {
const { t } = useLanguage();
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === ' ') {
setCurrentSlide((prev: number) => Math.min(prev + 1, slides.length - 1));
} else if (e.key === 'ArrowLeft') {
setCurrentSlide((prev: number) => Math.max(prev - 1, 0));
} else if (e.key === 'Escape') {
onExit();
}
}, [slides.length, setCurrentSlide, onExit]);
React.useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const slide = slides[currentSlide];
return (
<div className="preview-fullscreen">
<div className="preview-slide">
<h1 className="preview-title">{slide?.title}</h1>
<div className="preview-content">
{slide && <SlidePreview slide={slide} data={data} channels={channels} museums={museums} metrics={metrics} />}
</div>
<div className="preview-footer">
<span>{currentSlide + 1} / {slides.length}</span>
</div>
</div>
<div className="preview-controls">
<button onClick={() => setCurrentSlide((prev: number) => Math.max(prev - 1, 0))} disabled={currentSlide === 0}></button>
<button onClick={() => setCurrentSlide((prev: number) => Math.min(prev + 1, slides.length - 1))} disabled={currentSlide === slides.length - 1}></button>
<button onClick={onExit}>{t('slides.exit')}</button>
</div>
</div>
);
}
// Helper functions for HTML export
function generateSlideHTML(slide: SlideConfig, index: number, data: MuseumRecord[]): string {
const chartType = slide.chartType;
const canvasId = `chart-${index}`;
return `
<div class="slide" id="slide-${index}">
<h1 class="slide-title">${slide.title}</h1>
<p class="slide-subtitle">${formatDateRange(slide.startDate, slide.endDate)}</p>
${chartType === 'kpi-cards' ? generateKPIHTML(slide, data) : `<div class="chart-container"><canvas id="${canvasId}"></canvas></div>`}
<div class="slide-number">Slide ${index + 1}</div>
<div class="logo">
<svg width="120" height="24" viewBox="0 0 120 24">
<text x="0" y="18" fill="#64748b" font-family="system-ui" font-size="14" font-weight="600">HiHala Data</text>
</svg>
</div>
</div>`;
}
function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string {
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
channel: slide.channel ? [slide.channel] : [],
museum: slide.museum ? [slide.museum] : []
});
const metrics = calculateMetrics(filtered);
return `
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-value">${formatCompactCurrency(metrics.revenue)}</div>
<div class="kpi-label">Revenue</div>
</div>
<div class="kpi-card">
<div class="kpi-value">${formatCompact(metrics.visitors)}</div>
<div class="kpi-label">Visitors</div>
</div>
<div class="kpi-card">
<div class="kpi-value">${formatCompact(metrics.tickets)}</div>
<div class="kpi-label">Tickets</div>
</div>
</div>`;
}
function generateChartScripts(slides: SlideConfig[], data: MuseumRecord[]): string {
return slides.map((slide: SlideConfig, index: number) => {
if (slide.chartType === 'kpi-cards') return '';
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
channel: slide.channel ? [slide.channel] : [],
museum: slide.museum ? [slide.museum] : []
});
const chartConfig = generateChartConfig(slide, filtered);
return `
new Chart(document.getElementById('chart-${index}'), ${JSON.stringify(chartConfig)});
`;
}).join('\n');
}
function generateChartConfig(slide: SlideConfig, data: MuseumRecord[]): object {
const fieldMap: Record<string, keyof MuseumRecord> = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' };
const field = fieldMap[slide.metric];
if (slide.chartType === 'museum-bar') {
const byMuseum: Record<string, number> = {};
data.forEach((row: MuseumRecord) => {
if (!row.museum_name) return;
byMuseum[row.museum_name] = (byMuseum[row.museum_name] || 0) + parseFloat(String(row[field] || 0));
});
const museums = Object.keys(byMuseum).sort();
return {
type: 'bar',
data: {
labels: museums,
datasets: [{
data: museums.map(m => byMuseum[m]),
backgroundColor: '#3b82f6',
borderRadius: 6
}]
},
options: { indexAxis: 'y', plugins: { legend: { display: false } } }
};
}
// Default: trend line
const grouped: Record<string, number> = {};
data.forEach((row: MuseumRecord) => {
if (!row.date) return;
grouped[row.date] = (grouped[row.date] || 0) + parseFloat(String(row[field] || 0));
});
const dates = Object.keys(grouped).sort();
return {
type: 'line',
data: {
labels: dates.map(d => d.substring(5)),
datasets: [{
data: dates.map(d => grouped[d]),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59,130,246,0.1)',
fill: true,
tension: 0.4
}]
},
options: { plugins: { legend: { display: false } } }
};
}
function formatDateRange(start: string, end: string): string {
const s = new Date(start);
const e = new Date(end);
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' };
return `${s.toLocaleDateString('en-US', opts)} ${e.toLocaleDateString('en-US', opts)}`;
}
export default Slides;
-151
View File
@@ -1,151 +0,0 @@
import React, { useRef, useCallback, useState, ReactNode, KeyboardEvent, TouchEvent } from 'react';
interface CarouselProps {
children: ReactNode;
activeIndex: number;
setActiveIndex: (index: number) => void;
labels?: string[];
showLabels?: boolean;
className?: string;
}
function Carousel({
children,
activeIndex,
setActiveIndex,
labels = [],
showLabels = true,
className = ''
}: CarouselProps) {
const touchStartX = useRef<number | null>(null);
const touchStartY = useRef<number | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState(0);
const itemCount = React.Children.count(children);
// Threshold for swipe detection
const SWIPE_THRESHOLD = 50;
const VELOCITY_THRESHOLD = 0.3;
const handleTouchStart = useCallback((e: TouchEvent<HTMLDivElement>) => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
setIsDragging(true);
setDragOffset(0);
}, []);
const handleTouchMove = useCallback((e: TouchEvent<HTMLDivElement>) => {
if (!touchStartX.current || !isDragging) return;
const currentX = e.touches[0].clientX;
const currentY = e.touches[0].clientY;
const diffX = currentX - touchStartX.current;
const diffY = currentY - (touchStartY.current || 0);
// Only handle horizontal swipes
if (Math.abs(diffX) > Math.abs(diffY)) {
e.preventDefault();
// Add resistance at edges
let offset = diffX;
if ((activeIndex === 0 && diffX > 0) || (activeIndex === itemCount - 1 && diffX < 0)) {
offset = diffX * 0.3; // Rubber band effect
}
setDragOffset(offset);
}
}, [isDragging, activeIndex, itemCount]);
const handleTouchEnd = useCallback((e: TouchEvent<HTMLDivElement>) => {
if (!touchStartX.current || !isDragging) return;
const endX = e.changedTouches[0].clientX;
const diff = touchStartX.current - endX;
const velocity = Math.abs(diff) / 200; // Rough velocity calc
// Determine if we should change slide
if (Math.abs(diff) > SWIPE_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
if (diff > 0 && activeIndex < itemCount - 1) {
setActiveIndex(activeIndex + 1);
} else if (diff < 0 && activeIndex > 0) {
setActiveIndex(activeIndex - 1);
}
}
// Reset
touchStartX.current = null;
touchStartY.current = null;
setIsDragging(false);
setDragOffset(0);
}, [isDragging, activeIndex, setActiveIndex, itemCount]);
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'ArrowLeft' && activeIndex > 0) {
setActiveIndex(activeIndex - 1);
} else if (e.key === 'ArrowRight' && activeIndex < itemCount - 1) {
setActiveIndex(activeIndex + 1);
}
}, [activeIndex, setActiveIndex, itemCount]);
// Calculate transform
const baseTransform = -(activeIndex * 100);
const dragPercentage = trackRef.current ? (dragOffset / trackRef.current.offsetWidth) * 100 : 0;
const transform = baseTransform + dragPercentage;
return (
<div
className={`carousel ${className}`}
onKeyDown={handleKeyDown}
tabIndex={0}
role="region"
aria-label="Carousel"
>
<div className="carousel-container">
<div className="carousel-viewport">
<div
ref={trackRef}
className="carousel-track"
style={{
transform: `translateX(${transform}%)`,
transition: isDragging ? 'none' : 'transform 400ms cubic-bezier(0.25, 0.46, 0.45, 0.94)'
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{React.Children.map(children, (child, i) => (
<div
className="carousel-slide"
key={i}
role="tabpanel"
aria-hidden={activeIndex !== i}
aria-label={labels[i] || `Slide ${i + 1}`}
>
{child}
</div>
))}
</div>
</div>
</div>
<div className={`carousel-dots ${showLabels ? 'labeled' : ''}`} role="tablist">
{Array.from({ length: itemCount }).map((_, i) => (
<button
key={i}
className={`carousel-dot ${activeIndex === i ? 'active' : ''}`}
onClick={() => setActiveIndex(i)}
role="tab"
aria-selected={activeIndex === i}
aria-label={labels[i] || `Slide ${i + 1}`}
aria-controls={`slide-${i}`}
>
{showLabels && labels[i] && (
<span className="dot-label">{labels[i]}</span>
)}
</button>
))}
</div>
</div>
);
}
export default Carousel;
-37
View File
@@ -1,37 +0,0 @@
import React, { ReactNode } from 'react';
interface ChartCardProps {
title?: string;
children: ReactNode;
className?: string;
headerRight?: ReactNode;
fullWidth?: boolean;
halfWidth?: boolean;
}
function ChartCard({
title,
children,
className = '',
headerRight = null,
fullWidth = false,
halfWidth = false
}: ChartCardProps) {
const sizeClass = fullWidth ? 'full-width' : halfWidth ? 'half-width' : '';
return (
<div className={`chart-card ${sizeClass} ${className}`}>
{(title || headerRight) && (
<div className="chart-card-header">
{title && <h2>{title}</h2>}
{headerRight && <div className="chart-card-actions">{headerRight}</div>}
</div>
)}
<div className="chart-container">
{children}
</div>
</div>
);
}
export default ChartCard;
-247
View File
@@ -1,247 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import type { Season } from '../../types';
interface Props {
startDate: string;
endDate: string;
onChange: (start: string, end: string) => void;
availableYears: number[];
seasons?: Season[];
}
const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
const MONTH_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const MONTH_FULL = ['January','February','March','April','May','June','July','August','September','October','November','December'];
function isLeap(y: number) { return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; }
function makePresets(y: number): Record<string, { start: string; end: string }> {
const feb = isLeap(y) ? 29 : 28;
return {
jan: { start: `${y}-01-01`, end: `${y}-01-31` },
feb: { start: `${y}-02-01`, end: `${y}-02-${String(feb).padStart(2,'0')}` },
mar: { start: `${y}-03-01`, end: `${y}-03-31` },
apr: { start: `${y}-04-01`, end: `${y}-04-30` },
may: { start: `${y}-05-01`, end: `${y}-05-31` },
jun: { start: `${y}-06-01`, end: `${y}-06-30` },
jul: { start: `${y}-07-01`, end: `${y}-07-31` },
aug: { start: `${y}-08-01`, end: `${y}-08-31` },
sep: { start: `${y}-09-01`, end: `${y}-09-30` },
oct: { start: `${y}-10-01`, end: `${y}-10-31` },
nov: { start: `${y}-11-01`, end: `${y}-11-30` },
dec: { start: `${y}-12-01`, end: `${y}-12-31` },
q1: { start: `${y}-01-01`, end: `${y}-03-31` },
q2: { start: `${y}-04-01`, end: `${y}-06-30` },
q3: { start: `${y}-07-01`, end: `${y}-09-30` },
q4: { start: `${y}-10-01`, end: `${y}-12-31` },
h1: { start: `${y}-01-01`, end: `${y}-06-30` },
h2: { start: `${y}-07-01`, end: `${y}-12-31` },
full:{ start: `${y}-01-01`, end: `${y}-12-31` },
};
}
function guessPreset(start: string, end: string): { key: string; year: number } | null {
const year = parseInt(start.slice(0, 4));
const presets = makePresets(year);
for (const [key, range] of Object.entries(presets)) {
if (range.start === start && range.end === end) return { key, year };
}
return null;
}
function formatTriggerLabel(start: string, end: string, seasons: Season[]): string {
for (const s of seasons) {
if (s.StartDate === start && s.EndDate === end) return `${s.Name} ${s.HijriYear}`;
}
const year = parseInt(start.slice(0, 4));
const presets = makePresets(year);
for (const [key, range] of Object.entries(presets)) {
if (range.start !== start || range.end !== end) continue;
const mi = MONTH_KEYS.indexOf(key);
if (mi >= 0) return `${MONTH_FULL[mi]} ${year}`;
if (key === 'full') return String(year);
return `${key.toUpperCase()} ${year}`;
}
// Custom range
const fmt = (d: string) => {
const [, m, day] = d.split('-');
return `${parseInt(day)} ${MONTH_SHORT[parseInt(m) - 1]}`;
};
const sy = parseInt(start.slice(0, 4));
const ey = parseInt(end.slice(0, 4));
return sy === ey ? `${fmt(start)} ${fmt(end)} ${sy}` : `${fmt(start)} ${sy} ${fmt(end)} ${ey}`;
}
export default function DateRangePicker({ startDate, endDate, onChange, availableYears, seasons = [] }: Props) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [year, setYear] = useState(() => {
const g = guessPreset(startDate, endDate);
return g?.year ?? parseInt(startDate.slice(0, 4)) ?? new Date().getFullYear();
});
const [activePreset, setActivePreset] = useState<string | null>(() => guessPreset(startDate, endDate)?.key ?? null);
useEffect(() => {
const g = guessPreset(startDate, endDate);
setActivePreset(g?.key ?? null);
if (g) setYear(g.year);
else setYear(parseInt(startDate.slice(0, 4)) || new Date().getFullYear());
}, [startDate, endDate]);
useEffect(() => {
if (!open) return;
const onMouse = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onMouse);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onMouse);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const selectPreset = (key: string) => {
const range = makePresets(year)[key];
if (!range) return;
setActivePreset(key);
onChange(range.start, range.end);
setOpen(false);
};
const selectSeason = (s: Season) => {
setActivePreset(`season-${s.Id}`);
onChange(s.StartDate, s.EndDate);
setOpen(false);
};
const shiftYear = (delta: number) => {
const next = year + delta;
const min = availableYears.length ? Math.min(...availableYears) : year - 10;
const max = availableYears.length ? Math.max(...availableYears) : year + 10;
if (next < min || next > max) return;
setYear(next);
if (activePreset && !activePreset.startsWith('season-')) {
const range = makePresets(next)[activePreset];
if (range) onChange(range.start, range.end);
}
};
const minYear = availableYears.length ? Math.min(...availableYears) : year - 10;
const maxYear = availableYears.length ? Math.max(...availableYears) : year + 10;
const label = formatTriggerLabel(startDate, endDate, seasons);
return (
<div className="drp" ref={ref}>
<button
type="button"
className={`drp-trigger${open ? ' drp-open' : ''}`}
onClick={() => setOpen(v => !v)}
aria-haspopup="true"
aria-expanded={open}
aria-label={label}
>
<svg className="drp-cal-icon" width="14" height="14" viewBox="0 0 16 16" fill="none">
<rect x="1" y="3" width="14" height="12" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<path d="M5 1v4M11 1v4M1 7h14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
<span className="drp-trigger-label">{label}</span>
<svg className="drp-chevron" width="10" height="10" viewBox="0 0 10 10" fill="none"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }}>
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{open && (
<div className="drp-panel" role="dialog">
{/* Year navigation */}
<div className="drp-year-row">
<button type="button" className="drp-year-btn" onClick={() => shiftYear(-1)} disabled={year <= minYear}>
<svg width="8" height="12" viewBox="0 0 8 12" fill="none"><path d="M6 10L2 6L6 2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
<span className="drp-year-val">{year}</span>
<button type="button" className="drp-year-btn" onClick={() => shiftYear(1)} disabled={year >= maxYear}>
<svg width="8" height="12" viewBox="0 0 8 12" fill="none"><path d="M2 2L6 6L2 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
</div>
{/* Month chips */}
<div className="drp-group-label">Month</div>
<div className="drp-chips">
{MONTH_KEYS.map((k, i) => (
<button key={k} type="button"
className={`drp-chip${activePreset === k ? ' drp-chip-active' : ''}`}
onClick={() => selectPreset(k)}>
{MONTH_SHORT[i]}
</button>
))}
</div>
{/* Quarter / Half / Full */}
<div className="drp-group-label">Quarter · Half · Year</div>
<div className="drp-chips">
{['q1','q2','q3','q4'].map(k => (
<button key={k} type="button"
className={`drp-chip${activePreset === k ? ' drp-chip-active' : ''}`}
onClick={() => selectPreset(k)}>
{k.toUpperCase()}
</button>
))}
{['h1','h2'].map(k => (
<button key={k} type="button"
className={`drp-chip${activePreset === k ? ' drp-chip-active' : ''}`}
onClick={() => selectPreset(k)}>
{k.toUpperCase()}
</button>
))}
<button type="button"
className={`drp-chip drp-chip-wide${activePreset === 'full' ? ' drp-chip-active' : ''}`}
onClick={() => selectPreset('full')}>
Full Year
</button>
</div>
{/* Seasons */}
{seasons.length > 0 && (
<>
<div className="drp-group-label">Seasons</div>
<div className="drp-chips">
{seasons.map(s => (
<button key={s.Id} type="button"
className={`drp-chip drp-chip-season${activePreset === `season-${s.Id}` ? ' drp-chip-active' : ''}`}
onClick={() => selectSeason(s)}
style={{ '--sc': s.Color } as React.CSSProperties}>
{s.Name} {s.HijriYear}
</button>
))}
</div>
</>
)}
{/* Custom date inputs */}
<div className="drp-divider" />
<div className="drp-custom">
<div className="drp-custom-field">
<label>From</label>
<input type="date" value={startDate}
onChange={e => { setActivePreset(null); onChange(e.target.value, endDate); }} />
</div>
<div className="drp-custom-sep"></div>
<div className="drp-custom-field">
<label>To</label>
<input type="date" value={endDate}
onChange={e => { setActivePreset(null); onChange(startDate, e.target.value); }} />
</div>
</div>
</div>
)}
</div>
);
}
-44
View File
@@ -1,44 +0,0 @@
import React from 'react';
interface EmptyStateProps {
icon?: string;
title?: string;
message?: string;
action?: (() => void) | null;
actionLabel?: string;
className?: string;
}
function EmptyState({
icon = '📊',
title,
message,
action = null,
actionLabel = 'Try Again',
className = ''
}: EmptyStateProps) {
return (
<div className={`empty-state ${className}`}>
<div className="empty-state-icon" role="img" aria-hidden="true">
{icon}
</div>
{title && (
<h3 className="empty-state-title">{title}</h3>
)}
{message && (
<p className="empty-state-message">{message}</p>
)}
{action && (
<button
className="empty-state-action"
onClick={action}
type="button"
>
{actionLabel}
</button>
)}
</div>
);
}
export default EmptyState;
-120
View File
@@ -1,120 +0,0 @@
import React, { useState, useEffect, ReactNode } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
interface FilterControlsProps {
children: ReactNode;
title?: string;
defaultExpanded?: boolean;
onReset?: (() => void) | null;
className?: string;
}
interface FilterGroupProps {
label?: string;
children: ReactNode;
}
interface FilterRowProps {
children: ReactNode;
}
interface FilterControlsComponent extends React.FC<FilterControlsProps> {
Group: React.FC<FilterGroupProps>;
Row: React.FC<FilterRowProps>;
}
const FilterControls: FilterControlsComponent = ({
children,
title,
defaultExpanded = true,
onReset = null,
className = ''
}) => {
const { t } = useLanguage();
const displayTitle = title || t('filters.title');
// Start collapsed on mobile
const [expanded, setExpanded] = useState(() => {
if (typeof window !== 'undefined') {
return window.innerWidth > 768 ? defaultExpanded : false;
}
return defaultExpanded;
});
// Handle resize
useEffect(() => {
const handleResize = () => {
// Auto-expand on desktop, keep user preference on mobile
if (window.innerWidth > 768) {
setExpanded(true);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const toggleExpanded = () => {
setExpanded(!expanded);
};
return (
<div className={`controls ${expanded ? 'expanded' : 'collapsed'} ${className}`}>
<button
type="button"
className="controls-header"
onClick={toggleExpanded}
aria-expanded={expanded}
>
<h3>{displayTitle}</h3>
<div className="controls-header-actions">
{onReset && expanded && (
<button
className="controls-reset"
onClick={(e) => {
e.stopPropagation();
onReset();
}}
aria-label={t('filters.reset') || 'Reset filters'}
>
{t('filters.reset') || 'Reset'}
</button>
)}
<span className="controls-toggle" aria-hidden="true">
{expanded ? '▲' : '▼'}
</span>
</div>
</button>
<div
className="controls-body"
style={{
display: expanded ? 'block' : 'none',
animation: expanded ? 'fadeIn 200ms ease' : 'none'
}}
>
{children}
</div>
</div>
);
};
const FilterGroup: React.FC<FilterGroupProps> = ({ label, children }) => {
return (
<div className="control-group">
{label && <label>{label}</label>}
{children}
</div>
);
};
const FilterRow: React.FC<FilterRowProps> = ({ children }) => {
return <div className="control-row">{children}</div>;
};
FilterControls.Group = FilterGroup;
FilterControls.Row = FilterRow;
export default FilterControls;
-85
View File
@@ -1,85 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
interface MultiSelectProps {
options: string[];
selected: string[];
onChange: (selected: string[]) => void;
allLabel: string;
placeholder?: string;
label?: string;
}
function MultiSelect({ options, selected, onChange, allLabel, placeholder, label }: MultiSelectProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
const isAll = selected.length === 0;
const toggle = (value: string) => {
if (selected.includes(value)) {
onChange(selected.filter(v => v !== value));
} else {
onChange([...selected, value]);
}
};
const selectAll = () => onChange([]);
const displayText = isAll
? allLabel
: selected.length === 1
? selected[0]
: `${selected.length} selected`;
return (
<div className="multi-select" ref={ref}>
<button
type="button"
className="multi-select-trigger"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-label={label}
aria-haspopup="listbox"
>
<span className="multi-select-text">{displayText}</span>
<span className="multi-select-arrow"></span>
</button>
{open && (
<div className="multi-select-dropdown">
<label className="multi-select-option">
<input
type="checkbox"
checked={isAll}
onChange={selectAll}
/>
<span>{allLabel}</span>
</label>
{options.map(opt => (
<label key={opt} className="multi-select-option">
<input
type="checkbox"
checked={selected.includes(opt)}
onChange={() => toggle(opt)}
/>
<span>{opt}</span>
</label>
))}
</div>
)}
</div>
);
}
export default MultiSelect;
-146
View File
@@ -1,146 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import type { Season } from '../../types';
interface Props {
startDate: string;
endDate: string;
onChange: (start: string, end: string) => void;
availableYears: number[];
seasons?: Season[];
}
const PRESETS: Record<string, (year: number) => { start: string; end: string }> = {
jan: y => ({ start: `${y}-01-01`, end: `${y}-01-31` }),
feb: y => ({ start: `${y}-02-01`, end: `${y}-02-28` }),
mar: y => ({ start: `${y}-03-01`, end: `${y}-03-31` }),
apr: y => ({ start: `${y}-04-01`, end: `${y}-04-30` }),
may: y => ({ start: `${y}-05-01`, end: `${y}-05-31` }),
jun: y => ({ start: `${y}-06-01`, end: `${y}-06-30` }),
jul: y => ({ start: `${y}-07-01`, end: `${y}-07-31` }),
aug: y => ({ start: `${y}-08-01`, end: `${y}-08-31` }),
sep: y => ({ start: `${y}-09-01`, end: `${y}-09-30` }),
oct: y => ({ start: `${y}-10-01`, end: `${y}-10-31` }),
nov: y => ({ start: `${y}-11-01`, end: `${y}-11-30` }),
dec: y => ({ start: `${y}-12-01`, end: `${y}-12-31` }),
q1: y => ({ start: `${y}-01-01`, end: `${y}-03-31` }),
q2: y => ({ start: `${y}-04-01`, end: `${y}-06-30` }),
q3: y => ({ start: `${y}-07-01`, end: `${y}-09-30` }),
q4: y => ({ start: `${y}-10-01`, end: `${y}-12-31` }),
h1: y => ({ start: `${y}-01-01`, end: `${y}-06-30` }),
h2: y => ({ start: `${y}-07-01`, end: `${y}-12-31` }),
full: y => ({ start: `${y}-01-01`, end: `${y}-12-31` }),
};
function guessPreset(start: string, end: string): { preset: string; year: number } {
const year = parseInt(start.slice(0, 4));
for (const [key, fn] of Object.entries(PRESETS)) {
const p = fn(year);
if (p.start === start && p.end === end) return { preset: key, year };
}
return { preset: 'custom', year };
}
export default function PeriodPicker({ startDate, endDate, onChange, availableYears, seasons = [] }: Props) {
const { t } = useLanguage();
const [year, setYear] = useState<number>(() => guessPreset(startDate, endDate).year || new Date().getFullYear());
const [preset, setPreset] = useState<string>(() => guessPreset(startDate, endDate).preset);
// Sync internal state when parent updates dates externally
useEffect(() => {
const { preset: p, year: y } = guessPreset(startDate, endDate);
setPreset(p);
if (p !== 'custom') setYear(y);
}, [startDate, endDate]);
const handlePreset = (value: string) => {
setPreset(value);
if (value === 'custom') return;
if (value.startsWith('season-')) {
const season = seasons.find(s => String(s.Id) === value.replace('season-', ''));
if (season) onChange(season.StartDate, season.EndDate);
return;
}
const range = PRESETS[value]?.(year);
if (range) onChange(range.start, range.end);
};
const handleYear = (newYear: number) => {
setYear(newYear);
if (preset !== 'custom' && !preset.startsWith('season-')) {
const range = PRESETS[preset]?.(newYear);
if (range) onChange(range.start, range.end);
}
};
const handleStart = (value: string) => {
setPreset('custom');
onChange(value, endDate);
};
const handleEnd = (value: string) => {
setPreset('custom');
onChange(startDate, value);
};
return (
<div className="period-picker">
<div className="period-picker-row">
<div className="control-group">
<label>{t('comparison.period')}</label>
<select value={preset} onChange={e => handlePreset(e.target.value)}>
<option value="custom">{t('comparison.custom')}</option>
<option value="jan">{t('months.january')}</option>
<option value="feb">{t('months.february')}</option>
<option value="mar">{t('months.march')}</option>
<option value="apr">{t('months.april')}</option>
<option value="may">{t('months.may')}</option>
<option value="jun">{t('months.june')}</option>
<option value="jul">{t('months.july')}</option>
<option value="aug">{t('months.august')}</option>
<option value="sep">{t('months.september')}</option>
<option value="oct">{t('months.october')}</option>
<option value="nov">{t('months.november')}</option>
<option value="dec">{t('months.december')}</option>
<option value="q1">{t('time.q1')}</option>
<option value="q2">{t('time.q2')}</option>
<option value="q3">{t('time.q3')}</option>
<option value="q4">{t('time.q4')}</option>
<option value="h1">{t('time.h1')}</option>
<option value="h2">{t('time.h2')}</option>
<option value="full">{t('time.fullYear')}</option>
{seasons.length > 0 && (
<optgroup label={t('comparison.seasons') || 'Seasons'}>
{seasons.map(s => (
<option key={s.Id} value={`season-${s.Id}`}>
{s.Name} {s.HijriYear}
</option>
))}
</optgroup>
)}
</select>
</div>
{!preset.startsWith('season-') && availableYears.length > 0 && (
<div className="control-group">
<label>{t('filters.year')}</label>
<select value={year} onChange={e => handleYear(parseInt(e.target.value))}>
{availableYears.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
)}
<div className="control-group">
<label>{t('comparison.from')}</label>
<input type="date" value={startDate} onChange={e => handleStart(e.target.value)} />
</div>
<div className="control-group">
<label>{t('comparison.to')}</label>
<input type="date" value={endDate} onChange={e => handleEnd(e.target.value)} />
</div>
</div>
</div>
);
}
-32
View File
@@ -1,32 +0,0 @@
import React from 'react';
interface StatCardProps {
title: string;
value: string | number;
change?: number | null;
changeLabel?: string;
subtitle?: string | null;
}
function StatCard({ title, value, change = null, changeLabel = 'YoY', subtitle = null }: StatCardProps) {
const isPositive = change !== null && change >= 0;
return (
<div className="stat-card">
<h3>{title}</h3>
<div className="stat-value">{value}</div>
{subtitle && (
<div className="stat-subtitle">{subtitle}</div>
)}
{change !== null && (
<div className={`stat-change ${isPositive ? 'positive' : 'negative'}`}>
<span className="stat-change-arrow">{isPositive ? '↑' : '↓'}</span>
<span className="stat-change-value">{Math.abs(change).toFixed(1)}%</span>
<span className="stat-change-label">{changeLabel}</span>
</div>
)}
</div>
);
}
export default StatCard;
-33
View File
@@ -1,33 +0,0 @@
import React from 'react';
interface ToggleOption {
value: string;
label: string;
}
interface ToggleSwitchProps {
options: ToggleOption[];
value: string;
onChange: (value: string) => void;
className?: string;
}
function ToggleSwitch({ options, value, onChange, className = '' }: ToggleSwitchProps) {
return (
<div className={`toggle-switch ${className}`} role="radiogroup">
{options.map((option) => (
<button
key={option.value}
className={value === option.value ? 'active' : ''}
onClick={() => onChange(option.value)}
role="radio"
aria-checked={value === option.value}
>
{option.label}
</button>
))}
</div>
);
}
export default ToggleSwitch;
+1 -9
View File
@@ -1,9 +1 @@
export { default as Carousel } from './Carousel';
export { default as ChartCard } from './ChartCard';
export { default as EmptyState } from './EmptyState';
export { default as FilterControls } from './FilterControls';
export { default as MultiSelect } from './MultiSelect';
export { default as StatCard } from './StatCard';
export { default as ToggleSwitch } from './ToggleSwitch';
export { default as PeriodPicker } from './PeriodPicker';
export { default as DateRangePicker } from './DateRangePicker';
export { default as LoadingSkeleton } from './LoadingSkeleton';