Files
hihala-dashboard/src/components/Slides.tsx
fahed f6b7d4ba8d 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>
2026-03-26 16:43:34 +03:00

644 lines
22 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,
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<SlideConfig>) => 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<React.SetStateAction<number>>;
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<SlideConfig[]>([]);
const [editingSlide, setEditingSlide] = useState<number | null>(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<SlideConfig, 'id'> = {
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<SlideConfig>) => {
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 = `<!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)}
</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}
channels={channels}
museums={museums}
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)}
channels={channels}
museums={museums}
data={data}
chartTypes={CHART_TYPES}
metrics={METRICS}
/>
)}
</div>
</div>
);
}
function SlideEditor({ slide, onUpdate, channels, museums, data, chartTypes, metrics }: SlideEditorProps) {
const { t } = useLanguage();
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: ChartTypeOption) => (
<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: MetricOption) => <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.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>
{museums.map((m: string) => <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} channels={channels} museums={museums} metrics={metrics} />
</div>
</div>
);
}
// Static field mapping for charts (Chart.js labels don't need i18n)
const METRIC_FIELDS: Record<string, MetricFieldInfo> = {
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<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);
}, []);
const trendData = useMemo(() => {
const grouped: Record<string, MuseumRecord[]> = {};
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<string, MuseumRecord[]> = {};
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 (
<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, 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 (
<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} channels={channels} museums={museums} metrics={metrics} />}
</div>
<div className="preview-footer">
<span>{currentSlide + 1} / {slides.length}</span>
</div>
</div>
<div className="preview-controls">
<button onClick={() => setCurrentSlide((prev: number) => Math.max(prev - 1, 0))} disabled={currentSlide === 0}></button>
<button onClick={() => setCurrentSlide((prev: number) => 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: SlideConfig, index: number, data: MuseumRecord[]): string {
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: SlideConfig, data: MuseumRecord[]): string {
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
channel: slide.channel,
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: 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<string, keyof MuseumRecord> = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' };
const field = fieldMap[slide.metric];
if (slide.chartType === 'museum-bar') {
const byMuseum: Record<string, number> = {};
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<string, number> = {};
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;