import React, { useState, useMemo, useCallback } from 'react';
import { Line, Bar } from 'react-chartjs-2';
import { chartColors, createBaseOptions } from '../config/chartConfig';
import { useLanguage } from '../contexts/LanguageContext';
import {
filterDataByDateRange,
calculateMetrics,
formatCompact,
formatCompactCurrency,
getUniqueDistricts,
getDistrictMuseumMap,
getMuseumsForDistrict
} from '../services/dataService';
import JSZip from 'jszip';
function Slides({ data }) {
const { t } = useLanguage();
const CHART_TYPES = useMemo(() => [
{ id: 'trend', label: t('slides.revenueTrend'), icon: '📈' },
{ id: 'museum-bar', label: t('slides.byMuseum'), icon: '📊' },
{ id: 'kpi-cards', label: t('slides.kpiSummary'), icon: '🎯' },
{ id: 'comparison', label: t('slides.yoyComparison'), icon: '⚖️' }
], [t]);
const METRICS = useMemo(() => [
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax' },
{ id: 'visitors', label: t('metrics.visitors'), field: 'visits' },
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
], [t]);
const [slides, setSlides] = useState([]);
const [editingSlide, setEditingSlide] = useState(null);
const [previewMode, setPreviewMode] = useState(false);
const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0);
const districts = useMemo(() => getUniqueDistricts(data), [data]);
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
const defaultSlideConfig = {
title: 'Slide Title',
chartType: 'trend',
metric: 'revenue',
startDate: '2026-01-01',
endDate: '2026-01-31',
district: 'all',
museum: 'all',
showComparison: false
};
const addSlide = () => {
const newSlide = {
id: Date.now(),
...defaultSlideConfig,
title: `Slide ${slides.length + 1}`
};
setSlides([...slides, newSlide]);
setEditingSlide(newSlide.id);
};
const updateSlide = (id, updates) => {
setSlides(slides.map(s => s.id === id ? { ...s, ...updates } : s));
};
const removeSlide = (id) => {
setSlides(slides.filter(s => s.id !== id));
if (editingSlide === id) setEditingSlide(null);
};
const moveSlide = (id, direction) => {
const index = slides.findIndex(s => s.id === id);
if ((direction === -1 && index === 0) || (direction === 1 && index === slides.length - 1)) return;
const newSlides = [...slides];
[newSlides[index], newSlides[index + direction]] = [newSlides[index + direction], newSlides[index]];
setSlides(newSlides);
};
const duplicateSlide = (id) => {
const slide = slides.find(s => s.id === id);
if (slide) {
const newSlide = { ...slide, id: Date.now(), title: `${slide.title} (copy)` };
const index = slides.findIndex(s => s.id === id);
const newSlides = [...slides];
newSlides.splice(index + 1, 0, newSlide);
setSlides(newSlides);
}
};
const exportAsHTML = async () => {
const zip = new JSZip();
// Generate HTML for each slide
const slidesHTML = slides.map((slide, index) => {
return generateSlideHTML(slide, index, data, districts, districtMuseumMap);
}).join('\n');
const fullHTML = `
HiHala Data Presentation
${slidesHTML}
`;
zip.file('presentation.html', fullHTML);
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'hihala-presentation.zip';
a.click();
URL.revokeObjectURL(url);
};
if (previewMode) {
return (
setPreviewMode(false)}
metrics={METRICS}
/>
);
}
return (
{t('slides.title')}
{t('slides.subtitle')}
{slides.length > 0 && (
<>
>
)}
{t('slides.slidesCount')} ({slides.length})
{slides.length === 0 ? (
{t('slides.noSlides')}
) : (
{slides.map((slide, index) => (
setEditingSlide(slide.id)}
>
{index + 1}
{CHART_TYPES.find(c => c.id === slide.chartType)?.icon}
{slide.title}
))}
)}
{editingSlide && (
s.id === editingSlide)}
onUpdate={(updates) => updateSlide(editingSlide, updates)}
districts={districts}
districtMuseumMap={districtMuseumMap}
data={data}
chartTypes={CHART_TYPES}
metrics={METRICS}
/>
)}
);
}
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, chartTypes, metrics }) {
const { t } = useLanguage();
const availableMuseums = useMemo(() =>
getMuseumsForDistrict(districtMuseumMap, slide.district),
[districtMuseumMap, slide.district]
);
return (
onUpdate({ title: e.target.value })}
placeholder={t('slides.slideTitle')}
/>
{chartTypes.map(type => (
))}
{slide.chartType === 'comparison' && (
)}
{t('slides.preview')}
);
}
// Static field mapping for charts (Chart.js labels don't need i18n)
const METRIC_FIELDS = {
revenue: { field: 'revenue_incl_tax', label: 'Revenue' },
visitors: { field: 'visits', label: 'Visitors' },
tickets: { field: 'tickets', label: 'Tickets' }
};
function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
const { t } = useLanguage();
const filteredData = useMemo(() =>
filterDataByDateRange(data, slide.startDate, slide.endDate, {
district: slide.district,
museum: slide.museum
}),
[data, slide.startDate, slide.endDate, slide.district, slide.museum]
);
const metricsData = useMemo(() => calculateMetrics(filteredData), [filteredData]);
const baseOptions = useMemo(() => createBaseOptions(false), []);
const getMetricValue = useCallback((rows, metric) => {
const fieldMap = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
return rows.reduce((s, r) => s + parseFloat(r[fieldMap[metric]] || 0), 0);
}, []);
const trendData = useMemo(() => {
const grouped = {};
filteredData.forEach(row => {
if (!row.date) return;
const weekStart = row.date.substring(0, 10);
if (!grouped[weekStart]) grouped[weekStart] = [];
grouped[weekStart].push(row);
});
const sortedDates = Object.keys(grouped).sort();
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
return {
labels: sortedDates.map(d => d.substring(5)),
datasets: [{
label: metricLabel,
data: sortedDates.map(d => getMetricValue(grouped[d], slide.metric)),
borderColor: chartColors.primary,
backgroundColor: chartColors.primary + '20',
fill: true,
tension: 0.4
}]
};
}, [filteredData, slide.metric, getMetricValue, metrics]);
const museumData = useMemo(() => {
const byMuseum = {};
filteredData.forEach(row => {
if (!row.museum_name) return;
if (!byMuseum[row.museum_name]) byMuseum[row.museum_name] = [];
byMuseum[row.museum_name].push(row);
});
const museums = Object.keys(byMuseum).sort();
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
return {
labels: museums,
datasets: [{
label: metricLabel,
data: museums.map(m => getMetricValue(byMuseum[m], slide.metric)),
backgroundColor: chartColors.primary,
borderRadius: 6
}]
};
}, [filteredData, slide.metric, getMetricValue, metrics]);
if (slide.chartType === 'kpi-cards') {
return (
{formatCompactCurrency(metricsData.revenue)}
{t('metrics.revenue')}
{formatCompact(metricsData.visitors)}
{t('metrics.visitors')}
{formatCompact(metricsData.tickets)}
{t('metrics.tickets')}
);
}
if (slide.chartType === 'museum-bar') {
return (
);
}
return (
);
}
function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide, setCurrentSlide, onExit, metrics }) {
const { t } = useLanguage();
const handleKeyDown = useCallback((e) => {
if (e.key === 'ArrowRight' || e.key === ' ') {
setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1));
} else if (e.key === 'ArrowLeft') {
setCurrentSlide(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Escape') {
onExit();
}
}, [slides.length, setCurrentSlide, onExit]);
React.useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const slide = slides[currentSlide];
return (
{slide?.title}
{slide && }
{currentSlide + 1} / {slides.length}
);
}
// Helper functions for HTML export
function generateSlideHTML(slide, index, data, districts, districtMuseumMap) {
const chartType = slide.chartType;
const canvasId = `chart-${index}`;
return `
${slide.title}
${formatDateRange(slide.startDate, slide.endDate)}
${chartType === 'kpi-cards' ? generateKPIHTML(slide, data) : `
`}
Slide ${index + 1}
`;
}
function generateKPIHTML(slide, data) {
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
district: slide.district,
museum: slide.museum
});
const metrics = calculateMetrics(filtered);
return `
${formatCompactCurrency(metrics.revenue)}
Revenue
${formatCompact(metrics.visitors)}
Visitors
${formatCompact(metrics.tickets)}
Tickets
`;
}
function generateChartScripts(slides, data, districts, districtMuseumMap) {
return slides.map((slide, index) => {
if (slide.chartType === 'kpi-cards') return '';
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
district: slide.district,
museum: slide.museum
});
const chartConfig = generateChartConfig(slide, filtered);
return `
new Chart(document.getElementById('chart-${index}'), ${JSON.stringify(chartConfig)});
`;
}).join('\n');
}
function generateChartConfig(slide, data) {
const fieldMap = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
const field = fieldMap[slide.metric];
if (slide.chartType === 'museum-bar') {
const byMuseum = {};
data.forEach(row => {
if (!row.museum_name) return;
byMuseum[row.museum_name] = (byMuseum[row.museum_name] || 0) + parseFloat(row[field] || 0);
});
const museums = Object.keys(byMuseum).sort();
return {
type: 'bar',
data: {
labels: museums,
datasets: [{
data: museums.map(m => byMuseum[m]),
backgroundColor: '#3b82f6',
borderRadius: 6
}]
},
options: { indexAxis: 'y', plugins: { legend: { display: false } } }
};
}
// Default: trend line
const grouped = {};
data.forEach(row => {
if (!row.date) return;
grouped[row.date] = (grouped[row.date] || 0) + parseFloat(row[field] || 0);
});
const dates = Object.keys(grouped).sort();
return {
type: 'line',
data: {
labels: dates.map(d => d.substring(5)),
datasets: [{
data: dates.map(d => grouped[d]),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59,130,246,0.1)',
fill: true,
tension: 0.4
}]
},
options: { plugins: { legend: { display: false } } }
};
}
function formatDateRange(start, end) {
const s = new Date(start);
const e = new Date(end);
const opts = { month: 'short', day: 'numeric', year: 'numeric' };
return `${s.toLocaleDateString('en-US', opts)} – ${e.toLocaleDateString('en-US', opts)}`;
}
export default Slides;