Compare commits

..

11 Commits

Author SHA1 Message Date
fahed c9cfb58896 fix: mobile UX overhaul — collapsible filters, settings nav, responsive layout
Deploy HiHala Dashboard / deploy (push) Successful in 8s
- Add Settings link to desktop nav bar for admin users
- Rewrite Settings page from table layout to responsive card list (fixes unusable mobile state)
- Filter bar (Dashboard + Comparison): collapsible panel on mobile via display:contents trick; stacked full-width dropdowns replace horizontal scroll
- Active filter count badge shown in collapsed filter header
- AltMultiSelect dropdowns go full-width on mobile to prevent viewport overflow
- Chart control separators hidden on mobile to avoid crowding
- Metric grid: 2-col at ≤700px, 1-col at ≤480px
- Comparison period cards: smaller font and tighter padding at ≤680px
- Page shell padding reduced on mobile (48px→20px top, 24px→16px sides)
- Settings page gets correct 80px bottom padding for mobile nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:22:07 +03:00
fahed 30cdb5064a refactor: extract shared locale, date helpers, and components (H6)
~300 lines of code that were independently duplicated in Dashboard.tsx
and Comparison.tsx are now in shared modules:

- src/lib/locale.ts — LC interface, EN and AR language configs (merged
  fields from both pages into one unified interface)
- src/lib/dateHelpers.ts — MONTH_KEYS, isLeap, makePresets, guessPreset,
  periodNameL, dateRangeTextL, currentMonth, shiftYear
- src/components/shared/PeriodPicker.tsx — InlinePicker + PeriodHero
- src/components/shared/AltMultiSelect.tsx — AltMultiSelect
- src/components/shared/MetricCard.tsx — MetricCard

Dashboard.tsx and Comparison.tsx now import from these shared modules.
Zero behavioral changes — all props, ARIA, and render output unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:53:35 +03:00
fahed 25cb91e31b refactor: extract inline style blocks to App.css (H1)
The 130-line (Dashboard) and 155-line (Comparison) inline <style> JSX
blocks are removed and replaced with static CSS in App.css.

Font-family values that changed per language are now set as CSS custom
properties (--alt-body-font, --alt-display-font, --alt-mono-font) via
the root element's style prop — 3 vars instead of re-injecting 130+
lines of CSS on every language switch.

The redundant @import font URLs are dropped (fonts already preloaded
in index.html). Default values for the three font vars are defined in
:root so the page renders correctly before JS executes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:46:41 +03:00
fahed ef9a960e5d fix: responsive, ARIA, performance and CSS cleanup improvements
Addresses remaining medium and low severity audit findings:

- H2: Dark mode @media selector narrowed to :root:not([data-theme]) so
  OS-preference and manual-override blocks are now mutually exclusive
- L2: Remove ~410 lines of dead Slides Builder CSS (no component exists)
- M2: VAT toggle uses flex spacer instead of margin-inline-start:auto,
  preventing layout break when filter bar wraps at medium-small widths
- M3: Page content max-width aligned to 1400px (matches nav bar)
- M5: Period picker toggle now has aria-controls="period-picker-panel"
  pointing to the InlinePicker root in both Dashboard and Comparison
- M6: Offline badge cache timestamp exposed via sr-only span for
  screen reader accessibility (was title-only before)
- M7: chartOpts/barHorizOpts/barNoLegend wrapped in useMemo([baseOpts])
  to prevent unnecessary Chart.js re-renders
- L4: Filter bar scrolls horizontally on mobile instead of wrapping
- L5: Metrics grid uses auto-fit/minmax to eliminate orphaned cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:41:44 +03:00
fahed 9138ac1098 fix: accessibility, theming, and focus-visibility improvements
Addresses critical and high-severity findings from UI audit:

- C1: Define missing CSS tokens (--hover, --bg-primary/secondary/tertiary)
  fixing broken hover states and Slides Builder backgrounds
- C2: Chart colors now read CSS custom properties at render-time via
  getChartTheme(), adapting tooltip, ticks, and grid to dark mode
- C3: Multi-select ARIA fixed — label elements now carry role="option"
  and aria-selected for valid listbox semantics
- H1/M1: Remove unused --gold and duplicate --primary tokens;
  replace all var(--primary) with var(--accent) throughout App.css
- H3/H4: Focus-visible outlines added to all custom interactive elements
  (chips, controls, year buttons, hero button, multi-select trigger)
- H5: access-badge--full hardcoded colors replaced with design tokens
- H7: aria-pressed added to all chart toggle buttons
- L1: Hardcoded #fff/white replaced with var(--text-inverse)
- M4: index.html now preloads DM Serif Display, Outfit, and IBM Plex
  Sans Arabic — all fonts actually used in the app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:46:54 +03:00
fahed d3f9a6cd43 fix: remove duplicate EN/AR language toggle from filter bars
The header already has a language switcher; the one in the filter
bar was redundant on both Dashboard and Comparison pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:42:09 +03:00
fahed 36df0065ed refactor: rename Demo components to canonical names and purge dead code
Deploy HiHala Dashboard / deploy (push) Successful in 9s
- DashboardDemo → Dashboard, PeriodSelectorDemo → Comparison (these were the real active routes)
- Delete old Dashboard, Comparison, NavDemo, Slides, ChartExport (replaced / unused)
- Delete 8 unused shared components: DateRangePicker, PeriodPicker, FilterControls, MultiSelect, Carousel, ChartCard, EmptyState, StatCard, ToggleSwitch
- Fix date picker stay-open behavior: selections now update draft state only; Apply/Cancel buttons commit or discard
- shared/index.tsx now only exports LoadingSkeleton

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 17:07:39 +03:00
fahed c8c3465233 feat: redesigned dashboard UI with editorial aesthetic and RTL support
Deploy HiHala Dashboard / deploy (push) Successful in 9s
- Replace Dashboard/Comparison with DashboardDemo/PeriodSelectorDemo as primary pages at / and /comparison
- New editorial design: DM Serif Display + Outfit fonts, inline period picker, multi-select filters for museum/channel/district
- Full Arabic RTL support with IBM Plex Sans Arabic; EN/AR toggle synced to global LanguageContext
- Bar/pie chart toggle + absolute/percent toggle for museum, channel, district charts
- Refined top nav: transparent inactive links, accent active state, visual separator between nav links and utilities
- DateRangePicker, MultiSelect, FilterControls shared components added
- NavDemo: sidebar layout alternative (accessible at /nav-demo)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:58:33 +03:00
fahed 0f6881309c feat: replace year/quarter filters with free date range pickers
Deploy HiHala Dashboard / deploy (push) Successful in 8s
Dashboard: PeriodPicker replaces year + quarter dropdowns. Defaults to
current month. YoY stat card now compares same range vs previous year.

Comparison: two independent PeriodPicker blocks (Period A and Period B).
Changing Period A auto-updates Period B to same period previous year,
but Period B remains freely editable.

Both pages use filterDataByDateRange; Filters type drops year/quarter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 15:02:06 +03:00
fahed 9064df82be feat: fire-and-forget ETL sync with progress status endpoint
Deploy HiHala Dashboard / deploy (push) Successful in 8s
POST /api/etl/sync now returns immediately (202-style).
GET /api/etl/status shows running state, current month being
processed, and final result or error when done.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 13:40:19 +03:00
fahed ac32a541a1 fix: count full visitor per museum for combo tickets
Deploy HiHala Dashboard / deploy (push) Successful in 10s
Bundle tickets grant access to multiple museums, so each museum
should count 1 visitor — not 0.5. Revenue split remains 50/50.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 12:55:49 +03:00
28 changed files with 2126 additions and 4005 deletions
+1 -1
View File
@@ -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
View File
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { fetchSales, isConfigured } from '../services/erpClient';
import { etl } from '../config';
const router = Router();
+41 -17
View File
@@ -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() };
try {
console.log(`\nETL sync started (${mode})...`);
const result = await runSync(mode);
console.log(`ETL sync complete: ${result.transactionsFetched} transactions → ${result.recordsWritten} records in ${result.duration}`);
res.json(result);
} catch (err) {
console.error('ETL sync failed:', (err as Error).message);
res.status(500).json({ error: 'ETL sync failed', details: (err as Error).message });
}
res.json({ accepted: true, mode, message: 'Sync started — poll GET /api/etl/status for progress' });
console.log(`\nETL sync started (${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}`);
syncState = { status: 'done', result, finishedAt: new Date().toISOString() };
})
.catch(err => {
console.error('ETL sync failed:', (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;
+5 -3
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+28 -10
View File
@@ -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>
);
-184
View File
@@ -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;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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}
+128 -161
View File
@@ -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>
<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>
<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 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>
<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>
<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">
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
</div>
{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>
</div>
</td>
</tr>
)}
</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>
return (
<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">
{!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>
<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>
</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>
{loading ? (
<tr><td colSpan={4} style={{ textAlign: 'center', padding: 24 }}>Loading...</td></tr>
) : (
seasons.map(s => (
<SeasonRow 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>
<button className="btn-small btn-primary" onClick={handleCreate} disabled={!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate}>
{t('settings.add')}
</button>
</td>
</tr>
</tbody>
</table>
<div className="settings-list">
{loading ? (
<div className="settings-loading">Loading...</div>
) : (
seasons.map(s => (
<SeasonItem key={s.Id} season={s} onSave={handleSave} onDelete={handleDelete} />
))
)}
</div>
<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>
</div>
</div>
@@ -281,55 +266,37 @@ 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>
{users.map(u => (
<UserRow
key={u.Id}
user={u}
allMuseums={allMuseums}
allChannels={allChannels}
onUpdate={handleUpdateUser}
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 })}>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
</select>
</td>
<td></td>
<td>
<button className="btn-small btn-primary" onClick={async () => {
if (!newUser.Name || !newUser.PIN) return;
await createUser(newUser);
setNewUser({ Name: '', PIN: '', Role: 'viewer', AllowedMuseums: '[]', AllowedChannels: '[]' });
await loadUsers();
}} disabled={!newUser.Name || !newUser.PIN}>
{t('settings.add')}
</button>
</td>
</tr>
</tbody>
</table>
<div className="settings-list">
{users.map(u => (
<UserItem
key={u.Id}
user={u}
allMuseums={allMuseums}
allChannels={allChannels}
onUpdate={handleUpdateUser}
onDelete={async (id) => { await deleteUser(id); await loadUsers(); }}
/>
))}
</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>
</div>
<button className="btn-small btn-primary" onClick={async () => {
if (!newUser.Name || !newUser.PIN) return;
await createUser(newUser);
setNewUser({ Name: '', PIN: '', Role: 'viewer', AllowedMuseums: '[]', AllowedChannels: '[]' });
await loadUsers();
}} disabled={!newUser.Name || !newUser.PIN}>
{t('settings.add')}
</button>
</div>
</div>
</div>
-643
View File
@@ -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;
+44
View File
@@ -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>
);
}
-151
View File
@@ -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;
-37
View File
@@ -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;
-44
View File
@@ -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;
-130
View File
@@ -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;
+23
View File
@@ -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>
);
}
-82
View File
@@ -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;
+95
View File
@@ -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>
);
}
-32
View File
@@ -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;
-33
View File
@@ -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
View File
@@ -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';
+60 -38
View File
@@ -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,43 +86,53 @@ export const createDataLabelConfig = (showDataLabels: boolean): any => ({
}
});
export const createBaseOptions = (showDataLabels: boolean): any => ({
responsive: true,
maintainAspectRatio: false,
locale: 'en-US', // Force LTR number formatting
layout: {
padding: {
top: showDataLabels ? 25 : 5,
right: 5,
bottom: 5,
left: 5
}
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#1e293b',
padding: 12,
cornerRadius: 8,
titleFont: { size: 12 },
bodyFont: { size: 11 },
rtl: false,
textDirection: 'ltr'
export const createBaseOptions = (showDataLabels: boolean): any => {
const theme = getChartTheme();
return {
responsive: true,
maintainAspectRatio: false,
locale: 'en-US', // Force LTR number formatting
layout: {
padding: {
top: showDataLabels ? 25 : 5,
right: 5,
bottom: 5,
left: 5
}
},
datalabels: createDataLabelConfig(showDataLabels)
},
scales: {
x: {
grid: { display: false },
ticks: { font: { size: 10 }, color: '#94a3b8' }
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: theme.surface,
titleColor: theme.textPrimary,
bodyColor: theme.textMuted,
borderColor: theme.border,
borderWidth: 1,
padding: 12,
cornerRadius: 8,
titleFont: { size: 12 },
bodyFont: { size: 11 },
rtl: false,
textDirection: 'ltr'
},
datalabels: createDataLabelConfig(showDataLabels, {
color: theme.textPrimary,
backgroundColor: theme.surface + 'dd',
})
},
y: {
grid: { color: chartColors.grid },
ticks: { font: { size: 10 }, color: '#94a3b8' },
border: { display: false }
scales: {
x: {
grid: { display: false },
ticks: { font: { size: 10 }, color: theme.textMuted }
},
y: {
grid: { color: theme.border },
ticks: { font: { size: 10 }, color: theme.textMuted },
border: { display: false }
}
}
}
});
};
};
export const lineDatasetDefaults = {
borderWidth: 2,
+63
View File
@@ -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));
}
+139
View File
@@ -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: 'مقابل',
};
-2
View File
@@ -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
View File
@@ -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 {