feat: replace year/quarter filters with free date range pickers
Deploy HiHala Dashboard / deploy (push) Successful in 8s
Deploy HiHala Dashboard / deploy (push) Successful in 8s
Dashboard: PeriodPicker replaces year + quarter dropdowns. Defaults to current month. YoY stat card now compares same range vs previous year. Comparison: two independent PeriodPicker blocks (Period A and Period B). Changing Period A auto-updates Period B to same period previous year, but Period B remains freely editable. Both pages use filterDataByDateRange; Filters type drops year/quarter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+76
-200
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Line, Bar } from 'react-chartjs-2';
|
import { Line, Bar } from 'react-chartjs-2';
|
||||||
import { EmptyState, FilterControls, MultiSelect } from './shared';
|
import { EmptyState, FilterControls, MultiSelect, PeriodPicker } from './shared';
|
||||||
import { ExportableChart } from './ChartExport';
|
import { ExportableChart } from './ChartExport';
|
||||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
@@ -14,19 +14,10 @@ import {
|
|||||||
getUniqueChannels,
|
getUniqueChannels,
|
||||||
getUniqueMuseums,
|
getUniqueMuseums,
|
||||||
getUniqueDistricts,
|
getUniqueDistricts,
|
||||||
getMuseumsForDistrict,
|
getMuseumsForDistrict
|
||||||
getLatestYear
|
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import type { MuseumRecord, ComparisonProps, DateRangeFilters, Season } from '../types';
|
import type { MuseumRecord, ComparisonProps, DateRangeFilters, Season } from '../types';
|
||||||
|
|
||||||
interface PresetDateRange {
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PresetDates {
|
|
||||||
[key: string]: PresetDateRange;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MetricCardProps {
|
interface MetricCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -40,28 +31,18 @@ interface MetricCardProps {
|
|||||||
currYear: string;
|
currYear: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate preset dates for a given year
|
function currentMonthRange() {
|
||||||
const generatePresetDates = (year: number): PresetDates => ({
|
const now = new Date();
|
||||||
'jan': { start: `${year}-01-01`, end: `${year}-01-31` },
|
const y = now.getFullYear();
|
||||||
'feb': { start: `${year}-02-01`, end: `${year}-02-28` },
|
const m = now.getMonth() + 1;
|
||||||
'mar': { start: `${year}-03-01`, end: `${year}-03-31` },
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
'apr': { start: `${year}-04-01`, end: `${year}-04-30` },
|
const lastDay = new Date(y, m, 0).getDate();
|
||||||
'may': { start: `${year}-05-01`, end: `${year}-05-31` },
|
return { start: `${y}-${pad(m)}-01`, end: `${y}-${pad(m)}-${pad(lastDay)}` };
|
||||||
'jun': { start: `${year}-06-01`, end: `${year}-06-30` },
|
}
|
||||||
'jul': { start: `${year}-07-01`, end: `${year}-07-31` },
|
|
||||||
'aug': { start: `${year}-08-01`, end: `${year}-08-31` },
|
function shiftYearBack(dateStr: string): string {
|
||||||
'sep': { start: `${year}-09-01`, end: `${year}-09-30` },
|
return dateStr.replace(/^(\d{4})/, (_, y) => String(parseInt(y) - 1));
|
||||||
'oct': { start: `${year}-10-01`, end: `${year}-10-31` },
|
}
|
||||||
'nov': { start: `${year}-11-01`, end: `${year}-11-30` },
|
|
||||||
'dec': { start: `${year}-12-01`, end: `${year}-12-31` },
|
|
||||||
'q1': { start: `${year}-01-01`, end: `${year}-03-31` },
|
|
||||||
'q2': { start: `${year}-04-01`, end: `${year}-06-30` },
|
|
||||||
'q3': { start: `${year}-07-01`, end: `${year}-09-30` },
|
|
||||||
'q4': { start: `${year}-10-01`, end: `${year}-12-31` },
|
|
||||||
'h1': { start: `${year}-01-01`, end: `${year}-06-30` },
|
|
||||||
'h2': { start: `${year}-07-01`, end: `${year}-12-31` },
|
|
||||||
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
|
|
||||||
});
|
|
||||||
|
|
||||||
function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: ComparisonProps) {
|
function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: ComparisonProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
@@ -76,52 +57,25 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV
|
|||||||
return d;
|
return d;
|
||||||
}, [data, allowedMuseums, allowedChannels]);
|
}, [data, allowedMuseums, allowedChannels]);
|
||||||
|
|
||||||
// Get available years from data
|
|
||||||
const latestYear = useMemo(() => parseInt(getLatestYear(permissionFilteredData)), [permissionFilteredData]);
|
|
||||||
const availableYears = useMemo((): number[] => {
|
const availableYears = useMemo((): number[] => {
|
||||||
const yearsSet = new Set<number>();
|
const yearsSet = new Set<number>();
|
||||||
permissionFilteredData.forEach((r: MuseumRecord) => {
|
permissionFilteredData.forEach((r: MuseumRecord) => {
|
||||||
const d = r.date || (r as any).Date;
|
if (r.date) yearsSet.add(parseInt(r.date.slice(0, 4)));
|
||||||
if (d) yearsSet.add(new Date(d).getFullYear());
|
|
||||||
});
|
});
|
||||||
const years = Array.from(yearsSet).sort((a, b) => b - a);
|
const years = Array.from(yearsSet).sort((a, b) => b - a);
|
||||||
return years.length ? years : [new Date().getFullYear()];
|
return years.length ? years : [new Date().getFullYear()];
|
||||||
}, [data]);
|
}, [permissionFilteredData]);
|
||||||
|
|
||||||
// Initialize state from URL or defaults
|
const defaultCurr = currentMonthRange();
|
||||||
const [selectedYear, setSelectedYearState] = useState<number>(() => {
|
|
||||||
const urlYear = searchParams.get('year');
|
// Period A (current) — user selects freely
|
||||||
return urlYear ? parseInt(urlYear) : latestYear;
|
const [currStart, setCurrStartState] = useState(() => searchParams.get('aStart') || defaultCurr.start);
|
||||||
});
|
const [currEnd, setCurrEndState] = useState(() => searchParams.get('aEnd') || defaultCurr.end);
|
||||||
const presetDates = useMemo(() => generatePresetDates(selectedYear), [selectedYear]);
|
|
||||||
|
// 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 [preset, setPresetState] = useState(() => searchParams.get('preset') || 'jan');
|
|
||||||
const [startDate, setStartDateState] = useState(() => {
|
|
||||||
const urlPreset = searchParams.get('preset');
|
|
||||||
const yearParam = searchParams.get('year');
|
|
||||||
const year = yearParam ? parseInt(yearParam) : latestYear;
|
|
||||||
const dates = generatePresetDates(year);
|
|
||||||
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
|
||||||
return dates[urlPreset].start;
|
|
||||||
}
|
|
||||||
// Season presets store from/to in URL
|
|
||||||
const fromParam = searchParams.get('from');
|
|
||||||
if (fromParam) return fromParam;
|
|
||||||
return `${year}-01-01`;
|
|
||||||
});
|
|
||||||
const [endDate, setEndDateState] = useState(() => {
|
|
||||||
const urlPreset = searchParams.get('preset');
|
|
||||||
const yearParam = searchParams.get('year');
|
|
||||||
const year = yearParam ? parseInt(yearParam) : latestYear;
|
|
||||||
const dates = generatePresetDates(year);
|
|
||||||
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
|
||||||
return dates[urlPreset].end;
|
|
||||||
}
|
|
||||||
// Season presets store from/to in URL
|
|
||||||
const toParam = searchParams.get('to');
|
|
||||||
if (toParam) return toParam;
|
|
||||||
return `${year}-01-31`;
|
|
||||||
});
|
|
||||||
const [filters, setFiltersState] = useState(() => ({
|
const [filters, setFiltersState] = useState(() => ({
|
||||||
district: searchParams.get('district') || 'all',
|
district: searchParams.get('district') || 'all',
|
||||||
channel: searchParams.get('channel')?.split(',').filter(Boolean) || [],
|
channel: searchParams.get('channel')?.split(',').filter(Boolean) || [],
|
||||||
@@ -133,64 +87,39 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV
|
|||||||
const [activeChart, setActiveChart] = useState(0);
|
const [activeChart, setActiveChart] = useState(0);
|
||||||
const [activeCard, setActiveCard] = useState(0);
|
const [activeCard, setActiveCard] = useState(0);
|
||||||
|
|
||||||
// Update URL with current state
|
const updateUrl = useCallback((aStart: string, aEnd: string, bStart: string, bEnd: string, f: DateRangeFilters) => {
|
||||||
const updateUrl = useCallback((newPreset: string, newFrom: string | null, newTo: string | null, newFilters: DateRangeFilters | null, newYear: number) => {
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (newPreset && newPreset !== 'jan') params.set('preset', newPreset);
|
params.set('aStart', aStart);
|
||||||
if (newYear && newYear !== latestYear) params.set('year', newYear.toString());
|
params.set('aEnd', aEnd);
|
||||||
if (newPreset === 'custom' || newPreset.startsWith('season-')) {
|
params.set('bStart', bStart);
|
||||||
if (newFrom) params.set('from', newFrom);
|
params.set('bEnd', bEnd);
|
||||||
if (newTo) params.set('to', newTo);
|
if (f.district !== 'all') params.set('district', f.district);
|
||||||
}
|
if (f.channel.length > 0) params.set('channel', f.channel.join(','));
|
||||||
if (newFilters?.district && newFilters.district !== 'all') params.set('district', newFilters.district);
|
if (f.museum.length > 0) params.set('museum', f.museum.join(','));
|
||||||
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(params, { replace: true });
|
||||||
}, [setSearchParams, latestYear]);
|
}, [setSearchParams]);
|
||||||
|
|
||||||
const setSelectedYear = (year: number) => {
|
// When Period A changes, auto-update Period B to same period previous year
|
||||||
setSelectedYearState(year);
|
const handleCurrChange = (start: string, end: string) => {
|
||||||
const newDates = generatePresetDates(year);
|
const newPrevStart = shiftYearBack(start);
|
||||||
if (preset !== 'custom' && !preset.startsWith('season-') && newDates[preset]) {
|
const newPrevEnd = shiftYearBack(end);
|
||||||
setStartDateState(newDates[preset].start);
|
setCurrStartState(start);
|
||||||
setEndDateState(newDates[preset].end);
|
setCurrEndState(end);
|
||||||
}
|
setPrevStartState(newPrevStart);
|
||||||
updateUrl(preset, null, null, filters, year);
|
setPrevEndState(newPrevEnd);
|
||||||
|
updateUrl(start, end, newPrevStart, newPrevEnd, filters);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setPreset = (value: string) => {
|
const handlePrevChange = (start: string, end: string) => {
|
||||||
setPresetState(value);
|
setPrevStartState(start);
|
||||||
if (value.startsWith('season-')) {
|
setPrevEndState(end);
|
||||||
const seasonId = parseInt(value.replace('season-', ''));
|
updateUrl(currStart, currEnd, start, end, filters);
|
||||||
const season = seasons.find(s => s.Id === seasonId);
|
|
||||||
if (season) {
|
|
||||||
setStartDateState(season.StartDate);
|
|
||||||
setEndDateState(season.EndDate);
|
|
||||||
updateUrl(value, season.StartDate, season.EndDate, filters, selectedYear);
|
|
||||||
}
|
|
||||||
} else if (value !== 'custom' && presetDates[value]) {
|
|
||||||
setStartDateState(presetDates[value].start);
|
|
||||||
setEndDateState(presetDates[value].end);
|
|
||||||
updateUrl(value, null, null, filters, selectedYear);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setStartDate = (value: string) => {
|
|
||||||
setStartDateState(value);
|
|
||||||
setPresetState('custom');
|
|
||||||
updateUrl('custom', value, endDate, filters, selectedYear);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setEndDate = (value: string) => {
|
|
||||||
setEndDateState(value);
|
|
||||||
setPresetState('custom');
|
|
||||||
updateUrl('custom', startDate, value, filters, selectedYear);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setFilters = (newFilters: DateRangeFilters | ((prev: DateRangeFilters) => DateRangeFilters)) => {
|
const setFilters = (newFilters: DateRangeFilters | ((prev: DateRangeFilters) => DateRangeFilters)) => {
|
||||||
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
|
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
|
||||||
setFiltersState(updated);
|
setFiltersState(updated);
|
||||||
updateUrl(preset, startDate, endDate, updated, selectedYear);
|
updateUrl(currStart, currEnd, prevStart, prevEnd, updated);
|
||||||
};
|
};
|
||||||
|
|
||||||
const charts = [
|
const charts = [
|
||||||
@@ -249,39 +178,19 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV
|
|||||||
const districts = useMemo(() => getUniqueDistricts(permissionFilteredData), [permissionFilteredData]);
|
const districts = useMemo(() => getUniqueDistricts(permissionFilteredData), [permissionFilteredData]);
|
||||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]);
|
const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]);
|
||||||
|
|
||||||
// Year-over-year comparison: same dates, previous year
|
const ranges = useMemo(() => ({
|
||||||
// For season presets, try to find the same season name from the previous hijri year
|
curr: { start: currStart, end: currEnd },
|
||||||
const ranges = useMemo(() => {
|
prev: { start: prevStart, end: prevEnd },
|
||||||
const curr = { start: startDate, end: endDate };
|
}), [currStart, currEnd, prevStart, prevEnd]);
|
||||||
let prev = {
|
|
||||||
start: startDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1)),
|
|
||||||
end: endDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1))
|
|
||||||
};
|
|
||||||
|
|
||||||
if (preset.startsWith('season-')) {
|
|
||||||
const seasonId = parseInt(preset.replace('season-', ''));
|
|
||||||
const currentSeason = seasons.find(s => s.Id === seasonId);
|
|
||||||
if (currentSeason) {
|
|
||||||
const prevSeason = seasons.find(
|
|
||||||
s => s.Name === currentSeason.Name && s.HijriYear === currentSeason.HijriYear - 1
|
|
||||||
);
|
|
||||||
if (prevSeason) {
|
|
||||||
prev = { start: prevSeason.StartDate, end: prevSeason.EndDate };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { curr, prev };
|
|
||||||
}, [startDate, endDate, preset, seasons]);
|
|
||||||
|
|
||||||
const prevData = useMemo(() =>
|
const prevData = useMemo(() =>
|
||||||
filterDataByDateRange(permissionFilteredData, ranges.prev.start, ranges.prev.end, filters),
|
filterDataByDateRange(permissionFilteredData, prevStart, prevEnd, filters),
|
||||||
[permissionFilteredData, ranges.prev, filters]
|
[permissionFilteredData, prevStart, prevEnd, filters]
|
||||||
);
|
);
|
||||||
|
|
||||||
const currData = useMemo(() =>
|
const currData = useMemo(() =>
|
||||||
filterDataByDateRange(permissionFilteredData, ranges.curr.start, ranges.curr.end, filters),
|
filterDataByDateRange(permissionFilteredData, currStart, currEnd, filters),
|
||||||
[permissionFilteredData, ranges.curr, filters]
|
[permissionFilteredData, currStart, currEnd, filters]
|
||||||
);
|
);
|
||||||
|
|
||||||
const prevMetrics = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]);
|
const prevMetrics = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]);
|
||||||
@@ -616,60 +525,15 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterControls title={t('comparison.selectPeriod')} onReset={resetFilters}>
|
<FilterControls title={t('comparison.currentPeriod')} onReset={null}>
|
||||||
<FilterControls.Row>
|
<FilterControls.Row>
|
||||||
<FilterControls.Group label={t('comparison.period')}>
|
<PeriodPicker
|
||||||
<select value={preset} onChange={e => setPreset(e.target.value)}>
|
startDate={currStart}
|
||||||
<option value="custom">{t('comparison.custom')}</option>
|
endDate={currEnd}
|
||||||
<option value="jan">{t('months.january')}</option>
|
onChange={handleCurrChange}
|
||||||
<option value="feb">{t('months.february')}</option>
|
availableYears={availableYears}
|
||||||
<option value="mar">{t('months.march')}</option>
|
seasons={seasons}
|
||||||
<option value="apr">{t('months.april')}</option>
|
/>
|
||||||
<option value="may">{t('months.may')}</option>
|
|
||||||
<option value="jun">{t('months.june')}</option>
|
|
||||||
<option value="jul">{t('months.july')}</option>
|
|
||||||
<option value="aug">{t('months.august')}</option>
|
|
||||||
<option value="sep">{t('months.september')}</option>
|
|
||||||
<option value="oct">{t('months.october')}</option>
|
|
||||||
<option value="nov">{t('months.november')}</option>
|
|
||||||
<option value="dec">{t('months.december')}</option>
|
|
||||||
<option value="q1">{t('time.q1')}</option>
|
|
||||||
<option value="q2">{t('time.q2')}</option>
|
|
||||||
<option value="q3">{t('time.q3')}</option>
|
|
||||||
<option value="q4">{t('time.q4')}</option>
|
|
||||||
<option value="h1">{t('time.h1')}</option>
|
|
||||||
<option value="h2">{t('time.h2')}</option>
|
|
||||||
<option value="full">{t('time.fullYear')}</option>
|
|
||||||
{seasons.length > 0 && (
|
|
||||||
<optgroup label={t('comparison.seasons') || 'Seasons'}>
|
|
||||||
{seasons.map(s => (
|
|
||||||
<option key={s.Id} value={`season-${s.Id}`}>
|
|
||||||
{s.Name} {s.HijriYear}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</FilterControls.Group>
|
|
||||||
{preset !== 'custom' && !preset.startsWith('season-') && (
|
|
||||||
<FilterControls.Group label={t('filters.year')}>
|
|
||||||
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
|
|
||||||
{availableYears.map(y => (
|
|
||||||
<option key={y} value={y}>{y}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FilterControls.Group>
|
|
||||||
)}
|
|
||||||
{(preset === 'custom' || preset.startsWith('season-')) && (
|
|
||||||
<>
|
|
||||||
<FilterControls.Group label={t('comparison.from')}>
|
|
||||||
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
|
||||||
</FilterControls.Group>
|
|
||||||
<FilterControls.Group label={t('comparison.to')}>
|
|
||||||
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
|
|
||||||
</FilterControls.Group>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<FilterControls.Group label={t('filters.district')}>
|
<FilterControls.Group label={t('filters.district')}>
|
||||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: []})}>
|
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: []})}>
|
||||||
<option value="all">{t('filters.allDistricts')}</option>
|
<option value="all">{t('filters.allDistricts')}</option>
|
||||||
@@ -695,6 +559,18 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV
|
|||||||
</FilterControls.Row>
|
</FilterControls.Row>
|
||||||
</FilterControls>
|
</FilterControls>
|
||||||
|
|
||||||
|
<FilterControls title={t('comparison.previousPeriod')} onReset={null}>
|
||||||
|
<FilterControls.Row>
|
||||||
|
<PeriodPicker
|
||||||
|
startDate={prevStart}
|
||||||
|
endDate={prevEnd}
|
||||||
|
onChange={handlePrevChange}
|
||||||
|
availableYears={availableYears}
|
||||||
|
seasons={seasons}
|
||||||
|
/>
|
||||||
|
</FilterControls.Row>
|
||||||
|
</FilterControls>
|
||||||
|
|
||||||
<div className="period-display-banner" id="comparison-period">
|
<div className="period-display-banner" id="comparison-period">
|
||||||
<div className="period-box prev">
|
<div className="period-box prev">
|
||||||
<div className="period-label">{t('comparison.previousPeriod')}</div>
|
<div className="period-label">{t('comparison.previousPeriod')}</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { useSearchParams, Link } from 'react-router-dom';
|
import { useSearchParams, Link } from 'react-router-dom';
|
||||||
import { Line, Bar, Pie } from 'react-chartjs-2';
|
import { Line, Bar, Pie } from 'react-chartjs-2';
|
||||||
import { Carousel, EmptyState, FilterControls, MultiSelect, StatCard } from './shared';
|
import { Carousel, EmptyState, FilterControls, MultiSelect, PeriodPicker, StatCard } from './shared';
|
||||||
import { ExportableChart } from './ChartExport';
|
import { ExportableChart } from './ChartExport';
|
||||||
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import {
|
import {
|
||||||
filterData,
|
filterDataByDateRange,
|
||||||
calculateMetrics,
|
calculateMetrics,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
@@ -24,16 +24,22 @@ import {
|
|||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import type { DashboardProps, Filters, MuseumRecord, Season } from '../types';
|
import type { DashboardProps, Filters, MuseumRecord, Season } from '../types';
|
||||||
|
|
||||||
|
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)}` };
|
||||||
|
}
|
||||||
|
|
||||||
const defaultFilters: Filters = {
|
const defaultFilters: Filters = {
|
||||||
year: 'all',
|
...currentMonthRange(),
|
||||||
district: 'all',
|
district: 'all',
|
||||||
channel: [],
|
channel: [],
|
||||||
museum: [],
|
museum: [],
|
||||||
quarter: 'all'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter'];
|
|
||||||
|
|
||||||
function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: DashboardProps) {
|
function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: DashboardProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -46,30 +52,24 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
|
|||||||
|
|
||||||
// Initialize filters from URL or defaults
|
// Initialize filters from URL or defaults
|
||||||
const [filters, setFiltersState] = useState<Filters>(() => {
|
const [filters, setFiltersState] = useState<Filters>(() => {
|
||||||
const initial: Filters = { ...defaultFilters };
|
const def = defaultFilters;
|
||||||
filterKeys.forEach(key => {
|
return {
|
||||||
const value = searchParams.get(key);
|
startDate: searchParams.get('start') || def.startDate,
|
||||||
if (value) (initial as Record<string, unknown>)[key] = value;
|
endDate: searchParams.get('end') || def.endDate,
|
||||||
});
|
district: searchParams.get('district') || 'all',
|
||||||
const museumParam = searchParams.get('museum');
|
museum: searchParams.get('museum')?.split(',').filter(Boolean) || [],
|
||||||
if (museumParam) initial.museum = museumParam.split(',').filter(Boolean);
|
channel: searchParams.get('channel')?.split(',').filter(Boolean) || [],
|
||||||
const channelParam = searchParams.get('channel');
|
};
|
||||||
if (channelParam) initial.channel = channelParam.split(',').filter(Boolean);
|
|
||||||
return initial;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update both state and URL
|
// Update both state and URL
|
||||||
const setFilters = (newFilters: Filters | ((prev: Filters) => Filters)) => {
|
const setFilters = (newFilters: Filters | ((prev: Filters) => Filters)) => {
|
||||||
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
|
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
|
||||||
setFiltersState(updated);
|
setFiltersState(updated);
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
filterKeys.forEach(key => {
|
params.set('start', updated.startDate);
|
||||||
const val = (updated as Record<string, unknown>)[key] as string;
|
params.set('end', updated.endDate);
|
||||||
if (val && val !== 'all') {
|
if (updated.district !== 'all') params.set('district', updated.district);
|
||||||
params.set(key, val);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (updated.museum.length > 0) params.set('museum', updated.museum.join(','));
|
if (updated.museum.length > 0) params.set('museum', updated.museum.join(','));
|
||||||
if (updated.channel.length > 0) params.set('channel', updated.channel.join(','));
|
if (updated.channel.length > 0) params.set('channel', updated.channel.join(','));
|
||||||
setSearchParams(params, { replace: true });
|
setSearchParams(params, { replace: true });
|
||||||
@@ -97,7 +97,10 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
|
|||||||
return d;
|
return d;
|
||||||
}, [data, allowedMuseums, allowedChannels]);
|
}, [data, allowedMuseums, allowedChannels]);
|
||||||
|
|
||||||
const filteredData = useMemo(() => filterData(permissionFilteredData, filters), [permissionFilteredData, filters]);
|
const filteredData = useMemo(
|
||||||
|
() => filterDataByDateRange(permissionFilteredData, filters.startDate, filters.endDate, filters),
|
||||||
|
[permissionFilteredData, filters]
|
||||||
|
);
|
||||||
|
|
||||||
const seasonFilteredData = useMemo(() => {
|
const seasonFilteredData = useMemo(() => {
|
||||||
if (!selectedSeason) return filteredData;
|
if (!selectedSeason) return filteredData;
|
||||||
@@ -134,13 +137,13 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
|
|||||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]);
|
const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]);
|
||||||
|
|
||||||
const yoyChange = useMemo(() => {
|
const yoyChange = useMemo(() => {
|
||||||
if (filters.year === 'all') return null;
|
const prevStart = filters.startDate.replace(/^(\d{4})/, (_, y) => String(parseInt(y) - 1));
|
||||||
const prevYear = String(parseInt(filters.year) - 1);
|
const prevEnd = filters.endDate.replace(/^(\d{4})/, (_, y) => String(parseInt(y) - 1));
|
||||||
const prevData = permissionFilteredData.filter((row: MuseumRecord) => row.year === prevYear);
|
const prevData = filterDataByDateRange(permissionFilteredData, prevStart, prevEnd, filters);
|
||||||
if (prevData.length === 0) return null;
|
if (prevData.length === 0) return null;
|
||||||
const prevMetrics = calculateMetrics(prevData, includeVAT);
|
const prevMetrics = calculateMetrics(prevData, includeVAT);
|
||||||
return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null;
|
return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null;
|
||||||
}, [permissionFilteredData, filters.year, metrics.revenue, includeVAT]);
|
}, [permissionFilteredData, filters, metrics.revenue, includeVAT]);
|
||||||
|
|
||||||
// Revenue trend data (weekly or daily)
|
// Revenue trend data (weekly or daily)
|
||||||
const trendData = useMemo(() => {
|
const trendData = useMemo(() => {
|
||||||
@@ -514,12 +517,13 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
|
|||||||
|
|
||||||
<FilterControls title={t('filters.title')} onReset={resetFilters}>
|
<FilterControls title={t('filters.title')} onReset={resetFilters}>
|
||||||
<FilterControls.Row>
|
<FilterControls.Row>
|
||||||
<FilterControls.Group label={t('filters.year')}>
|
<PeriodPicker
|
||||||
<select value={filters.year} onChange={e => setFilters({...filters, year: e.target.value})}>
|
startDate={filters.startDate}
|
||||||
<option value="all">{t('filters.allYears')}</option>
|
endDate={filters.endDate}
|
||||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
onChange={(start, end) => setFilters({ ...filters, startDate: start, endDate: end })}
|
||||||
</select>
|
availableYears={years.map(Number)}
|
||||||
</FilterControls.Group>
|
seasons={seasons}
|
||||||
|
/>
|
||||||
<FilterControls.Group label={t('filters.district')}>
|
<FilterControls.Group label={t('filters.district')}>
|
||||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: []})}>
|
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: []})}>
|
||||||
<option value="all">{t('filters.allDistricts')}</option>
|
<option value="all">{t('filters.allDistricts')}</option>
|
||||||
@@ -542,15 +546,6 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
|
|||||||
allLabel={t('filters.allMuseums')}
|
allLabel={t('filters.allMuseums')}
|
||||||
/>
|
/>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.quarter')}>
|
|
||||||
<select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}>
|
|
||||||
<option value="all">{t('filters.allQuarters')}</option>
|
|
||||||
<option value="1">{t('time.q1')}</option>
|
|
||||||
<option value="2">{t('time.q2')}</option>
|
|
||||||
<option value="3">{t('time.q3')}</option>
|
|
||||||
<option value="4">{t('time.q4')}</option>
|
|
||||||
</select>
|
|
||||||
</FilterControls.Group>
|
|
||||||
<FilterControls.Group label={t('filters.season')}>
|
<FilterControls.Group label={t('filters.season')}>
|
||||||
<select value={selectedSeason} onChange={e => setSelectedSeason(e.target.value)}>
|
<select value={selectedSeason} onChange={e => setSelectedSeason(e.target.value)}>
|
||||||
<option value="">{t('filters.allSeasons')}</option>
|
<option value="">{t('filters.allSeasons')}</option>
|
||||||
|
|||||||
@@ -391,8 +391,8 @@ function SlidePreview({ slide, data, channels, museums, metrics }: SlidePreviewP
|
|||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const filteredData = useMemo(() =>
|
const filteredData = useMemo(() =>
|
||||||
filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||||
channel: slide.channel,
|
channel: slide.channel ? [slide.channel] : [],
|
||||||
museum: slide.museum
|
museum: slide.museum ? [slide.museum] : []
|
||||||
}),
|
}),
|
||||||
[data, slide.startDate, slide.endDate, slide.channel, slide.museum]
|
[data, slide.startDate, slide.endDate, slide.channel, slide.museum]
|
||||||
);
|
);
|
||||||
@@ -544,8 +544,8 @@ function generateSlideHTML(slide: SlideConfig, index: number, data: MuseumRecord
|
|||||||
|
|
||||||
function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string {
|
function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string {
|
||||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||||
channel: slide.channel,
|
channel: slide.channel ? [slide.channel] : [],
|
||||||
museum: slide.museum
|
museum: slide.museum ? [slide.museum] : []
|
||||||
});
|
});
|
||||||
const metrics = calculateMetrics(filtered);
|
const metrics = calculateMetrics(filtered);
|
||||||
|
|
||||||
@@ -571,8 +571,8 @@ function generateChartScripts(slides: SlideConfig[], data: MuseumRecord[]): stri
|
|||||||
if (slide.chartType === 'kpi-cards') return '';
|
if (slide.chartType === 'kpi-cards') return '';
|
||||||
|
|
||||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||||
channel: slide.channel,
|
channel: slide.channel ? [slide.channel] : [],
|
||||||
museum: slide.museum
|
museum: slide.museum ? [slide.museum] : []
|
||||||
});
|
});
|
||||||
|
|
||||||
const chartConfig = generateChartConfig(slide, filtered);
|
const chartConfig = generateChartConfig(slide, filtered);
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
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<string, (year: number) => { 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<number>(() => guessPreset(startDate, endDate).year || new Date().getFullYear());
|
||||||
|
const [preset, setPreset] = useState<string>(() => guessPreset(startDate, endDate).preset);
|
||||||
|
|
||||||
|
// When parent updates dates externally (e.g. auto-fill from Period A), sync internal state
|
||||||
|
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 (
|
||||||
|
<div style={{ display: 'contents' }}>
|
||||||
|
<div className="control-group">
|
||||||
|
<label>{t('comparison.period')}</label>
|
||||||
|
<select value={preset} onChange={e => handlePreset(e.target.value)}>
|
||||||
|
<option value="custom">{t('comparison.custom')}</option>
|
||||||
|
<option value="jan">{t('months.january')}</option>
|
||||||
|
<option value="feb">{t('months.february')}</option>
|
||||||
|
<option value="mar">{t('months.march')}</option>
|
||||||
|
<option value="apr">{t('months.april')}</option>
|
||||||
|
<option value="may">{t('months.may')}</option>
|
||||||
|
<option value="jun">{t('months.june')}</option>
|
||||||
|
<option value="jul">{t('months.july')}</option>
|
||||||
|
<option value="aug">{t('months.august')}</option>
|
||||||
|
<option value="sep">{t('months.september')}</option>
|
||||||
|
<option value="oct">{t('months.october')}</option>
|
||||||
|
<option value="nov">{t('months.november')}</option>
|
||||||
|
<option value="dec">{t('months.december')}</option>
|
||||||
|
<option value="q1">{t('time.q1')}</option>
|
||||||
|
<option value="q2">{t('time.q2')}</option>
|
||||||
|
<option value="q3">{t('time.q3')}</option>
|
||||||
|
<option value="q4">{t('time.q4')}</option>
|
||||||
|
<option value="h1">{t('time.h1')}</option>
|
||||||
|
<option value="h2">{t('time.h2')}</option>
|
||||||
|
<option value="full">{t('time.fullYear')}</option>
|
||||||
|
{seasons.length > 0 && (
|
||||||
|
<optgroup label={t('comparison.seasons') || 'Seasons'}>
|
||||||
|
{seasons.map(s => (
|
||||||
|
<option key={s.Id} value={`season-${s.Id}`}>
|
||||||
|
{s.Name} {s.HijriYear}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{preset !== 'custom' && !preset.startsWith('season-') && availableYears.length > 0 && (
|
||||||
|
<div className="control-group">
|
||||||
|
<label>{t('filters.year')}</label>
|
||||||
|
<select value={year} onChange={e => handleYear(parseInt(e.target.value))}>
|
||||||
|
{availableYears.map(y => <option key={y} value={y}>{y}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(preset === 'custom' || preset.startsWith('season-')) && (
|
||||||
|
<>
|
||||||
|
<div className="control-group">
|
||||||
|
<label>{t('comparison.from')}</label>
|
||||||
|
<input type="date" value={startDate} onChange={e => handleStart(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="control-group">
|
||||||
|
<label>{t('comparison.to')}</label>
|
||||||
|
<input type="date" value={endDate} onChange={e => handleEnd(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,3 +5,4 @@ export { default as FilterControls } from './FilterControls';
|
|||||||
export { default as MultiSelect } from './MultiSelect';
|
export { default as MultiSelect } from './MultiSelect';
|
||||||
export { default as StatCard } from './StatCard';
|
export { default as StatCard } from './StatCard';
|
||||||
export { default as ToggleSwitch } from './ToggleSwitch';
|
export { default as ToggleSwitch } from './ToggleSwitch';
|
||||||
|
export { default as PeriodPicker } from './PeriodPicker';
|
||||||
|
|||||||
@@ -273,11 +273,9 @@ export async function refreshData(): Promise<FetchResult> {
|
|||||||
|
|
||||||
export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord[] {
|
export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord[] {
|
||||||
return data.filter(row => {
|
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.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
||||||
if (filters.channel.length > 0 && !filters.channel.includes(row.channel)) 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.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;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -21,11 +21,11 @@ export interface Metrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Filters {
|
export interface Filters {
|
||||||
year: string;
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
district: string;
|
district: string;
|
||||||
channel: string[];
|
channel: string[];
|
||||||
museum: string[];
|
museum: string[];
|
||||||
quarter: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateRangeFilters {
|
export interface DateRangeFilters {
|
||||||
|
|||||||
Reference in New Issue
Block a user