Compare commits
14 Commits
802ff28754
...
04789ea9a1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04789ea9a1 | ||
|
|
219680fb5e | ||
|
|
4f4559023b | ||
|
|
1f1e0756d0 | ||
|
|
9c0ffa5721 | ||
|
|
b4f4104e3e | ||
|
|
18821fd560 | ||
|
|
ea71e54058 | ||
|
|
4ed4d83257 | ||
|
|
f6b7d4ba8d | ||
|
|
a84caaa31e | ||
|
|
8bdfc85027 | ||
|
|
e84d961536 | ||
|
|
9c1552e439 |
10
.env.example
10
.env.example
@@ -1,8 +1,4 @@
|
||||
# NocoDB (primary data source)
|
||||
# NocoDB (PilgrimStats only — museum sales come from ERP API via server proxy)
|
||||
VITE_NOCODB_URL=http://localhost:8090
|
||||
VITE_NOCODB_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
|
||||
|
||||
647
docs/superpowers/plans/2026-03-26-erp-api-migration.md
Normal file
647
docs/superpowers/plans/2026-03-26-erp-api-migration.md
Normal 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 3–9 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**
|
||||
242
docs/superpowers/specs/2026-03-26-etl-pipeline-design.md
Normal file
242
docs/superpowers/specs/2026-03-26-etl-pipeline-design.md
Normal 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
295
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
271
server/index.js
271
server/index.js
@@ -1,271 +0,0 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const axios = require('axios');
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
const PORT = process.env.SALLA_SERVER_PORT || 3001;
|
||||
|
||||
// Salla OAuth Config
|
||||
const SALLA_CLIENT_ID = process.env.SALLA_CLIENT_ID;
|
||||
const SALLA_CLIENT_SECRET = process.env.SALLA_CLIENT_SECRET;
|
||||
const SALLA_REDIRECT_URI = process.env.SALLA_REDIRECT_URI || 'http://localhost:3001/auth/callback';
|
||||
|
||||
// Token storage (in production, use a database)
|
||||
let accessToken = process.env.SALLA_ACCESS_TOKEN || null;
|
||||
let refreshToken = process.env.SALLA_REFRESH_TOKEN || null;
|
||||
|
||||
// ============================================
|
||||
// OAuth Endpoints
|
||||
// ============================================
|
||||
|
||||
// State for CSRF protection
|
||||
const crypto = require('crypto');
|
||||
let oauthState = null;
|
||||
|
||||
// Step 1: Redirect to Salla authorization
|
||||
app.get('/auth/login', (req, res) => {
|
||||
oauthState = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
const authUrl = `https://accounts.salla.sa/oauth2/auth?` +
|
||||
`client_id=${SALLA_CLIENT_ID}` +
|
||||
`&redirect_uri=${encodeURIComponent(SALLA_REDIRECT_URI)}` +
|
||||
`&response_type=code` +
|
||||
`&scope=offline_access` +
|
||||
`&state=${oauthState}`;
|
||||
|
||||
res.redirect(authUrl);
|
||||
});
|
||||
|
||||
// Step 2: Handle OAuth callback
|
||||
app.get('/auth/callback', async (req, res) => {
|
||||
const { code, error, state } = req.query;
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({ error: 'Authorization denied', details: error });
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'No authorization code received' });
|
||||
}
|
||||
|
||||
// Verify state (optional check - some flows may not return state)
|
||||
if (oauthState && state && state !== oauthState) {
|
||||
return res.status(400).json({ error: 'Invalid state parameter' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for tokens
|
||||
const response = await axios.post('https://accounts.salla.sa/oauth2/token', {
|
||||
client_id: SALLA_CLIENT_ID,
|
||||
client_secret: SALLA_CLIENT_SECRET,
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: SALLA_REDIRECT_URI
|
||||
}, {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
accessToken = response.data.access_token;
|
||||
refreshToken = response.data.refresh_token;
|
||||
|
||||
// Log tokens (save these to .env for persistence)
|
||||
console.log('\n========================================');
|
||||
console.log('🎉 SALLA CONNECTED SUCCESSFULLY!');
|
||||
console.log('========================================');
|
||||
console.log('Add these to your .env file:');
|
||||
console.log(`SALLA_ACCESS_TOKEN=${accessToken}`);
|
||||
console.log(`SALLA_REFRESH_TOKEN=${refreshToken}`);
|
||||
console.log('========================================\n');
|
||||
|
||||
res.send(`
|
||||
<html>
|
||||
<body style="font-family: Arial; text-align: center; padding: 50px;">
|
||||
<h1>✅ Salla Connected!</h1>
|
||||
<p>Authorization successful. You can close this window.</p>
|
||||
<p>Tokens have been logged to the console.</p>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} catch (err) {
|
||||
console.error('Token exchange failed:', err.response?.data || err.message);
|
||||
res.status(500).json({ error: 'Token exchange failed', details: err.response?.data });
|
||||
}
|
||||
});
|
||||
|
||||
// Check auth status
|
||||
app.get('/auth/status', (req, res) => {
|
||||
res.json({
|
||||
connected: !!accessToken,
|
||||
hasRefreshToken: !!refreshToken
|
||||
});
|
||||
});
|
||||
|
||||
// Refresh token
|
||||
async function refreshAccessToken() {
|
||||
if (!refreshToken) throw new Error('No refresh token available');
|
||||
|
||||
const response = await axios.post('https://accounts.salla.sa/oauth2/token', {
|
||||
client_id: SALLA_CLIENT_ID,
|
||||
client_secret: SALLA_CLIENT_SECRET,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
|
||||
accessToken = response.data.access_token;
|
||||
if (response.data.refresh_token) {
|
||||
refreshToken = response.data.refresh_token;
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Salla API Proxy Endpoints
|
||||
// ============================================
|
||||
|
||||
// Generic API caller with auto-refresh
|
||||
async function callSallaAPI(endpoint, method = 'GET', data = null) {
|
||||
if (!accessToken) throw new Error('Not authenticated. Visit /auth/login first.');
|
||||
|
||||
try {
|
||||
const response = await axios({
|
||||
method,
|
||||
url: `https://api.salla.dev/admin/v2${endpoint}`,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data
|
||||
});
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
if (err.response?.status === 401) {
|
||||
// Try refresh
|
||||
await refreshAccessToken();
|
||||
return callSallaAPI(endpoint, method, data);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Get store info
|
||||
app.get('/api/store', async (req, res) => {
|
||||
try {
|
||||
const data = await callSallaAPI('/store/info');
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get orders
|
||||
app.get('/api/orders', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, per_page = 50, status } = req.query;
|
||||
let endpoint = `/orders?page=${page}&per_page=${per_page}`;
|
||||
if (status) endpoint += `&status=${status}`;
|
||||
|
||||
const data = await callSallaAPI(endpoint);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get order details
|
||||
app.get('/api/orders/:id', async (req, res) => {
|
||||
try {
|
||||
const data = await callSallaAPI(`/orders/${req.params.id}`);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get products
|
||||
app.get('/api/products', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, per_page = 50 } = req.query;
|
||||
const data = await callSallaAPI(`/products?page=${page}&per_page=${per_page}`);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get customers
|
||||
app.get('/api/customers', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, per_page = 50 } = req.query;
|
||||
const data = await callSallaAPI(`/customers?page=${page}&per_page=${per_page}`);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get analytics/reports
|
||||
app.get('/api/analytics/summary', async (req, res) => {
|
||||
try {
|
||||
// Fetch multiple endpoints for a summary
|
||||
const [orders, products] = await Promise.all([
|
||||
callSallaAPI('/orders?per_page=100'),
|
||||
callSallaAPI('/products?per_page=100')
|
||||
]);
|
||||
|
||||
// Calculate summary
|
||||
const ordersList = orders.data || [];
|
||||
const totalRevenue = ordersList.reduce((sum, o) => sum + (o.amounts?.total?.amount || 0), 0);
|
||||
const avgOrderValue = ordersList.length > 0 ? totalRevenue / ordersList.length : 0;
|
||||
|
||||
res.json({
|
||||
orders: {
|
||||
total: orders.pagination?.total || ordersList.length,
|
||||
recent: ordersList.length
|
||||
},
|
||||
products: {
|
||||
total: products.pagination?.total || (products.data?.length || 0)
|
||||
},
|
||||
revenue: {
|
||||
total: totalRevenue,
|
||||
average_order: avgOrderValue,
|
||||
currency: ordersList[0]?.amounts?.total?.currency || 'SAR'
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Start Server
|
||||
// ============================================
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`\n🚀 Salla Integration Server running on http://localhost:${PORT}`);
|
||||
console.log('\nEndpoints:');
|
||||
console.log(' GET /auth/login - Start OAuth flow');
|
||||
console.log(' GET /auth/callback - OAuth callback');
|
||||
console.log(' GET /auth/status - Check connection status');
|
||||
console.log(' GET /api/store - Store info');
|
||||
console.log(' GET /api/orders - List orders');
|
||||
console.log(' GET /api/products - List products');
|
||||
console.log(' GET /api/customers - List customers');
|
||||
console.log(' GET /api/analytics/summary - Dashboard summary');
|
||||
|
||||
if (!SALLA_CLIENT_ID || !SALLA_CLIENT_SECRET) {
|
||||
console.log('\n⚠️ WARNING: SALLA_CLIENT_ID and SALLA_CLIENT_SECRET not set!');
|
||||
console.log(' Add them to server/.env file');
|
||||
}
|
||||
|
||||
if (accessToken) {
|
||||
console.log('\n✅ Access token loaded from environment');
|
||||
} else {
|
||||
console.log('\n📝 Visit http://localhost:' + PORT + '/auth/login to connect Salla');
|
||||
}
|
||||
});
|
||||
701
server/package-lock.json
generated
701
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
35
server/src/config.ts
Normal 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 || '',
|
||||
};
|
||||
70
server/src/config/museumMapping.ts
Normal file
70
server/src/config/museumMapping.ts
Normal 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
30
server/src/index.ts
Normal 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
34
server/src/routes/erp.ts
Normal 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
34
server/src/routes/etl.ts
Normal 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
160
server/src/routes/salla.ts
Normal 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;
|
||||
62
server/src/services/erpClient.ts
Normal file
62
server/src/services/erpClient.ts
Normal 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);
|
||||
}
|
||||
145
server/src/services/etlSync.ts
Normal file
145
server/src/services/etlSync.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
109
server/src/services/nocodbClient.ts
Normal file
109
server/src/services/nocodbClient.ts
Normal 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;
|
||||
}
|
||||
60
server/src/services/sallaClient.ts
Normal file
60
server/src/services/sallaClient.ts
Normal 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
31
server/src/types.ts
Normal 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
13
server/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
src/App.tsx
10
src/App.tsx
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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": "خادم قاعدة البيانات يستغرق وقتاً طويلاً للاستجابة. يرجى المحاولة مرة أخرى.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,14 +39,29 @@ 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;
|
||||
}
|
||||
|
||||
discoveredTables = tables;
|
||||
console.log('Discovered NocoDB tables:', Object.keys(tables).map(k => `${k}=${tables[k]}`).join(', '));
|
||||
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;
|
||||
}
|
||||
|
||||
return allRecords;
|
||||
}
|
||||
|
||||
// Cache keys
|
||||
@@ -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();
|
||||
@@ -122,7 +90,7 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
|
||||
const data: UmrahData = { 2024: {}, 2025: {} };
|
||||
for (const r of records) {
|
||||
const year = r.Year as number;
|
||||
const qStr = r.Quarter as string; // "Q1", "Q2", etc.
|
||||
const qStr = r.Quarter as string;
|
||||
const qNum = parseInt(qStr.replace('Q', ''));
|
||||
const total = r.TotalPilgrims as number;
|
||||
if (year && qNum && total) {
|
||||
@@ -131,7 +99,6 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// ============================================
|
||||
@@ -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,10 +257,9 @@ 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();
|
||||
@@ -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;
|
||||
@@ -369,6 +292,7 @@ export function filterDataByDateRange(
|
||||
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;
|
||||
@@ -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();
|
||||
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();
|
||||
}
|
||||
return districtMuseumMap[district] || [];
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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
44
src/utils/fetchHelpers.ts
Normal 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
45
start-dev.sh
Executable 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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user