From 36df0065ed762f9cdac7f69fe7118f6bfcc72f13 Mon Sep 17 00:00:00 2001 From: fahed Date: Thu, 23 Apr 2026 17:07:39 +0300 Subject: [PATCH] refactor: rename Demo components to canonical names and purge dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server/src/routes/erp.ts | 1 + src/App.css | 45 + src/App.tsx | 8 +- src/components/ChartExport.tsx | 184 --- src/components/Comparison.tsx | 1472 ++++++++---------- src/components/Dashboard.tsx | 1689 +++++++++------------ src/components/DashboardDemo.tsx | 713 --------- src/components/NavDemo.tsx | 606 -------- src/components/PeriodSelectorDemo.tsx | 671 -------- src/components/Slides.tsx | 643 -------- src/components/shared/Carousel.tsx | 151 -- src/components/shared/ChartCard.tsx | 37 - src/components/shared/DateRangePicker.tsx | 247 --- src/components/shared/EmptyState.tsx | 44 - src/components/shared/FilterControls.tsx | 120 -- src/components/shared/MultiSelect.tsx | 85 -- src/components/shared/PeriodPicker.tsx | 146 -- src/components/shared/StatCard.tsx | 32 - src/components/shared/ToggleSwitch.tsx | 33 - src/components/shared/index.tsx | 10 +- 20 files changed, 1398 insertions(+), 5539 deletions(-) delete mode 100644 src/components/ChartExport.tsx delete mode 100644 src/components/DashboardDemo.tsx delete mode 100644 src/components/NavDemo.tsx delete mode 100644 src/components/PeriodSelectorDemo.tsx delete mode 100644 src/components/Slides.tsx delete mode 100644 src/components/shared/Carousel.tsx delete mode 100644 src/components/shared/ChartCard.tsx delete mode 100644 src/components/shared/DateRangePicker.tsx delete mode 100644 src/components/shared/EmptyState.tsx delete mode 100644 src/components/shared/FilterControls.tsx delete mode 100644 src/components/shared/MultiSelect.tsx delete mode 100644 src/components/shared/PeriodPicker.tsx delete mode 100644 src/components/shared/StatCard.tsx delete mode 100644 src/components/shared/ToggleSwitch.tsx diff --git a/server/src/routes/erp.ts b/server/src/routes/erp.ts index 73897b4..ae21d24 100644 --- a/server/src/routes/erp.ts +++ b/server/src/routes/erp.ts @@ -1,5 +1,6 @@ import { Router, Request, Response } from 'express'; import { fetchSales, isConfigured } from '../services/erpClient'; +import { etl } from '../config'; const router = Router(); diff --git a/src/App.css b/src/App.css index 8164997..f7b416e 100644 --- a/src/App.css +++ b/src/App.css @@ -116,6 +116,16 @@ box-sizing: border-box; } +/* Prevent horizontal overflow at the root level */ +html, body { + overflow-x: hidden; +} + +main { + min-width: 0; + overflow-x: hidden; +} + body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); @@ -1089,6 +1099,41 @@ table tbody tr:hover { flex-shrink: 0; } +.drp-footer { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.drp-cancel, .drp-apply { + padding: 7px 16px; + border-radius: 7px; + font-size: 0.825rem; + font-weight: 600; + cursor: pointer; + transition: background 0.12s, color 0.12s; +} + +.drp-cancel { + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); +} + +.drp-cancel:hover { + background: var(--bg-secondary); +} + +.drp-apply { + background: var(--accent); + border: 1px solid transparent; + color: #fff; +} + +.drp-apply:hover { + opacity: 0.88; +} + /* Right-align panel when near viewport edge */ @media (max-width: 480px) { .drp-panel { diff --git a/src/App.tsx b/src/App.tsx index 26ace30..b866c86 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,8 +2,8 @@ import React, { useState, useEffect, useCallback, useMemo, ReactNode, lazy, Susp import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'; const Settings = lazy(() => import('./components/Settings')); -const PeriodSelectorDemo = lazy(() => import('./components/PeriodSelectorDemo')); -const DashboardDemo = lazy(() => import('./components/DashboardDemo')); +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'; @@ -301,8 +301,8 @@ function App() {
}> - } /> - } /> + } /> + } /> {userRole === 'admin' && } />} diff --git a/src/components/ChartExport.tsx b/src/components/ChartExport.tsx deleted file mode 100644 index 69bf97b..0000000 --- a/src/components/ChartExport.tsx +++ /dev/null @@ -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(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 ( -
- {/* Download button - positioned absolutely in corner */} - - {title && ( -
-

{title}

- {controls &&
{controls}
} -
- )} - {!title && controls &&
{controls}
} -
-
- {children} -
-
-
- ); -} - -// Utility function to export all charts from a container as a ZIP -export async function exportAllCharts(containerSelector: string, zipFilename: string = 'charts'): Promise { - 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 ( - - ); -} - -export default ExportableChart; diff --git a/src/components/Comparison.tsx b/src/components/Comparison.tsx index a0b83d5..c1b33d8 100644 --- a/src/components/Comparison.tsx +++ b/src/components/Comparison.tsx @@ -1,859 +1,685 @@ -import React, { useState, useMemo, useCallback, useRef } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import { Link } from 'react-router-dom'; import { Line, Bar } from 'react-chartjs-2'; -import { EmptyState, FilterControls, MultiSelect, DateRangePicker } from './shared'; -import { ExportableChart } from './ChartExport'; -import { chartColors, createBaseOptions } from '../config/chartConfig'; -import { useLanguage } from '../contexts/LanguageContext'; import { - filterDataByDateRange, - calculateMetrics, - formatCompact, - formatCompactCurrency, - umrahData, - getUniqueChannels, - getUniqueMuseums, - getUniqueDistricts, - getMuseumsForDistrict + filterDataByDateRange, calculateMetrics, formatCurrency, formatNumber, + getUniqueChannels, getUniqueMuseums, getUniqueDistricts, + umrahData } from '../services/dataService'; -import type { MuseumRecord, ComparisonProps, DateRangeFilters, Season } from '../types'; +import { chartColors, createBaseOptions } from '../config/chartConfig'; +import type { MuseumRecord, Season } from '../types'; +import { useLanguage } from '../contexts/LanguageContext'; - -interface MetricCardProps { - title: string; - prev: number | null; - curr: number | null; - change: number | null; - isCurrency?: boolean; - isPercent?: boolean; - pendingMessage?: string; - prevYear: string; - currYear: string; +interface Props { + data: MuseumRecord[]; + seasons: Season[]; + includeVAT: boolean; + allowedMuseums: string[] | null; + allowedChannels: string[] | null; + lang?: 'en' | 'ar'; } -function currentMonthRange() { - const now = new Date(); - const y = now.getFullYear(); - const m = now.getMonth() + 1; - const pad = (n: number) => String(n).padStart(2, '0'); - const lastDay = new Date(y, m, 0).getDate(); - return { start: `${y}-${pad(m)}-01`, end: `${y}-${pad(m)}-${pad(lastDay)}` }; +// ─── 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; apply: 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; } -function shiftYearBack(dateStr: string): string { - return dateStr.replace(/^(\d{4})/, (_, y) => String(parseInt(y) - 1)); +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: 'Cancel', apply: 'Apply', + 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: 'إلغاء', apply: 'تطبيق', + 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 Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: ComparisonProps) { - const { t } = useLanguage(); - const [searchParams, setSearchParams] = useSearchParams(); - - // Permission base filter — applied before any user-facing filter - const permissionFilteredData = useMemo(() => { - if (allowedMuseums === null || allowedChannels === null) return []; - let d = data; - if (allowedMuseums.length > 0) d = d.filter(r => allowedMuseums.includes(r.museum_name)); - if (allowedChannels.length > 0) d = d.filter(r => allowedChannels.includes(r.channel)); - return d; - }, [data, allowedMuseums, allowedChannels]); - - const availableYears = useMemo((): number[] => { - const yearsSet = new Set(); - permissionFilteredData.forEach((r: MuseumRecord) => { - if (r.date) yearsSet.add(parseInt(r.date.slice(0, 4))); - }); - const years = Array.from(yearsSet).sort((a, b) => b - a); - return years.length ? years : [new Date().getFullYear()]; - }, [permissionFilteredData]); - - const defaultCurr = currentMonthRange(); - - // Period A (current) — user selects freely - const [currStart, setCurrStartState] = useState(() => searchParams.get('aStart') || defaultCurr.start); - const [currEnd, setCurrEndState] = useState(() => searchParams.get('aEnd') || defaultCurr.end); - - // Period B (comparison) — defaults to same period previous year, freely editable - const [prevStart, setPrevStartState] = useState(() => searchParams.get('bStart') || shiftYearBack(defaultCurr.start)); - const [prevEnd, setPrevEndState] = useState(() => searchParams.get('bEnd') || shiftYearBack(defaultCurr.end)); - - const [filters, setFiltersState] = useState(() => ({ - district: searchParams.get('district') || 'all', - channel: searchParams.get('channel')?.split(',').filter(Boolean) || [], - museum: searchParams.get('museum')?.split(',').filter(Boolean) || [] - })); - - const [chartMetric, setChartMetric] = useState('revenue'); - const [chartGranularity, setChartGranularity] = useState('week'); - const [activeChart, setActiveChart] = useState(0); - const [activeCard, setActiveCard] = useState(0); - - const updateUrl = useCallback((aStart: string, aEnd: string, bStart: string, bEnd: string, f: DateRangeFilters) => { - const params = new URLSearchParams(); - params.set('aStart', aStart); - params.set('aEnd', aEnd); - params.set('bStart', bStart); - params.set('bEnd', bEnd); - if (f.district !== 'all') params.set('district', f.district); - if (f.channel.length > 0) params.set('channel', f.channel.join(',')); - if (f.museum.length > 0) params.set('museum', f.museum.join(',')); - setSearchParams(params, { replace: true }); - }, [setSearchParams]); - - // When Period A changes, auto-update Period B to same period previous year - const handleCurrChange = (start: string, end: string) => { - const newPrevStart = shiftYearBack(start); - const newPrevEnd = shiftYearBack(end); - setCurrStartState(start); - setCurrEndState(end); - setPrevStartState(newPrevStart); - setPrevEndState(newPrevEnd); - updateUrl(start, end, newPrevStart, newPrevEnd, filters); - }; - - const handlePrevChange = (start: string, end: string) => { - setPrevStartState(start); - setPrevEndState(end); - updateUrl(currStart, currEnd, start, end, filters); - }; - - const setFilters = (newFilters: DateRangeFilters | ((prev: DateRangeFilters) => DateRangeFilters)) => { - const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters; - setFiltersState(updated); - updateUrl(currStart, currEnd, prevStart, prevEnd, updated); - }; - - const charts = [ - { id: 'timeseries', label: t('comparison.trend') }, - { id: 'museum', label: t('comparison.byMuseum') } - ]; - - // Touch swipe handlers - const touchStartChart = useRef(null); - const touchStartCard = useRef(null); - - const handleChartTouchStart = (e: React.TouchEvent) => { - touchStartChart.current = e.touches[0].clientX; - }; - const handleChartTouchEnd = (e: React.TouchEvent) => { - if (!touchStartChart.current) return; - const diff = touchStartChart.current - e.changedTouches[0].clientX; - if (Math.abs(diff) > 50) { - if (diff > 0 && activeChart < charts.length - 1) { - setActiveChart(activeChart + 1); - } else if (diff < 0 && activeChart > 0) { - setActiveChart(activeChart - 1); - } - } - touchStartChart.current = null; - }; - - const granularityOptions = [ - { value: 'day', label: t('time.daily') }, - { value: 'week', label: t('time.weekly') }, - { value: 'month', label: t('time.monthly') } - ]; - - const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; - - const metricOptions = [ - { value: 'revenue', label: t('metrics.revenue'), field: revenueField, format: 'currency' }, - { value: 'visitors', label: t('metrics.visitors'), field: 'visits', format: 'number' }, - { value: 'tickets', label: t('metrics.tickets'), field: 'tickets', format: 'number' }, - { value: 'avgRevenue', label: t('metrics.avgRevenue'), field: null, format: 'currency' } - ]; - - const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => { - if (metric === 'avgRevenue') { - const revenue = rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[revenueField] || 0)), 0); - const visitors = rows.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0); - return visitors > 0 ? revenue / visitors : 0; - } - const fieldMap: Record = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' }; - const field = fieldMap[metric]; - return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[field] || 0)), 0); - }, [revenueField]); - - // Dynamic lists from data - const channels = useMemo(() => getUniqueChannels(permissionFilteredData), [permissionFilteredData]); - const districts = useMemo(() => getUniqueDistricts(permissionFilteredData), [permissionFilteredData]); - const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]); - - const ranges = useMemo(() => ({ - curr: { start: currStart, end: currEnd }, - prev: { start: prevStart, end: prevEnd }, - }), [currStart, currEnd, prevStart, prevEnd]); - - const prevData = useMemo(() => - filterDataByDateRange(permissionFilteredData, prevStart, prevEnd, filters), - [permissionFilteredData, prevStart, prevEnd, filters] - ); - - const currData = useMemo(() => - filterDataByDateRange(permissionFilteredData, currStart, currEnd, filters), - [permissionFilteredData, currStart, currEnd, filters] - ); - - const prevMetrics = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]); - const currMetrics = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]); - - const hasData = prevData.length > 0 || currData.length > 0; - const resetFilters = () => setFilters({ district: 'all', channel: [], museum: [] }); - - const calcChange = (prev: number, curr: number) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100); - - // Get quarter from date range (returns null if not a clean quarter) - const getQuarterFromRange = (start: string, end: string) => { - const quarterRanges: Record = { - 1: { start: '-01-01', end: '-03-31' }, - 2: { start: '-04-01', end: '-06-30' }, - 3: { start: '-07-01', end: '-09-30' }, - 4: { start: '-10-01', end: '-12-31' } - }; - for (let q = 1; q <= 4; q++) { - if (start.endsWith(quarterRanges[q].start) && end.endsWith(quarterRanges[q].end)) { - return q; - } - } - return null; - }; - - // Estimate pilgrims for any date range by prorating quarterly data - const estimatePilgrims = useCallback((start: string, end: string): number | null => { - const startDate = new Date(start); - const endDate = new Date(end); - let total = 0; - let hasData = false; - - // Iterate through each quarter that overlaps with the range - const startYear = startDate.getFullYear(); - const endYear = endDate.getFullYear(); - - for (let year = startYear; year <= endYear; year++) { - for (let q = 1; q <= 4; q++) { - const qStart = new Date(year, (q - 1) * 3, 1); - const qEnd = new Date(year, q * 3, 0); // last day of quarter - - // Check overlap - if (qEnd < startDate || qStart > endDate) continue; - - const pilgrims = umrahData[year]?.[q]; - if (!pilgrims) continue; - - // Calculate overlap fraction - const overlapStart = new Date(Math.max(qStart.getTime(), startDate.getTime())); - const overlapEnd = new Date(Math.min(qEnd.getTime(), endDate.getTime())); - const overlapDays = (overlapEnd.getTime() - overlapStart.getTime()) / (1000 * 60 * 60 * 24) + 1; - const quarterDays = (qEnd.getTime() - qStart.getTime()) / (1000 * 60 * 60 * 24) + 1; - - total += pilgrims * (overlapDays / quarterDays); - hasData = true; - } - } - - return hasData ? Math.round(total) : null; - }, []); - - // Calculate capture rate and pilgrim data for any date range - const quarterData = useMemo(() => { - const prevPilgrims = estimatePilgrims(ranges.prev.start, ranges.prev.end); - const currPilgrims = estimatePilgrims(ranges.curr.start, ranges.curr.end); - - if (!prevPilgrims && !currPilgrims) return null; - - const prevRate = prevPilgrims ? (prevMetrics.visitors / prevPilgrims * 100) : null; - const currRate = currPilgrims ? (currMetrics.visitors / currPilgrims * 100) : null; - - return { - pilgrims: { prev: prevPilgrims, curr: currPilgrims }, - captureRate: { prev: prevRate, curr: currRate } - }; - }, [ranges, prevMetrics.visitors, currMetrics.visitors, estimatePilgrims]); - - const captureRates = quarterData?.captureRate || null; - const pilgrimCounts = quarterData?.pilgrims || null; - - // Build cards array dynamically - interface CardData { - title: string; - prev: number | null; - curr: number | null; - change: number | null; - isCurrency?: boolean; - isPercent?: boolean; - pendingMessage?: string; +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; +} - const metricCards = useMemo((): CardData[] => { - const revenueChange = calcChange(prevMetrics.revenue, currMetrics.revenue); - const visitorsChange = calcChange(prevMetrics.visitors, currMetrics.visitors); - const ticketsChange = calcChange(prevMetrics.tickets, currMetrics.tickets); - const avgRevChange = calcChange(prevMetrics.avgRevPerVisitor, currMetrics.avgRevPerVisitor); - const pilgrimsChange = pilgrimCounts ? calcChange(pilgrimCounts.prev || 0, pilgrimCounts.curr || 0) : null; - const captureRateChange = captureRates ? calcChange(captureRates.prev || 0, captureRates.curr || 0) : 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}`; +} - const cards: CardData[] = [ - { title: t('metrics.revenue'), prev: prevMetrics.revenue, curr: currMetrics.revenue, change: revenueChange, isCurrency: true }, - { title: t('metrics.visitors'), prev: prevMetrics.visitors, curr: currMetrics.visitors, change: visitorsChange }, - { title: t('metrics.tickets'), prev: prevMetrics.tickets, curr: currMetrics.tickets, change: ticketsChange }, - { title: t('metrics.avgRevenue'), prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: avgRevChange, isCurrency: true } - ]; - if (pilgrimCounts) { - cards.push({ title: t('metrics.pilgrims'), prev: pilgrimCounts.prev, curr: pilgrimCounts.curr, change: pilgrimsChange, pendingMessage: t('comparison.pendingData') }); - } - if (captureRates) { - cards.push({ title: t('metrics.captureRate'), prev: captureRates.prev, curr: captureRates.curr, change: captureRateChange, isPercent: true, pendingMessage: t('comparison.pendingData') }); - } - return cards; - }, [prevMetrics, currMetrics, pilgrimCounts, captureRates, t]); +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)}`; +} - const handleCardTouchStart = (e: React.TouchEvent) => { - touchStartCard.current = e.touches[0].clientX; - }; - const handleCardTouchEnd = (e: React.TouchEvent) => { - if (!touchStartCard.current) return; - const diff = touchStartCard.current - e.changedTouches[0].clientX; - if (Math.abs(diff) > 50) { - if (diff > 0 && activeCard < metricCards.length - 1) { - setActiveCard(activeCard + 1); - } else if (diff < 0 && activeCard > 0) { - setActiveCard(activeCard - 1); - } - } - touchStartCard.current = null; - }; +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)); } - const formatDate = (dateStr: string) => { - if (!dateStr) return ''; - const [year, month, day] = dateStr.split('-').map(Number); - const d = new Date(year, month - 1, day); - return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); - }; +// ─── inline picker ──────────────────────────────────────────────── +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(g?.key ?? null); + const [draftStart, setDraftStart] = useState(start); + const [draftEnd, setDraftEnd] = useState(end); + const minY = Math.min(...availableYears), maxY = Math.max(...availableYears); - // Generate period label - shows year if same year, or "MMM YY–MMM YY" if spans years - const getPeriodLabel = useCallback((startDate: string, endDate: string) => { - if (!startDate || !endDate) return ''; - const startYear = startDate.substring(0, 4); - const endYear = endDate.substring(0, 4); - - if (startYear === endYear) { - return startYear; - } - - // Spans multiple years - show abbreviated range - const [sy, sm] = startDate.split('-').map(Number); - const [ey, em] = endDate.split('-').map(Number); - const startMonth = new Date(sy, sm - 1, 1).toLocaleDateString('en-US', { month: 'short' }); - const endMonth = new Date(ey, em - 1, 1).toLocaleDateString('en-US', { month: 'short' }); - return `${startMonth} ${String(sy).slice(-2)}–${endMonth} ${String(ey).slice(-2)}`; - }, []); - - // Time series chart (daily or weekly) - const timeSeriesChart = useMemo(() => { - const groupByPeriod = (periodData: MuseumRecord[], periodStart: string, metric: string, granularity: string) => { - const start = new Date(periodStart); - const groupedRows: Record = {}; - - periodData.forEach((row: MuseumRecord) => { - if (!row.date) return; - const rowDate = new Date(row.date); - const daysDiff = Math.floor((rowDate.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); - - let key; - if (granularity === 'month') { - // Group by month number (relative to start) - const monthsDiff = (rowDate.getFullYear() - start.getFullYear()) * 12 + (rowDate.getMonth() - start.getMonth()); - key = monthsDiff + 1; - } else if (granularity === 'week') { - key = Math.floor(daysDiff / 7) + 1; - } else { - key = daysDiff + 1; // day number from start - } - - if (!groupedRows[key]) groupedRows[key] = []; - groupedRows[key].push(row); - }); - - const result: Record = {}; - Object.keys(groupedRows).forEach(key => { - result[Number(key)] = getMetricValue(groupedRows[Number(key)], metric); - }); - return result; - }; - - const prevGrouped = groupByPeriod(prevData, ranges.prev.start, chartMetric, chartGranularity); - const currGrouped = groupByPeriod(currData, ranges.curr.start, chartMetric, chartGranularity); - const maxKey = Math.max(...Object.keys(prevGrouped).map(Number), ...Object.keys(currGrouped).map(Number), 1); - - const labels = Array.from({ length: maxKey }, (_, i) => { - if (chartGranularity === 'month') { - const startDate = new Date(ranges.curr.start); - const monthNum = ((startDate.getMonth() + i) % 12) + 1; - return String(monthNum); - } - if (chartGranularity === 'week') return `W${i + 1}`; - return `D${i + 1}`; - }); - - const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end); - const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end); - - return { - labels, - datasets: [ - { - label: prevLabel, - data: labels.map((_, i) => prevGrouped[i + 1] || 0), - borderColor: chartColors.muted, - backgroundColor: 'transparent', - borderWidth: 2, - tension: 0.4, - pointRadius: chartGranularity === 'week' ? 3 : 1, - pointBackgroundColor: chartColors.muted - }, - { - label: currLabel, - data: labels.map((_, i) => currGrouped[i + 1] || 0), - borderColor: chartColors.primary, - backgroundColor: chartColors.primary + '10', - borderWidth: 2, - tension: 0.4, - fill: true, - pointRadius: chartGranularity === 'week' ? 4 : 2, - pointBackgroundColor: chartColors.primary - } - ] - }; - }, [prevData, currData, ranges, chartMetric, chartGranularity, getMetricValue, getPeriodLabel]); - - // Museum chart - only show museums with data - const museumChart = useMemo(() => { - const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end); - const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end); - const allMuseums = [...new Set(data.map((r: MuseumRecord) => r.museum_name))].filter(Boolean) as string[]; - const prevByMuseum: Record = {}; - const currByMuseum: Record = {}; - allMuseums.forEach(m => { - const prevRows = prevData.filter(r => r.museum_name === m); - const currRows = currData.filter(r => r.museum_name === m); - prevByMuseum[m] = getMetricValue(prevRows, chartMetric); - currByMuseum[m] = getMetricValue(currRows, chartMetric); - }); - // Only include museums that have data in either period - const museums = allMuseums.filter(m => prevByMuseum[m] > 0 || currByMuseum[m] > 0); - return { - labels: museums, - datasets: [ - { label: prevLabel, data: museums.map(m => prevByMuseum[m]), backgroundColor: chartColors.muted, borderRadius: 4 }, - { label: currLabel, data: museums.map(m => currByMuseum[m]), backgroundColor: chartColors.primary, borderRadius: 4 } - ] - }; - }, [data, prevData, currData, ranges, chartMetric, getMetricValue, getPeriodLabel]); - - const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]); - - // Map seasons to annotation bands on the current period's timeline - const seasonAnnotations = useMemo(() => { - if (!seasons.length) return {}; - const currStart = new Date(ranges.curr.start); - const currEnd = new Date(ranges.curr.end); - const annotations: Record = {}; - const msPerDay = 1000 * 60 * 60 * 24; - const granDivisor = chartGranularity === 'month' ? 30 : chartGranularity === 'week' ? 7 : 1; - - seasons.forEach((s, i) => { - const sStart = new Date(s.StartDate); - const sEnd = new Date(s.EndDate); - // Check overlap with current period - if (sEnd < currStart || sStart > currEnd) return; - - const clampedStart = sStart < currStart ? currStart : sStart; - const clampedEnd = sEnd > currEnd ? currEnd : sEnd; - const startIdx = Math.floor((clampedStart.getTime() - currStart.getTime()) / msPerDay / granDivisor); - const endIdx = Math.floor((clampedEnd.getTime() - currStart.getTime()) / msPerDay / granDivisor); - - annotations[`season${i}`] = { - type: 'box', - xMin: startIdx - 0.5, - xMax: endIdx + 0.5, - backgroundColor: s.Color + '20', - borderColor: s.Color + '40', - borderWidth: 1, - label: { - display: true, - content: `${s.Name} ${s.HijriYear}`, - position: 'start', - color: s.Color, - font: { size: 10, weight: '600' }, - padding: 4 - } - }; - }); - return annotations; - }, [seasons, ranges.curr, chartGranularity]); - - const chartOptions: any = { - ...baseOptions, - plugins: { - ...baseOptions.plugins, - legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } }, - annotation: { annotations: seasonAnnotations } - } + 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(nymaxY) return; setYear(ny); + if(active && makePresets(ny)[active]) { setDraftStart(makePresets(ny)[active].start); setDraftEnd(makePresets(ny)[active].end); } }; return ( -
-
-
-

{t('comparison.title')}

-

{t('comparison.subtitle')}

-
-
-
- {t('nav.vat') || 'VAT'} -
- - -
-
-
- {t('nav.labels')} -
- - -
-
-
+
+
+ + {year} +
- -
-
-
{t('comparison.currentPeriod')}
- -
-
{t('comparison.vs')}
-
-
{t('comparison.previousPeriod')}
- -
-
- - - - - - - - setFilters({...filters, channel: selected})} - allLabel={t('filters.allChannels')} - /> - - - setFilters({...filters, museum: selected})} - allLabel={t('filters.allMuseums')} - /> - - - - -
-
-
{t('comparison.previousPeriod')}
-
{getPeriodLabel(ranges.prev.start, ranges.prev.end)}
-
{formatDate(ranges.prev.start)} → {formatDate(ranges.prev.end)}
-
-
{t('comparison.vs')}
-
-
{t('comparison.currentPeriod')}
-
{getPeriodLabel(ranges.curr.start, ranges.curr.end)}
-
{formatDate(ranges.curr.start)} → {formatDate(ranges.curr.end)}
-
-
- - {!hasData ? ( - - ) : ( - <> - {/* Desktop: Grid layout */} -
- {metricCards.map((card, i) => ( - +

{L.monthSection}

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

{L.periodSection}

+
+ {['q1','q2','q3','q4','h1','h2'].map(k => ( + + ))} + +
+
+
+
{ setActive(null); setDraftStart(e.target.value); }} />
+ {L.dateRangeSep} +
{ setActive(null); setDraftEnd(e.target.value); }} />
+
+
+
+ + +
+
+ ); +} - {/* Mobile: Carousel layout */} -
-
-
-
- {metricCards.map((card, i) => ( -
- -
- ))} -
+// ─── 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 && 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 && }
- -
- {metricCards.map((card, i) => ( - - ))} -
-
- - {/* Desktop: Show both charts */} -
-
- m.value === chartMetric)?.label} - ${t('comparison.trend')}`} - className="chart-container" - controls={ - <> -
- {granularityOptions.map(opt => ( - - ))} -
-
- {metricOptions.map(opt => ( - - ))} -
- - } - > - -
-
-
- m.value === chartMetric)?.label} - ${t('comparison.byMuseum')}`} - className="chart-container" - controls={ -
- {metricOptions.map(opt => ( - - ))} -
- } - > - -
-
-
- - {/* Mobile: Carousel */} -
-
-
-
-
-
-
-

{metricOptions.find(m => m.value === chartMetric)?.label} - {t('comparison.trend')}

-
- {granularityOptions.map(opt => ( - - ))} -
-
-
-
- {metricOptions.map(opt => ( - - ))} -
-
-
- -
-
-
-
-
-
-

{metricOptions.find(m => m.value === chartMetric)?.label} - {t('comparison.byMuseum')}

-
-
-
- {metricOptions.map(opt => ( - - ))} -
-
-
- -
-
-
-
-
-
- -
- {charts.map((chart, i) => ( - - ))} -
-
- )}
); } -function MetricCard({ title, prev, curr, change, isCurrency, isPercent, pendingMessage, prevYear, currYear }: MetricCardProps) { - const hasPending = prev === null || curr === null; - const isPositive = (change ?? 0) >= 0; - const changeText = (hasPending && pendingMessage) ? null : (change === Infinity || change === null ? '—' : `${isPositive ? '+' : ''}${change.toFixed(1)}%`); - - const formatValue = (val: number | null | undefined) => { - if (val === null || val === undefined) return '—'; - if (isPercent) return val.toFixed(2) + '%'; - if (isCurrency) return formatCompactCurrency(val); - return formatCompact(val); - }; - - const diff = (curr || 0) - (prev || 0); - const diffText = (hasPending && pendingMessage) ? pendingMessage : (isPercent - ? (diff >= 0 ? '+' : '') + diff.toFixed(2) + 'pp' - : (isCurrency ? formatCompactCurrency(diff) : formatCompact(diff))); - +// ─── 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}

-
-
-
{prevYear}
-
{formatValue(prev)}
-
-
- {hasPending && pendingMessage ? ( -
{pendingMessage}
- ) : ( - <> -
{changeText}
-
{diff >= 0 ? '+' : ''}{diffText}
- - )} -
-
-
{currYear}
-
{formatValue(curr)}
-
+
+

{title}

+
{fmt(curr)}
+
+ {isFinite(change) + ? {isPos?'▲':isNeg?'▼':'—'} {Math.abs(change).toFixed(1)}% + : {newLabel??'New'}} + {fmt(prev)}
); } -export default Comparison; +// ─── 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/Dashboard.tsx b/src/components/Dashboard.tsx index 422a100..9eb7250 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1,1021 +1,728 @@ -import React, { useState, useMemo, useEffect } from 'react'; -import { useSearchParams, Link } from 'react-router-dom'; +import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import { Link } from 'react-router-dom'; import { Line, Bar, Pie } from 'react-chartjs-2'; -import { Carousel, EmptyState, FilterControls, MultiSelect, DateRangePicker, StatCard } from './shared'; -import { ExportableChart } from './ChartExport'; -import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig'; -import { useLanguage } from '../contexts/LanguageContext'; import { - filterDataByDateRange, - calculateMetrics, - formatCurrency, - formatNumber, - groupByWeek, - groupByMuseum, - groupByChannel, + filterDataByDateRange, calculateMetrics, formatCurrency, formatNumber, + getUniqueChannels, getUniqueMuseums, getUniqueDistricts, + groupByMuseum, groupByChannel, groupByDistrict, umrahData, - fetchPilgrimStats, - getUniqueYears, - getUniqueChannels, - getUniqueMuseums, - getUniqueDistricts, - getMuseumsForDistrict, - groupByDistrict } from '../services/dataService'; -import type { DashboardProps, Filters, MuseumRecord, Season } from '../types'; +import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig'; +import type { MuseumRecord, Season } from '../types'; +import { useLanguage } from '../contexts/LanguageContext'; -function currentMonthRange(): { startDate: string; endDate: string } { - const now = new Date(); - const y = now.getFullYear(); - const m = now.getMonth() + 1; - const pad = (n: number) => String(n).padStart(2, '0'); - const lastDay = new Date(y, m, 0).getDate(); - return { startDate: `${y}-${pad(m)}-01`, endDate: `${y}-${pad(m)}-${pad(lastDay)}` }; +interface Props { + data: MuseumRecord[]; + seasons: Season[]; + includeVAT: boolean; + setIncludeVAT: (v: boolean) => void; + allowedMuseums: string[] | null; + allowedChannels: string[] | null; + lang?: 'en' | 'ar'; } -const defaultFilters: Filters = { - ...currentMonthRange(), - district: 'all', - channel: [], - museum: [], +// ─── 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; + 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; +} + +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: '%', }; -function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: DashboardProps) { - const { t } = useLanguage(); - const [searchParams, setSearchParams] = useSearchParams(); - const [pilgrimLoaded, setPilgrimLoaded] = useState(false); +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: '%', +}; - // Fetch pilgrim stats from NocoDB on mount - useEffect(() => { - fetchPilgrimStats().then(() => setPilgrimLoaded(true)); - }, []); - - // Initialize filters from URL or defaults - const [filters, setFiltersState] = useState(() => { - const def = defaultFilters; - return { - startDate: searchParams.get('start') || def.startDate, - endDate: searchParams.get('end') || def.endDate, - district: searchParams.get('district') || 'all', - museum: searchParams.get('museum')?.split(',').filter(Boolean) || [], - channel: searchParams.get('channel')?.split(',').filter(Boolean) || [], - }; - }); +// ─── date helpers ───────────────────────────────────────────────── +const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec']; - // Update both state and URL - const setFilters = (newFilters: Filters | ((prev: Filters) => Filters)) => { - const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters; - setFiltersState(updated); - const params = new URLSearchParams(); - params.set('start', updated.startDate); - params.set('end', updated.endDate); - if (updated.district !== 'all') params.set('district', updated.district); - if (updated.museum.length > 0) params.set('museum', updated.museum.join(',')); - if (updated.channel.length > 0) params.set('channel', updated.channel.join(',')); - setSearchParams(params, { replace: true }); +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`}, }; +} - const [activeStatCard, setActiveStatCard] = useState(0); - const [activeChart, setActiveChart] = useState(0); - const [trendGranularity, setTrendGranularity] = useState('week'); - const [selectedSeason, setSelectedSeason] = useState(''); - const [eventMetric, setEventMetric] = useState<'visitors' | 'revenue'>('revenue'); - const [eventChartType, setEventChartType] = useState<'bar' | 'pie'>('pie'); - const [channelChartType, setChannelChartType] = useState<'bar' | 'pie'>('pie'); - const [channelDisplayMode, setChannelDisplayMode] = useState<'absolute' | 'percent'>('absolute'); - const [eventDisplayMode, setEventDisplayMode] = useState<'absolute' | 'percent'>('absolute'); - const [districtChartType, setDistrictChartType] = useState<'bar' | 'pie'>('pie'); - const [districtDisplayMode, setDistrictDisplayMode] = useState<'absolute' | 'percent'>('absolute'); +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; +} - // Permission base filter — applied before any user-facing filter - // null = corrupted value → fail-closed (show nothing) - const permissionFilteredData = useMemo(() => { - if (allowedMuseums === null || allowedChannels === null) return []; - let d = data; - if (allowedMuseums.length > 0) d = d.filter(r => allowedMuseums.includes(r.museum_name)); - if (allowedChannels.length > 0) d = d.filter(r => allowedChannels.includes(r.channel)); - return d; - }, [data, allowedMuseums, allowedChannels]); +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}`; +} - const filteredData = useMemo( - () => filterDataByDateRange(permissionFilteredData, filters.startDate, filters.endDate, filters), - [permissionFilteredData, filters] - ); +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)}`; +} - const seasonFilteredData = useMemo(() => { - if (!selectedSeason) return filteredData; - const season = seasons.find(s => String(s.Id) === selectedSeason); - if (!season) return filteredData; - return filteredData.filter(row => row.date >= season.StartDate && row.date <= season.EndDate); - }, [filteredData, selectedSeason, seasons]); +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)); } - const metrics = useMemo(() => calculateMetrics(seasonFilteredData, includeVAT), [seasonFilteredData, includeVAT]); - const hasData = seasonFilteredData.length > 0; +// ─── inline picker ──────────────────────────────────────────────── +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(g?.key ?? null); + const [draftStart, setDraftStart] = useState(start); + const [draftEnd, setDraftEnd] = useState(end); + const minY = Math.min(...availableYears), maxY = Math.max(...availableYears); - const resetFilters = () => { - setFilters(defaultFilters); - setSelectedSeason(''); + 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); } }; - // Stat cards for carousel - const statCards = useMemo(() => [ - { title: t('metrics.totalRevenue'), value: formatCurrency(metrics.revenue), hasYoy: true }, - { title: t('metrics.totalVisitors'), value: formatNumber(metrics.visitors) }, - { title: t('metrics.totalTickets'), value: formatNumber(metrics.tickets) }, - { title: t('metrics.avgRevenue'), value: formatCurrency(metrics.avgRevPerVisitor) } - ], [metrics, t]); - - // Chart carousel labels - const chartLabels = useMemo(() => { - return [t('charts.revenueTrend'), t('charts.visitors'), t('charts.revenue'), t('charts.quarterly'), t('charts.channel'), t('charts.district'), t('charts.captureRate')]; - }, [t]); - - // Dynamic lists from data - const years = useMemo(() => getUniqueYears(permissionFilteredData), [permissionFilteredData]); - const districts = useMemo(() => getUniqueDistricts(permissionFilteredData), [permissionFilteredData]); - const channels = useMemo(() => getUniqueChannels(permissionFilteredData), [permissionFilteredData]); - const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]); - - const yoyChange = useMemo(() => { - const prevStart = filters.startDate.replace(/^(\d{4})/, (_, y) => String(parseInt(y) - 1)); - const prevEnd = filters.endDate.replace(/^(\d{4})/, (_, y) => String(parseInt(y) - 1)); - const prevData = filterDataByDateRange(permissionFilteredData, prevStart, prevEnd, filters); - if (prevData.length === 0) return null; - const prevMetrics = calculateMetrics(prevData, includeVAT); - return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null; - }, [permissionFilteredData, filters, metrics.revenue, includeVAT]); - - // Revenue trend data (weekly or daily) - const trendData = useMemo(() => { - const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; - const formatLabel = (dateStr: string) => { - if (!dateStr) return ''; - const [year, month, day] = dateStr.split('-').map(Number); - const d = new Date(year, month - 1, day); - return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - }; - - // Linear regression helper - const linearRegression = (values: number[]) => { - const n = values.length; - if (n < 2) return values; - let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; - for (let i = 0; i < n; i++) { - sumX += i; - sumY += values[i]; - sumXY += i * values[i]; - sumX2 += i * i; - } - const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); - const intercept = (sumY - slope * sumX) / n; - return values.map((_, i) => slope * i + intercept); - }; - - const trendlineDataset = (values: number[]) => ({ - label: 'Trend', - data: linearRegression(values), - borderColor: chartColors.secondary, - borderWidth: 2, - borderDash: [6, 4], - tension: 0, - fill: false, - pointRadius: 0, - pointHoverRadius: 0, - datalabels: { display: false } - }); - - if (trendGranularity === 'week') { - const grouped = groupByWeek(seasonFilteredData, includeVAT); - const weeks = Object.keys(grouped).filter(w => w).sort(); - const revenueValues = weeks.map(w => grouped[w].revenue); - return { - labels: weeks.map(formatLabel), - rawDates: weeks, - datasets: [{ - label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)', - data: revenueValues, - borderColor: chartColors.primary, - backgroundColor: chartColors.primary + '10', - borderWidth: 2, - tension: 0.4, - fill: true, - pointRadius: 0, - pointHoverRadius: 4 - }, trendlineDataset(revenueValues)] - }; - } else { - // Daily granularity - const dailyData: Record = {}; - seasonFilteredData.forEach(row => { - const date = row.date; - if (!dailyData[date]) dailyData[date] = 0; - dailyData[date] += Number((row as unknown as Record)[revenueField] || 0); - }); - const days = Object.keys(dailyData).sort(); - const revenueValues = days.map(d => dailyData[d]); - return { - labels: days.map(formatLabel), - rawDates: days, - datasets: [{ - label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)', - data: revenueValues, - borderColor: chartColors.primary, - backgroundColor: chartColors.primary + '10', - borderWidth: 1.5, - tension: 0.4, - fill: true, - pointRadius: 0, - pointHoverRadius: 3 - }, trendlineDataset(revenueValues)] - }; - } - }, [seasonFilteredData, trendGranularity, includeVAT]); - - // Museum data - const museumData = useMemo(() => { - const grouped = groupByMuseum(seasonFilteredData, includeVAT); - const museums = Object.keys(grouped); - return { - visitors: { - labels: museums, - datasets: [{ - data: museums.map(m => grouped[m].visitors), - backgroundColor: museums.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'), - borderWidth: 0, - borderRadius: 4 - }] - }, - revenue: { - labels: museums, - datasets: [{ - data: museums.map(m => grouped[m].revenue), - backgroundColor: museums.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'), - borderRadius: 4 - }] - } - }; - }, [seasonFilteredData, includeVAT]); - - // Channel data - const channelData = useMemo(() => { - const grouped = groupByChannel(seasonFilteredData, includeVAT); - const channels = Object.keys(grouped); - return { - labels: channels, - datasets: [{ - data: channels.map(d => grouped[d].revenue), - backgroundColor: channels.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'), - borderRadius: 4 - }] - }; - }, [seasonFilteredData, includeVAT]); - - const eventChartData = useMemo(() => { - const source = museumData[eventMetric]; - if (eventDisplayMode === 'absolute') return source; - const total = source.datasets[0].data.reduce((s: number, v: number) => s + v, 0); - if (total === 0) return source; - return { - ...source, - datasets: [{ ...source.datasets[0], data: source.datasets[0].data.map((v: number) => parseFloat(((v / total) * 100).toFixed(1))) }] - }; - }, [museumData, eventMetric, eventDisplayMode]); - - const channelChartData = useMemo(() => { - if (channelDisplayMode === 'absolute') return channelData; - const total = channelData.datasets[0].data.reduce((s: number, v: number) => s + v, 0); - if (total === 0) return channelData; - return { - ...channelData, - datasets: [{ ...channelData.datasets[0], data: channelData.datasets[0].data.map((v: number) => parseFloat(((v / total) * 100).toFixed(1))) }] - }; - }, [channelData, channelDisplayMode]); - - // District data - const districtData = useMemo(() => { - const grouped = groupByDistrict(seasonFilteredData, includeVAT); - const districtNames = Object.keys(grouped); - return { - labels: districtNames, - datasets: [{ - data: districtNames.map(d => grouped[d].revenue), - backgroundColor: districtNames.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'), - borderRadius: 4 - }] - }; - }, [seasonFilteredData, includeVAT]); - - const districtChartData = useMemo(() => { - if (districtDisplayMode === 'absolute') return districtData; - const total = districtData.datasets[0].data.reduce((s: number, v: number) => s + v, 0); - if (total === 0) return districtData; - return { - ...districtData, - datasets: [{ ...districtData.datasets[0], data: districtData.datasets[0].data.map((v: number) => parseFloat(((v / total) * 100).toFixed(1))) }] - }; - }, [districtData, districtDisplayMode]); - - // Quarterly YoY - const quarterlyYoYData = useMemo(() => { - const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; - const d2024 = data.filter((row: MuseumRecord) => row.year === '2024'); - const d2025 = data.filter((row: MuseumRecord) => row.year === '2025'); - const quarters = ['Q1', 'Q2', 'Q3', 'Q4']; - return { - labels: quarters, - datasets: [ - { - label: '2024', - data: quarters.map(q => d2024.filter((r: MuseumRecord) => r.quarter === q.slice(1)).reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0)), - backgroundColor: chartColors.muted, - borderRadius: 4 - }, - { - label: '2025', - data: quarters.map(q => d2025.filter((r: MuseumRecord) => r.quarter === q.slice(1)).reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0)), - backgroundColor: chartColors.primary, - borderRadius: 4 - } - ] - }; - }, [data, includeVAT]); - - // Capture rate - const captureRateData = useMemo(() => { - const labels: string[] = []; - const rates: number[] = []; - const pilgrimCounts: number[] = []; - [2024, 2025].forEach(year => { - [1, 2, 3, 4].forEach(q => { - const pilgrims = umrahData[year]?.[q]; - if (!pilgrims) return; - let qData = data.filter((r: MuseumRecord) => r.year === String(year) && r.quarter === String(q)); - if (filters.district !== 'all') qData = qData.filter((r: MuseumRecord) => r.district === filters.district); - if (filters.channel.length > 0) qData = qData.filter((r: MuseumRecord) => filters.channel.includes(r.channel)); - if (filters.museum.length > 0) qData = qData.filter((r: MuseumRecord) => filters.museum.includes(r.museum_name)); - const visitors = qData.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0); - labels.push(`Q${q} ${year}`); - rates.push((visitors / pilgrims * 100)); - pilgrimCounts.push(pilgrims); - }); - }); - return { - labels, - datasets: [ - { - label: 'Capture Rate (%)', - data: rates, - borderColor: chartColors.secondary, - backgroundColor: chartColors.secondary + '10', - borderWidth: 2, - tension: 0.4, - fill: true, - pointRadius: 4, - pointBackgroundColor: '#fff', - pointBorderColor: chartColors.secondary, - pointBorderWidth: 2, - yAxisID: 'y', - datalabels: { - display: showDataLabels, - formatter: (value: number) => value.toFixed(2) + '%', - color: '#1e293b', - backgroundColor: 'rgba(255, 255, 255, 0.9)', - borderRadius: 3, - font: { size: 10, weight: 600 }, - anchor: 'end', - align: 'top', - offset: 6 - } - }, - { - label: 'Pilgrims', - data: pilgrimCounts, - borderColor: chartColors.tertiary, - backgroundColor: chartColors.tertiary + '10', - borderWidth: 2, - tension: 0.4, - fill: true, - pointRadius: 4, - pointBackgroundColor: '#fff', - pointBorderColor: chartColors.tertiary, - pointBorderWidth: 2, - yAxisID: 'y1', - order: 1, - datalabels: { - display: showDataLabels, - formatter: (value: number) => (value / 1000000).toFixed(2) + 'M', - color: '#1e293b', - backgroundColor: 'rgba(255, 255, 255, 0.9)', - borderRadius: 3, - font: { size: 10, weight: 600 }, - anchor: 'start', - align: 'bottom', - offset: 6 - } - } - ] - }; - }, [data, filters.district, filters.channel, filters.museum, showDataLabels]); - - // Quarterly table - const quarterlyTable = useMemo(() => { - const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; - const d2024 = data.filter((row: MuseumRecord) => row.year === '2024'); - const d2025 = data.filter((row: MuseumRecord) => row.year === '2025'); - return [1, 2, 3, 4].map(q => { - let q2024 = d2024.filter((r: MuseumRecord) => r.quarter === String(q)); - let q2025 = d2025.filter((r: MuseumRecord) => r.quarter === String(q)); - if (filters.district !== 'all') { - q2024 = q2024.filter((r: MuseumRecord) => r.district === filters.district); - q2025 = q2025.filter((r: MuseumRecord) => r.district === filters.district); - } - if (filters.channel.length > 0) { - q2024 = q2024.filter((r: MuseumRecord) => filters.channel.includes(r.channel)); - q2025 = q2025.filter((r: MuseumRecord) => filters.channel.includes(r.channel)); - } - if (filters.museum.length > 0) { - q2024 = q2024.filter((r: MuseumRecord) => filters.museum.includes(r.museum_name)); - q2025 = q2025.filter((r: MuseumRecord) => filters.museum.includes(r.museum_name)); - } - const rev24 = q2024.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0); - const rev25 = q2025.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0); - const vis24 = q2024.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0); - const vis25 = q2025.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0); - const revChg = rev24 > 0 ? ((rev25 - rev24) / rev24 * 100) : 0; - const visChg = vis24 > 0 ? ((vis25 - vis24) / vis24 * 100) : 0; - const cap24 = umrahData[2024][q] ? (vis24 / umrahData[2024][q] * 100) : null; - const cap25 = umrahData[2025][q] ? (vis25 / umrahData[2025][q] * 100) : null; - return { q, rev24, rev25, revChg, vis24, vis25, visChg, cap24, cap25 }; - }); - }, [data, filters.district, filters.channel, filters.museum, includeVAT]); - - const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]); - - const pieOptions = useMemo(() => ({ - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { display: true, position: 'right' as const, labels: { boxWidth: 12, padding: 10, font: { size: 11 }, color: '#64748b' } }, - tooltip: baseOptions.plugins.tooltip, - datalabels: { display: false } - } - }), [baseOptions]); - - // Season annotation bands for revenue trend chart - const seasonAnnotations = useMemo(() => { - const raw = trendData.rawDates; - if (!seasons.length || !raw?.length) return {}; - const annotations: Record = {}; - seasons.forEach((s, i) => { - const startIdx = raw.findIndex(d => d >= s.StartDate); - const endIdx = raw.length - 1 - [...raw].reverse().findIndex(d => d <= s.EndDate); - if (startIdx === -1 || endIdx < startIdx) return; - annotations[`season${i}`] = { - type: 'box', - xMin: startIdx - 0.5, - xMax: endIdx + 0.5, - backgroundColor: s.Color + '20', - borderColor: s.Color + '40', - borderWidth: 1, - label: { - display: true, - content: `${s.Name} ${s.HijriYear}`, - position: 'start', - color: s.Color, - font: { size: 10, weight: '600' }, - padding: 4 - } - }; - }); - return annotations; - }, [seasons, trendData.rawDates]); - return ( -
-
-
-

{t('dashboard.title')}

-

{t('dashboard.subtitle')}

-
-
-
- {t('nav.vat') || 'VAT'} -
- - -
-
-
- {t('nav.labels')} -
- - -
-
-
+
+
+ + {year} +
- -
- setFilters({ ...filters, startDate: start, endDate: end })} - availableYears={years.map(Number)} - seasons={seasons} - /> +

{L.monthSection}

+
+ {MONTH_KEYS.map((k,i) => ( + + ))}
- - - - - - - - setFilters({...filters, channel})} - allLabel={t('filters.allChannels')} - /> - - - setFilters({...filters, museum})} - allLabel={t('filters.allMuseums')} - /> - - - - - - - - {/* Desktop: Grid */} -
- - - - +

{L.periodSection}

+
+ {['q1','q2','q3','q4','h1','h2'].map(k => ( + + ))} +
- - {/* Mobile: Stats Carousel */} -
- c.title.replace('Total ', '').replace('Avg ', ''))} - > - {statCards.map((card, i) => ( - - ))} - +
+
+
{ setActive(null); setDraftStart(e.target.value); }} />
+ {L.dateRangeSep} +
{ setActive(null); setDraftEnd(e.target.value); }} />
- - {!hasData ? ( - - ) : ( - <> -
-

{t('dashboard.quarterlyComparison')}

-
- - - - - - - - - - - - - - - - {quarterlyTable.map(row => ( - - - - - - - - - - - - ))} - -
{t('table.quarter')}{t('table.rev2024')}{t('table.rev2025')}{t('table.change')}{t('table.visitors2024')}{t('table.visitors2025')}{t('table.change')}{t('table.capture2024')}{t('table.capture2025')}
Q{row.q}{formatCurrency(row.rev24)}{formatCurrency(row.rev25)}= 0 ? 'positive' : 'negative'}> - {row.revChg >= 0 ? '+' : ''}{row.revChg.toFixed(1)}% - {formatNumber(row.vis24)}{formatNumber(row.vis25)}= 0 ? 'positive' : 'negative'}> - {row.visChg >= 0 ? '+' : ''}{row.visChg.toFixed(1)}% - {row.cap24 ? row.cap24.toFixed(2) + '%' : '—'}{row.cap25 ? row.cap25.toFixed(2) + '%' : '—'}
-
+
+
+ +
- - {/* Desktop: Charts Grid */} -
-
- - - -
- } - > - - -
- -
- -
- - -
-
- - -
-
- - -
-
- } - > - {eventChartType === 'bar' - ? - : v > 3 ? v.toFixed(1) + '%' : '' } - : { display: false }, - tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => eventDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } } - } - }} /> - } - -
- -
- - - -
- -
- -
- - -
-
- - -
-
- } - > - {channelChartType === 'bar' - ? channelDisplayMode === 'percent' ? v.toFixed(1) + '%' : baseOptions.plugins.datalabels.formatter(v, {} as any) } }, - scales: { ...baseOptions.scales, x: { ...baseOptions.scales.x, ticks: { ...baseOptions.scales.x.ticks, callback: (v: number | string) => channelDisplayMode === 'percent' ? v + '%' : v } } } - }} /> - : v > 3 ? v.toFixed(1) + '%' : '' } - : { display: false }, - tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => channelDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } } - } - }} /> - } - -
- -
- -
- - -
-
- - -
-
- } - > - {districtChartType === 'bar' - ? - : v > 3 ? v.toFixed(1) + '%' : '' } - : { display: false }, - tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => districtDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } } - } - }} /> - } - -
- -
- - { - if (ctx.dataset.label === 'Capture Rate (%)') { - return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`; - } - return `Pilgrims: ${ctx.parsed.y.toLocaleString()}`; - } - } - } - }, - scales: { - x: baseOptions.scales.x, - y: { - type: 'linear', - position: 'left', - grid: { color: chartColors.grid }, - ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v: number | string) => Number(v).toFixed(1) + '%' }, - border: { display: false }, - title: { display: true, text: 'Capture Rate (%)', font: { size: 12 }, color: chartColors.secondary } - }, - y1: { - type: 'linear', - position: 'right', - grid: { drawOnChartArea: false }, - ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v: number | string) => (Number(v) / 1000000).toFixed(0) + 'M' }, - border: { display: false }, - title: { display: true, text: 'Pilgrims', font: { size: 12 }, color: chartColors.tertiary } - } - } - }} /> - -
-
- - {/* Mobile: Charts Carousel */} -
-
-
-
-
-
-

{t('dashboard.revenueTrends')}

-
- - -
-
- -
-
-
- -
-
-

{eventMetric === 'visitors' ? t('dashboard.visitorsByMuseum') : t('dashboard.revenueByMuseum')}

-
-
- - -
-
- - -
-
- - -
-
-
- {eventChartType === 'bar' - ? - : v > 3 ? v.toFixed(1) + '%' : '' } - : { display: false }, - tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => eventDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } } - } - }} /> - } -
-
-
- -
-
-

{t('dashboard.quarterlyRevenue')}

-
- -
-
-
- -
-
-

{t('dashboard.channelPerformance')}

-
-
- - -
-
- - -
-
-
- {channelChartType === 'bar' - ? - : v > 3 ? v.toFixed(1) + '%' : '' } - : { display: false }, - tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => channelDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } } - } - }} /> - } -
-
-
- -
-
-

{t('dashboard.districtPerformance')}

-
-
- - -
-
- - -
-
-
- {districtChartType === 'bar' - ? - : v > 3 ? v.toFixed(1) + '%' : '' } - : { display: false }, - tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => districtDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } } - } - }} /> - } -
-
-
- -
-
-

{t('dashboard.captureRateChart')}

-
- { - if (ctx.dataset.label === 'Capture Rate (%)') { - return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`; - } - return `Pilgrims: ${ctx.parsed.y.toLocaleString()}`; - } - } - } - }, - scales: { - x: baseOptions.scales.x, - y: { - type: 'linear', - position: 'left', - grid: { color: chartColors.grid }, - ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v: number | string) => Number(v).toFixed(1) + '%' }, - border: { display: false } - }, - y1: { - type: 'linear', - position: 'right', - grid: { drawOnChartArea: false }, - ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v: number | string) => (Number(v) / 1000000).toFixed(0) + 'M' }, - border: { display: false } - } - } - }} /> -
-
-
-
-
-
- -
- {chartLabels.map((label, i) => ( - - ))} -
-
- - )} - - {userRole === 'admin' &&
- - - - - {t('nav.settings')} - -
}
); } -export default Dashboard; +// ─── 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 && 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/DashboardDemo.tsx b/src/components/DashboardDemo.tsx deleted file mode 100644 index 0ab4bfb..0000000 --- a/src/components/DashboardDemo.tsx +++ /dev/null @@ -1,713 +0,0 @@ -import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { Link } from 'react-router-dom'; -import { Line, Bar, Pie } from 'react-chartjs-2'; -import { - filterDataByDateRange, calculateMetrics, formatCurrency, formatNumber, - getUniqueChannels, getUniqueMuseums, getUniqueDistricts, - groupByMuseum, groupByChannel, groupByDistrict, - umrahData, -} from '../services/dataService'; -import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig'; -import type { MuseumRecord, Season } from '../types'; -import { useLanguage } from '../contexts/LanguageContext'; - -interface Props { - data: MuseumRecord[]; - seasons: Season[]; - includeVAT: boolean; - setIncludeVAT: (v: boolean) => void; - 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; - changePeriod: string; - close: 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; -} - -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: 'Close', - 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: '%', -}; - -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: 'إغلاق', - 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: '%', -}; - -// ─── 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 (ny < minY || ny > maxY) 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 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/NavDemo.tsx b/src/components/NavDemo.tsx deleted file mode 100644 index 4a083a3..0000000 --- a/src/components/NavDemo.tsx +++ /dev/null @@ -1,606 +0,0 @@ -import React, { useState } from 'react'; -import { Link, useLocation } from 'react-router-dom'; -import type { MuseumRecord, Season } from '../types'; -import DashboardDemo from './DashboardDemo'; - -interface NavDemoProps { - data: MuseumRecord[]; - seasons: Season[]; - includeVAT: boolean; - setIncludeVAT: (v: boolean) => 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 deleted file mode 100644 index d0a8ae6..0000000 --- a/src/components/PeriodSelectorDemo.tsx +++ /dev/null @@ -1,671 +0,0 @@ -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/Slides.tsx b/src/components/Slides.tsx deleted file mode 100644 index 28d26fa..0000000 --- a/src/components/Slides.tsx +++ /dev/null @@ -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) => 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>; - 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([]); - const [editingSlide, setEditingSlide] = useState(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 = { - 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) => { - 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 = ` - - - - - HiHala Data Presentation - - - - -${slidesHTML} - - -`; - - 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 ( - setPreviewMode(false)} - metrics={METRICS} - /> - ); - } - - return ( -
-
-

{t('slides.title')}

-

{t('slides.subtitle')}

-
- -
- - {slides.length > 0 && ( - <> - - - - )} -
- -
-
-

{t('slides.slidesCount')} ({slides.length})

- {slides.length === 0 ? ( -
-

{t('slides.noSlides')}

- -
- ) : ( -
- {slides.map((slide, index) => ( -
setEditingSlide(slide.id)} - > -
{index + 1}
-
{CHART_TYPES.find(c => c.id === slide.chartType)?.icon}
-
{slide.title}
-
- - - - -
-
- ))} -
- )} -
- - {editingSlide && ( - s.id === editingSlide)!} - onUpdate={(updates) => updateSlide(editingSlide, updates)} - channels={channels} - museums={museums} - data={data} - chartTypes={CHART_TYPES} - metrics={METRICS} - /> - )} -
-
- ); -} - -function SlideEditor({ slide, onUpdate, channels, museums, data, chartTypes, metrics }: SlideEditorProps) { - const { t } = useLanguage(); - - return ( -
-
- - onUpdate({ title: e.target.value })} - placeholder={t('slides.slideTitle')} - /> -
- -
- -
- {chartTypes.map((type: ChartTypeOption) => ( - - ))} -
-
- -
- - -
- -
-
- - onUpdate({ startDate: e.target.value })} /> -
-
- - onUpdate({ endDate: e.target.value })} /> -
-
- -
-
- - -
-
- - -
-
- - {slide.chartType === 'comparison' && ( -
- -
- )} - -
-

{t('slides.preview')}

- -
-
- ); -} - -// Static field mapping for charts (Chart.js labels don't need i18n) -const METRIC_FIELDS: Record = { - 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 ? [slide.channel] : [], - museum: slide.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 = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' }; - return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as unknown as Record)[fieldMap[metric]] || 0)), 0); - }, []); - - const trendData = useMemo(() => { - const grouped: Record = {}; - 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 = {}; - 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 ( -
-
-
{formatCompactCurrency(metricsData.revenue)}
-
{t('metrics.revenue')}
-
-
-
{formatCompact(metricsData.visitors)}
-
{t('metrics.visitors')}
-
-
-
{formatCompact(metricsData.tickets)}
-
{t('metrics.tickets')}
-
-
- ); - } - - if (slide.chartType === 'museum-bar') { - return ( -
- -
- ); - } - - return ( -
- -
- ); -} - -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 ( -
-
-

{slide?.title}

-
- {slide && } -
-
- {currentSlide + 1} / {slides.length} -
-
-
- - - -
-
- ); -} - -// Helper functions for HTML export -function generateSlideHTML(slide: SlideConfig, index: number, data: MuseumRecord[]): string { - const chartType = slide.chartType; - const canvasId = `chart-${index}`; - - return ` -
-

${slide.title}

-

${formatDateRange(slide.startDate, slide.endDate)}

- ${chartType === 'kpi-cards' ? generateKPIHTML(slide, data) : `
`} -
Slide ${index + 1}
- -
`; -} - -function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string { - const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, { - channel: slide.channel ? [slide.channel] : [], - museum: slide.museum ? [slide.museum] : [] - }); - const metrics = calculateMetrics(filtered); - - return ` -
-
-
${formatCompactCurrency(metrics.revenue)}
-
Revenue
-
-
-
${formatCompact(metrics.visitors)}
-
Visitors
-
-
-
${formatCompact(metrics.tickets)}
-
Tickets
-
-
`; -} - -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 ? [slide.channel] : [], - museum: slide.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 = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' }; - const field = fieldMap[slide.metric]; - - if (slide.chartType === 'museum-bar') { - const byMuseum: Record = {}; - 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 = {}; - 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; diff --git a/src/components/shared/Carousel.tsx b/src/components/shared/Carousel.tsx deleted file mode 100644 index 2f04662..0000000 --- a/src/components/shared/Carousel.tsx +++ /dev/null @@ -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(null); - const touchStartY = useRef(null); - const trackRef = useRef(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) => { - touchStartX.current = e.touches[0].clientX; - touchStartY.current = e.touches[0].clientY; - setIsDragging(true); - setDragOffset(0); - }, []); - - const handleTouchMove = useCallback((e: TouchEvent) => { - 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) => { - 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) => { - 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 ( -
-
-
-
- {React.Children.map(children, (child, i) => ( -
- {child} -
- ))} -
-
-
- -
- {Array.from({ length: itemCount }).map((_, i) => ( - - ))} -
-
- ); -} - -export default Carousel; diff --git a/src/components/shared/ChartCard.tsx b/src/components/shared/ChartCard.tsx deleted file mode 100644 index 83e4a40..0000000 --- a/src/components/shared/ChartCard.tsx +++ /dev/null @@ -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 ( -
- {(title || headerRight) && ( -
- {title &&

{title}

} - {headerRight &&
{headerRight}
} -
- )} -
- {children} -
-
- ); -} - -export default ChartCard; diff --git a/src/components/shared/DateRangePicker.tsx b/src/components/shared/DateRangePicker.tsx deleted file mode 100644 index a0732f4..0000000 --- a/src/components/shared/DateRangePicker.tsx +++ /dev/null @@ -1,247 +0,0 @@ -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/EmptyState.tsx b/src/components/shared/EmptyState.tsx deleted file mode 100644 index 9dd75db..0000000 --- a/src/components/shared/EmptyState.tsx +++ /dev/null @@ -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 ( -
- - {title && ( -

{title}

- )} - {message && ( -

{message}

- )} - {action && ( - - )} -
- ); -} - -export default EmptyState; diff --git a/src/components/shared/FilterControls.tsx b/src/components/shared/FilterControls.tsx deleted file mode 100644 index 49138bd..0000000 --- a/src/components/shared/FilterControls.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import React, { useState, useEffect, ReactNode } 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 { - Group: React.FC; - Row: React.FC; -} - -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); - }; - - - - return ( -
- - )} - -
- - -
- {children} -
-
- ); -}; - -const FilterGroup: React.FC = ({ label, children }) => { - return ( -
- {label && } - {children} -
- ); -}; - -const FilterRow: React.FC = ({ children }) => { - return
{children}
; -}; - -FilterControls.Group = FilterGroup; -FilterControls.Row = FilterRow; - -export default FilterControls; diff --git a/src/components/shared/MultiSelect.tsx b/src/components/shared/MultiSelect.tsx deleted file mode 100644 index 2cd9c37..0000000 --- a/src/components/shared/MultiSelect.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react'; - -interface MultiSelectProps { - options: string[]; - selected: string[]; - onChange: (selected: string[]) => void; - allLabel: string; - placeholder?: string; - label?: string; -} - -function MultiSelect({ options, selected, onChange, allLabel, placeholder, label }: MultiSelectProps) { - const [open, setOpen] = useState(false); - const ref = useRef(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 ( -
- - - {open && ( -
- - {options.map(opt => ( - - ))} -
- )} -
- ); -} - -export default MultiSelect; diff --git a/src/components/shared/PeriodPicker.tsx b/src/components/shared/PeriodPicker.tsx deleted file mode 100644 index f9be5e9..0000000 --- a/src/components/shared/PeriodPicker.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useLanguage } from '../../contexts/LanguageContext'; -import type { Season } from '../../types'; - -interface Props { - startDate: string; - endDate: string; - onChange: (start: string, end: string) => void; - availableYears: number[]; - seasons?: Season[]; -} - -const PRESETS: Record { start: string; end: string }> = { - jan: y => ({ start: `${y}-01-01`, end: `${y}-01-31` }), - feb: y => ({ start: `${y}-02-01`, end: `${y}-02-28` }), - mar: y => ({ start: `${y}-03-01`, end: `${y}-03-31` }), - apr: y => ({ start: `${y}-04-01`, end: `${y}-04-30` }), - may: y => ({ start: `${y}-05-01`, end: `${y}-05-31` }), - jun: y => ({ start: `${y}-06-01`, end: `${y}-06-30` }), - jul: y => ({ start: `${y}-07-01`, end: `${y}-07-31` }), - aug: y => ({ start: `${y}-08-01`, end: `${y}-08-31` }), - sep: y => ({ start: `${y}-09-01`, end: `${y}-09-30` }), - oct: y => ({ start: `${y}-10-01`, end: `${y}-10-31` }), - nov: y => ({ start: `${y}-11-01`, end: `${y}-11-30` }), - dec: y => ({ start: `${y}-12-01`, end: `${y}-12-31` }), - q1: y => ({ start: `${y}-01-01`, end: `${y}-03-31` }), - q2: y => ({ start: `${y}-04-01`, end: `${y}-06-30` }), - q3: y => ({ start: `${y}-07-01`, end: `${y}-09-30` }), - q4: y => ({ start: `${y}-10-01`, end: `${y}-12-31` }), - h1: y => ({ start: `${y}-01-01`, end: `${y}-06-30` }), - h2: y => ({ start: `${y}-07-01`, end: `${y}-12-31` }), - full: y => ({ start: `${y}-01-01`, end: `${y}-12-31` }), -}; - -function guessPreset(start: string, end: string): { preset: string; year: number } { - const year = parseInt(start.slice(0, 4)); - for (const [key, fn] of Object.entries(PRESETS)) { - const p = fn(year); - if (p.start === start && p.end === end) return { preset: key, year }; - } - return { preset: 'custom', year }; -} - -export default function PeriodPicker({ startDate, endDate, onChange, availableYears, seasons = [] }: Props) { - const { t } = useLanguage(); - - const [year, setYear] = useState(() => guessPreset(startDate, endDate).year || new Date().getFullYear()); - const [preset, setPreset] = useState(() => guessPreset(startDate, endDate).preset); - - // Sync internal state when parent updates dates externally - useEffect(() => { - const { preset: p, year: y } = guessPreset(startDate, endDate); - setPreset(p); - if (p !== 'custom') setYear(y); - }, [startDate, endDate]); - - const handlePreset = (value: string) => { - setPreset(value); - if (value === 'custom') return; - if (value.startsWith('season-')) { - const season = seasons.find(s => String(s.Id) === value.replace('season-', '')); - if (season) onChange(season.StartDate, season.EndDate); - return; - } - const range = PRESETS[value]?.(year); - if (range) onChange(range.start, range.end); - }; - - const handleYear = (newYear: number) => { - setYear(newYear); - if (preset !== 'custom' && !preset.startsWith('season-')) { - const range = PRESETS[preset]?.(newYear); - if (range) onChange(range.start, range.end); - } - }; - - const handleStart = (value: string) => { - setPreset('custom'); - onChange(value, endDate); - }; - - const handleEnd = (value: string) => { - setPreset('custom'); - onChange(startDate, value); - }; - - return ( -
-
-
- - -
- - {!preset.startsWith('season-') && availableYears.length > 0 && ( -
- - -
- )} - -
- - handleStart(e.target.value)} /> -
- -
- - handleEnd(e.target.value)} /> -
-
-
- ); -} diff --git a/src/components/shared/StatCard.tsx b/src/components/shared/StatCard.tsx deleted file mode 100644 index ddd6a09..0000000 --- a/src/components/shared/StatCard.tsx +++ /dev/null @@ -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 ( -
-

{title}

-
{value}
- {subtitle && ( -
{subtitle}
- )} - {change !== null && ( -
- {isPositive ? '↑' : '↓'} - {Math.abs(change).toFixed(1)}% - {changeLabel} -
- )} -
- ); -} - -export default StatCard; diff --git a/src/components/shared/ToggleSwitch.tsx b/src/components/shared/ToggleSwitch.tsx deleted file mode 100644 index 0bf3852..0000000 --- a/src/components/shared/ToggleSwitch.tsx +++ /dev/null @@ -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 ( -
- {options.map((option) => ( - - ))} -
- ); -} - -export default ToggleSwitch; diff --git a/src/components/shared/index.tsx b/src/components/shared/index.tsx index d808882..3c0a770 100644 --- a/src/components/shared/index.tsx +++ b/src/components/shared/index.tsx @@ -1,9 +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 PeriodPicker } from './PeriodPicker'; -export { default as DateRangePicker } from './DateRangePicker'; +export { default as LoadingSkeleton } from './LoadingSkeleton';