Compare commits
8 Commits
1070490ad2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| dd512444fb | |||
| 4f51280d1c | |||
| 89689c5979 | |||
| 49bda53598 | |||
| 2888936d54 | |||
| 131868a280 | |||
| 7365bc808b | |||
| 26bb69c76c |
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
@@ -7,7 +7,7 @@ const Dashboard = lazy(() => import('./components/Dashboard'));
|
||||
const Report = lazy(() => import('./components/Report'));
|
||||
import Login from './components/Login';
|
||||
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 { parseAllowed } from './services/usersService';
|
||||
import { useLanguage } from './contexts/LanguageContext';
|
||||
@@ -59,6 +59,7 @@ function App() {
|
||||
const [includeVAT, setIncludeVAT] = useState<boolean>(true);
|
||||
const [dataSource, setDataSource] = useState<string>('museums');
|
||||
const [seasons, setSeasons] = useState<Season[]>([]);
|
||||
const [museumTranslations, setMuseumTranslations] = useState<Record<string, string>>({});
|
||||
const [theme, setTheme] = useState<string>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('hihala_theme') || 'light';
|
||||
@@ -118,6 +119,11 @@ function App() {
|
||||
setSeasons(s);
|
||||
}, []);
|
||||
|
||||
const loadMuseumTranslations = useCallback(async () => {
|
||||
const t = await fetchMuseumTranslations();
|
||||
setMuseumTranslations(t);
|
||||
}, []);
|
||||
|
||||
// Check auth on mount
|
||||
useEffect(() => {
|
||||
fetch('/auth/check', { credentials: 'include' })
|
||||
@@ -131,6 +137,7 @@ function App() {
|
||||
setAllowedChannels(parseAllowed(d.allowedChannels));
|
||||
loadData();
|
||||
loadSeasons();
|
||||
loadMuseumTranslations();
|
||||
}
|
||||
})
|
||||
.catch(() => setAuthenticated(false));
|
||||
@@ -145,6 +152,7 @@ function App() {
|
||||
setAllowedChannels(parseAllowed(rawChannels));
|
||||
loadData();
|
||||
loadSeasons();
|
||||
loadMuseumTranslations();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
@@ -327,8 +335,8 @@ function App() {
|
||||
<main>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} 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} museumTranslations={museumTranslations} />} />
|
||||
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
|
||||
{userRole === 'admin' && <Route path="/report" element={<Report data={data} />} />}
|
||||
</Routes>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
|
||||
umrahData
|
||||
} 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 { useLanguage } from '../contexts/LanguageContext';
|
||||
import type { LC } from '../lib/locale';
|
||||
@@ -22,6 +22,7 @@ interface Props {
|
||||
includeVAT: boolean;
|
||||
allowedMuseums: string[] | null;
|
||||
allowedChannels: string[] | null;
|
||||
museumTranslations?: Record<string, string>;
|
||||
lang?: 'en' | 'ar';
|
||||
}
|
||||
|
||||
@@ -65,8 +66,9 @@ function PeriodCard({ role, hint, start, end, variant, onChange, availableYears,
|
||||
}
|
||||
|
||||
// ─── 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 tr = (name: string) => (activeLang === 'ar' && museumTranslations[name]) ? museumTranslations[name] : name;
|
||||
const L = activeLang === 'ar' ? AR : EN;
|
||||
const curr = currentMonth();
|
||||
const [currStart, setCurrStart] = useState(curr.start);
|
||||
@@ -76,8 +78,9 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
||||
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 [metric, setMetric] = useState('revenue');
|
||||
const [gran, setGran] = useState('week');
|
||||
const [showLabels, setShowLabels] = useState(false);
|
||||
|
||||
const perm = useMemo(() => {
|
||||
if (!allowedMuseums || !allowedChannels) return [];
|
||||
@@ -160,17 +163,37 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
||||
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 {
|
||||
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,
|
||||
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 },
|
||||
...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(() => {
|
||||
@@ -179,23 +202,50 @@ 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); });
|
||||
const active = all.filter(m => pb[m]>0 || cb[m]>0);
|
||||
return {
|
||||
labels: active,
|
||||
labels: active.map(tr),
|
||||
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]);
|
||||
}, [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: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } };
|
||||
return { chartOpts };
|
||||
}, [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: {
|
||||
@@ -278,7 +328,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
||||
<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} />
|
||||
<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>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,6 +353,8 @@ 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>)}
|
||||
<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 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 className="alt-chart-wrap"><Line data={trendData} options={trendOpts} /></div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
groupByMuseum, groupByChannel, groupByDistrict,
|
||||
umrahData,
|
||||
} 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 { useLanguage } from '../contexts/LanguageContext';
|
||||
import { EN, AR } from '../lib/locale';
|
||||
@@ -22,12 +22,14 @@ interface Props {
|
||||
setIncludeVAT: (v: boolean) => void;
|
||||
allowedMuseums: string[] | null;
|
||||
allowedChannels: string[] | null;
|
||||
museumTranslations?: Record<string, string>;
|
||||
lang?: 'en' | 'ar';
|
||||
}
|
||||
|
||||
// ─── 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 tr = (name: string) => (activeLang === 'ar' && museumTranslations[name]) ? museumTranslations[name] : name;
|
||||
const L = activeLang === 'ar' ? AR : EN;
|
||||
const curr = currentMonth();
|
||||
const [start, setStart] = useState(curr.start);
|
||||
@@ -35,8 +37,9 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
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 [metric, setMetric] = useState('revenue');
|
||||
const [gran, setGran] = useState('week');
|
||||
const [showLabels, setShowLabels] = useState(false);
|
||||
const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar');
|
||||
const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie');
|
||||
const [districtChartType, setDistrictChartType] = useState<'bar'|'pie'>('pie');
|
||||
@@ -121,25 +124,45 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
return ds.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
});
|
||||
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 {
|
||||
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,
|
||||
datasets: [
|
||||
{ label:`${prevYear}`, data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:1.5, tension:0.4, pointRadius:0, borderDash:[4,3] },
|
||||
{ label:start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'18', borderWidth:2.5, tension:0.4, fill:true, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.primary },
|
||||
...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 g = groupByMuseum(filteredData, includeVAT);
|
||||
const getM = (d: typeof g[string]) => metric==='visitors' ? d.visitors : metric==='tickets' ? d.tickets : d.revenue;
|
||||
const entries = Object.entries(g).sort((a,b) => getM(b[1]) - getM(a[1]));
|
||||
return { labels:entries.map(([k]) => k), datasets:[{ label:metric, data:entries.map(([,v]) => getM(v)), backgroundColor:chartPalette, borderRadius:4 }] };
|
||||
}, [filteredData, includeVAT, metric]);
|
||||
return { labels:entries.map(([k]) => tr(k)), datasets:[{ label:metric, data:entries.map(([,v]) => getM(v)), backgroundColor:chartPalette, borderRadius:4 }] };
|
||||
}, [filteredData, includeVAT, metric, activeLang, museumTranslations]);
|
||||
|
||||
const channelData = useMemo(() => {
|
||||
const g = groupByChannel(filteredData, includeVAT);
|
||||
@@ -184,7 +207,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
const currCapture = currPilgrims ? currM.visitors/currPilgrims*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: 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 } } };
|
||||
@@ -193,8 +216,35 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
}, [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: {
|
||||
@@ -248,7 +298,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
<div className="alt-filter-sep" />
|
||||
<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={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>}
|
||||
<div className="alt-filter-spacer" />
|
||||
<div className="alt-vat-toggle">
|
||||
@@ -280,6 +330,8 @@ 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>)}
|
||||
<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 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 className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={trendOpts} /></div>
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Document, Page, View, Text, Image, StyleSheet
|
||||
Document, Page, View, Text, Image, StyleSheet, Font
|
||||
} from '@react-pdf/renderer';
|
||||
import { PdfTrendChart, PdfHBarChart, CHART_PALETTE } from './reportCharts';
|
||||
import {
|
||||
ReportData, MuseumDataRow, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary
|
||||
} 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)
|
||||
// Portrait: 595 - 44 - 44 - 28 = 479
|
||||
// Landscape: 842 - 44 - 44 - 28 = 726
|
||||
@@ -17,14 +27,12 @@ const S = StyleSheet.create({
|
||||
|
||||
// ── Cover ──────────────────────────────────────────────
|
||||
coverPage: { flexDirection: 'column', padding: 0 },
|
||||
// colored header band
|
||||
coverHeader: { paddingTop: 56, paddingRight: 52, paddingBottom: 52, paddingLeft: 52 },
|
||||
coverHeaderTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 48 },
|
||||
coverBrand: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', letterSpacing: 0.8 },
|
||||
coverLogoBox: { width: 90, height: 44, justifyContent: 'flex-end', alignItems: 'flex-end' },
|
||||
coverClientLogo: { width: 90, height: 44, objectFit: 'contain' as const },
|
||||
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' },
|
||||
coverClientName: { fontSize: 15, color: '#0f172a', fontFamily: 'Helvetica-Bold', marginBottom: 5 },
|
||||
coverContactName: { fontSize: 11, color: '#64748b', marginBottom: 32 },
|
||||
@@ -66,8 +74,8 @@ const S = StyleSheet.create({
|
||||
|
||||
// ── Trend chart ────────────────────────────────────────
|
||||
chartWrap: { marginBottom: 8, backgroundColor: '#f8fafc', paddingTop: 14, paddingRight: 14, paddingBottom: 14, paddingLeft: 14, borderRadius: 6, borderWidth: 1, borderColor: '#f1f5f9' },
|
||||
legendRow: { flexDirection: 'row', marginBottom: 10 },
|
||||
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18 },
|
||||
legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 10 },
|
||||
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18, marginBottom: 4 },
|
||||
legendDot: { width: 8, height: 8, borderRadius: 4 },
|
||||
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}.`;
|
||||
}
|
||||
|
||||
interface PageHeaderProps { title: string; page: number; }
|
||||
function PageHeader({ title, page }: PageHeaderProps) {
|
||||
interface PageHeaderProps { title: string; page: number; isAr: boolean; arB: any; }
|
||||
function PageHeader({ title, page, isAr, arB }: PageHeaderProps) {
|
||||
return (
|
||||
<View style={S.pageHeader}>
|
||||
<Text style={S.pageHeaderLogo}>HiHala Data</Text>
|
||||
<Text style={S.pageHeaderTitle}>{title}</Text>
|
||||
<Text style={[S.pageHeaderLogo, arB]}>HiHala Data</Text>
|
||||
<Text style={[S.pageHeaderTitle, isAr ? { fontFamily: 'IBMPlexArabic' } : {}]}>{title}</Text>
|
||||
<Text style={S.pageHeaderNum}>{page}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -143,11 +151,11 @@ function PageFooter({ confidentiality, generatedAt }: PageFooterProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionProps { title: string; color: string; }
|
||||
function SectionHeading({ title, color }: SectionProps) {
|
||||
interface SectionProps { title: string; color: string; arB: any; }
|
||||
function SectionHeading({ title, color, arB }: SectionProps) {
|
||||
return (
|
||||
<View style={[S.sectionHeading, { backgroundColor: color }]}>
|
||||
<Text>{title}</Text>
|
||||
<Text style={arB}>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -156,18 +164,24 @@ interface Props { data: ReportData; }
|
||||
|
||||
export function ReportDocument({ data }: Props) {
|
||||
const { config: cfg, metrics, prevMetrics, comparisonPeriodLabel,
|
||||
trendLabels, trendCurrent, trendPrevious,
|
||||
trendCharts,
|
||||
museumData, channelBreakdown, districtBreakdown,
|
||||
pilgrimCapture, generatedAt } = data;
|
||||
|
||||
const lang = cfg.language;
|
||||
const isAr = lang === 'ar';
|
||||
const color = cfg.accentColor;
|
||||
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
|
||||
const isLandscape = cfg.orientation === 'landscape';
|
||||
const orientation = isLandscape ? 'landscape' : 'portrait';
|
||||
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 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 showChannelPage = cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets;
|
||||
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">
|
||||
|
||||
{/* ── Cover ─────────────────────────────────────────── */}
|
||||
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage]}>
|
||||
{/* Colored header band */}
|
||||
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage, arPageExtra]}>
|
||||
<View style={[S.coverHeader, { backgroundColor: color }]}>
|
||||
<View style={S.coverHeaderTop}>
|
||||
<Text style={S.coverBrand}>HiHala Data</Text>
|
||||
<Text style={[S.coverBrand, arB]}>HiHala Data</Text>
|
||||
{cfg.clientLogoBase64 && (
|
||||
<View style={S.coverLogoBox}>
|
||||
<Image src={cfg.clientLogoBase64} style={S.coverClientLogo} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={S.coverTitle}>{cfg.title || T.defaultTitle}</Text>
|
||||
<Text style={[S.coverTitle, arB]}>{cfg.title || T.defaultTitle}</Text>
|
||||
</View>
|
||||
|
||||
{/* White body */}
|
||||
<View style={S.coverBody}>
|
||||
{cfg.clientName && (
|
||||
<Text style={S.coverClientName}>{T.preparedFor}: {cfg.clientName}</Text>
|
||||
<Text style={[S.coverClientName, arB]}>{T.preparedFor}: {cfg.clientName}</Text>
|
||||
)}
|
||||
{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.coverPeriodRow}>
|
||||
<View style={[S.coverPeriodDot, { backgroundColor: color }]} />
|
||||
<Text style={S.coverPeriod}>{period}</Text>
|
||||
<Text style={[S.coverPeriod, arN]}>{period}</Text>
|
||||
</View>
|
||||
<Text style={S.coverDate}>{T.generated}: {generatedAt}</Text>
|
||||
{cfg.confidentiality !== 'Public' && (
|
||||
@@ -275,34 +283,34 @@ export function ReportDocument({ data }: Props) {
|
||||
</Page>
|
||||
|
||||
{/* ── Summary + Metrics + Trend ──────────────────────── */}
|
||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
||||
<PageHeader title={cfg.title || T.defaultTitle} page={mainPg} />
|
||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||
<PageHeader title={cfg.title || T.defaultTitle} page={mainPg} isAr={isAr} arB={arB} />
|
||||
|
||||
{cfg.showExecutiveSummary && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.execSummary} color={color} />
|
||||
<Text style={S.summaryText}>{generateExecutiveSummary(data)}</Text>
|
||||
<SectionHeading title={T.execSummary} color={color} arB={arB} />
|
||||
<Text style={[S.summaryText, arN]}>{generateExecutiveSummary(data)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{cfg.showMetricsTable && (
|
||||
<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.metricsHeaderRow}>
|
||||
<Text style={S.metricsHeaderLabel}> </Text>
|
||||
<Text style={S.metricsHeaderCell}>{period}</Text>
|
||||
{prevMetrics && <Text style={S.metricsHeaderCell}>{comparisonPeriodLabel}</Text>}
|
||||
{prevMetrics && <Text style={S.metricsHeaderCell}>{T.change}</Text>}
|
||||
<Text style={[S.metricsHeaderLabel, arB]}> </Text>
|
||||
<Text style={[S.metricsHeaderCell, arB]}>{period}</Text>
|
||||
{prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
|
||||
{prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{T.change}</Text>}
|
||||
</View>
|
||||
{metricsRows.map((row, i) => (
|
||||
<View key={row.label} style={[S.metricsRow, i % 2 === 1 ? S.metricsRowAlt : {}]}>
|
||||
<Text style={S.metricsLabel}>{row.label}</Text>
|
||||
<Text style={S.metricsValue}>{row.curr}</Text>
|
||||
{prevMetrics && <Text style={S.metricsValue}>{row.prev ?? '—'}</Text>}
|
||||
<Text style={[S.metricsLabel, arB]}>{row.label}</Text>
|
||||
<Text style={[S.metricsValue, arN]}>{row.curr}</Text>
|
||||
{prevMetrics && <Text style={[S.metricsValue, arN]}>{row.prev ?? '—'}</Text>}
|
||||
{prevMetrics && row.chg !== null && (
|
||||
<Text style={[S.metricsChange, row.chg >= 0 ? S.metricsChangeUp : S.metricsChangeDown]}>
|
||||
{row.chg >= 0 ? '+' : '-'}{formatPct(Math.abs(row.chg))}
|
||||
{formatPct(row.chg)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -311,63 +319,85 @@ export function ReportDocument({ data }: Props) {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{cfg.showTrendChart && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={trendTitle} color={color} />
|
||||
{cfg.includeComparison && (
|
||||
{cfg.showTrendChart && trendCharts.map((tc, tci) => {
|
||||
const trendTitle = tc.metric === 'visitors' ? T.trendVisitors
|
||||
: tc.metric === 'tickets' ? T.trendTickets
|
||||
: T.trendRevenue;
|
||||
return (
|
||||
<View key={tci} style={S.sectionGap}>
|
||||
<SectionHeading title={trendTitle} color={color} arB={arB} />
|
||||
<View style={S.legendRow}>
|
||||
{tc.museums.length >= 2 && tc.museums.map((m, i) => (
|
||||
<View key={m.name} style={S.legendItem}>
|
||||
<View style={[S.legendDot, { backgroundColor: CHART_PALETTE[i % CHART_PALETTE.length] }]} />
|
||||
<Text style={[S.legendLabel, arN]}>{m.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
<View style={S.legendItem}>
|
||||
<View style={[S.legendDot, { backgroundColor: color }]} />
|
||||
<Text style={S.legendLabel}>{period}</Text>
|
||||
</View>
|
||||
<View style={S.legendItem}>
|
||||
<View style={[S.legendDot, { backgroundColor: '#94a3b8' }]} />
|
||||
<Text style={S.legendLabel}>{comparisonPeriodLabel}</Text>
|
||||
<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.legendDot, { backgroundColor: '#94a3b8' }]} />
|
||||
<Text style={[S.legendLabel, arN]}>{comparisonPeriodLabel}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={S.chartWrap}>
|
||||
<PdfTrendChart
|
||||
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 style={S.chartWrap}>
|
||||
<PdfTrendChart labels={trendLabels} current={trendCurrent}
|
||||
previous={trendPrevious} color={color} width={chartW} height={155} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
|
||||
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||
</Page>
|
||||
|
||||
{/* ── Museum Mini-Reports ────────────────────────────── */}
|
||||
{showMuseumPage && museumData.length > 0 && (
|
||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
||||
<PageHeader title={cfg.title || T.defaultTitle} page={museumPg} />
|
||||
<SectionHeading title={T.museumBreakdowns} color={color} />
|
||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||
<PageHeader title={cfg.title || T.defaultTitle} page={museumPg} isAr={isAr} arB={arB} />
|
||||
<SectionHeading title={T.museumBreakdowns} color={color} arB={arB} />
|
||||
|
||||
{museumData.map((row, mi) => {
|
||||
const mRows = museumMetricRows(row);
|
||||
const hasPrev = row.prev !== null;
|
||||
return (
|
||||
<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 && (
|
||||
<Text style={S.museumIntroText}>
|
||||
<Text style={[S.museumIntroText, arN]}>
|
||||
{museumIntro(row, lang, comparisonPeriodLabel)}
|
||||
</Text>
|
||||
)}
|
||||
<View style={S.miniTable}>
|
||||
<View style={S.miniHeaderRow}>
|
||||
<Text style={S.miniHeaderLabel}> </Text>
|
||||
<Text style={S.miniHeaderCell}>{period}</Text>
|
||||
{hasPrev && <Text style={S.miniHeaderCell}>{comparisonPeriodLabel}</Text>}
|
||||
{hasPrev && <Text style={S.miniHeaderChangeCell}>{T.change}</Text>}
|
||||
<Text style={[S.miniHeaderLabel, arB]}> </Text>
|
||||
<Text style={[S.miniHeaderCell, arB]}>{period}</Text>
|
||||
{hasPrev && <Text style={[S.miniHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
|
||||
{hasPrev && <Text style={[S.miniHeaderChangeCell, arB]}>{T.change}</Text>}
|
||||
</View>
|
||||
{mRows.map((mr, ri) => (
|
||||
<View key={mr.label} style={[S.miniRow, ri % 2 === 1 ? S.miniRowAlt : {}]}>
|
||||
<Text style={S.miniLabel}>{mr.label}</Text>
|
||||
<Text style={S.miniValue}>{mr.curr}</Text>
|
||||
{hasPrev && <Text style={S.miniValue}>{mr.prev ?? '—'}</Text>}
|
||||
<Text style={[S.miniLabel, arB]}>{mr.label}</Text>
|
||||
<Text style={[S.miniValue, arN]}>{mr.curr}</Text>
|
||||
{hasPrev && <Text style={[S.miniValue, arN]}>{mr.prev ?? '—'}</Text>}
|
||||
{hasPrev && mr.chg !== null && (
|
||||
<Text style={[S.miniChange, mr.chg >= 0 ? S.miniChangeUp : S.miniChangeDown]}>
|
||||
{mr.chg >= 0 ? '+' : '-'}{formatPct(Math.abs(mr.chg))}
|
||||
{formatPct(mr.chg)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -383,12 +413,12 @@ export function ReportDocument({ data }: Props) {
|
||||
|
||||
{/* ── Channel Breakdowns ─────────────────────────────── */}
|
||||
{showChannelPage && (
|
||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
||||
<PageHeader title={cfg.title || T.defaultTitle} page={channelPg} />
|
||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||
<PageHeader title={cfg.title || T.defaultTitle} page={channelPg} isAr={isAr} arB={arB} />
|
||||
|
||||
{cfg.showChannelRevenue && channelBreakdown.revenue.length > 0 && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.byChannelRevenue} color={color} />
|
||||
<SectionHeading title={T.byChannelRevenue} color={color} arB={arB} />
|
||||
<View style={S.chartWrap}>
|
||||
<PdfHBarChart items={channelBreakdown.revenue} color={color} usepalette width={chartW} />
|
||||
</View>
|
||||
@@ -396,7 +426,7 @@ export function ReportDocument({ data }: Props) {
|
||||
)}
|
||||
{cfg.showChannelVisitors && channelBreakdown.visitors.length > 0 && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.byChannelVisitors} color={color} />
|
||||
<SectionHeading title={T.byChannelVisitors} color={color} arB={arB} />
|
||||
<View style={S.chartWrap}>
|
||||
<PdfHBarChart items={channelBreakdown.visitors} color={color} usepalette width={chartW} />
|
||||
</View>
|
||||
@@ -404,7 +434,7 @@ export function ReportDocument({ data }: Props) {
|
||||
)}
|
||||
{cfg.showChannelTickets && channelBreakdown.tickets.length > 0 && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.byChannelTickets} color={color} />
|
||||
<SectionHeading title={T.byChannelTickets} color={color} arB={arB} />
|
||||
<View style={S.chartWrap}>
|
||||
<PdfHBarChart items={channelBreakdown.tickets} color={color} usepalette width={chartW} />
|
||||
</View>
|
||||
@@ -417,12 +447,12 @@ export function ReportDocument({ data }: Props) {
|
||||
|
||||
{/* ── District Breakdowns ────────────────────────────── */}
|
||||
{showDistrictPage && (
|
||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
||||
<PageHeader title={cfg.title || T.defaultTitle} page={districtPg} />
|
||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||
<PageHeader title={cfg.title || T.defaultTitle} page={districtPg} isAr={isAr} arB={arB} />
|
||||
|
||||
{cfg.showDistrictRevenue && districtBreakdown.revenue.length > 0 && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.byDistrictRevenue} color={color} />
|
||||
<SectionHeading title={T.byDistrictRevenue} color={color} arB={arB} />
|
||||
<View style={S.chartWrap}>
|
||||
<PdfHBarChart items={districtBreakdown.revenue} color={color} usepalette width={chartW} />
|
||||
</View>
|
||||
@@ -430,7 +460,7 @@ export function ReportDocument({ data }: Props) {
|
||||
)}
|
||||
{cfg.showDistrictVisitors && districtBreakdown.visitors.length > 0 && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.byDistrictVisitors} color={color} />
|
||||
<SectionHeading title={T.byDistrictVisitors} color={color} arB={arB} />
|
||||
<View style={S.chartWrap}>
|
||||
<PdfHBarChart items={districtBreakdown.visitors} color={color} usepalette width={chartW} />
|
||||
</View>
|
||||
@@ -438,7 +468,7 @@ export function ReportDocument({ data }: Props) {
|
||||
)}
|
||||
{cfg.showDistrictTickets && districtBreakdown.tickets.length > 0 && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.byDistrictTickets} color={color} />
|
||||
<SectionHeading title={T.byDistrictTickets} color={color} arB={arB} />
|
||||
<View style={S.chartWrap}>
|
||||
<PdfHBarChart items={districtBreakdown.tickets} color={color} usepalette width={chartW} />
|
||||
</View>
|
||||
@@ -451,27 +481,27 @@ export function ReportDocument({ data }: Props) {
|
||||
|
||||
{/* ── Global Performance Summary ─────────────────────── */}
|
||||
{showSummaryPage && museumData.length > 0 && (
|
||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
||||
<PageHeader title={cfg.title || T.defaultTitle} page={summaryPg} />
|
||||
<SectionHeading title={T.globalSummary} color={color} />
|
||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||
<PageHeader title={cfg.title || T.defaultTitle} page={summaryPg} isAr={isAr} arB={arB} />
|
||||
<SectionHeading title={T.globalSummary} color={color} arB={arB} />
|
||||
|
||||
<Text style={S.summarySubLabel}>
|
||||
<Text style={[S.summarySubLabel, arN]}>
|
||||
{period} — {T.comparedTo} {comparisonPeriodLabel}
|
||||
</Text>
|
||||
|
||||
<View style={S.summaryHeaderRow}>
|
||||
<Text style={S.summaryHeaderMuseum}>{T.museum}</Text>
|
||||
<Text style={[S.summaryHeaderMuseum, arB]}>{T.museum}</Text>
|
||||
{cfg.showMuseumRevenue && <>
|
||||
<Text style={S.summaryHeaderMetric}>{T.revenue}</Text>
|
||||
<Text style={S.summaryHeaderDelta}>Δ</Text>
|
||||
<Text style={[S.summaryHeaderMetric, arB]}>{T.revenue}</Text>
|
||||
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
|
||||
</>}
|
||||
{cfg.showMuseumVisitors && <>
|
||||
<Text style={S.summaryHeaderMetric}>{T.visitors}</Text>
|
||||
<Text style={S.summaryHeaderDelta}>Δ</Text>
|
||||
<Text style={[S.summaryHeaderMetric, arB]}>{T.visitors}</Text>
|
||||
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
|
||||
</>}
|
||||
{cfg.showMuseumTickets && <>
|
||||
<Text style={S.summaryHeaderMetric}>{T.tickets}</Text>
|
||||
<Text style={S.summaryHeaderDelta}>Δ</Text>
|
||||
<Text style={[S.summaryHeaderMetric, arB]}>{T.tickets}</Text>
|
||||
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
|
||||
</>}
|
||||
</View>
|
||||
|
||||
@@ -479,26 +509,26 @@ export function ReportDocument({ data }: Props) {
|
||||
const hasPrev = row.prev !== null;
|
||||
return (
|
||||
<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 && <>
|
||||
<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 ? (() => {
|
||||
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>}
|
||||
</>}
|
||||
{cfg.showMuseumVisitors && <>
|
||||
<Text style={S.summaryMetric}>{row.curr.visitors.toLocaleString()}</Text>
|
||||
<Text style={[S.summaryMetric, arN]}>{row.curr.visitors.toLocaleString()}</Text>
|
||||
{hasPrev && row.prev ? (() => {
|
||||
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>}
|
||||
</>}
|
||||
{cfg.showMuseumTickets && <>
|
||||
<Text style={S.summaryMetric}>{row.curr.tickets.toLocaleString()}</Text>
|
||||
<Text style={[S.summaryMetric, arN]}>{row.curr.tickets.toLocaleString()}</Text>
|
||||
{hasPrev && row.prev ? (() => {
|
||||
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>}
|
||||
</>}
|
||||
</View>
|
||||
@@ -506,26 +536,26 @@ export function ReportDocument({ data }: Props) {
|
||||
})}
|
||||
|
||||
<View style={S.summaryTotalRow}>
|
||||
<Text style={S.summaryMuseumTotal}>{T.total}</Text>
|
||||
<Text style={[S.summaryMuseumTotal, arB]}>{T.total}</Text>
|
||||
{cfg.showMuseumRevenue && <>
|
||||
<Text style={S.summaryMetricTotal}>{formatCurrency(metrics.revenue, cfg.includeVAT)}</Text>
|
||||
<Text style={[S.summaryMetricTotal, arB]}>{formatCurrency(metrics.revenue, cfg.includeVAT)}</Text>
|
||||
{prevMetrics ? (() => {
|
||||
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>}
|
||||
</>}
|
||||
{cfg.showMuseumVisitors && <>
|
||||
<Text style={S.summaryMetricTotal}>{metrics.visitors.toLocaleString()}</Text>
|
||||
<Text style={[S.summaryMetricTotal, arB]}>{metrics.visitors.toLocaleString()}</Text>
|
||||
{prevMetrics ? (() => {
|
||||
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>}
|
||||
</>}
|
||||
{cfg.showMuseumTickets && <>
|
||||
<Text style={S.summaryMetricTotal}>{metrics.tickets.toLocaleString()}</Text>
|
||||
<Text style={[S.summaryMetricTotal, arB]}>{metrics.tickets.toLocaleString()}</Text>
|
||||
{prevMetrics ? (() => {
|
||||
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>}
|
||||
</>}
|
||||
</View>
|
||||
|
||||
@@ -336,21 +336,28 @@ export default function ReportForm({ config: cfg, onChange, allMuseums, allChann
|
||||
title="Trend Chart"
|
||||
enabled={cfg.showTrendChart}
|
||||
onToggle={v => onChange({ showTrendChart: v })}
|
||||
badge={cfg.showTrendChart
|
||||
? cfg.trendMetric.charAt(0).toUpperCase() + cfg.trendMetric.slice(1)
|
||||
badge={cfg.showTrendChart && cfg.trendMetrics.length
|
||||
? cfg.trendMetrics.map(m => m.charAt(0).toUpperCase() + m.slice(1)).join(' · ')
|
||||
: undefined}
|
||||
>
|
||||
{/* H7: PillGroup instead of <select> for full consistency */}
|
||||
<PillGroup
|
||||
label="Trend metric"
|
||||
options={[
|
||||
{ label: 'Revenue', value: 'revenue' },
|
||||
{ label: 'Visitors', value: 'visitors' },
|
||||
{ label: 'Tickets', value: 'tickets' },
|
||||
]}
|
||||
value={cfg.trendMetric}
|
||||
onChange={v => onChange({ trendMetric: v as TrendMetric })}
|
||||
/>
|
||||
<div className="rf-metric-pills" role="group" aria-label="Trend metrics to include">
|
||||
{(['revenue', 'visitors', 'tickets'] as TrendMetric[]).map(m => {
|
||||
const on = cfg.trendMetrics.includes(m);
|
||||
return (
|
||||
<button key={m} type="button"
|
||||
className={`rf-metric-pill${on ? ' rf-metric-pill--on' : ''}`}
|
||||
aria-pressed={on}
|
||||
onClick={() => {
|
||||
const next = on
|
||||
? 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>
|
||||
|
||||
<div className="rf-divider" />
|
||||
|
||||
@@ -19,12 +19,14 @@ interface TrendChartProps {
|
||||
current: number[];
|
||||
previous: number[] | null;
|
||||
color: string;
|
||||
series?: Array<{ label: string; color: string; data: number[] }>;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function PdfTrendChart({ labels, current, previous, color, width = 470, height = 155 }: TrendChartProps) {
|
||||
const allValues = [...current, ...(previous ?? [])].filter(v => v > 0);
|
||||
export function PdfTrendChart({ labels, current, previous, color, series, width = 470, height = 155 }: TrendChartProps) {
|
||||
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;
|
||||
// padL wide enough for y-axis labels like "1.2M"
|
||||
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" />
|
||||
)}
|
||||
|
||||
{/* 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) && (
|
||||
<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 */}
|
||||
|
||||
@@ -4,6 +4,14 @@ import type { MuseumRecord, Metrics } from '../../types';
|
||||
|
||||
// ─── config ───────────────────────────────────────────────────────
|
||||
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 _end = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString().slice(0, 10);
|
||||
@@ -28,7 +36,7 @@ export interface ReportConfig {
|
||||
showPilgrimCapture: boolean;
|
||||
// Trend chart
|
||||
showTrendChart: boolean;
|
||||
trendMetric: TrendMetric;
|
||||
trendMetrics: TrendMetric[];
|
||||
// Museum mini-reports
|
||||
showMuseumRevenue: boolean;
|
||||
showMuseumVisitors: boolean;
|
||||
@@ -67,7 +75,7 @@ export const DEFAULT_CONFIG: ReportConfig = {
|
||||
showMetricsTable: true,
|
||||
showPilgrimCapture: true,
|
||||
showTrendChart: true,
|
||||
trendMetric: 'revenue',
|
||||
trendMetrics: ['revenue'],
|
||||
showMuseumRevenue: true,
|
||||
showMuseumVisitors: true,
|
||||
showMuseumTickets: false,
|
||||
@@ -98,14 +106,20 @@ export interface MuseumDataRow {
|
||||
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 {
|
||||
config: ReportConfig;
|
||||
metrics: Metrics;
|
||||
prevMetrics: Metrics | null;
|
||||
comparisonPeriodLabel: string;
|
||||
trendLabels: string[];
|
||||
trendCurrent: number[];
|
||||
trendPrevious: number[] | null;
|
||||
trendCharts: TrendChart[];
|
||||
museumData: MuseumDataRow[];
|
||||
museumBreakdown: DimensionBreakdown;
|
||||
channelBreakdown: DimensionBreakdown;
|
||||
@@ -147,21 +161,27 @@ function getMetricVal(r: MuseumRecord, metric: TrendMetric, includeVAT: boolean)
|
||||
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 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 = 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] = [];
|
||||
acc[key].push(r);
|
||||
});
|
||||
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 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 };
|
||||
}
|
||||
@@ -186,14 +206,25 @@ export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): R
|
||||
? formatPeriodLabel(cfg.comparisonStartDate, cfg.comparisonEndDate, cfg.language)
|
||||
: '';
|
||||
|
||||
const currTrend = buildTrend(currRows, cfg.startDate, cfg);
|
||||
const prevTrend = cfg.includeComparison ? buildTrend(prevRows, cfg.comparisonStartDate, cfg) : null;
|
||||
const maxLen = Math.max(currTrend.labels.length, prevTrend?.values.length ?? 0);
|
||||
const trendLabels = Array.from({ length: maxLen }, (_, i) => `W${i + 1}`);
|
||||
const trendCurrent = Array.from({ length: maxLen }, (_, i) => currTrend.values[i] ?? 0);
|
||||
const trendPrevious = prevTrend
|
||||
? Array.from({ length: maxLen }, (_, i) => prevTrend.values[i] ?? 0)
|
||||
: null;
|
||||
const gran = inferGranularity(cfg.startDate, cfg.endDate);
|
||||
const museumNames = Object.keys(groupByMuseum(currRows, cfg.includeVAT))
|
||||
.filter(name => currRows.some(r => r.museum_name === name));
|
||||
|
||||
const trendCharts: TrendChart[] = cfg.trendMetrics.map(metric => {
|
||||
const currT = buildTrend(currRows, cfg.startDate, metric, cfg.includeVAT, gran);
|
||||
const prevT = cfg.includeComparison
|
||||
? buildTrend(prevRows, cfg.comparisonStartDate, metric, cfg.includeVAT, gran)
|
||||
: 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 prevMuseumGroups = cfg.includeComparison ? groupByMuseum(prevRows, cfg.includeVAT) : {};
|
||||
@@ -223,9 +254,7 @@ export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): R
|
||||
metrics,
|
||||
prevMetrics,
|
||||
comparisonPeriodLabel,
|
||||
trendLabels,
|
||||
trendCurrent,
|
||||
trendPrevious,
|
||||
trendCharts,
|
||||
museumData,
|
||||
museumBreakdown,
|
||||
channelBreakdown,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
// ─── 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[];
|
||||
onChange: (vals: string[]) => void;
|
||||
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
|
||||
labelFn?: (opt: string) => string;
|
||||
}) {
|
||||
const display = labelFn ?? ((opt: string) => opt);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -15,7 +17,7 @@ export default function AltMultiSelect({ value, options, onChange, allLabel, cou
|
||||
}, [open]);
|
||||
|
||||
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v !== opt) : [...value, opt]);
|
||||
const label = value.length === 0 ? allLabel : value.length === 1 ? value[0] : countLabel(value.length);
|
||||
const label = value.length === 0 ? allLabel : value.length === 1 ? display(value[0]) : countLabel(value.length);
|
||||
|
||||
return (
|
||||
<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' : ''}`}>
|
||||
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
|
||||
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
|
||||
<span className="altms-opt-label">{opt}</span>
|
||||
<span className="altms-opt-label">{display(opt)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,9 @@ ChartJS.register(
|
||||
Annotation
|
||||
);
|
||||
|
||||
// Used for the "Total" line in multi-museum trend charts — always distinct from chartPalette.
|
||||
export const TOTAL_COLOR = '#1e293b';
|
||||
|
||||
export const chartColors = {
|
||||
primary: '#2563eb',
|
||||
secondary: '#7c3aed',
|
||||
@@ -113,7 +116,9 @@ export const createBaseOptions = (showDataLabels: boolean): any => {
|
||||
titleFont: { size: 12 },
|
||||
bodyFont: { size: 11 },
|
||||
rtl: false,
|
||||
textDirection: 'ltr'
|
||||
textDirection: 'ltr',
|
||||
usePointStyle: true,
|
||||
boxPadding: 6,
|
||||
},
|
||||
datalabels: createDataLabelConfig(showDataLabels, {
|
||||
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 = {
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
|
||||
@@ -75,6 +75,21 @@ export let umrahData: UmrahData = {
|
||||
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> {
|
||||
try {
|
||||
const tables = await discoverTableIds();
|
||||
|
||||
Reference in New Issue
Block a user