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

@@ -1,8 +1,4 @@
# NocoDB (primary data source)
# NocoDB (PilgrimStats only — museum sales come from ERP API via server proxy)
VITE_NOCODB_URL=http://localhost:8090
VITE_NOCODB_TOKEN=your_token_here
VITE_NOCODB_BASE_ID=your_base_id_here
# Google Sheets (fallback if NocoDB fails)
VITE_SHEETS_ID=your_spreadsheet_id_here
VITE_SHEETS_NAME=Consolidated Data
VITE_NOCODB_TOKEN=your-token
VITE_NOCODB_BASE_ID=your-base-id

View File

@@ -1,271 +0,0 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const axios = require('axios');
const app = express();
app.use(cors());
app.use(express.json());
const PORT = process.env.SALLA_SERVER_PORT || 3001;
// Salla OAuth Config
const SALLA_CLIENT_ID = process.env.SALLA_CLIENT_ID;
const SALLA_CLIENT_SECRET = process.env.SALLA_CLIENT_SECRET;
const SALLA_REDIRECT_URI = process.env.SALLA_REDIRECT_URI || 'http://localhost:3001/auth/callback';
// Token storage (in production, use a database)
let accessToken = process.env.SALLA_ACCESS_TOKEN || null;
let refreshToken = process.env.SALLA_REFRESH_TOKEN || null;
// ============================================
// OAuth Endpoints
// ============================================
// State for CSRF protection
const crypto = require('crypto');
let oauthState = null;
// Step 1: Redirect to Salla authorization
app.get('/auth/login', (req, res) => {
oauthState = crypto.randomBytes(16).toString('hex');
const authUrl = `https://accounts.salla.sa/oauth2/auth?` +
`client_id=${SALLA_CLIENT_ID}` +
`&redirect_uri=${encodeURIComponent(SALLA_REDIRECT_URI)}` +
`&response_type=code` +
`&scope=offline_access` +
`&state=${oauthState}`;
res.redirect(authUrl);
});
// Step 2: Handle OAuth callback
app.get('/auth/callback', async (req, res) => {
const { code, error, state } = req.query;
if (error) {
return res.status(400).json({ error: 'Authorization denied', details: error });
}
if (!code) {
return res.status(400).json({ error: 'No authorization code received' });
}
// Verify state (optional check - some flows may not return state)
if (oauthState && state && state !== oauthState) {
return res.status(400).json({ error: 'Invalid state parameter' });
}
try {
// Exchange code for tokens
const response = await axios.post('https://accounts.salla.sa/oauth2/token', {
client_id: SALLA_CLIENT_ID,
client_secret: SALLA_CLIENT_SECRET,
grant_type: 'authorization_code',
code: code,
redirect_uri: SALLA_REDIRECT_URI
}, {
headers: { 'Content-Type': 'application/json' }
});
accessToken = response.data.access_token;
refreshToken = response.data.refresh_token;
// Log tokens (save these to .env for persistence)
console.log('\n========================================');
console.log('🎉 SALLA CONNECTED SUCCESSFULLY!');
console.log('========================================');
console.log('Add these to your .env file:');
console.log(`SALLA_ACCESS_TOKEN=${accessToken}`);
console.log(`SALLA_REFRESH_TOKEN=${refreshToken}`);
console.log('========================================\n');
res.send(`
<html>
<body style="font-family: Arial; text-align: center; padding: 50px;">
<h1>✅ Salla Connected!</h1>
<p>Authorization successful. You can close this window.</p>
<p>Tokens have been logged to the console.</p>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
</html>
`);
} catch (err) {
console.error('Token exchange failed:', err.response?.data || err.message);
res.status(500).json({ error: 'Token exchange failed', details: err.response?.data });
}
});
// Check auth status
app.get('/auth/status', (req, res) => {
res.json({
connected: !!accessToken,
hasRefreshToken: !!refreshToken
});
});
// Refresh token
async function refreshAccessToken() {
if (!refreshToken) throw new Error('No refresh token available');
const response = await axios.post('https://accounts.salla.sa/oauth2/token', {
client_id: SALLA_CLIENT_ID,
client_secret: SALLA_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: refreshToken
});
accessToken = response.data.access_token;
if (response.data.refresh_token) {
refreshToken = response.data.refresh_token;
}
return accessToken;
}
// ============================================
// Salla API Proxy Endpoints
// ============================================
// Generic API caller with auto-refresh
async function callSallaAPI(endpoint, method = 'GET', data = null) {
if (!accessToken) throw new Error('Not authenticated. Visit /auth/login first.');
try {
const response = await axios({
method,
url: `https://api.salla.dev/admin/v2${endpoint}`,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
data
});
return response.data;
} catch (err) {
if (err.response?.status === 401) {
// Try refresh
await refreshAccessToken();
return callSallaAPI(endpoint, method, data);
}
throw err;
}
}
// Get store info
app.get('/api/store', async (req, res) => {
try {
const data = await callSallaAPI('/store/info');
res.json(data);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get orders
app.get('/api/orders', async (req, res) => {
try {
const { page = 1, per_page = 50, status } = req.query;
let endpoint = `/orders?page=${page}&per_page=${per_page}`;
if (status) endpoint += `&status=${status}`;
const data = await callSallaAPI(endpoint);
res.json(data);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get order details
app.get('/api/orders/:id', async (req, res) => {
try {
const data = await callSallaAPI(`/orders/${req.params.id}`);
res.json(data);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get products
app.get('/api/products', async (req, res) => {
try {
const { page = 1, per_page = 50 } = req.query;
const data = await callSallaAPI(`/products?page=${page}&per_page=${per_page}`);
res.json(data);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get customers
app.get('/api/customers', async (req, res) => {
try {
const { page = 1, per_page = 50 } = req.query;
const data = await callSallaAPI(`/customers?page=${page}&per_page=${per_page}`);
res.json(data);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get analytics/reports
app.get('/api/analytics/summary', async (req, res) => {
try {
// Fetch multiple endpoints for a summary
const [orders, products] = await Promise.all([
callSallaAPI('/orders?per_page=100'),
callSallaAPI('/products?per_page=100')
]);
// Calculate summary
const ordersList = orders.data || [];
const totalRevenue = ordersList.reduce((sum, o) => sum + (o.amounts?.total?.amount || 0), 0);
const avgOrderValue = ordersList.length > 0 ? totalRevenue / ordersList.length : 0;
res.json({
orders: {
total: orders.pagination?.total || ordersList.length,
recent: ordersList.length
},
products: {
total: products.pagination?.total || (products.data?.length || 0)
},
revenue: {
total: totalRevenue,
average_order: avgOrderValue,
currency: ordersList[0]?.amounts?.total?.currency || 'SAR'
}
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============================================
// Start Server
// ============================================
app.listen(PORT, () => {
console.log(`\n🚀 Salla Integration Server running on http://localhost:${PORT}`);
console.log('\nEndpoints:');
console.log(' GET /auth/login - Start OAuth flow');
console.log(' GET /auth/callback - OAuth callback');
console.log(' GET /auth/status - Check connection status');
console.log(' GET /api/store - Store info');
console.log(' GET /api/orders - List orders');
console.log(' GET /api/products - List products');
console.log(' GET /api/customers - List customers');
console.log(' GET /api/analytics/summary - Dashboard summary');
if (!SALLA_CLIENT_ID || !SALLA_CLIENT_SECRET) {
console.log('\n⚠ WARNING: SALLA_CLIENT_ID and SALLA_CLIENT_SECRET not set!');
console.log(' Add them to server/.env file');
}
if (accessToken) {
console.log('\n✅ Access token loaded from environment');
} else {
console.log('\n📝 Visit http://localhost:' + PORT + '/auth/login to connect Salla');
}
});

View File

@@ -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')}>

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>

View File

@@ -7,14 +7,12 @@ import {
calculateMetrics,
formatCompact,
formatCompactCurrency,
getUniqueDistricts,
getDistrictMuseumMap,
getMuseumsForDistrict
getUniqueChannels,
getUniqueMuseums
} from '../services/dataService';
import JSZip from 'jszip';
import type {
MuseumRecord,
DistrictMuseumMap,
SlideConfig,
ChartTypeOption,
MetricOption,
@@ -25,8 +23,8 @@ import type {
interface SlideEditorProps {
slide: SlideConfig;
onUpdate: (updates: Partial<SlideConfig>) => void;
districts: string[];
districtMuseumMap: DistrictMuseumMap;
channels: string[];
museums: string[];
data: MuseumRecord[];
chartTypes: ChartTypeOption[];
metrics: MetricOption[];
@@ -35,16 +33,16 @@ interface SlideEditorProps {
interface SlidePreviewProps {
slide: SlideConfig;
data: MuseumRecord[];
districts: string[];
districtMuseumMap: DistrictMuseumMap;
channels: string[];
museums: string[];
metrics: MetricOption[];
}
interface PreviewModeProps {
slides: SlideConfig[];
data: MuseumRecord[];
districts: string[];
districtMuseumMap: DistrictMuseumMap;
channels: string[];
museums: string[];
currentSlide: number;
setCurrentSlide: React.Dispatch<React.SetStateAction<number>>;
onExit: () => void;
@@ -62,7 +60,7 @@ function Slides({ data }: SlidesProps) {
], [t]);
const METRICS: MetricOption[] = useMemo(() => [
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax' },
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_gross' },
{ id: 'visitors', label: t('metrics.visitors'), field: 'visits' },
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
], [t]);
@@ -71,8 +69,8 @@ function Slides({ data }: SlidesProps) {
const [previewMode, setPreviewMode] = useState(false);
const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0);
const districts = useMemo(() => getUniqueDistricts(data), [data]);
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
const channels = useMemo(() => getUniqueChannels(data), [data]);
const museums = useMemo(() => getUniqueMuseums(data), [data]);
const defaultSlideConfig: Omit<SlideConfig, 'id'> = {
title: 'Slide Title',
@@ -80,7 +78,7 @@ function Slides({ data }: SlidesProps) {
metric: 'revenue',
startDate: '2026-01-01',
endDate: '2026-01-31',
district: 'all',
channel: 'all',
museum: 'all',
showComparison: false
};
@@ -128,7 +126,7 @@ function Slides({ data }: SlidesProps) {
// Generate HTML for each slide
const slidesHTML = slides.map((slide, index) => {
return generateSlideHTML(slide, index, data, districts, districtMuseumMap);
return generateSlideHTML(slide, index, data);
}).join('\n');
const fullHTML = `<!DOCTYPE html>
@@ -185,7 +183,7 @@ function Slides({ data }: SlidesProps) {
${slidesHTML}
<script>
// Chart.js initialization scripts will be here
${generateChartScripts(slides, data, districts, districtMuseumMap)}
${generateChartScripts(slides, data)}
</script>
</body>
</html>`;
@@ -206,8 +204,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
<PreviewMode
slides={slides}
data={data}
districts={districts}
districtMuseumMap={districtMuseumMap}
channels={channels}
museums={museums}
currentSlide={currentPreviewSlide}
setCurrentSlide={setCurrentPreviewSlide}
onExit={() => setPreviewMode(false)}
@@ -283,8 +281,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
<SlideEditor
slide={slides.find(s => s.id === editingSlide)!}
onUpdate={(updates) => updateSlide(editingSlide, updates)}
districts={districts}
districtMuseumMap={districtMuseumMap}
channels={channels}
museums={museums}
data={data}
chartTypes={CHART_TYPES}
metrics={METRICS}
@@ -295,12 +293,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
);
}
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, chartTypes, metrics }: SlideEditorProps) {
function SlideEditor({ slide, onUpdate, channels, museums, data, chartTypes, metrics }: SlideEditorProps) {
const { t } = useLanguage();
const availableMuseums = useMemo(() =>
getMuseumsForDistrict(districtMuseumMap, slide.district),
[districtMuseumMap, slide.district]
);
return (
<div className="slide-editor">
@@ -350,17 +344,17 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
<div className="editor-row">
<div className="editor-section">
<label>{t('filters.district')}</label>
<select value={slide.district} onChange={e => onUpdate({ district: e.target.value, museum: 'all' })}>
<option value="all">{t('filters.allDistricts')}</option>
{districts.map((d: string) => <option key={d} value={d}>{d}</option>)}
<label>{t('filters.channel')}</label>
<select value={slide.channel} onChange={e => onUpdate({ channel: e.target.value, museum: 'all' })}>
<option value="all">{t('filters.allChannels')}</option>
{channels.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
</div>
<div className="editor-section">
<label>{t('filters.museum')}</label>
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
<option value="all">{t('filters.allMuseums')}</option>
{availableMuseums.map((m: string) => <option key={m} value={m}>{m}</option>)}
{museums.map((m: string) => <option key={m} value={m}>{m}</option>)}
</select>
</div>
</div>
@@ -380,7 +374,7 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
<div className="slide-preview-box">
<h4>{t('slides.preview')}</h4>
<SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} metrics={metrics} />
<SlidePreview slide={slide} data={data} channels={channels} museums={museums} metrics={metrics} />
</div>
</div>
);
@@ -388,26 +382,26 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
// Static field mapping for charts (Chart.js labels don't need i18n)
const METRIC_FIELDS: Record<string, MetricFieldInfo> = {
revenue: { field: 'revenue_incl_tax', label: 'Revenue' },
revenue: { field: 'revenue_gross', label: 'Revenue' },
visitors: { field: 'visits', label: 'Visitors' },
tickets: { field: 'tickets', label: 'Tickets' }
};
function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }: SlidePreviewProps) {
function SlidePreview({ slide, data, channels, museums, metrics }: SlidePreviewProps) {
const { t } = useLanguage();
const filteredData = useMemo(() =>
filterDataByDateRange(data, slide.startDate, slide.endDate, {
district: slide.district,
channel: slide.channel,
museum: slide.museum
}),
[data, slide.startDate, slide.endDate, slide.district, slide.museum]
[data, slide.startDate, slide.endDate, slide.channel, slide.museum]
);
const metricsData = useMemo(() => calculateMetrics(filteredData), [filteredData]);
const baseOptions = useMemo(() => createBaseOptions(false), []);
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
const fieldMap: Record<string, string> = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
const fieldMap: Record<string, string> = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' };
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as unknown as Record<string, unknown>)[fieldMap[metric]] || 0)), 0);
}, []);
@@ -490,7 +484,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }: Sl
);
}
function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide, setCurrentSlide, onExit, metrics }: PreviewModeProps) {
function PreviewMode({ slides, data, channels, museums, currentSlide, setCurrentSlide, onExit, metrics }: PreviewModeProps) {
const { t } = useLanguage();
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === ' ') {
@@ -514,7 +508,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
<div className="preview-slide">
<h1 className="preview-title">{slide?.title}</h1>
<div className="preview-content">
{slide && <SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} metrics={metrics} />}
{slide && <SlidePreview slide={slide} data={data} channels={channels} museums={museums} metrics={metrics} />}
</div>
<div className="preview-footer">
<span>{currentSlide + 1} / {slides.length}</span>
@@ -530,7 +524,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
}
// Helper functions for HTML export
function generateSlideHTML(slide: SlideConfig, index: number, data: MuseumRecord[], districts: string[], districtMuseumMap: DistrictMuseumMap): string {
function generateSlideHTML(slide: SlideConfig, index: number, data: MuseumRecord[]): string {
const chartType = slide.chartType;
const canvasId = `chart-${index}`;
@@ -550,7 +544,7 @@ function generateSlideHTML(slide: SlideConfig, index: number, data: MuseumRecord
function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string {
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
district: slide.district,
channel: slide.channel,
museum: slide.museum
});
const metrics = calculateMetrics(filtered);
@@ -572,12 +566,12 @@ function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string {
</div>`;
}
function generateChartScripts(slides: SlideConfig[], data: MuseumRecord[], districts: string[], districtMuseumMap: DistrictMuseumMap): string {
function generateChartScripts(slides: SlideConfig[], data: MuseumRecord[]): string {
return slides.map((slide: SlideConfig, index: number) => {
if (slide.chartType === 'kpi-cards') return '';
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
district: slide.district,
channel: slide.channel,
museum: slide.museum
});
@@ -590,7 +584,7 @@ function generateChartScripts(slides: SlideConfig[], data: MuseumRecord[], distr
}
function generateChartConfig(slide: SlideConfig, data: MuseumRecord[]): object {
const fieldMap: Record<string, keyof MuseumRecord> = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
const fieldMap: Record<string, keyof MuseumRecord> = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' };
const field = fieldMap[slide.metric];
if (slide.chartType === 'museum-bar') {

View File

@@ -33,11 +33,11 @@
"filters": {
"title": "الفلاتر",
"year": "السنة",
"district": "المنطقة",
"channel": "القناة",
"museum": "المتحف",
"quarter": "الربع",
"allYears": "كل السنوات",
"allDistricts": "كل المناطق",
"allChannels": "جميع القنوات",
"allMuseums": "كل المتاحف",
"allQuarters": "كل الأرباع",
"reset": "إعادة تعيين الفلاتر"
@@ -56,7 +56,7 @@
},
"dashboard": {
"title": "لوحة التحكم",
"subtitle": "تحليلات المتاحف من تقارير مبيعات VivaTicket",
"subtitle": "تحليلات المتاحف من نظام Hono ERP",
"noData": "لا توجد بيانات",
"noDataMessage": "لا توجد سجلات تطابق الفلاتر الحالية. حاول تعديل اختيارك.",
"quarterlyComparison": "مقارنة ربع سنوية: 2024 مقابل 2025",
@@ -64,7 +64,7 @@
"visitorsByMuseum": "الزوار حسب المتحف",
"revenueByMuseum": "الإيرادات حسب المتحف",
"quarterlyRevenue": "الإيرادات الربعية (سنوي)",
"districtPerformance": "أداء المناطق",
"channelPerformance": "أداء القنوات",
"captureRateChart": "نسبة الاستقطاب مقابل المعتمرين"
},
"table": {
@@ -146,11 +146,11 @@
"visitors": "الزوار",
"revenue": "الإيرادات",
"quarterly": "ربع سنوي",
"district": "المنطقة",
"channel": "القناة",
"captureRate": "نسبة الاستقطاب"
},
"errors": {
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال NocoDB.",
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال ERP API.",
"network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.",
"auth": "تم رفض الوصول. قد يكون رمز API غير صالح أو منتهي الصلاحية.",
"timeout": "خادم قاعدة البيانات يستغرق وقتاً طويلاً للاستجابة. يرجى المحاولة مرة أخرى.",

View File

@@ -33,11 +33,11 @@
"filters": {
"title": "Filters",
"year": "Year",
"district": "District",
"channel": "Channel",
"museum": "Museum",
"quarter": "Quarter",
"allYears": "All Years",
"allDistricts": "All Districts",
"allChannels": "All Channels",
"allMuseums": "All Museums",
"allQuarters": "All Quarters",
"reset": "Reset Filters"
@@ -56,7 +56,7 @@
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Museum analytics from VivaTicket Sales Reports",
"subtitle": "Museum analytics from Hono ERP",
"noData": "No data found",
"noDataMessage": "No records match your current filters. Try adjusting your selection.",
"quarterlyComparison": "Quarterly Comparison: 2024 vs 2025",
@@ -64,7 +64,7 @@
"visitorsByMuseum": "Visitors by Museum",
"revenueByMuseum": "Revenue by Museum",
"quarterlyRevenue": "Quarterly Revenue (YoY)",
"districtPerformance": "District Performance",
"channelPerformance": "Channel Performance",
"captureRateChart": "Capture Rate vs Umrah Pilgrims"
},
"table": {
@@ -146,11 +146,11 @@
"visitors": "Visitors",
"revenue": "Revenue",
"quarterly": "Quarterly",
"district": "District",
"channel": "Channel",
"captureRate": "Capture Rate"
},
"errors": {
"config": "The dashboard is not configured. Please set up the NocoDB connection.",
"config": "The dashboard is not configured. Please set up the ERP API connection.",
"network": "Cannot reach the database server. Please check your internet connection.",
"auth": "Access denied. The API token may be invalid or expired.",
"timeout": "The database server is taking too long to respond. Please try again.",

View File

@@ -1,4 +1,5 @@
// Data source: NocoDB only
// Data source: Hono ERP API (via server proxy) for museum sales
// NocoDB: PilgrimStats only
// Offline mode: caches data to localStorage for resilience
import type {
@@ -10,28 +11,23 @@ import type {
CacheResult,
FetchResult,
GroupedData,
DistrictMuseumMap,
UmrahData,
NocoDBDistrict,
NocoDBMuseum,
NocoDBDailyStat,
DataErrorType
} from '../types';
import { DataError } from '../types';
import { fetchWithRetry } from '../utils/fetchHelpers';
import { fetchFromERP } from './erpService';
// NocoDB config (PilgrimStats only)
const NOCODB_URL = import.meta.env.VITE_NOCODB_URL || '';
const NOCODB_TOKEN = import.meta.env.VITE_NOCODB_TOKEN || '';
const NOCODB_BASE_ID = import.meta.env.VITE_NOCODB_BASE_ID || '';
const VAT_RATE = 1.15;
// Table IDs discovered dynamically from NocoDB meta API
let discoveredTables: Record<string, string> | null = null;
async function discoverTableIds(): Promise<Record<string, string>> {
if (discoveredTables) return discoveredTables;
if (!NOCODB_BASE_ID) throw new Error('VITE_NOCODB_BASE_ID not configured');
const res = await fetchWithRetry(
@@ -45,13 +41,7 @@ async function discoverTableIds(): Promise<Record<string, string>> {
tables[t.title] = t.id;
}
const required = ['Districts', 'Museums', 'DailyStats'];
for (const name of required) {
if (!tables[name]) throw new Error(`Required table '${name}' not found in NocoDB base`);
}
discoveredTables = tables;
console.log('Discovered NocoDB tables:', Object.keys(tables).map(k => `${k}=${tables[k]}`).join(', '));
return tables;
}
@@ -66,7 +56,6 @@ export let umrahData: UmrahData = {
2025: { 1: 15222497, 2: 5443393, 3: 26643148, 4: 31591871 }
};
// Fetch pilgrim stats from NocoDB and update umrahData
export async function fetchPilgrimStats(): Promise<UmrahData> {
try {
const tables = await discoverTableIds();
@@ -82,7 +71,7 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
const data: UmrahData = { 2024: {}, 2025: {} };
for (const r of records) {
const year = r.Year as number;
const qStr = r.Quarter as string; // "Q1", "Q2", etc.
const qStr = r.Quarter as string;
const qNum = parseInt(qStr.replace('Q', ''));
const total = r.TotalPilgrims as number;
if (year && qNum && total) {
@@ -91,7 +80,6 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
}
}
// Update the global umrahData
umrahData = data;
console.log('PilgrimStats loaded from NocoDB:', data);
return data;
@@ -161,93 +149,6 @@ export function clearCache(): void {
console.log('Cache cleared');
}
// ============================================
// NocoDB Data Fetching
// ============================================
async function fetchNocoDBTable<T>(tableId: string, limit: number = 1000): Promise<T[]> {
let allRecords: T[] = [];
let offset = 0;
while (true) {
const response = await fetchWithRetry(
`${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`,
{ headers: { 'xc-token': NOCODB_TOKEN } }
);
const json = await response.json();
const records: T[] = json.list || [];
allRecords = allRecords.concat(records);
if (records.length < limit) break;
offset += limit;
}
return allRecords;
}
interface MuseumMapEntry {
code: string;
name: string;
district: string;
}
async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
console.log('Fetching from NocoDB...');
const tables = await discoverTableIds();
// Fetch all three tables in parallel
const [districts, museums, dailyStats] = await Promise.all([
fetchNocoDBTable<NocoDBDistrict>(tables['Districts']),
fetchNocoDBTable<NocoDBMuseum>(tables['Museums']),
fetchNocoDBTable<NocoDBDailyStat>(tables['DailyStats'])
]);
// Build lookup maps
const districtMap: Record<number, string> = {};
districts.forEach(d => { districtMap[d.Id] = d.Name; });
const museumMap: Record<number, MuseumMapEntry> = {};
museums.forEach(m => {
museumMap[m.Id] = {
code: m.Code,
name: m.Name,
district: districtMap[m.DistrictId ?? m['nc_epk____Districts_id'] ?? 0] || 'Unknown'
};
});
// Join data into flat structure
const data: MuseumRecord[] = dailyStats.map(row => {
const museum = museumMap[row.MuseumId ?? row['nc_epk____Museums_id'] ?? 0] || { code: '', name: '', district: '' };
const date = row.Date;
const year = date ? date.substring(0, 4) : '';
const month = date ? parseInt(date.substring(5, 7)) : 0;
const quarter = month <= 3 ? '1' : month <= 6 ? '2' : month <= 9 ? '3' : '4';
// GrossRevenue = including VAT, NetRevenue = excluding VAT
const grossRevenue = row.GrossRevenue || 0;
const netRevenue = row.NetRevenue || (grossRevenue / VAT_RATE);
return {
date: date,
museum_code: museum.code,
museum_name: museum.name,
district: museum.district,
visits: row.Visits,
tickets: row.Tickets,
revenue_gross: grossRevenue,
revenue_net: netRevenue,
revenue_incl_tax: grossRevenue, // Legacy compatibility
year: year,
quarter: quarter
};
}).filter(r => r.date && r.museum_name);
console.log(`Loaded ${data.length} rows from NocoDB`);
return data;
}
// ============================================
// Error Classification
// ============================================
@@ -266,22 +167,12 @@ function classifyError(err: Error): DataErrorType {
// ============================================
export async function fetchData(): Promise<FetchResult> {
// Check if NocoDB is configured
if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) {
const cached = loadFromCache();
if (cached) {
console.warn('NocoDB not configured, using cached data');
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp };
}
throw new DataError('NocoDB not configured', 'config');
}
try {
const data = await fetchFromNocoDB();
const data = await fetchFromERP();
saveToCache(data);
return { data, fromCache: false };
} catch (err) {
console.error('NocoDB fetch failed:', (err as Error).message);
console.error('ERP fetch failed:', (err as Error).message);
const cached = loadFromCache();
if (cached) {
@@ -294,13 +185,8 @@ export async function fetchData(): Promise<FetchResult> {
}
}
// Force refresh (bypass cache read, but still write to cache)
export async function refreshData(): Promise<FetchResult> {
if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) {
throw new Error('NocoDB not configured');
}
const data = await fetchFromNocoDB();
const data = await fetchFromERP();
saveToCache(data);
return { data, fromCache: false };
}
@@ -312,7 +198,7 @@ export async function refreshData(): Promise<FetchResult> {
export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord[] {
return data.filter(row => {
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.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false;
return true;
@@ -328,7 +214,7 @@ export function filterDataByDateRange(
return data.filter(row => {
if (!row.date) 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.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
return true;
});
@@ -336,7 +222,7 @@ export function filterDataByDateRange(
export function calculateMetrics(data: MuseumRecord[], includeVAT: boolean = true): Metrics {
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const revenue = data.reduce((sum, row) => sum + (row[revenueField] || row.revenue_incl_tax || 0), 0);
const revenue = data.reduce((sum, row) => sum + (row[revenueField] || 0), 0);
const visitors = data.reduce((sum, row) => sum + (row.visits || 0), 0);
const tickets = data.reduce((sum, row) => sum + (row.tickets || 0), 0);
const avgRevPerVisitor = visitors > 0 ? revenue / visitors : 0;
@@ -405,7 +291,7 @@ export function groupByWeek(data: MuseumRecord[], includeVAT: boolean = true): R
const weekStart = getWeekStart(row.date);
if (!weekStart) return;
if (!grouped[weekStart]) grouped[weekStart] = { revenue: 0, visitors: 0, tickets: 0 };
grouped[weekStart].revenue += row[revenueField] || row.revenue_incl_tax || 0;
grouped[weekStart].revenue += row[revenueField] || 0;
grouped[weekStart].visitors += row.visits || 0;
grouped[weekStart].tickets += row.tickets || 0;
});
@@ -418,22 +304,22 @@ export function groupByMuseum(data: MuseumRecord[], includeVAT: boolean = true):
data.forEach(row => {
if (!row.museum_name) return;
if (!grouped[row.museum_name]) grouped[row.museum_name] = { revenue: 0, visitors: 0, tickets: 0 };
grouped[row.museum_name].revenue += row[revenueField] || row.revenue_incl_tax || 0;
grouped[row.museum_name].revenue += row[revenueField] || 0;
grouped[row.museum_name].visitors += row.visits || 0;
grouped[row.museum_name].tickets += row.tickets || 0;
});
return grouped;
}
export function groupByDistrict(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
export function groupByChannel(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] || row.revenue_incl_tax || 0;
grouped[row.district].visitors += row.visits || 0;
grouped[row.district].tickets += row.tickets || 0;
if (!row.channel) return;
if (!grouped[row.channel]) grouped[row.channel] = { revenue: 0, visitors: 0, tickets: 0 };
grouped[row.channel].revenue += row[revenueField] || 0;
grouped[row.channel].visitors += row.visits || 0;
grouped[row.channel].tickets += row.tickets || 0;
});
return grouped;
}
@@ -447,29 +333,12 @@ export function getUniqueYears(data: MuseumRecord[]): string[] {
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 getUniqueChannels(data: MuseumRecord[]): string[] {
return [...new Set(data.map(r => r.channel).filter(Boolean))].sort();
}
export function getDistrictMuseumMap(data: MuseumRecord[]): DistrictMuseumMap {
const map: Record<string, Set<string>> = {};
data.forEach(row => {
if (!row.district || !row.museum_name) return;
if (!map[row.district]) map[row.district] = new Set();
map[row.district].add(row.museum_name);
});
const result: DistrictMuseumMap = {};
Object.keys(map).forEach(d => {
result[d] = [...map[d]].sort();
});
return result;
}
export function getMuseumsForDistrict(districtMuseumMap: DistrictMuseumMap, district: string): string[] {
if (district === 'all') {
return Object.values(districtMuseumMap).flat().sort();
}
return districtMuseumMap[district] || [];
export function getUniqueMuseums(data: MuseumRecord[]): string[] {
return [...new Set(data.map(r => r.museum_name).filter(Boolean))].sort();
}
export function getLatestYear(data: MuseumRecord[]): string {

107
src/services/erpService.ts Normal file
View File

@@ -0,0 +1,107 @@
import { getMuseumFromProduct, getChannelLabel } from '../config/museumMapping';
import type { ERPSaleRecord, MuseumRecord } from '../types';
function generateMonthBoundaries(startYear: number, startMonth: number): Array<[string, string]> {
const now = new Date();
const endYear = now.getFullYear();
const endMonth = now.getMonth() + 1;
const boundaries: Array<[string, string]> = [];
let y = startYear;
let m = startMonth;
while (y < endYear || (y === endYear && m <= endMonth)) {
const start = `${y}-${String(m).padStart(2, '0')}-01T00:00:00`;
const nextM = m === 12 ? 1 : m + 1;
const nextY = m === 12 ? y + 1 : y;
const end = `${nextY}-${String(nextM).padStart(2, '0')}-01T00:00:00`;
boundaries.push([start, end]);
y = nextY;
m = nextM;
}
return boundaries;
}
async function fetchChunk(startDate: string, endDate: string): Promise<ERPSaleRecord[]> {
const params = new URLSearchParams({ startDate, endDate });
const res = await fetch(`/api/erp/sales?${params}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as { error?: string }).error || `ERP proxy returned ${res.status}`);
}
return res.json();
}
export function aggregateTransactions(sales: ERPSaleRecord[]): MuseumRecord[] {
const map = new Map<string, { visits: number; tickets: number; revenue_gross: number; revenue_net: number }>();
for (const sale of sales) {
const date = sale.TransactionDate.split(' ')[0];
const channel = getChannelLabel(sale.OperatingAreaName);
for (const product of sale.Products) {
const museum = getMuseumFromProduct(product.ProductDescription);
const key = `${date}|${museum}|${channel}`;
let entry = map.get(key);
if (!entry) {
entry = { visits: 0, tickets: 0, revenue_gross: 0, revenue_net: 0 };
map.set(key, entry);
}
entry.visits += product.PeopleCount;
entry.tickets += product.UnitQuantity;
entry.revenue_gross += product.TotalPrice;
entry.revenue_net += product.TotalPrice - product.TaxAmount;
}
}
const records: MuseumRecord[] = [];
for (const [key, entry] of map) {
const [date, museum_name, channel] = key.split('|');
const year = date.substring(0, 4);
const month = parseInt(date.substring(5, 7));
const quarter = month <= 3 ? '1' : month <= 6 ? '2' : month <= 9 ? '3' : '4';
records.push({
date,
museum_name,
channel,
visits: entry.visits,
tickets: entry.tickets,
revenue_gross: entry.revenue_gross,
revenue_net: entry.revenue_net,
year,
quarter,
});
}
return records;
}
export async function fetchFromERP(): Promise<MuseumRecord[]> {
console.log('Fetching from ERP API via proxy...');
const months = generateMonthBoundaries(2024, 1);
// Fetch all months in parallel (batched in groups of 4 to avoid overwhelming)
const batchSize = 4;
const allSales: ERPSaleRecord[] = [];
for (let i = 0; i < months.length; i += batchSize) {
const batch = months.slice(i, i + batchSize);
const results = await Promise.all(
batch.map(([start, end]) => fetchChunk(start, end))
);
for (const chunk of results) {
allSales.push(...chunk);
}
}
console.log(`Fetched ${allSales.length} transactions, aggregating...`);
const records = aggregateTransactions(allSales);
console.log(`Aggregated into ${records.length} daily records`);
return records;
}

View File

@@ -2,14 +2,12 @@
export interface MuseumRecord {
date: string;
museum_code: string;
museum_name: string;
district: string;
channel: string;
visits: number;
tickets: number;
revenue_gross: number;
revenue_net: number;
revenue_incl_tax: number; // Legacy field
year: string;
quarter: string;
}
@@ -23,13 +21,13 @@ export interface Metrics {
export interface Filters {
year: string;
district: string;
channel: string;
museum: string;
quarter: string;
}
export interface DateRangeFilters {
district: string;
channel: string;
museum: string;
}
@@ -69,16 +67,35 @@ export interface GroupedData {
tickets: number;
}
export interface DistrictMuseumMap {
[district: string]: string[];
}
export interface UmrahData {
[year: number]: {
[quarter: number]: number | null;
};
}
// ERP API types
export interface ERPProduct {
ProductDescription: string;
SiteDescription: string | null;
UnitQuantity: number;
PeopleCount: number;
TaxAmount: number;
TotalPrice: number;
}
export interface ERPPayment {
PaymentMethodDescription: string;
}
export interface ERPSaleRecord {
SaleId: number;
TransactionDate: string;
CustIdentification: string;
OperatingAreaName: string;
Payments: ERPPayment[];
Products: ERPProduct[];
}
// Chart data types
export interface ChartDataset {
label?: string;
@@ -149,31 +166,6 @@ export interface MetricCardData {
pendingMessage?: string;
}
// NocoDB raw types
export interface NocoDBDistrict {
Id: number;
Name: string;
}
export interface NocoDBMuseum {
Id: number;
Code: string;
Name: string;
DistrictId?: number;
'nc_epk____Districts_id'?: number;
}
export interface NocoDBDailyStat {
Id: number;
Date: string;
Visits: number;
Tickets: number;
GrossRevenue: number;
NetRevenue: number;
MuseumId?: number;
'nc_epk____Museums_id'?: number;
}
// Slide types
export interface SlideConfig {
id: number;
@@ -182,7 +174,7 @@ export interface SlideConfig {
metric: string;
startDate: string;
endDate: string;
district: string;
channel: string;
museum: string;
showComparison: boolean;
}