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