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, getUniqueChannels, getUniqueMuseums } from '../services/dataService'; import JSZip from 'jszip'; import type { MuseumRecord, SlideConfig, ChartTypeOption, MetricOption, MetricFieldInfo, SlidesProps } from '../types'; interface SlideEditorProps { slide: SlideConfig; onUpdate: (updates: Partial) => void; channels: string[]; museums: string[]; data: MuseumRecord[]; chartTypes: ChartTypeOption[]; metrics: MetricOption[]; } interface SlidePreviewProps { slide: SlideConfig; data: MuseumRecord[]; channels: string[]; museums: string[]; metrics: MetricOption[]; } interface PreviewModeProps { slides: SlideConfig[]; data: MuseumRecord[]; channels: string[]; museums: string[]; currentSlide: number; setCurrentSlide: React.Dispatch>; onExit: () => void; metrics: MetricOption[]; } function Slides({ data }: SlidesProps) { const { t } = useLanguage(); const CHART_TYPES: ChartTypeOption[] = 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: MetricOption[] = useMemo(() => [ { 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]); const [slides, setSlides] = useState([]); const [editingSlide, setEditingSlide] = useState(null); const [previewMode, setPreviewMode] = useState(false); const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0); const channels = useMemo(() => getUniqueChannels(data), [data]); const museums = useMemo(() => getUniqueMuseums(data), [data]); const defaultSlideConfig: Omit = { title: 'Slide Title', chartType: 'trend', metric: 'revenue', startDate: '2026-01-01', endDate: '2026-01-31', channel: 'all', museum: 'all', showComparison: false }; const addSlide = () => { const newSlide: SlideConfig = { id: Date.now(), ...defaultSlideConfig, title: `Slide ${slides.length + 1}` }; setSlides([...slides, newSlide]); setEditingSlide(newSlide.id); }; const updateSlide = (id: number, updates: Partial) => { setSlides(slides.map(s => s.id === id ? { ...s, ...updates } : s)); }; const removeSlide = (id: number) => { setSlides(slides.filter(s => s.id !== id)); if (editingSlide === id) setEditingSlide(null); }; const moveSlide = (id: number, direction: number) => { 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: number) => { const slide = slides.find(s => s.id === id); if (slide) { const newSlide: SlideConfig = { ...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); }).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)} channels={channels} museums={museums} data={data} chartTypes={CHART_TYPES} metrics={METRICS} /> )}
); } function SlideEditor({ slide, onUpdate, channels, museums, data, chartTypes, metrics }: SlideEditorProps) { const { t } = useLanguage(); return (
onUpdate({ title: e.target.value })} placeholder={t('slides.slideTitle')} />
{chartTypes.map((type: ChartTypeOption) => ( ))}
onUpdate({ startDate: e.target.value })} />
onUpdate({ endDate: e.target.value })} />
{slide.chartType === 'comparison' && (
)}

{t('slides.preview')}

); } // Static field mapping for charts (Chart.js labels don't need i18n) const METRIC_FIELDS: Record = { revenue: { field: 'revenue_gross', label: 'Revenue' }, visitors: { field: 'visits', label: 'Visitors' }, tickets: { field: 'tickets', label: 'Tickets' } }; function SlidePreview({ slide, data, channels, museums, metrics }: SlidePreviewProps) { const { t } = useLanguage(); const filteredData = useMemo(() => filterDataByDateRange(data, slide.startDate, slide.endDate, { channel: slide.channel, museum: 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 = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' }; return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as unknown as Record)[fieldMap[metric]] || 0)), 0); }, []); const trendData = useMemo(() => { const grouped: Record = {}; 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: MetricOption) => 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: Record = {}; 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: MetricOption) => 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, channels, museums, currentSlide, setCurrentSlide, onExit, metrics }: PreviewModeProps) { const { t } = useLanguage(); const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'ArrowRight' || e.key === ' ') { setCurrentSlide((prev: number) => Math.min(prev + 1, slides.length - 1)); } else if (e.key === 'ArrowLeft') { setCurrentSlide((prev: number) => 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: SlideConfig, index: number, data: MuseumRecord[]): string { 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: SlideConfig, data: MuseumRecord[]): string { const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, { channel: slide.channel, museum: slide.museum }); const metrics = calculateMetrics(filtered); return `
${formatCompactCurrency(metrics.revenue)}
Revenue
${formatCompact(metrics.visitors)}
Visitors
${formatCompact(metrics.tickets)}
Tickets
`; } 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, { channel: slide.channel, museum: slide.museum }); const chartConfig = generateChartConfig(slide, filtered); return ` new Chart(document.getElementById('chart-${index}'), ${JSON.stringify(chartConfig)}); `; }).join('\n'); } function generateChartConfig(slide: SlideConfig, data: MuseumRecord[]): object { const fieldMap: Record = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' }; const field = fieldMap[slide.metric]; if (slide.chartType === 'museum-bar') { const byMuseum: Record = {}; data.forEach((row: MuseumRecord) => { if (!row.museum_name) return; byMuseum[row.museum_name] = (byMuseum[row.museum_name] || 0) + parseFloat(String(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: Record = {}; data.forEach((row: MuseumRecord) => { if (!row.date) return; grouped[row.date] = (grouped[row.date] || 0) + parseFloat(String(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: string, end: string): string { const s = new Date(start); const e = new Date(end); const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }; return `${s.toLocaleDateString('en-US', opts)} – ${e.toLocaleDateString('en-US', opts)}`; } export default Slides;