Compare commits

..

9 Commits

Author SHA1 Message Date
fahed dd512444fb feat(i18n): display Arabic museum names from NocoDB when language is Arabic
Deploy HiHala Dashboard / deploy (push) Successful in 12s
Fetches Museums table (Name + NameAr columns) at startup and passes translations
to Dashboard and Comparison. Museum filter dropdown, trend chart datasets, and
museum bar/pie chart labels all switch to Arabic when lang === 'ar'. Internal
filter state stays as English keys to match DailySales museum_name field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 13:40:44 +03:00
fahed 4f51280d1c feat(report+charts): report builder improvements and TOTAL_COLOR consistency
Deploy HiHala Dashboard / deploy (push) Successful in 11s
- Add TOTAL_COLOR constant to chartConfig and use it in Dashboard and Comparison for consistent total-line styling
- Overhaul ReportDocument layout, ReportForm UX, and reportHelpers logic
- Add IBM Plex Sans Arabic and Noto Sans Arabic font assets for PDF rendering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 15:49:09 +03:00
fahed 89689c5979 feat(charts): right-side bold legend with circle indicators + tooltip polish
Deploy HiHala Dashboard / deploy (push) Successful in 10s
- Legend moved to right, bold text, color matches line, circle outline indicator
- Museums with no data in current period excluded from chart and legend
- Tooltip uses circle point style and boxPadding for readable spacing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 11:37:40 +03:00
fahed 49bda53598 fix(charts): collision-aware end-of-line labels when lines converge
Deploy HiHala Dashboard / deploy (push) Successful in 15s
Replace per-dataset label drawing with a post-pass in afterDatasetsDraw
that collects all museum line endpoints, sorts by Y, then pushes overlapping
labels apart with a connector line back to the actual data point.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 11:21:32 +03:00
fahed 2888936d54 feat(charts): hover dimming, end-of-line labels, and value-label toggle
Deploy HiHala Dashboard / deploy (push) Successful in 11s
- Hover: non-hovered lines fade to 15% opacity so active line pops out
- End labels: museum name rendered at the tip of each line (always visible,
  stays full-opacity even when dimmed) with 110px right-padding for space
- Labels toggle: button in chart controls shows/hides per-point value labels
- interaction mode set to nearest/no-intersect for responsive hover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 11:13:05 +03:00
fahed 131868a280 feat(report): per-museum trend lines in PDF report chart
Deploy HiHala Dashboard / deploy (push) Successful in 11s
When multiple museums are present, the report trend chart now renders one
colored line per museum plus a bold Total line, mirroring dashboard behavior.
Legend is updated to list each museum with its corresponding color.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:56:26 +03:00
fahed 7365bc808b feat(charts): always show per-museum trend lines, with or without filter
Deploy HiHala Dashboard / deploy (push) Successful in 11s
When no museum is selected, all museums get individual lines. When a subset
is selected, only those museums are shown. Both Dashboard and Comparison
trend charts now follow this pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:44:52 +03:00
fahed 26bb69c76c feat(charts): show per-museum trend lines when multiple museums selected
Deploy HiHala Dashboard / deploy (push) Successful in 10s
When 2+ museums are selected, the trend chart now renders one colored line
per museum plus a bold Total line, instead of a single aggregated line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:42:18 +03:00
fahed 1070490ad2 feat(charts): show actual dates in trend chart tooltips
Deploy HiHala Dashboard / deploy (push) Successful in 11s
Replace opaque W1/D1/month abbreviation tooltip titles with human-readable
period labels (e.g. "Week 1 · 1 Apr – 7 Apr", "1 April 2025", "April 2025")
in both Dashboard and Comparison trend charts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:37:05 +03:00
17 changed files with 6411 additions and 181 deletions
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+11 -3
View File
@@ -7,7 +7,7 @@ const Dashboard = lazy(() => import('./components/Dashboard'));
const Report = lazy(() => import('./components/Report')); const Report = lazy(() => import('./components/Report'));
import Login from './components/Login'; import Login from './components/Login';
import LoadingSkeleton from './components/shared/LoadingSkeleton'; import LoadingSkeleton from './components/shared/LoadingSkeleton';
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService'; import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels, fetchMuseumTranslations } from './services/dataService';
import { fetchSeasons } from './services/seasonsService'; import { fetchSeasons } from './services/seasonsService';
import { parseAllowed } from './services/usersService'; import { parseAllowed } from './services/usersService';
import { useLanguage } from './contexts/LanguageContext'; import { useLanguage } from './contexts/LanguageContext';
@@ -59,6 +59,7 @@ function App() {
const [includeVAT, setIncludeVAT] = useState<boolean>(true); const [includeVAT, setIncludeVAT] = useState<boolean>(true);
const [dataSource, setDataSource] = useState<string>('museums'); const [dataSource, setDataSource] = useState<string>('museums');
const [seasons, setSeasons] = useState<Season[]>([]); const [seasons, setSeasons] = useState<Season[]>([]);
const [museumTranslations, setMuseumTranslations] = useState<Record<string, string>>({});
const [theme, setTheme] = useState<string>(() => { const [theme, setTheme] = useState<string>(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return localStorage.getItem('hihala_theme') || 'light'; return localStorage.getItem('hihala_theme') || 'light';
@@ -118,6 +119,11 @@ function App() {
setSeasons(s); setSeasons(s);
}, []); }, []);
const loadMuseumTranslations = useCallback(async () => {
const t = await fetchMuseumTranslations();
setMuseumTranslations(t);
}, []);
// Check auth on mount // Check auth on mount
useEffect(() => { useEffect(() => {
fetch('/auth/check', { credentials: 'include' }) fetch('/auth/check', { credentials: 'include' })
@@ -131,6 +137,7 @@ function App() {
setAllowedChannels(parseAllowed(d.allowedChannels)); setAllowedChannels(parseAllowed(d.allowedChannels));
loadData(); loadData();
loadSeasons(); loadSeasons();
loadMuseumTranslations();
} }
}) })
.catch(() => setAuthenticated(false)); .catch(() => setAuthenticated(false));
@@ -145,6 +152,7 @@ function App() {
setAllowedChannels(parseAllowed(rawChannels)); setAllowedChannels(parseAllowed(rawChannels));
loadData(); loadData();
loadSeasons(); loadSeasons();
loadMuseumTranslations();
}; };
const handleRefresh = () => { const handleRefresh = () => {
@@ -327,8 +335,8 @@ function App() {
<main> <main>
<Suspense fallback={<LoadingSkeleton />}> <Suspense fallback={<LoadingSkeleton />}>
<Routes> <Routes>
<Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} /> <Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} museumTranslations={museumTranslations} />} />
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} /> <Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} museumTranslations={museumTranslations} />} />
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />} {userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
{userRole === 'admin' && <Route path="/report" element={<Report data={data} />} />} {userRole === 'admin' && <Route path="/report" element={<Report data={data} />} />}
</Routes> </Routes>
+94 -11
View File
@@ -6,7 +6,7 @@ import {
getUniqueChannels, getUniqueMuseums, getUniqueDistricts, getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
umrahData umrahData
} from '../services/dataService'; } from '../services/dataService';
import { chartColors, createBaseOptions } from '../config/chartConfig'; import { chartColors, chartPalette, createBaseOptions, TOTAL_COLOR } from '../config/chartConfig';
import type { MuseumRecord, Season } from '../types'; import type { MuseumRecord, Season } from '../types';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import type { LC } from '../lib/locale'; import type { LC } from '../lib/locale';
@@ -22,6 +22,7 @@ interface Props {
includeVAT: boolean; includeVAT: boolean;
allowedMuseums: string[] | null; allowedMuseums: string[] | null;
allowedChannels: string[] | null; allowedChannels: string[] | null;
museumTranslations?: Record<string, string>;
lang?: 'en' | 'ar'; lang?: 'en' | 'ar';
} }
@@ -65,8 +66,9 @@ function PeriodCard({ role, hint, start, end, variant, onChange, availableYears,
} }
// ─── main page ──────────────────────────────────────────────────── // ─── main page ────────────────────────────────────────────────────
export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels }: Props) { export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels, museumTranslations = {} }: Props) {
const { lang: activeLang, setLanguage } = useLanguage(); const { lang: activeLang, setLanguage } = useLanguage();
const tr = (name: string) => (activeLang === 'ar' && museumTranslations[name]) ? museumTranslations[name] : name;
const L = activeLang === 'ar' ? AR : EN; const L = activeLang === 'ar' ? AR : EN;
const curr = currentMonth(); const curr = currentMonth();
const [currStart, setCurrStart] = useState(curr.start); const [currStart, setCurrStart] = useState(curr.start);
@@ -78,6 +80,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
const [selMuseums, setSelMuseums] = useState<string[]>([]); const [selMuseums, setSelMuseums] = useState<string[]>([]);
const [metric, setMetric] = useState('revenue'); const [metric, setMetric] = useState('revenue');
const [gran, setGran] = useState('week'); const [gran, setGran] = useState('week');
const [showLabels, setShowLabels] = useState(false);
const perm = useMemo(() => { const perm = useMemo(() => {
if (!allowedMuseums || !allowedChannels) return []; if (!allowedMuseums || !allowedChannels) return [];
@@ -128,7 +131,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
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)}`; 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 trendResult = useMemo(() => {
const group = (rows: MuseumRecord[], ps: string) => { const group = (rows: MuseumRecord[], ps: string) => {
const s=new Date(ps); const acc: Record<number,MuseumRecord[]> = {}; const s=new Date(ps); const acc: Record<number,MuseumRecord[]> = {};
rows.forEach(r => { rows.forEach(r => {
@@ -142,17 +145,56 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
}; };
const pg = group(prevData, prevStart), cg = group(currData, currStart); 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 maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
const cs0 = new Date(currStart);
const fmt = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
const labels = Array.from({length:maxK}, (_,i) => 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}` gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(cs0.getMonth()+i)%12] : `D${i+1}`
); );
const tooltipLabels = Array.from({length:maxK}, (_,i) => {
if (gran==='week') {
const ws = new Date(cs0.getTime() + i * 7 * 86400000);
const we = new Date(cs0.getTime() + (i+1) * 7 * 86400000 - 86400000);
return `Week ${i+1} · ${fmt(ws)} ${fmt(we)}`;
}
if (gran==='month') {
const ms = new Date(cs0.getFullYear(), cs0.getMonth() + i, 1);
return ms.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
}
const ds = new Date(cs0.getTime() + i * 86400000);
return ds.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
});
const museumList = (selMuseums.length > 0 ? selMuseums : museums)
.filter(museum => currData.some(r => r.museum_name === museum));
const multiMuseum = museumList.length >= 2;
const museumDatasets = museumList.map((museum, idx) => {
const mg = group(currData.filter(r => r.museum_name === museum), currStart);
return { return {
label: tr(museum),
data: labels.map((_,i) => mg[i+1]||0),
borderColor: chartPalette[idx % chartPalette.length],
backgroundColor: 'transparent',
borderWidth: 1.5,
tension: 0.4,
fill: false,
pointRadius: gran==='week' ? 3 : 1,
pointBackgroundColor: chartPalette[idx % chartPalette.length],
_isMuseumLine: true,
};
});
return {
tooltipLabels,
multiMuseum,
data: {
labels, labels,
datasets: [ 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(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 }, ...museumDatasets,
{ label: multiMuseum ? `Total · ${periodLabel(currStart,currEnd)}` : periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:TOTAL_COLOR, backgroundColor: multiMuseum ? 'transparent' : TOTAL_COLOR+'15', borderWidth:2.5, tension:0.4, fill: !multiMuseum, pointRadius:gran==='week'?4:2, pointBackgroundColor:TOTAL_COLOR },
] ]
}
}; };
}, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L]); }, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L, selMuseums, museums]);
const trendData = trendResult.data;
const museumData = useMemo(() => { const museumData = useMemo(() => {
const all = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[]; const all = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[];
@@ -160,19 +202,58 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
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); }); 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); const active = all.filter(m => pb[m]>0 || cb[m]>0);
return { return {
labels: active, labels: active.map(tr),
datasets: [ datasets: [
{ label:periodLabel(prevStart,prevEnd), data:active.map(m => pb[m]), backgroundColor:chartColors.muted+'cc', borderRadius:4 }, { 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 }, { label:periodLabel(currStart,currEnd), data:active.map(m => cb[m]), backgroundColor:chartColors.primary, borderRadius:4 },
] ]
}; };
}, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal]); }, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal, activeLang, museumTranslations]);
const baseOpts = useMemo(() => createBaseOptions(false), []); const baseOpts = useMemo(() => createBaseOptions(showLabels), [showLabels]);
const { chartOpts } = useMemo(() => { const { chartOpts } = useMemo(() => {
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } }; const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } };
return { chartOpts }; return { chartOpts };
}, [baseOpts]); }, [baseOpts]);
const trendOpts: any = useMemo(() => ({
...chartOpts,
interaction: { mode: 'nearest', intersect: false },
plugins: {
...chartOpts.plugins,
legend: {
display: true,
position: 'right' as const,
labels: {
padding: 14,
font: { size: 11, weight: 'bold' as const },
usePointStyle: true,
generateLabels: (chart: any) =>
chart.data.datasets.map((ds: any, i: number) => {
const color: string = ds.borderColor || '#64748b';
const pill = document.createElement('canvas');
pill.width = 10; pill.height = 10;
const pCtx = pill.getContext('2d');
if (pCtx) {
pCtx.strokeStyle = color;
pCtx.lineWidth = 1;
pCtx.beginPath();
pCtx.arc(5, 5, 4, 0, Math.PI * 2);
pCtx.stroke();
}
return { text: ds.label, fillStyle: color, strokeStyle: color,
fontColor: color, lineWidth: 0, pointStyle: pill,
hidden: !chart.isDatasetVisible(i), datasetIndex: i };
}),
},
},
tooltip: {
...chartOpts.plugins.tooltip,
callbacks: {
title: (items: any[]) => trendResult.tooltipLabels[items[0]?.dataIndex] ?? items[0]?.label,
}
}
}
}), [chartOpts, trendResult.tooltipLabels]);
const metricOpts = [ const metricOpts = [
{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors }, { value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors },
@@ -247,7 +328,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
<div className="alt-filter-sep" /> <div className="alt-filter-sep" />
<AltMultiSelect value={selDistricts} options={districts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} /> <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={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} /> <AltMultiSelect value={selMuseums} options={museums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} labelFn={activeLang === 'ar' ? tr : undefined} />
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>} {hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
</div> </div>
</div> </div>
@@ -272,9 +353,11 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
{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>)} {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" /> <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>)} {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 className="alt-ctrl-sep" />
<button type="button" aria-pressed={showLabels} className={`alt-ctrl${showLabels?' alt-ctrl-on':''}`} onClick={() => setShowLabels(v => !v)}>{'Labels'}</button>
</div> </div>
</div> </div>
<div className="alt-chart-wrap"><Line data={trendData} options={chartOpts} /></div> <div className="alt-chart-wrap"><Line data={trendData} options={trendOpts} /></div>
</div> </div>
<div className="alt-chart-card"> <div className="alt-chart-card">
<div className="alt-chart-header"> <div className="alt-chart-header">
+95 -11
View File
@@ -6,7 +6,7 @@ import {
groupByMuseum, groupByChannel, groupByDistrict, groupByMuseum, groupByChannel, groupByDistrict,
umrahData, umrahData,
} from '../services/dataService'; } from '../services/dataService';
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig'; import { chartColors, chartPalette, createBaseOptions, TOTAL_COLOR } from '../config/chartConfig';
import type { MuseumRecord, Season } from '../types'; import type { MuseumRecord, Season } from '../types';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { EN, AR } from '../lib/locale'; import { EN, AR } from '../lib/locale';
@@ -22,12 +22,14 @@ interface Props {
setIncludeVAT: (v: boolean) => void; setIncludeVAT: (v: boolean) => void;
allowedMuseums: string[] | null; allowedMuseums: string[] | null;
allowedChannels: string[] | null; allowedChannels: string[] | null;
museumTranslations?: Record<string, string>;
lang?: 'en' | 'ar'; lang?: 'en' | 'ar';
} }
// ─── main page ──────────────────────────────────────────────────── // ─── main page ────────────────────────────────────────────────────
export default function DashboardDemo({ data, seasons: _seasons, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: Props) { export default function DashboardDemo({ data, seasons: _seasons, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels, museumTranslations = {} }: Props) {
const { lang: activeLang, setLanguage } = useLanguage(); const { lang: activeLang, setLanguage } = useLanguage();
const tr = (name: string) => (activeLang === 'ar' && museumTranslations[name]) ? museumTranslations[name] : name;
const L = activeLang === 'ar' ? AR : EN; const L = activeLang === 'ar' ? AR : EN;
const curr = currentMonth(); const curr = currentMonth();
const [start, setStart] = useState(curr.start); const [start, setStart] = useState(curr.start);
@@ -37,6 +39,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
const [selMuseums, setSelMuseums] = useState<string[]>([]); const [selMuseums, setSelMuseums] = useState<string[]>([]);
const [metric, setMetric] = useState('revenue'); const [metric, setMetric] = useState('revenue');
const [gran, setGran] = useState('week'); const [gran, setGran] = useState('week');
const [showLabels, setShowLabels] = useState(false);
const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar'); const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar');
const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie'); const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie');
const [districtChartType, setDistrictChartType] = useState<'bar'|'pie'>('pie'); const [districtChartType, setDistrictChartType] = useState<'bar'|'pie'>('pie');
@@ -88,7 +91,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0); return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0);
}, [revenueField]); }, [revenueField]);
const trendData = useMemo(() => { const trendResult = useMemo(() => {
const group = (rows: MuseumRecord[], ps: string) => { const group = (rows: MuseumRecord[], ps: string) => {
const s = new Date(ps); const acc: Record<number, MuseumRecord[]> = {}; const s = new Date(ps); const acc: Record<number, MuseumRecord[]> = {};
rows.forEach(r => { rows.forEach(r => {
@@ -102,25 +105,64 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
}; };
const pg = group(prevData, prevStart), cg = group(filteredData, start); 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 maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
const s0 = new Date(start);
const fmt = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
const labels = Array.from({length:maxK}, (_,i) => 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}` gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(s0.getMonth()+i)%12] : `D${i+1}`
); );
const tooltipLabels = Array.from({length:maxK}, (_,i) => {
if (gran==='week') {
const ws = new Date(s0.getTime() + i * 7 * 86400000);
const we = new Date(s0.getTime() + (i+1) * 7 * 86400000 - 86400000);
return `Week ${i+1} · ${fmt(ws)} ${fmt(we)}`;
}
if (gran==='month') {
const ms = new Date(s0.getFullYear(), s0.getMonth() + i, 1);
return ms.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
}
const ds = new Date(s0.getTime() + i * 86400000);
return ds.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
});
const prevYear = parseInt(start.slice(0,4))-1; const prevYear = parseInt(start.slice(0,4))-1;
const museumList = (selMuseums.length > 0 ? selMuseums : allMuseums)
.filter(museum => filteredData.some(r => r.museum_name === museum));
const multiMuseum = museumList.length >= 2;
const museumDatasets = museumList.map((museum, idx) => {
const mg = group(filteredData.filter(r => r.museum_name === museum), start);
return { return {
label: tr(museum),
data: labels.map((_,i) => mg[i+1]||0),
borderColor: chartPalette[idx % chartPalette.length],
backgroundColor: 'transparent',
borderWidth: 1.5,
tension: 0.4,
fill: false,
pointRadius: gran==='week' ? 3 : 1,
pointBackgroundColor: chartPalette[idx % chartPalette.length],
_isMuseumLine: true,
};
});
return {
tooltipLabels,
multiMuseum,
data: {
labels, labels,
datasets: [ 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:`${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 }, ...museumDatasets,
{ label: multiMuseum ? `Total ${start.slice(0,4)}` : start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:TOTAL_COLOR, backgroundColor: multiMuseum ? 'transparent' : TOTAL_COLOR+'18', borderWidth:2.5, tension:0.4, fill: !multiMuseum, pointRadius:gran==='week'?3:1, pointBackgroundColor:TOTAL_COLOR },
] ]
}
}; };
}, [filteredData, prevData, prevStart, start, metric, gran, getVal, L]); }, [filteredData, prevData, prevStart, start, metric, gran, getVal, L, selMuseums, allMuseums]);
const trendData = trendResult.data;
const museumData = useMemo(() => { const museumData = useMemo(() => {
const g = groupByMuseum(filteredData, includeVAT); const g = groupByMuseum(filteredData, includeVAT);
const getM = (d: typeof g[string]) => metric==='visitors' ? d.visitors : metric==='tickets' ? d.tickets : d.revenue; 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])); 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 }] }; return { labels:entries.map(([k]) => tr(k)), datasets:[{ label:metric, data:entries.map(([,v]) => getM(v)), backgroundColor:chartPalette, borderRadius:4 }] };
}, [filteredData, includeVAT, metric]); }, [filteredData, includeVAT, metric, activeLang, museumTranslations]);
const channelData = useMemo(() => { const channelData = useMemo(() => {
const g = groupByChannel(filteredData, includeVAT); const g = groupByChannel(filteredData, includeVAT);
@@ -165,13 +207,53 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null; const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null;
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null; const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
const baseOpts = useMemo(() => createBaseOptions(false), []); const baseOpts = useMemo(() => createBaseOptions(showLabels), [showLabels]);
const { chartOpts, barHorizOpts, barNoLegend } = useMemo(() => { const { chartOpts, barHorizOpts, barNoLegend } = useMemo(() => {
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:10, padding:10, font:{ size:11 } } } } }; 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 barHorizOpts: any = { ...chartOpts, indexAxis:'y', plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
const barNoLegend: any = { ...chartOpts, plugins:{ ...chartOpts.plugins, legend:{ display:false } } }; const barNoLegend: any = { ...chartOpts, plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
return { chartOpts, barHorizOpts, barNoLegend }; return { chartOpts, barHorizOpts, barNoLegend };
}, [baseOpts]); }, [baseOpts]);
const trendOpts: any = useMemo(() => ({
...chartOpts,
interaction: { mode: 'nearest', intersect: false },
plugins: {
...chartOpts.plugins,
legend: {
display: true,
position: 'right' as const,
labels: {
padding: 14,
font: { size: 11, weight: 'bold' as const },
usePointStyle: true,
generateLabels: (chart: any) =>
chart.data.datasets.map((ds: any, i: number) => {
const color: string = ds.borderColor || '#64748b';
const pill = document.createElement('canvas');
pill.width = 10; pill.height = 10;
const pCtx = pill.getContext('2d');
if (pCtx) {
pCtx.strokeStyle = color;
pCtx.lineWidth = 1;
pCtx.beginPath();
pCtx.arc(5, 5, 4, 0, Math.PI * 2);
pCtx.stroke();
}
return { text: ds.label, fillStyle: color, strokeStyle: color,
fontColor: color, lineWidth: 0, pointStyle: pill,
hidden: !chart.isDatasetVisible(i), datasetIndex: i };
}),
},
},
tooltip: {
...chartOpts.plugins.tooltip,
callbacks: {
title: (items: any[]) => trendResult.tooltipLabels[items[0]?.dataIndex] ?? items[0]?.label,
}
}
}
}), [chartOpts, trendResult.tooltipLabels]);
const pieOptions: any = useMemo(() => ({ const pieOptions: any = useMemo(() => ({
responsive: true, maintainAspectRatio: false, responsive: true, maintainAspectRatio: false,
plugins: { plugins: {
@@ -216,7 +298,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
<div className="alt-filter-sep" /> <div className="alt-filter-sep" />
<AltMultiSelect value={selDistricts} options={allDistricts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} /> <AltMultiSelect value={selDistricts} options={allDistricts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
<AltMultiSelect value={selChannels} options={allChannels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} /> <AltMultiSelect value={selChannels} options={allChannels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
<AltMultiSelect value={selMuseums} options={allMuseums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} /> <AltMultiSelect value={selMuseums} options={allMuseums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} labelFn={activeLang === 'ar' ? tr : undefined} />
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>} {hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
<div className="alt-filter-spacer" /> <div className="alt-filter-spacer" />
<div className="alt-vat-toggle"> <div className="alt-vat-toggle">
@@ -248,9 +330,11 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
{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>)} {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" /> <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>)} {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 className="alt-ctrl-sep" />
<button type="button" aria-pressed={showLabels} className={`alt-ctrl${showLabels?' alt-ctrl-on':''}`} onClick={() => setShowLabels(v => !v)}>{'Labels'}</button>
</div> </div>
</div> </div>
<div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={chartOpts} /></div> <div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={trendOpts} /></div>
</div> </div>
<div className="alt-chart-card"> <div className="alt-chart-card">
+129 -99
View File
@@ -1,12 +1,22 @@
import React from 'react'; import React from 'react';
import { import {
Document, Page, View, Text, Image, StyleSheet Document, Page, View, Text, Image, StyleSheet, Font
} from '@react-pdf/renderer'; } from '@react-pdf/renderer';
import { PdfTrendChart, PdfHBarChart, CHART_PALETTE } from './reportCharts'; import { PdfTrendChart, PdfHBarChart, CHART_PALETTE } from './reportCharts';
import { import {
ReportData, MuseumDataRow, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary ReportData, MuseumDataRow, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary
} from './reportHelpers'; } from './reportHelpers';
Font.register({
family: 'IBMPlexArabic',
fonts: [
{ src: '/fonts/IBMPlexSansArabic-Regular.woff2', fontWeight: 400 },
{ src: '/fonts/IBMPlexSansArabic-Bold.woff2', fontWeight: 700 },
],
});
const TOTAL_LINE_COLOR = '#1e293b';
// A4 content width minus chart-wrap padding (14×2) // A4 content width minus chart-wrap padding (14×2)
// Portrait: 595 - 44 - 44 - 28 = 479 // Portrait: 595 - 44 - 44 - 28 = 479
// Landscape: 842 - 44 - 44 - 28 = 726 // Landscape: 842 - 44 - 44 - 28 = 726
@@ -17,14 +27,12 @@ const S = StyleSheet.create({
// ── Cover ────────────────────────────────────────────── // ── Cover ──────────────────────────────────────────────
coverPage: { flexDirection: 'column', padding: 0 }, coverPage: { flexDirection: 'column', padding: 0 },
// colored header band
coverHeader: { paddingTop: 56, paddingRight: 52, paddingBottom: 52, paddingLeft: 52 }, coverHeader: { paddingTop: 56, paddingRight: 52, paddingBottom: 52, paddingLeft: 52 },
coverHeaderTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 48 }, coverHeaderTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 48 },
coverBrand: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', letterSpacing: 0.8 }, coverBrand: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', letterSpacing: 0.8 },
coverLogoBox: { width: 90, height: 44, justifyContent: 'flex-end', alignItems: 'flex-end' }, coverLogoBox: { width: 90, height: 44, justifyContent: 'flex-end', alignItems: 'flex-end' },
coverClientLogo: { width: 90, height: 44, objectFit: 'contain' as const }, coverClientLogo: { width: 90, height: 44, objectFit: 'contain' as const },
coverTitle: { fontSize: 36, fontFamily: 'Helvetica-Bold', color: '#ffffff', lineHeight: 1.2 }, coverTitle: { fontSize: 36, fontFamily: 'Helvetica-Bold', color: '#ffffff', lineHeight: 1.2 },
// white body
coverBody: { flex: 1, paddingTop: 44, paddingRight: 52, paddingBottom: 44, paddingLeft: 52, flexDirection: 'column' }, coverBody: { flex: 1, paddingTop: 44, paddingRight: 52, paddingBottom: 44, paddingLeft: 52, flexDirection: 'column' },
coverClientName: { fontSize: 15, color: '#0f172a', fontFamily: 'Helvetica-Bold', marginBottom: 5 }, coverClientName: { fontSize: 15, color: '#0f172a', fontFamily: 'Helvetica-Bold', marginBottom: 5 },
coverContactName: { fontSize: 11, color: '#64748b', marginBottom: 32 }, coverContactName: { fontSize: 11, color: '#64748b', marginBottom: 32 },
@@ -66,8 +74,8 @@ const S = StyleSheet.create({
// ── Trend chart ──────────────────────────────────────── // ── Trend chart ────────────────────────────────────────
chartWrap: { marginBottom: 8, backgroundColor: '#f8fafc', paddingTop: 14, paddingRight: 14, paddingBottom: 14, paddingLeft: 14, borderRadius: 6, borderWidth: 1, borderColor: '#f1f5f9' }, chartWrap: { marginBottom: 8, backgroundColor: '#f8fafc', paddingTop: 14, paddingRight: 14, paddingBottom: 14, paddingLeft: 14, borderRadius: 6, borderWidth: 1, borderColor: '#f1f5f9' },
legendRow: { flexDirection: 'row', marginBottom: 10 }, legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 10 },
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18 }, legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18, marginBottom: 4 },
legendDot: { width: 8, height: 8, borderRadius: 4 }, legendDot: { width: 8, height: 8, borderRadius: 4 },
legendLabel: { fontSize: 8, color: '#64748b', marginLeft: 5 }, legendLabel: { fontSize: 8, color: '#64748b', marginLeft: 5 },
@@ -122,12 +130,12 @@ function museumIntro(row: MuseumDataRow, lang: 'en' | 'ar', compLabel: string):
return `الإيرادات ${revChg >= 0 ? 'ارتفعت' : 'انخفضت'} ${Math.abs(revChg)}%، الزوار ${visChg >= 0 ? 'ارتفعوا' : 'انخفضوا'} ${Math.abs(visChg)}% مقارنةً بـ${compLabel}.`; return `الإيرادات ${revChg >= 0 ? 'ارتفعت' : 'انخفضت'} ${Math.abs(revChg)}%، الزوار ${visChg >= 0 ? 'ارتفعوا' : 'انخفضوا'} ${Math.abs(visChg)}% مقارنةً بـ${compLabel}.`;
} }
interface PageHeaderProps { title: string; page: number; } interface PageHeaderProps { title: string; page: number; isAr: boolean; arB: any; }
function PageHeader({ title, page }: PageHeaderProps) { function PageHeader({ title, page, isAr, arB }: PageHeaderProps) {
return ( return (
<View style={S.pageHeader}> <View style={S.pageHeader}>
<Text style={S.pageHeaderLogo}>HiHala Data</Text> <Text style={[S.pageHeaderLogo, arB]}>HiHala Data</Text>
<Text style={S.pageHeaderTitle}>{title}</Text> <Text style={[S.pageHeaderTitle, isAr ? { fontFamily: 'IBMPlexArabic' } : {}]}>{title}</Text>
<Text style={S.pageHeaderNum}>{page}</Text> <Text style={S.pageHeaderNum}>{page}</Text>
</View> </View>
); );
@@ -143,11 +151,11 @@ function PageFooter({ confidentiality, generatedAt }: PageFooterProps) {
); );
} }
interface SectionProps { title: string; color: string; } interface SectionProps { title: string; color: string; arB: any; }
function SectionHeading({ title, color }: SectionProps) { function SectionHeading({ title, color, arB }: SectionProps) {
return ( return (
<View style={[S.sectionHeading, { backgroundColor: color }]}> <View style={[S.sectionHeading, { backgroundColor: color }]}>
<Text>{title}</Text> <Text style={arB}>{title}</Text>
</View> </View>
); );
} }
@@ -156,18 +164,24 @@ interface Props { data: ReportData; }
export function ReportDocument({ data }: Props) { export function ReportDocument({ data }: Props) {
const { config: cfg, metrics, prevMetrics, comparisonPeriodLabel, const { config: cfg, metrics, prevMetrics, comparisonPeriodLabel,
trendLabels, trendCurrent, trendPrevious, trendCharts,
museumData, channelBreakdown, districtBreakdown, museumData, channelBreakdown, districtBreakdown,
pilgrimCapture, generatedAt } = data; pilgrimCapture, generatedAt } = data;
const lang = cfg.language; const lang = cfg.language;
const isAr = lang === 'ar';
const color = cfg.accentColor; const color = cfg.accentColor;
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang); const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
const isLandscape = cfg.orientation === 'landscape'; const isLandscape = cfg.orientation === 'landscape';
const orientation = isLandscape ? 'landscape' : 'portrait'; const orientation = isLandscape ? 'landscape' : 'portrait';
const T = lang === 'en' ? LABELS_EN : LABELS_AR; const T = lang === 'en' ? LABELS_EN : LABELS_AR;
// Chart width adapts to orientation // Arabic font overrides — Helvetica has no Arabic glyphs; cast as any so style arrays stay compatible
const arN: any = isAr ? { fontFamily: 'IBMPlexArabic', fontWeight: 400 } : {};
const arB: any = isAr ? { fontFamily: 'IBMPlexArabic', fontWeight: 700 } : {};
// direction: 'rtl' flips flex-row children right-to-left; fontFamily cascades to elements without an explicit one
const arPageExtra: any = isAr ? { direction: 'rtl', fontFamily: 'IBMPlexArabic' } : {};
const chartW = isLandscape ? CHART_W.landscape : CHART_W.portrait; const chartW = isLandscape ? CHART_W.landscape : CHART_W.portrait;
const avgTicketPrice = metrics.tickets > 0 ? metrics.revenue / metrics.tickets : 0; const avgTicketPrice = metrics.tickets > 0 ? metrics.revenue / metrics.tickets : 0;
@@ -197,10 +211,6 @@ export function ReportDocument({ data }: Props) {
}] : []), }] : []),
]; ];
const trendTitle = cfg.trendMetric === 'visitors' ? T.trendVisitors
: cfg.trendMetric === 'tickets' ? T.trendTickets
: T.trendRevenue;
const showMuseumPage = cfg.showMuseumRevenue || cfg.showMuseumVisitors || cfg.showMuseumTickets; const showMuseumPage = cfg.showMuseumRevenue || cfg.showMuseumVisitors || cfg.showMuseumTickets;
const showChannelPage = cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets; const showChannelPage = cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets;
const showDistrictPage = cfg.showDistrictRevenue || cfg.showDistrictVisitors || cfg.showDistrictTickets; const showDistrictPage = cfg.showDistrictRevenue || cfg.showDistrictVisitors || cfg.showDistrictTickets;
@@ -240,32 +250,30 @@ export function ReportDocument({ data }: Props) {
<Document title={cfg.title || 'HiHala Report'} author="HiHala Data"> <Document title={cfg.title || 'HiHala Report'} author="HiHala Data">
{/* ── Cover ─────────────────────────────────────────── */} {/* ── Cover ─────────────────────────────────────────── */}
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage]}> <Page size="A4" orientation={orientation} style={[S.page, S.coverPage, arPageExtra]}>
{/* Colored header band */}
<View style={[S.coverHeader, { backgroundColor: color }]}> <View style={[S.coverHeader, { backgroundColor: color }]}>
<View style={S.coverHeaderTop}> <View style={S.coverHeaderTop}>
<Text style={S.coverBrand}>HiHala Data</Text> <Text style={[S.coverBrand, arB]}>HiHala Data</Text>
{cfg.clientLogoBase64 && ( {cfg.clientLogoBase64 && (
<View style={S.coverLogoBox}> <View style={S.coverLogoBox}>
<Image src={cfg.clientLogoBase64} style={S.coverClientLogo} /> <Image src={cfg.clientLogoBase64} style={S.coverClientLogo} />
</View> </View>
)} )}
</View> </View>
<Text style={S.coverTitle}>{cfg.title || T.defaultTitle}</Text> <Text style={[S.coverTitle, arB]}>{cfg.title || T.defaultTitle}</Text>
</View> </View>
{/* White body */}
<View style={S.coverBody}> <View style={S.coverBody}>
{cfg.clientName && ( {cfg.clientName && (
<Text style={S.coverClientName}>{T.preparedFor}: {cfg.clientName}</Text> <Text style={[S.coverClientName, arB]}>{T.preparedFor}: {cfg.clientName}</Text>
)} )}
{cfg.contactName && ( {cfg.contactName && (
<Text style={S.coverContactName}>{T.attention}: {cfg.contactName}</Text> <Text style={[S.coverContactName, arN]}>{T.attention}: {cfg.contactName}</Text>
)} )}
<View style={S.coverBodySpacer} /> <View style={S.coverBodySpacer} />
<View style={S.coverPeriodRow}> <View style={S.coverPeriodRow}>
<View style={[S.coverPeriodDot, { backgroundColor: color }]} /> <View style={[S.coverPeriodDot, { backgroundColor: color }]} />
<Text style={S.coverPeriod}>{period}</Text> <Text style={[S.coverPeriod, arN]}>{period}</Text>
</View> </View>
<Text style={S.coverDate}>{T.generated}: {generatedAt}</Text> <Text style={S.coverDate}>{T.generated}: {generatedAt}</Text>
{cfg.confidentiality !== 'Public' && ( {cfg.confidentiality !== 'Public' && (
@@ -275,34 +283,34 @@ export function ReportDocument({ data }: Props) {
</Page> </Page>
{/* ── Summary + Metrics + Trend ──────────────────────── */} {/* ── Summary + Metrics + Trend ──────────────────────── */}
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}> <Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
<PageHeader title={cfg.title || T.defaultTitle} page={mainPg} /> <PageHeader title={cfg.title || T.defaultTitle} page={mainPg} isAr={isAr} arB={arB} />
{cfg.showExecutiveSummary && ( {cfg.showExecutiveSummary && (
<View style={S.sectionGap}> <View style={S.sectionGap}>
<SectionHeading title={T.execSummary} color={color} /> <SectionHeading title={T.execSummary} color={color} arB={arB} />
<Text style={S.summaryText}>{generateExecutiveSummary(data)}</Text> <Text style={[S.summaryText, arN]}>{generateExecutiveSummary(data)}</Text>
</View> </View>
)} )}
{cfg.showMetricsTable && ( {cfg.showMetricsTable && (
<View style={S.sectionGap}> <View style={S.sectionGap}>
<SectionHeading title={`${T.keyMetrics}${cfg.includeVAT ? T.inclVAT : T.exclVAT}`} color={color} /> <SectionHeading title={`${T.keyMetrics}${cfg.includeVAT ? T.inclVAT : T.exclVAT}`} color={color} arB={arB} />
<View style={S.metricsTable}> <View style={S.metricsTable}>
<View style={S.metricsHeaderRow}> <View style={S.metricsHeaderRow}>
<Text style={S.metricsHeaderLabel}> </Text> <Text style={[S.metricsHeaderLabel, arB]}> </Text>
<Text style={S.metricsHeaderCell}>{period}</Text> <Text style={[S.metricsHeaderCell, arB]}>{period}</Text>
{prevMetrics && <Text style={S.metricsHeaderCell}>{comparisonPeriodLabel}</Text>} {prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
{prevMetrics && <Text style={S.metricsHeaderCell}>{T.change}</Text>} {prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{T.change}</Text>}
</View> </View>
{metricsRows.map((row, i) => ( {metricsRows.map((row, i) => (
<View key={row.label} style={[S.metricsRow, i % 2 === 1 ? S.metricsRowAlt : {}]}> <View key={row.label} style={[S.metricsRow, i % 2 === 1 ? S.metricsRowAlt : {}]}>
<Text style={S.metricsLabel}>{row.label}</Text> <Text style={[S.metricsLabel, arB]}>{row.label}</Text>
<Text style={S.metricsValue}>{row.curr}</Text> <Text style={[S.metricsValue, arN]}>{row.curr}</Text>
{prevMetrics && <Text style={S.metricsValue}>{row.prev ?? '—'}</Text>} {prevMetrics && <Text style={[S.metricsValue, arN]}>{row.prev ?? '—'}</Text>}
{prevMetrics && row.chg !== null && ( {prevMetrics && row.chg !== null && (
<Text style={[S.metricsChange, row.chg >= 0 ? S.metricsChangeUp : S.metricsChangeDown]}> <Text style={[S.metricsChange, row.chg >= 0 ? S.metricsChangeUp : S.metricsChangeDown]}>
{row.chg >= 0 ? '+' : '-'}{formatPct(Math.abs(row.chg))} {formatPct(row.chg)}
</Text> </Text>
)} )}
</View> </View>
@@ -311,63 +319,85 @@ export function ReportDocument({ data }: Props) {
</View> </View>
)} )}
{cfg.showTrendChart && ( {cfg.showTrendChart && trendCharts.map((tc, tci) => {
<View style={S.sectionGap}> const trendTitle = tc.metric === 'visitors' ? T.trendVisitors
<SectionHeading title={trendTitle} color={color} /> : tc.metric === 'tickets' ? T.trendTickets
{cfg.includeComparison && ( : T.trendRevenue;
return (
<View key={tci} style={S.sectionGap}>
<SectionHeading title={trendTitle} color={color} arB={arB} />
<View style={S.legendRow}> <View style={S.legendRow}>
<View style={S.legendItem}> {tc.museums.length >= 2 && tc.museums.map((m, i) => (
<View style={[S.legendDot, { backgroundColor: color }]} /> <View key={m.name} style={S.legendItem}>
<Text style={S.legendLabel}>{period}</Text> <View style={[S.legendDot, { backgroundColor: CHART_PALETTE[i % CHART_PALETTE.length] }]} />
<Text style={[S.legendLabel, arN]}>{m.name}</Text>
</View> </View>
))}
<View style={S.legendItem}>
<View style={[S.legendDot, { backgroundColor: TOTAL_LINE_COLOR }]} />
<Text style={[S.legendLabel, arN]}>{tc.museums.length >= 2 ? `Total · ${period}` : period}</Text>
</View>
{cfg.includeComparison && tc.previous && (
<View style={S.legendItem}> <View style={S.legendItem}>
<View style={[S.legendDot, { backgroundColor: '#94a3b8' }]} /> <View style={[S.legendDot, { backgroundColor: '#94a3b8' }]} />
<Text style={S.legendLabel}>{comparisonPeriodLabel}</Text> <Text style={[S.legendLabel, arN]}>{comparisonPeriodLabel}</Text>
</View>
</View> </View>
)} )}
</View>
<View style={S.chartWrap}> <View style={S.chartWrap}>
<PdfTrendChart labels={trendLabels} current={trendCurrent} <PdfTrendChart
previous={trendPrevious} color={color} width={chartW} height={155} /> labels={tc.labels}
current={tc.current}
previous={tc.previous}
color={TOTAL_LINE_COLOR}
width={chartW}
height={155}
series={tc.museums.length >= 2 ? tc.museums.map((m, i) => ({
label: m.name,
color: CHART_PALETTE[i % CHART_PALETTE.length],
data: m.values,
})) : undefined}
/>
</View> </View>
</View> </View>
)} );
})}
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} /> <PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
</Page> </Page>
{/* ── Museum Mini-Reports ────────────────────────────── */} {/* ── Museum Mini-Reports ────────────────────────────── */}
{showMuseumPage && museumData.length > 0 && ( {showMuseumPage && museumData.length > 0 && (
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}> <Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
<PageHeader title={cfg.title || T.defaultTitle} page={museumPg} /> <PageHeader title={cfg.title || T.defaultTitle} page={museumPg} isAr={isAr} arB={arB} />
<SectionHeading title={T.museumBreakdowns} color={color} /> <SectionHeading title={T.museumBreakdowns} color={color} arB={arB} />
{museumData.map((row, mi) => { {museumData.map((row, mi) => {
const mRows = museumMetricRows(row); const mRows = museumMetricRows(row);
const hasPrev = row.prev !== null; const hasPrev = row.prev !== null;
return ( return (
<View key={row.name} style={[S.museumBlock, { borderLeftColor: CHART_PALETTE[mi % CHART_PALETTE.length] }]}> <View key={row.name} style={[S.museumBlock, { borderLeftColor: CHART_PALETTE[mi % CHART_PALETTE.length] }]}>
<Text style={S.museumBlockName}>{row.name}</Text> <Text style={[S.museumBlockName, arB]}>{row.name}</Text>
{hasPrev && ( {hasPrev && (
<Text style={S.museumIntroText}> <Text style={[S.museumIntroText, arN]}>
{museumIntro(row, lang, comparisonPeriodLabel)} {museumIntro(row, lang, comparisonPeriodLabel)}
</Text> </Text>
)} )}
<View style={S.miniTable}> <View style={S.miniTable}>
<View style={S.miniHeaderRow}> <View style={S.miniHeaderRow}>
<Text style={S.miniHeaderLabel}> </Text> <Text style={[S.miniHeaderLabel, arB]}> </Text>
<Text style={S.miniHeaderCell}>{period}</Text> <Text style={[S.miniHeaderCell, arB]}>{period}</Text>
{hasPrev && <Text style={S.miniHeaderCell}>{comparisonPeriodLabel}</Text>} {hasPrev && <Text style={[S.miniHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
{hasPrev && <Text style={S.miniHeaderChangeCell}>{T.change}</Text>} {hasPrev && <Text style={[S.miniHeaderChangeCell, arB]}>{T.change}</Text>}
</View> </View>
{mRows.map((mr, ri) => ( {mRows.map((mr, ri) => (
<View key={mr.label} style={[S.miniRow, ri % 2 === 1 ? S.miniRowAlt : {}]}> <View key={mr.label} style={[S.miniRow, ri % 2 === 1 ? S.miniRowAlt : {}]}>
<Text style={S.miniLabel}>{mr.label}</Text> <Text style={[S.miniLabel, arB]}>{mr.label}</Text>
<Text style={S.miniValue}>{mr.curr}</Text> <Text style={[S.miniValue, arN]}>{mr.curr}</Text>
{hasPrev && <Text style={S.miniValue}>{mr.prev ?? '—'}</Text>} {hasPrev && <Text style={[S.miniValue, arN]}>{mr.prev ?? '—'}</Text>}
{hasPrev && mr.chg !== null && ( {hasPrev && mr.chg !== null && (
<Text style={[S.miniChange, mr.chg >= 0 ? S.miniChangeUp : S.miniChangeDown]}> <Text style={[S.miniChange, mr.chg >= 0 ? S.miniChangeUp : S.miniChangeDown]}>
{mr.chg >= 0 ? '+' : '-'}{formatPct(Math.abs(mr.chg))} {formatPct(mr.chg)}
</Text> </Text>
)} )}
</View> </View>
@@ -383,12 +413,12 @@ export function ReportDocument({ data }: Props) {
{/* ── Channel Breakdowns ─────────────────────────────── */} {/* ── Channel Breakdowns ─────────────────────────────── */}
{showChannelPage && ( {showChannelPage && (
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}> <Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
<PageHeader title={cfg.title || T.defaultTitle} page={channelPg} /> <PageHeader title={cfg.title || T.defaultTitle} page={channelPg} isAr={isAr} arB={arB} />
{cfg.showChannelRevenue && channelBreakdown.revenue.length > 0 && ( {cfg.showChannelRevenue && channelBreakdown.revenue.length > 0 && (
<View style={S.sectionGap}> <View style={S.sectionGap}>
<SectionHeading title={T.byChannelRevenue} color={color} /> <SectionHeading title={T.byChannelRevenue} color={color} arB={arB} />
<View style={S.chartWrap}> <View style={S.chartWrap}>
<PdfHBarChart items={channelBreakdown.revenue} color={color} usepalette width={chartW} /> <PdfHBarChart items={channelBreakdown.revenue} color={color} usepalette width={chartW} />
</View> </View>
@@ -396,7 +426,7 @@ export function ReportDocument({ data }: Props) {
)} )}
{cfg.showChannelVisitors && channelBreakdown.visitors.length > 0 && ( {cfg.showChannelVisitors && channelBreakdown.visitors.length > 0 && (
<View style={S.sectionGap}> <View style={S.sectionGap}>
<SectionHeading title={T.byChannelVisitors} color={color} /> <SectionHeading title={T.byChannelVisitors} color={color} arB={arB} />
<View style={S.chartWrap}> <View style={S.chartWrap}>
<PdfHBarChart items={channelBreakdown.visitors} color={color} usepalette width={chartW} /> <PdfHBarChart items={channelBreakdown.visitors} color={color} usepalette width={chartW} />
</View> </View>
@@ -404,7 +434,7 @@ export function ReportDocument({ data }: Props) {
)} )}
{cfg.showChannelTickets && channelBreakdown.tickets.length > 0 && ( {cfg.showChannelTickets && channelBreakdown.tickets.length > 0 && (
<View style={S.sectionGap}> <View style={S.sectionGap}>
<SectionHeading title={T.byChannelTickets} color={color} /> <SectionHeading title={T.byChannelTickets} color={color} arB={arB} />
<View style={S.chartWrap}> <View style={S.chartWrap}>
<PdfHBarChart items={channelBreakdown.tickets} color={color} usepalette width={chartW} /> <PdfHBarChart items={channelBreakdown.tickets} color={color} usepalette width={chartW} />
</View> </View>
@@ -417,12 +447,12 @@ export function ReportDocument({ data }: Props) {
{/* ── District Breakdowns ────────────────────────────── */} {/* ── District Breakdowns ────────────────────────────── */}
{showDistrictPage && ( {showDistrictPage && (
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}> <Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
<PageHeader title={cfg.title || T.defaultTitle} page={districtPg} /> <PageHeader title={cfg.title || T.defaultTitle} page={districtPg} isAr={isAr} arB={arB} />
{cfg.showDistrictRevenue && districtBreakdown.revenue.length > 0 && ( {cfg.showDistrictRevenue && districtBreakdown.revenue.length > 0 && (
<View style={S.sectionGap}> <View style={S.sectionGap}>
<SectionHeading title={T.byDistrictRevenue} color={color} /> <SectionHeading title={T.byDistrictRevenue} color={color} arB={arB} />
<View style={S.chartWrap}> <View style={S.chartWrap}>
<PdfHBarChart items={districtBreakdown.revenue} color={color} usepalette width={chartW} /> <PdfHBarChart items={districtBreakdown.revenue} color={color} usepalette width={chartW} />
</View> </View>
@@ -430,7 +460,7 @@ export function ReportDocument({ data }: Props) {
)} )}
{cfg.showDistrictVisitors && districtBreakdown.visitors.length > 0 && ( {cfg.showDistrictVisitors && districtBreakdown.visitors.length > 0 && (
<View style={S.sectionGap}> <View style={S.sectionGap}>
<SectionHeading title={T.byDistrictVisitors} color={color} /> <SectionHeading title={T.byDistrictVisitors} color={color} arB={arB} />
<View style={S.chartWrap}> <View style={S.chartWrap}>
<PdfHBarChart items={districtBreakdown.visitors} color={color} usepalette width={chartW} /> <PdfHBarChart items={districtBreakdown.visitors} color={color} usepalette width={chartW} />
</View> </View>
@@ -438,7 +468,7 @@ export function ReportDocument({ data }: Props) {
)} )}
{cfg.showDistrictTickets && districtBreakdown.tickets.length > 0 && ( {cfg.showDistrictTickets && districtBreakdown.tickets.length > 0 && (
<View style={S.sectionGap}> <View style={S.sectionGap}>
<SectionHeading title={T.byDistrictTickets} color={color} /> <SectionHeading title={T.byDistrictTickets} color={color} arB={arB} />
<View style={S.chartWrap}> <View style={S.chartWrap}>
<PdfHBarChart items={districtBreakdown.tickets} color={color} usepalette width={chartW} /> <PdfHBarChart items={districtBreakdown.tickets} color={color} usepalette width={chartW} />
</View> </View>
@@ -451,27 +481,27 @@ export function ReportDocument({ data }: Props) {
{/* ── Global Performance Summary ─────────────────────── */} {/* ── Global Performance Summary ─────────────────────── */}
{showSummaryPage && museumData.length > 0 && ( {showSummaryPage && museumData.length > 0 && (
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}> <Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
<PageHeader title={cfg.title || T.defaultTitle} page={summaryPg} /> <PageHeader title={cfg.title || T.defaultTitle} page={summaryPg} isAr={isAr} arB={arB} />
<SectionHeading title={T.globalSummary} color={color} /> <SectionHeading title={T.globalSummary} color={color} arB={arB} />
<Text style={S.summarySubLabel}> <Text style={[S.summarySubLabel, arN]}>
{period} {T.comparedTo} {comparisonPeriodLabel} {period} {T.comparedTo} {comparisonPeriodLabel}
</Text> </Text>
<View style={S.summaryHeaderRow}> <View style={S.summaryHeaderRow}>
<Text style={S.summaryHeaderMuseum}>{T.museum}</Text> <Text style={[S.summaryHeaderMuseum, arB]}>{T.museum}</Text>
{cfg.showMuseumRevenue && <> {cfg.showMuseumRevenue && <>
<Text style={S.summaryHeaderMetric}>{T.revenue}</Text> <Text style={[S.summaryHeaderMetric, arB]}>{T.revenue}</Text>
<Text style={S.summaryHeaderDelta}>Δ</Text> <Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
</>} </>}
{cfg.showMuseumVisitors && <> {cfg.showMuseumVisitors && <>
<Text style={S.summaryHeaderMetric}>{T.visitors}</Text> <Text style={[S.summaryHeaderMetric, arB]}>{T.visitors}</Text>
<Text style={S.summaryHeaderDelta}>Δ</Text> <Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
</>} </>}
{cfg.showMuseumTickets && <> {cfg.showMuseumTickets && <>
<Text style={S.summaryHeaderMetric}>{T.tickets}</Text> <Text style={[S.summaryHeaderMetric, arB]}>{T.tickets}</Text>
<Text style={S.summaryHeaderDelta}>Δ</Text> <Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
</>} </>}
</View> </View>
@@ -479,26 +509,26 @@ export function ReportDocument({ data }: Props) {
const hasPrev = row.prev !== null; const hasPrev = row.prev !== null;
return ( return (
<View key={row.name} style={[S.summaryRow, i % 2 === 1 ? S.summaryRowAlt : {}]}> <View key={row.name} style={[S.summaryRow, i % 2 === 1 ? S.summaryRowAlt : {}]}>
<Text style={S.summaryMuseum}>{row.name.length > 30 ? row.name.slice(0, 30) + '…' : row.name}</Text> <Text style={[S.summaryMuseum, arN]}>{row.name.length > 30 ? row.name.slice(0, 30) + '…' : row.name}</Text>
{cfg.showMuseumRevenue && <> {cfg.showMuseumRevenue && <>
<Text style={S.summaryMetric}>{formatCurrency(row.curr.revenue, cfg.includeVAT)}</Text> <Text style={[S.summaryMetric, arN]}>{formatCurrency(row.curr.revenue, cfg.includeVAT)}</Text>
{hasPrev && row.prev ? (() => { {hasPrev && row.prev ? (() => {
const c = pctChange(row.curr.revenue, row.prev!.revenue); const c = pctChange(row.curr.revenue, row.prev!.revenue);
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>; return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
})() : <Text style={S.summaryDelta}></Text>} })() : <Text style={S.summaryDelta}></Text>}
</>} </>}
{cfg.showMuseumVisitors && <> {cfg.showMuseumVisitors && <>
<Text style={S.summaryMetric}>{row.curr.visitors.toLocaleString()}</Text> <Text style={[S.summaryMetric, arN]}>{row.curr.visitors.toLocaleString()}</Text>
{hasPrev && row.prev ? (() => { {hasPrev && row.prev ? (() => {
const c = pctChange(row.curr.visitors, row.prev!.visitors); const c = pctChange(row.curr.visitors, row.prev!.visitors);
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>; return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
})() : <Text style={S.summaryDelta}></Text>} })() : <Text style={S.summaryDelta}></Text>}
</>} </>}
{cfg.showMuseumTickets && <> {cfg.showMuseumTickets && <>
<Text style={S.summaryMetric}>{row.curr.tickets.toLocaleString()}</Text> <Text style={[S.summaryMetric, arN]}>{row.curr.tickets.toLocaleString()}</Text>
{hasPrev && row.prev ? (() => { {hasPrev && row.prev ? (() => {
const c = pctChange(row.curr.tickets, row.prev!.tickets); const c = pctChange(row.curr.tickets, row.prev!.tickets);
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>; return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
})() : <Text style={S.summaryDelta}></Text>} })() : <Text style={S.summaryDelta}></Text>}
</>} </>}
</View> </View>
@@ -506,26 +536,26 @@ export function ReportDocument({ data }: Props) {
})} })}
<View style={S.summaryTotalRow}> <View style={S.summaryTotalRow}>
<Text style={S.summaryMuseumTotal}>{T.total}</Text> <Text style={[S.summaryMuseumTotal, arB]}>{T.total}</Text>
{cfg.showMuseumRevenue && <> {cfg.showMuseumRevenue && <>
<Text style={S.summaryMetricTotal}>{formatCurrency(metrics.revenue, cfg.includeVAT)}</Text> <Text style={[S.summaryMetricTotal, arB]}>{formatCurrency(metrics.revenue, cfg.includeVAT)}</Text>
{prevMetrics ? (() => { {prevMetrics ? (() => {
const c = pctChange(metrics.revenue, prevMetrics.revenue); const c = pctChange(metrics.revenue, prevMetrics.revenue);
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>; return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
})() : <Text style={S.summaryDeltaTotal}></Text>} })() : <Text style={S.summaryDeltaTotal}></Text>}
</>} </>}
{cfg.showMuseumVisitors && <> {cfg.showMuseumVisitors && <>
<Text style={S.summaryMetricTotal}>{metrics.visitors.toLocaleString()}</Text> <Text style={[S.summaryMetricTotal, arB]}>{metrics.visitors.toLocaleString()}</Text>
{prevMetrics ? (() => { {prevMetrics ? (() => {
const c = pctChange(metrics.visitors, prevMetrics.visitors); const c = pctChange(metrics.visitors, prevMetrics.visitors);
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>; return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
})() : <Text style={S.summaryDeltaTotal}></Text>} })() : <Text style={S.summaryDeltaTotal}></Text>}
</>} </>}
{cfg.showMuseumTickets && <> {cfg.showMuseumTickets && <>
<Text style={S.summaryMetricTotal}>{metrics.tickets.toLocaleString()}</Text> <Text style={[S.summaryMetricTotal, arB]}>{metrics.tickets.toLocaleString()}</Text>
{prevMetrics ? (() => { {prevMetrics ? (() => {
const c = pctChange(metrics.tickets, prevMetrics.tickets); const c = pctChange(metrics.tickets, prevMetrics.tickets);
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>; return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
})() : <Text style={S.summaryDeltaTotal}></Text>} })() : <Text style={S.summaryDeltaTotal}></Text>}
</>} </>}
</View> </View>
+20 -13
View File
@@ -336,21 +336,28 @@ export default function ReportForm({ config: cfg, onChange, allMuseums, allChann
title="Trend Chart" title="Trend Chart"
enabled={cfg.showTrendChart} enabled={cfg.showTrendChart}
onToggle={v => onChange({ showTrendChart: v })} onToggle={v => onChange({ showTrendChart: v })}
badge={cfg.showTrendChart badge={cfg.showTrendChart && cfg.trendMetrics.length
? cfg.trendMetric.charAt(0).toUpperCase() + cfg.trendMetric.slice(1) ? cfg.trendMetrics.map(m => m.charAt(0).toUpperCase() + m.slice(1)).join(' · ')
: undefined} : undefined}
> >
{/* H7: PillGroup instead of <select> for full consistency */} <div className="rf-metric-pills" role="group" aria-label="Trend metrics to include">
<PillGroup {(['revenue', 'visitors', 'tickets'] as TrendMetric[]).map(m => {
label="Trend metric" const on = cfg.trendMetrics.includes(m);
options={[ return (
{ label: 'Revenue', value: 'revenue' }, <button key={m} type="button"
{ label: 'Visitors', value: 'visitors' }, className={`rf-metric-pill${on ? ' rf-metric-pill--on' : ''}`}
{ label: 'Tickets', value: 'tickets' }, aria-pressed={on}
]} onClick={() => {
value={cfg.trendMetric} const next = on
onChange={v => onChange({ trendMetric: v as TrendMetric })} ? cfg.trendMetrics.filter(x => x !== m)
/> : [...cfg.trendMetrics, m];
onChange({ trendMetrics: next.length ? next : [m] });
}}>
{m.charAt(0).toUpperCase() + m.slice(1)}
</button>
);
})}
</div>
</ModuleCard> </ModuleCard>
<div className="rf-divider" /> <div className="rf-divider" />
+12 -4
View File
@@ -19,12 +19,14 @@ interface TrendChartProps {
current: number[]; current: number[];
previous: number[] | null; previous: number[] | null;
color: string; color: string;
series?: Array<{ label: string; color: string; data: number[] }>;
width?: number; width?: number;
height?: number; height?: number;
} }
export function PdfTrendChart({ labels, current, previous, color, width = 470, height = 155 }: TrendChartProps) { export function PdfTrendChart({ labels, current, previous, color, series, width = 470, height = 155 }: TrendChartProps) {
const allValues = [...current, ...(previous ?? [])].filter(v => v > 0); const seriesValues = (series ?? []).flatMap(s => s.data);
const allValues = [...current, ...(previous ?? []), ...seriesValues].filter(v => v > 0);
const max = allValues.length > 0 ? Math.max(...allValues) : 1; const max = allValues.length > 0 ? Math.max(...allValues) : 1;
// padL wide enough for y-axis labels like "1.2M" // padL wide enough for y-axis labels like "1.2M"
const padL = 38, padR = 8, padT = 10, padB = 20; const padL = 38, padR = 8, padT = 10, padB = 20;
@@ -66,10 +68,16 @@ export function PdfTrendChart({ labels, current, previous, color, width = 470, h
stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 3" fill="none" /> stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 3" fill="none" />
)} )}
{/* Current period line */} {/* Per-museum series */}
{(series ?? []).map(s => s.data.some(v => v > 0) && (
<Polyline key={s.label} points={toPoints(s.data)}
stroke={s.color} strokeWidth={1.5} fill="none" />
))}
{/* Current period total line */}
{current.some(v => v > 0) && ( {current.some(v => v > 0) && (
<Polyline points={toPoints(current)} <Polyline points={toPoints(current)}
stroke={color} strokeWidth={2.5} fill="none" /> stroke={color} strokeWidth={series && series.length >= 2 ? 2 : 2.5} fill="none" />
)} )}
{/* X-axis week labels */} {/* X-axis week labels */}
+48 -19
View File
@@ -4,6 +4,14 @@ import type { MuseumRecord, Metrics } from '../../types';
// ─── config ─────────────────────────────────────────────────────── // ─── config ───────────────────────────────────────────────────────
export type TrendMetric = 'revenue' | 'visitors' | 'tickets'; export type TrendMetric = 'revenue' | 'visitors' | 'tickets';
export type TrendGranularity = 'day' | 'week' | 'month';
function inferGranularity(start: string, end: string): TrendGranularity {
const days = Math.round((new Date(end).getTime() - new Date(start).getTime()) / 86400000);
if (days > 180) return 'month';
if (days >= 14) return 'week';
return 'day';
}
const _start = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10); const _start = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10);
const _end = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString().slice(0, 10); const _end = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString().slice(0, 10);
@@ -28,7 +36,7 @@ export interface ReportConfig {
showPilgrimCapture: boolean; showPilgrimCapture: boolean;
// Trend chart // Trend chart
showTrendChart: boolean; showTrendChart: boolean;
trendMetric: TrendMetric; trendMetrics: TrendMetric[];
// Museum mini-reports // Museum mini-reports
showMuseumRevenue: boolean; showMuseumRevenue: boolean;
showMuseumVisitors: boolean; showMuseumVisitors: boolean;
@@ -67,7 +75,7 @@ export const DEFAULT_CONFIG: ReportConfig = {
showMetricsTable: true, showMetricsTable: true,
showPilgrimCapture: true, showPilgrimCapture: true,
showTrendChart: true, showTrendChart: true,
trendMetric: 'revenue', trendMetrics: ['revenue'],
showMuseumRevenue: true, showMuseumRevenue: true,
showMuseumVisitors: true, showMuseumVisitors: true,
showMuseumTickets: false, showMuseumTickets: false,
@@ -98,14 +106,20 @@ export interface MuseumDataRow {
prev: { revenue: number; visitors: number; tickets: number } | null; prev: { revenue: number; visitors: number; tickets: number } | null;
} }
export interface TrendChart {
metric: TrendMetric;
labels: string[];
current: number[];
previous: number[] | null;
museums: Array<{ name: string; values: number[] }>;
}
export interface ReportData { export interface ReportData {
config: ReportConfig; config: ReportConfig;
metrics: Metrics; metrics: Metrics;
prevMetrics: Metrics | null; prevMetrics: Metrics | null;
comparisonPeriodLabel: string; comparisonPeriodLabel: string;
trendLabels: string[]; trendCharts: TrendChart[];
trendCurrent: number[];
trendPrevious: number[] | null;
museumData: MuseumDataRow[]; museumData: MuseumDataRow[];
museumBreakdown: DimensionBreakdown; museumBreakdown: DimensionBreakdown;
channelBreakdown: DimensionBreakdown; channelBreakdown: DimensionBreakdown;
@@ -147,21 +161,27 @@ function getMetricVal(r: MuseumRecord, metric: TrendMetric, includeVAT: boolean)
return (includeVAT ? r.revenue_gross : r.revenue_net) || 0; return (includeVAT ? r.revenue_gross : r.revenue_net) || 0;
} }
function buildTrend(rows: MuseumRecord[], start: string, cfg: ReportConfig): { labels: string[]; values: number[] } { const MONTH_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function buildTrend(rows: MuseumRecord[], start: string, metric: TrendMetric, includeVAT: boolean, gran: TrendGranularity): { labels: string[]; values: number[] } {
const s = new Date(start); const s = new Date(start);
const acc: Record<number, MuseumRecord[]> = {}; const acc: Record<number, MuseumRecord[]> = {};
rows.forEach(r => { rows.forEach(r => {
if (!r.date) return; if (!r.date) return;
const diff = Math.floor((new Date(r.date).getTime() - s.getTime()) / 86400000); const diff = Math.floor((new Date(r.date).getTime() - s.getTime()) / 86400000);
const key = Math.floor(diff / 7) + 1; const key = gran === 'month' ? Math.floor(diff / 30) + 1 : gran === 'week' ? Math.floor(diff / 7) + 1 : diff + 1;
if (!acc[key]) acc[key] = []; if (!acc[key]) acc[key] = [];
acc[key].push(r); acc[key].push(r);
}); });
const maxK = Math.max(...Object.keys(acc).map(Number), 1); const maxK = Math.max(...Object.keys(acc).map(Number), 1);
const labels = Array.from({ length: maxK }, (_, i) => `W${i + 1}`); const labels = Array.from({ length: maxK }, (_, i) => {
if (gran === 'month') return MONTH_SHORT[(s.getMonth() + i) % 12];
if (gran === 'week') return `W${i + 1}`;
return `${i + 1}`;
});
const values = labels.map((_, i) => { const values = labels.map((_, i) => {
const group = acc[i + 1] || []; const group = acc[i + 1] || [];
return group.reduce((s, r) => s + getMetricVal(r, cfg.trendMetric, cfg.includeVAT), 0); return group.reduce((sum, r) => sum + getMetricVal(r, metric, includeVAT), 0);
}); });
return { labels, values }; return { labels, values };
} }
@@ -186,14 +206,25 @@ export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): R
? formatPeriodLabel(cfg.comparisonStartDate, cfg.comparisonEndDate, cfg.language) ? formatPeriodLabel(cfg.comparisonStartDate, cfg.comparisonEndDate, cfg.language)
: ''; : '';
const currTrend = buildTrend(currRows, cfg.startDate, cfg); const gran = inferGranularity(cfg.startDate, cfg.endDate);
const prevTrend = cfg.includeComparison ? buildTrend(prevRows, cfg.comparisonStartDate, cfg) : null; const museumNames = Object.keys(groupByMuseum(currRows, cfg.includeVAT))
const maxLen = Math.max(currTrend.labels.length, prevTrend?.values.length ?? 0); .filter(name => currRows.some(r => r.museum_name === name));
const trendLabels = Array.from({ length: maxLen }, (_, i) => `W${i + 1}`);
const trendCurrent = Array.from({ length: maxLen }, (_, i) => currTrend.values[i] ?? 0); const trendCharts: TrendChart[] = cfg.trendMetrics.map(metric => {
const trendPrevious = prevTrend const currT = buildTrend(currRows, cfg.startDate, metric, cfg.includeVAT, gran);
? Array.from({ length: maxLen }, (_, i) => prevTrend.values[i] ?? 0) const prevT = cfg.includeComparison
? buildTrend(prevRows, cfg.comparisonStartDate, metric, cfg.includeVAT, gran)
: null; : null;
const maxLen = Math.max(currT.labels.length, prevT ? prevT.values.length : 0, 1);
const labels = Array.from({ length: maxLen }, (_, i) => currT.labels[i] ?? `${i + 1}`);
const current = Array.from({ length: maxLen }, (_, i) => currT.values[i] ?? 0);
const previous = prevT ? Array.from({ length: maxLen }, (_, i) => prevT.values[i] ?? 0) : null;
const museums = museumNames.map(name => {
const mt = buildTrend(currRows.filter(r => r.museum_name === name), cfg.startDate, metric, cfg.includeVAT, gran);
return { name, values: Array.from({ length: maxLen }, (_, i) => mt.values[i] ?? 0) };
}).filter(m => m.values.some(v => v > 0));
return { metric, labels, current, previous, museums };
});
const currMuseumGroups = groupByMuseum(currRows, cfg.includeVAT); const currMuseumGroups = groupByMuseum(currRows, cfg.includeVAT);
const prevMuseumGroups = cfg.includeComparison ? groupByMuseum(prevRows, cfg.includeVAT) : {}; const prevMuseumGroups = cfg.includeComparison ? groupByMuseum(prevRows, cfg.includeVAT) : {};
@@ -223,9 +254,7 @@ export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): R
metrics, metrics,
prevMetrics, prevMetrics,
comparisonPeriodLabel, comparisonPeriodLabel,
trendLabels, trendCharts,
trendCurrent,
trendPrevious,
museumData, museumData,
museumBreakdown, museumBreakdown,
channelBreakdown, channelBreakdown,
+5 -3
View File
@@ -1,11 +1,13 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
// ─── multi-select ───────────────────────────────────────────────── // ─── multi-select ─────────────────────────────────────────────────
export default function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: { export default function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel, labelFn }: {
value: string[]; options: string[]; value: string[]; options: string[];
onChange: (vals: string[]) => void; onChange: (vals: string[]) => void;
allLabel: string; countLabel: (n: number) => string; clearLabel: string; allLabel: string; countLabel: (n: number) => string; clearLabel: string;
labelFn?: (opt: string) => string;
}) { }) {
const display = labelFn ?? ((opt: string) => opt);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -15,7 +17,7 @@ export default function AltMultiSelect({ value, options, onChange, allLabel, cou
}, [open]); }, [open]);
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v !== opt) : [...value, opt]); 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); const label = value.length === 0 ? allLabel : value.length === 1 ? display(value[0]) : countLabel(value.length);
return ( return (
<div ref={ref} className="altms"> <div ref={ref} className="altms">
@@ -32,7 +34,7 @@ export default function AltMultiSelect({ value, options, onChange, allLabel, cou
<label key={opt} role="option" aria-selected={value.includes(opt)} className={`altms-option${value.includes(opt) ? ' altms-option--checked' : ''}`}> <label key={opt} role="option" aria-selected={value.includes(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} /> <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-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> <span className="altms-opt-label">{display(opt)}</span>
</label> </label>
))} ))}
</div> </div>
+33 -1
View File
@@ -30,6 +30,9 @@ ChartJS.register(
Annotation Annotation
); );
// Used for the "Total" line in multi-museum trend charts — always distinct from chartPalette.
export const TOTAL_COLOR = '#1e293b';
export const chartColors = { export const chartColors = {
primary: '#2563eb', primary: '#2563eb',
secondary: '#7c3aed', secondary: '#7c3aed',
@@ -113,7 +116,9 @@ export const createBaseOptions = (showDataLabels: boolean): any => {
titleFont: { size: 12 }, titleFont: { size: 12 },
bodyFont: { size: 11 }, bodyFont: { size: 11 },
rtl: false, rtl: false,
textDirection: 'ltr' textDirection: 'ltr',
usePointStyle: true,
boxPadding: 6,
}, },
datalabels: createDataLabelConfig(showDataLabels, { datalabels: createDataLabelConfig(showDataLabels, {
color: theme.textPrimary, color: theme.textPrimary,
@@ -134,6 +139,33 @@ export const createBaseOptions = (showDataLabels: boolean): any => {
}; };
}; };
// Hover-dim + end-of-line name labels for multi-museum trend charts.
// Only activates for charts that have datasets marked with _isMuseumLine.
const trendLinePlugin = {
id: 'trendLineOverlay',
// ── hover dim ──────────────────────────────────────────────────
beforeDatasetDraw(chart: any, args: any) {
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
const active = chart.getActiveElements();
if (active.length === 0) return;
if (active[0].datasetIndex !== args.index) {
chart.ctx.save();
chart.ctx.globalAlpha = 0.15;
}
},
afterDatasetDraw(chart: any, args: any) {
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
const active = chart.getActiveElements();
if (active.length > 0 && active[0].datasetIndex !== args.index) {
chart.ctx.restore();
}
},
};
ChartJS.register(trendLinePlugin);
export const lineDatasetDefaults = { export const lineDatasetDefaults = {
borderWidth: 2, borderWidth: 2,
tension: 0.4, tension: 0.4,
+15
View File
@@ -75,6 +75,21 @@ export let umrahData: UmrahData = {
2025: { 1: 15222497, 2: 5443393, 3: 26643148, 4: 31591871 } 2025: { 1: 15222497, 2: 5443393, 3: 26643148, 4: 31591871 }
}; };
export async function fetchMuseumTranslations(): Promise<Record<string, string>> {
try {
const tables = await discoverTableIds();
if (!tables['Museums']) return {};
const rows = await fetchNocoDBTable<{ Name: string; NameAr: string }>(tables['Museums']);
const map: Record<string, string> = {};
for (const r of rows) {
if (r.Name && r.NameAr) map[r.Name] = r.NameAr;
}
return map;
} catch {
return {};
}
}
export async function fetchPilgrimStats(): Promise<UmrahData> { export async function fetchPilgrimStats(): Promise<UmrahData> {
try { try {
const tables = await discoverTableIds(); const tables = await discoverTableIds();