From 219680fb5ec1e4a4a7d3eda888bcf694770cdbdd Mon Sep 17 00:00:00 2001 From: fahed Date: Tue, 31 Mar 2026 14:08:16 +0300 Subject: [PATCH] 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) --- server/src/config/museumMapping.ts | 15 ++++++++ server/src/services/etlSync.ts | 4 ++- server/src/types.ts | 1 + src/components/Comparison.tsx | 15 ++++++-- src/components/Dashboard.tsx | 57 ++++++++++++++++++++++++++---- src/locales/ar.json | 4 +++ src/locales/en.json | 4 +++ src/services/dataService.ts | 25 +++++++++++++ src/types/index.ts | 4 +++ 9 files changed, 120 insertions(+), 9 deletions(-) diff --git a/server/src/config/museumMapping.ts b/server/src/config/museumMapping.ts index ca0970a..4e16db1 100644 --- a/server/src/config/museumMapping.ts +++ b/server/src/config/museumMapping.ts @@ -39,6 +39,21 @@ export function getMuseumsFromProduct(productDescription: string): MuseumMatch { return { museums: matched, split: 1 / matched.length }; } +// Static museum → district mapping +const MUSEUM_DISTRICT: Record = { + 'Revelation Exhibition': 'Hiraa', + 'Holy Quraan Museum': 'Hiraa', + 'Trail To Hira Cave': 'Hiraa', + 'Makkah Greets Us': 'Hiraa', + 'VIP Experience': 'Hiraa', + 'Creation Story Museum': 'AsSaffiyah', + 'Best of Creation': 'AsSaffiyah', +}; + +export function getDistrict(museumName: string): string { + return MUSEUM_DISTRICT[museumName] || 'Other'; +} + export const CHANNEL_LABELS: Record = { 'B2C': 'HiHala Website/App', 'B2B': 'B2B', diff --git a/server/src/services/etlSync.ts b/server/src/services/etlSync.ts index e777492..95d9404 100644 --- a/server/src/services/etlSync.ts +++ b/server/src/services/etlSync.ts @@ -1,6 +1,6 @@ import { fetchSales } from './erpClient'; import { discoverTableIds, deleteRowsByMonth, deleteAllRows, insertRecords } from './nocodbClient'; -import { getMuseumsFromProduct, getChannelLabel } from '../config/museumMapping'; +import { getMuseumsFromProduct, getChannelLabel, getDistrict } from '../config/museumMapping'; import type { ERPSaleRecord, AggregatedRecord } from '../types'; function generateMonthBoundaries(startYear: number, startMonth: number): Array<[string, string]> { @@ -51,12 +51,14 @@ export function aggregateTransactions(sales: ERPSaleRecord[]): AggregatedRecord[ ? museums.filter(m => m !== museum).join(', ') : ''; const ticketType = isCombo ? 'combo' : 'single'; + const district = getDistrict(museum); const key = `${date}|${museum}|${channel}|${ticketType}`; let entry = map.get(key); if (!entry) { entry = { Date: date, + District: district, MuseumName: museum, Channel: channel, TicketType: ticketType, diff --git a/server/src/types.ts b/server/src/types.ts index befb231..d45c3be 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -18,6 +18,7 @@ export interface ERPSaleRecord { export interface AggregatedRecord { Date: string; + District: string; MuseumName: string; Channel: string; TicketType: 'single' | 'combo'; diff --git a/src/components/Comparison.tsx b/src/components/Comparison.tsx index 8b4d060..321ffea 100644 --- a/src/components/Comparison.tsx +++ b/src/components/Comparison.tsx @@ -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 )} + + + + + +