Files
hihala-dashboard/src/components/Comparison.tsx
T
fahed c9cfb58896
Deploy HiHala Dashboard / deploy (push) Successful in 8s
fix: mobile UX overhaul — collapsible filters, settings nav, responsive layout
- Add Settings link to desktop nav bar for admin users
- Rewrite Settings page from table layout to responsive card list (fixes unusable mobile state)
- Filter bar (Dashboard + Comparison): collapsible panel on mobile via display:contents trick; stacked full-width dropdowns replace horizontal scroll
- Active filter count badge shown in collapsed filter header
- AltMultiSelect dropdowns go full-width on mobile to prevent viewport overflow
- Chart control separators hidden on mobile to avoid crowding
- Metric grid: 2-col at ≤700px, 1-col at ≤480px
- Comparison period cards: smaller font and tighter padding at ≤680px
- Page shell padding reduced on mobile (48px→20px top, 24px→16px sides)
- Settings page gets correct 80px bottom padding for mobile nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:22:07 +03:00

292 lines
16 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,
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
umrahData
} from '../services/dataService';
import { chartColors, createBaseOptions } from '../config/chartConfig';
import type { MuseumRecord, Season } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
import type { LC } from '../lib/locale';
import { EN, AR } from '../lib/locale';
import { currentMonth, shiftYear, periodNameL, dateRangeTextL } from '../lib/dateHelpers';
import { InlinePicker } from './shared/PeriodPicker';
import AltMultiSelect from './shared/AltMultiSelect';
import MetricCard from './shared/MetricCard';
interface Props {
data: MuseumRecord[];
seasons: Season[];
includeVAT: boolean;
allowedMuseums: string[] | null;
allowedChannels: string[] | null;
lang?: 'en' | 'ar';
}
// ─── 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} aria-controls="period-picker-panel">
{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={onChange} onClose={() => setOpen(false)} availableYears={availableYears} L={L} />}
</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 } = useMemo(() => {
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } };
return { chartOpts };
}, [baseOpts]);
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;
const activeFilterCount = selDistricts.length + selChannels.length + selMuseums.length;
const [filtersOpen, setFiltersOpen] = useState(false);
return (
<div
className="alt-page"
dir={L.dir}
style={{
'--alt-body-font': L.bodyFont,
'--alt-display-font': L.displayFont,
'--alt-mono-font': L.monoFont,
} as React.CSSProperties}
>
<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${filtersOpen ? ' alt-filter-bar--open' : ''}`}>
<div className="alt-filter-head">
<span className="alt-filter-label">{L.filter}</span>
{activeFilterCount > 0 && <span className="alt-filter-badge">{activeFilterCount}</span>}
<button type="button" className="alt-filter-toggle" onClick={() => setFiltersOpen(v => !v)} aria-expanded={filtersOpen} aria-label="Toggle filters">
<svg className={`alt-filter-chevron${filtersOpen ? ' alt-filter-chevron--open' : ''}`} width="14" height="14" 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>
</div>
<div className="alt-filter-body">
<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>
</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" aria-pressed={metric===o.value} 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" aria-pressed={gran===o.value} 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" aria-pressed={metric===o.value} 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>
);
}