Compare commits
1 Commits
main
...
dd512444fb
| Author | SHA1 | Date | |
|---|---|---|---|
| dd512444fb |
+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>
|
||||
|
||||
@@ -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);
|
||||
@@ -167,7 +169,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
||||
const museumDatasets = museumList.map((museum, idx) => {
|
||||
const mg = group(currData.filter(r => r.museum_name === museum), currStart);
|
||||
return {
|
||||
label: museum,
|
||||
label: tr(museum),
|
||||
data: labels.map((_,i) => mg[i+1]||0),
|
||||
borderColor: chartPalette[idx % chartPalette.length],
|
||||
backgroundColor: 'transparent',
|
||||
@@ -200,13 +202,13 @@ 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(showLabels), [showLabels]);
|
||||
const { chartOpts } = useMemo(() => {
|
||||
@@ -326,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>
|
||||
|
||||
@@ -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);
|
||||
@@ -128,7 +130,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
const museumDatasets = museumList.map((museum, idx) => {
|
||||
const mg = group(filteredData.filter(r => r.museum_name === museum), start);
|
||||
return {
|
||||
label: museum,
|
||||
label: tr(museum),
|
||||
data: labels.map((_,i) => mg[i+1]||0),
|
||||
borderColor: chartPalette[idx % chartPalette.length],
|
||||
backgroundColor: 'transparent',
|
||||
@@ -159,8 +161,8 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
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);
|
||||
@@ -296,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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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