c9cfb58896
Deploy HiHala Dashboard / deploy (push) Successful in 8s
- 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>
292 lines
16 KiB
TypeScript
292 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|