Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9cfb58896 | |||
| 30cdb5064a | |||
| 25cb91e31b | |||
| ef9a960e5d | |||
| 9138ac1098 | |||
| d3f9a6cd43 | |||
| 36df0065ed | |||
| c8c3465233 | |||
| 0f6881309c | |||
| 9064df82be | |||
| ac32a541a1 |
+1
-1
@@ -8,7 +8,7 @@
|
||||
<meta name="description" content="HiHala Data Dashboard — Event analytics, visitor tracking, and revenue insights" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap">
|
||||
<title>HiHala Data</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { fetchSales, isConfigured } from '../services/erpClient';
|
||||
import { etl } from '../config';
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
||||
+38
-14
@@ -1,34 +1,58 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { etl } from '../config';
|
||||
import { runSync } from '../services/etlSync';
|
||||
import { runSync, SyncResult } from '../services/etlSync';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/etl/sync?mode=full|incremental
|
||||
router.post('/sync', async (req: Request, res: Response) => {
|
||||
// Auth check
|
||||
const auth = req.headers.authorization;
|
||||
if (etl.secret && auth !== `Bearer ${etl.secret}`) {
|
||||
type SyncState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'running'; mode: string; startedAt: string; currentMonth?: string }
|
||||
| { status: 'done'; result: SyncResult; finishedAt: string }
|
||||
| { status: 'error'; error: string; finishedAt: string };
|
||||
|
||||
let syncState: SyncState = { status: 'idle' };
|
||||
|
||||
function auth(req: Request, res: Response): boolean {
|
||||
const header = req.headers.authorization;
|
||||
if (etl.secret && header !== `Bearer ${etl.secret}`) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/etl/sync?mode=full|incremental — fires and returns immediately
|
||||
router.post('/sync', (req: Request, res: Response) => {
|
||||
if (!auth(req, res)) return;
|
||||
|
||||
if (syncState.status === 'running') {
|
||||
res.status(409).json({ error: 'Sync already running', state: syncState });
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = (req.query.mode as string) === 'full' ? 'full' : 'incremental';
|
||||
syncState = { status: 'running', mode, startedAt: new Date().toISOString() };
|
||||
|
||||
res.json({ accepted: true, mode, message: 'Sync started — poll GET /api/etl/status for progress' });
|
||||
|
||||
try {
|
||||
console.log(`\nETL sync started (${mode})...`);
|
||||
const result = await runSync(mode);
|
||||
runSync(mode, (month) => {
|
||||
if (syncState.status === 'running') syncState = { ...syncState, currentMonth: month };
|
||||
})
|
||||
.then(result => {
|
||||
console.log(`ETL sync complete: ${result.transactionsFetched} transactions → ${result.recordsWritten} records in ${result.duration}`);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
syncState = { status: 'done', result, finishedAt: new Date().toISOString() };
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('ETL sync failed:', (err as Error).message);
|
||||
res.status(500).json({ error: 'ETL sync failed', details: (err as Error).message });
|
||||
}
|
||||
syncState = { status: 'error', error: (err as Error).message, finishedAt: new Date().toISOString() };
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/etl/status
|
||||
router.get('/status', (_req: Request, res: Response) => {
|
||||
res.json({ configured: !!etl.secret });
|
||||
router.get('/status', (req: Request, res: Response) => {
|
||||
if (!auth(req, res)) return;
|
||||
res.json(syncState);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -77,7 +77,7 @@ export function aggregateTransactions(sales: ERPSaleRecord[]): AggregatedRecord[
|
||||
}
|
||||
|
||||
const visitors = isB2C ? product.UnitQuantity : product.PeopleCount;
|
||||
entry.Visits += visitors * split;
|
||||
entry.Visits += visitors; // each museum in a combo counts the full visitor
|
||||
entry.Tickets += product.UnitQuantity * split;
|
||||
entry.GrossRevenue += product.TotalPrice * split;
|
||||
entry.NetRevenue += (product.TotalPrice - product.TaxAmount) * split;
|
||||
@@ -96,7 +96,7 @@ export interface SyncResult {
|
||||
duration: string;
|
||||
}
|
||||
|
||||
export async function runSync(mode: 'full' | 'incremental' = 'incremental'): Promise<SyncResult> {
|
||||
export async function runSync(mode: 'full' | 'incremental' = 'incremental', onMonth?: (month: string) => void): Promise<SyncResult> {
|
||||
const start = Date.now();
|
||||
|
||||
const tables = await discoverTableIds();
|
||||
@@ -113,7 +113,9 @@ export async function runSync(mode: 'full' | 'incremental' = 'incremental'): Pro
|
||||
// Fetch from ERP sequentially (API can't handle concurrent requests)
|
||||
const allSales: ERPSaleRecord[] = [];
|
||||
for (const [startDate, endDate] of months) {
|
||||
console.log(` Fetching ${startDate.slice(0, 7)}...`);
|
||||
const monthLabel = startDate.slice(0, 7);
|
||||
console.log(` Fetching ${monthLabel}...`);
|
||||
onMonth?.(monthLabel);
|
||||
const chunk = await fetchSales(startDate, endDate) as ERPSaleRecord[];
|
||||
allSales.push(...chunk);
|
||||
}
|
||||
|
||||
+984
-526
File diff suppressed because it is too large
Load Diff
+28
-10
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, ReactNode, lazy, Suspense } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
|
||||
const Dashboard = lazy(() => import('./components/Dashboard'));
|
||||
const Comparison = lazy(() => import('./components/Comparison'));
|
||||
const Settings = lazy(() => import('./components/Settings'));
|
||||
const Comparison = lazy(() => import('./components/Comparison'));
|
||||
const Dashboard = lazy(() => import('./components/Dashboard'));
|
||||
import Login from './components/Login';
|
||||
import LoadingSkeleton from './components/shared/LoadingSkeleton';
|
||||
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService';
|
||||
@@ -30,6 +30,10 @@ function NavLink({ to, children, className }: NavLinkProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function AppNav({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface DataSource {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
@@ -51,7 +55,6 @@ function App() {
|
||||
const [error, setError] = useState<{ message: string; type: DataErrorType } | null>(null);
|
||||
const [isOffline, setIsOffline] = useState<boolean>(false);
|
||||
const [cacheInfo, setCacheInfo] = useState<CacheStatus | null>(null);
|
||||
const [showDataLabels, setShowDataLabels] = useState<boolean>(false);
|
||||
const [includeVAT, setIncludeVAT] = useState<boolean>(true);
|
||||
const [dataSource, setDataSource] = useState<string>('museums');
|
||||
const [seasons, setSeasons] = useState<Season[]>([]);
|
||||
@@ -188,7 +191,7 @@ function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="app" dir={dir}>
|
||||
<nav className="nav-bar" aria-label={t('nav.dashboard')}>
|
||||
<AppNav><nav className="nav-bar" aria-label={t('nav.dashboard')}>
|
||||
<div className="nav-content">
|
||||
<div className="nav-brand">
|
||||
<svg className="nav-brand-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
@@ -198,7 +201,8 @@ function App() {
|
||||
<rect x="14" y="11" width="7" height="10" rx="1"/>
|
||||
</svg>
|
||||
<span className="nav-brand-text">
|
||||
HiHala Data
|
||||
<span className="nav-brand-name">HiHala</span>
|
||||
<span className="nav-brand-tag">Data</span>
|
||||
<select
|
||||
className="data-source-select"
|
||||
value={dataSource}
|
||||
@@ -233,6 +237,15 @@ function App() {
|
||||
</svg>
|
||||
{t('nav.comparison')}
|
||||
</NavLink>
|
||||
{userRole === 'admin' && (
|
||||
<NavLink to="/settings">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
{t('nav.settings')}
|
||||
</NavLink>
|
||||
)}
|
||||
<span className="nav-sep" aria-hidden="true" />
|
||||
{isOffline && (
|
||||
<span className="offline-badge" title={cacheInfo ? `Cached: ${new Date(cacheInfo.timestamp || '').toLocaleString()}` : ''}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -245,6 +258,11 @@ function App() {
|
||||
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
||||
</svg>
|
||||
{t('app.offline') || 'Offline'}
|
||||
{cacheInfo && (
|
||||
<span className="sr-only">
|
||||
{` (cached ${new Date(cacheInfo.timestamp || '').toLocaleString()})`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
@@ -291,20 +309,20 @@ function App() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav></AppNav>
|
||||
|
||||
<main>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard data={data} seasons={seasons} userRole={userRole} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||
<Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
<nav className="mobile-nav" aria-label="Mobile navigation">
|
||||
<AppNav><nav className="mobile-nav" aria-label="Mobile navigation">
|
||||
<NavLink to="/" className="mobile-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1"/>
|
||||
@@ -342,7 +360,7 @@ function App() {
|
||||
</svg>
|
||||
<span>{t('language.switch')}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</nav></AppNav>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import React, { useRef, useState, ReactNode } from 'react';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
interface ExportableChartProps {
|
||||
children: ReactNode;
|
||||
filename?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
controls?: ReactNode;
|
||||
}
|
||||
|
||||
// Wrapper component that adds PNG export to any chart
|
||||
export function ExportableChart({
|
||||
children,
|
||||
filename = 'chart',
|
||||
title = '',
|
||||
className = '',
|
||||
controls = null
|
||||
}: ExportableChartProps) {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const exportAsPNG = () => {
|
||||
const chartContainer = chartRef.current;
|
||||
if (!chartContainer) return;
|
||||
|
||||
const canvas = chartContainer.querySelector('canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
// Create a new canvas with white background and title
|
||||
const exportCanvas = document.createElement('canvas');
|
||||
const ctx = exportCanvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set dimensions with padding and title space
|
||||
const padding = 24;
|
||||
const titleHeight = title ? 48 : 0;
|
||||
exportCanvas.width = canvas.width + (padding * 2);
|
||||
exportCanvas.height = canvas.height + (padding * 2) + titleHeight;
|
||||
|
||||
// Fill white background
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
||||
|
||||
// Draw title if provided (left-aligned, matching on-screen style)
|
||||
if (title) {
|
||||
ctx.fillStyle = '#1e293b';
|
||||
ctx.font = '600 20px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(title, padding, padding + 24);
|
||||
}
|
||||
|
||||
// Draw the chart
|
||||
ctx.drawImage(canvas, padding, padding + titleHeight);
|
||||
|
||||
// Export
|
||||
const link = document.createElement('a');
|
||||
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.png`;
|
||||
link.href = exportCanvas.toDataURL('image/png', 1.0);
|
||||
link.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="exportable-chart-wrapper">
|
||||
{/* Download button - positioned absolutely in corner */}
|
||||
<button
|
||||
className="chart-export-btn visible"
|
||||
onClick={exportAsPNG}
|
||||
title="Download as PNG"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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>
|
||||
</button>
|
||||
{title && (
|
||||
<div className="chart-header-with-export">
|
||||
<h2>{title}</h2>
|
||||
{controls && <div className="chart-header-actions">{controls}</div>}
|
||||
</div>
|
||||
)}
|
||||
{!title && controls && <div className="chart-controls">{controls}</div>}
|
||||
<div className={`exportable-chart ${className}`}>
|
||||
<div ref={chartRef} className="chart-canvas-wrapper">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Utility function to export all charts from a container as a ZIP
|
||||
export async function exportAllCharts(containerSelector: string, zipFilename: string = 'charts'): Promise<void> {
|
||||
const container = document.querySelector(containerSelector);
|
||||
if (!container) return;
|
||||
|
||||
const zip = new JSZip();
|
||||
const chartWrappers = container.querySelectorAll('.exportable-chart-wrapper');
|
||||
|
||||
for (let i = 0; i < chartWrappers.length; i++) {
|
||||
const wrapper = chartWrappers[i];
|
||||
const canvas = wrapper.querySelector('canvas');
|
||||
const titleEl = wrapper.querySelector('.chart-header-with-export h2');
|
||||
const title = titleEl?.textContent || `chart-${i + 1}`;
|
||||
|
||||
if (!canvas) continue;
|
||||
|
||||
// Create export canvas with white background and title
|
||||
const exportCanvas = document.createElement('canvas');
|
||||
const ctx = exportCanvas.getContext('2d');
|
||||
if (!ctx) continue;
|
||||
|
||||
const padding = 32;
|
||||
const titleHeight = 56;
|
||||
exportCanvas.width = canvas.width + (padding * 2);
|
||||
exportCanvas.height = canvas.height + (padding * 2) + titleHeight;
|
||||
|
||||
// White background
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
||||
|
||||
// Draw title
|
||||
ctx.fillStyle = '#1e293b';
|
||||
ctx.font = '600 24px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(title, padding, padding + 28);
|
||||
|
||||
// Draw chart
|
||||
ctx.drawImage(canvas, padding, padding + titleHeight);
|
||||
|
||||
// Convert to blob and add to zip
|
||||
const dataUrl = exportCanvas.toDataURL('image/png', 1.0);
|
||||
const base64Data = dataUrl.split(',')[1];
|
||||
const safeFilename = title.replace(/[^a-zA-Z0-9\u0600-\u06FF\s-]/g, '').replace(/\s+/g, '-');
|
||||
zip.file(`${String(i + 1).padStart(2, '0')}-${safeFilename}.png`, base64Data, { base64: true });
|
||||
}
|
||||
|
||||
// Generate and download ZIP
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${zipFilename}-${new Date().toISOString().split('T')[0]}.zip`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
interface ExportAllButtonProps {
|
||||
containerSelector: string;
|
||||
zipFilename?: string;
|
||||
label: string;
|
||||
loadingLabel: string;
|
||||
}
|
||||
|
||||
// Button component for exporting all charts
|
||||
export function ExportAllButton({ containerSelector, zipFilename = 'charts', label, loadingLabel }: ExportAllButtonProps) {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportAllCharts(containerSelector, zipFilename);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn-export-all"
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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>
|
||||
{exporting ? loadingLabel : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExportableChart;
|
||||
+229
-914
File diff suppressed because it is too large
Load Diff
+272
-980
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,9 @@ function Login({ onLogin }: LoginProps) {
|
||||
<p className="login-subtitle">{t('login.subtitle')}</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label htmlFor="pin-input" className="sr-only">{t('login.placeholder')}</label>
|
||||
<input
|
||||
id="pin-input"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
value={pin}
|
||||
|
||||
+88
-121
@@ -6,13 +6,13 @@ import type { Season } from '../types';
|
||||
|
||||
const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899'];
|
||||
|
||||
interface SeasonRowProps {
|
||||
interface SeasonItemProps {
|
||||
season: Season;
|
||||
onSave: (id: number, data: Partial<Season>) => Promise<void>;
|
||||
onDelete: (id: number) => Promise<void>;
|
||||
}
|
||||
|
||||
function SeasonRow({ season, onSave, onDelete }: SeasonRowProps) {
|
||||
function SeasonItem({ season, onSave, onDelete }: SeasonItemProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState(season);
|
||||
|
||||
@@ -21,48 +21,46 @@ function SeasonRow({ season, onSave, onDelete }: SeasonRowProps) {
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<div className={`settings-item${editing ? ' settings-item--editing' : ''}`}>
|
||||
<div className="settings-item-row">
|
||||
<span className="season-chip" style={{ backgroundColor: season.Color + '20', color: season.Color, borderColor: season.Color }}>
|
||||
{season.Name} {season.HijriYear}
|
||||
</span>
|
||||
</td>
|
||||
<td>{season.StartDate}</td>
|
||||
<td>{season.EndDate}</td>
|
||||
<td>
|
||||
<div className="season-actions">
|
||||
<button className="btn-small" onClick={() => setEditing(true)}>Edit</button>
|
||||
<span className="settings-dates">{season.StartDate} → {season.EndDate}</span>
|
||||
<div className="settings-item-actions">
|
||||
<button className="btn-small" onClick={() => { setForm(season); setEditing(true); }}>Edit</button>
|
||||
<button className="btn-small btn-danger" onClick={() => onDelete(season.Id!)}>Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="editing">
|
||||
<td>
|
||||
<div className="season-edit-name">
|
||||
<input type="text" value={form.Name} onChange={e => setForm({ ...form, Name: e.target.value })} placeholder="Name" />
|
||||
<input type="number" value={form.HijriYear} onChange={e => setForm({ ...form, HijriYear: parseInt(e.target.value) || 0 })} placeholder="Year" style={{ width: 80 }} />
|
||||
<input type="color" value={form.Color} onChange={e => setForm({ ...form, Color: e.target.value })} />
|
||||
</div>
|
||||
</td>
|
||||
<td><input type="date" value={form.StartDate} onChange={e => setForm({ ...form, StartDate: e.target.value })} /></td>
|
||||
<td><input type="date" value={form.EndDate} onChange={e => setForm({ ...form, EndDate: e.target.value })} /></td>
|
||||
<td>
|
||||
<div className="season-actions">
|
||||
{editing && (
|
||||
<div className="settings-item-form">
|
||||
<div className="form-row">
|
||||
<input className="form-input" type="text" value={form.Name} onChange={e => setForm({ ...form, Name: e.target.value })} placeholder="Name" />
|
||||
<input className="form-input form-input--sm" type="number" value={form.HijriYear} onChange={e => setForm({ ...form, HijriYear: parseInt(e.target.value) || 0 })} placeholder="Hijri Year" />
|
||||
<input type="color" className="form-color" value={form.Color} onChange={e => setForm({ ...form, Color: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
<span className="form-label">Start</span>
|
||||
<input className="form-input" type="date" value={form.StartDate} onChange={e => setForm({ ...form, StartDate: e.target.value })} />
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<span className="form-label">End</span>
|
||||
<input className="form-input" type="date" value={form.EndDate} onChange={e => setForm({ ...form, EndDate: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
|
||||
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UserRowProps {
|
||||
interface UserItemProps {
|
||||
user: User;
|
||||
allMuseums: string[];
|
||||
allChannels: string[];
|
||||
@@ -70,7 +68,7 @@ interface UserRowProps {
|
||||
onDelete: (id: number) => Promise<void>;
|
||||
}
|
||||
|
||||
function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowProps) {
|
||||
function UserItem({ user, allMuseums, allChannels, onUpdate, onDelete }: UserItemProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [allowedMuseums, setAllowedMuseums] = useState<string[]>(() => {
|
||||
try { return JSON.parse(user.AllowedMuseums || '[]'); } catch { return []; }
|
||||
@@ -91,17 +89,18 @@ function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowP
|
||||
};
|
||||
|
||||
const isAdmin = user.Role === 'admin';
|
||||
|
||||
const museumCount = (() => { try { const a = JSON.parse(user.AllowedMuseums || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
|
||||
const channelCount = (() => { try { const a = JSON.parse(user.AllowedChannels || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<tr key={user.Id}>
|
||||
<td>{user.Name}</td>
|
||||
<td><code>{user.PIN}</code></td>
|
||||
<td>{user.Role}</td>
|
||||
<td>
|
||||
<div className={`settings-item${editing ? ' settings-item--editing' : ''}`}>
|
||||
<div className="settings-item-row">
|
||||
<div className="settings-user-info">
|
||||
<span className="settings-user-name">{user.Name}</span>
|
||||
<code className="settings-user-pin">{user.PIN}</code>
|
||||
<span className="settings-user-role">{user.Role}</span>
|
||||
</div>
|
||||
<div className="settings-user-access">
|
||||
{isAdmin ? (
|
||||
<span className="access-badge access-badge--full">Full access</span>
|
||||
) : (
|
||||
@@ -110,53 +109,45 @@ function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowP
|
||||
<span className="access-badge">{channelCount === 0 ? 'All channels' : `${channelCount} channels`}</span>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="season-actions">
|
||||
</div>
|
||||
<div className="settings-item-actions">
|
||||
{!isAdmin && <button className="btn-small" onClick={() => setEditing(true)}>Edit access</button>}
|
||||
<button className="btn-small btn-danger" onClick={() => onDelete(user.Id!)}>Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="editing">
|
||||
<td colSpan={5}>
|
||||
<div style={{ padding: '12px 4px' }}>
|
||||
<strong>{user.Name}</strong>
|
||||
<div style={{ display: 'flex', gap: 32, marginTop: 12, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||
Allowed Events {allowedMuseums.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="settings-item-form">
|
||||
<div className="access-columns">
|
||||
<div className="access-col">
|
||||
<div className="access-col-title">
|
||||
Events {allowedMuseums.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
||||
</div>
|
||||
{allMuseums.map(m => (
|
||||
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, cursor: 'pointer' }}>
|
||||
<label key={m} className="access-check">
|
||||
<input type="checkbox" checked={allowedMuseums.includes(m)} onChange={() => toggleItem(allowedMuseums, setAllowedMuseums, m)} />
|
||||
{m}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||
Allowed Channels {allowedChannels.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
||||
<div className="access-col">
|
||||
<div className="access-col-title">
|
||||
Channels {allowedChannels.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
||||
</div>
|
||||
{allChannels.map(c => (
|
||||
<label key={c} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, cursor: 'pointer' }}>
|
||||
<label key={c} className="access-check">
|
||||
<input type="checkbox" checked={allowedChannels.includes(c)} onChange={() => toggleItem(allowedChannels, setAllowedChannels, c)} />
|
||||
{c}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||||
<div className="form-actions">
|
||||
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
|
||||
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,7 +164,7 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
||||
|
||||
const [newSeason, setNewSeason] = useState<Omit<Season, 'Id'>>({
|
||||
Name: '',
|
||||
HijriYear: new Date().getFullYear() - 579, // rough Gregorian → Hijri
|
||||
HijriYear: new Date().getFullYear() - 579,
|
||||
StartDate: '',
|
||||
EndDate: '',
|
||||
Color: DEFAULT_COLORS[0],
|
||||
@@ -238,42 +229,36 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
||||
<h2>{t('settings.seasons')}</h2>
|
||||
<p className="settings-hint">{t('settings.seasonsHint')}</p>
|
||||
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('settings.seasonName')}</th>
|
||||
<th>{t('settings.startDate')}</th>
|
||||
<th>{t('settings.endDate')}</th>
|
||||
<th>{t('settings.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div className="settings-list">
|
||||
{loading ? (
|
||||
<tr><td colSpan={4} style={{ textAlign: 'center', padding: 24 }}>Loading...</td></tr>
|
||||
<div className="settings-loading">Loading...</div>
|
||||
) : (
|
||||
seasons.map(s => (
|
||||
<SeasonRow key={s.Id} season={s} onSave={handleSave} onDelete={handleDelete} />
|
||||
<SeasonItem key={s.Id} season={s} onSave={handleSave} onDelete={handleDelete} />
|
||||
))
|
||||
)}
|
||||
<tr className="add-row">
|
||||
<td>
|
||||
<div className="season-edit-name">
|
||||
<input type="text" value={newSeason.Name} onChange={e => setNewSeason({ ...newSeason, Name: e.target.value })} placeholder={t('settings.namePlaceholder')} />
|
||||
<input type="number" value={newSeason.HijriYear} onChange={e => setNewSeason({ ...newSeason, HijriYear: parseInt(e.target.value) || 0 })} style={{ width: 80 }} />
|
||||
<input type="color" value={newSeason.Color} onChange={e => setNewSeason({ ...newSeason, Color: e.target.value })} />
|
||||
</div>
|
||||
</td>
|
||||
<td><input type="date" value={newSeason.StartDate} onChange={e => setNewSeason({ ...newSeason, StartDate: e.target.value })} /></td>
|
||||
<td><input type="date" value={newSeason.EndDate} onChange={e => setNewSeason({ ...newSeason, EndDate: e.target.value })} /></td>
|
||||
<td>
|
||||
|
||||
<div className="settings-add-form">
|
||||
<div className="settings-add-title">{t('settings.add')} Season</div>
|
||||
<div className="form-row">
|
||||
<input className="form-input" type="text" value={newSeason.Name} onChange={e => setNewSeason({ ...newSeason, Name: e.target.value })} placeholder={t('settings.namePlaceholder')} />
|
||||
<input className="form-input form-input--sm" type="number" value={newSeason.HijriYear} onChange={e => setNewSeason({ ...newSeason, HijriYear: parseInt(e.target.value) || 0 })} />
|
||||
<input type="color" className="form-color" value={newSeason.Color} onChange={e => setNewSeason({ ...newSeason, Color: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
<span className="form-label">{t('settings.startDate')}</span>
|
||||
<input className="form-input" type="date" value={newSeason.StartDate} onChange={e => setNewSeason({ ...newSeason, StartDate: e.target.value })} />
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<span className="form-label">{t('settings.endDate')}</span>
|
||||
<input className="form-input" type="date" value={newSeason.EndDate} onChange={e => setNewSeason({ ...newSeason, EndDate: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<button className="btn-small btn-primary" onClick={handleCreate} disabled={!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate}>
|
||||
{t('settings.add')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -281,20 +266,9 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
||||
<h2>{t('settings.users')}</h2>
|
||||
<p className="settings-hint">{t('settings.usersHint')}</p>
|
||||
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('settings.userName')}</th>
|
||||
<th>{t('settings.userPin')}</th>
|
||||
<th>{t('settings.userRole')}</th>
|
||||
<th>Access</th>
|
||||
<th>{t('settings.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div className="settings-list">
|
||||
{users.map(u => (
|
||||
<UserRow
|
||||
<UserItem
|
||||
key={u.Id}
|
||||
user={u}
|
||||
allMuseums={allMuseums}
|
||||
@@ -303,21 +277,18 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
||||
onDelete={async (id) => { await deleteUser(id); await loadUsers(); }}
|
||||
/>
|
||||
))}
|
||||
<tr className="add-row">
|
||||
<td>
|
||||
<input type="text" value={newUser.Name} onChange={e => setNewUser({ ...newUser, Name: e.target.value })} placeholder={t('settings.userNamePlaceholder')} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" value={newUser.PIN} onChange={e => setNewUser({ ...newUser, PIN: e.target.value })} placeholder="PIN" />
|
||||
</td>
|
||||
<td>
|
||||
<select value={newUser.Role} onChange={e => setNewUser({ ...newUser, Role: e.target.value })}>
|
||||
</div>
|
||||
|
||||
<div className="settings-add-form">
|
||||
<div className="settings-add-title">{t('settings.add')} User</div>
|
||||
<div className="form-row">
|
||||
<input className="form-input" type="text" value={newUser.Name} onChange={e => setNewUser({ ...newUser, Name: e.target.value })} placeholder={t('settings.userNamePlaceholder')} />
|
||||
<input className="form-input form-input--sm" type="text" value={newUser.PIN} onChange={e => setNewUser({ ...newUser, PIN: e.target.value })} placeholder="PIN" />
|
||||
<select className="form-input form-input--sm" value={newUser.Role} onChange={e => setNewUser({ ...newUser, Role: e.target.value })}>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
</div>
|
||||
<button className="btn-small btn-primary" onClick={async () => {
|
||||
if (!newUser.Name || !newUser.PIN) return;
|
||||
await createUser(newUser);
|
||||
@@ -326,10 +297,6 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
||||
}} disabled={!newUser.Name || !newUser.PIN}>
|
||||
{t('settings.add')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,643 +0,0 @@
|
||||
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;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
// ─── multi-select ─────────────────────────────────────────────────
|
||||
export default function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
|
||||
value: string[]; options: string[];
|
||||
onChange: (vals: string[]) => void;
|
||||
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
|
||||
}, [open]);
|
||||
|
||||
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v !== opt) : [...value, opt]);
|
||||
const label = value.length === 0 ? allLabel : value.length === 1 ? value[0] : countLabel(value.length);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="altms">
|
||||
<button type="button" className={`altms-trigger${value.length > 0 ? ' altms-trigger--active' : ''}`} onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox">
|
||||
<span className="altms-label">{label}</span>
|
||||
<svg className={`altms-chevron${open ? ' altms-chevron--open' : ''}`} width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="altms-dropdown" role="listbox" aria-multiselectable="true">
|
||||
<div className="altms-list">
|
||||
{options.map(opt => (
|
||||
<label key={opt} role="option" aria-selected={value.includes(opt)} className={`altms-option${value.includes(opt) ? ' altms-option--checked' : ''}`}>
|
||||
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
|
||||
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
|
||||
<span className="altms-opt-label">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{value.length > 0 && <button type="button" className="altms-clear" onClick={() => { onChange([]); setOpen(false); }}>{clearLabel}</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import React, { useRef, useCallback, useState, ReactNode, KeyboardEvent, TouchEvent } from 'react';
|
||||
|
||||
interface CarouselProps {
|
||||
children: ReactNode;
|
||||
activeIndex: number;
|
||||
setActiveIndex: (index: number) => void;
|
||||
labels?: string[];
|
||||
showLabels?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
children,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
labels = [],
|
||||
showLabels = true,
|
||||
className = ''
|
||||
}: CarouselProps) {
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const touchStartY = useRef<number | null>(null);
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragOffset, setDragOffset] = useState(0);
|
||||
const itemCount = React.Children.count(children);
|
||||
|
||||
// Threshold for swipe detection
|
||||
const SWIPE_THRESHOLD = 50;
|
||||
const VELOCITY_THRESHOLD = 0.3;
|
||||
|
||||
const handleTouchStart = useCallback((e: TouchEvent<HTMLDivElement>) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
setIsDragging(true);
|
||||
setDragOffset(0);
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e: TouchEvent<HTMLDivElement>) => {
|
||||
if (!touchStartX.current || !isDragging) return;
|
||||
|
||||
const currentX = e.touches[0].clientX;
|
||||
const currentY = e.touches[0].clientY;
|
||||
const diffX = currentX - touchStartX.current;
|
||||
const diffY = currentY - (touchStartY.current || 0);
|
||||
|
||||
// Only handle horizontal swipes
|
||||
if (Math.abs(diffX) > Math.abs(diffY)) {
|
||||
e.preventDefault();
|
||||
// Add resistance at edges
|
||||
let offset = diffX;
|
||||
if ((activeIndex === 0 && diffX > 0) || (activeIndex === itemCount - 1 && diffX < 0)) {
|
||||
offset = diffX * 0.3; // Rubber band effect
|
||||
}
|
||||
setDragOffset(offset);
|
||||
}
|
||||
}, [isDragging, activeIndex, itemCount]);
|
||||
|
||||
const handleTouchEnd = useCallback((e: TouchEvent<HTMLDivElement>) => {
|
||||
if (!touchStartX.current || !isDragging) return;
|
||||
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const diff = touchStartX.current - endX;
|
||||
const velocity = Math.abs(diff) / 200; // Rough velocity calc
|
||||
|
||||
// Determine if we should change slide
|
||||
if (Math.abs(diff) > SWIPE_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
|
||||
if (diff > 0 && activeIndex < itemCount - 1) {
|
||||
setActiveIndex(activeIndex + 1);
|
||||
} else if (diff < 0 && activeIndex > 0) {
|
||||
setActiveIndex(activeIndex - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset
|
||||
touchStartX.current = null;
|
||||
touchStartY.current = null;
|
||||
setIsDragging(false);
|
||||
setDragOffset(0);
|
||||
}, [isDragging, activeIndex, setActiveIndex, itemCount]);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'ArrowLeft' && activeIndex > 0) {
|
||||
setActiveIndex(activeIndex - 1);
|
||||
} else if (e.key === 'ArrowRight' && activeIndex < itemCount - 1) {
|
||||
setActiveIndex(activeIndex + 1);
|
||||
}
|
||||
}, [activeIndex, setActiveIndex, itemCount]);
|
||||
|
||||
// Calculate transform
|
||||
const baseTransform = -(activeIndex * 100);
|
||||
const dragPercentage = trackRef.current ? (dragOffset / trackRef.current.offsetWidth) * 100 : 0;
|
||||
const transform = baseTransform + dragPercentage;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`carousel ${className}`}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label="Carousel"
|
||||
>
|
||||
<div className="carousel-container">
|
||||
<div className="carousel-viewport">
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="carousel-track"
|
||||
style={{
|
||||
transform: `translateX(${transform}%)`,
|
||||
transition: isDragging ? 'none' : 'transform 400ms cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{React.Children.map(children, (child, i) => (
|
||||
<div
|
||||
className="carousel-slide"
|
||||
key={i}
|
||||
role="tabpanel"
|
||||
aria-hidden={activeIndex !== i}
|
||||
aria-label={labels[i] || `Slide ${i + 1}`}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`carousel-dots ${showLabels ? 'labeled' : ''}`} role="tablist">
|
||||
{Array.from({ length: itemCount }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`carousel-dot ${activeIndex === i ? 'active' : ''}`}
|
||||
onClick={() => setActiveIndex(i)}
|
||||
role="tab"
|
||||
aria-selected={activeIndex === i}
|
||||
aria-label={labels[i] || `Slide ${i + 1}`}
|
||||
aria-controls={`slide-${i}`}
|
||||
>
|
||||
{showLabels && labels[i] && (
|
||||
<span className="dot-label">{labels[i]}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Carousel;
|
||||
@@ -1,37 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface ChartCardProps {
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
headerRight?: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
halfWidth?: boolean;
|
||||
}
|
||||
|
||||
function ChartCard({
|
||||
title,
|
||||
children,
|
||||
className = '',
|
||||
headerRight = null,
|
||||
fullWidth = false,
|
||||
halfWidth = false
|
||||
}: ChartCardProps) {
|
||||
const sizeClass = fullWidth ? 'full-width' : halfWidth ? 'half-width' : '';
|
||||
|
||||
return (
|
||||
<div className={`chart-card ${sizeClass} ${className}`}>
|
||||
{(title || headerRight) && (
|
||||
<div className="chart-card-header">
|
||||
{title && <h2>{title}</h2>}
|
||||
{headerRight && <div className="chart-card-actions">{headerRight}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="chart-container">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChartCard;
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
action?: (() => void) | null;
|
||||
actionLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
icon = '📊',
|
||||
title,
|
||||
message,
|
||||
action = null,
|
||||
actionLabel = 'Try Again',
|
||||
className = ''
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className={`empty-state ${className}`}>
|
||||
<div className="empty-state-icon" role="img" aria-hidden="true">
|
||||
{icon}
|
||||
</div>
|
||||
{title && (
|
||||
<h3 className="empty-state-title">{title}</h3>
|
||||
)}
|
||||
{message && (
|
||||
<p className="empty-state-message">{message}</p>
|
||||
)}
|
||||
{action && (
|
||||
<button
|
||||
className="empty-state-action"
|
||||
onClick={action}
|
||||
type="button"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmptyState;
|
||||
@@ -1,130 +0,0 @@
|
||||
import React, { useState, useEffect, ReactNode, KeyboardEvent } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
interface FilterControlsProps {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
defaultExpanded?: boolean;
|
||||
onReset?: (() => void) | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface FilterGroupProps {
|
||||
label?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface FilterRowProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface FilterControlsComponent extends React.FC<FilterControlsProps> {
|
||||
Group: React.FC<FilterGroupProps>;
|
||||
Row: React.FC<FilterRowProps>;
|
||||
}
|
||||
|
||||
const FilterControls: FilterControlsComponent = ({
|
||||
children,
|
||||
title,
|
||||
defaultExpanded = true,
|
||||
onReset = null,
|
||||
className = ''
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const displayTitle = title || t('filters.title');
|
||||
|
||||
// Start collapsed on mobile
|
||||
const [expanded, setExpanded] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.innerWidth > 768 ? defaultExpanded : false;
|
||||
}
|
||||
return defaultExpanded;
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
// Auto-expand on desktop, keep user preference on mobile
|
||||
if (window.innerWidth > 768) {
|
||||
setExpanded(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setExpanded(!expanded);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleExpanded();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`controls ${expanded ? 'expanded' : 'collapsed'} ${className}`}>
|
||||
<div
|
||||
className="controls-header"
|
||||
onClick={toggleExpanded}
|
||||
role="button"
|
||||
aria-expanded={expanded}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<h3>{displayTitle}</h3>
|
||||
<div className="controls-header-actions">
|
||||
{onReset && expanded && (
|
||||
<button
|
||||
className="controls-reset"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReset();
|
||||
}}
|
||||
aria-label={t('filters.reset') || 'Reset filters'}
|
||||
>
|
||||
{t('filters.reset') || 'Reset'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="controls-toggle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{expanded ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="controls-body"
|
||||
style={{
|
||||
display: expanded ? 'block' : 'none',
|
||||
animation: expanded ? 'fadeIn 200ms ease' : 'none'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterGroup: React.FC<FilterGroupProps> = ({ label, children }) => {
|
||||
return (
|
||||
<div className="control-group">
|
||||
{label && <label>{label}</label>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterRow: React.FC<FilterRowProps> = ({ children }) => {
|
||||
return <div className="control-row">{children}</div>;
|
||||
};
|
||||
|
||||
FilterControls.Group = FilterGroup;
|
||||
FilterControls.Row = FilterRow;
|
||||
|
||||
export default FilterControls;
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { formatCurrency, formatNumber } from '../../services/dataService';
|
||||
|
||||
// ─── metric card ──────────────────────────────────────────────────
|
||||
export default function MetricCard({ title, curr, prev, isCurrency, newLabel }: {
|
||||
title: string; curr: number; prev: number; isCurrency?: boolean; newLabel?: string;
|
||||
}) {
|
||||
const fmt = (n: number) => isCurrency ? formatCurrency(n) : formatNumber(n);
|
||||
const change = prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
||||
const isPos = change > 0, isNeg = change < 0;
|
||||
return (
|
||||
<div className="alt-metric">
|
||||
<p className="alt-metric-title">{title}</p>
|
||||
<div className="alt-metric-value">{fmt(curr)}</div>
|
||||
<div className="alt-metric-footer">
|
||||
{isFinite(change)
|
||||
? <span className={`alt-change ${isPos ? 'alt-change--up' : isNeg ? 'alt-change--down' : 'alt-change--flat'}`}>{isPos ? '▲' : isNeg ? '▼' : '—'} {Math.abs(change).toFixed(1)}%</span>
|
||||
: <span className="alt-change alt-change--up">{newLabel ?? 'New'}</span>}
|
||||
<span className="alt-metric-prev">{fmt(prev)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface MultiSelectProps {
|
||||
options: string[];
|
||||
selected: string[];
|
||||
onChange: (selected: string[]) => void;
|
||||
allLabel: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function MultiSelect({ options, selected, onChange, allLabel, placeholder }: MultiSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
const isAll = selected.length === 0;
|
||||
|
||||
const toggle = (value: string) => {
|
||||
if (selected.includes(value)) {
|
||||
onChange(selected.filter(v => v !== value));
|
||||
} else {
|
||||
onChange([...selected, value]);
|
||||
}
|
||||
};
|
||||
|
||||
const selectAll = () => onChange([]);
|
||||
|
||||
const displayText = isAll
|
||||
? allLabel
|
||||
: selected.length === 1
|
||||
? selected[0]
|
||||
: `${selected.length} selected`;
|
||||
|
||||
return (
|
||||
<div className="multi-select" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className="multi-select-trigger"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="multi-select-text">{displayText}</span>
|
||||
<span className="multi-select-arrow">▼</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="multi-select-dropdown">
|
||||
<label className="multi-select-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAll}
|
||||
onChange={selectAll}
|
||||
/>
|
||||
<span>{allLabel}</span>
|
||||
</label>
|
||||
{options.map(opt => (
|
||||
<label key={opt} className="multi-select-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(opt)}
|
||||
onChange={() => toggle(opt)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultiSelect;
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import type { LC } from '../../lib/locale';
|
||||
import { MONTH_KEYS, makePresets, guessPreset, periodNameL, dateRangeTextL } from '../../lib/dateHelpers';
|
||||
|
||||
// ─── inline picker ────────────────────────────────────────────────
|
||||
export function InlinePicker({ start, end, onChange, onClose, availableYears, L }: {
|
||||
start: string; end: string; onChange: (s: string, e: string) => void;
|
||||
onClose: () => void;
|
||||
availableYears: number[]; L: LC;
|
||||
}) {
|
||||
const g = guessPreset(start, end);
|
||||
const [year, setYear] = useState(g?.year ?? parseInt(start.slice(0, 4)));
|
||||
const [active, setActive] = useState<string | null>(g?.key ?? null);
|
||||
const [draftStart, setDraftStart] = useState(start);
|
||||
const [draftEnd, setDraftEnd] = useState(end);
|
||||
const minY = Math.min(...availableYears), maxY = Math.max(...availableYears);
|
||||
|
||||
const pick = (key: string) => { const r = makePresets(year)[key]; if (!r) return; setActive(key); setDraftStart(r.start); setDraftEnd(r.end); };
|
||||
const shift = (d: number) => {
|
||||
const ny = year + d; if (ny < minY || ny > maxY) return; setYear(ny);
|
||||
if (active && makePresets(ny)[active]) { setDraftStart(makePresets(ny)[active].start); setDraftEnd(makePresets(ny)[active].end); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="alt-picker" id="period-picker-panel">
|
||||
<div className="alt-picker-year">
|
||||
<button type="button" onClick={() => shift(L.dir === 'rtl' ? 1 : -1)} disabled={L.dir === 'rtl' ? year >= maxY : year <= minY} className="alt-yr-btn">
|
||||
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M5.5 9.5L1.5 5.5L5.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
<span className="alt-yr-val">{year}</span>
|
||||
<button type="button" onClick={() => shift(L.dir === 'rtl' ? -1 : 1)} disabled={L.dir === 'rtl' ? year <= minY : year >= maxY} className="alt-yr-btn">
|
||||
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M1.5 1.5L5.5 5.5L1.5 9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="alt-picker-section">{L.monthSection}</p>
|
||||
<div className="alt-chips">
|
||||
{MONTH_KEYS.map((k, i) => (
|
||||
<button key={k} type="button" className={`alt-chip${active === k ? ' alt-chip-on' : ''}`} onClick={() => pick(k)}>{L.monthShort[i]}</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="alt-picker-section">{L.periodSection}</p>
|
||||
<div className="alt-chips">
|
||||
{['q1','q2','q3','q4','h1','h2'].map(k => (
|
||||
<button key={k} type="button" className={`alt-chip${active === k ? ' alt-chip-on' : ''}`} onClick={() => pick(k)}>{L.periods[k]}</button>
|
||||
))}
|
||||
<button type="button" className={`alt-chip alt-chip-wide${active === 'full' ? ' alt-chip-on' : ''}`} onClick={() => pick('full')}>{L.periods.full}</button>
|
||||
</div>
|
||||
<div className="alt-picker-div" />
|
||||
<div className="alt-custom">
|
||||
<div className="alt-custom-f"><label>{L.from}</label><input type="date" value={draftStart} onChange={e => { setActive(null); setDraftStart(e.target.value); }} /></div>
|
||||
<span className="alt-custom-arrow">{L.dateRangeSep}</span>
|
||||
<div className="alt-custom-f"><label>{L.to}</label><input type="date" value={draftEnd} onChange={e => { setActive(null); setDraftEnd(e.target.value); }} /></div>
|
||||
</div>
|
||||
<div className="alt-picker-div" />
|
||||
<div className="alt-footer">
|
||||
<button type="button" className="alt-cancel" onClick={onClose}>{L.close}</button>
|
||||
<button type="button" className="alt-apply" onClick={() => { onChange(draftStart, draftEnd); onClose(); }}>{L.apply}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── period hero ──────────────────────────────────────────────────
|
||||
export default function PeriodHero({ start, end, onChange, availableYears, L }: {
|
||||
start: string; end: string; onChange: (s: string, e: string) => void;
|
||||
availableYears: number[]; L: LC;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onM = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
const onK = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onM); document.addEventListener('keydown', onK);
|
||||
return () => { document.removeEventListener('mousedown', onM); document.removeEventListener('keydown', onK); };
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="dalt-hero">
|
||||
<div className="dalt-hero-inner">
|
||||
<div>
|
||||
<div className="dalt-hero-name">{periodNameL(start, end, L)}</div>
|
||||
<div className="dalt-hero-range">{dateRangeTextL(start, end, L)}</div>
|
||||
</div>
|
||||
<button type="button" className="dalt-hero-btn" onClick={() => setOpen(v => !v)} aria-expanded={open} aria-controls="period-picker-panel">
|
||||
{open ? L.close : L.changePeriod}
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
|
||||
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{open && <InlinePicker start={start} end={end} onChange={onChange} onClose={() => setOpen(false)} availableYears={availableYears} L={L} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
change?: number | null;
|
||||
changeLabel?: string;
|
||||
subtitle?: string | null;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, change = null, changeLabel = 'YoY', subtitle = null }: StatCardProps) {
|
||||
const isPositive = change !== null && change >= 0;
|
||||
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<h3>{title}</h3>
|
||||
<div className="stat-value">{value}</div>
|
||||
{subtitle && (
|
||||
<div className="stat-subtitle">{subtitle}</div>
|
||||
)}
|
||||
{change !== null && (
|
||||
<div className={`stat-change ${isPositive ? 'positive' : 'negative'}`}>
|
||||
<span className="stat-change-arrow">{isPositive ? '↑' : '↓'}</span>
|
||||
<span className="stat-change-value">{Math.abs(change).toFixed(1)}%</span>
|
||||
<span className="stat-change-label">{changeLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatCard;
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ToggleOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
options: ToggleOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ToggleSwitch({ options, value, onChange, className = '' }: ToggleSwitchProps) {
|
||||
return (
|
||||
<div className={`toggle-switch ${className}`} role="radiogroup">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={value === option.value ? 'active' : ''}
|
||||
onClick={() => onChange(option.value)}
|
||||
role="radio"
|
||||
aria-checked={value === option.value}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToggleSwitch;
|
||||
@@ -1,7 +1 @@
|
||||
export { default as Carousel } from './Carousel';
|
||||
export { default as ChartCard } from './ChartCard';
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export { default as FilterControls } from './FilterControls';
|
||||
export { default as MultiSelect } from './MultiSelect';
|
||||
export { default as StatCard } from './StatCard';
|
||||
export { default as ToggleSwitch } from './ToggleSwitch';
|
||||
export { default as LoadingSkeleton } from './LoadingSkeleton';
|
||||
|
||||
+33
-11
@@ -37,9 +37,21 @@ export const chartColors = {
|
||||
success: '#059669',
|
||||
danger: '#dc2626',
|
||||
muted: '#94a3b8',
|
||||
grid: '#f1f5f9'
|
||||
grid: '#e2e8f0' // fallback only; use getChartTheme().border at runtime
|
||||
};
|
||||
|
||||
export function getChartTheme() {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const get = (v: string) => style.getPropertyValue(v).trim();
|
||||
return {
|
||||
surface: get('--surface') || '#ffffff',
|
||||
textPrimary: get('--text-primary') || '#0f172a',
|
||||
textMuted: get('--text-muted') || '#64748b',
|
||||
border: get('--border') || '#e2e8f0',
|
||||
textInverse: get('--text-inverse') || '#ffffff',
|
||||
};
|
||||
}
|
||||
|
||||
// Extended palette for charts with many categories (events, channels)
|
||||
export const chartPalette = [
|
||||
'#2563eb', // blue
|
||||
@@ -54,15 +66,15 @@ export const chartPalette = [
|
||||
'#ea580c', // orange
|
||||
];
|
||||
|
||||
export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
||||
export const createDataLabelConfig = (showDataLabels: boolean, overrides?: { color?: string; backgroundColor?: string }): any => ({
|
||||
display: showDataLabels,
|
||||
color: '#1e293b',
|
||||
color: overrides?.color ?? '#1e293b',
|
||||
font: { size: 10, weight: 600 },
|
||||
anchor: 'end',
|
||||
align: 'end',
|
||||
offset: 4,
|
||||
padding: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
||||
backgroundColor: overrides?.backgroundColor ?? 'rgba(255, 255, 255, 0.85)',
|
||||
borderRadius: 3,
|
||||
textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment
|
||||
formatter: (value: number | null) => {
|
||||
@@ -74,7 +86,9 @@ export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
||||
}
|
||||
});
|
||||
|
||||
export const createBaseOptions = (showDataLabels: boolean): any => ({
|
||||
export const createBaseOptions = (showDataLabels: boolean): any => {
|
||||
const theme = getChartTheme();
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
locale: 'en-US', // Force LTR number formatting
|
||||
@@ -89,7 +103,11 @@ export const createBaseOptions = (showDataLabels: boolean): any => ({
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: '#1e293b',
|
||||
backgroundColor: theme.surface,
|
||||
titleColor: theme.textPrimary,
|
||||
bodyColor: theme.textMuted,
|
||||
borderColor: theme.border,
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
cornerRadius: 8,
|
||||
titleFont: { size: 12 },
|
||||
@@ -97,20 +115,24 @@ export const createBaseOptions = (showDataLabels: boolean): any => ({
|
||||
rtl: false,
|
||||
textDirection: 'ltr'
|
||||
},
|
||||
datalabels: createDataLabelConfig(showDataLabels)
|
||||
datalabels: createDataLabelConfig(showDataLabels, {
|
||||
color: theme.textPrimary,
|
||||
backgroundColor: theme.surface + 'dd',
|
||||
})
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { font: { size: 10 }, color: '#94a3b8' }
|
||||
ticks: { font: { size: 10 }, color: theme.textMuted }
|
||||
},
|
||||
y: {
|
||||
grid: { color: chartColors.grid },
|
||||
ticks: { font: { size: 10 }, color: '#94a3b8' },
|
||||
grid: { color: theme.border },
|
||||
ticks: { font: { size: 10 }, color: theme.textMuted },
|
||||
border: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const lineDatasetDefaults = {
|
||||
borderWidth: 2,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { LC } from './locale';
|
||||
|
||||
// ─── date helpers ─────────────────────────────────────────────────
|
||||
|
||||
export const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
|
||||
|
||||
export function isLeap(y: number): boolean {
|
||||
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
|
||||
}
|
||||
|
||||
export function makePresets(y: number): Record<string, { start: string; end: string }> {
|
||||
const feb = isLeap(y) ? 29 : 28;
|
||||
return {
|
||||
jan:{start:`${y}-01-01`,end:`${y}-01-31`}, feb:{start:`${y}-02-01`,end:`${y}-02-${String(feb).padStart(2,'0')}`},
|
||||
mar:{start:`${y}-03-01`,end:`${y}-03-31`}, apr:{start:`${y}-04-01`,end:`${y}-04-30`},
|
||||
may:{start:`${y}-05-01`,end:`${y}-05-31`}, jun:{start:`${y}-06-01`,end:`${y}-06-30`},
|
||||
jul:{start:`${y}-07-01`,end:`${y}-07-31`}, aug:{start:`${y}-08-01`,end:`${y}-08-31`},
|
||||
sep:{start:`${y}-09-01`,end:`${y}-09-30`}, oct:{start:`${y}-10-01`,end:`${y}-10-31`},
|
||||
nov:{start:`${y}-11-01`,end:`${y}-11-30`}, dec:{start:`${y}-12-01`,end:`${y}-12-31`},
|
||||
q1:{start:`${y}-01-01`,end:`${y}-03-31`}, q2:{start:`${y}-04-01`,end:`${y}-06-30`},
|
||||
q3:{start:`${y}-07-01`,end:`${y}-09-30`}, q4:{start:`${y}-10-01`,end:`${y}-12-31`},
|
||||
h1:{start:`${y}-01-01`,end:`${y}-06-30`}, h2:{start:`${y}-07-01`,end:`${y}-12-31`},
|
||||
full:{start:`${y}-01-01`,end:`${y}-12-31`},
|
||||
};
|
||||
}
|
||||
|
||||
export function guessPreset(start: string, end: string): { key: string; year: number } | null {
|
||||
const year = parseInt(start.slice(0, 4));
|
||||
const presets = makePresets(year);
|
||||
for (const [key, r] of Object.entries(presets)) {
|
||||
if (r.start === start && r.end === end) return { key, year };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function periodNameL(start: string, end: string, L: LC): string {
|
||||
const year = parseInt(start.slice(0, 4));
|
||||
const g = guessPreset(start, end);
|
||||
if (!g) {
|
||||
const fmt = (d: string) => { const [,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]}`; };
|
||||
const ey = parseInt(end.slice(0, 4));
|
||||
return year === ey ? `${fmt(start)} – ${fmt(end)} ${year}` : `${fmt(start)} ${year} – ${fmt(end)} ${ey}`;
|
||||
}
|
||||
const mi = MONTH_KEYS.indexOf(g.key);
|
||||
if (mi >= 0) return `${L.monthFull[mi]} ${g.year}`;
|
||||
if (g.key === 'full') return L.fullYearLabel(g.year);
|
||||
return `${L.periods[g.key] ?? g.key.toUpperCase()} ${g.year}`;
|
||||
}
|
||||
|
||||
export function dateRangeTextL(start: string, end: string, L: LC): string {
|
||||
const fmt = (d: string) => { const [y,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]} ${y}`; };
|
||||
return `${fmt(start)} ${L.dateRangeSep} ${fmt(end)}`;
|
||||
}
|
||||
|
||||
export function currentMonth(): { start: string; end: string } {
|
||||
const now = new Date(); const y = now.getFullYear(), m = now.getMonth() + 1;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return { start: `${y}-${p(m)}-01`, end: `${y}-${p(m)}-${p(new Date(y, m, 0).getDate())}` };
|
||||
}
|
||||
|
||||
export function shiftYear(s: string): string {
|
||||
return s.replace(/^(\d{4})/, (_, y) => String(parseInt(y) - 1));
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// ─── language config ──────────────────────────────────────────────
|
||||
// Shared LC interface used by Dashboard and Comparison.
|
||||
// Fields marked with a comment are only consumed by one page but kept
|
||||
// here so both components share a single type.
|
||||
export interface LC {
|
||||
dir: 'ltr' | 'rtl';
|
||||
/** @deprecated Fonts are now loaded from index.html; kept for compatibility */
|
||||
fontImport: string;
|
||||
bodyFont: string;
|
||||
displayFont: string;
|
||||
monoFont: string;
|
||||
monthFull: string[];
|
||||
monthShort: string[];
|
||||
periods: Record<string, string>;
|
||||
fullYearLabel: (y: number) => string;
|
||||
dateRangeSep: string;
|
||||
backLink: string;
|
||||
backTo: string;
|
||||
pageTitle: string;
|
||||
pageSub: string;
|
||||
// Dashboard
|
||||
changePeriod: string;
|
||||
close: string;
|
||||
apply: string;
|
||||
filter: string;
|
||||
allDistricts: string;
|
||||
allChannels: string;
|
||||
allMuseums: string;
|
||||
countDistricts: (n: number) => string;
|
||||
countChannels: (n: number) => string;
|
||||
countMuseums: (n: number) => string;
|
||||
reset: string;
|
||||
exclVAT: string;
|
||||
inclVAT: string;
|
||||
keyMetrics: string;
|
||||
revenue: string;
|
||||
visitors: string;
|
||||
tickets: string;
|
||||
avgRev: string;
|
||||
pilgrims: string;
|
||||
captureRate: string;
|
||||
charts: string;
|
||||
trendTitle: string;
|
||||
museumTitle: string;
|
||||
channelTitle: string;
|
||||
districtTitle: string;
|
||||
daily: string;
|
||||
weekly: string;
|
||||
monthly: string;
|
||||
newLabel: string;
|
||||
clearSel: string;
|
||||
monthSection: string;
|
||||
periodSection: string;
|
||||
from: string;
|
||||
to: string;
|
||||
vsLabel: string;
|
||||
barLabel: string;
|
||||
pieLabel: string;
|
||||
absLabel: string;
|
||||
pctLabel: string;
|
||||
// Comparison-specific
|
||||
currentRole: string;
|
||||
previousRole: string;
|
||||
currentHint: string;
|
||||
previousHint: string;
|
||||
vs: string;
|
||||
}
|
||||
|
||||
export const EN: LC = {
|
||||
dir: 'ltr',
|
||||
fontImport: `@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&display=swap');`,
|
||||
bodyFont: "'Outfit', sans-serif",
|
||||
displayFont: "'DM Serif Display', serif",
|
||||
monoFont: "ui-monospace, 'Cascadia Code', monospace",
|
||||
monthFull: ['January','February','March','April','May','June','July','August','September','October','November','December'],
|
||||
monthShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
|
||||
periods: { q1:'Q1', q2:'Q2', q3:'Q3', q4:'Q4', h1:'H1', h2:'H2', full:'Full Year' },
|
||||
fullYearLabel: (y) => String(y),
|
||||
dateRangeSep: '→',
|
||||
backLink: 'Back to Dashboard', backTo: '/',
|
||||
pageTitle: 'Overview', pageSub: 'Museum performance at a glance.',
|
||||
changePeriod: 'Change period', close: 'Cancel', apply: 'Apply',
|
||||
filter: 'Filter',
|
||||
allDistricts: 'All districts', allChannels: 'All channels', allMuseums: 'All museums',
|
||||
countDistricts: (n) => `${n} districts`,
|
||||
countChannels: (n) => `${n} channels`,
|
||||
countMuseums: (n) => `${n} museums`,
|
||||
reset: 'Reset', exclVAT: 'Excl. VAT', inclVAT: 'Incl. VAT',
|
||||
keyMetrics: 'Key Metrics',
|
||||
revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets',
|
||||
avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %',
|
||||
charts: 'Charts',
|
||||
trendTitle: 'Trend over time', museumTitle: 'By museum',
|
||||
channelTitle: 'By channel', districtTitle: 'By district',
|
||||
daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
|
||||
newLabel: 'New', clearSel: 'Clear selection',
|
||||
monthSection: 'Month', periodSection: 'Quarter · Half · Year',
|
||||
from: 'From', to: 'To', vsLabel: 'vs',
|
||||
barLabel: 'Bar', pieLabel: 'Pie', absLabel: '#', pctLabel: '%',
|
||||
currentRole: 'This period', previousRole: 'Compared to',
|
||||
currentHint: 'primary', previousHint: 'auto year −1',
|
||||
vs: 'vs',
|
||||
};
|
||||
|
||||
export const AR: LC = {
|
||||
dir: 'rtl',
|
||||
fontImport: `@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');`,
|
||||
bodyFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||
displayFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||
monoFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||
monthFull: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'],
|
||||
monthShort: ['ينا','فبر','مار','أبر','ماي','يون','يول','أغس','سبت','أكت','نوف','ديس'],
|
||||
periods: { q1:'ر١', q2:'ر٢', q3:'ر٣', q4:'ر٤', h1:'ن١', h2:'ن٢', full:'السنة' },
|
||||
fullYearLabel: (y) => `${y} كاملاً`,
|
||||
dateRangeSep: '–',
|
||||
backLink: 'العودة إلى لوحة التحكم', backTo: '/ar',
|
||||
pageTitle: 'نظرة عامة', pageSub: 'أداء المتاحف في لمحة.',
|
||||
changePeriod: 'تغيير الفترة', close: 'إلغاء', apply: 'تطبيق',
|
||||
filter: 'تصفية',
|
||||
allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف',
|
||||
countDistricts: (n) => `${n} مناطق`,
|
||||
countChannels: (n) => `${n} قنوات`,
|
||||
countMuseums: (n) => `${n} متاحف`,
|
||||
reset: 'إعادة ضبط', exclVAT: 'بدون ضريبة', inclVAT: 'مع ضريبة',
|
||||
keyMetrics: 'المؤشرات الرئيسية',
|
||||
revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر',
|
||||
avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %',
|
||||
charts: 'المخططات',
|
||||
trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف',
|
||||
channelTitle: 'حسب القناة', districtTitle: 'حسب المنطقة',
|
||||
daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري',
|
||||
newLabel: 'جديد', clearSel: 'مسح التحديد',
|
||||
monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة',
|
||||
from: 'من', to: 'إلى', vsLabel: 'مقابل',
|
||||
barLabel: 'أعمدة', pieLabel: 'دائرة', absLabel: '#', pctLabel: '%',
|
||||
currentRole: 'الفترة الحالية', previousRole: 'مقارنةً بـ',
|
||||
currentHint: 'رئيسية', previousHint: 'تلقائياً −١ سنة',
|
||||
vs: 'مقابل',
|
||||
};
|
||||
@@ -273,11 +273,9 @@ export async function refreshData(): Promise<FetchResult> {
|
||||
|
||||
export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord[] {
|
||||
return data.filter(row => {
|
||||
if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false;
|
||||
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
||||
if (filters.channel.length > 0 && !filters.channel.includes(row.channel)) return false;
|
||||
if (filters.museum.length > 0 && !filters.museum.includes(row.museum_name)) return false;
|
||||
if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
+2
-2
@@ -21,11 +21,11 @@ export interface Metrics {
|
||||
}
|
||||
|
||||
export interface Filters {
|
||||
year: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
district: string;
|
||||
channel: string[];
|
||||
museum: string[];
|
||||
quarter: string;
|
||||
}
|
||||
|
||||
export interface DateRangeFilters {
|
||||
|
||||
Reference in New Issue
Block a user