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:
@@ -11,9 +11,8 @@ import {
|
||||
formatCompact,
|
||||
formatCompactCurrency,
|
||||
umrahData,
|
||||
getUniqueDistricts,
|
||||
getDistrictMuseumMap,
|
||||
getMuseumsForDistrict,
|
||||
getUniqueChannels,
|
||||
getUniqueMuseums,
|
||||
getLatestYear
|
||||
} from '../services/dataService';
|
||||
import type { MuseumRecord, ComparisonProps, DateRangeFilters } from '../types';
|
||||
@@ -107,7 +106,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'
|
||||
}));
|
||||
|
||||
@@ -125,7 +124,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 });
|
||||
}, [setSearchParams, latestYear]);
|
||||
@@ -209,19 +208,18 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
|
||||
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
|
||||
if (metric === 'avgRevenue') {
|
||||
const revenue = rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[revenueField] || r.revenue_incl_tax || 0)), 0);
|
||||
const revenue = rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[revenueField] || 0)), 0);
|
||||
const visitors = rows.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
|
||||
return visitors > 0 ? revenue / visitors : 0;
|
||||
}
|
||||
const fieldMap: Record<string, string> = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' };
|
||||
const field = fieldMap[metric];
|
||||
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[field] || r.revenue_incl_tax || 0)), 0);
|
||||
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[field] || 0)), 0);
|
||||
}, [revenueField]);
|
||||
|
||||
// Dynamic lists from 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]);
|
||||
|
||||
// Year-over-year comparison: same dates, previous year
|
||||
const ranges = useMemo(() => ({
|
||||
@@ -246,7 +244,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({ district: 'all', museum: 'all' });
|
||||
const resetFilters = () => setFilters({ channel: 'all', museum: 'all' });
|
||||
|
||||
const calcChange = (prev: number, curr: number) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
||||
|
||||
@@ -577,10 +575,10 @@ 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>)}
|
||||
<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')}>
|
||||
|
||||
Reference in New Issue
Block a user