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:
@@ -39,6 +39,21 @@ export function getMuseumsFromProduct(productDescription: string): MuseumMatch {
|
|||||||
return { museums: matched, split: 1 / matched.length };
|
return { museums: matched, split: 1 / matched.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Static museum → district mapping
|
||||||
|
const MUSEUM_DISTRICT: Record<string, string> = {
|
||||||
|
'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<string, string> = {
|
export const CHANNEL_LABELS: Record<string, string> = {
|
||||||
'B2C': 'HiHala Website/App',
|
'B2C': 'HiHala Website/App',
|
||||||
'B2B': 'B2B',
|
'B2B': 'B2B',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fetchSales } from './erpClient';
|
import { fetchSales } from './erpClient';
|
||||||
import { discoverTableIds, deleteRowsByMonth, deleteAllRows, insertRecords } from './nocodbClient';
|
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';
|
import type { ERPSaleRecord, AggregatedRecord } from '../types';
|
||||||
|
|
||||||
function generateMonthBoundaries(startYear: number, startMonth: number): Array<[string, string]> {
|
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(', ')
|
? museums.filter(m => m !== museum).join(', ')
|
||||||
: '';
|
: '';
|
||||||
const ticketType = isCombo ? 'combo' : 'single';
|
const ticketType = isCombo ? 'combo' : 'single';
|
||||||
|
const district = getDistrict(museum);
|
||||||
const key = `${date}|${museum}|${channel}|${ticketType}`;
|
const key = `${date}|${museum}|${channel}|${ticketType}`;
|
||||||
|
|
||||||
let entry = map.get(key);
|
let entry = map.get(key);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
entry = {
|
entry = {
|
||||||
Date: date,
|
Date: date,
|
||||||
|
District: district,
|
||||||
MuseumName: museum,
|
MuseumName: museum,
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
TicketType: ticketType,
|
TicketType: ticketType,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface ERPSaleRecord {
|
|||||||
|
|
||||||
export interface AggregatedRecord {
|
export interface AggregatedRecord {
|
||||||
Date: string;
|
Date: string;
|
||||||
|
District: string;
|
||||||
MuseumName: string;
|
MuseumName: string;
|
||||||
Channel: string;
|
Channel: string;
|
||||||
TicketType: 'single' | 'combo';
|
TicketType: 'single' | 'combo';
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
umrahData,
|
umrahData,
|
||||||
getUniqueChannels,
|
getUniqueChannels,
|
||||||
getUniqueMuseums,
|
getUniqueMuseums,
|
||||||
|
getUniqueDistricts,
|
||||||
|
getMuseumsForDistrict,
|
||||||
getLatestYear
|
getLatestYear
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import type { MuseumRecord, ComparisonProps, DateRangeFilters } from '../types';
|
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`;
|
return searchParams.get('to') || `${year}-01-31`;
|
||||||
});
|
});
|
||||||
const [filters, setFiltersState] = useState(() => ({
|
const [filters, setFiltersState] = useState(() => ({
|
||||||
|
district: searchParams.get('district') || 'all',
|
||||||
channel: searchParams.get('channel') || 'all',
|
channel: searchParams.get('channel') || 'all',
|
||||||
museum: searchParams.get('museum') || 'all'
|
museum: searchParams.get('museum') || 'all'
|
||||||
}));
|
}));
|
||||||
@@ -124,6 +127,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
if (newFrom) params.set('from', newFrom);
|
if (newFrom) params.set('from', newFrom);
|
||||||
if (newTo) params.set('to', newTo);
|
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?.channel && newFilters.channel !== 'all') params.set('channel', newFilters.channel);
|
||||||
if (newFilters?.museum && newFilters.museum !== 'all') params.set('museum', newFilters.museum);
|
if (newFilters?.museum && newFilters.museum !== 'all') params.set('museum', newFilters.museum);
|
||||||
setSearchParams(params, { replace: true });
|
setSearchParams(params, { replace: true });
|
||||||
@@ -219,7 +223,8 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
|
|
||||||
// Dynamic lists from data
|
// Dynamic lists from data
|
||||||
const channels = useMemo(() => getUniqueChannels(data), [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
|
// Year-over-year comparison: same dates, previous year
|
||||||
const ranges = useMemo(() => ({
|
const ranges = useMemo(() => ({
|
||||||
@@ -244,7 +249,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
const currMetrics = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]);
|
const currMetrics = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]);
|
||||||
|
|
||||||
const hasData = prevData.length > 0 || currData.length > 0;
|
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);
|
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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<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')}>
|
<FilterControls.Group label={t('filters.channel')}>
|
||||||
<select value={filters.channel} onChange={e => setFilters({...filters, channel: e.target.value})}>
|
<select value={filters.channel} onChange={e => setFilters({...filters, channel: e.target.value})}>
|
||||||
<option value="all">{t('filters.allChannels')}</option>
|
<option value="all">{t('filters.allChannels')}</option>
|
||||||
|
|||||||
@@ -17,18 +17,22 @@ import {
|
|||||||
fetchPilgrimStats,
|
fetchPilgrimStats,
|
||||||
getUniqueYears,
|
getUniqueYears,
|
||||||
getUniqueChannels,
|
getUniqueChannels,
|
||||||
getUniqueMuseums
|
getUniqueMuseums,
|
||||||
|
getUniqueDistricts,
|
||||||
|
getMuseumsForDistrict,
|
||||||
|
groupByDistrict
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import type { DashboardProps, Filters, MuseumRecord } from '../types';
|
import type { DashboardProps, Filters, MuseumRecord } from '../types';
|
||||||
|
|
||||||
const defaultFilters: Filters = {
|
const defaultFilters: Filters = {
|
||||||
year: 'all',
|
year: 'all',
|
||||||
|
district: 'all',
|
||||||
channel: 'all',
|
channel: 'all',
|
||||||
museum: 'all',
|
museum: 'all',
|
||||||
quarter: '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) {
|
function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
@@ -84,14 +88,15 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
|
|
||||||
// Chart carousel labels
|
// Chart carousel labels
|
||||||
const chartLabels = useMemo(() => {
|
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);
|
return filters.museum === 'all' ? labels : labels.filter((_, i) => i !== 1 && i !== 2);
|
||||||
}, [filters.museum, t]);
|
}, [filters.museum, t]);
|
||||||
|
|
||||||
// Dynamic lists from data
|
// Dynamic lists from data
|
||||||
const years = useMemo(() => getUniqueYears(data), [data]);
|
const years = useMemo(() => getUniqueYears(data), [data]);
|
||||||
|
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||||
const channels = useMemo(() => getUniqueChannels(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(() => {
|
const yoyChange = useMemo(() => {
|
||||||
if (filters.year === 'all') return null;
|
if (filters.year === 'all') return null;
|
||||||
@@ -224,6 +229,20 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
};
|
};
|
||||||
}, [filteredData, includeVAT]);
|
}, [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
|
// Quarterly YoY
|
||||||
const quarterlyYoYData = useMemo(() => {
|
const quarterlyYoYData = useMemo(() => {
|
||||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||||
@@ -259,6 +278,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
const pilgrims = umrahData[year]?.[q];
|
const pilgrims = umrahData[year]?.[q];
|
||||||
if (!pilgrims) return;
|
if (!pilgrims) return;
|
||||||
let qData = data.filter((r: MuseumRecord) => r.year === String(year) && r.quarter === String(q));
|
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.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.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);
|
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
|
// Quarterly table
|
||||||
const quarterlyTable = useMemo(() => {
|
const quarterlyTable = useMemo(() => {
|
||||||
@@ -333,6 +353,10 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
return [1, 2, 3, 4].map(q => {
|
return [1, 2, 3, 4].map(q => {
|
||||||
let q2024 = d2024.filter((r: MuseumRecord) => r.quarter === String(q));
|
let q2024 = d2024.filter((r: MuseumRecord) => r.quarter === String(q));
|
||||||
let q2025 = d2025.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') {
|
if (filters.channel !== 'all') {
|
||||||
q2024 = q2024.filter((r: MuseumRecord) => r.channel === filters.channel);
|
q2024 = q2024.filter((r: MuseumRecord) => r.channel === filters.channel);
|
||||||
q2025 = q2025.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;
|
const cap25 = umrahData[2025][q] ? (vis25 / umrahData[2025][q] * 100) : null;
|
||||||
return { q, rev24, rev25, revChg, vis24, vis25, visChg, cap24, cap25 };
|
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]);
|
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>)}
|
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</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')}>
|
<FilterControls.Group label={t('filters.channel')}>
|
||||||
<select value={filters.channel} onChange={e => setFilters({...filters, channel: e.target.value})}>
|
<select value={filters.channel} onChange={e => setFilters({...filters, channel: e.target.value})}>
|
||||||
<option value="all">{t('filters.allChannels')}</option>
|
<option value="all">{t('filters.allChannels')}</option>
|
||||||
@@ -534,6 +564,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
</ExportableChart>
|
</ExportableChart>
|
||||||
</div>
|
</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">
|
<div className="chart-card full-width">
|
||||||
<ExportableChart filename="capture-rate" title={t('dashboard.captureRateChart')} className="chart-container">
|
<ExportableChart filename="capture-rate" title={t('dashboard.captureRateChart')} className="chart-container">
|
||||||
<Line data={captureRateData} options={{
|
<Line data={captureRateData} options={{
|
||||||
@@ -638,6 +674,15 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
</div>
|
</div>
|
||||||
</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="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h2>{t('dashboard.captureRateChart')}</h2>
|
<h2>{t('dashboard.captureRateChart')}</h2>
|
||||||
|
|||||||
@@ -33,10 +33,12 @@
|
|||||||
"filters": {
|
"filters": {
|
||||||
"title": "الفلاتر",
|
"title": "الفلاتر",
|
||||||
"year": "السنة",
|
"year": "السنة",
|
||||||
|
"district": "المنطقة",
|
||||||
"channel": "القناة",
|
"channel": "القناة",
|
||||||
"museum": "المتحف",
|
"museum": "المتحف",
|
||||||
"quarter": "الربع",
|
"quarter": "الربع",
|
||||||
"allYears": "كل السنوات",
|
"allYears": "كل السنوات",
|
||||||
|
"allDistricts": "كل المناطق",
|
||||||
"allChannels": "جميع القنوات",
|
"allChannels": "جميع القنوات",
|
||||||
"allMuseums": "كل المتاحف",
|
"allMuseums": "كل المتاحف",
|
||||||
"allQuarters": "كل الأرباع",
|
"allQuarters": "كل الأرباع",
|
||||||
@@ -64,6 +66,7 @@
|
|||||||
"visitorsByMuseum": "الزوار حسب المتحف",
|
"visitorsByMuseum": "الزوار حسب المتحف",
|
||||||
"revenueByMuseum": "الإيرادات حسب المتحف",
|
"revenueByMuseum": "الإيرادات حسب المتحف",
|
||||||
"quarterlyRevenue": "الإيرادات الربعية (سنوي)",
|
"quarterlyRevenue": "الإيرادات الربعية (سنوي)",
|
||||||
|
"districtPerformance": "أداء المناطق",
|
||||||
"channelPerformance": "أداء القنوات",
|
"channelPerformance": "أداء القنوات",
|
||||||
"captureRateChart": "نسبة الاستقطاب مقابل المعتمرين"
|
"captureRateChart": "نسبة الاستقطاب مقابل المعتمرين"
|
||||||
},
|
},
|
||||||
@@ -146,6 +149,7 @@
|
|||||||
"visitors": "الزوار",
|
"visitors": "الزوار",
|
||||||
"revenue": "الإيرادات",
|
"revenue": "الإيرادات",
|
||||||
"quarterly": "ربع سنوي",
|
"quarterly": "ربع سنوي",
|
||||||
|
"district": "المنطقة",
|
||||||
"channel": "القناة",
|
"channel": "القناة",
|
||||||
"captureRate": "نسبة الاستقطاب"
|
"captureRate": "نسبة الاستقطاب"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,10 +33,12 @@
|
|||||||
"filters": {
|
"filters": {
|
||||||
"title": "Filters",
|
"title": "Filters",
|
||||||
"year": "Year",
|
"year": "Year",
|
||||||
|
"district": "District",
|
||||||
"channel": "Channel",
|
"channel": "Channel",
|
||||||
"museum": "Museum",
|
"museum": "Museum",
|
||||||
"quarter": "Quarter",
|
"quarter": "Quarter",
|
||||||
"allYears": "All Years",
|
"allYears": "All Years",
|
||||||
|
"allDistricts": "All Districts",
|
||||||
"allChannels": "All Channels",
|
"allChannels": "All Channels",
|
||||||
"allMuseums": "All Museums",
|
"allMuseums": "All Museums",
|
||||||
"allQuarters": "All Quarters",
|
"allQuarters": "All Quarters",
|
||||||
@@ -64,6 +66,7 @@
|
|||||||
"visitorsByMuseum": "Visitors by Museum",
|
"visitorsByMuseum": "Visitors by Museum",
|
||||||
"revenueByMuseum": "Revenue by Museum",
|
"revenueByMuseum": "Revenue by Museum",
|
||||||
"quarterlyRevenue": "Quarterly Revenue (YoY)",
|
"quarterlyRevenue": "Quarterly Revenue (YoY)",
|
||||||
|
"districtPerformance": "District Performance",
|
||||||
"channelPerformance": "Channel Performance",
|
"channelPerformance": "Channel Performance",
|
||||||
"captureRateChart": "Capture Rate vs Umrah Pilgrims"
|
"captureRateChart": "Capture Rate vs Umrah Pilgrims"
|
||||||
},
|
},
|
||||||
@@ -146,6 +149,7 @@
|
|||||||
"visitors": "Visitors",
|
"visitors": "Visitors",
|
||||||
"revenue": "Revenue",
|
"revenue": "Revenue",
|
||||||
"quarterly": "Quarterly",
|
"quarterly": "Quarterly",
|
||||||
|
"district": "District",
|
||||||
"channel": "Channel",
|
"channel": "Channel",
|
||||||
"captureRate": "Capture Rate"
|
"captureRate": "Capture Rate"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
|
district: row.District,
|
||||||
museum_name: row.MuseumName,
|
museum_name: row.MuseumName,
|
||||||
channel: row.Channel,
|
channel: row.Channel,
|
||||||
visits: row.Visits,
|
visits: row.Visits,
|
||||||
@@ -273,6 +274,7 @@ 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.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.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.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
|
||||||
if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false;
|
if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false;
|
||||||
@@ -289,6 +291,7 @@ export function filterDataByDateRange(
|
|||||||
return data.filter(row => {
|
return data.filter(row => {
|
||||||
if (!row.date) return false;
|
if (!row.date) return false;
|
||||||
if (row.date < startDate || row.date > endDate) 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.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.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -408,6 +411,28 @@ export function getUniqueYears(data: MuseumRecord[]): string[] {
|
|||||||
return years.sort((a, b) => parseInt(a) - parseInt(b));
|
return years.sort((a, b) => parseInt(a) - parseInt(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getUniqueDistricts(data: MuseumRecord[]): string[] {
|
||||||
|
return [...new Set(data.map(r => r.district).filter(Boolean))].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupByDistrict(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
|
||||||
|
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||||
|
const grouped: Record<string, GroupedData> = {};
|
||||||
|
data.forEach(row => {
|
||||||
|
if (!row.district) return;
|
||||||
|
if (!grouped[row.district]) grouped[row.district] = { revenue: 0, visitors: 0, tickets: 0 };
|
||||||
|
grouped[row.district].revenue += row[revenueField] || 0;
|
||||||
|
grouped[row.district].visitors += row.visits || 0;
|
||||||
|
grouped[row.district].tickets += row.tickets || 0;
|
||||||
|
});
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMuseumsForDistrict(data: MuseumRecord[], district: string): string[] {
|
||||||
|
if (district === 'all') return getUniqueMuseums(data);
|
||||||
|
return [...new Set(data.filter(r => r.district === district).map(r => r.museum_name).filter(Boolean))].sort();
|
||||||
|
}
|
||||||
|
|
||||||
export function getUniqueChannels(data: MuseumRecord[]): string[] {
|
export function getUniqueChannels(data: MuseumRecord[]): string[] {
|
||||||
return [...new Set(data.map(r => r.channel).filter(Boolean))].sort();
|
return [...new Set(data.map(r => r.channel).filter(Boolean))].sort();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
export interface MuseumRecord {
|
export interface MuseumRecord {
|
||||||
date: string;
|
date: string;
|
||||||
|
district: string;
|
||||||
museum_name: string;
|
museum_name: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
visits: number;
|
visits: number;
|
||||||
@@ -21,12 +22,14 @@ export interface Metrics {
|
|||||||
|
|
||||||
export interface Filters {
|
export interface Filters {
|
||||||
year: string;
|
year: string;
|
||||||
|
district: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
museum: string;
|
museum: string;
|
||||||
quarter: string;
|
quarter: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateRangeFilters {
|
export interface DateRangeFilters {
|
||||||
|
district: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
museum: string;
|
museum: string;
|
||||||
}
|
}
|
||||||
@@ -77,6 +80,7 @@ export interface UmrahData {
|
|||||||
export interface NocoDBDailySale {
|
export interface NocoDBDailySale {
|
||||||
Id: number;
|
Id: number;
|
||||||
Date: string;
|
Date: string;
|
||||||
|
District: string;
|
||||||
MuseumName: string;
|
MuseumName: string;
|
||||||
Channel: string;
|
Channel: string;
|
||||||
TicketType: string;
|
TicketType: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user