From c8c3465233e7cc93b4b1e82ccd08ab8250a6b1cc Mon Sep 17 00:00:00 2001 From: fahed Date: Sun, 19 Apr 2026 17:58:33 +0300 Subject: [PATCH] feat: redesigned dashboard UI with editorial aesthetic and RTL support - 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 --- src/App.css | 536 +++++++++++++--- src/App.tsx | 27 +- src/components/Comparison.tsx | 39 +- src/components/Dashboard.tsx | 21 +- src/components/DashboardDemo.tsx | 713 ++++++++++++++++++++++ src/components/Login.tsx | 2 + src/components/NavDemo.tsx | 606 ++++++++++++++++++ src/components/PeriodSelectorDemo.tsx | 671 ++++++++++++++++++++ src/components/shared/DateRangePicker.tsx | 247 ++++++++ src/components/shared/FilterControls.tsx | 26 +- src/components/shared/MultiSelect.tsx | 5 +- src/components/shared/PeriodPicker.tsx | 103 ++-- src/components/shared/index.tsx | 1 + 13 files changed, 2819 insertions(+), 178 deletions(-) create mode 100644 src/components/DashboardDemo.tsx create mode 100644 src/components/NavDemo.tsx create mode 100644 src/components/PeriodSelectorDemo.tsx create mode 100644 src/components/shared/DateRangePicker.tsx diff --git a/src/App.css b/src/App.css index 6defc85..8164997 100644 --- a/src/App.css +++ b/src/App.css @@ -2,6 +2,19 @@ HiHala Dashboard - Minimalist Luxury ======================================== */ +/* Screen-reader only utility */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; +} + :root { --bg: #f8fafc; --surface: #ffffff; @@ -223,14 +236,15 @@ html[dir="rtl"] { .nav-bar { background: var(--surface); border-bottom: 1px solid var(--border); - padding: 0 32px; - height: 64px; + padding: 0 28px; + height: 56px; display: flex; align-items: center; justify-content: center; position: sticky; top: 0; z-index: 100; + box-shadow: 0 1px 0 var(--border), 0 2px 8px rgba(0,0,0,0.04); } .nav-content { @@ -244,52 +258,67 @@ html[dir="rtl"] { .nav-brand { display: flex; align-items: center; - gap: 10px; + gap: 9px; } .nav-brand-icon { color: var(--brand-icon); + flex-shrink: 0; } .nav-brand-text { - font-family: 'DM Sans', 'Inter', -apple-system, sans-serif; - font-size: 1.25rem; + display: flex; + align-items: baseline; + gap: 5px; +} + +.nav-brand-name { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 0.9375rem; font-weight: 700; - color: var(--brand-text); + color: var(--text-primary); letter-spacing: -0.02em; } +.nav-brand-tag { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} + .data-source-select { appearance: none; -webkit-appearance: none; background: transparent; border: none; - color: var(--brand-icon); + color: var(--accent); font-family: inherit; - font-size: inherit; + font-size: 0.8125rem; font-weight: 500; cursor: pointer; - padding: 2px 20px 2px 6px; - margin-inline-start: 4px; - border-radius: 6px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%233b82f6' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + padding: 2px 18px 2px 4px; + border-radius: 5px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='11' viewBox='0 0 24 24' fill='none' stroke='%232563eb' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); background-repeat: no-repeat; - background-position: right 4px center; - transition: background-color 0.15s ease; + background-position: right 3px center; + transition: background-color 0.12s; + margin-inline-start: 2px; } .data-source-select:hover { - background-color: rgba(59, 130, 246, 0.08); + background-color: var(--accent-light); } .data-source-select:focus { outline: 2px solid var(--accent); outline-offset: 1px; - background-color: rgba(59, 130, 246, 0.12); } .data-source-select option { - color: var(--brand-text); + color: var(--text-primary); background: var(--surface); font-weight: 500; } @@ -300,81 +329,94 @@ html[dir="rtl"] { .nav-links { display: flex; - gap: 8px; + align-items: center; + gap: 2px; +} + +/* Separator between nav links and utility buttons */ +.nav-sep { + display: block; + width: 1px; + height: 20px; + background: var(--border); + margin: 0 8px; + flex-shrink: 0; + align-self: center; } .nav-link { - color: var(--text-secondary); + color: var(--text-muted); text-decoration: none; - padding: 10px 20px; + padding: 7px 13px; border-radius: 8px; - font-size: 0.9375rem; - font-weight: 600; - transition: all 0.2s; + font-size: 0.875rem; + font-weight: 500; + transition: background 0.12s, color 0.12s; display: flex; align-items: center; - gap: 8px; - background: var(--bg); - border: 1px solid transparent; + gap: 7px; + background: transparent; + border: none; + white-space: nowrap; } .nav-link svg { flex-shrink: 0; - opacity: 0.7; + opacity: 0.6; + transition: opacity 0.12s; } .nav-link:hover { - background: var(--surface); - border-color: var(--border); + background: var(--muted-light); color: var(--text-primary); } .nav-link:hover svg { - opacity: 1; + opacity: 0.85; } .nav-link.active { - background: var(--primary); - color: var(--text-inverse); - border-color: var(--primary); + background: var(--accent-light); + color: var(--accent); + font-weight: 600; } .nav-link.active svg { opacity: 1; } -/* Language Toggle Button */ +/* Utility icon buttons (theme + lang toggle) */ .nav-lang-toggle { display: flex; align-items: center; justify-content: center; - gap: 6px; - padding: 8px 14px; - border-radius: 8px; + gap: 5px; + height: 32px; + padding: 0 10px; + border-radius: 7px; font-size: 0.75rem; font-weight: 600; cursor: pointer; - transition: all 0.2s ease; + transition: background 0.12s, color 0.12s; background: transparent; - border: 1px solid var(--border); + border: none; color: var(--text-muted); - min-width: 44px; letter-spacing: 0.02em; + white-space: nowrap; } .nav-lang-toggle:hover { - background: var(--surface); + background: var(--muted-light); color: var(--text-primary); - border-color: var(--primary); } .nav-lang-toggle:active { - transform: scale(0.96); + transform: scale(0.95); } /* RTL adjustments */ html[dir="rtl"] .nav-lang-toggle { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } /* Offline Badge */ @@ -382,11 +424,11 @@ html[dir="rtl"] .nav-lang-toggle { display: flex; align-items: center; gap: 5px; - padding: 6px 12px; + padding: 4px 10px; background: var(--warning-bg); color: var(--warning-text); border-radius: 6px; - font-size: 0.75rem; + font-size: 0.6875rem; font-weight: 600; border: 1px solid var(--warning-border); } @@ -400,27 +442,28 @@ html[dir="rtl"] .nav-lang-toggle { display: flex; align-items: center; justify-content: center; - padding: 8px 10px; - border-radius: 8px; + width: 32px; + height: 32px; + border-radius: 7px; cursor: pointer; - transition: all 0.2s ease; + transition: background 0.12s, color 0.12s; background: transparent; - border: 1px solid var(--border); + border: none; color: var(--text-muted); + flex-shrink: 0; } .nav-refresh-btn:hover { - background: var(--surface); + background: var(--muted-light); color: var(--text-primary); - border-color: var(--primary); } .nav-refresh-btn:active { - transform: scale(0.96); + transform: scale(0.95); } .nav-refresh-btn:disabled { - opacity: 0.5; + opacity: 0.4; cursor: not-allowed; } @@ -672,6 +715,13 @@ table tbody tr:hover { justify-content: space-between; align-items: center; cursor: pointer; + background: none; + border: none; + width: 100%; + padding: 0; + text-align: left; + font: inherit; + color: inherit; } .controls-header h3 { @@ -762,6 +812,292 @@ table tbody tr:hover { border-color: var(--accent); } +/* Always-visible period selector row (Dashboard) */ +.period-selector-row { + display: flex; + align-items: center; + margin-bottom: 12px; +} + +/* Comparison period row (bare, no collapsible wrapper) */ +.comparison-periods { + margin-bottom: 12px; +} + +/* Period Picker (legacy) */ +.period-picker { width: 100%; } +.period-picker-row { display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-end; } + +/* ── DateRangePicker ─────────────────────────── */ +.drp { + position: relative; + display: inline-block; +} + +.drp-standalone { + padding: 8px 0 4px; +} + +.drp-trigger { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 14px; + background: var(--surface); + border: 1.5px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; + letter-spacing: -0.01em; +} + +.drp-trigger:hover { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08); +} + +.drp-trigger.drp-open { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); + background: var(--accent-light, #dbeafe); + color: var(--accent); +} + +.drp-cal-icon { + color: var(--text-muted); + flex-shrink: 0; + transition: color 0.15s; +} + +.drp-trigger.drp-open .drp-cal-icon { + color: var(--accent); +} + +.drp-trigger-label { + font-variant-numeric: tabular-nums; +} + +.drp-chevron { + color: var(--text-muted); + flex-shrink: 0; +} + +/* Panel */ +.drp-panel { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 300; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 12px 32px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.06); + padding: 16px; + width: min(324px, calc(100vw - 32px)); + animation: drpIn 140ms cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes drpIn { + from { opacity: 0; transform: translateY(-6px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +/* Year nav */ +.drp-year-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} + +.drp-year-val { + font-size: 1.125rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.02em; +} + +.drp-year-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--bg); + color: var(--text-secondary); + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} + +.drp-year-btn:hover:not(:disabled) { + background: var(--accent); + border-color: var(--accent); + color: var(--text-inverse); +} + +.drp-year-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* Section labels */ +.drp-group-label { + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin: 10px 0 6px; +} + +.drp-year-row + .drp-group-label { + margin-top: 0; +} + +/* Chips */ +.drp-chips { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.drp-chip { + padding: 4px 9px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: background 0.1s, border-color 0.1s, color 0.1s; + letter-spacing: 0.01em; +} + +.drp-chip:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-light); +} + +.drp-chip.drp-chip-active { + background: var(--accent); + border-color: var(--accent); + color: var(--text-inverse); + font-weight: 600; +} + +.drp-chip-wide { + padding-left: 12px; + padding-right: 12px; +} + +/* Season chips — base (all browsers) */ +.drp-chip-season { + border-color: var(--border); + color: var(--text-secondary); +} + +.drp-chip-season:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-light); +} + +.drp-chip-season.drp-chip-active { + background: var(--accent); + border-color: var(--accent); + color: var(--text-inverse); +} + +/* Enhanced with color-mix where supported */ +@supports (color: color-mix(in srgb, red, blue)) { + .drp-chip-season { + border-color: color-mix(in srgb, var(--sc, var(--border)) 50%, var(--border)); + color: var(--sc, var(--text-secondary)); + } + + .drp-chip-season:hover { + border-color: var(--sc, var(--accent)); + color: var(--sc, var(--accent)); + background: color-mix(in srgb, var(--sc, var(--accent)) 8%, transparent); + } + + .drp-chip-season.drp-chip-active { + background: var(--sc, var(--accent)); + border-color: var(--sc, var(--accent)); + } +} + +/* Divider + custom inputs */ +.drp-divider { + height: 1px; + background: var(--border); + margin: 12px 0 10px; +} + +.drp-custom { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.drp-custom-field { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.drp-custom-field label { + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-muted); +} + +.drp-custom-field input[type="date"] { + padding: 7px 9px; + border: 1px solid var(--border); + border-radius: 7px; + font-size: 0.825rem; + background: var(--bg); + color: var(--text-primary); + width: 100%; + transition: border-color 0.12s, box-shadow 0.12s; +} + +.drp-custom-field input[type="date"]:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12); +} + +.drp-custom-sep { + font-size: 0.75rem; + color: var(--text-muted); + padding-bottom: 9px; + flex-shrink: 0; +} + +/* Right-align panel when near viewport edge */ +@media (max-width: 480px) { + .drp-panel { + left: auto; + right: 0; + width: 290px; + } +} + /* Multi-select */ .multi-select { position: relative; @@ -1147,6 +1483,62 @@ tr.editing td { padding: 0 12px; } +/* Comparison Period Picker Layout */ +.comparison-periods { + display: flex; + align-items: flex-start; + gap: 24px; + width: 100%; +} + +.comparison-period-block { + flex: 1; + min-width: 0; +} + +.comparison-period-label { + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 8px; + padding: 3px 8px; + border-radius: 4px; + display: inline-block; +} + +.comparison-period-label.curr-label { + background: rgba(37, 99, 235, 0.1); + color: var(--accent); +} + +.comparison-period-label.prev-label { + background: var(--muted-light); + color: var(--text-muted); +} + +.comparison-period-vs { + align-self: center; + font-size: 0.875rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 0 4px; + flex-shrink: 0; +} + +@media (max-width: 900px) { + .comparison-periods { + flex-direction: column; + gap: 20px; + } + .comparison-period-vs { + align-self: flex-start; + padding: 4px 0; + } +} + /* Comparison Metrics */ .comparison-grid { display: grid; @@ -1420,12 +1812,12 @@ tr.editing td { width: 8px; height: 8px; border-radius: 4px; - padding: 8px; + padding: 18px; background-clip: content-box; cursor: pointer; - transition: all 0.3s ease; - min-width: 24px; - min-height: 24px; + transition: background 0.3s ease, width 0.3s ease; + min-width: 44px; + min-height: 44px; display: flex; align-items: center; justify-content: center; @@ -1585,28 +1977,28 @@ tr.editing td { .nav-links { display: none; } - + .nav-bar { - padding: 12px 16px; - height: auto; + padding: 0 16px; + height: 52px; } - + .nav-content { justify-content: center; } - - .nav-brand-text { - font-size: 1rem; + + .nav-brand-name { + font-size: 0.9375rem; } - + .nav-brand-icon { width: 18px; height: 18px; } - + .data-source-select { - font-size: 0.9rem; - padding: 2px 18px 2px 4px; + font-size: 0.8125rem; + padding: 2px 16px 2px 4px; } /* Mobile Bottom Navigation */ @@ -1859,10 +2251,10 @@ tr.editing td { @media (max-width: 400px) { /* Very small screens */ .nav-bar { - padding: 6px 8px; + padding: 0 8px; } - - .nav-brand { + + .nav-brand-name { font-size: 0.875rem; } diff --git a/src/App.tsx b/src/App.tsx index 5777453..26ace30 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 PeriodSelectorDemo = lazy(() => import('./components/PeriodSelectorDemo')); +const DashboardDemo = lazy(() => import('./components/DashboardDemo')); 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(false); const [cacheInfo, setCacheInfo] = useState(null); - const [showDataLabels, setShowDataLabels] = useState(false); const [includeVAT, setIncludeVAT] = useState(true); const [dataSource, setDataSource] = useState('museums'); const [seasons, setSeasons] = useState([]); @@ -188,7 +191,7 @@ function App() { return (
-
+ ); +} + +// ─── period hero ────────────────────────────────────────────────── +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(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 ( +
+
+
+
{periodNameL(start, end, L)}
+
{dateRangeTextL(start, end, L)}
+
+ +
+ {open && { onChange(s,e); setOpen(false); }} availableYears={availableYears} L={L} />} +
+ ); +} + +// ─── multi-select ───────────────────────────────────────────────── +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(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 ( +
+ + {open && ( +
+
+ {options.map(opt => ( + + ))} +
+ {value.length>0 && } +
+ )} +
+ ); +} + +// ─── metric card ────────────────────────────────────────────────── +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 ( +
+

{title}

+
{fmt(curr)}
+
+ {isFinite(change) + ? {isPos?'▲':isNeg?'▼':'—'} {Math.abs(change).toFixed(1)}% + : {newLabel??'New'}} + {fmt(prev)} +
+
+ ); +} + +// ─── main page ──────────────────────────────────────────────────── +export default function DashboardDemo({ data, seasons: _seasons, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: Props) { + const { lang: activeLang, setLanguage } = useLanguage(); + const L = activeLang === 'ar' ? AR : EN; + const curr = currentMonth(); + const [start, setStart] = useState(curr.start); + const [end, setEnd] = useState(curr.end); + const [selDistricts, setSelDistricts] = useState([]); + const [selChannels, setSelChannels] = useState([]); + const [selMuseums, setSelMuseums] = useState([]); + const [metric, setMetric] = useState('revenue'); + const [gran, setGran] = useState('week'); + const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar'); + const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie'); + const [districtChartType, setDistrictChartType] = useState<'bar'|'pie'>('pie'); + const [museumDisplayMode, setMuseumDisplayMode] = useState<'absolute'|'percent'>('absolute'); + const [channelDisplayMode, setChannelDisplayMode] = useState<'absolute'|'percent'>('absolute'); + const [districtDisplayMode, setDistrictDisplayMode] = useState<'absolute'|'percent'>('absolute'); + + const perm = useMemo(() => { + if (!allowedMuseums || !allowedChannels) return []; + let d = data; + if (allowedMuseums.length) d = d.filter(r => allowedMuseums.includes(r.museum_name)); + if (allowedChannels.length) d = d.filter(r => allowedChannels.includes(r.channel)); + return d; + }, [data, allowedMuseums, allowedChannels]); + + const availableYears = useMemo(() => { + const s = new Set(); perm.forEach(r => r.date && s.add(parseInt(r.date.slice(0,4)))); + const a = Array.from(s).sort((a,b) => b-a); return a.length ? a : [new Date().getFullYear()]; + }, [perm]); + + const allDistricts = useMemo(() => getUniqueDistricts(perm), [perm]); + const allChannels = useMemo(() => getUniqueChannels(perm), [perm]); + const allMuseums = useMemo(() => getUniqueMuseums(perm), [perm]); + + const applyFilters = useCallback((rows: MuseumRecord[]) => { + let d = rows; + if (selChannels.length) d = d.filter(r => selChannels.includes(r.channel)); + if (selMuseums.length) d = d.filter(r => selMuseums.includes(r.museum_name)); + if (selDistricts.length) d = d.filter(r => selDistricts.includes(r.district)); + return d; + }, [selDistricts, selChannels, selMuseums]); + + const filteredData = useMemo(() => applyFilters(filterDataByDateRange(perm, start, end, {})), [perm, start, end, applyFilters]); + const prevStart = shiftYear(start), prevEnd = shiftYear(end); + const prevData = useMemo(() => applyFilters(filterDataByDateRange(perm, prevStart, prevEnd, {})), [perm, prevStart, prevEnd, applyFilters]); + + const currM = useMemo(() => calculateMetrics(filteredData, includeVAT), [filteredData, includeVAT]); + const prevM = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]); + + const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; + + const getVal = useCallback((rows: MuseumRecord[], m: string) => { + if (m==='avgRevenue') { + const rev = rows.reduce((s,r) => s + parseFloat(String((r as any)[revenueField]||0)), 0); + const vis = rows.reduce((s,r) => s + parseInt(String(r.visits||0)), 0); + return vis>0 ? rev/vis : 0; + } + const f: Record = { revenue: revenueField, visitors:'visits', tickets:'tickets' }; + return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0); + }, [revenueField]); + + const trendData = useMemo(() => { + const group = (rows: MuseumRecord[], ps: string) => { + const s = new Date(ps); const acc: Record = {}; + rows.forEach(r => { + if (!r.date) return; + const diff = Math.floor((new Date(r.date).getTime() - s.getTime()) / 86400000); + const key = gran==='month' ? Math.floor(diff/30)+1 : gran==='week' ? Math.floor(diff/7)+1 : diff+1; + if (!acc[key]) acc[key]=[]; acc[key].push(r); + }); + const res: Record = {}; + Object.entries(acc).forEach(([k,v]) => res[Number(k)] = getVal(v, metric)); return res; + }; + const pg = group(prevData, prevStart), cg = group(filteredData, start); + const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1); + const labels = Array.from({length:maxK}, (_,i) => + gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(new Date(start).getMonth()+i)%12] : `D${i+1}` + ); + const prevYear = parseInt(start.slice(0,4))-1; + return { + labels, + datasets: [ + { label:`${prevYear}`, data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:1.5, tension:0.4, pointRadius:0, borderDash:[4,3] }, + { label:start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'18', borderWidth:2.5, tension:0.4, fill:true, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.primary }, + ] + }; + }, [filteredData, prevData, prevStart, start, metric, gran, getVal, L]); + + const museumData = useMemo(() => { + const g = groupByMuseum(filteredData, includeVAT); + const getM = (d: typeof g[string]) => metric==='visitors' ? d.visitors : metric==='tickets' ? d.tickets : d.revenue; + const entries = Object.entries(g).sort((a,b) => getM(b[1]) - getM(a[1])); + return { labels:entries.map(([k]) => k), datasets:[{ label:metric, data:entries.map(([,v]) => getM(v)), backgroundColor:chartPalette, borderRadius:4 }] }; + }, [filteredData, includeVAT, metric]); + + const channelData = useMemo(() => { + const g = groupByChannel(filteredData, includeVAT); + const getM = (d: typeof g[string]) => metric==='visitors' ? d.visitors : metric==='tickets' ? d.tickets : d.revenue; + const entries = Object.entries(g).sort((a,b) => getM(b[1]) - getM(a[1])); + return { labels:entries.map(([k]) => k), datasets:[{ label:metric, data:entries.map(([,v]) => getM(v)), backgroundColor:chartPalette, borderRadius:4 }] }; + }, [filteredData, includeVAT, metric]); + + const districtData = useMemo(() => { + const g = groupByDistrict(filteredData, includeVAT); + const getM = (d: typeof g[string]) => metric==='visitors' ? d.visitors : metric==='tickets' ? d.tickets : d.revenue; + const entries = Object.entries(g).sort((a,b) => getM(b[1]) - getM(a[1])); + return { labels:entries.map(([k]) => k), datasets:[{ label:metric, data:entries.map(([,v]) => getM(v)), backgroundColor:chartPalette.map(c => c+'cc'), borderRadius:4 }] }; + }, [filteredData, includeVAT, metric]); + + const toPercent = (chartData: any) => { + const total = chartData.datasets[0].data.reduce((s: number, v: number) => s+v, 0); + if (total===0) return chartData; + return { ...chartData, datasets: [{ ...chartData.datasets[0], data: chartData.datasets[0].data.map((v: number) => parseFloat(((v/total)*100).toFixed(1))) }] }; + }; + + const museumDisplay = useMemo(() => museumDisplayMode==='percent' ? toPercent(museumData) : museumData, [museumData, museumDisplayMode]); + const channelDisplay = useMemo(() => channelDisplayMode==='percent' ? toPercent(channelData) : channelData, [channelData, channelDisplayMode]); + const districtDisplay = useMemo(() => districtDisplayMode==='percent' ? toPercent(districtData) : districtData, [districtData, districtDisplayMode]); + + const estimatePilgrims = useCallback((s: string, e: string) => { + const sd=new Date(s), ed=new Date(e); let total=0, has=false; + for (let y=sd.getFullYear(); y<=ed.getFullYear(); y++) { + for (let q=1; q<=4; q++) { + const qs=new Date(y,(q-1)*3,1), qe=new Date(y,q*3,0); + if (qeed) continue; + const p=umrahData[y]?.[q]; if (!p) continue; + const os=new Date(Math.max(qs.getTime(),sd.getTime())), oe=new Date(Math.min(qe.getTime(),ed.getTime())); + total+=p*((oe.getTime()-os.getTime())/86400000+1)/((qe.getTime()-qs.getTime())/86400000+1); has=true; + } + } + return has ? Math.round(total) : null; + }, []); + + const currPilgrims = useMemo(() => estimatePilgrims(start, end), [start, end, estimatePilgrims]); + const prevPilgrims = useMemo(() => estimatePilgrims(prevStart, prevEnd), [prevStart, prevEnd, estimatePilgrims]); + const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null; + const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null; + + const baseOpts = useMemo(() => createBaseOptions(false), []); + const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:10, padding:10, font:{ size:11 } } } } }; + const barHorizOpts: any = { ...chartOpts, indexAxis:'y', plugins:{ ...chartOpts.plugins, legend:{ display:false } } }; + const barNoLegend: any = { ...chartOpts, plugins:{ ...chartOpts.plugins, legend:{ display:false } } }; + const pieOptions: any = useMemo(() => ({ + responsive: true, maintainAspectRatio: false, + plugins: { + legend: { display:true, position:'right', labels:{ boxWidth:12, padding:10, font:{ size:11 }, color:'#64748b' } }, + tooltip: baseOpts.plugins.tooltip, + datalabels: { display:false }, + } + }), [baseOpts]); + + const metricOpts = [{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors }, { value:'tickets', label:L.tickets }]; + const granOpts = [{ value:'day', label:L.daily }, { value:'week', label:L.weekly }, { value:'month', label:L.monthly }]; + const hasFilters = selDistricts.length>0 || selChannels.length>0 || selMuseums.length>0; + + return ( +
+ + +

{L.pageTitle}

+

{L.pageSub}

+ + { setStart(s); setEnd(e); }} availableYears={availableYears} L={L} /> + +
+ {L.filter} +
+ + + + {hasFilters && } +
+ + +
+
+ + +
+
+ +

{L.keyMetrics}

+
+ + + + + {currPilgrims!==null && prevPilgrims!==null && + } + {currCapture!==null && prevCapture!==null && + } +
+ +

{L.charts}

+
+ +
+
+

{L.trendTitle}

+
+ {metricOpts.map(o => )} +
+ {granOpts.map(o => )} +
+
+
+
+ +
+
+

{L.museumTitle}

+
+ {metricOpts.map(o => )} +
+ + +
+ + +
+
+
+ {museumChartType==='pie' ? : } +
+
+ +
+
+

{L.channelTitle}

+
+ {metricOpts.map(o => )} +
+ + +
+ + +
+
+
+ {channelChartType==='pie' ? : } +
+
+ +
+
+

{L.districtTitle}

+
+ {metricOpts.map(o => )} +
+ + +
+ + +
+
+
+ {districtChartType==='pie' ? : } +
+
+ +
+
+ ); +} diff --git a/src/components/Login.tsx b/src/components/Login.tsx index 24ec0ac..0372445 100644 --- a/src/components/Login.tsx +++ b/src/components/Login.tsx @@ -53,7 +53,9 @@ function Login({ onLogin }: LoginProps) {

{t('login.subtitle')}

+ void; + allowedMuseums: string[] | null; + allowedChannels: string[] | null; + userRole?: string; + theme?: string; + toggleTheme?: () => void; + switchLanguage?: () => void; + handleRefresh?: () => void; + refreshing?: boolean; +} + +// ── Icons ──────────────────────────────────────────────────────────────────── + +const IconDashboard = () => ( + + + + + + +); + +const IconCompare = () => ( + + + + + +); + +const IconSettings = () => ( + + + + +); + +const IconStar = () => ( + + + +); + +const IconGlobe = () => ( + + + + + +); + +const IconSun = () => ( + + + + + + + +); + +const IconMoon = () => ( + + + +); + +const IconSystem = () => ( + + + +); + +const IconLang = () => ( + + + + + +); + +const IconRefresh = () => ( + + + + +); + +const IconMenu = () => ( + + + +); + +const IconChevronLeft = () => ( + + + +); + +const IconChevronRight = () => ( + + + +); + +// ── Styles ──────────────────────────────────────────────────────────────────── + +const CSS = ` +@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Outfit:wght@300;400;500;600;700&display=swap'); + +.nd-layout { + display: flex; + height: 100vh; + overflow: hidden; + font-family: 'Outfit', sans-serif; +} + +/* ── Sidebar ── */ +.nd-sidebar { + width: 240px; + min-width: 240px; + height: 100vh; + background: #0f172a; + color: #94a3b8; + display: flex; + flex-direction: column; + transition: width 0.25s cubic-bezier(0.4,0,0.2,1), min-width 0.25s cubic-bezier(0.4,0,0.2,1); + overflow: hidden; + position: relative; + z-index: 100; + flex-shrink: 0; +} + +.nd-sidebar.nd-collapsed { + width: 60px; + min-width: 60px; +} + +/* Brand */ +.nd-brand { + display: flex; + align-items: center; + gap: 12px; + padding: 20px 14px 18px; + border-bottom: 1px solid rgba(148,163,184,0.08); + flex-shrink: 0; + min-height: 72px; +} + +.nd-brand-monogram { + width: 34px; + height: 34px; + min-width: 34px; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + font-family: 'DM Serif Display', serif; + font-size: 13px; + color: #fff; + letter-spacing: -0.5px; + box-shadow: 0 2px 8px rgba(99,102,241,0.4); + flex-shrink: 0; +} + +.nd-brand-text { + overflow: hidden; + white-space: nowrap; + transition: opacity 0.2s, transform 0.2s; + transform-origin: left; +} + +.nd-brand-name { + font-family: 'DM Serif Display', serif; + font-size: 16px; + color: #f1f5f9; + line-height: 1.1; + letter-spacing: 0.1px; +} + +.nd-brand-sub { + font-family: 'Outfit', sans-serif; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.15em; + text-transform: uppercase; + color: #6366f1; + margin-top: 2px; +} + +/* Collapse when sidebar is collapsed */ +.nd-collapsed .nd-brand-text, +.nd-collapsed .nd-section-label, +.nd-collapsed .nd-item-label, +.nd-collapsed .nd-ds-select, +.nd-collapsed .nd-util-label { + opacity: 0; + pointer-events: none; + max-width: 0; + overflow: hidden; +} + +/* Collapse toggle button */ +.nd-collapse-btn { + position: absolute; + top: 22px; + right: -13px; + width: 26px; + height: 26px; + background: #1e293b; + border: 1px solid rgba(148,163,184,0.15); + border-radius: 50%; + color: #64748b; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; + z-index: 101; +} + +.nd-collapse-btn:hover { + background: #6366f1; + color: #fff; + border-color: #6366f1; +} + +/* Nav scroll area */ +.nd-nav { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 8px 0; +} + +.nd-nav::-webkit-scrollbar { width: 3px; } +.nd-nav::-webkit-scrollbar-track { background: transparent; } +.nd-nav::-webkit-scrollbar-thumb { background: rgba(148,163,184,0.15); border-radius: 2px; } + +/* Section */ +.nd-section { margin-bottom: 2px; } + +.nd-section-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #334155; + padding: 10px 16px 4px; + white-space: nowrap; + transition: opacity 0.15s; +} + +.nd-divider { + height: 1px; + background: rgba(148,163,184,0.07); + margin: 6px 0; +} + +/* Nav item */ +.nd-item { + display: flex; + align-items: center; + gap: 11px; + padding: 9px 16px; + font-size: 13px; + font-weight: 400; + color: #64748b; + text-decoration: none; + transition: background 0.12s, color 0.12s, border-color 0.12s; + position: relative; + white-space: nowrap; + border-left: 3px solid transparent; + line-height: 1; +} + +.nd-item:hover { + background: rgba(148,163,184,0.07); + color: #94a3b8; +} + +.nd-item.nd-active { + background: rgba(99,102,241,0.1); + color: #a5b4fc; + border-left-color: #6366f1; + font-weight: 500; +} + +.nd-item-icon { + width: 16px; + height: 16px; + min-width: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0.65; +} + +.nd-item.nd-active .nd-item-icon { opacity: 1; } + +.nd-item-label { + transition: opacity 0.15s; + overflow: hidden; +} + +/* Collapsed item tooltip */ +.nd-collapsed .nd-item { + justify-content: center; + padding: 10px; + border-left: none; + border-radius: 0; +} + +.nd-collapsed .nd-item.nd-active { + background: rgba(99,102,241,0.15); +} + +/* Bottom utilities */ +.nd-bottom { + flex-shrink: 0; + border-top: 1px solid rgba(148,163,184,0.08); + padding: 10px 0 8px; +} + +.nd-bottom-row { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 14px; +} + +.nd-util-btn { + width: 32px; + height: 32px; + min-width: 32px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(148,163,184,0.1); + border-radius: 7px; + color: #64748b; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; + flex-shrink: 0; +} + +.nd-util-btn:hover { + background: rgba(99,102,241,0.15); + color: #a5b4fc; + border-color: rgba(99,102,241,0.3); +} + +.nd-util-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +.nd-util-btn.nd-refreshing svg { + animation: nd-spin 1s linear infinite; +} + +@keyframes nd-spin { to { transform: rotate(360deg); } } + +.nd-util-label { + font-size: 11.5px; + color: #475569; + transition: opacity 0.15s; + white-space: nowrap; + overflow: hidden; +} + +/* ── Main content ── */ +.nd-content { + flex: 1; + height: 100vh; + overflow-y: auto; + overflow-x: hidden; + position: relative; + min-width: 0; +} + +/* ── Mobile hamburger ── */ +.nd-hamburger { + display: none; + position: fixed; + top: 14px; + left: 14px; + z-index: 200; + width: 40px; + height: 40px; + background: #0f172a; + border: 1px solid rgba(148,163,184,0.15); + border-radius: 10px; + color: #94a3b8; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); +} + +/* ── Overlay ── */ +.nd-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.55); + z-index: 99; + backdrop-filter: blur(2px); +} + +/* ── Mobile ── */ +@media (max-width: 768px) { + .nd-sidebar { + position: fixed; + left: 0; + top: 0; + height: 100vh; + transform: translateX(-100%); + transition: transform 0.25s cubic-bezier(0.4,0,0.2,1); + z-index: 100; + width: 260px !important; + min-width: 260px !important; + } + + .nd-sidebar.nd-open { transform: translateX(0); } + + .nd-content { width: 100%; } + + .nd-hamburger { display: flex; } + + .nd-overlay.nd-visible { display: block; } + + .nd-collapse-btn { display: none; } +} +`; + +// ── Component ───────────────────────────────────────────────────────────────── + +export default function NavDemo({ + data, + seasons, + includeVAT, + setIncludeVAT, + allowedMuseums, + allowedChannels, + userRole = 'viewer', + theme = 'light', + toggleTheme, + switchLanguage, + handleRefresh, + refreshing = false, +}: NavDemoProps) { + const [collapsed, setCollapsed] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); + const { pathname } = useLocation(); + + const ThemeIcon = theme === 'dark' ? : theme === 'light' ? : ; + + const navSections = [ + { + label: 'Navigate', + items: [ + { to: '/', label: 'Dashboard', icon: }, + { to: '/comparison', label: 'Comparison', icon: }, + ...(userRole === 'admin' + ? [{ to: '/settings', label: 'Settings', icon: }] + : [] + ), + ], + }, + { + label: 'Arabic', + items: [ + { to: '/ar', label: 'نظرة عامة (AR)', icon: }, + { to: '/comparison-ar', label: 'مقارنة (AR)', icon: }, + ], + }, + ]; + + return ( + <> + +
+ + {/* Mobile backdrop */} +
setMobileOpen(false)} + /> + + {/* Sidebar */} + + + {/* Mobile hamburger */} + + + {/* Main content — DashboardDemo embedded */} +
+ +
+ +
+ + ); +} diff --git a/src/components/PeriodSelectorDemo.tsx b/src/components/PeriodSelectorDemo.tsx new file mode 100644 index 0000000..d0a8ae6 --- /dev/null +++ b/src/components/PeriodSelectorDemo.tsx @@ -0,0 +1,671 @@ +import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { Line, Bar } from 'react-chartjs-2'; +import { + filterDataByDateRange, calculateMetrics, formatCurrency, formatNumber, + getUniqueChannels, getUniqueMuseums, getUniqueDistricts, + umrahData +} from '../services/dataService'; +import { chartColors, createBaseOptions } from '../config/chartConfig'; +import type { MuseumRecord, Season } from '../types'; +import { useLanguage } from '../contexts/LanguageContext'; + +interface Props { + data: MuseumRecord[]; + seasons: Season[]; + includeVAT: boolean; + allowedMuseums: string[] | null; + allowedChannels: string[] | null; + lang?: 'en' | 'ar'; +} + +// ─── language config ────────────────────────────────────────────── +interface LC { + dir: 'ltr' | 'rtl'; + fontImport: string; + bodyFont: string; + displayFont: string; + monoFont: string; + monthFull: string[]; + monthShort: string[]; + periods: Record; + fullYearLabel: (y: number) => string; + dateRangeSep: string; + backLink: string; + backTo: string; + pageTitle: string; + pageSub: string; + currentRole: string; previousRole: string; + currentHint: string; previousHint: string; + changePeriod: string; close: string; + vs: string; + filter: string; + allDistricts: string; allChannels: string; allMuseums: string; + countDistricts: (n: number) => string; + countChannels: (n: number) => string; + countMuseums: (n: number) => string; + reset: string; + keyMetrics: string; + revenue: string; visitors: string; tickets: string; avgRev: string; + pilgrims: string; captureRate: string; + trendTitle: string; museumTitle: string; + daily: string; weekly: string; monthly: string; + newLabel: string; clearSel: string; + monthSection: string; periodSection: string; + from: string; to: string; +} + +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: '← Overview', backTo: '/', + pageTitle: 'Period Comparison', pageSub: 'Compare any two periods side by side.', + currentRole: 'This period', previousRole: 'Compared to', + currentHint: 'primary', previousHint: 'auto year −1', + changePeriod: 'Change period', close: 'Close', + vs: 'vs', + 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', + keyMetrics: 'Key Metrics', + revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets', + avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %', + trendTitle: 'Trend over time', museumTitle: 'By museum', + daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly', + newLabel: 'New', clearSel: 'Clear selection', + monthSection: 'Month', periodSection: 'Quarter · Half · Year', + from: 'From', to: 'To', +}; + +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: 'قارن بين فترتين زمنيتين.', + currentRole: 'الفترة الحالية', previousRole: 'مقارنةً بـ', + currentHint: 'رئيسية', previousHint: 'تلقائياً −١ سنة', + changePeriod: 'تغيير الفترة', close: 'إغلاق', + vs: 'مقابل', + filter: 'تصفية', + allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف', + countDistricts: (n) => `${n} مناطق`, + countChannels: (n) => `${n} قنوات`, + countMuseums: (n) => `${n} متاحف`, + reset: 'إعادة ضبط', + keyMetrics: 'المؤشرات الرئيسية', + revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر', + avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %', + trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف', + daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري', + newLabel: 'جديد', clearSel: 'مسح التحديد', + monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة', + from: 'من', to: 'إلى', +}; + +// ─── date helpers ───────────────────────────────────────────────── +const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec']; + +function isLeap(y: number) { return (y%4===0 && y%100!==0) || y%400===0; } + +function makePresets(y: number): Record { + 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`}, + }; +} + +function guessPreset(start: string, end: string) { + 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; +} + +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}`; +} + +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)}`; +} + +function currentMonth() { + 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())}` }; +} +function shiftYear(s: string) { return s.replace(/^(\d{4})/, (_,y) => String(parseInt(y)-1)); } + +// ─── inline picker ──────────────────────────────────────────────── +function InlinePicker({ start, end, onChange, availableYears, L }: { + start: string; end: string; onChange: (s: string, e: string) => 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(g?.key ?? null); + const minY = Math.min(...availableYears), maxY = Math.max(...availableYears); + + const pick = (key: string) => { const r=makePresets(year)[key]; if(!r) return; setActive(key); onChange(r.start, r.end); }; + const shift = (d: number) => { + const ny=year+d; if(nymaxY) return; setYear(ny); + if(active && makePresets(ny)[active]) onChange(makePresets(ny)[active].start, makePresets(ny)[active].end); + }; + + return ( +
+
+ + {year} + +
+

{L.monthSection}

+
+ {MONTH_KEYS.map((k,i) => ( + + ))} +
+

{L.periodSection}

+
+ {['q1','q2','q3','q4','h1','h2'].map(k => ( + + ))} + +
+
+
+
{ setActive(null); onChange(e.target.value, end); }} />
+ {L.dateRangeSep} +
{ setActive(null); onChange(start, e.target.value); }} />
+
+
+ ); +} + +// ─── period card ────────────────────────────────────────────────── +function PeriodCard({ role, hint, start, end, variant, onChange, availableYears, L }: { + role: string; hint: string; start: string; end: string; + variant: 'current'|'previous'; + onChange: (s: string, e: string) => void; + availableYears: number[]; L: LC; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(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 ( +
+
+
+
+ {role} + {hint} +
+
{periodNameL(start, end, L)}
+
{dateRangeTextL(start, end, L)}
+ +
+ {open && { onChange(s,e); setOpen(false); }} availableYears={availableYears} L={L} />} +
+ ); +} + +// ─── multi-select ───────────────────────────────────────────────── +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(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 ( +
+ + {open && ( +
+
+ {options.map(opt => ( + + ))} +
+ {value.length>0 && } +
+ )} +
+ ); +} + +// ─── metric card ────────────────────────────────────────────────── +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 ( +
+

{title}

+
{fmt(curr)}
+
+ {isFinite(change) + ? {isPos?'▲':isNeg?'▼':'—'} {Math.abs(change).toFixed(1)}% + : {newLabel??'New'}} + {fmt(prev)} +
+
+ ); +} + +// ─── main page ──────────────────────────────────────────────────── +export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels }: Props) { + const { lang: activeLang, setLanguage } = useLanguage(); + const L = activeLang === 'ar' ? AR : EN; + const curr = currentMonth(); + const [currStart, setCurrStart] = useState(curr.start); + const [currEnd, setCurrEnd] = useState(curr.end); + const [prevStart, setPrevStart] = useState(() => shiftYear(curr.start)); + const [prevEnd, setPrevEnd] = useState(() => shiftYear(curr.end)); + const [selDistricts, setSelDistricts] = useState([]); + const [selChannels, setSelChannels] = useState([]); + const [selMuseums, setSelMuseums] = useState([]); + const [metric, setMetric] = useState('revenue'); + const [gran, setGran] = useState('week'); + + const perm = useMemo(() => { + if (!allowedMuseums || !allowedChannels) return []; + let d = data; + if (allowedMuseums.length) d = d.filter(r => allowedMuseums.includes(r.museum_name)); + if (allowedChannels.length) d = d.filter(r => allowedChannels.includes(r.channel)); + return d; + }, [data, allowedMuseums, allowedChannels]); + + const availableYears = useMemo(() => { + const s = new Set(); perm.forEach(r => r.date && s.add(parseInt(r.date.slice(0,4)))); + const a = Array.from(s).sort((a,b) => b-a); return a.length ? a : [new Date().getFullYear()]; + }, [perm]); + + const handleCurr = (s: string, e: string) => { setCurrStart(s); setCurrEnd(e); setPrevStart(shiftYear(s)); setPrevEnd(shiftYear(e)); }; + + const applyFilters = useCallback((rows: MuseumRecord[]) => { + let d = rows; + if (selChannels.length) d = d.filter(r => selChannels.includes(r.channel)); + if (selMuseums.length) d = d.filter(r => selMuseums.includes(r.museum_name)); + if (selDistricts.length) d = d.filter(r => selDistricts.includes(r.district)); + return d; + }, [selDistricts, selChannels, selMuseums]); + + const currData = useMemo(() => applyFilters(filterDataByDateRange(perm, currStart, currEnd, {})), [perm, currStart, currEnd, applyFilters]); + const prevData = useMemo(() => applyFilters(filterDataByDateRange(perm, prevStart, prevEnd, {})), [perm, prevStart, prevEnd, applyFilters]); + const currM = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]); + const prevM = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]); + + const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; + + const getVal = useCallback((rows: MuseumRecord[], m: string) => { + if (m==='avgRevenue') { + const rev = rows.reduce((s,r) => s + parseFloat(String((r as any)[revenueField]||0)), 0); + const vis = rows.reduce((s,r) => s + parseInt(String(r.visits||0)), 0); + return vis>0 ? rev/vis : 0; + } + const f: Record = { revenue:revenueField, visitors:'visits', tickets:'tickets' }; + return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0); + }, [revenueField]); + + const channels = useMemo(() => getUniqueChannels(perm), [perm]); + const districts = useMemo(() => getUniqueDistricts(perm), [perm]); + const museums = useMemo(() => getUniqueMuseums(perm), [perm]); + + const periodLabel = (s: string, e: string) => { + const sy=s.slice(0,4), ey=e.slice(0,4); + return sy===ey ? sy : `${L.monthShort[parseInt(s.slice(5,7))-1]} '${sy.slice(-2)}–${L.monthShort[parseInt(e.slice(5,7))-1]} '${ey.slice(-2)}`; + }; + + const trendData = useMemo(() => { + const group = (rows: MuseumRecord[], ps: string) => { + const s=new Date(ps); const acc: Record = {}; + rows.forEach(r => { + if (!r.date) return; + const diff = Math.floor((new Date(r.date).getTime() - s.getTime()) / 86400000); + const key = gran==='month' ? Math.floor(diff/30)+1 : gran==='week' ? Math.floor(diff/7)+1 : diff+1; + if (!acc[key]) acc[key]=[]; acc[key].push(r); + }); + const res: Record = {}; + Object.entries(acc).forEach(([k,v]) => res[Number(k)] = getVal(v, metric)); return res; + }; + const pg = group(prevData, prevStart), cg = group(currData, currStart); + const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1); + const labels = Array.from({length:maxK}, (_,i) => + gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(new Date(currStart).getMonth()+i)%12] : `D${i+1}` + ); + return { + labels, + datasets: [ + { label:periodLabel(prevStart,prevEnd), data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:2, tension:0.4, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.muted }, + { label:periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'15', borderWidth:2, tension:0.4, fill:true, pointRadius:gran==='week'?4:2, pointBackgroundColor:chartColors.primary }, + ] + }; + }, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L]); + + const museumData = useMemo(() => { + const all = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[]; + const pb: Record={}, cb: Record={}; + all.forEach(m => { pb[m]=getVal(prevData.filter(r => r.museum_name===m), metric); cb[m]=getVal(currData.filter(r => r.museum_name===m), metric); }); + const active = all.filter(m => pb[m]>0 || cb[m]>0); + return { + labels: active, + datasets: [ + { label:periodLabel(prevStart,prevEnd), data:active.map(m => pb[m]), backgroundColor:chartColors.muted+'cc', borderRadius:4 }, + { label:periodLabel(currStart,currEnd), data:active.map(m => cb[m]), backgroundColor:chartColors.primary, borderRadius:4 }, + ] + }; + }, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal]); + + const baseOpts = useMemo(() => createBaseOptions(false), []); + const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } }; + + const metricOpts = [ + { value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors }, + { value:'tickets', label:L.tickets }, { value:'avgRevenue', label:L.avgRev }, + ]; + const granOpts = [{ value:'day', label:L.daily }, { value:'week', label:L.weekly }, { value:'month', label:L.monthly }]; + + const estimatePilgrims = useCallback((s: string, e: string) => { + const sd=new Date(s), ed=new Date(e); let total=0, has=false; + for (let y=sd.getFullYear(); y<=ed.getFullYear(); y++) { + for (let q=1; q<=4; q++) { + const qs=new Date(y,(q-1)*3,1), qe=new Date(y,q*3,0); + if (qeed) continue; + const p=umrahData[y]?.[q]; if(!p) continue; + const os=new Date(Math.max(qs.getTime(),sd.getTime())), oe=new Date(Math.min(qe.getTime(),ed.getTime())); + total+=p*((oe.getTime()-os.getTime())/86400000+1)/((qe.getTime()-qs.getTime())/86400000+1); has=true; + } + } + return has ? Math.round(total) : null; + }, []); + + const currPilgrims = useMemo(() => estimatePilgrims(currStart, currEnd), [currStart, currEnd, estimatePilgrims]); + const prevPilgrims = useMemo(() => estimatePilgrims(prevStart, prevEnd), [prevStart, prevEnd, estimatePilgrims]); + const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null; + const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null; + + const hasFilters = selDistricts.length>0 || selChannels.length>0 || selMuseums.length>0; + + return ( +
+ + + + + + + {L.backLink} + +

{L.pageTitle}

+

{L.pageSub}

+ +
+ +
+
+ {L.vs} +
+ { setPrevStart(s); setPrevEnd(e); }} availableYears={availableYears} L={L} /> +
+ +
+ {L.filter} +
+ + + + {hasFilters && } +
+ + +
+
+ +

{L.keyMetrics}

+
+ + + + + {currPilgrims!==null && prevPilgrims!==null && + } + {currCapture!==null && prevCapture!==null && + } +
+ +
+
+
+

{L.trendTitle}

+
+ {metricOpts.map(o => )} +
+ {granOpts.map(o => )} +
+
+
+
+
+
+

{L.museumTitle}

+
+ {metricOpts.map(o => )} +
+
+
+
+
+
+ ); +} diff --git a/src/components/shared/DateRangePicker.tsx b/src/components/shared/DateRangePicker.tsx new file mode 100644 index 0000000..a0732f4 --- /dev/null +++ b/src/components/shared/DateRangePicker.tsx @@ -0,0 +1,247 @@ +import React, { useState, useEffect, useRef } from 'react'; +import type { Season } from '../../types'; + +interface Props { + startDate: string; + endDate: string; + onChange: (start: string, end: string) => void; + availableYears: number[]; + seasons?: Season[]; +} + +const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec']; +const MONTH_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; +const MONTH_FULL = ['January','February','March','April','May','June','July','August','September','October','November','December']; + +function isLeap(y: number) { return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; } + +function makePresets(y: number): Record { + 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` }, + }; +} + +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, range] of Object.entries(presets)) { + if (range.start === start && range.end === end) return { key, year }; + } + return null; +} + +function formatTriggerLabel(start: string, end: string, seasons: Season[]): string { + for (const s of seasons) { + if (s.StartDate === start && s.EndDate === end) return `${s.Name} ${s.HijriYear}`; + } + + const year = parseInt(start.slice(0, 4)); + const presets = makePresets(year); + + for (const [key, range] of Object.entries(presets)) { + if (range.start !== start || range.end !== end) continue; + const mi = MONTH_KEYS.indexOf(key); + if (mi >= 0) return `${MONTH_FULL[mi]} ${year}`; + if (key === 'full') return String(year); + return `${key.toUpperCase()} ${year}`; + } + + // Custom range + const fmt = (d: string) => { + const [, m, day] = d.split('-'); + return `${parseInt(day)} ${MONTH_SHORT[parseInt(m) - 1]}`; + }; + const sy = parseInt(start.slice(0, 4)); + const ey = parseInt(end.slice(0, 4)); + return sy === ey ? `${fmt(start)} – ${fmt(end)} ${sy}` : `${fmt(start)} ${sy} – ${fmt(end)} ${ey}`; +} + +export default function DateRangePicker({ startDate, endDate, onChange, availableYears, seasons = [] }: Props) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const [year, setYear] = useState(() => { + const g = guessPreset(startDate, endDate); + return g?.year ?? parseInt(startDate.slice(0, 4)) ?? new Date().getFullYear(); + }); + const [activePreset, setActivePreset] = useState(() => guessPreset(startDate, endDate)?.key ?? null); + + useEffect(() => { + const g = guessPreset(startDate, endDate); + setActivePreset(g?.key ?? null); + if (g) setYear(g.year); + else setYear(parseInt(startDate.slice(0, 4)) || new Date().getFullYear()); + }, [startDate, endDate]); + + useEffect(() => { + if (!open) return; + const onMouse = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', onMouse); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onMouse); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + const selectPreset = (key: string) => { + const range = makePresets(year)[key]; + if (!range) return; + setActivePreset(key); + onChange(range.start, range.end); + setOpen(false); + }; + + const selectSeason = (s: Season) => { + setActivePreset(`season-${s.Id}`); + onChange(s.StartDate, s.EndDate); + setOpen(false); + }; + + const shiftYear = (delta: number) => { + const next = year + delta; + const min = availableYears.length ? Math.min(...availableYears) : year - 10; + const max = availableYears.length ? Math.max(...availableYears) : year + 10; + if (next < min || next > max) return; + setYear(next); + if (activePreset && !activePreset.startsWith('season-')) { + const range = makePresets(next)[activePreset]; + if (range) onChange(range.start, range.end); + } + }; + + const minYear = availableYears.length ? Math.min(...availableYears) : year - 10; + const maxYear = availableYears.length ? Math.max(...availableYears) : year + 10; + const label = formatTriggerLabel(startDate, endDate, seasons); + + return ( +
+ + + {open && ( +
+ {/* Year navigation */} +
+ + {year} + +
+ + {/* Month chips */} +
Month
+
+ {MONTH_KEYS.map((k, i) => ( + + ))} +
+ + {/* Quarter / Half / Full */} +
Quarter · Half · Year
+
+ {['q1','q2','q3','q4'].map(k => ( + + ))} + {['h1','h2'].map(k => ( + + ))} + +
+ + {/* Seasons */} + {seasons.length > 0 && ( + <> +
Seasons
+
+ {seasons.map(s => ( + + ))} +
+ + )} + + {/* Custom date inputs */} +
+
+
+ + { setActivePreset(null); onChange(e.target.value, endDate); }} /> +
+
+
+ + { setActivePreset(null); onChange(startDate, e.target.value); }} /> +
+
+
+ )} +
+ ); +} diff --git a/src/components/shared/FilterControls.tsx b/src/components/shared/FilterControls.tsx index 02d328b..49138bd 100644 --- a/src/components/shared/FilterControls.tsx +++ b/src/components/shared/FilterControls.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, ReactNode, KeyboardEvent } from 'react'; +import React, { useState, useEffect, ReactNode } from 'react'; import { useLanguage } from '../../contexts/LanguageContext'; interface FilterControlsProps { @@ -58,22 +58,15 @@ const FilterControls: FilterControlsComponent = ({ setExpanded(!expanded); }; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleExpanded(); - } - }; + return (
-

{displayTitle}

@@ -89,14 +82,11 @@ const FilterControls: FilterControlsComponent = ({ {t('filters.reset') || 'Reset'} )} - +
-
+
void; allLabel: string; placeholder?: string; + label?: string; } -function MultiSelect({ options, selected, onChange, allLabel, placeholder }: MultiSelectProps) { +function MultiSelect({ options, selected, onChange, allLabel, placeholder, label }: MultiSelectProps) { const [open, setOpen] = useState(false); const ref = useRef(null); @@ -48,6 +49,8 @@ function MultiSelect({ options, selected, onChange, allLabel, placeholder }: Mul className="multi-select-trigger" onClick={() => setOpen(!open)} aria-expanded={open} + aria-label={label} + aria-haspopup="listbox" > {displayText} diff --git a/src/components/shared/PeriodPicker.tsx b/src/components/shared/PeriodPicker.tsx index fead563..f9be5e9 100644 --- a/src/components/shared/PeriodPicker.tsx +++ b/src/components/shared/PeriodPicker.tsx @@ -47,7 +47,7 @@ export default function PeriodPicker({ startDate, endDate, onChange, availableYe const [year, setYear] = useState(() => guessPreset(startDate, endDate).year || new Date().getFullYear()); const [preset, setPreset] = useState(() => guessPreset(startDate, endDate).preset); - // When parent updates dates externally (e.g. auto-fill from Period A), sync internal state + // Sync internal state when parent updates dates externally useEffect(() => { const { preset: p, year: y } = guessPreset(startDate, endDate); setPreset(p); @@ -85,63 +85,62 @@ export default function PeriodPicker({ startDate, endDate, onChange, availableYe }; return ( -
-
- - -
- - {preset !== 'custom' && !preset.startsWith('season-') && availableYears.length > 0 && ( +
+
- - handlePreset(e.target.value)}> + + + + + + + + + + + + + + + + + + + + + {seasons.length > 0 && ( + + {seasons.map(s => ( + + ))} + + )}
- )} - {(preset === 'custom' || preset.startsWith('season-')) && ( - <> + {!preset.startsWith('season-') && availableYears.length > 0 && (
- - handleStart(e.target.value)} /> + +
-
- - handleEnd(e.target.value)} /> -
- - )} + )} + +
+ + handleStart(e.target.value)} /> +
+ +
+ + handleEnd(e.target.value)} /> +
+
); } diff --git a/src/components/shared/index.tsx b/src/components/shared/index.tsx index 5772b01..d808882 100644 --- a/src/components/shared/index.tsx +++ b/src/components/shared/index.tsx @@ -6,3 +6,4 @@ export { default as MultiSelect } from './MultiSelect'; export { default as StatCard } from './StatCard'; export { default as ToggleSwitch } from './ToggleSwitch'; export { default as PeriodPicker } from './PeriodPicker'; +export { default as DateRangePicker } from './DateRangePicker';