feat: migrate museum sales from NocoDB to Hono ERP API
- Replace NocoDB museum data (Districts/Museums/DailyStats) with ERP API - Client fetches via server proxy (/api/erp/sales) — no credentials in browser - Aggregate transaction-level ERP data into daily/museum/channel records - Replace "district" dimension with "channel" (B2C/HiHala, POS, B2B, etc.) - Add product-to-museum mapping (46 products → 6 museums) - NocoDB retained only for PilgrimStats - Remove old server/index.js (replaced by modular TS in server/src/) - Update all components, types, and locale files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,9 +11,8 @@ import {
|
||||
formatCompact,
|
||||
formatCompactCurrency,
|
||||
umrahData,
|
||||
getUniqueDistricts,
|
||||
getDistrictMuseumMap,
|
||||
getMuseumsForDistrict,
|
||||
getUniqueChannels,
|
||||
getUniqueMuseums,
|
||||
getLatestYear
|
||||
} from '../services/dataService';
|
||||
import type { MuseumRecord, ComparisonProps, DateRangeFilters } from '../types';
|
||||
@@ -107,7 +106,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
return searchParams.get('to') || `${year}-01-31`;
|
||||
});
|
||||
const [filters, setFiltersState] = useState(() => ({
|
||||
district: searchParams.get('district') || 'all',
|
||||
channel: searchParams.get('channel') || 'all',
|
||||
museum: searchParams.get('museum') || 'all'
|
||||
}));
|
||||
|
||||
@@ -125,7 +124,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
if (newFrom) params.set('from', newFrom);
|
||||
if (newTo) params.set('to', newTo);
|
||||
}
|
||||
if (newFilters?.district && newFilters.district !== 'all') params.set('district', newFilters.district);
|
||||
if (newFilters?.channel && newFilters.channel !== 'all') params.set('channel', newFilters.channel);
|
||||
if (newFilters?.museum && newFilters.museum !== 'all') params.set('museum', newFilters.museum);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [setSearchParams, latestYear]);
|
||||
@@ -209,19 +208,18 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
|
||||
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
|
||||
if (metric === 'avgRevenue') {
|
||||
const revenue = rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[revenueField] || r.revenue_incl_tax || 0)), 0);
|
||||
const revenue = rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[revenueField] || 0)), 0);
|
||||
const visitors = rows.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
|
||||
return visitors > 0 ? revenue / visitors : 0;
|
||||
}
|
||||
const fieldMap: Record<string, string> = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' };
|
||||
const field = fieldMap[metric];
|
||||
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[field] || r.revenue_incl_tax || 0)), 0);
|
||||
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[field] || 0)), 0);
|
||||
}, [revenueField]);
|
||||
|
||||
// Dynamic lists from data
|
||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
|
||||
const channels = useMemo(() => getUniqueChannels(data), [data]);
|
||||
const availableMuseums = useMemo(() => getUniqueMuseums(data), [data]);
|
||||
|
||||
// Year-over-year comparison: same dates, previous year
|
||||
const ranges = useMemo(() => ({
|
||||
@@ -246,7 +244,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
const currMetrics = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]);
|
||||
|
||||
const hasData = prevData.length > 0 || currData.length > 0;
|
||||
const resetFilters = () => setFilters({ district: 'all', museum: 'all' });
|
||||
const resetFilters = () => setFilters({ channel: 'all', museum: 'all' });
|
||||
|
||||
const calcChange = (prev: number, curr: number) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
||||
|
||||
@@ -577,10 +575,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
</FilterControls.Group>
|
||||
</>
|
||||
)}
|
||||
<FilterControls.Group label={t('filters.district')}>
|
||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
||||
<option value="all">{t('filters.allDistricts')}</option>
|
||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
<FilterControls.Group label={t('filters.channel')}>
|
||||
<select value={filters.channel} onChange={e => setFilters({...filters, channel: e.target.value})}>
|
||||
<option value="all">{t('filters.allChannels')}</option>
|
||||
{channels.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label={t('filters.museum')}>
|
||||
|
||||
@@ -12,24 +12,23 @@ import {
|
||||
formatNumber,
|
||||
groupByWeek,
|
||||
groupByMuseum,
|
||||
groupByDistrict,
|
||||
groupByChannel,
|
||||
umrahData,
|
||||
fetchPilgrimStats,
|
||||
getUniqueYears,
|
||||
getUniqueDistricts,
|
||||
getDistrictMuseumMap,
|
||||
getMuseumsForDistrict
|
||||
getUniqueChannels,
|
||||
getUniqueMuseums
|
||||
} from '../services/dataService';
|
||||
import type { DashboardProps, Filters, MuseumRecord } from '../types';
|
||||
|
||||
const defaultFilters: Filters = {
|
||||
year: 'all',
|
||||
district: 'all',
|
||||
channel: 'all',
|
||||
museum: 'all',
|
||||
quarter: 'all'
|
||||
};
|
||||
|
||||
const filterKeys: (keyof Filters)[] = ['year', 'district', 'museum', 'quarter'];
|
||||
const filterKeys: (keyof Filters)[] = ['year', 'channel', 'museum', 'quarter'];
|
||||
|
||||
function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
|
||||
const { t } = useLanguage();
|
||||
@@ -85,15 +84,14 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
|
||||
// Chart carousel labels
|
||||
const chartLabels = useMemo(() => {
|
||||
const labels = [t('charts.revenueTrend'), t('charts.visitors'), t('charts.revenue'), t('charts.quarterly'), t('charts.district'), t('charts.captureRate')];
|
||||
const labels = [t('charts.revenueTrend'), t('charts.visitors'), t('charts.revenue'), t('charts.quarterly'), t('charts.channel'), t('charts.captureRate')];
|
||||
return filters.museum === 'all' ? labels : labels.filter((_, i) => i !== 1 && i !== 2);
|
||||
}, [filters.museum, t]);
|
||||
|
||||
// Dynamic lists from data
|
||||
const years = useMemo(() => getUniqueYears(data), [data]);
|
||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
|
||||
const channels = useMemo(() => getUniqueChannels(data), [data]);
|
||||
const availableMuseums = useMemo(() => getUniqueMuseums(data), [data]);
|
||||
|
||||
const yoyChange = useMemo(() => {
|
||||
if (filters.year === 'all') return null;
|
||||
@@ -167,7 +165,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
filteredData.forEach(row => {
|
||||
const date = row.date;
|
||||
if (!dailyData[date]) dailyData[date] = 0;
|
||||
dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || row.revenue_incl_tax || 0);
|
||||
dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || 0);
|
||||
});
|
||||
const days = Object.keys(dailyData).sort();
|
||||
const revenueValues = days.map(d => dailyData[d]);
|
||||
@@ -212,14 +210,14 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
};
|
||||
}, [filteredData, includeVAT]);
|
||||
|
||||
// District data
|
||||
const districtData = useMemo(() => {
|
||||
const grouped = groupByDistrict(filteredData, includeVAT);
|
||||
const districts = Object.keys(grouped);
|
||||
// Channel data
|
||||
const channelData = useMemo(() => {
|
||||
const grouped = groupByChannel(filteredData, includeVAT);
|
||||
const channels = Object.keys(grouped);
|
||||
return {
|
||||
labels: districts,
|
||||
labels: channels,
|
||||
datasets: [{
|
||||
data: districts.map(d => grouped[d].revenue),
|
||||
data: channels.map(d => grouped[d].revenue),
|
||||
backgroundColor: [chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
||||
borderRadius: 4
|
||||
}]
|
||||
@@ -237,13 +235,13 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
datasets: [
|
||||
{
|
||||
label: '2024',
|
||||
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)),
|
||||
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] || 0)), 0)),
|
||||
backgroundColor: chartColors.muted,
|
||||
borderRadius: 4
|
||||
},
|
||||
{
|
||||
label: '2025',
|
||||
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)),
|
||||
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] || 0)), 0)),
|
||||
backgroundColor: chartColors.primary,
|
||||
borderRadius: 4
|
||||
}
|
||||
@@ -261,7 +259,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
const pilgrims = umrahData[year]?.[q];
|
||||
if (!pilgrims) return;
|
||||
let qData = data.filter((r: MuseumRecord) => r.year === String(year) && r.quarter === String(q));
|
||||
if (filters.district !== 'all') qData = qData.filter((r: MuseumRecord) => r.district === filters.district);
|
||||
if (filters.channel !== 'all') qData = qData.filter((r: MuseumRecord) => r.channel === filters.channel);
|
||||
if (filters.museum !== 'all') qData = qData.filter((r: MuseumRecord) => r.museum_name === filters.museum);
|
||||
const visitors = qData.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
|
||||
labels.push(`Q${q} ${year}`);
|
||||
@@ -325,7 +323,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data, filters.district, filters.museum, showDataLabels]);
|
||||
}, [data, filters.channel, filters.museum, showDataLabels]);
|
||||
|
||||
// Quarterly table
|
||||
const quarterlyTable = useMemo(() => {
|
||||
@@ -335,16 +333,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
return [1, 2, 3, 4].map(q => {
|
||||
let q2024 = d2024.filter((r: MuseumRecord) => r.quarter === String(q));
|
||||
let q2025 = d2025.filter((r: MuseumRecord) => r.quarter === String(q));
|
||||
if (filters.district !== 'all') {
|
||||
q2024 = q2024.filter((r: MuseumRecord) => r.district === filters.district);
|
||||
q2025 = q2025.filter((r: MuseumRecord) => r.district === filters.district);
|
||||
if (filters.channel !== 'all') {
|
||||
q2024 = q2024.filter((r: MuseumRecord) => r.channel === filters.channel);
|
||||
q2025 = q2025.filter((r: MuseumRecord) => r.channel === filters.channel);
|
||||
}
|
||||
if (filters.museum !== 'all') {
|
||||
q2024 = q2024.filter((r: MuseumRecord) => r.museum_name === filters.museum);
|
||||
q2025 = q2025.filter((r: MuseumRecord) => r.museum_name === filters.museum);
|
||||
}
|
||||
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: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || r.revenue_incl_tax || 0)), 0);
|
||||
const rev24 = q2024.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0);
|
||||
const rev25 = q2025.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0);
|
||||
const vis24 = q2024.reduce((s: number, r: MuseumRecord) => s + parseInt(String(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;
|
||||
@@ -353,7 +351,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
const cap25 = umrahData[2025][q] ? (vis25 / umrahData[2025][q] * 100) : null;
|
||||
return { q, rev24, rev25, revChg, vis24, vis25, visChg, cap24, cap25 };
|
||||
});
|
||||
}, [data, filters.district, filters.museum, includeVAT]);
|
||||
}, [data, filters.channel, filters.museum, includeVAT]);
|
||||
|
||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||
|
||||
@@ -390,10 +388,10 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label={t('filters.district')}>
|
||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
||||
<option value="all">{t('filters.allDistricts')}</option>
|
||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
<FilterControls.Group label={t('filters.channel')}>
|
||||
<select value={filters.channel} onChange={e => setFilters({...filters, channel: e.target.value})}>
|
||||
<option value="all">{t('filters.allChannels')}</option>
|
||||
{channels.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label={t('filters.museum')}>
|
||||
@@ -531,8 +529,8 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
</div>
|
||||
|
||||
<div className="chart-card half-width">
|
||||
<ExportableChart filename="district-performance" title={t('dashboard.districtPerformance')} className="chart-container">
|
||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||
<ExportableChart filename="channel-performance" title={t('dashboard.channelPerformance')} className="chart-container">
|
||||
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||
</ExportableChart>
|
||||
</div>
|
||||
|
||||
@@ -633,9 +631,9 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>{t('dashboard.districtPerformance')}</h2>
|
||||
<h2>{t('dashboard.channelPerformance')}</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,14 +7,12 @@ import {
|
||||
calculateMetrics,
|
||||
formatCompact,
|
||||
formatCompactCurrency,
|
||||
getUniqueDistricts,
|
||||
getDistrictMuseumMap,
|
||||
getMuseumsForDistrict
|
||||
getUniqueChannels,
|
||||
getUniqueMuseums
|
||||
} from '../services/dataService';
|
||||
import JSZip from 'jszip';
|
||||
import type {
|
||||
MuseumRecord,
|
||||
DistrictMuseumMap,
|
||||
SlideConfig,
|
||||
ChartTypeOption,
|
||||
MetricOption,
|
||||
@@ -25,8 +23,8 @@ import type {
|
||||
interface SlideEditorProps {
|
||||
slide: SlideConfig;
|
||||
onUpdate: (updates: Partial<SlideConfig>) => void;
|
||||
districts: string[];
|
||||
districtMuseumMap: DistrictMuseumMap;
|
||||
channels: string[];
|
||||
museums: string[];
|
||||
data: MuseumRecord[];
|
||||
chartTypes: ChartTypeOption[];
|
||||
metrics: MetricOption[];
|
||||
@@ -35,16 +33,16 @@ interface SlideEditorProps {
|
||||
interface SlidePreviewProps {
|
||||
slide: SlideConfig;
|
||||
data: MuseumRecord[];
|
||||
districts: string[];
|
||||
districtMuseumMap: DistrictMuseumMap;
|
||||
channels: string[];
|
||||
museums: string[];
|
||||
metrics: MetricOption[];
|
||||
}
|
||||
|
||||
interface PreviewModeProps {
|
||||
slides: SlideConfig[];
|
||||
data: MuseumRecord[];
|
||||
districts: string[];
|
||||
districtMuseumMap: DistrictMuseumMap;
|
||||
channels: string[];
|
||||
museums: string[];
|
||||
currentSlide: number;
|
||||
setCurrentSlide: React.Dispatch<React.SetStateAction<number>>;
|
||||
onExit: () => void;
|
||||
@@ -62,7 +60,7 @@ function Slides({ data }: SlidesProps) {
|
||||
], [t]);
|
||||
|
||||
const METRICS: MetricOption[] = useMemo(() => [
|
||||
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax' },
|
||||
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_gross' },
|
||||
{ id: 'visitors', label: t('metrics.visitors'), field: 'visits' },
|
||||
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
|
||||
], [t]);
|
||||
@@ -71,8 +69,8 @@ function Slides({ data }: SlidesProps) {
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0);
|
||||
|
||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
||||
const channels = useMemo(() => getUniqueChannels(data), [data]);
|
||||
const museums = useMemo(() => getUniqueMuseums(data), [data]);
|
||||
|
||||
const defaultSlideConfig: Omit<SlideConfig, 'id'> = {
|
||||
title: 'Slide Title',
|
||||
@@ -80,7 +78,7 @@ function Slides({ data }: SlidesProps) {
|
||||
metric: 'revenue',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-31',
|
||||
district: 'all',
|
||||
channel: 'all',
|
||||
museum: 'all',
|
||||
showComparison: false
|
||||
};
|
||||
@@ -128,7 +126,7 @@ function Slides({ data }: SlidesProps) {
|
||||
|
||||
// Generate HTML for each slide
|
||||
const slidesHTML = slides.map((slide, index) => {
|
||||
return generateSlideHTML(slide, index, data, districts, districtMuseumMap);
|
||||
return generateSlideHTML(slide, index, data);
|
||||
}).join('\n');
|
||||
|
||||
const fullHTML = `<!DOCTYPE html>
|
||||
@@ -185,7 +183,7 @@ function Slides({ data }: SlidesProps) {
|
||||
${slidesHTML}
|
||||
<script>
|
||||
// Chart.js initialization scripts will be here
|
||||
${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
${generateChartScripts(slides, data)}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -206,8 +204,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
<PreviewMode
|
||||
slides={slides}
|
||||
data={data}
|
||||
districts={districts}
|
||||
districtMuseumMap={districtMuseumMap}
|
||||
channels={channels}
|
||||
museums={museums}
|
||||
currentSlide={currentPreviewSlide}
|
||||
setCurrentSlide={setCurrentPreviewSlide}
|
||||
onExit={() => setPreviewMode(false)}
|
||||
@@ -283,8 +281,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
<SlideEditor
|
||||
slide={slides.find(s => s.id === editingSlide)!}
|
||||
onUpdate={(updates) => updateSlide(editingSlide, updates)}
|
||||
districts={districts}
|
||||
districtMuseumMap={districtMuseumMap}
|
||||
channels={channels}
|
||||
museums={museums}
|
||||
data={data}
|
||||
chartTypes={CHART_TYPES}
|
||||
metrics={METRICS}
|
||||
@@ -295,12 +293,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
);
|
||||
}
|
||||
|
||||
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, chartTypes, metrics }: SlideEditorProps) {
|
||||
function SlideEditor({ slide, onUpdate, channels, museums, data, chartTypes, metrics }: SlideEditorProps) {
|
||||
const { t } = useLanguage();
|
||||
const availableMuseums = useMemo(() =>
|
||||
getMuseumsForDistrict(districtMuseumMap, slide.district),
|
||||
[districtMuseumMap, slide.district]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="slide-editor">
|
||||
@@ -350,17 +344,17 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
||||
|
||||
<div className="editor-row">
|
||||
<div className="editor-section">
|
||||
<label>{t('filters.district')}</label>
|
||||
<select value={slide.district} onChange={e => onUpdate({ district: e.target.value, museum: 'all' })}>
|
||||
<option value="all">{t('filters.allDistricts')}</option>
|
||||
{districts.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
||||
<label>{t('filters.channel')}</label>
|
||||
<select value={slide.channel} onChange={e => onUpdate({ channel: e.target.value, museum: 'all' })}>
|
||||
<option value="all">{t('filters.allChannels')}</option>
|
||||
{channels.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="editor-section">
|
||||
<label>{t('filters.museum')}</label>
|
||||
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
|
||||
<option value="all">{t('filters.allMuseums')}</option>
|
||||
{availableMuseums.map((m: string) => <option key={m} value={m}>{m}</option>)}
|
||||
{museums.map((m: string) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,7 +374,7 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
||||
|
||||
<div className="slide-preview-box">
|
||||
<h4>{t('slides.preview')}</h4>
|
||||
<SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} metrics={metrics} />
|
||||
<SlidePreview slide={slide} data={data} channels={channels} museums={museums} metrics={metrics} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -388,26 +382,26 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
||||
|
||||
// Static field mapping for charts (Chart.js labels don't need i18n)
|
||||
const METRIC_FIELDS: Record<string, MetricFieldInfo> = {
|
||||
revenue: { field: 'revenue_incl_tax', label: 'Revenue' },
|
||||
revenue: { field: 'revenue_gross', label: 'Revenue' },
|
||||
visitors: { field: 'visits', label: 'Visitors' },
|
||||
tickets: { field: 'tickets', label: 'Tickets' }
|
||||
};
|
||||
|
||||
function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }: SlidePreviewProps) {
|
||||
function SlidePreview({ slide, data, channels, museums, metrics }: SlidePreviewProps) {
|
||||
const { t } = useLanguage();
|
||||
const filteredData = useMemo(() =>
|
||||
filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||
district: slide.district,
|
||||
channel: slide.channel,
|
||||
museum: slide.museum
|
||||
}),
|
||||
[data, slide.startDate, slide.endDate, slide.district, slide.museum]
|
||||
[data, slide.startDate, slide.endDate, slide.channel, slide.museum]
|
||||
);
|
||||
|
||||
const metricsData = useMemo(() => calculateMetrics(filteredData), [filteredData]);
|
||||
const baseOptions = useMemo(() => createBaseOptions(false), []);
|
||||
|
||||
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
|
||||
const fieldMap: Record<string, string> = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
|
||||
const fieldMap: Record<string, string> = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' };
|
||||
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as unknown as Record<string, unknown>)[fieldMap[metric]] || 0)), 0);
|
||||
}, []);
|
||||
|
||||
@@ -490,7 +484,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }: Sl
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide, setCurrentSlide, onExit, metrics }: PreviewModeProps) {
|
||||
function PreviewMode({ slides, data, channels, museums, currentSlide, setCurrentSlide, onExit, metrics }: PreviewModeProps) {
|
||||
const { t } = useLanguage();
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowRight' || e.key === ' ') {
|
||||
@@ -514,7 +508,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
|
||||
<div className="preview-slide">
|
||||
<h1 className="preview-title">{slide?.title}</h1>
|
||||
<div className="preview-content">
|
||||
{slide && <SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} metrics={metrics} />}
|
||||
{slide && <SlidePreview slide={slide} data={data} channels={channels} museums={museums} metrics={metrics} />}
|
||||
</div>
|
||||
<div className="preview-footer">
|
||||
<span>{currentSlide + 1} / {slides.length}</span>
|
||||
@@ -530,7 +524,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
|
||||
}
|
||||
|
||||
// Helper functions for HTML export
|
||||
function generateSlideHTML(slide: SlideConfig, index: number, data: MuseumRecord[], districts: string[], districtMuseumMap: DistrictMuseumMap): string {
|
||||
function generateSlideHTML(slide: SlideConfig, index: number, data: MuseumRecord[]): string {
|
||||
const chartType = slide.chartType;
|
||||
const canvasId = `chart-${index}`;
|
||||
|
||||
@@ -550,7 +544,7 @@ function generateSlideHTML(slide: SlideConfig, index: number, data: MuseumRecord
|
||||
|
||||
function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string {
|
||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||
district: slide.district,
|
||||
channel: slide.channel,
|
||||
museum: slide.museum
|
||||
});
|
||||
const metrics = calculateMetrics(filtered);
|
||||
@@ -572,12 +566,12 @@ function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function generateChartScripts(slides: SlideConfig[], data: MuseumRecord[], districts: string[], districtMuseumMap: DistrictMuseumMap): string {
|
||||
function generateChartScripts(slides: SlideConfig[], data: MuseumRecord[]): string {
|
||||
return slides.map((slide: SlideConfig, index: number) => {
|
||||
if (slide.chartType === 'kpi-cards') return '';
|
||||
|
||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||
district: slide.district,
|
||||
channel: slide.channel,
|
||||
museum: slide.museum
|
||||
});
|
||||
|
||||
@@ -590,7 +584,7 @@ function generateChartScripts(slides: SlideConfig[], data: MuseumRecord[], distr
|
||||
}
|
||||
|
||||
function generateChartConfig(slide: SlideConfig, data: MuseumRecord[]): object {
|
||||
const fieldMap: Record<string, keyof MuseumRecord> = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
|
||||
const fieldMap: Record<string, keyof MuseumRecord> = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' };
|
||||
const field = fieldMap[slide.metric];
|
||||
|
||||
if (slide.chartType === 'museum-bar') {
|
||||
|
||||
Reference in New Issue
Block a user