From f6b7d4ba8d9049e2453186b29eddf82f921ae741 Mon Sep 17 00:00:00 2001 From: fahed Date: Thu, 26 Mar 2026 16:43:34 +0300 Subject: [PATCH] feat: migrate museum sales from NocoDB to Hono ERP API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .env.example | 10 +- server/index.js | 271 ---------------------------------- src/components/Comparison.tsx | 28 ++-- src/components/Dashboard.tsx | 68 +++++---- src/components/Slides.tsx | 80 +++++----- src/locales/ar.json | 12 +- src/locales/en.json | 12 +- src/services/dataService.ts | 209 +++++--------------------- src/services/erpService.ts | 107 ++++++++++++++ src/types/index.ts | 62 ++++---- 10 files changed, 271 insertions(+), 588 deletions(-) delete mode 100644 server/index.js create mode 100644 src/services/erpService.ts diff --git a/.env.example b/.env.example index 992a43d..b465417 100644 --- a/.env.example +++ b/.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_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 diff --git a/server/index.js b/server/index.js deleted file mode 100644 index 46a7fea..0000000 --- a/server/index.js +++ /dev/null @@ -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(` - - -

✅ Salla Connected!

-

Authorization successful. You can close this window.

-

Tokens have been logged to the console.

- - - - `); - } 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'); - } -}); diff --git a/src/components/Comparison.tsx b/src/components/Comparison.tsx index be0eb32..8b4d060 100644 --- a/src/components/Comparison.tsx +++ b/src/components/Comparison.tsx @@ -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 = { 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 )} - - setFilters({...filters, channel: e.target.value})}> + + {channels.map(c => )} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index be52f9b..beedc0e 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -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)[revenueField] || row.revenue_incl_tax || 0); + dailyData[date] += Number((row as unknown as Record)[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 => )} - - setFilters({...filters, channel: e.target.value})}> + + {channels.map(c => )} @@ -531,8 +529,8 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
- - + +
@@ -633,9 +631,9 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
-

{t('dashboard.districtPerformance')}

+

{t('dashboard.channelPerformance')}

- +
diff --git a/src/components/Slides.tsx b/src/components/Slides.tsx index 3926898..37c9a51 100644 --- a/src/components/Slides.tsx +++ b/src/components/Slides.tsx @@ -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) => 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>; 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 = { 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 = ` @@ -185,7 +183,7 @@ function Slides({ data }: SlidesProps) { ${slidesHTML} `; @@ -206,8 +204,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)} setPreviewMode(false)} @@ -283,8 +281,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)} 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 (
@@ -350,17 +344,17 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
- - onUpdate({ channel: e.target.value, museum: 'all' })}> + + {channels.map((d: string) => )}
@@ -380,7 +374,7 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char

{t('slides.preview')}

- +
); @@ -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 = { - 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 = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' }; + const fieldMap: Record = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' }; return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as unknown as Record)[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,

{slide?.title}

- {slide && } + {slide && }
{currentSlide + 1} / {slides.length} @@ -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 {
`; } -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 = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' }; + const fieldMap: Record = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' }; const field = fieldMap[slide.metric]; if (slide.chartType === 'museum-bar') { diff --git a/src/locales/ar.json b/src/locales/ar.json index 989d64a..96589df 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -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": "خادم قاعدة البيانات يستغرق وقتاً طويلاً للاستجابة. يرجى المحاولة مرة أخرى.", diff --git a/src/locales/en.json b/src/locales/en.json index feeffac..44e745b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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.", diff --git a/src/services/dataService.ts b/src/services/dataService.ts index b369569..759d257 100644 --- a/src/services/dataService.ts +++ b/src/services/dataService.ts @@ -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 | null = null; async function discoverTableIds(): Promise> { if (discoveredTables) return discoveredTables; - if (!NOCODB_BASE_ID) throw new Error('VITE_NOCODB_BASE_ID not configured'); const res = await fetchWithRetry( @@ -45,20 +41,14 @@ async function discoverTableIds(): Promise> { 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; } // Cache keys const CACHE_KEY = 'hihala_data_cache'; 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) export let umrahData: UmrahData = { @@ -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 { try { const tables = await discoverTableIds(); @@ -78,11 +67,11 @@ export async function fetchPilgrimStats(): Promise { const res = await fetchWithRetry(url, { headers: { 'xc-token': NOCODB_TOKEN } }); const json = await res.json(); const records = json.list || []; - + 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) { @@ -90,8 +79,7 @@ export async function fetchPilgrimStats(): Promise { data[year][qNum] = total; } } - - // Update the global umrahData + umrahData = data; console.log('PilgrimStats loaded from NocoDB:', data); return data; @@ -119,15 +107,15 @@ function loadFromCache(): CacheResult | null { try { const cached = localStorage.getItem(CACHE_KEY); const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY); - + if (!cached) return null; - + const data: MuseumRecord[] = JSON.parse(cached); const age = timestamp ? Date.now() - parseInt(timestamp) : Infinity; const isStale = age > CACHE_MAX_AGE_MS; - + console.log(`Loaded ${data.length} rows from cache (age: ${Math.round(age / 1000 / 60)} min, stale: ${isStale})`); - + return { data, isStale, timestamp: parseInt(timestamp || '0') }; } catch (err) { console.warn('Failed to load from cache:', (err as Error).message); @@ -138,14 +126,14 @@ function loadFromCache(): CacheResult | null { export function getCacheStatus(): CacheStatus { const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY); const cached = localStorage.getItem(CACHE_KEY); - + if (!cached || !timestamp) { return { available: false, timestamp: null, age: null, rows: 0 }; } - + const ts = parseInt(timestamp); const data: MuseumRecord[] = JSON.parse(cached); - + return { available: true, timestamp: new Date(ts).toISOString(), @@ -161,93 +149,6 @@ export function clearCache(): void { console.log('Cache cleared'); } -// ============================================ -// NocoDB Data Fetching -// ============================================ - -async function fetchNocoDBTable(tableId: string, limit: number = 1000): Promise { - 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 { - console.log('Fetching from NocoDB...'); - - const tables = await discoverTableIds(); - - // Fetch all three tables in parallel - const [districts, museums, dailyStats] = await Promise.all([ - fetchNocoDBTable(tables['Districts']), - fetchNocoDBTable(tables['Museums']), - fetchNocoDBTable(tables['DailyStats']) - ]); - - // Build lookup maps - const districtMap: Record = {}; - districts.forEach(d => { districtMap[d.Id] = d.Name; }); - - const museumMap: Record = {}; - 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 { - // 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 { } } -// Force refresh (bypass cache read, but still write to cache) export async function refreshData(): Promise { - 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 { 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; @@ -320,15 +206,15 @@ export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord } export function filterDataByDateRange( - data: MuseumRecord[], - startDate: string, - endDate: string, + data: MuseumRecord[], + startDate: string, + endDate: string, filters: Partial = {} ): MuseumRecord[] { 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; @@ -383,17 +269,17 @@ export function formatCompactCurrency(num: number): string { export function getWeekStart(dateStr: string): string | null { if (!dateStr || !dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) return null; - + const [year, month, day] = dateStr.split('-').map(Number); const date = new Date(year, month - 1, day); const dayOfWeek = date.getDay(); const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; - + const monday = new Date(year, month - 1, day + diff); const y = monday.getFullYear(); const m = String(monday.getMonth() + 1).padStart(2, '0'); const d = String(monday.getDate()).padStart(2, '0'); - + return `${y}-${m}-${d}`; } @@ -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 { +export function groupByChannel(data: MuseumRecord[], includeVAT: boolean = true): Record { const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net'; const grouped: Record = {}; 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> = {}; - 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 { diff --git a/src/services/erpService.ts b/src/services/erpService.ts new file mode 100644 index 0000000..e66d160 --- /dev/null +++ b/src/services/erpService.ts @@ -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 { + 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(); + + 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 { + 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; +} diff --git a/src/types/index.ts b/src/types/index.ts index 08b635e..5813ec5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; }