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:
10
.env.example
10
.env.example
@@ -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_URL=http://localhost:8090
|
||||||
VITE_NOCODB_TOKEN=your_token_here
|
VITE_NOCODB_TOKEN=your-token
|
||||||
VITE_NOCODB_BASE_ID=your_base_id_here
|
VITE_NOCODB_BASE_ID=your-base-id
|
||||||
|
|
||||||
# Google Sheets (fallback if NocoDB fails)
|
|
||||||
VITE_SHEETS_ID=your_spreadsheet_id_here
|
|
||||||
VITE_SHEETS_NAME=Consolidated Data
|
|
||||||
|
|||||||
271
server/index.js
271
server/index.js
@@ -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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -11,9 +11,8 @@ import {
|
|||||||
formatCompact,
|
formatCompact,
|
||||||
formatCompactCurrency,
|
formatCompactCurrency,
|
||||||
umrahData,
|
umrahData,
|
||||||
getUniqueDistricts,
|
getUniqueChannels,
|
||||||
getDistrictMuseumMap,
|
getUniqueMuseums,
|
||||||
getMuseumsForDistrict,
|
|
||||||
getLatestYear
|
getLatestYear
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import type { MuseumRecord, ComparisonProps, DateRangeFilters } from '../types';
|
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`;
|
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',
|
||||||
museum: searchParams.get('museum') || 'all'
|
museum: searchParams.get('museum') || 'all'
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -125,7 +124,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?.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 });
|
||||||
}, [setSearchParams, latestYear]);
|
}, [setSearchParams, latestYear]);
|
||||||
@@ -209,19 +208,18 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
|
|
||||||
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
|
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
|
||||||
if (metric === 'avgRevenue') {
|
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);
|
const visitors = rows.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
|
||||||
return visitors > 0 ? revenue / visitors : 0;
|
return visitors > 0 ? revenue / visitors : 0;
|
||||||
}
|
}
|
||||||
const fieldMap: Record<string, string> = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' };
|
const fieldMap: Record<string, string> = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' };
|
||||||
const field = fieldMap[metric];
|
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]);
|
}, [revenueField]);
|
||||||
|
|
||||||
// Dynamic lists from data
|
// Dynamic lists from data
|
||||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
const channels = useMemo(() => getUniqueChannels(data), [data]);
|
||||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
const availableMuseums = useMemo(() => getUniqueMuseums(data), [data]);
|
||||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
|
|
||||||
|
|
||||||
// Year-over-year comparison: same dates, previous year
|
// Year-over-year comparison: same dates, previous year
|
||||||
const ranges = useMemo(() => ({
|
const ranges = useMemo(() => ({
|
||||||
@@ -246,7 +244,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({ 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);
|
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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<FilterControls.Group label={t('filters.district')}>
|
<FilterControls.Group label={t('filters.channel')}>
|
||||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
<select value={filters.channel} onChange={e => setFilters({...filters, channel: e.target.value})}>
|
||||||
<option value="all">{t('filters.allDistricts')}</option>
|
<option value="all">{t('filters.allChannels')}</option>
|
||||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
{channels.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.museum')}>
|
<FilterControls.Group label={t('filters.museum')}>
|
||||||
|
|||||||
@@ -12,24 +12,23 @@ import {
|
|||||||
formatNumber,
|
formatNumber,
|
||||||
groupByWeek,
|
groupByWeek,
|
||||||
groupByMuseum,
|
groupByMuseum,
|
||||||
groupByDistrict,
|
groupByChannel,
|
||||||
umrahData,
|
umrahData,
|
||||||
fetchPilgrimStats,
|
fetchPilgrimStats,
|
||||||
getUniqueYears,
|
getUniqueYears,
|
||||||
getUniqueDistricts,
|
getUniqueChannels,
|
||||||
getDistrictMuseumMap,
|
getUniqueMuseums
|
||||||
getMuseumsForDistrict
|
|
||||||
} 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',
|
||||||
museum: 'all',
|
museum: 'all',
|
||||||
quarter: '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) {
|
function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
@@ -85,15 +84,14 @@ 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.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);
|
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 districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
const availableMuseums = useMemo(() => getUniqueMuseums(data), [data]);
|
||||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
|
|
||||||
|
|
||||||
const yoyChange = useMemo(() => {
|
const yoyChange = useMemo(() => {
|
||||||
if (filters.year === 'all') return null;
|
if (filters.year === 'all') return null;
|
||||||
@@ -167,7 +165,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
filteredData.forEach(row => {
|
filteredData.forEach(row => {
|
||||||
const date = row.date;
|
const date = row.date;
|
||||||
if (!dailyData[date]) dailyData[date] = 0;
|
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 days = Object.keys(dailyData).sort();
|
||||||
const revenueValues = days.map(d => dailyData[d]);
|
const revenueValues = days.map(d => dailyData[d]);
|
||||||
@@ -212,14 +210,14 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
};
|
};
|
||||||
}, [filteredData, includeVAT]);
|
}, [filteredData, includeVAT]);
|
||||||
|
|
||||||
// District data
|
// Channel data
|
||||||
const districtData = useMemo(() => {
|
const channelData = useMemo(() => {
|
||||||
const grouped = groupByDistrict(filteredData, includeVAT);
|
const grouped = groupByChannel(filteredData, includeVAT);
|
||||||
const districts = Object.keys(grouped);
|
const channels = Object.keys(grouped);
|
||||||
return {
|
return {
|
||||||
labels: districts,
|
labels: channels,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: districts.map(d => grouped[d].revenue),
|
data: channels.map(d => grouped[d].revenue),
|
||||||
backgroundColor: [chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
backgroundColor: [chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
||||||
borderRadius: 4
|
borderRadius: 4
|
||||||
}]
|
}]
|
||||||
@@ -237,13 +235,13 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: '2024',
|
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,
|
backgroundColor: chartColors.muted,
|
||||||
borderRadius: 4
|
borderRadius: 4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '2025',
|
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,
|
backgroundColor: chartColors.primary,
|
||||||
borderRadius: 4
|
borderRadius: 4
|
||||||
}
|
}
|
||||||
@@ -261,7 +259,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.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);
|
||||||
labels.push(`Q${q} ${year}`);
|
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
|
// Quarterly table
|
||||||
const quarterlyTable = useMemo(() => {
|
const quarterlyTable = useMemo(() => {
|
||||||
@@ -335,16 +333,16 @@ 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') {
|
if (filters.channel !== 'all') {
|
||||||
q2024 = q2024.filter((r: MuseumRecord) => r.district === filters.district);
|
q2024 = q2024.filter((r: MuseumRecord) => r.channel === filters.channel);
|
||||||
q2025 = q2025.filter((r: MuseumRecord) => r.district === filters.district);
|
q2025 = q2025.filter((r: MuseumRecord) => r.channel === filters.channel);
|
||||||
}
|
}
|
||||||
if (filters.museum !== 'all') {
|
if (filters.museum !== 'all') {
|
||||||
q2024 = q2024.filter((r: MuseumRecord) => r.museum_name === filters.museum);
|
q2024 = q2024.filter((r: MuseumRecord) => r.museum_name === filters.museum);
|
||||||
q2025 = q2025.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 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] || r.revenue_incl_tax || 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 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 vis25 = q2025.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
|
||||||
const revChg = rev24 > 0 ? ((rev25 - rev24) / rev24 * 100) : 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;
|
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.district, filters.museum, includeVAT]);
|
}, [data, filters.channel, filters.museum, includeVAT]);
|
||||||
|
|
||||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
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>)}
|
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.district')}>
|
<FilterControls.Group label={t('filters.channel')}>
|
||||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
<select value={filters.channel} onChange={e => setFilters({...filters, channel: e.target.value})}>
|
||||||
<option value="all">{t('filters.allDistricts')}</option>
|
<option value="all">{t('filters.allChannels')}</option>
|
||||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
{channels.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.museum')}>
|
<FilterControls.Group label={t('filters.museum')}>
|
||||||
@@ -531,8 +529,8 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<ExportableChart filename="district-performance" title={t('dashboard.districtPerformance')} className="chart-container">
|
<ExportableChart filename="channel-performance" title={t('dashboard.channelPerformance')} className="chart-container">
|
||||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||||
</ExportableChart>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -633,9 +631,9 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
|
|
||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h2>{t('dashboard.districtPerformance')}</h2>
|
<h2>{t('dashboard.channelPerformance')}</h2>
|
||||||
<div className="chart-container">
|
<div className="chart-container">
|
||||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,14 +7,12 @@ import {
|
|||||||
calculateMetrics,
|
calculateMetrics,
|
||||||
formatCompact,
|
formatCompact,
|
||||||
formatCompactCurrency,
|
formatCompactCurrency,
|
||||||
getUniqueDistricts,
|
getUniqueChannels,
|
||||||
getDistrictMuseumMap,
|
getUniqueMuseums
|
||||||
getMuseumsForDistrict
|
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import type {
|
import type {
|
||||||
MuseumRecord,
|
MuseumRecord,
|
||||||
DistrictMuseumMap,
|
|
||||||
SlideConfig,
|
SlideConfig,
|
||||||
ChartTypeOption,
|
ChartTypeOption,
|
||||||
MetricOption,
|
MetricOption,
|
||||||
@@ -25,8 +23,8 @@ import type {
|
|||||||
interface SlideEditorProps {
|
interface SlideEditorProps {
|
||||||
slide: SlideConfig;
|
slide: SlideConfig;
|
||||||
onUpdate: (updates: Partial<SlideConfig>) => void;
|
onUpdate: (updates: Partial<SlideConfig>) => void;
|
||||||
districts: string[];
|
channels: string[];
|
||||||
districtMuseumMap: DistrictMuseumMap;
|
museums: string[];
|
||||||
data: MuseumRecord[];
|
data: MuseumRecord[];
|
||||||
chartTypes: ChartTypeOption[];
|
chartTypes: ChartTypeOption[];
|
||||||
metrics: MetricOption[];
|
metrics: MetricOption[];
|
||||||
@@ -35,16 +33,16 @@ interface SlideEditorProps {
|
|||||||
interface SlidePreviewProps {
|
interface SlidePreviewProps {
|
||||||
slide: SlideConfig;
|
slide: SlideConfig;
|
||||||
data: MuseumRecord[];
|
data: MuseumRecord[];
|
||||||
districts: string[];
|
channels: string[];
|
||||||
districtMuseumMap: DistrictMuseumMap;
|
museums: string[];
|
||||||
metrics: MetricOption[];
|
metrics: MetricOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PreviewModeProps {
|
interface PreviewModeProps {
|
||||||
slides: SlideConfig[];
|
slides: SlideConfig[];
|
||||||
data: MuseumRecord[];
|
data: MuseumRecord[];
|
||||||
districts: string[];
|
channels: string[];
|
||||||
districtMuseumMap: DistrictMuseumMap;
|
museums: string[];
|
||||||
currentSlide: number;
|
currentSlide: number;
|
||||||
setCurrentSlide: React.Dispatch<React.SetStateAction<number>>;
|
setCurrentSlide: React.Dispatch<React.SetStateAction<number>>;
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
@@ -62,7 +60,7 @@ function Slides({ data }: SlidesProps) {
|
|||||||
], [t]);
|
], [t]);
|
||||||
|
|
||||||
const METRICS: MetricOption[] = useMemo(() => [
|
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: 'visitors', label: t('metrics.visitors'), field: 'visits' },
|
||||||
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
|
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
|
||||||
], [t]);
|
], [t]);
|
||||||
@@ -71,8 +69,8 @@ function Slides({ data }: SlidesProps) {
|
|||||||
const [previewMode, setPreviewMode] = useState(false);
|
const [previewMode, setPreviewMode] = useState(false);
|
||||||
const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0);
|
const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0);
|
||||||
|
|
||||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
const channels = useMemo(() => getUniqueChannels(data), [data]);
|
||||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
const museums = useMemo(() => getUniqueMuseums(data), [data]);
|
||||||
|
|
||||||
const defaultSlideConfig: Omit<SlideConfig, 'id'> = {
|
const defaultSlideConfig: Omit<SlideConfig, 'id'> = {
|
||||||
title: 'Slide Title',
|
title: 'Slide Title',
|
||||||
@@ -80,7 +78,7 @@ function Slides({ data }: SlidesProps) {
|
|||||||
metric: 'revenue',
|
metric: 'revenue',
|
||||||
startDate: '2026-01-01',
|
startDate: '2026-01-01',
|
||||||
endDate: '2026-01-31',
|
endDate: '2026-01-31',
|
||||||
district: 'all',
|
channel: 'all',
|
||||||
museum: 'all',
|
museum: 'all',
|
||||||
showComparison: false
|
showComparison: false
|
||||||
};
|
};
|
||||||
@@ -128,7 +126,7 @@ function Slides({ data }: SlidesProps) {
|
|||||||
|
|
||||||
// Generate HTML for each slide
|
// Generate HTML for each slide
|
||||||
const slidesHTML = slides.map((slide, index) => {
|
const slidesHTML = slides.map((slide, index) => {
|
||||||
return generateSlideHTML(slide, index, data, districts, districtMuseumMap);
|
return generateSlideHTML(slide, index, data);
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
const fullHTML = `<!DOCTYPE html>
|
const fullHTML = `<!DOCTYPE html>
|
||||||
@@ -185,7 +183,7 @@ function Slides({ data }: SlidesProps) {
|
|||||||
${slidesHTML}
|
${slidesHTML}
|
||||||
<script>
|
<script>
|
||||||
// Chart.js initialization scripts will be here
|
// Chart.js initialization scripts will be here
|
||||||
${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
${generateChartScripts(slides, data)}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
@@ -206,8 +204,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
|||||||
<PreviewMode
|
<PreviewMode
|
||||||
slides={slides}
|
slides={slides}
|
||||||
data={data}
|
data={data}
|
||||||
districts={districts}
|
channels={channels}
|
||||||
districtMuseumMap={districtMuseumMap}
|
museums={museums}
|
||||||
currentSlide={currentPreviewSlide}
|
currentSlide={currentPreviewSlide}
|
||||||
setCurrentSlide={setCurrentPreviewSlide}
|
setCurrentSlide={setCurrentPreviewSlide}
|
||||||
onExit={() => setPreviewMode(false)}
|
onExit={() => setPreviewMode(false)}
|
||||||
@@ -283,8 +281,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
|||||||
<SlideEditor
|
<SlideEditor
|
||||||
slide={slides.find(s => s.id === editingSlide)!}
|
slide={slides.find(s => s.id === editingSlide)!}
|
||||||
onUpdate={(updates) => updateSlide(editingSlide, updates)}
|
onUpdate={(updates) => updateSlide(editingSlide, updates)}
|
||||||
districts={districts}
|
channels={channels}
|
||||||
districtMuseumMap={districtMuseumMap}
|
museums={museums}
|
||||||
data={data}
|
data={data}
|
||||||
chartTypes={CHART_TYPES}
|
chartTypes={CHART_TYPES}
|
||||||
metrics={METRICS}
|
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 { t } = useLanguage();
|
||||||
const availableMuseums = useMemo(() =>
|
|
||||||
getMuseumsForDistrict(districtMuseumMap, slide.district),
|
|
||||||
[districtMuseumMap, slide.district]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="slide-editor">
|
<div className="slide-editor">
|
||||||
@@ -350,17 +344,17 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
|||||||
|
|
||||||
<div className="editor-row">
|
<div className="editor-row">
|
||||||
<div className="editor-section">
|
<div className="editor-section">
|
||||||
<label>{t('filters.district')}</label>
|
<label>{t('filters.channel')}</label>
|
||||||
<select value={slide.district} onChange={e => onUpdate({ district: e.target.value, museum: 'all' })}>
|
<select value={slide.channel} onChange={e => onUpdate({ channel: e.target.value, museum: 'all' })}>
|
||||||
<option value="all">{t('filters.allDistricts')}</option>
|
<option value="all">{t('filters.allChannels')}</option>
|
||||||
{districts.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
{channels.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-section">
|
<div className="editor-section">
|
||||||
<label>{t('filters.museum')}</label>
|
<label>{t('filters.museum')}</label>
|
||||||
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
|
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
|
||||||
<option value="all">{t('filters.allMuseums')}</option>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -380,7 +374,7 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
|||||||
|
|
||||||
<div className="slide-preview-box">
|
<div className="slide-preview-box">
|
||||||
<h4>{t('slides.preview')}</h4>
|
<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>
|
||||||
</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)
|
// Static field mapping for charts (Chart.js labels don't need i18n)
|
||||||
const METRIC_FIELDS: Record<string, MetricFieldInfo> = {
|
const METRIC_FIELDS: Record<string, MetricFieldInfo> = {
|
||||||
revenue: { field: 'revenue_incl_tax', label: 'Revenue' },
|
revenue: { field: 'revenue_gross', label: 'Revenue' },
|
||||||
visitors: { field: 'visits', label: 'Visitors' },
|
visitors: { field: 'visits', label: 'Visitors' },
|
||||||
tickets: { field: 'tickets', label: 'Tickets' }
|
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 { t } = useLanguage();
|
||||||
const filteredData = useMemo(() =>
|
const filteredData = useMemo(() =>
|
||||||
filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||||
district: slide.district,
|
channel: slide.channel,
|
||||||
museum: slide.museum
|
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 metricsData = useMemo(() => calculateMetrics(filteredData), [filteredData]);
|
||||||
const baseOptions = useMemo(() => createBaseOptions(false), []);
|
const baseOptions = useMemo(() => createBaseOptions(false), []);
|
||||||
|
|
||||||
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
|
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);
|
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 { t } = useLanguage();
|
||||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
if (e.key === 'ArrowRight' || e.key === ' ') {
|
if (e.key === 'ArrowRight' || e.key === ' ') {
|
||||||
@@ -514,7 +508,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
|
|||||||
<div className="preview-slide">
|
<div className="preview-slide">
|
||||||
<h1 className="preview-title">{slide?.title}</h1>
|
<h1 className="preview-title">{slide?.title}</h1>
|
||||||
<div className="preview-content">
|
<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>
|
||||||
<div className="preview-footer">
|
<div className="preview-footer">
|
||||||
<span>{currentSlide + 1} / {slides.length}</span>
|
<span>{currentSlide + 1} / {slides.length}</span>
|
||||||
@@ -530,7 +524,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for HTML export
|
// 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 chartType = slide.chartType;
|
||||||
const canvasId = `chart-${index}`;
|
const canvasId = `chart-${index}`;
|
||||||
|
|
||||||
@@ -550,7 +544,7 @@ function generateSlideHTML(slide: SlideConfig, index: number, data: MuseumRecord
|
|||||||
|
|
||||||
function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string {
|
function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string {
|
||||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||||
district: slide.district,
|
channel: slide.channel,
|
||||||
museum: slide.museum
|
museum: slide.museum
|
||||||
});
|
});
|
||||||
const metrics = calculateMetrics(filtered);
|
const metrics = calculateMetrics(filtered);
|
||||||
@@ -572,12 +566,12 @@ function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string {
|
|||||||
</div>`;
|
</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) => {
|
return slides.map((slide: SlideConfig, index: number) => {
|
||||||
if (slide.chartType === 'kpi-cards') return '';
|
if (slide.chartType === 'kpi-cards') return '';
|
||||||
|
|
||||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||||
district: slide.district,
|
channel: slide.channel,
|
||||||
museum: slide.museum
|
museum: slide.museum
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -590,7 +584,7 @@ function generateChartScripts(slides: SlideConfig[], data: MuseumRecord[], distr
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generateChartConfig(slide: SlideConfig, data: MuseumRecord[]): object {
|
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];
|
const field = fieldMap[slide.metric];
|
||||||
|
|
||||||
if (slide.chartType === 'museum-bar') {
|
if (slide.chartType === 'museum-bar') {
|
||||||
|
|||||||
@@ -33,11 +33,11 @@
|
|||||||
"filters": {
|
"filters": {
|
||||||
"title": "الفلاتر",
|
"title": "الفلاتر",
|
||||||
"year": "السنة",
|
"year": "السنة",
|
||||||
"district": "المنطقة",
|
"channel": "القناة",
|
||||||
"museum": "المتحف",
|
"museum": "المتحف",
|
||||||
"quarter": "الربع",
|
"quarter": "الربع",
|
||||||
"allYears": "كل السنوات",
|
"allYears": "كل السنوات",
|
||||||
"allDistricts": "كل المناطق",
|
"allChannels": "جميع القنوات",
|
||||||
"allMuseums": "كل المتاحف",
|
"allMuseums": "كل المتاحف",
|
||||||
"allQuarters": "كل الأرباع",
|
"allQuarters": "كل الأرباع",
|
||||||
"reset": "إعادة تعيين الفلاتر"
|
"reset": "إعادة تعيين الفلاتر"
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "لوحة التحكم",
|
"title": "لوحة التحكم",
|
||||||
"subtitle": "تحليلات المتاحف من تقارير مبيعات VivaTicket",
|
"subtitle": "تحليلات المتاحف من نظام Hono ERP",
|
||||||
"noData": "لا توجد بيانات",
|
"noData": "لا توجد بيانات",
|
||||||
"noDataMessage": "لا توجد سجلات تطابق الفلاتر الحالية. حاول تعديل اختيارك.",
|
"noDataMessage": "لا توجد سجلات تطابق الفلاتر الحالية. حاول تعديل اختيارك.",
|
||||||
"quarterlyComparison": "مقارنة ربع سنوية: 2024 مقابل 2025",
|
"quarterlyComparison": "مقارنة ربع سنوية: 2024 مقابل 2025",
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
"visitorsByMuseum": "الزوار حسب المتحف",
|
"visitorsByMuseum": "الزوار حسب المتحف",
|
||||||
"revenueByMuseum": "الإيرادات حسب المتحف",
|
"revenueByMuseum": "الإيرادات حسب المتحف",
|
||||||
"quarterlyRevenue": "الإيرادات الربعية (سنوي)",
|
"quarterlyRevenue": "الإيرادات الربعية (سنوي)",
|
||||||
"districtPerformance": "أداء المناطق",
|
"channelPerformance": "أداء القنوات",
|
||||||
"captureRateChart": "نسبة الاستقطاب مقابل المعتمرين"
|
"captureRateChart": "نسبة الاستقطاب مقابل المعتمرين"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
@@ -146,11 +146,11 @@
|
|||||||
"visitors": "الزوار",
|
"visitors": "الزوار",
|
||||||
"revenue": "الإيرادات",
|
"revenue": "الإيرادات",
|
||||||
"quarterly": "ربع سنوي",
|
"quarterly": "ربع سنوي",
|
||||||
"district": "المنطقة",
|
"channel": "القناة",
|
||||||
"captureRate": "نسبة الاستقطاب"
|
"captureRate": "نسبة الاستقطاب"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال NocoDB.",
|
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال ERP API.",
|
||||||
"network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.",
|
"network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.",
|
||||||
"auth": "تم رفض الوصول. قد يكون رمز API غير صالح أو منتهي الصلاحية.",
|
"auth": "تم رفض الوصول. قد يكون رمز API غير صالح أو منتهي الصلاحية.",
|
||||||
"timeout": "خادم قاعدة البيانات يستغرق وقتاً طويلاً للاستجابة. يرجى المحاولة مرة أخرى.",
|
"timeout": "خادم قاعدة البيانات يستغرق وقتاً طويلاً للاستجابة. يرجى المحاولة مرة أخرى.",
|
||||||
|
|||||||
@@ -33,11 +33,11 @@
|
|||||||
"filters": {
|
"filters": {
|
||||||
"title": "Filters",
|
"title": "Filters",
|
||||||
"year": "Year",
|
"year": "Year",
|
||||||
"district": "District",
|
"channel": "Channel",
|
||||||
"museum": "Museum",
|
"museum": "Museum",
|
||||||
"quarter": "Quarter",
|
"quarter": "Quarter",
|
||||||
"allYears": "All Years",
|
"allYears": "All Years",
|
||||||
"allDistricts": "All Districts",
|
"allChannels": "All Channels",
|
||||||
"allMuseums": "All Museums",
|
"allMuseums": "All Museums",
|
||||||
"allQuarters": "All Quarters",
|
"allQuarters": "All Quarters",
|
||||||
"reset": "Reset Filters"
|
"reset": "Reset Filters"
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"subtitle": "Museum analytics from VivaTicket Sales Reports",
|
"subtitle": "Museum analytics from Hono ERP",
|
||||||
"noData": "No data found",
|
"noData": "No data found",
|
||||||
"noDataMessage": "No records match your current filters. Try adjusting your selection.",
|
"noDataMessage": "No records match your current filters. Try adjusting your selection.",
|
||||||
"quarterlyComparison": "Quarterly Comparison: 2024 vs 2025",
|
"quarterlyComparison": "Quarterly Comparison: 2024 vs 2025",
|
||||||
@@ -64,7 +64,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",
|
||||||
"captureRateChart": "Capture Rate vs Umrah Pilgrims"
|
"captureRateChart": "Capture Rate vs Umrah Pilgrims"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
@@ -146,11 +146,11 @@
|
|||||||
"visitors": "Visitors",
|
"visitors": "Visitors",
|
||||||
"revenue": "Revenue",
|
"revenue": "Revenue",
|
||||||
"quarterly": "Quarterly",
|
"quarterly": "Quarterly",
|
||||||
"district": "District",
|
"channel": "Channel",
|
||||||
"captureRate": "Capture Rate"
|
"captureRate": "Capture Rate"
|
||||||
},
|
},
|
||||||
"errors": {
|
"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.",
|
"network": "Cannot reach the database server. Please check your internet connection.",
|
||||||
"auth": "Access denied. The API token may be invalid or expired.",
|
"auth": "Access denied. The API token may be invalid or expired.",
|
||||||
"timeout": "The database server is taking too long to respond. Please try again.",
|
"timeout": "The database server is taking too long to respond. Please try again.",
|
||||||
|
|||||||
@@ -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
|
// Offline mode: caches data to localStorage for resilience
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -10,28 +11,23 @@ import type {
|
|||||||
CacheResult,
|
CacheResult,
|
||||||
FetchResult,
|
FetchResult,
|
||||||
GroupedData,
|
GroupedData,
|
||||||
DistrictMuseumMap,
|
|
||||||
UmrahData,
|
UmrahData,
|
||||||
NocoDBDistrict,
|
|
||||||
NocoDBMuseum,
|
|
||||||
NocoDBDailyStat,
|
|
||||||
DataErrorType
|
DataErrorType
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { DataError } from '../types';
|
import { DataError } from '../types';
|
||||||
import { fetchWithRetry } from '../utils/fetchHelpers';
|
import { fetchWithRetry } from '../utils/fetchHelpers';
|
||||||
|
import { fetchFromERP } from './erpService';
|
||||||
|
|
||||||
|
// NocoDB config (PilgrimStats only)
|
||||||
const NOCODB_URL = import.meta.env.VITE_NOCODB_URL || '';
|
const NOCODB_URL = import.meta.env.VITE_NOCODB_URL || '';
|
||||||
const NOCODB_TOKEN = import.meta.env.VITE_NOCODB_TOKEN || '';
|
const NOCODB_TOKEN = import.meta.env.VITE_NOCODB_TOKEN || '';
|
||||||
const NOCODB_BASE_ID = import.meta.env.VITE_NOCODB_BASE_ID || '';
|
const NOCODB_BASE_ID = import.meta.env.VITE_NOCODB_BASE_ID || '';
|
||||||
|
|
||||||
const VAT_RATE = 1.15;
|
|
||||||
|
|
||||||
// Table IDs discovered dynamically from NocoDB meta API
|
// Table IDs discovered dynamically from NocoDB meta API
|
||||||
let discoveredTables: Record<string, string> | null = null;
|
let discoveredTables: Record<string, string> | null = null;
|
||||||
|
|
||||||
async function discoverTableIds(): Promise<Record<string, string>> {
|
async function discoverTableIds(): Promise<Record<string, string>> {
|
||||||
if (discoveredTables) return discoveredTables;
|
if (discoveredTables) return discoveredTables;
|
||||||
|
|
||||||
if (!NOCODB_BASE_ID) throw new Error('VITE_NOCODB_BASE_ID not configured');
|
if (!NOCODB_BASE_ID) throw new Error('VITE_NOCODB_BASE_ID not configured');
|
||||||
|
|
||||||
const res = await fetchWithRetry(
|
const res = await fetchWithRetry(
|
||||||
@@ -45,20 +41,14 @@ async function discoverTableIds(): Promise<Record<string, string>> {
|
|||||||
tables[t.title] = t.id;
|
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;
|
discoveredTables = tables;
|
||||||
console.log('Discovered NocoDB tables:', Object.keys(tables).map(k => `${k}=${tables[k]}`).join(', '));
|
|
||||||
return tables;
|
return tables;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache keys
|
// Cache keys
|
||||||
const CACHE_KEY = 'hihala_data_cache';
|
const CACHE_KEY = 'hihala_data_cache';
|
||||||
const CACHE_TIMESTAMP_KEY = 'hihala_data_cache_timestamp';
|
const CACHE_TIMESTAMP_KEY = 'hihala_data_cache_timestamp';
|
||||||
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
|
||||||
// Default umrah data (overridden by NocoDB PilgrimStats when available)
|
// Default umrah data (overridden by NocoDB PilgrimStats when available)
|
||||||
export let umrahData: UmrahData = {
|
export let umrahData: UmrahData = {
|
||||||
@@ -66,7 +56,6 @@ export let umrahData: UmrahData = {
|
|||||||
2025: { 1: 15222497, 2: 5443393, 3: 26643148, 4: 31591871 }
|
2025: { 1: 15222497, 2: 5443393, 3: 26643148, 4: 31591871 }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch pilgrim stats from NocoDB and update umrahData
|
|
||||||
export async function fetchPilgrimStats(): Promise<UmrahData> {
|
export async function fetchPilgrimStats(): Promise<UmrahData> {
|
||||||
try {
|
try {
|
||||||
const tables = await discoverTableIds();
|
const tables = await discoverTableIds();
|
||||||
@@ -78,11 +67,11 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
|
|||||||
const res = await fetchWithRetry(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
const res = await fetchWithRetry(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
const records = json.list || [];
|
const records = json.list || [];
|
||||||
|
|
||||||
const data: UmrahData = { 2024: {}, 2025: {} };
|
const data: UmrahData = { 2024: {}, 2025: {} };
|
||||||
for (const r of records) {
|
for (const r of records) {
|
||||||
const year = r.Year as number;
|
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 qNum = parseInt(qStr.replace('Q', ''));
|
||||||
const total = r.TotalPilgrims as number;
|
const total = r.TotalPilgrims as number;
|
||||||
if (year && qNum && total) {
|
if (year && qNum && total) {
|
||||||
@@ -90,8 +79,7 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
|
|||||||
data[year][qNum] = total;
|
data[year][qNum] = total;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the global umrahData
|
|
||||||
umrahData = data;
|
umrahData = data;
|
||||||
console.log('PilgrimStats loaded from NocoDB:', data);
|
console.log('PilgrimStats loaded from NocoDB:', data);
|
||||||
return data;
|
return data;
|
||||||
@@ -119,15 +107,15 @@ function loadFromCache(): CacheResult | null {
|
|||||||
try {
|
try {
|
||||||
const cached = localStorage.getItem(CACHE_KEY);
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
||||||
|
|
||||||
if (!cached) return null;
|
if (!cached) return null;
|
||||||
|
|
||||||
const data: MuseumRecord[] = JSON.parse(cached);
|
const data: MuseumRecord[] = JSON.parse(cached);
|
||||||
const age = timestamp ? Date.now() - parseInt(timestamp) : Infinity;
|
const age = timestamp ? Date.now() - parseInt(timestamp) : Infinity;
|
||||||
const isStale = age > CACHE_MAX_AGE_MS;
|
const isStale = age > CACHE_MAX_AGE_MS;
|
||||||
|
|
||||||
console.log(`Loaded ${data.length} rows from cache (age: ${Math.round(age / 1000 / 60)} min, stale: ${isStale})`);
|
console.log(`Loaded ${data.length} rows from cache (age: ${Math.round(age / 1000 / 60)} min, stale: ${isStale})`);
|
||||||
|
|
||||||
return { data, isStale, timestamp: parseInt(timestamp || '0') };
|
return { data, isStale, timestamp: parseInt(timestamp || '0') };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to load from cache:', (err as Error).message);
|
console.warn('Failed to load from cache:', (err as Error).message);
|
||||||
@@ -138,14 +126,14 @@ function loadFromCache(): CacheResult | null {
|
|||||||
export function getCacheStatus(): CacheStatus {
|
export function getCacheStatus(): CacheStatus {
|
||||||
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
||||||
const cached = localStorage.getItem(CACHE_KEY);
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
|
|
||||||
if (!cached || !timestamp) {
|
if (!cached || !timestamp) {
|
||||||
return { available: false, timestamp: null, age: null, rows: 0 };
|
return { available: false, timestamp: null, age: null, rows: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ts = parseInt(timestamp);
|
const ts = parseInt(timestamp);
|
||||||
const data: MuseumRecord[] = JSON.parse(cached);
|
const data: MuseumRecord[] = JSON.parse(cached);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
available: true,
|
available: true,
|
||||||
timestamp: new Date(ts).toISOString(),
|
timestamp: new Date(ts).toISOString(),
|
||||||
@@ -161,93 +149,6 @@ export function clearCache(): void {
|
|||||||
console.log('Cache cleared');
|
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
|
// Error Classification
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -266,22 +167,12 @@ function classifyError(err: Error): DataErrorType {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
export async function fetchData(): Promise<FetchResult> {
|
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 {
|
try {
|
||||||
const data = await fetchFromNocoDB();
|
const data = await fetchFromERP();
|
||||||
saveToCache(data);
|
saveToCache(data);
|
||||||
return { data, fromCache: false };
|
return { data, fromCache: false };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('NocoDB fetch failed:', (err as Error).message);
|
console.error('ERP fetch failed:', (err as Error).message);
|
||||||
|
|
||||||
const cached = loadFromCache();
|
const cached = loadFromCache();
|
||||||
if (cached) {
|
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> {
|
export async function refreshData(): Promise<FetchResult> {
|
||||||
if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) {
|
const data = await fetchFromERP();
|
||||||
throw new Error('NocoDB not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fetchFromNocoDB();
|
|
||||||
saveToCache(data);
|
saveToCache(data);
|
||||||
return { data, fromCache: false };
|
return { data, fromCache: false };
|
||||||
}
|
}
|
||||||
@@ -312,7 +198,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.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;
|
||||||
return true;
|
return true;
|
||||||
@@ -320,15 +206,15 @@ export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function filterDataByDateRange(
|
export function filterDataByDateRange(
|
||||||
data: MuseumRecord[],
|
data: MuseumRecord[],
|
||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string,
|
endDate: string,
|
||||||
filters: Partial<DateRangeFilters> = {}
|
filters: Partial<DateRangeFilters> = {}
|
||||||
): MuseumRecord[] {
|
): MuseumRecord[] {
|
||||||
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.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;
|
||||||
});
|
});
|
||||||
@@ -336,7 +222,7 @@ export function filterDataByDateRange(
|
|||||||
|
|
||||||
export function calculateMetrics(data: MuseumRecord[], includeVAT: boolean = true): Metrics {
|
export function calculateMetrics(data: MuseumRecord[], includeVAT: boolean = true): Metrics {
|
||||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
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 visitors = data.reduce((sum, row) => sum + (row.visits || 0), 0);
|
||||||
const tickets = data.reduce((sum, row) => sum + (row.tickets || 0), 0);
|
const tickets = data.reduce((sum, row) => sum + (row.tickets || 0), 0);
|
||||||
const avgRevPerVisitor = visitors > 0 ? revenue / visitors : 0;
|
const avgRevPerVisitor = visitors > 0 ? revenue / visitors : 0;
|
||||||
@@ -383,17 +269,17 @@ export function formatCompactCurrency(num: number): string {
|
|||||||
|
|
||||||
export function getWeekStart(dateStr: string): string | null {
|
export function getWeekStart(dateStr: string): string | null {
|
||||||
if (!dateStr || !dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) return null;
|
if (!dateStr || !dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) return null;
|
||||||
|
|
||||||
const [year, month, day] = dateStr.split('-').map(Number);
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
const date = new Date(year, month - 1, day);
|
const date = new Date(year, month - 1, day);
|
||||||
const dayOfWeek = date.getDay();
|
const dayOfWeek = date.getDay();
|
||||||
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
||||||
|
|
||||||
const monday = new Date(year, month - 1, day + diff);
|
const monday = new Date(year, month - 1, day + diff);
|
||||||
const y = monday.getFullYear();
|
const y = monday.getFullYear();
|
||||||
const m = String(monday.getMonth() + 1).padStart(2, '0');
|
const m = String(monday.getMonth() + 1).padStart(2, '0');
|
||||||
const d = String(monday.getDate()).padStart(2, '0');
|
const d = String(monday.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
return `${y}-${m}-${d}`;
|
return `${y}-${m}-${d}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +291,7 @@ export function groupByWeek(data: MuseumRecord[], includeVAT: boolean = true): R
|
|||||||
const weekStart = getWeekStart(row.date);
|
const weekStart = getWeekStart(row.date);
|
||||||
if (!weekStart) return;
|
if (!weekStart) return;
|
||||||
if (!grouped[weekStart]) grouped[weekStart] = { revenue: 0, visitors: 0, tickets: 0 };
|
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].visitors += row.visits || 0;
|
||||||
grouped[weekStart].tickets += row.tickets || 0;
|
grouped[weekStart].tickets += row.tickets || 0;
|
||||||
});
|
});
|
||||||
@@ -418,22 +304,22 @@ export function groupByMuseum(data: MuseumRecord[], includeVAT: boolean = true):
|
|||||||
data.forEach(row => {
|
data.forEach(row => {
|
||||||
if (!row.museum_name) return;
|
if (!row.museum_name) return;
|
||||||
if (!grouped[row.museum_name]) grouped[row.museum_name] = { revenue: 0, visitors: 0, tickets: 0 };
|
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].visitors += row.visits || 0;
|
||||||
grouped[row.museum_name].tickets += row.tickets || 0;
|
grouped[row.museum_name].tickets += row.tickets || 0;
|
||||||
});
|
});
|
||||||
return grouped;
|
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 revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||||
const grouped: Record<string, GroupedData> = {};
|
const grouped: Record<string, GroupedData> = {};
|
||||||
data.forEach(row => {
|
data.forEach(row => {
|
||||||
if (!row.district) return;
|
if (!row.channel) return;
|
||||||
if (!grouped[row.district]) grouped[row.district] = { revenue: 0, visitors: 0, tickets: 0 };
|
if (!grouped[row.channel]) grouped[row.channel] = { revenue: 0, visitors: 0, tickets: 0 };
|
||||||
grouped[row.district].revenue += row[revenueField] || row.revenue_incl_tax || 0;
|
grouped[row.channel].revenue += row[revenueField] || 0;
|
||||||
grouped[row.district].visitors += row.visits || 0;
|
grouped[row.channel].visitors += row.visits || 0;
|
||||||
grouped[row.district].tickets += row.tickets || 0;
|
grouped[row.channel].tickets += row.tickets || 0;
|
||||||
});
|
});
|
||||||
return grouped;
|
return grouped;
|
||||||
}
|
}
|
||||||
@@ -447,29 +333,12 @@ 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[] {
|
export function getUniqueChannels(data: MuseumRecord[]): string[] {
|
||||||
return [...new Set(data.map(r => r.district).filter(Boolean))].sort();
|
return [...new Set(data.map(r => r.channel).filter(Boolean))].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDistrictMuseumMap(data: MuseumRecord[]): DistrictMuseumMap {
|
export function getUniqueMuseums(data: MuseumRecord[]): string[] {
|
||||||
const map: Record<string, Set<string>> = {};
|
return [...new Set(data.map(r => r.museum_name).filter(Boolean))].sort();
|
||||||
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 getLatestYear(data: MuseumRecord[]): string {
|
export function getLatestYear(data: MuseumRecord[]): string {
|
||||||
|
|||||||
107
src/services/erpService.ts
Normal file
107
src/services/erpService.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -2,14 +2,12 @@
|
|||||||
|
|
||||||
export interface MuseumRecord {
|
export interface MuseumRecord {
|
||||||
date: string;
|
date: string;
|
||||||
museum_code: string;
|
|
||||||
museum_name: string;
|
museum_name: string;
|
||||||
district: string;
|
channel: string;
|
||||||
visits: number;
|
visits: number;
|
||||||
tickets: number;
|
tickets: number;
|
||||||
revenue_gross: number;
|
revenue_gross: number;
|
||||||
revenue_net: number;
|
revenue_net: number;
|
||||||
revenue_incl_tax: number; // Legacy field
|
|
||||||
year: string;
|
year: string;
|
||||||
quarter: string;
|
quarter: string;
|
||||||
}
|
}
|
||||||
@@ -23,13 +21,13 @@ export interface Metrics {
|
|||||||
|
|
||||||
export interface Filters {
|
export interface Filters {
|
||||||
year: string;
|
year: string;
|
||||||
district: string;
|
channel: string;
|
||||||
museum: string;
|
museum: string;
|
||||||
quarter: string;
|
quarter: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateRangeFilters {
|
export interface DateRangeFilters {
|
||||||
district: string;
|
channel: string;
|
||||||
museum: string;
|
museum: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,16 +67,35 @@ export interface GroupedData {
|
|||||||
tickets: number;
|
tickets: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DistrictMuseumMap {
|
|
||||||
[district: string]: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UmrahData {
|
export interface UmrahData {
|
||||||
[year: number]: {
|
[year: number]: {
|
||||||
[quarter: number]: number | null;
|
[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
|
// Chart data types
|
||||||
export interface ChartDataset {
|
export interface ChartDataset {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -149,31 +166,6 @@ export interface MetricCardData {
|
|||||||
pendingMessage?: string;
|
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
|
// Slide types
|
||||||
export interface SlideConfig {
|
export interface SlideConfig {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -182,7 +174,7 @@ export interface SlideConfig {
|
|||||||
metric: string;
|
metric: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
district: string;
|
channel: string;
|
||||||
museum: string;
|
museum: string;
|
||||||
showComparison: boolean;
|
showComparison: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user