Compare commits

...

14 Commits

Author SHA1 Message Date
fahed
04789ea9a1 fix: B2C visitor count uses UnitQuantity (1 ticket = 1 visitor)
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 8s
B2C generates one PDF ticket per person, so UnitQuantity = visitors.
Other channels (POS, Safiyyah POS, etc.) use PeopleCount for visitors
since group tickets cover multiple people.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:41:51 +03:00
fahed
219680fb5e feat: add district filter (Hiraa/AsSaffiyah) from static mapping
- ETL writes District column to NocoDB DailySales
- Museums mapped: Hiraa (Revelation, Holy Quraan, Trail, Makkah, VIP)
  AsSaffiyah (Creation Story, Best of Creation)
- District filter added to Dashboard and Comparison (cascades to museum)
- District Performance chart added (desktop + mobile)
- Locale keys added for both EN and AR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:08:16 +03:00
fahed
4f4559023b feat: combo ticket 50/50 split + Best of Creation museum
- Combo tickets (matching multiple museums) split revenue/visits evenly
- Each museum gets its own row tagged with TicketType=combo, ComboWith
- Added Best of Creation (متحف خير الخلق) to museum mapping
- Holy Quraan Museum now shows 3.3M total (was 971K without combo share)
- ComboMuseums column tracks split factor for auditing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:53:25 +03:00
fahed
1f1e0756d0 feat: add server-side ETL pipeline, revert client to NocoDB reads
ETL Pipeline (server):
- POST /api/etl/sync?mode=full|incremental — fetches ERP, aggregates, writes NocoDB
- nocodbClient.ts: table discovery, paginated delete/insert
- etlSync.ts: orchestrates fetch → aggregate → upsert
- museumMapping.ts moved from client to server
- Auth via ETL_SECRET bearer token

Client:
- dataService.ts reverts to reading NocoDB DailySales table
- Paginated fetch via fetchNocoDBTable (handles >1000 rows)
- Suspicious data check: prefers cache if NocoDB returns <10 rows
- Deleted erpService.ts and client-side museumMapping.ts

First full sync: 391K transactions → 5,760 daily records in 108s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:25:50 +03:00
fahed
9c0ffa5721 docs: add ETL pipeline design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:26:23 +03:00
fahed
b4f4104e3e chore: remove Slides page and Salla console output
- Remove Slides route, import, and mobile nav link from App.tsx
- Remove Salla route mounting and console output from server

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:16:40 +03:00
fahed
18821fd560 fix: fetch ERP months sequentially to avoid 500 errors
The ERP API can't handle concurrent requests — switch from batched
parallel (4 at a time) to sequential fetching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:59:21 +03:00
fahed
ea71e54058 fix: change server port to 3002 to avoid conflict with rawaj-v2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:49:59 +03:00
fahed
4ed4d83257 feat: add unified dev script that launches server + client
npm run dev now starts both the ERP proxy server and Vite in parallel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:46:31 +03:00
fahed
f6b7d4ba8d 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>
2026-03-26 16:43:34 +03:00
fahed
a84caaa31e feat: add product-to-museum and channel mapping config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:03:10 +03:00
fahed
8bdfc85027 refactor: extract fetch helpers to shared util
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:02:43 +03:00
fahed
e84d961536 feat: convert server to TypeScript + add ERP API proxy
- Migrate server/index.js → modular TS structure (config, routes, services)
- Add ERP proxy: GET /api/erp/sales proxies Hono ERP API with server-side auth
- JWT token cached server-side, auto-refreshes on 401
- ERP credentials stay server-side only (no VITE_ prefix)
- Vite dev proxy routes /api/erp → localhost:3001
- Preserve existing Salla OAuth integration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:58:35 +03:00
fahed
9c1552e439 chore: add ERP API migration plan (pre-migration snapshot)
Preserves current NocoDB-based state before switching museum
sales data source to the Hono ERP API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:43:20 +03:00
32 changed files with 3057 additions and 598 deletions

View File

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

View File

@@ -0,0 +1,647 @@
# ERP API Migration — Replace NocoDB Museum Data with Hono ERP API
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace NocoDB as the museum sales data source with the Hono ERP API, keeping NocoDB only for PilgrimStats. Add "channel" as a new filterable dimension (replacing "district").
**Architecture:** The Hono ERP API returns transaction-level sales data (each sale with nested `Products[]`). We authenticate via JWT (POST `/auth/login`), then fetch by date range (GET `/api/getbydate`). Client-side code aggregates transactions into daily/museum/channel records that match the existing `MuseumRecord` shape. NocoDB remains solely for PilgrimStats.
**Tech Stack:** React 19, TypeScript (strict), Vite, Chart.js, Hono ERP REST API (Azure-hosted)
**Security note:** ERP credentials are stored as `VITE_*` env vars which get bundled into the client-side JS (same pattern as the existing NocoDB token). The ERP account (`sales_user`) is a read-only reporting account. A server-side proxy can be added later if needed.
**Compilation note:** Tasks 39 form an atomic migration — the codebase will not compile between them. They must be executed as a single batch on one branch. Individual commits are for traceability, not for producing intermediate working states.
---
## File Structure
| Action | File | Responsibility |
|--------|------|----------------|
| Create | `src/utils/fetchHelpers.ts` | Shared `fetchWithTimeout` + `fetchWithRetry` (extracted from dataService) |
| Create | `src/config/museumMapping.ts` | Product description → museum mapping + channel label mapping |
| Create | `src/services/erpService.ts` | ERP API auth, fetching, transaction → MuseumRecord aggregation |
| Modify | `src/types/index.ts` | Add `channel` to MuseumRecord, add ERP API types, remove NocoDB museum types, remove `DistrictMuseumMap` |
| Modify | `src/services/dataService.ts` | Replace NocoDB fetch with ERP fetch, replace district→channel in grouping/filter functions, remove `revenue_incl_tax` fallbacks |
| Modify | `src/components/Dashboard.tsx` | Replace district filter/chart with channel, update `filterKeys` array, update all district references |
| Modify | `src/components/Comparison.tsx` | Replace district filter with channel |
| Modify | `src/components/Slides.tsx` | Full refactor: replace `DistrictMuseumMap` prop threading (10+ call sites), `SlideConfig.district``channel`, update `SlideEditor`/`SlidePreview`/`PreviewMode` interfaces, update `generateSlideHTML`/`generateChartScripts` |
| Modify | `src/locales/en.json` | Replace district→channel keys, add `charts.channel`, update error messages |
| Modify | `src/locales/ar.json` | Arabic translations for all channel-related keys |
| Modify | `src/App.tsx` | Update env var check for ERP config |
| Modify | `.env.local` | Add ERP API credentials |
| Modify | `.env.example` | Update to reflect ERP as primary museum data source |
---
### Task 1: Environment Configuration
**Files:**
- Modify: `.env.local`
- Modify: `.env.example`
- [ ] **Step 1: Add ERP env vars to `.env.local`**
```env
# Hono ERP API (museum sales data)
VITE_ERP_API_URL=<see .env.local on machine>
VITE_ERP_API_CODE=<see .env.local on machine>
VITE_ERP_USERNAME=<see .env.local on machine>
VITE_ERP_PASSWORD=<see .env.local on machine>
```
The actual values are in the Postman collection at `~/Downloads/hono-erp Copy.postman_collection.json`. Read that file for the credentials. Keep existing NocoDB vars (needed for PilgrimStats).
- [ ] **Step 2: Update `.env.example`**
Update to document both data sources:
```env
# Hono ERP API (museum sales data — primary source)
VITE_ERP_API_URL=https://hono-erp.azurewebsites.net
VITE_ERP_API_CODE=your-api-function-key
VITE_ERP_USERNAME=your-username
VITE_ERP_PASSWORD=your-password
# NocoDB (PilgrimStats only)
VITE_NOCODB_URL=http://localhost:8090
VITE_NOCODB_TOKEN=your-token
VITE_NOCODB_BASE_ID=your-base-id
```
- [ ] **Step 3: Commit** (`.env.local` is gitignored — only commit `.env.example`)
```bash
git add .env.example
git commit -m "feat: update env example for ERP API as primary museum data source"
```
---
### Task 2: Extract Fetch Helpers
**Files:**
- Create: `src/utils/fetchHelpers.ts`
- Modify: `src/services/dataService.ts` (update imports)
- [ ] **Step 1: Extract `fetchWithTimeout` and `fetchWithRetry`**
Move these two functions from `dataService.ts` into `src/utils/fetchHelpers.ts`. Export them. Also move the constants `FETCH_TIMEOUT_MS` and `MAX_RETRIES`.
- [ ] **Step 2: Update dataService.ts imports**
Replace the function definitions with:
```typescript
import { fetchWithTimeout, fetchWithRetry } from '../utils/fetchHelpers';
```
- [ ] **Step 3: Verify build still works**
```bash
npm run build
```
- [ ] **Step 4: Commit**
```bash
git add src/utils/fetchHelpers.ts src/services/dataService.ts
git commit -m "refactor: extract fetch helpers to shared util"
```
---
### Task 3: Museum Mapping Configuration
**Files:**
- Create: `src/config/museumMapping.ts`
Definitive mapping of all 47 known product descriptions to museum names, plus channel label mappings.
- [ ] **Step 1: Create museum mapping file**
The mapping uses keyword matching with a **priority order** — this matters for combo tickets. Check keywords in this order (first match wins):
1. **Revelation Exhibition** — keywords: `"Revelation"`, `"الوحي"` (catches combo tickets mentioning both الوحي and القرآن الكريم)
2. **Creation Story Museum** — keywords: `"Creation Story"`, `"قصة الخلق"`
3. **Holy Quraan Museum** — keywords: `"Holy Quraan"`, `"القرآن الكريم"`
4. **Trail To Hira Cave** — keywords: `"Trail To Hira"`, `"غار حراء"`
5. **Makkah Greets Us** — keywords: `"Makkah Greets"`
6. **VIP Experience** — keywords: `"VIP Experience"`
If no match: return `"Other"`.
Channel label mapping:
```typescript
const CHANNEL_LABELS: Record<string, string> = {
'B2C': 'HiHala Website/App',
'B2B': 'B2B',
'POS': 'POS',
'Safiyyah POS': 'Safiyyah POS',
'Standalone': 'Standalone',
'Mobile': 'Mobile',
'Viva': 'Viva',
'IT': 'IT'
};
```
Exports:
- `getMuseumFromProduct(productDescription: string): string`
- `getChannelLabel(operatingAreaName: string): string`
- `MUSEUM_NAMES: string[]`
- `CHANNEL_LABELS: Record<string, string>`
- [ ] **Step 2: Commit**
```bash
git add src/config/museumMapping.ts
git commit -m "feat: add product-to-museum and channel mapping config"
```
---
### Task 4: TypeScript Types Update
**Files:**
- Modify: `src/types/index.ts`
- [ ] **Step 1: Update MuseumRecord**
Replace `district` with `channel`. Remove `museum_code` and `revenue_incl_tax` (legacy).
```typescript
export interface MuseumRecord {
date: string;
museum_name: string;
channel: string; // was: district
visits: number; // = sum of PeopleCount per product line
tickets: number; // = sum of UnitQuantity per product line
revenue_gross: number; // = sum of TotalPrice (includes VAT)
revenue_net: number; // = revenue_gross - sum of TaxAmount
year: string;
quarter: string;
}
```
- [ ] **Step 2: Add ERP API types**
```typescript
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[];
}
export interface ERPLoginResponse {
token: string;
}
```
- [ ] **Step 3: Update Filters interface**
```typescript
export interface Filters {
year: string;
channel: string; // was: district
museum: string;
quarter: string;
}
export interface DateRangeFilters {
channel: string; // was: district
museum: string;
}
```
- [ ] **Step 4: Remove obsolete types**
Remove: `NocoDBDistrict`, `NocoDBMuseum`, `NocoDBDailyStat`, `DistrictMuseumMap`.
Update `SlideConfig.district``SlideConfig.channel`.
- [ ] **Step 5: Commit**
```bash
git add src/types/index.ts
git commit -m "feat: update types for ERP API — channel replaces district"
```
---
### Task 5: ERP Service
**Files:**
- Create: `src/services/erpService.ts`
- [ ] **Step 1: Implement auth + fetch**
```typescript
import { fetchWithRetry } from '../utils/fetchHelpers';
import { getMuseumFromProduct, getChannelLabel } from '../config/museumMapping';
import type { ERPSaleRecord, ERPLoginResponse, MuseumRecord } from '../types';
const ERP_API_URL = import.meta.env.VITE_ERP_API_URL || '';
const ERP_API_CODE = import.meta.env.VITE_ERP_API_CODE || '';
const ERP_USERNAME = import.meta.env.VITE_ERP_USERNAME || '';
const ERP_PASSWORD = import.meta.env.VITE_ERP_PASSWORD || '';
let cachedToken: string | null = null;
async function login(): Promise<string> { /* POST /auth/login, cache token */ }
async function fetchSalesByDateRange(startDate: string, endDate: string): Promise<ERPSaleRecord[]> { /* GET /api/getbydate with Bearer token + code param */ }
```
Auth: token cached in module-level variable, re-login on 401.
Fetch strategy: generate month boundaries from 2024-01 to current month, fetch all in parallel with `Promise.all`.
- [ ] **Step 2: Implement aggregation function**
```typescript
export function aggregateTransactions(sales: ERPSaleRecord[]): MuseumRecord[]
```
For each sale:
1. Extract date from `TransactionDate` (split on space, take first part → `"2025-01-01"`)
2. Get channel from `OperatingAreaName` via `getChannelLabel()`
3. For each product in `Products[]`:
- Get museum from `getMuseumFromProduct(product.ProductDescription)`
- Accumulate into key `${date}|${museum}|${channel}`:
- `visits += product.PeopleCount`
- `tickets += product.UnitQuantity`
- `revenue_gross += product.TotalPrice`
- `revenue_net += (product.TotalPrice - product.TaxAmount)`
4. Convert accumulated map to `MuseumRecord[]` with computed `year` and `quarter`
Negative quantities (refunds) sum correctly by default — no special handling needed.
- [ ] **Step 3: Export main entry point**
```typescript
export async function fetchFromERP(): Promise<MuseumRecord[]>
```
This orchestrates: login → fetch all months → aggregate → return.
- [ ] **Step 4: Commit**
```bash
git add src/services/erpService.ts
git commit -m "feat: add ERP service with auth, fetch, and aggregation"
```
---
### Task 6: Refactor dataService.ts
**Files:**
- Modify: `src/services/dataService.ts`
- [ ] **Step 1: Replace NocoDB museum fetch with ERP**
- Remove: `discoverTableIds()`, `fetchNocoDBTable()`, `fetchFromNocoDB()`, `MuseumMapEntry`, NocoDB museum env var reads
- Keep: NocoDB config for PilgrimStats path only (`fetchPilgrimStats()` unchanged)
- Import `fetchFromERP` from erpService
- Update `fetchData()` and `refreshData()` to call `fetchFromERP()` instead of `fetchFromNocoDB()`
- Update config check: `if (!ERP_API_URL || !ERP_API_CODE || !ERP_USERNAME || !ERP_PASSWORD)` → throw DataError('config')
- [ ] **Step 2: Replace all district→channel in data functions**
- `filterData()`: `filters.district``filters.channel`, `row.district``row.channel`
- `filterDataByDateRange()`: same
- `groupByDistrict()`→ rename to `groupByChannel()`, change `row.district``row.channel`
- `getUniqueDistricts()`→ rename to `getUniqueChannels()`, change `r.district``r.channel`
- `getDistrictMuseumMap()`**delete**
- `getMuseumsForDistrict()`→ replace with `getUniqueMuseums(data: MuseumRecord[]): string[]` returning all unique museum names sorted
- Remove all `revenue_incl_tax` fallback references (e.g. `|| row.revenue_incl_tax || 0` in groupBy functions and calculateMetrics)
- [ ] **Step 3: Update type imports**
Remove unused NocoDB types from the import block. Add imports for new ERP-related types if needed.
- [ ] **Step 4: Commit**
```bash
git add src/services/dataService.ts
git commit -m "refactor: replace NocoDB museum fetch with ERP API, district→channel"
```
---
### Task 7: Update Dashboard Component
**Files:**
- Modify: `src/components/Dashboard.tsx`
- [ ] **Step 1: Update imports**
- `getUniqueDistricts``getUniqueChannels`
- `getDistrictMuseumMap` → remove
- `getMuseumsForDistrict``getUniqueMuseums`
- `groupByDistrict``groupByChannel`
- [ ] **Step 2: Update filter state and controls**
- `defaultFilters`: `district: 'all'``channel: 'all'`
- `filterKeys` array (line 32): `'district'``'channel'` — this controls URL param serialization
- All `filters.district``filters.channel`
- District `<select>` → Channel `<select>` with `t('filters.channel')` label
- Museum `<select>`: no longer cascaded from district/channel — just show all `getUniqueMuseums(data)`
- `availableMuseums` memo: `getMuseumsForDistrict(districtMuseumMap, filters.district)``getUniqueMuseums(data)`
- Remove `districtMuseumMap` memo entirely
- [ ] **Step 3: Update charts**
- `districtData``channelData` using `groupByChannel(filteredData, includeVAT)`
- "District Performance" → "Channel Performance" using `t('dashboard.channelPerformance')`
- Chart carousel label `t('charts.district')``t('charts.channel')` (line 88)
- Capture rate chart: `filters.district``filters.channel`, `r.district``r.channel`
- [ ] **Step 4: Update quarterly table**
Replace `filters.district``filters.channel` and `r.district``r.channel`
- [ ] **Step 5: Commit**
```bash
git add src/components/Dashboard.tsx
git commit -m "feat: update Dashboard — channel replaces district, new channel chart"
```
---
### Task 8: Update Comparison Component
**Files:**
- Modify: `src/components/Comparison.tsx`
- [ ] **Step 1: Update imports and filter references**
- Replace `getUniqueDistricts``getUniqueChannels`
- Remove `getDistrictMuseumMap`, `getMuseumsForDistrict`
- Add `getUniqueMuseums`
- Replace all `district` filter references with `channel` (includes URL params, `DateRangeFilters` usage, `<select>` elements)
- Museum filter: use `getUniqueMuseums(data)` (no longer cascaded)
- [ ] **Step 2: Commit**
```bash
git add src/components/Comparison.tsx
git commit -m "feat: update Comparison — channel replaces district"
```
---
### Task 9: Update Slides Component (FULL REFACTOR)
**Files:**
- Modify: `src/components/Slides.tsx`
This is a significant change — Slides.tsx has 30+ district references across 10+ call sites including function signatures, prop interfaces, and HTML export generation.
- [ ] **Step 1: Update imports**
- Replace `getUniqueDistricts``getUniqueChannels`
- Replace `getDistrictMuseumMap` → remove
- Replace `getMuseumsForDistrict``getUniqueMuseums`
- Remove import of `DistrictMuseumMap` type
- [ ] **Step 2: Update component interfaces**
All three interfaces pass `districts: string[]` and `districtMuseumMap: DistrictMuseumMap`:
```typescript
// SlideEditorProps (line 25): districts→channels, remove districtMuseumMap
interface SlideEditorProps {
slide: SlideConfig;
onUpdate: (updates: Partial<SlideConfig>) => void;
channels: string[];
museums: string[]; // flat list, independent of channel
data: MuseumRecord[];
chartTypes: ChartTypeOption[];
metrics: MetricOption[];
}
// SlidePreviewProps (line 35): same pattern
interface SlidePreviewProps {
slide: SlideConfig;
data: MuseumRecord[];
channels: string[];
museums: string[];
metrics: MetricOption[];
}
// PreviewModeProps (line 43): same pattern
interface PreviewModeProps {
slides: SlideConfig[];
data: MuseumRecord[];
channels: string[];
museums: string[];
currentSlide: number;
setCurrentSlide: React.Dispatch<React.SetStateAction<number>>;
onExit: () => void;
metrics: MetricOption[];
}
```
- [ ] **Step 3: Update Slides() main function**
- `districts` memo → `channels` using `getUniqueChannels(data)`
- `districtMuseumMap` memo → `museums` using `getUniqueMuseums(data)`
- `defaultSlide.district: 'all'``channel: 'all'`
- Update all prop passing: `districts={districts} districtMuseumMap={districtMuseumMap}``channels={channels} museums={museums}`
- [ ] **Step 4: Update SlideEditor function**
- `getMuseumsForDistrict(districtMuseumMap, slide.district)` → just use `museums` prop directly
- Filter label: `t('filters.district')``t('filters.channel')`
- `<select>` for district → channel: `slide.district``slide.channel`, `onUpdate({ district: ... })``onUpdate({ channel: ... })`
- Museum select: no longer cascaded, just show all `museums`
- [ ] **Step 5: Update SlidePreview function**
- `district: slide.district``channel: slide.channel` in `filterDataByDateRange` calls
- [ ] **Step 6: Update generateSlideHTML and generateChartScripts**
- Function signatures: remove `districts: string[]` and `districtMuseumMap: DistrictMuseumMap` params, add `channels: string[]` and `museums: string[]`
- Internal references: `slide.district``slide.channel`
- `filterDataByDateRange` calls: `district:``channel:`
- [ ] **Step 7: Commit**
```bash
git add src/components/Slides.tsx
git commit -m "feat: update Slides — full district→channel refactor across all interfaces"
```
---
### Task 10: Update Locale Files
**Files:**
- Modify: `src/locales/en.json`
- Modify: `src/locales/ar.json`
- [ ] **Step 1: Update English translations**
Replace/add:
```json
{
"filters": {
"channel": "Channel",
"allChannels": "All Channels"
},
"charts": {
"channel": "Channel"
},
"dashboard": {
"subtitle": "Museum analytics from Hono ERP",
"channelPerformance": "Channel Performance"
},
"errors": {
"config": "The dashboard is not configured. Please set up the ERP API connection."
}
}
```
Remove: `filters.district`, `filters.allDistricts`, `charts.district`, `dashboard.districtPerformance`.
- [ ] **Step 2: Update Arabic translations**
```json
{
"filters": {
"channel": "القناة",
"allChannels": "جميع القنوات"
},
"charts": {
"channel": "القناة"
},
"dashboard": {
"subtitle": "تحليلات المتاحف من نظام Hono ERP",
"channelPerformance": "أداء القنوات"
},
"errors": {
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال ERP API."
}
}
```
Remove: `filters.district`, `filters.allDistricts`, `charts.district`, `dashboard.districtPerformance`.
- [ ] **Step 3: Commit**
```bash
git add src/locales/en.json src/locales/ar.json
git commit -m "feat: update locale files — channel replaces district, ERP error messages"
```
---
### Task 11: Build Verification & Smoke Test
- [ ] **Step 1: Run `npm run build` to verify TypeScript compiles**
All `district` references should be gone. Any remaining will cause TS errors. Also search for `revenue_incl_tax` and `museum_code` — these should be fully removed.
- [ ] **Step 2: Run the dev server and verify**
```bash
npm run dev
```
Check:
1. Dashboard loads with data from ERP API (not NocoDB)
2. Channel filter shows: HiHala Website/App, B2B, POS, Safiyyah POS, Standalone, Mobile, Viva, IT
3. Museum filter shows: Revelation Exhibition, Creation Story Museum, Holy Quraan Museum, Trail To Hira Cave, Makkah Greets Us, VIP Experience
4. Museum and channel filters work independently (not cascaded)
5. All charts render: revenue trend, visitors by museum, revenue by museum, quarterly YoY, **channel performance** (was district), capture rate
6. Comparison page works with channel filter
7. Slides page works — create/preview/export with channel filter
8. PilgrimStats loads from NocoDB (capture rate chart shows pilgrim data)
9. Cache works (reload → uses cached data)
10. Offline fallback works (disconnect → shows cached data with offline badge)
- [ ] **Step 3: Commit any fixes**
---
## Appendix: All 47 Known Product Descriptions
For reference when building the mapping in Task 3.
**Revelation Exhibition (12 products):**
1. `Revelation Exhibition - Child`
2. `Revelation Exhibition - Groups`
3. `Revelation Exhibition - Individuals`
4. `Revelation Exhibition - POD`
5. `Revelation Exhibition and Trail To Hiraa Cave - Individuals` _(combo → Revelation)_
6. `معرض الوحي - أطفال | Revelation Exhibition - Child`
7. `معرض الوحي - أفراد | Revelation Exhibition - Individuals`
8. `معرض الوحي - المجموعات | Revelation Exhibition - Group`
9. `معرض الوحي - ذوي الإعاقة | Revelation Exhibition - POD`
10. `معرض الوحي - مجموعات| Revelation Exhibition - Groups`
11. `تذكرة دخول أفراد - معرض الوحي | متحف القرآن الكريم` _(combo → Revelation, because الوحي matched first)_
12. `تذكرة دخول مجموعات - معرض الوحي | متحف القرآن الكريم` _(combo → Revelation)_
**Creation Story Museum (21 products):**
1. `Creation Story - Groups`
2. `Creation Story - Individuals`
3. `Creation Story - Groups` _(extra space variant)_
4. `Creation Story - Indviduals - Open Date` _(typo "Indviduals" is in the source data)_
5. `Creation Story Group`
6. `Creation Story Individual`
7. `Creation Story School`
8. `متحف قصة الخلق - أفراد | Creation Story Museum - Individuals`
9. `متحف قصة الخلق - مجموعات| Creation Story Museum - Group`
10. `متحف قصة الخلق - مدرسة | Creation Story Museum - School`
11. `متحف قصة الخلق - أفراد - خصم بولمان زمزم`
12. `متحف قصة الخلق - مجموعات - خصم بولمان زمزم`
13. `تذكرة دخول متحف قصة الخلق (جامعة) | Creation Story Museum`
14. `تذكرة دخول متحف قصة الخلق مخفضة | Creation Story Museum`
15. `تذكرة دخول متحف قصة الخلق مخفضة 10 | Creation Story Museum`
16. `تذكرة دخول متحف قصة الخلق مخفضة 11.5 | Creation Story Museum`
17. `تذكرة دخول متحف قصة الخلق مخفضة 15 | Creation Story Museum`
18. `تذكرة دخول متحف قصة الخلق مخفضة 19 | Creation Story Museum`
19. `تذكرة مجانية دخول متحف قصة الخلق (ترويجية) | Creation Sto`
20. `تذكرة مجانية دخول متحف قصة الخلق (ذوي الهمم) | Creation Sto`
21. ` تذكرة مجانية دخول متحف قصة الخلق (أطفال) | Creation Story ` _(leading space)_
**Holy Quraan Museum (8 products):**
1. `Holy Quraan Museum - Child`
2. `Holy Quraan Museum - Child | متحف القرآن الكريم - أطفال`
3. `Holy Quraan Museum - Groups`
4. `Holy Quraan Museum - Groups | متحف القرآن الكريم - المجموعات`
5. `Holy Quraan Museum - Individu | متحف القرآن الكريم - أفراد`
6. `Holy Quraan Museum - Individuals`
7. `Holy Quraan Museum - POD`
8. `Holy Quraan Museum - POD | متحف القرآن الكريم - ذوي الإعاقة`
**Trail To Hira Cave (3 products):**
1. `Trail To Hira Cave - Car | غار حراء - الصعود بالسيارة`
2. `Trail To Hira Cave - Walking`
3. `Trail To Hira Cave - Walking | غار حراء - الصعود على الأقدام`
**Makkah Greets Us (1 product):**
1. `Makkah Greets us - Entry Ticket`
**VIP Experience (1 product):**
1. `VIP Experience`
**Total: 12 + 21 + 8 + 3 + 1 + 1 = 46 products**

View File

@@ -0,0 +1,242 @@
# ETL Pipeline: ERP → NocoDB Daily Sales
## Goal
Replace the current client-side ERP fetching (which downloads hundreds of MBs of raw transactions to the browser) with a server-side ETL pipeline that aggregates ERP data into NocoDB. The dashboard reads pre-aggregated data from NocoDB — fast and lightweight.
## Data Flow
```
Daily (2am cron):
ERP API → Server (fetch + aggregate) → NocoDB "DailySales" table
On page load:
NocoDB "DailySales" → Dashboard client (small payload, fast)
```
## NocoDB "DailySales" Table
One row per date/museum/channel combination. Flat — no lookup tables needed.
| Column | Type | Example |
|--------|------|---------|
| Date | string | `2025-03-01` |
| MuseumName | string | `Revelation Exhibition` |
| Channel | string | `HiHala Website/App` |
| Visits | number | `702` |
| Tickets | number | `71` |
| GrossRevenue | number | `12049.00` |
| NetRevenue | number | `10477.40` |
Museums are derived from product descriptions using a priority-ordered keyword mapping (46 products → 6 museums). Channels are derived from `OperatingAreaName` with display labels (e.g. B2C → "HiHala Website/App").
## Server Architecture
### New files
| File | Responsibility |
|------|----------------|
| `server/src/config/museumMapping.ts` | Product → museum mapping, channel labels (moved from client) |
| `server/src/types.ts` | Server-side ERP types (`ERPSaleRecord`, `ERPProduct`, `ERPPayment`, `AggregatedRecord`) |
| `server/src/services/nocodbClient.ts` | NocoDB table discovery (via `process.env`, NOT `import.meta.env`) + paginated read/write |
| `server/src/services/etlSync.ts` | Orchestrate: fetch ERP → aggregate → write NocoDB |
| `server/src/routes/etl.ts` | `POST /api/etl/sync` endpoint (protected by secret token) |
### Modified files
| File | Change |
|------|--------|
| `server/src/config.ts` | Add NocoDB config (`process.env.NOCODB_*`) |
| `server/src/index.ts` | Mount ETL route |
| `server/.env` | Add `NOCODB_*` and `ETL_SECRET` vars |
| `server/.env.example` | Add `NOCODB_*` and `ETL_SECRET` placeholders |
| `src/services/dataService.ts` | Revert to NocoDB fetch with paginated reads for DailySales |
### Removed files
| File | Reason |
|------|--------|
| `server/src/routes/erp.ts` | Client no longer calls ERP directly |
| `src/services/erpService.ts` | Client no longer aggregates transactions |
| `src/config/museumMapping.ts` | Moved to server |
## ETL Sync Endpoint
```
POST /api/etl/sync?mode=full|incremental
Authorization: Bearer <ETL_SECRET>
```
Protected by a secret token (`ETL_SECRET` env var). Requests without a valid token get 401. The cron passes it: `curl -H "Authorization: Bearer $ETL_SECRET" -X POST ...`.
- **incremental** (default): fetch current month from ERP, aggregate, upsert into NocoDB. Used by daily cron.
- **full**: fetch all months from 2024-01 to now, clear and replace all NocoDB DailySales data. Used for initial setup or recovery.
### Incremental date range
The current month is defined as:
- `startDate`: `YYYY-MM-01T00:00:00` (first of current month)
- `endDate`: `YYYY-{MM+1}-01T00:00:00` (first of next month, exclusive)
This matches the convention already used in `erpService.ts` month boundary generation.
Response:
```json
{
"status": "ok",
"mode": "incremental",
"transactionsFetched": 12744,
"recordsWritten": 342,
"duration": "8.2s"
}
```
## Aggregation Logic
For each ERP transaction:
1. Extract date from `TransactionDate` (split on space, take first part)
2. Map `OperatingAreaName` → channel label via `getChannelLabel()`
3. For each product in `Products[]`:
- Map `ProductDescription` → museum name via `getMuseumFromProduct()` (priority-ordered keyword matching)
- Accumulate into composite key `date|museum|channel`:
- `visits += PeopleCount`
- `tickets += UnitQuantity`
- `GrossRevenue += TotalPrice`
- `NetRevenue += TotalPrice - TaxAmount`
Negative quantities (refunds) sum correctly by default.
## NocoDB Upsert Strategy
For **incremental** sync:
1. Delete all rows in DailySales where `Date` falls within the fetched month range
2. Insert the newly aggregated rows
For **full** sync:
1. Delete all rows in DailySales
2. Insert all aggregated rows
This avoids duplicate detection complexity — just replace the month's data.
### Race condition note
During the delete/insert window, dashboard reads may see incomplete data. Mitigations:
- The sync runs at 2am when traffic is minimal
- The client's localStorage cache (7-day TTL) means most page loads never hit NocoDB
- The client checks if fetched data is suspiciously small (< 10 rows) and prefers cached data over a likely-incomplete NocoDB read
- For full syncs, the window is larger (~2-5 minutes). If this becomes a problem, a shadow-table swap pattern can be added later.
## Client Changes
### dataService.ts
Revert to reading from NocoDB. The `DailySales` table is flat, so no joins needed. **Must use paginated fetch** (NocoDB defaults to 25 rows per page, max 1000). The existing `fetchNocoDBTable()` helper already handles pagination — reintroduce it.
```typescript
async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
const tables = await discoverTableIds();
const rows = await fetchNocoDBTable<NocoDBDailySale>(tables['DailySales']);
return rows.map(row => ({
date: row.Date,
museum_name: row.MuseumName,
channel: row.Channel,
visits: row.Visits,
tickets: row.Tickets,
revenue_gross: row.GrossRevenue,
revenue_net: row.NetRevenue,
year: row.Date.substring(0, 4),
quarter: computeQuarter(row.Date),
}));
}
```
Add a `NocoDBDailySale` type to `src/types/index.ts`:
```typescript
export interface NocoDBDailySale {
Id: number;
Date: string;
MuseumName: string;
Channel: string;
Visits: number;
Tickets: number;
GrossRevenue: number;
NetRevenue: number;
}
```
No `Districts`, `Museums`, or `DailyStats` tables needed — just `DailySales` and `PilgrimStats`.
### Suspicious data check
In `fetchData()`, if NocoDB returns fewer than 10 rows and a cache exists, prefer the cache:
```typescript
if (data.length < 10 && cached) {
console.warn('NocoDB returned suspiciously few rows, using cache');
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp };
}
```
## Server Environment
Add to `server/.env`:
```
NOCODB_URL=http://localhost:8090
NOCODB_TOKEN=<token>
NOCODB_BASE_ID=<base_id>
ETL_SECRET=<random-secret-for-cron>
```
**Note:** Client `.env.local` retains its existing `VITE_NOCODB_*` vars — the client still reads NocoDB directly for both DailySales and PilgrimStats.
Update `server/.env.example` with the same keys (placeholder values).
## Server-Side Types
ERP types are re-declared in `server/src/types.ts` (not imported from the client `src/types/index.ts`):
```typescript
export interface ERPProduct {
ProductDescription: string;
SiteDescription: string | null;
UnitQuantity: number;
PeopleCount: number;
TaxAmount: number;
TotalPrice: number;
}
export interface ERPSaleRecord {
SaleId: number;
TransactionDate: string;
CustIdentification: string;
OperatingAreaName: string;
Payments: Array<{ PaymentMethodDescription: string }>;
Products: ERPProduct[];
}
export interface AggregatedRecord {
Date: string;
MuseumName: string;
Channel: string;
Visits: number;
Tickets: number;
GrossRevenue: number;
NetRevenue: number;
}
```
## Cron
```bash
0 2 * * * curl -s -H "Authorization: Bearer $ETL_SECRET" -X POST http://localhost:3002/api/etl/sync
```
Runs daily at 2am. The incremental mode fetches only the current month (~15-25K transactions), aggregates server-side, and writes ~300-500 rows to NocoDB.
## What's NOT Changing
- PilgrimStats still fetched from NocoDB by the client (unchanged)
- Client `.env.local` retains `VITE_NOCODB_*` vars (still needed for client reads)
- All dashboard UI components (Dashboard, Comparison) stay as-is
- Channel and museum filters stay as-is
- Cache/offline fallback logic stays as-is (enhanced with suspicious-data check)
- Dark mode, i18n, accessibility — all unchanged

295
package-lock.json generated
View File

@@ -28,6 +28,7 @@
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.4",
"concurrently": "^9.2.1",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
@@ -1426,6 +1427,22 @@
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-query": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
@@ -1512,6 +1529,36 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
@@ -1533,6 +1580,66 @@
"chart.js": ">=3.0.0"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1608,6 +1715,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -1703,6 +1817,26 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
@@ -1737,6 +1871,16 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2084,6 +2228,16 @@
"node": ">=8"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -2129,6 +2283,16 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -2157,6 +2321,19 @@
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2167,6 +2344,34 @@
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -2179,6 +2384,22 @@
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
@@ -2205,6 +2426,23 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2353,12 +2591,69 @@
"integrity": "sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==",
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

View File

@@ -18,6 +18,9 @@
"web-vitals": "^2.1.4"
},
"scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
"dev:client": "vite",
"dev:server": "cd server && npm run dev",
"start": "vite",
"build": "vite build",
"preview": "vite preview"
@@ -28,6 +31,7 @@
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.4",
"concurrently": "^9.2.1",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}

View File

@@ -1,11 +1,16 @@
# Salla OAuth Credentials (from Salla Partners dashboard)
SALLA_CLIENT_ID=your_client_id_here
SALLA_CLIENT_SECRET=your_client_secret_here
SALLA_REDIRECT_URI=http://localhost:3001/auth/callback
# Server
SERVER_PORT=3002
# Server port
SALLA_SERVER_PORT=3001
# Hono ERP API (museum sales data)
ERP_API_URL=https://hono-erp.azurewebsites.net
ERP_API_CODE=your-api-function-key
ERP_USERNAME=your-username
ERP_PASSWORD=your-password
# After OAuth, these will be populated automatically
# SALLA_ACCESS_TOKEN=
# SALLA_REFRESH_TOKEN=
# NocoDB (for ETL writes)
NOCODB_URL=http://localhost:8090
NOCODB_TOKEN=your-token
NOCODB_BASE_ID=your-base-id
# ETL sync secret (for cron auth)
ETL_SECRET=your-secret-here

View File

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

701
server/package-lock.json generated
View File

@@ -1,17 +1,593 @@
{
"name": "hihala-salla-server",
"name": "hihala-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hihala-salla-server",
"name": "hihala-server",
"version": "1.0.0",
"dependencies": {
"axios": "^1.6.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"tsx": "^4.19.0",
"typescript": "^5.9.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/express": {
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "^1"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.19.8",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
"@types/send": "<1"
}
},
"node_modules/@types/serve-static/node_modules/@types/send": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"node_modules/accepts": {
@@ -300,6 +876,48 @@
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -433,6 +1051,21 @@
"node": ">= 0.6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -479,6 +1112,19 @@
"node": ">= 0.4"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -767,6 +1413,16 @@
"node": ">= 0.8"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -934,6 +1590,26 @@
"node": ">=0.6"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -947,6 +1623,27 @@
"node": ">= 0.6"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View File

@@ -1,16 +1,23 @@
{
"name": "hihala-salla-server",
"name": "hihala-server",
"version": "1.0.0",
"description": "Backend server for Salla OAuth and API integration",
"main": "index.js",
"type": "module",
"description": "Backend server for ERP proxy and Salla integration",
"main": "src/index.ts",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
"start": "tsx src/index.ts",
"dev": "tsx watch src/index.ts"
},
"dependencies": {
"axios": "^1.6.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"tsx": "^4.19.0",
"typescript": "^5.9.3"
}
}

35
server/src/config.ts Normal file
View File

@@ -0,0 +1,35 @@
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: resolve(__dirname, '..', '.env') });
export const server = {
port: parseInt(process.env.SERVER_PORT || '3002', 10),
};
export const salla = {
clientId: process.env.SALLA_CLIENT_ID || '',
clientSecret: process.env.SALLA_CLIENT_SECRET || '',
redirectUri: process.env.SALLA_REDIRECT_URI || 'http://localhost:3001/auth/callback',
accessToken: process.env.SALLA_ACCESS_TOKEN || '',
refreshToken: process.env.SALLA_REFRESH_TOKEN || '',
};
export const erp = {
apiUrl: process.env.ERP_API_URL || '',
apiCode: process.env.ERP_API_CODE || '',
username: process.env.ERP_USERNAME || '',
password: process.env.ERP_PASSWORD || '',
};
export const nocodb = {
url: process.env.NOCODB_URL || '',
token: process.env.NOCODB_TOKEN || '',
baseId: process.env.NOCODB_BASE_ID || '',
};
export const etl = {
secret: process.env.ETL_SECRET || '',
};

View File

@@ -0,0 +1,70 @@
// Definitive mapping of ERP product descriptions to museum names.
// Priority order matters — first match wins (handles combo tickets).
// Combo tickets matching multiple museums split revenue/visits 50/50.
const MUSEUM_KEYWORDS: [string, string[]][] = [
['Revelation Exhibition', ['Revelation', 'الوحي']],
['Creation Story Museum', ['Creation Story', 'قصة الخلق']],
['Holy Quraan Museum', ['Holy Quraan', 'القرآن الكريم']],
['Trail To Hira Cave', ['Trail To Hira', 'غار حراء']],
['Makkah Greets Us', ['Makkah Greets']],
['Best of Creation', ['Best of Creation', 'خير الخلق']],
['VIP Experience', ['VIP Experience']],
];
export const MUSEUM_NAMES = MUSEUM_KEYWORDS.map(([name]) => name);
export interface MuseumMatch {
museums: string[];
split: number; // 1 = full, 0.5 = split between 2 museums
}
export function getMuseumsFromProduct(productDescription: string): MuseumMatch {
const desc = productDescription.trim();
const matched: string[] = [];
for (const [museum, keywords] of MUSEUM_KEYWORDS) {
for (const kw of keywords) {
if (desc.includes(kw)) {
matched.push(museum);
break;
}
}
}
if (matched.length === 0) return { museums: ['Other'], split: 1 };
if (matched.length === 1) return { museums: matched, split: 1 };
// Multiple museums matched — split evenly
return { museums: matched, split: 1 / matched.length };
}
// Static museum → district mapping
const MUSEUM_DISTRICT: Record<string, string> = {
'Revelation Exhibition': 'Hiraa',
'Holy Quraan Museum': 'Hiraa',
'Trail To Hira Cave': 'Hiraa',
'Makkah Greets Us': 'Hiraa',
'VIP Experience': 'Hiraa',
'Creation Story Museum': 'AsSaffiyah',
'Best of Creation': 'AsSaffiyah',
};
export function getDistrict(museumName: string): string {
return MUSEUM_DISTRICT[museumName] || 'Other';
}
export const CHANNEL_LABELS: Record<string, string> = {
'B2C': 'HiHala Website/App',
'B2B': 'B2B',
'POS': 'POS',
'Safiyyah POS': 'Safiyyah POS',
'Standalone': 'Standalone',
'Mobile': 'Mobile',
'Viva': 'Viva',
'IT': 'IT',
};
export function getChannelLabel(operatingAreaName: string): string {
return CHANNEL_LABELS[operatingAreaName] || operatingAreaName;
}

30
server/src/index.ts Normal file
View File

@@ -0,0 +1,30 @@
import express from 'express';
import cors from 'cors';
import { server, erp, nocodb } from './config';
import erpRoutes from './routes/erp';
import etlRoutes from './routes/etl';
const app = express();
app.use(cors());
app.use(express.json());
// Mount routes
app.use('/api/erp', erpRoutes);
app.use('/api/etl', etlRoutes);
app.listen(server.port, () => {
console.log(`\nServer running on http://localhost:${server.port}`);
if (erp.apiUrl && erp.username) {
console.log(' ERP: configured');
} else {
console.log(' ERP: WARNING — not configured');
}
if (nocodb.url && nocodb.token) {
console.log(' NocoDB: configured');
console.log(' POST /api/etl/sync?mode=full|incremental');
} else {
console.log(' NocoDB: WARNING — not configured');
}
});

34
server/src/routes/erp.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Router, Request, Response } from 'express';
import { fetchSales, isConfigured } from '../services/erpClient';
const router = Router();
// GET /api/erp/sales?startDate=2025-01-01T00:00:00&endDate=2025-01-31T00:00:00
router.get('/sales', async (req: Request, res: Response) => {
if (!isConfigured()) {
res.status(503).json({ error: 'ERP API not configured on server' });
return;
}
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
res.status(400).json({ error: 'startDate and endDate query params required' });
return;
}
try {
const data = await fetchSales(startDate as string, endDate as string);
res.json(data);
} catch (err) {
console.error('ERP fetch error:', (err as Error).message);
res.status(502).json({ error: 'Failed to fetch from ERP API', details: (err as Error).message });
}
});
// GET /api/erp/status
router.get('/status', (_req: Request, res: Response) => {
res.json({ configured: isConfigured() });
});
export default router;

34
server/src/routes/etl.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Router, Request, Response } from 'express';
import { etl } from '../config';
import { runSync } from '../services/etlSync';
const router = Router();
// POST /api/etl/sync?mode=full|incremental
router.post('/sync', async (req: Request, res: Response) => {
// Auth check
const auth = req.headers.authorization;
if (etl.secret && auth !== `Bearer ${etl.secret}`) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const mode = (req.query.mode as string) === 'full' ? 'full' : 'incremental';
try {
console.log(`\nETL sync started (${mode})...`);
const result = await runSync(mode);
console.log(`ETL sync complete: ${result.transactionsFetched} transactions → ${result.recordsWritten} records in ${result.duration}`);
res.json(result);
} catch (err) {
console.error('ETL sync failed:', (err as Error).message);
res.status(500).json({ error: 'ETL sync failed', details: (err as Error).message });
}
});
// GET /api/etl/status
router.get('/status', (_req: Request, res: Response) => {
res.json({ configured: !!etl.secret });
});
export default router;

160
server/src/routes/salla.ts Normal file
View File

@@ -0,0 +1,160 @@
import { Router, Request, Response } from 'express';
import crypto from 'crypto';
import axios from 'axios';
import { salla } from '../config';
import { getAuthStatus, setTokens, callSallaAPI } from '../services/sallaClient';
const router = Router();
let oauthState: string | null = null;
// OAuth: redirect to Salla authorization
router.get('/auth/login', (_req: Request, res: Response) => {
oauthState = crypto.randomBytes(16).toString('hex');
const authUrl =
`https://accounts.salla.sa/oauth2/auth?` +
`client_id=${salla.clientId}` +
`&redirect_uri=${encodeURIComponent(salla.redirectUri)}` +
`&response_type=code` +
`&scope=offline_access` +
`&state=${oauthState}`;
res.redirect(authUrl);
});
// OAuth: handle callback
router.get('/auth/callback', async (req: Request, res: Response) => {
const { code, error, state } = req.query;
if (error) {
res.status(400).json({ error: 'Authorization denied', details: error });
return;
}
if (!code) {
res.status(400).json({ error: 'No authorization code received' });
return;
}
if (oauthState && state && state !== oauthState) {
res.status(400).json({ error: 'Invalid state parameter' });
return;
}
try {
const response = await axios.post('https://accounts.salla.sa/oauth2/token', {
client_id: salla.clientId,
client_secret: salla.clientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: salla.redirectUri,
});
setTokens(response.data.access_token, response.data.refresh_token);
console.log('\n========================================');
console.log('SALLA CONNECTED SUCCESSFULLY!');
console.log('========================================');
console.log('Add these to your .env file:');
console.log(`SALLA_ACCESS_TOKEN=${response.data.access_token}`);
console.log(`SALLA_REFRESH_TOKEN=${response.data.refresh_token}`);
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>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
</html>
`);
} catch (err: unknown) {
const axiosErr = err as { response?: { data: unknown }; message: string };
console.error('Token exchange failed:', axiosErr.response?.data || axiosErr.message);
res.status(500).json({ error: 'Token exchange failed', details: axiosErr.response?.data });
}
});
// Auth status
router.get('/auth/status', (_req: Request, res: Response) => {
res.json(getAuthStatus());
});
// Salla API proxy endpoints
router.get('/api/store', async (_req: Request, res: Response) => {
try {
const data = await callSallaAPI('/store/info');
res.json(data);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get('/api/orders', async (req: Request, res: Response) => {
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 as Error).message });
}
});
router.get('/api/orders/:id', async (req: Request, res: Response) => {
try {
const data = await callSallaAPI(`/orders/${req.params.id}`);
res.json(data);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get('/api/products', async (req: Request, res: Response) => {
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 as Error).message });
}
});
router.get('/api/customers', async (req: Request, res: Response) => {
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 as Error).message });
}
});
router.get('/api/analytics/summary', async (_req: Request, res: Response) => {
try {
const [orders, products] = await Promise.all([
callSallaAPI('/orders?per_page=100') as Promise<{ data?: Array<{ amounts?: { total?: { amount?: number; currency?: string } } }>; pagination?: { total?: number } }>,
callSallaAPI('/products?per_page=100') as Promise<{ data?: unknown[]; pagination?: { total?: number } }>,
]);
const ordersList = orders.data || [];
const totalRevenue = ordersList.reduce((sum: number, 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 as Error).message });
}
});
export default router;

View File

@@ -0,0 +1,62 @@
import { erp } from '../config';
let cachedToken: string | null = null;
async function login(): Promise<string> {
const res = await fetch(`${erp.apiUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: erp.username, password: erp.password }),
});
if (!res.ok) {
throw new Error(`ERP login failed: ${res.status} ${res.statusText}`);
}
const data = await res.json();
cachedToken = data.token;
return cachedToken;
}
async function getToken(): Promise<string> {
if (cachedToken) return cachedToken;
return login();
}
export async function fetchSales(startDate: string, endDate: string): Promise<unknown[]> {
const token = await getToken();
const url = new URL(`${erp.apiUrl}/api/getbydate`);
url.searchParams.set('startDate', startDate);
url.searchParams.set('endDate', endDate);
url.searchParams.set('code', erp.apiCode);
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${token}` },
});
// Token expired — re-login and retry once
if (res.status === 401) {
cachedToken = null;
const freshToken = await login();
const retry = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${freshToken}` },
});
if (!retry.ok) {
throw new Error(`ERP fetch failed after re-login: ${retry.status}`);
}
return retry.json();
}
if (!res.ok) {
throw new Error(`ERP fetch failed: ${res.status} ${res.statusText}`);
}
return res.json();
}
export function isConfigured(): boolean {
return !!(erp.apiUrl && erp.apiCode && erp.username && erp.password);
}

View File

@@ -0,0 +1,145 @@
import { fetchSales } from './erpClient';
import { discoverTableIds, deleteRowsByMonth, deleteAllRows, insertRecords } from './nocodbClient';
import { getMuseumsFromProduct, getChannelLabel, getDistrict } from '../config/museumMapping';
import type { ERPSaleRecord, AggregatedRecord } 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;
}
function currentMonthBoundary(): [string, string] {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
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`;
return [start, end];
}
export function aggregateTransactions(sales: ERPSaleRecord[]): AggregatedRecord[] {
const map = new Map<string, AggregatedRecord>();
for (const sale of sales) {
const date = sale.TransactionDate.split(' ')[0];
const rawChannel = sale.OperatingAreaName;
const channel = getChannelLabel(rawChannel);
// B2C: each ticket = one visitor (1 PDF per person)
// Other channels: PeopleCount = actual visitors (group tickets)
const isB2C = rawChannel === 'B2C';
for (const product of sale.Products) {
const { museums, split } = getMuseumsFromProduct(product.ProductDescription);
const isCombo = museums.length > 1;
for (const museum of museums) {
const comboWith = isCombo
? museums.filter(m => m !== museum).join(', ')
: '';
const ticketType = isCombo ? 'combo' : 'single';
const district = getDistrict(museum);
const key = `${date}|${museum}|${channel}|${ticketType}`;
let entry = map.get(key);
if (!entry) {
entry = {
Date: date,
District: district,
MuseumName: museum,
Channel: channel,
TicketType: ticketType,
ComboMuseums: museums.length,
ComboWith: comboWith,
Visits: 0,
Tickets: 0,
GrossRevenue: 0,
NetRevenue: 0,
};
map.set(key, entry);
}
const visitors = isB2C ? product.UnitQuantity : product.PeopleCount;
entry.Visits += visitors * split;
entry.Tickets += product.UnitQuantity * split;
entry.GrossRevenue += product.TotalPrice * split;
entry.NetRevenue += (product.TotalPrice - product.TaxAmount) * split;
}
}
}
return Array.from(map.values());
}
export interface SyncResult {
status: string;
mode: string;
transactionsFetched: number;
recordsWritten: number;
duration: string;
}
export async function runSync(mode: 'full' | 'incremental' = 'incremental'): Promise<SyncResult> {
const start = Date.now();
const tables = await discoverTableIds();
const tableId = tables['DailySales'];
if (!tableId) throw new Error("NocoDB table 'DailySales' not found");
let months: Array<[string, string]>;
if (mode === 'full') {
months = generateMonthBoundaries(2024, 1);
} else {
months = [currentMonthBoundary()];
}
// Fetch from ERP sequentially (API can't handle concurrent requests)
const allSales: ERPSaleRecord[] = [];
for (const [startDate, endDate] of months) {
console.log(` Fetching ${startDate.slice(0, 7)}...`);
const chunk = await fetchSales(startDate, endDate) as ERPSaleRecord[];
allSales.push(...chunk);
}
const records = aggregateTransactions(allSales);
// Write to NocoDB
if (mode === 'full') {
console.log(' Clearing all DailySales rows...');
await deleteAllRows(tableId);
} else {
const yearMonth = months[0][0].slice(0, 7);
console.log(` Clearing ${yearMonth} rows...`);
await deleteRowsByMonth(tableId, yearMonth);
}
console.log(` Inserting ${records.length} records...`);
const written = await insertRecords(tableId, records);
const duration = ((Date.now() - start) / 1000).toFixed(1) + 's';
return {
status: 'ok',
mode,
transactionsFetched: allSales.length,
recordsWritten: written,
duration,
};
}

View File

@@ -0,0 +1,109 @@
import { nocodb } from '../config';
import type { AggregatedRecord } from '../types';
let discoveredTables: Record<string, string> | null = null;
async function fetchJson(url: string, options: RequestInit = {}): Promise<unknown> {
const res = await fetch(url, {
...options,
headers: {
'xc-token': nocodb.token,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`NocoDB ${res.status}: ${text.slice(0, 200)}`);
}
return res.json();
}
export async function discoverTableIds(): Promise<Record<string, string>> {
if (discoveredTables) return discoveredTables;
if (!nocodb.baseId) throw new Error('NOCODB_BASE_ID not configured');
const json = await fetchJson(
`${nocodb.url}/api/v2/meta/bases/${nocodb.baseId}/tables`
) as { list: Array<{ title: string; id: string }> };
const tables: Record<string, string> = {};
for (const t of json.list) {
tables[t.title] = t.id;
}
discoveredTables = tables;
return tables;
}
export async function deleteRowsByMonth(tableId: string, yearMonth: string): Promise<number> {
// Fetch all row IDs for the given month using a where filter
const where = `(Date,like,${yearMonth}%)`;
let deleted = 0;
let hasMore = true;
while (hasMore) {
const json = await fetchJson(
`${nocodb.url}/api/v2/tables/${tableId}/records?where=${encodeURIComponent(where)}&limit=100&fields=Id`
) as { list: Array<{ Id: number }> };
const ids = json.list.map(r => r.Id);
if (ids.length === 0) {
hasMore = false;
break;
}
// Bulk delete
await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
method: 'DELETE',
body: JSON.stringify(ids.map(Id => ({ Id }))),
});
deleted += ids.length;
}
return deleted;
}
export async function deleteAllRows(tableId: string): Promise<number> {
let deleted = 0;
let hasMore = true;
while (hasMore) {
const json = await fetchJson(
`${nocodb.url}/api/v2/tables/${tableId}/records?limit=100&fields=Id`
) as { list: Array<{ Id: number }> };
const ids = json.list.map(r => r.Id);
if (ids.length === 0) {
hasMore = false;
break;
}
await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
method: 'DELETE',
body: JSON.stringify(ids.map(Id => ({ Id }))),
});
deleted += ids.length;
}
return deleted;
}
export async function insertRecords(tableId: string, records: AggregatedRecord[]): Promise<number> {
// NocoDB bulk insert accepts max 100 records at a time
const batchSize = 100;
let inserted = 0;
for (let i = 0; i < records.length; i += batchSize) {
const batch = records.slice(i, i + batchSize);
await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
method: 'POST',
body: JSON.stringify(batch),
});
inserted += batch.length;
}
return inserted;
}

View File

@@ -0,0 +1,60 @@
import axios from 'axios';
import { salla } from '../config';
let accessToken = salla.accessToken || null;
let refreshToken = salla.refreshToken || null;
export function getAuthStatus() {
return { connected: !!accessToken, hasRefreshToken: !!refreshToken };
}
export function setTokens(access: string, refresh?: string) {
accessToken = access;
if (refresh) refreshToken = refresh;
}
export async function refreshAccessToken(): Promise<string> {
if (!refreshToken) throw new Error('No refresh token available');
const response = await axios.post('https://accounts.salla.sa/oauth2/token', {
client_id: salla.clientId,
client_secret: salla.clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
accessToken = response.data.access_token;
if (response.data.refresh_token) {
refreshToken = response.data.refresh_token;
}
return accessToken!;
}
export async function callSallaAPI(
endpoint: string,
method: 'GET' | 'POST' = 'GET',
data: unknown = null
): Promise<unknown> {
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: unknown) {
const axiosErr = err as { response?: { status: number } };
if (axiosErr.response?.status === 401) {
await refreshAccessToken();
return callSallaAPI(endpoint, method, data);
}
throw err;
}
}

31
server/src/types.ts Normal file
View File

@@ -0,0 +1,31 @@
export interface ERPProduct {
ProductDescription: string;
SiteDescription: string | null;
UnitQuantity: number;
PeopleCount: number;
TaxAmount: number;
TotalPrice: number;
}
export interface ERPSaleRecord {
SaleId: number;
TransactionDate: string;
CustIdentification: string;
OperatingAreaName: string;
Payments: Array<{ PaymentMethodDescription: string }>;
Products: ERPProduct[];
}
export interface AggregatedRecord {
Date: string;
District: string;
MuseumName: string;
Channel: string;
TicketType: 'single' | 'combo';
ComboMuseums: number;
ComboWith: string;
Visits: number;
Tickets: number;
GrossRevenue: number;
NetRevenue: number;
}

13
server/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src"]
}

View File

@@ -3,7 +3,6 @@ import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react
const Dashboard = lazy(() => import('./components/Dashboard'));
const Comparison = lazy(() => import('./components/Comparison'));
const Slides = lazy(() => import('./components/Slides'));
import LoadingSkeleton from './components/shared/LoadingSkeleton';
import { fetchData, getCacheStatus, refreshData } from './services/dataService';
import { useLanguage } from './contexts/LanguageContext';
@@ -240,7 +239,6 @@ function App() {
<Routes>
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
<Route path="/slides" element={<Slides data={data} />} />
</Routes>
</Suspense>
</main>
@@ -264,14 +262,6 @@ function App() {
</svg>
<span>{t('nav.compare')}</span>
</NavLink>
<NavLink to="/slides" className="mobile-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<span>{t('nav.slides')}</span>
</NavLink>
<button
className="mobile-nav-item"
onClick={switchLanguage}

View File

@@ -11,8 +11,9 @@ import {
formatCompact,
formatCompactCurrency,
umrahData,
getUniqueChannels,
getUniqueMuseums,
getUniqueDistricts,
getDistrictMuseumMap,
getMuseumsForDistrict,
getLatestYear
} from '../services/dataService';
@@ -108,6 +109,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
});
const [filters, setFiltersState] = useState(() => ({
district: searchParams.get('district') || 'all',
channel: searchParams.get('channel') || 'all',
museum: searchParams.get('museum') || 'all'
}));
@@ -126,6 +128,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
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 +212,19 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
if (metric === 'avgRevenue') {
const revenue = rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[revenueField] || r.revenue_incl_tax || 0)), 0);
const revenue = rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[revenueField] || 0)), 0);
const visitors = rows.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
return visitors > 0 ? revenue / visitors : 0;
}
const fieldMap: Record<string, string> = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' };
const field = fieldMap[metric];
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[field] || r.revenue_incl_tax || 0)), 0);
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[field] || 0)), 0);
}, [revenueField]);
// Dynamic lists from data
const channels = useMemo(() => getUniqueChannels(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 availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
// Year-over-year comparison: same dates, previous year
const ranges = useMemo(() => ({
@@ -246,7 +249,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({ district: 'all', channel: 'all', museum: 'all' });
const calcChange = (prev: number, curr: number) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
@@ -583,6 +586,12 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
{districts.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</FilterControls.Group>
<FilterControls.Group label={t('filters.channel')}>
<select value={filters.channel} onChange={e => setFilters({...filters, channel: e.target.value})}>
<option value="all">{t('filters.allChannels')}</option>
{channels.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</FilterControls.Group>
<FilterControls.Group label={t('filters.museum')}>
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
<option value="all">{t('filters.allMuseums')}</option>

View File

@@ -12,24 +12,27 @@ import {
formatNumber,
groupByWeek,
groupByMuseum,
groupByDistrict,
groupByChannel,
umrahData,
fetchPilgrimStats,
getUniqueYears,
getUniqueChannels,
getUniqueMuseums,
getUniqueDistricts,
getDistrictMuseumMap,
getMuseumsForDistrict
getMuseumsForDistrict,
groupByDistrict
} 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', 'district', 'channel', 'museum', 'quarter'];
function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
const { t } = useLanguage();
@@ -85,15 +88,15 @@ 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.district'), 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(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
const yoyChange = useMemo(() => {
if (filters.year === 'all') return null;
@@ -167,7 +170,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
filteredData.forEach(row => {
const date = row.date;
if (!dailyData[date]) dailyData[date] = 0;
dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || row.revenue_incl_tax || 0);
dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || 0);
});
const days = Object.keys(dailyData).sort();
const revenueValues = days.map(d => dailyData[d]);
@@ -212,15 +215,29 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
};
}, [filteredData, includeVAT]);
// Channel data
const channelData = useMemo(() => {
const grouped = groupByChannel(filteredData, includeVAT);
const channels = Object.keys(grouped);
return {
labels: channels,
datasets: [{
data: channels.map(d => grouped[d].revenue),
backgroundColor: [chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
borderRadius: 4
}]
};
}, [filteredData, includeVAT]);
// District data
const districtData = useMemo(() => {
const grouped = groupByDistrict(filteredData, includeVAT);
const districts = Object.keys(grouped);
const districtNames = Object.keys(grouped);
return {
labels: districts,
labels: districtNames,
datasets: [{
data: districts.map(d => grouped[d].revenue),
backgroundColor: [chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
data: districtNames.map(d => grouped[d].revenue),
backgroundColor: [chartColors.primary + 'cc', chartColors.secondary + 'cc', chartColors.tertiary + 'cc', chartColors.muted + 'cc'],
borderRadius: 4
}]
};
@@ -237,13 +254,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
}
@@ -262,6 +279,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
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 +343,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
}
]
};
}, [data, filters.district, filters.museum, showDataLabels]);
}, [data, filters.district, filters.channel, filters.museum, showDataLabels]);
// Quarterly table
const quarterlyTable = useMemo(() => {
@@ -339,12 +357,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
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 +375,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.district, filters.channel, filters.museum, includeVAT]);
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
@@ -396,6 +418,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
{districts.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</FilterControls.Group>
<FilterControls.Group label={t('filters.channel')}>
<select value={filters.channel} onChange={e => setFilters({...filters, channel: e.target.value})}>
<option value="all">{t('filters.allChannels')}</option>
{channels.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</FilterControls.Group>
<FilterControls.Group label={t('filters.museum')}>
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
<option value="all">{t('filters.allMuseums')}</option>
@@ -530,6 +558,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
</ExportableChart>
</div>
<div className="chart-card half-width">
<ExportableChart filename="channel-performance" title={t('dashboard.channelPerformance')} className="chart-container">
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
</ExportableChart>
</div>
<div className="chart-card half-width">
<ExportableChart filename="district-performance" title={t('dashboard.districtPerformance')} className="chart-container">
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
@@ -631,6 +665,15 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
</div>
</div>
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.channelPerformance')}</h2>
<div className="chart-container">
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
</div>
</div>
</div>
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.districtPerformance')}</h2>

View File

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

View File

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

View File

@@ -34,10 +34,12 @@
"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 +58,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",
@@ -65,6 +67,7 @@
"revenueByMuseum": "Revenue by Museum",
"quarterlyRevenue": "Quarterly Revenue (YoY)",
"districtPerformance": "District Performance",
"channelPerformance": "Channel Performance",
"captureRateChart": "Capture Rate vs Umrah Pilgrims"
},
"table": {
@@ -147,10 +150,11 @@
"revenue": "Revenue",
"quarterly": "Quarterly",
"district": "District",
"channel": "Channel",
"captureRate": "Capture Rate"
},
"errors": {
"config": "The dashboard is not configured. Please set up the NocoDB connection.",
"config": "The dashboard is not configured. Please set up the ERP API connection.",
"network": "Cannot reach the database server. Please check your internet connection.",
"auth": "Access denied. The API token may be invalid or expired.",
"timeout": "The database server is taking too long to respond. Please try again.",

View File

@@ -1,4 +1,4 @@
// Data source: NocoDB only
// Data source: NocoDB (DailySales populated by server-side ETL, PilgrimStats)
// Offline mode: caches data to localStorage for resilience
import type {
@@ -10,69 +10,23 @@ import type {
CacheResult,
FetchResult,
GroupedData,
DistrictMuseumMap,
UmrahData,
NocoDBDistrict,
NocoDBMuseum,
NocoDBDailyStat,
NocoDBDailySale,
DataErrorType
} from '../types';
import { DataError } from '../types';
import { fetchWithRetry } from '../utils/fetchHelpers';
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 FETCH_TIMEOUT_MS = 10_000;
const MAX_RETRIES = 3;
const VAT_RATE = 1.15;
// Table IDs discovered dynamically from NocoDB meta API
let discoveredTables: Record<string, string> | null = null;
// ============================================
// Fetch Helpers (timeout + retry)
// ============================================
async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs: number = FETCH_TIMEOUT_MS): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { ...options, signal: controller.signal });
return res;
} catch (err) {
if ((err as Error).name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
async function fetchWithRetry(url: string, options: RequestInit = {}, retries: number = MAX_RETRIES): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < retries; attempt++) {
try {
const res = await fetchWithTimeout(url, options);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res;
} catch (err) {
lastError = err as Error;
if (attempt < retries - 1) {
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
console.warn(`Fetch attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
}
throw lastError;
}
async function discoverTableIds(): Promise<Record<string, string>> {
if (discoveredTables) return discoveredTables;
if (!NOCODB_BASE_ID) throw new Error('VITE_NOCODB_BASE_ID not configured');
if (!NOCODB_BASE_ID) throw new Error('NocoDB not configured');
const res = await fetchWithRetry(
`${NOCODB_URL}/api/v2/meta/bases/${NOCODB_BASE_ID}/tables`,
@@ -85,20 +39,35 @@ async function discoverTableIds(): Promise<Record<string, string>> {
tables[t.title] = t.id;
}
const required = ['Districts', 'Museums', 'DailyStats'];
for (const name of required) {
if (!tables[name]) throw new Error(`Required table '${name}' not found in NocoDB base`);
discoveredTables = tables;
return tables;
}
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;
}
discoveredTables = tables;
console.log('Discovered NocoDB tables:', Object.keys(tables).map(k => `${k}=${tables[k]}`).join(', '));
return tables;
return allRecords;
}
// 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 = {
@@ -106,7 +75,6 @@ export let umrahData: UmrahData = {
2025: { 1: 15222497, 2: 5443393, 3: 26643148, 4: 31591871 }
};
// Fetch pilgrim stats from NocoDB and update umrahData
export async function fetchPilgrimStats(): Promise<UmrahData> {
try {
const tables = await discoverTableIds();
@@ -118,11 +86,11 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
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) {
@@ -130,8 +98,7 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
data[year][qNum] = total;
}
}
// Update the global umrahData
umrahData = data;
console.log('PilgrimStats loaded from NocoDB:', data);
return data;
@@ -141,6 +108,42 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
}
}
// ============================================
// NocoDB DailySales Fetching
// ============================================
async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
console.log('Fetching from NocoDB DailySales...');
const tables = await discoverTableIds();
if (!tables['DailySales']) throw new Error("NocoDB table 'DailySales' not found — run ETL sync first");
const rows = await fetchNocoDBTable<NocoDBDailySale>(tables['DailySales']);
const data: MuseumRecord[] = rows.map(row => {
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';
return {
date,
district: row.District,
museum_name: row.MuseumName,
channel: row.Channel,
visits: row.Visits,
tickets: row.Tickets,
revenue_gross: row.GrossRevenue,
revenue_net: row.NetRevenue,
year,
quarter,
};
}).filter(r => r.date && r.museum_name);
console.log(`Loaded ${data.length} rows from NocoDB DailySales`);
return data;
}
// ============================================
// Offline Cache Functions
// ============================================
@@ -159,15 +162,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);
@@ -178,14 +181,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(),
@@ -201,93 +204,6 @@ export function clearCache(): void {
console.log('Cache cleared');
}
// ============================================
// NocoDB Data Fetching
// ============================================
async function fetchNocoDBTable<T>(tableId: string, limit: number = 1000): Promise<T[]> {
let allRecords: T[] = [];
let offset = 0;
while (true) {
const response = await fetchWithRetry(
`${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`,
{ headers: { 'xc-token': NOCODB_TOKEN } }
);
const json = await response.json();
const records: T[] = json.list || [];
allRecords = allRecords.concat(records);
if (records.length < limit) break;
offset += limit;
}
return allRecords;
}
interface MuseumMapEntry {
code: string;
name: string;
district: string;
}
async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
console.log('Fetching from NocoDB...');
const tables = await discoverTableIds();
// Fetch all three tables in parallel
const [districts, museums, dailyStats] = await Promise.all([
fetchNocoDBTable<NocoDBDistrict>(tables['Districts']),
fetchNocoDBTable<NocoDBMuseum>(tables['Museums']),
fetchNocoDBTable<NocoDBDailyStat>(tables['DailyStats'])
]);
// Build lookup maps
const districtMap: Record<number, string> = {};
districts.forEach(d => { districtMap[d.Id] = d.Name; });
const museumMap: Record<number, MuseumMapEntry> = {};
museums.forEach(m => {
museumMap[m.Id] = {
code: m.Code,
name: m.Name,
district: districtMap[m.DistrictId ?? m['nc_epk____Districts_id'] ?? 0] || 'Unknown'
};
});
// Join data into flat structure
const data: MuseumRecord[] = dailyStats.map(row => {
const museum = museumMap[row.MuseumId ?? row['nc_epk____Museums_id'] ?? 0] || { code: '', name: '', district: '' };
const date = row.Date;
const year = date ? date.substring(0, 4) : '';
const month = date ? parseInt(date.substring(5, 7)) : 0;
const quarter = month <= 3 ? '1' : month <= 6 ? '2' : month <= 9 ? '3' : '4';
// GrossRevenue = including VAT, NetRevenue = excluding VAT
const grossRevenue = row.GrossRevenue || 0;
const netRevenue = row.NetRevenue || (grossRevenue / VAT_RATE);
return {
date: date,
museum_code: museum.code,
museum_name: museum.name,
district: museum.district,
visits: row.Visits,
tickets: row.Tickets,
revenue_gross: grossRevenue,
revenue_net: netRevenue,
revenue_incl_tax: grossRevenue, // Legacy compatibility
year: year,
quarter: quarter
};
}).filter(r => r.date && r.museum_name);
console.log(`Loaded ${data.length} rows from NocoDB`);
return data;
}
// ============================================
// Error Classification
// ============================================
@@ -306,7 +222,6 @@ function classifyError(err: Error): DataErrorType {
// ============================================
export async function fetchData(): Promise<FetchResult> {
// Check if NocoDB is configured
if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) {
const cached = loadFromCache();
if (cached) {
@@ -318,6 +233,14 @@ export async function fetchData(): Promise<FetchResult> {
try {
const data = await fetchFromNocoDB();
// Suspicious data check — prefer cache if NocoDB returns too few rows
const cached = loadFromCache();
if (data.length < 10 && cached && cached.data.length > 10) {
console.warn('NocoDB returned suspiciously few rows, using cache');
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp };
}
saveToCache(data);
return { data, fromCache: false };
} catch (err) {
@@ -334,12 +257,11 @@ export async function fetchData(): Promise<FetchResult> {
}
}
// Force refresh (bypass cache read, but still write to cache)
export async function refreshData(): Promise<FetchResult> {
if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) {
throw new Error('NocoDB not configured');
throw new DataError('NocoDB not configured', 'config');
}
const data = await fetchFromNocoDB();
saveToCache(data);
return { data, fromCache: false };
@@ -353,6 +275,7 @@ 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;
@@ -360,15 +283,16 @@ 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<DateRangeFilters> = {}
): 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;
});
@@ -376,7 +300,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;
@@ -423,17 +347,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}`;
}
@@ -445,7 +369,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;
});
@@ -458,22 +382,22 @@ export function groupByMuseum(data: MuseumRecord[], includeVAT: boolean = true):
data.forEach(row => {
if (!row.museum_name) return;
if (!grouped[row.museum_name]) grouped[row.museum_name] = { revenue: 0, visitors: 0, tickets: 0 };
grouped[row.museum_name].revenue += row[revenueField] || row.revenue_incl_tax || 0;
grouped[row.museum_name].revenue += row[revenueField] || 0;
grouped[row.museum_name].visitors += row.visits || 0;
grouped[row.museum_name].tickets += row.tickets || 0;
});
return grouped;
}
export function groupByDistrict(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
export function groupByChannel(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const grouped: Record<string, GroupedData> = {};
data.forEach(row => {
if (!row.district) return;
if (!grouped[row.district]) grouped[row.district] = { revenue: 0, visitors: 0, tickets: 0 };
grouped[row.district].revenue += row[revenueField] || row.revenue_incl_tax || 0;
grouped[row.district].visitors += row.visits || 0;
grouped[row.district].tickets += row.tickets || 0;
if (!row.channel) return;
if (!grouped[row.channel]) grouped[row.channel] = { revenue: 0, visitors: 0, tickets: 0 };
grouped[row.channel].revenue += row[revenueField] || 0;
grouped[row.channel].visitors += row.visits || 0;
grouped[row.channel].tickets += row.tickets || 0;
});
return grouped;
}
@@ -491,25 +415,30 @@ export function getUniqueDistricts(data: MuseumRecord[]): string[] {
return [...new Set(data.map(r => r.district).filter(Boolean))].sort();
}
export function getDistrictMuseumMap(data: MuseumRecord[]): DistrictMuseumMap {
const map: Record<string, Set<string>> = {};
export function groupByDistrict(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const grouped: Record<string, GroupedData> = {};
data.forEach(row => {
if (!row.district || !row.museum_name) return;
if (!map[row.district]) map[row.district] = new Set();
map[row.district].add(row.museum_name);
if (!row.district) return;
if (!grouped[row.district]) grouped[row.district] = { revenue: 0, visitors: 0, tickets: 0 };
grouped[row.district].revenue += row[revenueField] || 0;
grouped[row.district].visitors += row.visits || 0;
grouped[row.district].tickets += row.tickets || 0;
});
const result: DistrictMuseumMap = {};
Object.keys(map).forEach(d => {
result[d] = [...map[d]].sort();
});
return result;
return grouped;
}
export function getMuseumsForDistrict(districtMuseumMap: DistrictMuseumMap, district: string): string[] {
if (district === 'all') {
return Object.values(districtMuseumMap).flat().sort();
}
return districtMuseumMap[district] || [];
export function getMuseumsForDistrict(data: MuseumRecord[], district: string): string[] {
if (district === 'all') return getUniqueMuseums(data);
return [...new Set(data.filter(r => r.district === district).map(r => r.museum_name).filter(Boolean))].sort();
}
export function getUniqueChannels(data: MuseumRecord[]): string[] {
return [...new Set(data.map(r => r.channel).filter(Boolean))].sort();
}
export function getUniqueMuseums(data: MuseumRecord[]): string[] {
return [...new Set(data.map(r => r.museum_name).filter(Boolean))].sort();
}
export function getLatestYear(data: MuseumRecord[]): string {

View File

@@ -2,14 +2,13 @@
export interface MuseumRecord {
date: string;
museum_code: string;
museum_name: string;
district: string;
museum_name: string;
channel: string;
visits: number;
tickets: number;
revenue_gross: number;
revenue_net: number;
revenue_incl_tax: number; // Legacy field
year: string;
quarter: string;
}
@@ -24,12 +23,14 @@ 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 +70,28 @@ export interface GroupedData {
tickets: number;
}
export interface DistrictMuseumMap {
[district: string]: string[];
}
export interface UmrahData {
[year: number]: {
[quarter: number]: number | null;
};
}
// NocoDB DailySales row (populated by server-side ETL)
export interface NocoDBDailySale {
Id: number;
Date: string;
District: string;
MuseumName: string;
Channel: string;
TicketType: string;
ComboMuseums: number;
ComboWith: string;
Visits: number;
Tickets: number;
GrossRevenue: number;
NetRevenue: number;
}
// Chart data types
export interface ChartDataset {
label?: string;
@@ -149,31 +162,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 +170,7 @@ export interface SlideConfig {
metric: string;
startDate: string;
endDate: string;
district: string;
channel: string;
museum: string;
showComparison: boolean;
}

44
src/utils/fetchHelpers.ts Normal file
View File

@@ -0,0 +1,44 @@
const FETCH_TIMEOUT_MS = 10_000;
const MAX_RETRIES = 3;
export async function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeoutMs: number = FETCH_TIMEOUT_MS
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} catch (err) {
if ((err as Error).name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
export async function fetchWithRetry(
url: string,
options: RequestInit = {},
retries: number = MAX_RETRIES
): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < retries; attempt++) {
try {
const res = await fetchWithTimeout(url, options);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res;
} catch (err) {
lastError = err as Error;
if (attempt < retries - 1) {
const delay = Math.pow(2, attempt) * 1000;
console.warn(`Fetch attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
}
throw lastError;
}

45
start-dev.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Temporary dev script for ERP migration — starts NocoDB + Express server + Vite
set -e
cleanup() {
echo ""
echo "Shutting down..."
kill $SERVER_PID $CLIENT_PID 2>/dev/null
docker stop nocodb 2>/dev/null
echo "Done."
}
trap cleanup EXIT INT TERM
cd "$(dirname "$0")"
# Start NocoDB
if docker ps --format '{{.Names}}' | grep -q '^nocodb$'; then
echo "NocoDB already running on port 8090"
else
echo "Starting NocoDB..."
docker start nocodb 2>/dev/null || docker run -d \
--name nocodb -p 8090:8080 nocodb/nocodb:latest
fi
echo "Waiting for NocoDB..."
for i in $(seq 1 30); do
curl -s http://localhost:8090/api/v1/health >/dev/null 2>&1 && echo "NocoDB ready" && break
sleep 1
done
# Start Express server (port 3002)
echo "Starting Express server..."
(cd server && npm run dev) &
SERVER_PID=$!
sleep 2
# Start Vite (port 3000)
echo "Starting Vite..."
npx vite &
CLIENT_PID=$!
wait $CLIENT_PID

View File

@@ -6,6 +6,10 @@ export default defineConfig({
server: {
port: 3000,
proxy: {
'/api/erp': {
target: 'http://localhost:3002',
changeOrigin: true,
},
'/api': {
target: 'http://localhost:8090',
changeOrigin: true,