feat: migrate museum sales from NocoDB to Hono ERP API

- Replace NocoDB museum data (Districts/Museums/DailyStats) with ERP API
- Client fetches via server proxy (/api/erp/sales) — no credentials in browser
- Aggregate transaction-level ERP data into daily/museum/channel records
- Replace "district" dimension with "channel" (B2C/HiHala, POS, B2B, etc.)
- Add product-to-museum mapping (46 products → 6 museums)
- NocoDB retained only for PilgrimStats
- Remove old server/index.js (replaced by modular TS in server/src/)
- Update all components, types, and locale files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-26 16:43:34 +03:00
parent a84caaa31e
commit f6b7d4ba8d
10 changed files with 271 additions and 588 deletions

View File

@@ -12,24 +12,23 @@ import {
formatNumber,
groupByWeek,
groupByMuseum,
groupByDistrict,
groupByChannel,
umrahData,
fetchPilgrimStats,
getUniqueYears,
getUniqueDistricts,
getDistrictMuseumMap,
getMuseumsForDistrict
getUniqueChannels,
getUniqueMuseums
} 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', 'district', 'museum', 'quarter'];
const filterKeys: (keyof Filters)[] = ['year', 'channel', 'museum', 'quarter'];
function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
const { t } = useLanguage();
@@ -85,15 +84,14 @@ 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.district'), t('charts.captureRate')];
const labels = [t('charts.revenueTrend'), t('charts.visitors'), t('charts.revenue'), t('charts.quarterly'), t('charts.channel'), 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 districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
const channels = useMemo(() => getUniqueChannels(data), [data]);
const availableMuseums = useMemo(() => getUniqueMuseums(data), [data]);
const yoyChange = useMemo(() => {
if (filters.year === 'all') return null;
@@ -167,7 +165,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
filteredData.forEach(row => {
const date = row.date;
if (!dailyData[date]) dailyData[date] = 0;
dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || row.revenue_incl_tax || 0);
dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || 0);
});
const days = Object.keys(dailyData).sort();
const revenueValues = days.map(d => dailyData[d]);
@@ -212,14 +210,14 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
};
}, [filteredData, includeVAT]);
// District data
const districtData = useMemo(() => {
const grouped = groupByDistrict(filteredData, includeVAT);
const districts = Object.keys(grouped);
// Channel data
const channelData = useMemo(() => {
const grouped = groupByChannel(filteredData, includeVAT);
const channels = Object.keys(grouped);
return {
labels: districts,
labels: channels,
datasets: [{
data: districts.map(d => grouped[d].revenue),
data: channels.map(d => grouped[d].revenue),
backgroundColor: [chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
borderRadius: 4
}]
@@ -237,13 +235,13 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
datasets: [
{
label: '2024',
data: quarters.map(q => d2024.filter((r: MuseumRecord) => r.quarter === q.slice(1)).reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || r.revenue_incl_tax || 0)), 0)),
data: quarters.map(q => d2024.filter((r: MuseumRecord) => r.quarter === q.slice(1)).reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0)),
backgroundColor: chartColors.muted,
borderRadius: 4
},
{
label: '2025',
data: quarters.map(q => d2025.filter((r: MuseumRecord) => r.quarter === q.slice(1)).reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || r.revenue_incl_tax || 0)), 0)),
data: quarters.map(q => d2025.filter((r: MuseumRecord) => r.quarter === q.slice(1)).reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0)),
backgroundColor: chartColors.primary,
borderRadius: 4
}
@@ -261,7 +259,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);
labels.push(`Q${q} ${year}`);
@@ -325,7 +323,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
}
]
};
}, [data, filters.district, filters.museum, showDataLabels]);
}, [data, filters.channel, filters.museum, showDataLabels]);
// Quarterly table
const quarterlyTable = useMemo(() => {
@@ -335,16 +333,16 @@ 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);
}
if (filters.museum !== 'all') {
q2024 = q2024.filter((r: MuseumRecord) => r.museum_name === filters.museum);
q2025 = q2025.filter((r: MuseumRecord) => r.museum_name === filters.museum);
}
const rev24 = q2024.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || r.revenue_incl_tax || 0)), 0);
const rev25 = q2025.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || r.revenue_incl_tax || 0)), 0);
const rev24 = q2024.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0);
const rev25 = q2025.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0);
const vis24 = q2024.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
const vis25 = q2025.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
const revChg = rev24 > 0 ? ((rev25 - rev24) / rev24 * 100) : 0;
@@ -353,7 +351,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.district, filters.museum, includeVAT]);
}, [data, filters.channel, filters.museum, includeVAT]);
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
@@ -390,10 +388,10 @@ 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>)}
<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>
{channels.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</FilterControls.Group>
<FilterControls.Group label={t('filters.museum')}>
@@ -531,8 +529,8 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
</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 filename="channel-performance" title={t('dashboard.channelPerformance')} className="chart-container">
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
</ExportableChart>
</div>
@@ -633,9 +631,9 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.districtPerformance')}</h2>
<h2>{t('dashboard.channelPerformance')}</h2>
<div className="chart-container">
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
</div>
</div>
</div>