Add Slides builder, data source selector, fix cross-year labels

Features:
- Slides page: build presentations with configurable charts per slide
- Export slides as HTML zip
- Data source dropdown (Museums active, Coffees/eCommerce placeholders)
- Renamed app to 'HiHala Data' with 'Museums' as subtitle

Fixes:
- Cross-year period labels now show 'Nov 25–Jan 26' instead of misleading year
- Period display boxes updated to use smart labels

UI:
- New nav link for Slides
- Mobile nav updated
- Data source select styled to match brand
This commit is contained in:
fahed
2026-02-02 17:36:15 +03:00
parent 75a11170f6
commit a2e7aa16cd
10 changed files with 1358 additions and 81 deletions

594
src/components/Slides.js Normal file
View File

@@ -0,0 +1,594 @@
import React, { useState, useMemo, useCallback } from 'react';
import { Line, Bar } from 'react-chartjs-2';
import { chartColors, createBaseOptions } from '../config/chartConfig';
import {
filterDataByDateRange,
calculateMetrics,
formatCompact,
formatCompactCurrency,
getUniqueDistricts,
getDistrictMuseumMap,
getMuseumsForDistrict
} from '../services/dataService';
import JSZip from 'jszip';
const CHART_TYPES = [
{ id: 'trend', label: 'Revenue Trend', icon: '📈' },
{ id: 'museum-bar', label: 'By Museum', icon: '📊' },
{ id: 'kpi-cards', label: 'KPI Summary', icon: '🎯' },
{ id: 'comparison', label: 'YoY Comparison', icon: '⚖️' }
];
const METRICS = [
{ id: 'revenue', label: 'Revenue', field: 'revenue_incl_tax' },
{ id: 'visitors', label: 'Visitors', field: 'visits' },
{ id: 'tickets', label: 'Tickets', field: 'tickets' }
];
function Slides({ data }) {
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)}
/>
);
}
return (
<div className="slides-builder">
<div className="page-title">
<h1>Presentation Builder</h1>
<p>Create slides with charts and export as HTML or PDF</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>
Add Slide
</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>
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>
Export HTML
</button>
</>
)}
</div>
<div className="slides-workspace">
<div className="slides-list">
<h3>Slides ({slides.length})</h3>
{slides.length === 0 ? (
<div className="empty-slides">
<p>No slides yet</p>
<button onClick={addSlide}>Add your first slide</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}
/>
)}
</div>
</div>
);
}
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data }) {
const availableMuseums = useMemo(() =>
getMuseumsForDistrict(districtMuseumMap, slide.district),
[districtMuseumMap, slide.district]
);
return (
<div className="slide-editor">
<div className="editor-section">
<label>Slide Title</label>
<input
type="text"
value={slide.title}
onChange={e => onUpdate({ title: e.target.value })}
placeholder="Enter slide title"
/>
</div>
<div className="editor-section">
<label>Chart Type</label>
<div className="chart-type-grid">
{CHART_TYPES.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>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>Start Date</label>
<input type="date" value={slide.startDate} onChange={e => onUpdate({ startDate: e.target.value })} />
</div>
<div className="editor-section">
<label>End Date</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>District</label>
<select value={slide.district} onChange={e => onUpdate({ district: e.target.value, museum: 'all' })}>
<option value="all">All Districts</option>
{districts.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
<div className="editor-section">
<label>Museum</label>
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
<option value="all">All Museums</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 })}
/>
Show Year-over-Year Comparison
</label>
</div>
)}
<div className="slide-preview-box">
<h4>Preview</h4>
<SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} />
</div>
</div>
);
}
function SlidePreview({ slide, data, districts, districtMuseumMap }) {
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 metrics = 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();
return {
labels: sortedDates.map(d => d.substring(5)),
datasets: [{
label: METRICS.find(m => m.id === slide.metric)?.label,
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]);
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();
return {
labels: museums,
datasets: [{
label: METRICS.find(m => m.id === slide.metric)?.label,
data: museums.map(m => getMetricValue(byMuseum[m], slide.metric)),
backgroundColor: chartColors.primary,
borderRadius: 6
}]
};
}, [filteredData, slide.metric, getMetricValue]);
if (slide.chartType === 'kpi-cards') {
return (
<div className="preview-kpis">
<div className="preview-kpi">
<div className="kpi-value">{formatCompactCurrency(metrics.revenue)}</div>
<div className="kpi-label">Revenue</div>
</div>
<div className="preview-kpi">
<div className="kpi-value">{formatCompact(metrics.visitors)}</div>
<div className="kpi-label">Visitors</div>
</div>
<div className="preview-kpi">
<div className="kpi-value">{formatCompact(metrics.tickets)}</div>
<div className="kpi-label">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 }) {
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} />}
</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}>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, 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;