Enable TypeScript strict mode and fix all type errors
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s

- Enable strict: true in tsconfig.json (was false)
- Add proper interfaces for all component props (Dashboard, Comparison, Slides)
- Add SlideConfig, ChartTypeOption, MetricOption types
- Type all function parameters, callbacks, and state variables
- Fix dynamic property access with proper keyof assertions
- 233 type errors resolved across 5 files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-25 18:17:09 +03:00
parent 30ea4b6ecb
commit c8567da75f
7 changed files with 254 additions and 163 deletions

View File

@@ -16,9 +16,31 @@ import {
getMuseumsForDistrict, getMuseumsForDistrict,
getLatestYear getLatestYear
} from '../services/dataService'; } from '../services/dataService';
import type { MuseumRecord, ComparisonProps, DateRangeFilters } from '../types';
interface PresetDateRange {
start: string;
end: string;
}
interface PresetDates {
[key: string]: PresetDateRange;
}
interface MetricCardProps {
title: string;
prev: number | null;
curr: number | null;
change: number | null;
isCurrency?: boolean;
isPercent?: boolean;
pendingMessage?: string;
prevYear: string;
currYear: string;
}
// Generate preset dates for a given year // Generate preset dates for a given year
const generatePresetDates = (year) => ({ const generatePresetDates = (year: number): PresetDates => ({
'jan': { start: `${year}-01-01`, end: `${year}-01-31` }, 'jan': { start: `${year}-01-01`, end: `${year}-01-31` },
'feb': { start: `${year}-02-01`, end: `${year}-02-28` }, 'feb': { start: `${year}-02-01`, end: `${year}-02-28` },
'mar': { start: `${year}-03-01`, end: `${year}-03-31` }, 'mar': { start: `${year}-03-01`, end: `${year}-03-31` },
@@ -40,15 +62,15 @@ const generatePresetDates = (year) => ({
'full': { start: `${year}-01-01`, end: `${year}-12-31` } 'full': { start: `${year}-01-01`, end: `${year}-12-31` }
}); });
function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }) { function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: ComparisonProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
// Get available years from data // Get available years from data
const latestYear = useMemo(() => getLatestYear(data), [data]); const latestYear = useMemo(() => parseInt(getLatestYear(data)), [data]);
const availableYears = useMemo((): number[] => { const availableYears = useMemo((): number[] => {
const yearsSet = new Set<number>(); const yearsSet = new Set<number>();
data.forEach(r => { data.forEach((r: MuseumRecord) => {
const d = r.date || (r as any).Date; const d = r.date || (r as any).Date;
if (d) yearsSet.add(new Date(d).getFullYear()); if (d) yearsSet.add(new Date(d).getFullYear());
}); });
@@ -57,7 +79,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
}, [data]); }, [data]);
// Initialize state from URL or defaults // Initialize state from URL or defaults
const [selectedYear, setSelectedYearState] = useState(() => { const [selectedYear, setSelectedYearState] = useState<number>(() => {
const urlYear = searchParams.get('year'); const urlYear = searchParams.get('year');
return urlYear ? parseInt(urlYear) : latestYear; return urlYear ? parseInt(urlYear) : latestYear;
}); });
@@ -66,7 +88,8 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const [preset, setPresetState] = useState(() => searchParams.get('preset') || 'jan'); const [preset, setPresetState] = useState(() => searchParams.get('preset') || 'jan');
const [startDate, setStartDateState] = useState(() => { const [startDate, setStartDateState] = useState(() => {
const urlPreset = searchParams.get('preset'); const urlPreset = searchParams.get('preset');
const year = searchParams.get('year') ? parseInt(searchParams.get('year')) : latestYear; const yearParam = searchParams.get('year');
const year = yearParam ? parseInt(yearParam) : latestYear;
const dates = generatePresetDates(year); const dates = generatePresetDates(year);
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) { if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
return dates[urlPreset].start; return dates[urlPreset].start;
@@ -75,7 +98,8 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
}); });
const [endDate, setEndDateState] = useState(() => { const [endDate, setEndDateState] = useState(() => {
const urlPreset = searchParams.get('preset'); const urlPreset = searchParams.get('preset');
const year = searchParams.get('year') ? parseInt(searchParams.get('year')) : latestYear; const yearParam = searchParams.get('year');
const year = yearParam ? parseInt(yearParam) : latestYear;
const dates = generatePresetDates(year); const dates = generatePresetDates(year);
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) { if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
return dates[urlPreset].end; return dates[urlPreset].end;
@@ -93,7 +117,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const [activeCard, setActiveCard] = useState(0); const [activeCard, setActiveCard] = useState(0);
// Update URL with current state // Update URL with current state
const updateUrl = useCallback((newPreset, newFrom, newTo, newFilters, newYear) => { const updateUrl = useCallback((newPreset: string, newFrom: string | null, newTo: string | null, newFilters: DateRangeFilters | null, newYear: number) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (newPreset && newPreset !== 'jan') params.set('preset', newPreset); if (newPreset && newPreset !== 'jan') params.set('preset', newPreset);
if (newYear && newYear !== latestYear) params.set('year', newYear.toString()); if (newYear && newYear !== latestYear) params.set('year', newYear.toString());
@@ -106,7 +130,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
setSearchParams(params, { replace: true }); setSearchParams(params, { replace: true });
}, [setSearchParams, latestYear]); }, [setSearchParams, latestYear]);
const setSelectedYear = (year) => { const setSelectedYear = (year: number) => {
setSelectedYearState(year); setSelectedYearState(year);
const newDates = generatePresetDates(year); const newDates = generatePresetDates(year);
if (preset !== 'custom' && newDates[preset]) { if (preset !== 'custom' && newDates[preset]) {
@@ -116,7 +140,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
updateUrl(preset, null, null, filters, year); updateUrl(preset, null, null, filters, year);
}; };
const setPreset = (value) => { const setPreset = (value: string) => {
setPresetState(value); setPresetState(value);
if (value !== 'custom' && presetDates[value]) { if (value !== 'custom' && presetDates[value]) {
setStartDateState(presetDates[value].start); setStartDateState(presetDates[value].start);
@@ -125,19 +149,19 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
} }
}; };
const setStartDate = (value) => { const setStartDate = (value: string) => {
setStartDateState(value); setStartDateState(value);
setPresetState('custom'); setPresetState('custom');
updateUrl('custom', value, endDate, filters, selectedYear); updateUrl('custom', value, endDate, filters, selectedYear);
}; };
const setEndDate = (value) => { const setEndDate = (value: string) => {
setEndDateState(value); setEndDateState(value);
setPresetState('custom'); setPresetState('custom');
updateUrl('custom', startDate, value, filters, selectedYear); updateUrl('custom', startDate, value, filters, selectedYear);
}; };
const setFilters = (newFilters) => { const setFilters = (newFilters: DateRangeFilters | ((prev: DateRangeFilters) => DateRangeFilters)) => {
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters; const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
setFiltersState(updated); setFiltersState(updated);
updateUrl(preset, startDate, endDate, updated, selectedYear); updateUrl(preset, startDate, endDate, updated, selectedYear);
@@ -149,13 +173,13 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
]; ];
// Touch swipe handlers // Touch swipe handlers
const touchStartChart = useRef(null); const touchStartChart = useRef<number | null>(null);
const touchStartCard = useRef(null); const touchStartCard = useRef<number | null>(null);
const handleChartTouchStart = (e) => { const handleChartTouchStart = (e: React.TouchEvent) => {
touchStartChart.current = e.touches[0].clientX; touchStartChart.current = e.touches[0].clientX;
}; };
const handleChartTouchEnd = (e) => { const handleChartTouchEnd = (e: React.TouchEvent) => {
if (!touchStartChart.current) return; if (!touchStartChart.current) return;
const diff = touchStartChart.current - e.changedTouches[0].clientX; const diff = touchStartChart.current - e.changedTouches[0].clientX;
if (Math.abs(diff) > 50) { if (Math.abs(diff) > 50) {
@@ -183,15 +207,15 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
{ value: 'avgRevenue', label: t('metrics.avgRevenue'), field: null, format: 'currency' } { value: 'avgRevenue', label: t('metrics.avgRevenue'), field: null, format: 'currency' }
]; ];
const getMetricValue = useCallback((rows, metric) => { const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
if (metric === 'avgRevenue') { if (metric === 'avgRevenue') {
const revenue = rows.reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0); const revenue = rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[revenueField] || r.revenue_incl_tax || 0)), 0);
const visitors = rows.reduce((s, r) => s + parseInt(r.visits || 0), 0); const visitors = rows.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
return visitors > 0 ? revenue / visitors : 0; return visitors > 0 ? revenue / visitors : 0;
} }
const fieldMap = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' }; const fieldMap: Record<string, string> = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' };
const field = fieldMap[metric]; const field = fieldMap[metric];
return rows.reduce((s, r) => s + parseFloat(r[field] || r.revenue_incl_tax || 0), 0); return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[field] || r.revenue_incl_tax || 0)), 0);
}, [revenueField]); }, [revenueField]);
// Dynamic lists from data // Dynamic lists from data
@@ -203,8 +227,8 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const ranges = useMemo(() => ({ const ranges = useMemo(() => ({
curr: { start: startDate, end: endDate }, curr: { start: startDate, end: endDate },
prev: { prev: {
start: startDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1), start: startDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1)),
end: endDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1) end: endDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1))
} }
}), [startDate, endDate]); }), [startDate, endDate]);
@@ -224,11 +248,11 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const hasData = prevData.length > 0 || currData.length > 0; const hasData = prevData.length > 0 || currData.length > 0;
const resetFilters = () => setFilters({ district: 'all', museum: 'all' }); const resetFilters = () => setFilters({ district: 'all', museum: 'all' });
const calcChange = (prev, curr) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100); const calcChange = (prev: number, curr: number) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
// Get quarter from date range (returns null if not a clean quarter) // Get quarter from date range (returns null if not a clean quarter)
const getQuarterFromRange = (start, end) => { const getQuarterFromRange = (start: string, end: string) => {
const quarterRanges = { const quarterRanges: Record<number, { start: string; end: string }> = {
1: { start: '-01-01', end: '-03-31' }, 1: { start: '-01-01', end: '-03-31' },
2: { start: '-04-01', end: '-06-30' }, 2: { start: '-04-01', end: '-06-30' },
3: { start: '-07-01', end: '-09-30' }, 3: { start: '-07-01', end: '-09-30' },
@@ -331,10 +355,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
return cards; return cards;
}, [prevMetrics, currMetrics, pilgrimCounts, captureRates, t]); }, [prevMetrics, currMetrics, pilgrimCounts, captureRates, t]);
const handleCardTouchStart = (e) => { const handleCardTouchStart = (e: React.TouchEvent) => {
touchStartCard.current = e.touches[0].clientX; touchStartCard.current = e.touches[0].clientX;
}; };
const handleCardTouchEnd = (e) => { const handleCardTouchEnd = (e: React.TouchEvent) => {
if (!touchStartCard.current) return; if (!touchStartCard.current) return;
const diff = touchStartCard.current - e.changedTouches[0].clientX; const diff = touchStartCard.current - e.changedTouches[0].clientX;
if (Math.abs(diff) > 50) { if (Math.abs(diff) > 50) {
@@ -347,7 +371,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
touchStartCard.current = null; touchStartCard.current = null;
}; };
const formatDate = (dateStr) => { const formatDate = (dateStr: string) => {
if (!dateStr) return ''; if (!dateStr) return '';
const [year, month, day] = dateStr.split('-').map(Number); const [year, month, day] = dateStr.split('-').map(Number);
const d = new Date(year, month - 1, day); const d = new Date(year, month - 1, day);
@@ -355,7 +379,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
}; };
// Generate period label - shows year if same year, or "MMM YYMMM YY" if spans years // Generate period label - shows year if same year, or "MMM YYMMM YY" if spans years
const getPeriodLabel = useCallback((startDate, endDate) => { const getPeriodLabel = useCallback((startDate: string, endDate: string) => {
if (!startDate || !endDate) return ''; if (!startDate || !endDate) return '';
const startYear = startDate.substring(0, 4); const startYear = startDate.substring(0, 4);
const endYear = endDate.substring(0, 4); const endYear = endDate.substring(0, 4);
@@ -374,11 +398,11 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
// Time series chart (daily or weekly) // Time series chart (daily or weekly)
const timeSeriesChart = useMemo(() => { const timeSeriesChart = useMemo(() => {
const groupByPeriod = (periodData, periodStart, metric, granularity) => { const groupByPeriod = (periodData: MuseumRecord[], periodStart: string, metric: string, granularity: string) => {
const start = new Date(periodStart); const start = new Date(periodStart);
const groupedRows = {}; const groupedRows: Record<number, MuseumRecord[]> = {};
periodData.forEach(row => { periodData.forEach((row: MuseumRecord) => {
if (!row.date) return; if (!row.date) return;
const rowDate = new Date(row.date); const rowDate = new Date(row.date);
const daysDiff = Math.floor((rowDate.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); const daysDiff = Math.floor((rowDate.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
@@ -398,9 +422,9 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
groupedRows[key].push(row); groupedRows[key].push(row);
}); });
const result = {}; const result: Record<number, number> = {};
Object.keys(groupedRows).forEach(key => { Object.keys(groupedRows).forEach(key => {
result[key] = getMetricValue(groupedRows[key], metric); result[Number(key)] = getMetricValue(groupedRows[Number(key)], metric);
}); });
return result; return result;
}; };
@@ -454,7 +478,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const museumChart = useMemo(() => { const museumChart = useMemo(() => {
const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end); const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end); const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
const allMuseums = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[]; const allMuseums = [...new Set(data.map((r: MuseumRecord) => r.museum_name))].filter(Boolean) as string[];
const prevByMuseum: Record<string, number> = {}; const prevByMuseum: Record<string, number> = {};
const currByMuseum: Record<string, number> = {}; const currByMuseum: Record<string, number> = {};
allMuseums.forEach(m => { allMuseums.forEach(m => {
@@ -802,12 +826,12 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
); );
} }
function MetricCard({ title, prev, curr, change, isCurrency, isPercent, pendingMessage, prevYear, currYear }) { function MetricCard({ title, prev, curr, change, isCurrency, isPercent, pendingMessage, prevYear, currYear }: MetricCardProps) {
const hasPending = prev === null || curr === null; const hasPending = prev === null || curr === null;
const isPositive = change >= 0; const isPositive = (change ?? 0) >= 0;
const changeText = (hasPending && pendingMessage) ? null : (change === Infinity || change === null ? '—' : `${isPositive ? '+' : ''}${change.toFixed(1)}%`); const changeText = (hasPending && pendingMessage) ? null : (change === Infinity || change === null ? '—' : `${isPositive ? '+' : ''}${change.toFixed(1)}%`);
const formatValue = (val) => { const formatValue = (val: number | null | undefined) => {
if (val === null || val === undefined) return '—'; if (val === null || val === undefined) return '—';
if (isPercent) return val.toFixed(2) + '%'; if (isPercent) return val.toFixed(2) + '%';
if (isCurrency) return formatCompactCurrency(val); if (isCurrency) return formatCompactCurrency(val);

View File

@@ -20,17 +20,18 @@ import {
getDistrictMuseumMap, getDistrictMuseumMap,
getMuseumsForDistrict getMuseumsForDistrict
} from '../services/dataService'; } from '../services/dataService';
import type { DashboardProps, Filters, MuseumRecord } from '../types';
const defaultFilters = { const defaultFilters: Filters = {
year: 'all', year: 'all',
district: 'all', district: 'all',
museum: 'all', museum: 'all',
quarter: 'all' quarter: 'all'
}; };
const filterKeys = ['year', 'district', 'museum', 'quarter']; const filterKeys: (keyof Filters)[] = ['year', 'district', 'museum', 'quarter'];
function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }) { function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [pilgrimLoaded, setPilgrimLoaded] = useState(false); const [pilgrimLoaded, setPilgrimLoaded] = useState(false);
@@ -51,7 +52,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
}); });
// Update both state and URL // Update both state and URL
const setFilters = (newFilters) => { const setFilters = (newFilters: Filters | ((prev: Filters) => Filters)) => {
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters; const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
setFiltersState(updated); setFiltersState(updated);
@@ -97,7 +98,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
const yoyChange = useMemo(() => { const yoyChange = useMemo(() => {
if (filters.year === 'all') return null; if (filters.year === 'all') return null;
const prevYear = String(parseInt(filters.year) - 1); const prevYear = String(parseInt(filters.year) - 1);
const prevData = data.filter(row => row.year === prevYear); const prevData = data.filter((row: MuseumRecord) => row.year === prevYear);
if (prevData.length === 0) return null; if (prevData.length === 0) return null;
const prevMetrics = calculateMetrics(prevData, includeVAT); const prevMetrics = calculateMetrics(prevData, includeVAT);
return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null; return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null;
@@ -106,7 +107,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
// Revenue trend data (weekly or daily) // Revenue trend data (weekly or daily)
const trendData = useMemo(() => { const trendData = useMemo(() => {
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const formatLabel = (dateStr) => { const formatLabel = (dateStr: string) => {
if (!dateStr) return ''; if (!dateStr) return '';
const [year, month, day] = dateStr.split('-').map(Number); const [year, month, day] = dateStr.split('-').map(Number);
const d = new Date(year, month - 1, day); const d = new Date(year, month - 1, day);
@@ -166,7 +167,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
filteredData.forEach(row => { filteredData.forEach(row => {
const date = row.date; const date = row.date;
if (!dailyData[date]) dailyData[date] = 0; if (!dailyData[date]) dailyData[date] = 0;
dailyData[date] += Number(row[revenueField] || row.revenue_incl_tax || 0); dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || row.revenue_incl_tax || 0);
}); });
const days = Object.keys(dailyData).sort(); const days = Object.keys(dailyData).sort();
const revenueValues = days.map(d => dailyData[d]); const revenueValues = days.map(d => dailyData[d]);
@@ -228,21 +229,21 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
// Quarterly YoY // Quarterly YoY
const quarterlyYoYData = useMemo(() => { const quarterlyYoYData = useMemo(() => {
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const d2024 = data.filter(row => row.year === '2024'); const d2024 = data.filter((row: MuseumRecord) => row.year === '2024');
const d2025 = data.filter(row => row.year === '2025'); const d2025 = data.filter((row: MuseumRecord) => row.year === '2025');
const quarters = ['Q1', 'Q2', 'Q3', 'Q4']; const quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
return { return {
labels: quarters, labels: quarters,
datasets: [ datasets: [
{ {
label: '2024', label: '2024',
data: quarters.map(q => d2024.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0)), data: quarters.map(q => d2024.filter((r: MuseumRecord) => r.quarter === q.slice(1)).reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || r.revenue_incl_tax || 0)), 0)),
backgroundColor: chartColors.muted, backgroundColor: chartColors.muted,
borderRadius: 4 borderRadius: 4
}, },
{ {
label: '2025', label: '2025',
data: quarters.map(q => d2025.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0)), data: quarters.map(q => d2025.filter((r: MuseumRecord) => r.quarter === q.slice(1)).reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || r.revenue_incl_tax || 0)), 0)),
backgroundColor: chartColors.primary, backgroundColor: chartColors.primary,
borderRadius: 4 borderRadius: 4
} }
@@ -252,17 +253,17 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
// Capture rate // Capture rate
const captureRateData = useMemo(() => { const captureRateData = useMemo(() => {
const labels = []; const labels: string[] = [];
const rates = []; const rates: number[] = [];
const pilgrimCounts = []; const pilgrimCounts: number[] = [];
[2024, 2025].forEach(year => { [2024, 2025].forEach(year => {
[1, 2, 3, 4].forEach(q => { [1, 2, 3, 4].forEach(q => {
const pilgrims = umrahData[year]?.[q]; const pilgrims = umrahData[year]?.[q];
if (!pilgrims) return; if (!pilgrims) return;
let qData = data.filter(r => r.year === String(year) && r.quarter === String(q)); let qData = data.filter((r: MuseumRecord) => r.year === String(year) && r.quarter === String(q));
if (filters.district !== 'all') qData = qData.filter(r => r.district === filters.district); if (filters.district !== 'all') qData = qData.filter((r: MuseumRecord) => r.district === filters.district);
if (filters.museum !== 'all') qData = qData.filter(r => r.museum_name === filters.museum); if (filters.museum !== 'all') qData = qData.filter((r: MuseumRecord) => r.museum_name === filters.museum);
const visitors = qData.reduce((s, r) => s + parseInt(r.visits || 0), 0); const visitors = qData.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
labels.push(`Q${q} ${year}`); labels.push(`Q${q} ${year}`);
rates.push((visitors / pilgrims * 100)); rates.push((visitors / pilgrims * 100));
pilgrimCounts.push(pilgrims); pilgrimCounts.push(pilgrims);
@@ -286,7 +287,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
yAxisID: 'y', yAxisID: 'y',
datalabels: { datalabels: {
display: showDataLabels, display: showDataLabels,
formatter: (value) => value.toFixed(2) + '%', formatter: (value: number) => value.toFixed(2) + '%',
color: '#1e293b', color: '#1e293b',
backgroundColor: 'rgba(255, 255, 255, 0.9)', backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 3, borderRadius: 3,
@@ -312,7 +313,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
order: 1, order: 1,
datalabels: { datalabels: {
display: showDataLabels, display: showDataLabels,
formatter: (value) => (value / 1000000).toFixed(2) + 'M', formatter: (value: number) => (value / 1000000).toFixed(2) + 'M',
color: '#1e293b', color: '#1e293b',
backgroundColor: 'rgba(255, 255, 255, 0.9)', backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 3, borderRadius: 3,
@@ -329,23 +330,23 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
// Quarterly table // Quarterly table
const quarterlyTable = useMemo(() => { const quarterlyTable = useMemo(() => {
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const d2024 = data.filter(row => row.year === '2024'); const d2024 = data.filter((row: MuseumRecord) => row.year === '2024');
const d2025 = data.filter(row => row.year === '2025'); const d2025 = data.filter((row: MuseumRecord) => row.year === '2025');
return [1, 2, 3, 4].map(q => { return [1, 2, 3, 4].map(q => {
let q2024 = d2024.filter(r => r.quarter === String(q)); let q2024 = d2024.filter((r: MuseumRecord) => r.quarter === String(q));
let q2025 = d2025.filter(r => r.quarter === String(q)); let q2025 = d2025.filter((r: MuseumRecord) => r.quarter === String(q));
if (filters.district !== 'all') { if (filters.district !== 'all') {
q2024 = q2024.filter(r => r.district === filters.district); q2024 = q2024.filter((r: MuseumRecord) => r.district === filters.district);
q2025 = q2025.filter(r => r.district === filters.district); q2025 = q2025.filter((r: MuseumRecord) => r.district === filters.district);
} }
if (filters.museum !== 'all') { if (filters.museum !== 'all') {
q2024 = q2024.filter(r => r.museum_name === filters.museum); q2024 = q2024.filter((r: MuseumRecord) => r.museum_name === filters.museum);
q2025 = q2025.filter(r => r.museum_name === filters.museum); q2025 = q2025.filter((r: MuseumRecord) => r.museum_name === filters.museum);
} }
const rev24 = q2024.reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0); const rev24 = q2024.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || r.revenue_incl_tax || 0)), 0);
const rev25 = q2025.reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0); const rev25 = q2025.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || r.revenue_incl_tax || 0)), 0);
const vis24 = q2024.reduce((s, r) => s + parseInt(r.visits || 0), 0); const vis24 = q2024.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
const vis25 = q2025.reduce((s, r) => s + parseInt(r.visits || 0), 0); const vis25 = q2025.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
const revChg = rev24 > 0 ? ((rev25 - rev24) / rev24 * 100) : 0; const revChg = rev24 > 0 ? ((rev25 - rev24) / rev24 * 100) : 0;
const visChg = vis24 > 0 ? ((vis25 - vis24) / vis24 * 100) : 0; const visChg = vis24 > 0 ? ((vis25 - vis24) / vis24 * 100) : 0;
const cap24 = umrahData[2024][q] ? (vis24 / umrahData[2024][q] * 100) : null; const cap24 = umrahData[2024][q] ? (vis24 / umrahData[2024][q] * 100) : null;
@@ -545,7 +546,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
tooltip: { tooltip: {
...baseOptions.plugins.tooltip, ...baseOptions.plugins.tooltip,
callbacks: { callbacks: {
label: (ctx) => { label: (ctx: { dataset: { label?: string }; parsed: { y: number } }) => {
if (ctx.dataset.label === 'Capture Rate (%)') { if (ctx.dataset.label === 'Capture Rate (%)') {
return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`; return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`;
} }
@@ -560,7 +561,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
type: 'linear', type: 'linear',
position: 'left', position: 'left',
grid: { color: chartColors.grid }, grid: { color: chartColors.grid },
ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' }, ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v: number | string) => Number(v).toFixed(1) + '%' },
border: { display: false }, border: { display: false },
title: { display: true, text: 'Capture Rate (%)', font: { size: 12 }, color: chartColors.secondary } title: { display: true, text: 'Capture Rate (%)', font: { size: 12 }, color: chartColors.secondary }
}, },
@@ -568,7 +569,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
type: 'linear', type: 'linear',
position: 'right', position: 'right',
grid: { drawOnChartArea: false }, grid: { drawOnChartArea: false },
ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' }, ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v: number | string) => (Number(v) / 1000000).toFixed(0) + 'M' },
border: { display: false }, border: { display: false },
title: { display: true, text: 'Pilgrims', font: { size: 12 }, color: chartColors.tertiary } title: { display: true, text: 'Pilgrims', font: { size: 12 }, color: chartColors.tertiary }
} }
@@ -651,7 +652,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
tooltip: { tooltip: {
...baseOptions.plugins.tooltip, ...baseOptions.plugins.tooltip,
callbacks: { callbacks: {
label: (ctx) => { label: (ctx: { dataset: { label?: string }; parsed: { y: number } }) => {
if (ctx.dataset.label === 'Capture Rate (%)') { if (ctx.dataset.label === 'Capture Rate (%)') {
return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`; return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`;
} }
@@ -666,14 +667,14 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
type: 'linear', type: 'linear',
position: 'left', position: 'left',
grid: { color: chartColors.grid }, grid: { color: chartColors.grid },
ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' }, ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v: number | string) => Number(v).toFixed(1) + '%' },
border: { display: false } border: { display: false }
}, },
y1: { y1: {
type: 'linear', type: 'linear',
position: 'right', position: 'right',
grid: { drawOnChartArea: false }, grid: { drawOnChartArea: false },
ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' }, ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v: number | string) => (Number(v) / 1000000).toFixed(0) + 'M' },
border: { display: false } border: { display: false }
} }
} }

View File

@@ -12,31 +12,69 @@ import {
getMuseumsForDistrict getMuseumsForDistrict
} from '../services/dataService'; } from '../services/dataService';
import JSZip from 'jszip'; import JSZip from 'jszip';
import type {
MuseumRecord,
DistrictMuseumMap,
SlideConfig,
ChartTypeOption,
MetricOption,
MetricFieldInfo,
SlidesProps
} from '../types';
function Slides({ data }) { interface SlideEditorProps {
slide: SlideConfig;
onUpdate: (updates: Partial<SlideConfig>) => void;
districts: string[];
districtMuseumMap: DistrictMuseumMap;
data: MuseumRecord[];
chartTypes: ChartTypeOption[];
metrics: MetricOption[];
}
interface SlidePreviewProps {
slide: SlideConfig;
data: MuseumRecord[];
districts: string[];
districtMuseumMap: DistrictMuseumMap;
metrics: MetricOption[];
}
interface PreviewModeProps {
slides: SlideConfig[];
data: MuseumRecord[];
districts: string[];
districtMuseumMap: DistrictMuseumMap;
currentSlide: number;
setCurrentSlide: React.Dispatch<React.SetStateAction<number>>;
onExit: () => void;
metrics: MetricOption[];
}
function Slides({ data }: SlidesProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const CHART_TYPES = useMemo(() => [ const CHART_TYPES: ChartTypeOption[] = useMemo(() => [
{ id: 'trend', label: t('slides.revenueTrend'), icon: '📈' }, { id: 'trend', label: t('slides.revenueTrend'), icon: '📈' },
{ id: 'museum-bar', label: t('slides.byMuseum'), icon: '📊' }, { id: 'museum-bar', label: t('slides.byMuseum'), icon: '📊' },
{ id: 'kpi-cards', label: t('slides.kpiSummary'), icon: '🎯' }, { id: 'kpi-cards', label: t('slides.kpiSummary'), icon: '🎯' },
{ id: 'comparison', label: t('slides.yoyComparison'), icon: '⚖️' } { id: 'comparison', label: t('slides.yoyComparison'), icon: '⚖️' }
], [t]); ], [t]);
const METRICS = useMemo(() => [ const METRICS: MetricOption[] = useMemo(() => [
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax' }, { id: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax' },
{ id: 'visitors', label: t('metrics.visitors'), field: 'visits' }, { id: 'visitors', label: t('metrics.visitors'), field: 'visits' },
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' } { id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
], [t]); ], [t]);
const [slides, setSlides] = useState([]); const [slides, setSlides] = useState<SlideConfig[]>([]);
const [editingSlide, setEditingSlide] = useState(null); const [editingSlide, setEditingSlide] = useState<number | null>(null);
const [previewMode, setPreviewMode] = useState(false); const [previewMode, setPreviewMode] = useState(false);
const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0); const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0);
const districts = useMemo(() => getUniqueDistricts(data), [data]); const districts = useMemo(() => getUniqueDistricts(data), [data]);
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]); const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
const defaultSlideConfig = { const defaultSlideConfig: Omit<SlideConfig, 'id'> = {
title: 'Slide Title', title: 'Slide Title',
chartType: 'trend', chartType: 'trend',
metric: 'revenue', metric: 'revenue',
@@ -48,7 +86,7 @@ function Slides({ data }) {
}; };
const addSlide = () => { const addSlide = () => {
const newSlide = { const newSlide: SlideConfig = {
id: Date.now(), id: Date.now(),
...defaultSlideConfig, ...defaultSlideConfig,
title: `Slide ${slides.length + 1}` title: `Slide ${slides.length + 1}`
@@ -57,16 +95,16 @@ function Slides({ data }) {
setEditingSlide(newSlide.id); setEditingSlide(newSlide.id);
}; };
const updateSlide = (id, updates) => { const updateSlide = (id: number, updates: Partial<SlideConfig>) => {
setSlides(slides.map(s => s.id === id ? { ...s, ...updates } : s)); setSlides(slides.map(s => s.id === id ? { ...s, ...updates } : s));
}; };
const removeSlide = (id) => { const removeSlide = (id: number) => {
setSlides(slides.filter(s => s.id !== id)); setSlides(slides.filter(s => s.id !== id));
if (editingSlide === id) setEditingSlide(null); if (editingSlide === id) setEditingSlide(null);
}; };
const moveSlide = (id, direction) => { const moveSlide = (id: number, direction: number) => {
const index = slides.findIndex(s => s.id === id); const index = slides.findIndex(s => s.id === id);
if ((direction === -1 && index === 0) || (direction === 1 && index === slides.length - 1)) return; if ((direction === -1 && index === 0) || (direction === 1 && index === slides.length - 1)) return;
const newSlides = [...slides]; const newSlides = [...slides];
@@ -74,10 +112,10 @@ function Slides({ data }) {
setSlides(newSlides); setSlides(newSlides);
}; };
const duplicateSlide = (id) => { const duplicateSlide = (id: number) => {
const slide = slides.find(s => s.id === id); const slide = slides.find(s => s.id === id);
if (slide) { if (slide) {
const newSlide = { ...slide, id: Date.now(), title: `${slide.title} (copy)` }; const newSlide: SlideConfig = { ...slide, id: Date.now(), title: `${slide.title} (copy)` };
const index = slides.findIndex(s => s.id === id); const index = slides.findIndex(s => s.id === id);
const newSlides = [...slides]; const newSlides = [...slides];
newSlides.splice(index + 1, 0, newSlide); newSlides.splice(index + 1, 0, newSlide);
@@ -243,7 +281,7 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
{editingSlide && ( {editingSlide && (
<SlideEditor <SlideEditor
slide={slides.find(s => s.id === editingSlide)} slide={slides.find(s => s.id === editingSlide)!}
onUpdate={(updates) => updateSlide(editingSlide, updates)} onUpdate={(updates) => updateSlide(editingSlide, updates)}
districts={districts} districts={districts}
districtMuseumMap={districtMuseumMap} districtMuseumMap={districtMuseumMap}
@@ -257,7 +295,7 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
); );
} }
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, chartTypes, metrics }) { function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, chartTypes, metrics }: SlideEditorProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const availableMuseums = useMemo(() => const availableMuseums = useMemo(() =>
getMuseumsForDistrict(districtMuseumMap, slide.district), getMuseumsForDistrict(districtMuseumMap, slide.district),
@@ -279,7 +317,7 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
<div className="editor-section"> <div className="editor-section">
<label>{t('slides.chartType')}</label> <label>{t('slides.chartType')}</label>
<div className="chart-type-grid"> <div className="chart-type-grid">
{chartTypes.map(type => ( {chartTypes.map((type: ChartTypeOption) => (
<button <button
key={type.id} key={type.id}
className={`chart-type-btn ${slide.chartType === type.id ? 'active' : ''}`} className={`chart-type-btn ${slide.chartType === type.id ? 'active' : ''}`}
@@ -295,7 +333,7 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
<div className="editor-section"> <div className="editor-section">
<label>{t('slides.metric')}</label> <label>{t('slides.metric')}</label>
<select value={slide.metric} onChange={e => onUpdate({ metric: e.target.value })}> <select value={slide.metric} onChange={e => onUpdate({ metric: e.target.value })}>
{metrics.map(m => <option key={m.id} value={m.id}>{m.label}</option>)} {metrics.map((m: MetricOption) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select> </select>
</div> </div>
@@ -315,14 +353,14 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
<label>{t('filters.district')}</label> <label>{t('filters.district')}</label>
<select value={slide.district} onChange={e => onUpdate({ district: e.target.value, museum: 'all' })}> <select value={slide.district} onChange={e => onUpdate({ district: e.target.value, museum: 'all' })}>
<option value="all">{t('filters.allDistricts')}</option> <option value="all">{t('filters.allDistricts')}</option>
{districts.map(d => <option key={d} value={d}>{d}</option>)} {districts.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select> </select>
</div> </div>
<div className="editor-section"> <div className="editor-section">
<label>{t('filters.museum')}</label> <label>{t('filters.museum')}</label>
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}> <select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
<option value="all">{t('filters.allMuseums')}</option> <option value="all">{t('filters.allMuseums')}</option>
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)} {availableMuseums.map((m: string) => <option key={m} value={m}>{m}</option>)}
</select> </select>
</div> </div>
</div> </div>
@@ -349,13 +387,13 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
} }
// Static field mapping for charts (Chart.js labels don't need i18n) // Static field mapping for charts (Chart.js labels don't need i18n)
const METRIC_FIELDS = { const METRIC_FIELDS: Record<string, MetricFieldInfo> = {
revenue: { field: 'revenue_incl_tax', label: 'Revenue' }, revenue: { field: 'revenue_incl_tax', label: 'Revenue' },
visitors: { field: 'visits', label: 'Visitors' }, visitors: { field: 'visits', label: 'Visitors' },
tickets: { field: 'tickets', label: 'Tickets' } tickets: { field: 'tickets', label: 'Tickets' }
}; };
function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) { function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }: SlidePreviewProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const filteredData = useMemo(() => const filteredData = useMemo(() =>
filterDataByDateRange(data, slide.startDate, slide.endDate, { filterDataByDateRange(data, slide.startDate, slide.endDate, {
@@ -368,13 +406,13 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
const metricsData = useMemo(() => calculateMetrics(filteredData), [filteredData]); const metricsData = useMemo(() => calculateMetrics(filteredData), [filteredData]);
const baseOptions = useMemo(() => createBaseOptions(false), []); const baseOptions = useMemo(() => createBaseOptions(false), []);
const getMetricValue = useCallback((rows, metric) => { const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
const fieldMap = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' }; const fieldMap: Record<string, string> = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
return rows.reduce((s, r) => s + parseFloat(r[fieldMap[metric]] || 0), 0); return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as unknown as Record<string, unknown>)[fieldMap[metric]] || 0)), 0);
}, []); }, []);
const trendData = useMemo(() => { const trendData = useMemo(() => {
const grouped = {}; const grouped: Record<string, MuseumRecord[]> = {};
filteredData.forEach(row => { filteredData.forEach(row => {
if (!row.date) return; if (!row.date) return;
const weekStart = row.date.substring(0, 10); const weekStart = row.date.substring(0, 10);
@@ -383,7 +421,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
}); });
const sortedDates = Object.keys(grouped).sort(); const sortedDates = Object.keys(grouped).sort();
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric; const metricLabel = metrics?.find((m: MetricOption) => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
return { return {
labels: sortedDates.map(d => d.substring(5)), labels: sortedDates.map(d => d.substring(5)),
datasets: [{ datasets: [{
@@ -398,7 +436,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
}, [filteredData, slide.metric, getMetricValue, metrics]); }, [filteredData, slide.metric, getMetricValue, metrics]);
const museumData = useMemo(() => { const museumData = useMemo(() => {
const byMuseum = {}; const byMuseum: Record<string, MuseumRecord[]> = {};
filteredData.forEach(row => { filteredData.forEach(row => {
if (!row.museum_name) return; if (!row.museum_name) return;
if (!byMuseum[row.museum_name]) byMuseum[row.museum_name] = []; if (!byMuseum[row.museum_name]) byMuseum[row.museum_name] = [];
@@ -406,7 +444,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
}); });
const museums = Object.keys(byMuseum).sort(); const museums = Object.keys(byMuseum).sort();
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric; const metricLabel = metrics?.find((m: MetricOption) => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
return { return {
labels: museums, labels: museums,
datasets: [{ datasets: [{
@@ -452,13 +490,13 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
); );
} }
function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide, setCurrentSlide, onExit, metrics }) { function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide, setCurrentSlide, onExit, metrics }: PreviewModeProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const handleKeyDown = useCallback((e) => { const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === ' ') { if (e.key === 'ArrowRight' || e.key === ' ') {
setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1)); setCurrentSlide((prev: number) => Math.min(prev + 1, slides.length - 1));
} else if (e.key === 'ArrowLeft') { } else if (e.key === 'ArrowLeft') {
setCurrentSlide(prev => Math.max(prev - 1, 0)); setCurrentSlide((prev: number) => Math.max(prev - 1, 0));
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
onExit(); onExit();
} }
@@ -483,8 +521,8 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
</div> </div>
</div> </div>
<div className="preview-controls"> <div className="preview-controls">
<button onClick={() => setCurrentSlide(prev => Math.max(prev - 1, 0))} disabled={currentSlide === 0}></button> <button onClick={() => setCurrentSlide((prev: number) => Math.max(prev - 1, 0))} disabled={currentSlide === 0}></button>
<button onClick={() => setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1))} disabled={currentSlide === slides.length - 1}></button> <button onClick={() => setCurrentSlide((prev: number) => Math.min(prev + 1, slides.length - 1))} disabled={currentSlide === slides.length - 1}></button>
<button onClick={onExit}>{t('slides.exit')}</button> <button onClick={onExit}>{t('slides.exit')}</button>
</div> </div>
</div> </div>
@@ -492,7 +530,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
} }
// Helper functions for HTML export // Helper functions for HTML export
function generateSlideHTML(slide, index, data, districts, districtMuseumMap) { function generateSlideHTML(slide: SlideConfig, index: number, data: MuseumRecord[], districts: string[], districtMuseumMap: DistrictMuseumMap): string {
const chartType = slide.chartType; const chartType = slide.chartType;
const canvasId = `chart-${index}`; const canvasId = `chart-${index}`;
@@ -510,7 +548,7 @@ function generateSlideHTML(slide, index, data, districts, districtMuseumMap) {
</div>`; </div>`;
} }
function generateKPIHTML(slide, data) { function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string {
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, { const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
district: slide.district, district: slide.district,
museum: slide.museum museum: slide.museum
@@ -534,8 +572,8 @@ function generateKPIHTML(slide, data) {
</div>`; </div>`;
} }
function generateChartScripts(slides, data, districts, districtMuseumMap) { function generateChartScripts(slides: SlideConfig[], data: MuseumRecord[], districts: string[], districtMuseumMap: DistrictMuseumMap): string {
return slides.map((slide, index) => { return slides.map((slide: SlideConfig, index: number) => {
if (slide.chartType === 'kpi-cards') return ''; if (slide.chartType === 'kpi-cards') return '';
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, { const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
@@ -551,15 +589,15 @@ function generateChartScripts(slides, data, districts, districtMuseumMap) {
}).join('\n'); }).join('\n');
} }
function generateChartConfig(slide, data) { function generateChartConfig(slide: SlideConfig, data: MuseumRecord[]): object {
const fieldMap = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' }; const fieldMap: Record<string, keyof MuseumRecord> = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
const field = fieldMap[slide.metric]; const field = fieldMap[slide.metric];
if (slide.chartType === 'museum-bar') { if (slide.chartType === 'museum-bar') {
const byMuseum = {}; const byMuseum: Record<string, number> = {};
data.forEach(row => { data.forEach((row: MuseumRecord) => {
if (!row.museum_name) return; if (!row.museum_name) return;
byMuseum[row.museum_name] = (byMuseum[row.museum_name] || 0) + parseFloat(row[field] || 0); byMuseum[row.museum_name] = (byMuseum[row.museum_name] || 0) + parseFloat(String(row[field] || 0));
}); });
const museums = Object.keys(byMuseum).sort(); const museums = Object.keys(byMuseum).sort();
@@ -578,10 +616,10 @@ function generateChartConfig(slide, data) {
} }
// Default: trend line // Default: trend line
const grouped = {}; const grouped: Record<string, number> = {};
data.forEach(row => { data.forEach((row: MuseumRecord) => {
if (!row.date) return; if (!row.date) return;
grouped[row.date] = (grouped[row.date] || 0) + parseFloat(row[field] || 0); grouped[row.date] = (grouped[row.date] || 0) + parseFloat(String(row[field] || 0));
}); });
const dates = Object.keys(grouped).sort(); const dates = Object.keys(grouped).sort();

View File

@@ -49,7 +49,7 @@ export const createDataLabelConfig = (showDataLabels: boolean): any => ({
backgroundColor: 'rgba(255, 255, 255, 0.85)', backgroundColor: 'rgba(255, 255, 255, 0.85)',
borderRadius: 3, borderRadius: 3,
textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment
formatter: (value) => { formatter: (value: number | null) => {
if (value == null) return ''; if (value == null) return '';
if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M'; if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
if (value >= 1000) return (value / 1000).toFixed(2) + 'K'; if (value >= 1000) return (value / 1000).toFixed(2) + 'K';

View File

@@ -253,13 +253,13 @@ async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
museumMap[m.Id] = { museumMap[m.Id] = {
code: m.Code, code: m.Code,
name: m.Name, name: m.Name,
district: districtMap[m.DistrictId || m['nc_epk____Districts_id']] || 'Unknown' district: districtMap[m.DistrictId ?? m['nc_epk____Districts_id'] ?? 0] || 'Unknown'
}; };
}); });
// Join data into flat structure // Join data into flat structure
const data: MuseumRecord[] = dailyStats.map(row => { const data: MuseumRecord[] = dailyStats.map(row => {
const museum = museumMap[row.MuseumId || row['nc_epk____Museums_id']] || { code: '', name: '', district: '' }; const museum = museumMap[row.MuseumId ?? row['nc_epk____Museums_id'] ?? 0] || { code: '', name: '', district: '' };
const date = row.Date; const date = row.Date;
const year = date ? date.substring(0, 4) : ''; const year = date ? date.substring(0, 4) : '';
const month = date ? parseInt(date.substring(5, 7)) : 0; const month = date ? parseInt(date.substring(5, 7)) : 0;

View File

@@ -174,5 +174,35 @@ export interface NocoDBDailyStat {
'nc_epk____Museums_id'?: number; 'nc_epk____Museums_id'?: number;
} }
// Slide types
export interface SlideConfig {
id: number;
title: string;
chartType: string;
metric: string;
startDate: string;
endDate: string;
district: string;
museum: string;
showComparison: boolean;
}
export interface ChartTypeOption {
id: string;
label: string;
icon: string;
}
export interface MetricOption {
id: string;
label: string;
field: string;
}
export interface MetricFieldInfo {
field: string;
label: string;
}
// Translation function type // Translation function type
export type TranslateFunction = (key: string) => string; export type TranslateFunction = (key: string) => string;

View File

@@ -6,9 +6,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"strict": false, "strict": true,
"noImplicitAny": false,
"strictNullChecks": false,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"module": "ESNext", "module": "ESNext",