Files
hihala-dashboard/src/components/PeriodSelectorDemo.tsx
T
fahed c8c3465233
Deploy HiHala Dashboard / deploy (push) Successful in 9s
feat: redesigned dashboard UI with editorial aesthetic and RTL support
- Replace Dashboard/Comparison with DashboardDemo/PeriodSelectorDemo as primary pages at / and /comparison
- New editorial design: DM Serif Display + Outfit fonts, inline period picker, multi-select filters for museum/channel/district
- Full Arabic RTL support with IBM Plex Sans Arabic; EN/AR toggle synced to global LanguageContext
- Bar/pie chart toggle + absolute/percent toggle for museum, channel, district charts
- Refined top nav: transparent inactive links, accent active state, visual separator between nav links and utilities
- DateRangePicker, MultiSelect, FilterControls shared components added
- NavDemo: sidebar layout alternative (accessible at /nav-demo)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:58:33 +03:00

672 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}