diff --git a/src/App.css b/src/App.css index 529aec0..c27702a 100644 --- a/src/App.css +++ b/src/App.css @@ -762,6 +762,84 @@ table tbody tr:hover { border-color: var(--accent); } +/* Multi-select */ +.multi-select { + position: relative; +} + +.multi-select-trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 0.9375rem; + background: var(--surface); + color: var(--text-primary); + cursor: pointer; + text-align: left; +} + +.multi-select-trigger:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; + border-color: var(--accent); +} + +.multi-select-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.multi-select-arrow { + font-size: 0.65rem; + opacity: 0.5; + margin-inline-start: 8px; +} + +.multi-select-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 50; + max-height: 240px; + overflow-y: auto; + padding: 4px; +} + +.multi-select-option { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; + color: var(--text-primary); + text-transform: none; + letter-spacing: normal; + font-weight: normal; +} + +.multi-select-option:hover { + background: var(--hover); +} + +.multi-select-option input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); +} + .period-display { background: var(--bg); padding: 16px; diff --git a/src/components/Comparison.tsx b/src/components/Comparison.tsx index 321ffea..877fd85 100644 --- a/src/components/Comparison.tsx +++ b/src/components/Comparison.tsx @@ -1,7 +1,7 @@ import React, { useState, useMemo, useCallback, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Line, Bar } from 'react-chartjs-2'; -import { EmptyState, FilterControls } from './shared'; +import { EmptyState, FilterControls, MultiSelect } from './shared'; import { ExportableChart } from './ChartExport'; import { chartColors, createBaseOptions } from '../config/chartConfig'; import { useLanguage } from '../contexts/LanguageContext'; @@ -109,8 +109,8 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn }); const [filters, setFiltersState] = useState(() => ({ district: searchParams.get('district') || 'all', - channel: searchParams.get('channel') || 'all', - museum: searchParams.get('museum') || 'all' + channel: searchParams.get('channel')?.split(',').filter(Boolean) || [], + museum: searchParams.get('museum')?.split(',').filter(Boolean) || [] })); const [chartMetric, setChartMetric] = useState('revenue'); @@ -128,8 +128,8 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn if (newTo) params.set('to', newTo); } if (newFilters?.district && newFilters.district !== 'all') params.set('district', newFilters.district); - if (newFilters?.channel && newFilters.channel !== 'all') params.set('channel', newFilters.channel); - if (newFilters?.museum && newFilters.museum !== 'all') params.set('museum', newFilters.museum); + if (newFilters?.channel && newFilters.channel.length > 0) params.set('channel', newFilters.channel.join(',')); + if (newFilters?.museum && newFilters.museum.length > 0) params.set('museum', newFilters.museum.join(',')); setSearchParams(params, { replace: true }); }, [setSearchParams, latestYear]); @@ -249,7 +249,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn const currMetrics = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]); const hasData = prevData.length > 0 || currData.length > 0; - const resetFilters = () => setFilters({ district: 'all', channel: 'all', museum: 'all' }); + const resetFilters = () => setFilters({ district: 'all', channel: [], museum: [] }); const calcChange = (prev: number, curr: number) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100); @@ -581,22 +581,26 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn > )} - setFilters({...filters, district: e.target.value, museum: 'all'})}> + setFilters({...filters, district: e.target.value, museum: []})}> {t('filters.allDistricts')} {districts.map(d => {d})} - setFilters({...filters, channel: e.target.value})}> - {t('filters.allChannels')} - {channels.map(c => {c})} - + setFilters({...filters, channel: selected})} + allLabel={t('filters.allChannels')} + /> - setFilters({...filters, museum: e.target.value})}> - {t('filters.allMuseums')} - {availableMuseums.map(m => {m})} - + setFilters({...filters, museum: selected})} + allLabel={t('filters.allMuseums')} + /> diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index d076381..db81d78 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1,7 +1,7 @@ import React, { useState, useMemo, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Line, Doughnut, Bar } from 'react-chartjs-2'; -import { Carousel, EmptyState, FilterControls, StatCard } from './shared'; +import { Carousel, EmptyState, FilterControls, MultiSelect, StatCard } from './shared'; import { ExportableChart } from './ChartExport'; import { chartColors, createBaseOptions } from '../config/chartConfig'; import { useLanguage } from '../contexts/LanguageContext'; @@ -27,12 +27,12 @@ import type { DashboardProps, Filters, MuseumRecord } from '../types'; const defaultFilters: Filters = { year: 'all', district: 'all', - channel: 'all', - museum: 'all', + channel: [], + museum: [], quarter: 'all' }; -const filterKeys: (keyof Filters)[] = ['year', 'district', 'channel', 'museum', 'quarter']; +const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter']; function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) { const { t } = useLanguage(); @@ -45,12 +45,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc }, []); // Initialize filters from URL or defaults - const [filters, setFiltersState] = useState(() => { - const initial = { ...defaultFilters }; + const [filters, setFiltersState] = useState(() => { + const initial: Filters = { ...defaultFilters }; filterKeys.forEach(key => { const value = searchParams.get(key); - if (value) initial[key] = value; + if (value) (initial as Record)[key] = value; }); + const museumParam = searchParams.get('museum'); + if (museumParam) initial.museum = museumParam.split(',').filter(Boolean); + const channelParam = searchParams.get('channel'); + if (channelParam) initial.channel = channelParam.split(',').filter(Boolean); return initial; }); @@ -61,10 +65,13 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc const params = new URLSearchParams(); filterKeys.forEach(key => { - if (updated[key] && updated[key] !== 'all') { - params.set(key, updated[key]); + const val = (updated as Record)[key] as string; + if (val && val !== 'all') { + params.set(key, val); } }); + 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 }); }; @@ -89,7 +96,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc // Chart carousel labels const chartLabels = useMemo(() => { const labels = [t('charts.revenueTrend'), t('charts.visitors'), t('charts.revenue'), t('charts.quarterly'), t('charts.channel'), t('charts.district'), t('charts.captureRate')]; - return filters.museum === 'all' ? labels : labels.filter((_, i) => i !== 1 && i !== 2); + return filters.museum.length === 0 ? labels : labels.filter((_, i) => i !== 1 && i !== 2); }, [filters.museum, t]); // Dynamic lists from data @@ -279,8 +286,8 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc 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 !== 'all') qData = qData.filter((r: MuseumRecord) => r.channel === filters.channel); - if (filters.museum !== 'all') qData = qData.filter((r: MuseumRecord) => r.museum_name === filters.museum); + 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)); @@ -357,13 +364,13 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc q2024 = q2024.filter((r: MuseumRecord) => r.district === filters.district); q2025 = q2025.filter((r: MuseumRecord) => r.district === filters.district); } - if (filters.channel !== 'all') { - q2024 = q2024.filter((r: MuseumRecord) => r.channel === filters.channel); - q2025 = q2025.filter((r: MuseumRecord) => r.channel === filters.channel); + 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 !== 'all') { - q2024 = q2024.filter((r: MuseumRecord) => r.museum_name === filters.museum); - q2025 = q2025.filter((r: MuseumRecord) => r.museum_name === filters.museum); + 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); @@ -413,22 +420,26 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc - setFilters({...filters, district: e.target.value, museum: 'all'})}> + setFilters({...filters, district: e.target.value, museum: []})}> {t('filters.allDistricts')} {districts.map(d => {d})} - setFilters({...filters, channel: e.target.value})}> - {t('filters.allChannels')} - {channels.map(c => {c})} - + setFilters({...filters, channel})} + allLabel={t('filters.allChannels')} + /> - setFilters({...filters, museum: e.target.value})}> - {t('filters.allMuseums')} - {availableMuseums.map(m => {m})} - + setFilters({...filters, museum})} + allLabel={t('filters.allMuseums')} + /> setFilters({...filters, quarter: e.target.value})}> @@ -536,7 +547,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc - {filters.museum === 'all' && ( + {filters.museum.length === 0 && ( @@ -544,7 +555,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc )} - {filters.museum === 'all' && ( + {filters.museum.length === 0 && ( @@ -634,7 +645,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc - {filters.museum === 'all' && ( + {filters.museum.length === 0 && ( {t('dashboard.visitorsByMuseum')} @@ -645,7 +656,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc )} - {filters.museum === 'all' && ( + {filters.museum.length === 0 && ( {t('dashboard.revenueByMuseum')} diff --git a/src/components/shared/MultiSelect.tsx b/src/components/shared/MultiSelect.tsx new file mode 100644 index 0000000..4c190c0 --- /dev/null +++ b/src/components/shared/MultiSelect.tsx @@ -0,0 +1,82 @@ +import React, { useState, useRef, useEffect } from 'react'; + +interface MultiSelectProps { + options: string[]; + selected: string[]; + onChange: (selected: string[]) => void; + allLabel: string; + placeholder?: string; +} + +function MultiSelect({ options, selected, onChange, allLabel, placeholder }: MultiSelectProps) { + const [open, setOpen] = useState(false); + const ref = useRef(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 ( + + setOpen(!open)} + aria-expanded={open} + > + {displayText} + {open ? '▲' : '▼'} + + + {open && ( + + + + {allLabel} + + {options.map(opt => ( + + toggle(opt)} + /> + {opt} + + ))} + + )} + + ); +} + +export default MultiSelect; diff --git a/src/components/shared/index.tsx b/src/components/shared/index.tsx index d10bbb4..164b82d 100644 --- a/src/components/shared/index.tsx +++ b/src/components/shared/index.tsx @@ -2,5 +2,6 @@ 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'; diff --git a/src/services/dataService.ts b/src/services/dataService.ts index 5ba6ec6..25a8660 100644 --- a/src/services/dataService.ts +++ b/src/services/dataService.ts @@ -275,8 +275,8 @@ export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord return data.filter(row => { if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false; if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false; - if (filters.channel && filters.channel !== 'all' && row.channel !== filters.channel) return false; - if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false; + if (filters.channel.length > 0 && !filters.channel.includes(row.channel)) return false; + if (filters.museum.length > 0 && !filters.museum.includes(row.museum_name)) return false; if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false; return true; }); @@ -292,8 +292,8 @@ export function filterDataByDateRange( if (!row.date) return false; if (row.date < startDate || row.date > endDate) return false; if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false; - if (filters.channel && filters.channel !== 'all' && row.channel !== filters.channel) return false; - if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false; + if (filters.channel && filters.channel.length > 0 && !filters.channel.includes(row.channel)) return false; + if (filters.museum && filters.museum.length > 0 && !filters.museum.includes(row.museum_name)) return false; return true; }); } diff --git a/src/types/index.ts b/src/types/index.ts index edb6ab0..f17fa45 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -23,15 +23,15 @@ export interface Metrics { export interface Filters { year: string; district: string; - channel: string; - museum: string; + channel: string[]; + museum: string[]; quarter: string; } export interface DateRangeFilters { district: string; - channel: string; - museum: string; + channel: string[]; + museum: string[]; } export interface CacheStatus {