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; fullYearLabel: (y: number) => string; dateRangeSep: string; backLink: string; backTo: string; pageTitle: string; pageSub: string; changePeriod: string; close: string; apply: 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: 'Cancel', apply: 'Apply', 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: 'إلغاء', apply: 'تطبيق', 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 { 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, onClose, availableYears, L }: { start: string; end: string; onChange: (s: string, e: string) => void; onClose: () => 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(g?.key ?? null); const [draftStart, setDraftStart] = useState(start); const [draftEnd, setDraftEnd] = useState(end); const minY = Math.min(...availableYears), maxY = Math.max(...availableYears); const pick = (key: string) => { const r = makePresets(year)[key]; if (!r) return; setActive(key); setDraftStart(r.start); setDraftEnd(r.end); }; const shift = (d: number) => { const ny = year+d; if (ny < minY || ny > maxY) return; setYear(ny); if (active && makePresets(ny)[active]) { setDraftStart(makePresets(ny)[active].start); setDraftEnd(makePresets(ny)[active].end); } }; return (
{year}

{L.monthSection}

{MONTH_KEYS.map((k,i) => ( ))}

{L.periodSection}

{['q1','q2','q3','q4','h1','h2'].map(k => ( ))}
{ setActive(null); setDraftStart(e.target.value); }} />
{L.dateRangeSep}
{ setActive(null); setDraftEnd(e.target.value); }} />
); } // ─── 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(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 (
{periodNameL(start, end, L)}
{dateRangeTextL(start, end, L)}
{open && setOpen(false)} availableYears={availableYears} L={L} />}
); } // ─── 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(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 (
{open && (
{options.map(opt => ( ))}
{value.length>0 && }
)}
); } // ─── 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 (

{title}

{fmt(curr)}
{isFinite(change) ? {isPos?'▲':isNeg?'▼':'—'} {Math.abs(change).toFixed(1)}% : {newLabel??'New'}} {fmt(prev)}
); } // ─── 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([]); const [selChannels, setSelChannels] = useState([]); const [selMuseums, setSelMuseums] = useState([]); 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(); 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 = { 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 = {}; 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 = {}; 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 (qeed) 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 (

{L.pageTitle}

{L.pageSub}

{ setStart(s); setEnd(e); }} availableYears={availableYears} L={L} />
{L.filter}
{hasFilters && }

{L.keyMetrics}

{currPilgrims!==null && prevPilgrims!==null && } {currCapture!==null && prevCapture!==null && }

{L.charts}

{L.trendTitle}

{metricOpts.map(o => )}
{granOpts.map(o => )}

{L.museumTitle}

{metricOpts.map(o => )}
{museumChartType==='pie' ? : }

{L.channelTitle}

{metricOpts.map(o => )}
{channelChartType==='pie' ? : }

{L.districtTitle}

{metricOpts.map(o => )}
{districtChartType==='pie' ? : }
); }