Files
hihala-dashboard/src/components/Slides.tsx
fahed 868f46fc6e chore: migrate to TypeScript
- Convert all .js files to .tsx/.ts
- Add types for data structures (MuseumRecord, Metrics, etc.)
- Add type declarations for react-chartjs-2
- Configure tsconfig with relaxed strictness for gradual adoption
- All components now use TypeScript
2026-02-04 13:45:50 +03:00

612 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HiHala Data Presentation</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f172a; }
.slide {
width: 100vw; height: 100vh;
display: flex; flex-direction: column;
justify-content: center; align-items: center;
padding: 60px; background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
page-break-after: always;
}
.slide-title {
color: #f8fafc; font-size: 2.5rem; font-weight: 600;
margin-bottom: 40px; text-align: center;
}
.slide-subtitle {
color: #94a3b8; font-size: 1.1rem; margin-bottom: 30px;
}
.chart-container {
width: 100%; max-width: 900px; height: 400px;
background: rgba(255,255,255,0.03); border-radius: 16px;
padding: 30px;
}
.kpi-grid {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: 30px; width: 100%; max-width: 900px;
}
.kpi-card {
background: rgba(255,255,255,0.05); border-radius: 16px;
padding: 30px; text-align: center;
}
.kpi-value { color: #3b82f6; font-size: 2.5rem; font-weight: 700; }
.kpi-label { color: #94a3b8; font-size: 1rem; margin-top: 8px; }
.logo { position: absolute; bottom: 30px; right: 40px; opacity: 0.6; }
.logo svg { height: 30px; }
.slide-number {
position: absolute; bottom: 30px; left: 40px;
color: #475569; font-size: 0.9rem;
}
@media print {
.slide { page-break-after: always; }
}
</style>
</head>
<body>
${slidesHTML}
<script>
// Chart.js initialization scripts will be here
${generateChartScripts(slides, data, districts, districtMuseumMap)}
</script>
</body>
</html>`;
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 (
<PreviewMode
slides={slides}
data={data}
districts={districts}
districtMuseumMap={districtMuseumMap}
currentSlide={currentPreviewSlide}
setCurrentSlide={setCurrentPreviewSlide}
onExit={() => setPreviewMode(false)}
metrics={METRICS}
/>
);
}
return (
<div className="slides-builder">
<div className="page-title">
<h1>{t('slides.title')}</h1>
<p>{t('slides.subtitle')}</p>
</div>
<div className="slides-toolbar">
<button className="btn-primary" onClick={addSlide}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{t('slides.addSlide')}
</button>
{slides.length > 0 && (
<>
<button className="btn-secondary" onClick={() => setPreviewMode(true)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
{t('slides.preview')}
</button>
<button className="btn-secondary" onClick={exportAsHTML}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
{t('slides.exportHtml')}
</button>
</>
)}
</div>
<div className="slides-workspace">
<div className="slides-list">
<h3>{t('slides.slidesCount')} ({slides.length})</h3>
{slides.length === 0 ? (
<div className="empty-slides">
<p>{t('slides.noSlides')}</p>
<button onClick={addSlide}>{t('slides.addFirst')}</button>
</div>
) : (
<div className="slides-thumbnails">
{slides.map((slide, index) => (
<div
key={slide.id}
className={`slide-thumbnail ${editingSlide === slide.id ? 'active' : ''}`}
onClick={() => setEditingSlide(slide.id)}
>
<div className="slide-number">{index + 1}</div>
<div className="slide-icon">{CHART_TYPES.find(c => c.id === slide.chartType)?.icon}</div>
<div className="slide-title-preview">{slide.title}</div>
<div className="slide-actions">
<button onClick={(e) => { e.stopPropagation(); moveSlide(slide.id, -1); }} disabled={index === 0}></button>
<button onClick={(e) => { e.stopPropagation(); moveSlide(slide.id, 1); }} disabled={index === slides.length - 1}></button>
<button onClick={(e) => { e.stopPropagation(); duplicateSlide(slide.id); }}></button>
<button onClick={(e) => { e.stopPropagation(); removeSlide(slide.id); }} className="delete">×</button>
</div>
</div>
))}
</div>
)}
</div>
{editingSlide && (
<SlideEditor
slide={slides.find(s => s.id === editingSlide)}
onUpdate={(updates) => updateSlide(editingSlide, updates)}
districts={districts}
districtMuseumMap={districtMuseumMap}
data={data}
chartTypes={CHART_TYPES}
metrics={METRICS}
/>
)}
</div>
</div>
);
}
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, chartTypes, metrics }) {
const { t } = useLanguage();
const availableMuseums = useMemo(() =>
getMuseumsForDistrict(districtMuseumMap, slide.district),
[districtMuseumMap, slide.district]
);
return (
<div className="slide-editor">
<div className="editor-section">
<label>{t('slides.slideTitle')}</label>
<input
type="text"
value={slide.title}
onChange={e => onUpdate({ title: e.target.value })}
placeholder={t('slides.slideTitle')}
/>
</div>
<div className="editor-section">
<label>{t('slides.chartType')}</label>
<div className="chart-type-grid">
{chartTypes.map(type => (
<button
key={type.id}
className={`chart-type-btn ${slide.chartType === type.id ? 'active' : ''}`}
onClick={() => onUpdate({ chartType: type.id })}
>
<span className="chart-icon">{type.icon}</span>
<span>{type.label}</span>
</button>
))}
</div>
</div>
<div className="editor-section">
<label>{t('slides.metric')}</label>
<select value={slide.metric} onChange={e => onUpdate({ metric: e.target.value })}>
{metrics.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</div>
<div className="editor-row">
<div className="editor-section">
<label>{t('slides.startDate')}</label>
<input type="date" value={slide.startDate} onChange={e => onUpdate({ startDate: e.target.value })} />
</div>
<div className="editor-section">
<label>{t('slides.endDate')}</label>
<input type="date" value={slide.endDate} onChange={e => onUpdate({ endDate: e.target.value })} />
</div>
</div>
<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 => <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 => <option key={m} value={m}>{m}</option>)}
</select>
</div>
</div>
{slide.chartType === 'comparison' && (
<div className="editor-section">
<label>
<input
type="checkbox"
checked={slide.showComparison}
onChange={e => onUpdate({ showComparison: e.target.checked })}
/>
{t('slides.showYoY')}
</label>
</div>
)}
<div className="slide-preview-box">
<h4>{t('slides.preview')}</h4>
<SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} metrics={metrics} />
</div>
</div>
);
}
// 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 (
<div className="preview-kpis">
<div className="preview-kpi">
<div className="kpi-value">{formatCompactCurrency(metricsData.revenue)}</div>
<div className="kpi-label">{t('metrics.revenue')}</div>
</div>
<div className="preview-kpi">
<div className="kpi-value">{formatCompact(metricsData.visitors)}</div>
<div className="kpi-label">{t('metrics.visitors')}</div>
</div>
<div className="preview-kpi">
<div className="kpi-value">{formatCompact(metricsData.tickets)}</div>
<div className="kpi-label">{t('metrics.tickets')}</div>
</div>
</div>
);
}
if (slide.chartType === 'museum-bar') {
return (
<div className="preview-chart">
<Bar data={museumData} options={{ ...baseOptions, indexAxis: 'y' }} />
</div>
);
}
return (
<div className="preview-chart">
<Line data={trendData} options={baseOptions} />
</div>
);
}
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 (
<div className="preview-fullscreen">
<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} />}
</div>
<div className="preview-footer">
<span>{currentSlide + 1} / {slides.length}</span>
</div>
</div>
<div className="preview-controls">
<button onClick={() => setCurrentSlide(prev => 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={onExit}>{t('slides.exit')}</button>
</div>
</div>
);
}
// Helper functions for HTML export
function generateSlideHTML(slide, index, data, districts, districtMuseumMap) {
const chartType = slide.chartType;
const canvasId = `chart-${index}`;
return `
<div class="slide" id="slide-${index}">
<h1 class="slide-title">${slide.title}</h1>
<p class="slide-subtitle">${formatDateRange(slide.startDate, slide.endDate)}</p>
${chartType === 'kpi-cards' ? generateKPIHTML(slide, data) : `<div class="chart-container"><canvas id="${canvasId}"></canvas></div>`}
<div class="slide-number">Slide ${index + 1}</div>
<div class="logo">
<svg width="120" height="24" viewBox="0 0 120 24">
<text x="0" y="18" fill="#64748b" font-family="system-ui" font-size="14" font-weight="600">HiHala Data</text>
</svg>
</div>
</div>`;
}
function generateKPIHTML(slide, data) {
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
district: slide.district,
museum: slide.museum
});
const metrics = calculateMetrics(filtered);
return `
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-value">${formatCompactCurrency(metrics.revenue)}</div>
<div class="kpi-label">Revenue</div>
</div>
<div class="kpi-card">
<div class="kpi-value">${formatCompact(metrics.visitors)}</div>
<div class="kpi-label">Visitors</div>
</div>
<div class="kpi-card">
<div class="kpi-value">${formatCompact(metrics.tickets)}</div>
<div class="kpi-label">Tickets</div>
</div>
</div>`;
}
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: 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;