feat: add district filter (Hiraa/AsSaffiyah) from static mapping
- ETL writes District column to NocoDB DailySales - Museums mapped: Hiraa (Revelation, Holy Quraan, Trail, Makkah, VIP) AsSaffiyah (Creation Story, Best of Creation) - District filter added to Dashboard and Comparison (cascades to museum) - District Performance chart added (desktop + mobile) - Locale keys added for both EN and AR Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@ import {
|
||||
umrahData,
|
||||
getUniqueChannels,
|
||||
getUniqueMuseums,
|
||||
getUniqueDistricts,
|
||||
getMuseumsForDistrict,
|
||||
getLatestYear
|
||||
} from '../services/dataService';
|
||||
import type { MuseumRecord, ComparisonProps, DateRangeFilters } from '../types';
|
||||
@@ -106,6 +108,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
return searchParams.get('to') || `${year}-01-31`;
|
||||
});
|
||||
const [filters, setFiltersState] = useState(() => ({
|
||||
district: searchParams.get('district') || 'all',
|
||||
channel: searchParams.get('channel') || 'all',
|
||||
museum: searchParams.get('museum') || 'all'
|
||||
}));
|
||||
@@ -124,6 +127,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
if (newFrom) params.set('from', newFrom);
|
||||
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);
|
||||
setSearchParams(params, { replace: true });
|
||||
@@ -219,7 +223,8 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
|
||||
// Dynamic lists from data
|
||||
const channels = useMemo(() => getUniqueChannels(data), [data]);
|
||||
const availableMuseums = useMemo(() => getUniqueMuseums(data), [data]);
|
||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
|
||||
|
||||
// Year-over-year comparison: same dates, previous year
|
||||
const ranges = useMemo(() => ({
|
||||
@@ -244,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({ channel: 'all', museum: 'all' });
|
||||
const resetFilters = () => setFilters({ district: 'all', channel: 'all', museum: 'all' });
|
||||
|
||||
const calcChange = (prev: number, curr: number) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
||||
|
||||
@@ -575,6 +580,12 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
</FilterControls.Group>
|
||||
</>
|
||||
)}
|
||||
<FilterControls.Group label={t('filters.district')}>
|
||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
||||
<option value="all">{t('filters.allDistricts')}</option>
|
||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label={t('filters.channel')}>
|
||||
<select value={filters.channel} onChange={e => setFilters({...filters, channel: e.target.value})}>
|
||||
<option value="all">{t('filters.allChannels')}</option>
|
||||
|
||||
@@ -17,18 +17,22 @@ import {
|
||||
fetchPilgrimStats,
|
||||
getUniqueYears,
|
||||
getUniqueChannels,
|
||||
getUniqueMuseums
|
||||
getUniqueMuseums,
|
||||
getUniqueDistricts,
|
||||
getMuseumsForDistrict,
|
||||
groupByDistrict
|
||||
} from '../services/dataService';
|
||||
import type { DashboardProps, Filters, MuseumRecord } from '../types';
|
||||
|
||||
const defaultFilters: Filters = {
|
||||
year: 'all',
|
||||
district: 'all',
|
||||
channel: 'all',
|
||||
museum: 'all',
|
||||
quarter: 'all'
|
||||
};
|
||||
|
||||
const filterKeys: (keyof Filters)[] = ['year', 'channel', 'museum', 'quarter'];
|
||||
const filterKeys: (keyof Filters)[] = ['year', 'district', 'channel', 'museum', 'quarter'];
|
||||
|
||||
function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
|
||||
const { t } = useLanguage();
|
||||
@@ -84,14 +88,15 @@ 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.captureRate')];
|
||||
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);
|
||||
}, [filters.museum, t]);
|
||||
|
||||
// Dynamic lists from data
|
||||
const years = useMemo(() => getUniqueYears(data), [data]);
|
||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||
const channels = useMemo(() => getUniqueChannels(data), [data]);
|
||||
const availableMuseums = useMemo(() => getUniqueMuseums(data), [data]);
|
||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
|
||||
|
||||
const yoyChange = useMemo(() => {
|
||||
if (filters.year === 'all') return null;
|
||||
@@ -224,6 +229,20 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
};
|
||||
}, [filteredData, includeVAT]);
|
||||
|
||||
// District data
|
||||
const districtData = useMemo(() => {
|
||||
const grouped = groupByDistrict(filteredData, includeVAT);
|
||||
const districtNames = Object.keys(grouped);
|
||||
return {
|
||||
labels: districtNames,
|
||||
datasets: [{
|
||||
data: districtNames.map(d => grouped[d].revenue),
|
||||
backgroundColor: [chartColors.primary + 'cc', chartColors.secondary + 'cc', chartColors.tertiary + 'cc', chartColors.muted + 'cc'],
|
||||
borderRadius: 4
|
||||
}]
|
||||
};
|
||||
}, [filteredData, includeVAT]);
|
||||
|
||||
// Quarterly YoY
|
||||
const quarterlyYoYData = useMemo(() => {
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
@@ -259,6 +278,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
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 !== 'all') qData = qData.filter((r: MuseumRecord) => r.channel === filters.channel);
|
||||
if (filters.museum !== 'all') qData = qData.filter((r: MuseumRecord) => r.museum_name === filters.museum);
|
||||
const visitors = qData.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
|
||||
@@ -323,7 +343,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data, filters.channel, filters.museum, showDataLabels]);
|
||||
}, [data, filters.district, filters.channel, filters.museum, showDataLabels]);
|
||||
|
||||
// Quarterly table
|
||||
const quarterlyTable = useMemo(() => {
|
||||
@@ -333,6 +353,10 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
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 !== 'all') {
|
||||
q2024 = q2024.filter((r: MuseumRecord) => r.channel === filters.channel);
|
||||
q2025 = q2025.filter((r: MuseumRecord) => r.channel === filters.channel);
|
||||
@@ -351,7 +375,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
const cap25 = umrahData[2025][q] ? (vis25 / umrahData[2025][q] * 100) : null;
|
||||
return { q, rev24, rev25, revChg, vis24, vis25, visChg, cap24, cap25 };
|
||||
});
|
||||
}, [data, filters.channel, filters.museum, includeVAT]);
|
||||
}, [data, filters.district, filters.channel, filters.museum, includeVAT]);
|
||||
|
||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||
|
||||
@@ -388,6 +412,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label={t('filters.district')}>
|
||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
||||
<option value="all">{t('filters.allDistricts')}</option>
|
||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label={t('filters.channel')}>
|
||||
<select value={filters.channel} onChange={e => setFilters({...filters, channel: e.target.value})}>
|
||||
<option value="all">{t('filters.allChannels')}</option>
|
||||
@@ -534,6 +564,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
</ExportableChart>
|
||||
</div>
|
||||
|
||||
<div className="chart-card half-width">
|
||||
<ExportableChart filename="district-performance" title={t('dashboard.districtPerformance')} className="chart-container">
|
||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||
</ExportableChart>
|
||||
</div>
|
||||
|
||||
<div className="chart-card full-width">
|
||||
<ExportableChart filename="capture-rate" title={t('dashboard.captureRateChart')} className="chart-container">
|
||||
<Line data={captureRateData} options={{
|
||||
@@ -638,6 +674,15 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>{t('dashboard.districtPerformance')}</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>{t('dashboard.captureRateChart')}</h2>
|
||||
|
||||
Reference in New Issue
Block a user