feat: season filter + chart bands on Dashboard and Comparison
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Dashboard: - Season dropdown filter (filters data by season date range) - Revenue trend chart shows colored annotation bands for each season - All downstream memos use season-filtered data Comparison: - Season presets in period selector (optgroup) - Auto-compares with same season from previous hijri year if defined - Season preset persists start/end dates in URL Added chartjs-plugin-annotation for chart bands. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"chartjs-plugin-annotation": "^3.1.0",
|
||||||
"chartjs-plugin-datalabels": "^2.2.0",
|
"chartjs-plugin-datalabels": "^2.2.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
@@ -1571,6 +1572,15 @@
|
|||||||
"pnpm": ">=8"
|
"pnpm": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chartjs-plugin-annotation": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chartjs-plugin-datalabels": {
|
"node_modules/chartjs-plugin-datalabels": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"chartjs-plugin-annotation": "^3.1.0",
|
||||||
"chartjs-plugin-datalabels": "^2.2.0",
|
"chartjs-plugin-datalabels": "^2.2.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
getMuseumsForDistrict,
|
getMuseumsForDistrict,
|
||||||
getLatestYear
|
getLatestYear
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import type { MuseumRecord, ComparisonProps, DateRangeFilters } from '../types';
|
import type { MuseumRecord, ComparisonProps, DateRangeFilters, Season } from '../types';
|
||||||
|
|
||||||
interface PresetDateRange {
|
interface PresetDateRange {
|
||||||
start: string;
|
start: string;
|
||||||
@@ -63,7 +63,7 @@ const generatePresetDates = (year: number): PresetDates => ({
|
|||||||
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
|
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
|
||||||
});
|
});
|
||||||
|
|
||||||
function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: ComparisonProps) {
|
function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: ComparisonProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
@@ -95,7 +95,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
||||||
return dates[urlPreset].start;
|
return dates[urlPreset].start;
|
||||||
}
|
}
|
||||||
return searchParams.get('from') || `${year}-01-01`;
|
// 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 [endDate, setEndDateState] = useState(() => {
|
||||||
const urlPreset = searchParams.get('preset');
|
const urlPreset = searchParams.get('preset');
|
||||||
@@ -105,7 +108,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
||||||
return dates[urlPreset].end;
|
return dates[urlPreset].end;
|
||||||
}
|
}
|
||||||
return searchParams.get('to') || `${year}-01-31`;
|
// 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',
|
||||||
@@ -123,7 +129,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (newPreset && newPreset !== 'jan') params.set('preset', newPreset);
|
if (newPreset && newPreset !== 'jan') params.set('preset', newPreset);
|
||||||
if (newYear && newYear !== latestYear) params.set('year', newYear.toString());
|
if (newYear && newYear !== latestYear) params.set('year', newYear.toString());
|
||||||
if (newPreset === 'custom') {
|
if (newPreset === 'custom' || newPreset.startsWith('season-')) {
|
||||||
if (newFrom) params.set('from', newFrom);
|
if (newFrom) params.set('from', newFrom);
|
||||||
if (newTo) params.set('to', newTo);
|
if (newTo) params.set('to', newTo);
|
||||||
}
|
}
|
||||||
@@ -136,7 +142,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
const setSelectedYear = (year: number) => {
|
const setSelectedYear = (year: number) => {
|
||||||
setSelectedYearState(year);
|
setSelectedYearState(year);
|
||||||
const newDates = generatePresetDates(year);
|
const newDates = generatePresetDates(year);
|
||||||
if (preset !== 'custom' && newDates[preset]) {
|
if (preset !== 'custom' && !preset.startsWith('season-') && newDates[preset]) {
|
||||||
setStartDateState(newDates[preset].start);
|
setStartDateState(newDates[preset].start);
|
||||||
setEndDateState(newDates[preset].end);
|
setEndDateState(newDates[preset].end);
|
||||||
}
|
}
|
||||||
@@ -145,7 +151,15 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
|
|
||||||
const setPreset = (value: string) => {
|
const setPreset = (value: string) => {
|
||||||
setPresetState(value);
|
setPresetState(value);
|
||||||
if (value !== 'custom' && presetDates[value]) {
|
if (value.startsWith('season-')) {
|
||||||
|
const seasonId = parseInt(value.replace('season-', ''));
|
||||||
|
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);
|
setStartDateState(presetDates[value].start);
|
||||||
setEndDateState(presetDates[value].end);
|
setEndDateState(presetDates[value].end);
|
||||||
updateUrl(value, null, null, filters, selectedYear);
|
updateUrl(value, null, null, filters, selectedYear);
|
||||||
@@ -227,13 +241,29 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
|
const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
|
||||||
|
|
||||||
// Year-over-year comparison: same dates, previous year
|
// 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: startDate, end: endDate },
|
const ranges = useMemo(() => {
|
||||||
prev: {
|
const curr = { start: startDate, end: endDate };
|
||||||
|
let prev = {
|
||||||
start: startDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1)),
|
start: startDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1)),
|
||||||
end: endDate.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 };
|
||||||
}
|
}
|
||||||
}), [startDate, endDate]);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { curr, prev };
|
||||||
|
}, [startDate, endDate, preset, seasons]);
|
||||||
|
|
||||||
const prevData = useMemo(() =>
|
const prevData = useMemo(() =>
|
||||||
filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters),
|
filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters),
|
||||||
@@ -559,9 +589,18 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
<option value="h1">{t('time.h1')}</option>
|
<option value="h1">{t('time.h1')}</option>
|
||||||
<option value="h2">{t('time.h2')}</option>
|
<option value="h2">{t('time.h2')}</option>
|
||||||
<option value="full">{t('time.fullYear')}</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>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
{preset !== 'custom' && (
|
{preset !== 'custom' && !preset.startsWith('season-') && (
|
||||||
<FilterControls.Group label={t('filters.year')}>
|
<FilterControls.Group label={t('filters.year')}>
|
||||||
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
|
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
|
||||||
{availableYears.map(y => (
|
{availableYears.map(y => (
|
||||||
@@ -570,7 +609,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
)}
|
)}
|
||||||
{preset === 'custom' && (
|
{(preset === 'custom' || preset.startsWith('season-')) && (
|
||||||
<>
|
<>
|
||||||
<FilterControls.Group label={t('comparison.from')}>
|
<FilterControls.Group label={t('comparison.from')}>
|
||||||
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
getMuseumsForDistrict,
|
getMuseumsForDistrict,
|
||||||
groupByDistrict
|
groupByDistrict
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import type { DashboardProps, Filters, MuseumRecord } from '../types';
|
import type { DashboardProps, Filters, MuseumRecord, Season } from '../types';
|
||||||
|
|
||||||
const defaultFilters: Filters = {
|
const defaultFilters: Filters = {
|
||||||
year: 'all',
|
year: 'all',
|
||||||
@@ -34,7 +34,7 @@ const defaultFilters: Filters = {
|
|||||||
|
|
||||||
const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter'];
|
const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter'];
|
||||||
|
|
||||||
function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
|
function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [pilgrimLoaded, setPilgrimLoaded] = useState(false);
|
const [pilgrimLoaded, setPilgrimLoaded] = useState(false);
|
||||||
@@ -78,12 +78,24 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
const [activeStatCard, setActiveStatCard] = useState(0);
|
const [activeStatCard, setActiveStatCard] = useState(0);
|
||||||
const [activeChart, setActiveChart] = useState(0);
|
const [activeChart, setActiveChart] = useState(0);
|
||||||
const [trendGranularity, setTrendGranularity] = useState('week');
|
const [trendGranularity, setTrendGranularity] = useState('week');
|
||||||
|
const [selectedSeason, setSelectedSeason] = useState<string>('');
|
||||||
|
|
||||||
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
|
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
|
||||||
const metrics = useMemo(() => calculateMetrics(filteredData, includeVAT), [filteredData, includeVAT]);
|
|
||||||
const hasData = filteredData.length > 0;
|
|
||||||
|
|
||||||
const resetFilters = () => setFilters(defaultFilters);
|
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]);
|
||||||
|
|
||||||
|
const metrics = useMemo(() => calculateMetrics(seasonFilteredData, includeVAT), [seasonFilteredData, includeVAT]);
|
||||||
|
const hasData = seasonFilteredData.length > 0;
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setFilters(defaultFilters);
|
||||||
|
setSelectedSeason('');
|
||||||
|
};
|
||||||
|
|
||||||
// Stat cards for carousel
|
// Stat cards for carousel
|
||||||
const statCards = useMemo(() => [
|
const statCards = useMemo(() => [
|
||||||
@@ -153,11 +165,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (trendGranularity === 'week') {
|
if (trendGranularity === 'week') {
|
||||||
const grouped = groupByWeek(filteredData, includeVAT);
|
const grouped = groupByWeek(seasonFilteredData, includeVAT);
|
||||||
const weeks = Object.keys(grouped).filter(w => w).sort();
|
const weeks = Object.keys(grouped).filter(w => w).sort();
|
||||||
const revenueValues = weeks.map(w => grouped[w].revenue);
|
const revenueValues = weeks.map(w => grouped[w].revenue);
|
||||||
return {
|
return {
|
||||||
labels: weeks.map(formatLabel),
|
labels: weeks.map(formatLabel),
|
||||||
|
rawDates: weeks,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
||||||
data: revenueValues,
|
data: revenueValues,
|
||||||
@@ -173,7 +186,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
} else {
|
} else {
|
||||||
// Daily granularity
|
// Daily granularity
|
||||||
const dailyData: Record<string, number> = {};
|
const dailyData: Record<string, number> = {};
|
||||||
filteredData.forEach(row => {
|
seasonFilteredData.forEach(row => {
|
||||||
const date = row.date;
|
const date = row.date;
|
||||||
if (!dailyData[date]) dailyData[date] = 0;
|
if (!dailyData[date]) dailyData[date] = 0;
|
||||||
dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || 0);
|
dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || 0);
|
||||||
@@ -182,6 +195,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
const revenueValues = days.map(d => dailyData[d]);
|
const revenueValues = days.map(d => dailyData[d]);
|
||||||
return {
|
return {
|
||||||
labels: days.map(formatLabel),
|
labels: days.map(formatLabel),
|
||||||
|
rawDates: days,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
||||||
data: revenueValues,
|
data: revenueValues,
|
||||||
@@ -195,11 +209,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
}, trendlineDataset(revenueValues)]
|
}, trendlineDataset(revenueValues)]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [filteredData, trendGranularity, includeVAT]);
|
}, [seasonFilteredData, trendGranularity, includeVAT]);
|
||||||
|
|
||||||
// Museum data
|
// Museum data
|
||||||
const museumData = useMemo(() => {
|
const museumData = useMemo(() => {
|
||||||
const grouped = groupByMuseum(filteredData, includeVAT);
|
const grouped = groupByMuseum(seasonFilteredData, includeVAT);
|
||||||
const museums = Object.keys(grouped);
|
const museums = Object.keys(grouped);
|
||||||
return {
|
return {
|
||||||
visitors: {
|
visitors: {
|
||||||
@@ -220,11 +234,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [filteredData, includeVAT]);
|
}, [seasonFilteredData, includeVAT]);
|
||||||
|
|
||||||
// Channel data
|
// Channel data
|
||||||
const channelData = useMemo(() => {
|
const channelData = useMemo(() => {
|
||||||
const grouped = groupByChannel(filteredData, includeVAT);
|
const grouped = groupByChannel(seasonFilteredData, includeVAT);
|
||||||
const channels = Object.keys(grouped);
|
const channels = Object.keys(grouped);
|
||||||
return {
|
return {
|
||||||
labels: channels,
|
labels: channels,
|
||||||
@@ -234,11 +248,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
borderRadius: 4
|
borderRadius: 4
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
}, [filteredData, includeVAT]);
|
}, [seasonFilteredData, includeVAT]);
|
||||||
|
|
||||||
// District data
|
// District data
|
||||||
const districtData = useMemo(() => {
|
const districtData = useMemo(() => {
|
||||||
const grouped = groupByDistrict(filteredData, includeVAT);
|
const grouped = groupByDistrict(seasonFilteredData, includeVAT);
|
||||||
const districtNames = Object.keys(grouped);
|
const districtNames = Object.keys(grouped);
|
||||||
return {
|
return {
|
||||||
labels: districtNames,
|
labels: districtNames,
|
||||||
@@ -248,7 +262,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
borderRadius: 4
|
borderRadius: 4
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
}, [filteredData, includeVAT]);
|
}, [seasonFilteredData, includeVAT]);
|
||||||
|
|
||||||
// Quarterly YoY
|
// Quarterly YoY
|
||||||
const quarterlyYoYData = useMemo(() => {
|
const quarterlyYoYData = useMemo(() => {
|
||||||
@@ -386,6 +400,35 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
|
|
||||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||||
|
|
||||||
|
// Season annotation bands for revenue trend chart
|
||||||
|
const seasonAnnotations = useMemo(() => {
|
||||||
|
const raw = trendData.rawDates;
|
||||||
|
if (!seasons.length || !raw?.length) return {};
|
||||||
|
const annotations: Record<string, unknown> = {};
|
||||||
|
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 (
|
return (
|
||||||
<div className="dashboard" id="dashboard-container">
|
<div className="dashboard" id="dashboard-container">
|
||||||
<div className="page-title-with-actions">
|
<div className="page-title-with-actions">
|
||||||
@@ -450,6 +493,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
<option value="4">{t('time.q4')}</option>
|
<option value="4">{t('time.q4')}</option>
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
|
<FilterControls.Group label={t('filters.season')}>
|
||||||
|
<select value={selectedSeason} onChange={e => setSelectedSeason(e.target.value)}>
|
||||||
|
<option value="">{t('filters.allSeasons')}</option>
|
||||||
|
{seasons.map(s => (
|
||||||
|
<option key={s.Id} value={String(s.Id)}>
|
||||||
|
{s.Name} {s.HijriYear}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FilterControls.Group>
|
||||||
</FilterControls.Row>
|
</FilterControls.Row>
|
||||||
</FilterControls>
|
</FilterControls>
|
||||||
|
|
||||||
@@ -543,7 +596,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 13}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
|
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, annotation: { annotations: seasonAnnotations }, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 13}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
|
||||||
</ExportableChart>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -636,7 +689,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button>
|
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-container">
|
<div className="chart-container">
|
||||||
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 10, padding: 8, font: {size: 12}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} />
|
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, annotation: { annotations: seasonAnnotations }, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 10, padding: 8, font: {size: 12}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Filler
|
Filler
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||||
|
import Annotation from 'chartjs-plugin-annotation';
|
||||||
|
|
||||||
// Register ChartJS components once
|
// Register ChartJS components once
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
@@ -25,7 +26,8 @@ ChartJS.register(
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
Filler,
|
Filler,
|
||||||
ChartDataLabels
|
ChartDataLabels,
|
||||||
|
Annotation
|
||||||
);
|
);
|
||||||
|
|
||||||
export const chartColors = {
|
export const chartColors = {
|
||||||
|
|||||||
Reference in New Issue
Block a user