Compare commits
57 Commits
db2617f37d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35771595dc | ||
|
|
e09c3f8190 | ||
|
|
e41cff831b | ||
|
|
d4ce5b6478 | ||
|
|
aa143dfacd | ||
|
|
f615407bba | ||
|
|
47122b5445 | ||
|
|
e373363e75 | ||
|
|
0a80103cfc | ||
|
|
ebdf90c8ab | ||
|
|
cb4fb6071a | ||
|
|
e70d9b92c6 | ||
|
|
418eb2c17c | ||
|
|
b8d33f4f8c | ||
|
|
f3ce7705d6 | ||
|
|
70af4962a6 | ||
|
|
8cf6f9eedd | ||
|
|
c99f2abe10 | ||
|
|
a06436baac | ||
|
|
9657a9d221 | ||
|
|
3c19dee236 | ||
|
|
b4c436f909 | ||
|
|
db6a6ac609 | ||
|
|
ef48372033 | ||
|
|
1dd216f933 | ||
|
|
ac5b23326c | ||
|
|
3912b3dd41 | ||
|
|
9332cae350 | ||
|
|
aa9813aed4 | ||
|
|
fba72692ee | ||
|
|
04789ea9a1 | ||
|
|
219680fb5e | ||
|
|
4f4559023b | ||
|
|
1f1e0756d0 | ||
|
|
9c0ffa5721 | ||
|
|
b4f4104e3e | ||
|
|
18821fd560 | ||
|
|
ea71e54058 | ||
|
|
4ed4d83257 | ||
|
|
f6b7d4ba8d | ||
|
|
a84caaa31e | ||
|
|
8bdfc85027 | ||
|
|
e84d961536 | ||
|
|
9c1552e439 | ||
|
|
802ff28754 | ||
|
|
7d919979cc | ||
|
|
784a914ebf | ||
|
|
0df13abfee | ||
|
|
cf169b6b69 | ||
|
|
25066af67c | ||
|
|
c8567da75f | ||
|
|
30ea4b6ecb | ||
|
|
cd1e395ffa | ||
|
|
8934ba1e51 | ||
|
|
ed29e7c22c | ||
|
|
39b36bf6d9 | ||
|
|
bf996749e5 |
@@ -1,7 +1,4 @@
|
|||||||
# NocoDB (primary data source)
|
# NocoDB (PilgrimStats only — museum sales come from ERP API via server proxy)
|
||||||
VITE_NOCODB_URL=http://localhost:8090
|
VITE_NOCODB_URL=http://localhost:8090
|
||||||
VITE_NOCODB_TOKEN=your_token_here
|
VITE_NOCODB_TOKEN=your-token
|
||||||
|
VITE_NOCODB_BASE_ID=your-base-id
|
||||||
# Google Sheets (fallback if NocoDB fails)
|
|
||||||
VITE_SHEETS_ID=your_spreadsheet_id_here
|
|
||||||
VITE_SHEETS_NAME=Consolidated Data
|
|
||||||
|
|||||||
@@ -8,19 +8,55 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
|
|
||||||
|
# --- Frontend ---
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
env:
|
env:
|
||||||
VITE_NOCODB_URL: ${{ secrets.VITE_NOCODB_URL }}
|
VITE_NOCODB_URL: ${{ secrets.VITE_NOCODB_URL }}
|
||||||
VITE_NOCODB_TOKEN: ${{ secrets.VITE_NOCODB_TOKEN }}
|
VITE_NOCODB_TOKEN: ${{ secrets.VITE_NOCODB_TOKEN }}
|
||||||
|
VITE_NOCODB_BASE_ID: ${{ secrets.VITE_NOCODB_BASE_ID }}
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
- name: Deploy to server
|
- name: Deploy frontend
|
||||||
|
run: rsync -a --delete build/ /opt/apps/hihala-dashboard/build/
|
||||||
|
|
||||||
|
# --- Backend ---
|
||||||
|
- name: Deploy server
|
||||||
|
run: rsync -a --delete --exclude='.env' --exclude='node_modules' server/ /opt/apps/hihala-dashboard/server/
|
||||||
|
|
||||||
|
- name: Install server dependencies
|
||||||
|
run: cd /opt/apps/hihala-dashboard/server && npm ci
|
||||||
|
|
||||||
|
- name: Write server .env
|
||||||
|
env:
|
||||||
|
ADMIN_PIN: ${{ secrets.ADMIN_PIN }}
|
||||||
|
NOCODB_URL: ${{ secrets.VITE_NOCODB_URL }}
|
||||||
|
NOCODB_TOKEN: ${{ secrets.VITE_NOCODB_TOKEN }}
|
||||||
|
NOCODB_BASE_ID: ${{ secrets.VITE_NOCODB_BASE_ID }}
|
||||||
|
ERP_API_URL: ${{ secrets.ERP_API_URL }}
|
||||||
|
ERP_API_CODE: ${{ secrets.ERP_API_CODE }}
|
||||||
|
ERP_USERNAME: ${{ secrets.ERP_USERNAME }}
|
||||||
|
ERP_PASSWORD: ${{ secrets.ERP_PASSWORD }}
|
||||||
|
ETL_SECRET: ${{ secrets.ETL_SECRET }}
|
||||||
run: |
|
run: |
|
||||||
rsync -a --delete build/ /opt/apps/hihala-dashboard/build/
|
cat > /opt/apps/hihala-dashboard/server/.env << EOF
|
||||||
|
NODE_ENV=production
|
||||||
|
SERVER_PORT=3002
|
||||||
|
ADMIN_PIN=${ADMIN_PIN}
|
||||||
|
NOCODB_URL=${NOCODB_URL}
|
||||||
|
NOCODB_TOKEN=${NOCODB_TOKEN}
|
||||||
|
NOCODB_BASE_ID=${NOCODB_BASE_ID}
|
||||||
|
ERP_API_URL=${ERP_API_URL}
|
||||||
|
ERP_API_CODE=${ERP_API_CODE}
|
||||||
|
ERP_USERNAME=${ERP_USERNAME}
|
||||||
|
ERP_PASSWORD=${ERP_PASSWORD}
|
||||||
|
ETL_SECRET=${ETL_SECRET}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Restart manually: sudo systemctl restart hihala-dashboard.service
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Dashboard Quick & Medium Improvements
|
||||||
|
|
||||||
|
> **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:** Improve reliability, performance, and code quality of the HiHala Dashboard.
|
||||||
|
|
||||||
|
**Architecture:** Focused improvements across data layer (timeout, retry), UI (error handling, loading skeletons, code splitting), config (VAT rate), and DX (TypeScript strict, dead code removal).
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, Vite 7, TypeScript 5, Chart.js
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Fetch Timeout + Retry Logic
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/services/dataService.ts`
|
||||||
|
|
||||||
|
- [ ] Add `fetchWithTimeout` wrapper (10s timeout) around all fetch calls
|
||||||
|
- [ ] Add retry with exponential backoff (3 attempts, 1s/2s/4s) to `fetchNocoDBTable` and `discoverTableIds`
|
||||||
|
- [ ] Commit
|
||||||
|
|
||||||
|
### Task 2: Friendly Error Handling
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/App.tsx` (error display)
|
||||||
|
- Modify: `src/services/dataService.ts` (error classification)
|
||||||
|
|
||||||
|
- [ ] Add error classification in dataService (network, auth, config, unknown)
|
||||||
|
- [ ] Replace raw error message in App.tsx with user-friendly messages using i18n keys
|
||||||
|
- [ ] Add error keys to `src/locales/en.json` and `src/locales/ar.json`
|
||||||
|
- [ ] Commit
|
||||||
|
|
||||||
|
### Task 3: Remove Dead Code
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Delete: `src/hooks/useUrlState.ts`
|
||||||
|
- Delete: `src/services/sallaService.ts`
|
||||||
|
|
||||||
|
- [ ] Delete unused files
|
||||||
|
- [ ] Verify no imports reference them
|
||||||
|
- [ ] Commit
|
||||||
|
|
||||||
|
### Task 4: Route-Based Code Splitting
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/App.tsx`
|
||||||
|
|
||||||
|
- [ ] Lazy-load Dashboard, Comparison, Slides with `React.lazy` + `Suspense`
|
||||||
|
- [ ] Commit
|
||||||
|
|
||||||
|
### Task 5: Loading Skeletons
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/shared/LoadingSkeleton.tsx`
|
||||||
|
- Modify: `src/App.tsx` (replace spinner with skeleton)
|
||||||
|
- Modify: `src/App.css` (skeleton styles)
|
||||||
|
|
||||||
|
- [ ] Create skeleton component (stat cards + chart placeholders)
|
||||||
|
- [ ] Use as Suspense fallback and initial loading state
|
||||||
|
- [ ] Commit
|
||||||
|
|
||||||
|
### Task 6: VAT Rate from Config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/services/dataService.ts`
|
||||||
|
|
||||||
|
- [ ] Extract VAT_RATE to a named constant at top of file
|
||||||
|
- [ ] Commit
|
||||||
|
|
||||||
|
### Task 7: TypeScript Strict Mode
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tsconfig.json`
|
||||||
|
- Modify: various files as needed to fix type errors
|
||||||
|
|
||||||
|
- [ ] Enable `strict: true`, `noImplicitAny: true`, `strictNullChecks: true`
|
||||||
|
- [ ] Fix all resulting type errors
|
||||||
|
- [ ] Verify build passes
|
||||||
|
- [ ] Commit
|
||||||
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
|
||||||
118
docs/superpowers/specs/2026-03-31-hijri-seasons-design.md
Normal file
118
docs/superpowers/specs/2026-03-31-hijri-seasons-design.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Hijri Seasons Feature
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add configurable hijri seasons (Ramadan, Hajj, etc.) to the dashboard as a presentation overlay. Seasons are user-defined with Gregorian date ranges (since hijri months shift ~11 days each year). They appear as filter presets, chart bands, and are managed through a settings page.
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
New NocoDB `Seasons` table:
|
||||||
|
|
||||||
|
| Column | Type | Example |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Name | string | `Ramadan` |
|
||||||
|
| HijriYear | number | `1446` |
|
||||||
|
| StartDate | string | `2025-02-28` |
|
||||||
|
| EndDate | string | `2025-03-30` |
|
||||||
|
| Color | string | `#10b981` |
|
||||||
|
|
||||||
|
Read on dashboard load alongside PilgrimStats. Written via server proxy to keep NocoDB credentials server-side.
|
||||||
|
|
||||||
|
**Loading lifecycle:** Seasons load independently of the main data fetch. A failure to load seasons degrades gracefully — seasons state defaults to `[]`, the dashboard renders normally without bands or season presets. Seasons are non-blocking and non-critical.
|
||||||
|
|
||||||
|
## Server Changes
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|----------------|
|
||||||
|
| `server/src/routes/seasons.ts` | `GET /api/seasons` (read all), `POST /api/seasons` (create), `PUT /api/seasons/:id` (update), `DELETE /api/seasons/:id` (delete) |
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `server/src/index.ts` | Mount seasons routes at `/api/seasons` |
|
||||||
|
| `server/src/services/nocodbClient.ts` | Add generic CRUD helpers typed as `<T extends Record<string, unknown>>` so both ETL and seasons routes can share them without coupling |
|
||||||
|
| `vite.config.ts` | Add `/api/seasons` proxy rule **before** the catch-all `/api` rule (same pattern as `/api/erp`). Order: `/api/erp` → `/api/etl` → `/api/seasons` → `/api` |
|
||||||
|
|
||||||
|
## Client Changes
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|----------------|
|
||||||
|
| `src/components/Settings.tsx` | Settings page with seasons CRUD table |
|
||||||
|
| `src/services/seasonsService.ts` | Fetch/create/update/delete seasons via server proxy |
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `src/types/index.ts` | Add `Season` interface |
|
||||||
|
| `src/App.tsx` | Add `/settings` route, nav link (both desktop and mobile bottom nav), load seasons on mount (non-blocking) |
|
||||||
|
| `src/components/Dashboard.tsx` | Add season filter dropdown, chart annotation bands |
|
||||||
|
| `src/components/Comparison.tsx` | Add season filter as period preset |
|
||||||
|
| `src/config/chartConfig.ts` | Import and register `chartjs-plugin-annotation` in the central `ChartJS.register()` call |
|
||||||
|
| `src/locales/en.json` | Settings page labels, season filter labels |
|
||||||
|
| `src/locales/ar.json` | Arabic translations |
|
||||||
|
| `package.json` | Add `chartjs-plugin-annotation` dependency |
|
||||||
|
|
||||||
|
## Season Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface Season {
|
||||||
|
Id?: number;
|
||||||
|
Name: string;
|
||||||
|
HijriYear: number;
|
||||||
|
StartDate: string;
|
||||||
|
EndDate: string;
|
||||||
|
Color: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Settings Page (`/settings`)
|
||||||
|
|
||||||
|
New route accessible from the nav bar (gear icon on desktop, gear in mobile bottom nav). Contains:
|
||||||
|
|
||||||
|
- **Seasons table**: lists all defined seasons with columns: Name, Hijri Year, Start Date, End Date, Color, Actions (edit/delete)
|
||||||
|
- **Add season form**: inline row at the bottom of the table with inputs for each field + color picker + save button
|
||||||
|
- **Edit**: click a row to edit inline
|
||||||
|
- **Delete**: delete button per row with confirmation
|
||||||
|
- **No empty state needed**: just show the empty table with the add form
|
||||||
|
|
||||||
|
## Period Filter Integration
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
Add a "Season" select in the filters section (after Quarter). Options populated from the loaded seasons list:
|
||||||
|
- `All Seasons` (default — no date filtering from season)
|
||||||
|
- `Ramadan 1446 (Feb 28 – Mar 30, 2025)`
|
||||||
|
- `Hajj 1446 (Jun 4 – Jun 9, 2025)`
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
Selecting a season sets a date range filter on the data — equivalent to filtering by start/end date. This works alongside existing year/district/channel/event filters.
|
||||||
|
|
||||||
|
Implementation: when a season is selected, filter data to `row.date >= season.StartDate && row.date <= season.EndDate`. Store the selected season ID in state (not URL params — seasons are dynamic).
|
||||||
|
|
||||||
|
### Comparison
|
||||||
|
|
||||||
|
Seasons appear as preset period options alongside months/quarters. Selecting "Ramadan 1446" sets the period dates and auto-compares with the same season name in the previous hijri year if defined (e.g. "Ramadan 1445").
|
||||||
|
|
||||||
|
## Chart Bands (Revenue Trend)
|
||||||
|
|
||||||
|
Uses `chartjs-plugin-annotation` to draw semi-transparent vertical bands on the revenue trend chart. Must be registered in `chartConfig.ts` via `ChartJS.register(Annotation)`.
|
||||||
|
|
||||||
|
For each season whose date range overlaps the chart's visible range:
|
||||||
|
- Draw a vertical box from `season.StartDate` to `season.EndDate`
|
||||||
|
- Fill with `season.Color` at 15% opacity
|
||||||
|
- Label at the top with season name + hijri year
|
||||||
|
|
||||||
|
Only the revenue trend chart gets bands (it's the only time-series chart where seasons make visual sense).
|
||||||
|
|
||||||
|
## What's NOT Changing
|
||||||
|
|
||||||
|
- ETL pipeline unchanged — seasons are a UI/presentation concern
|
||||||
|
- NocoDB DailySales schema unchanged
|
||||||
|
- All existing filters (year, district, channel, event, quarter) unchanged
|
||||||
|
- Seasons don't affect data aggregation or storage
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# Per-User Museum & Channel Access Control
|
||||||
|
|
||||||
|
**Date:** 2026-04-08
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Allow admins to restrict client-facing users to specific museums (events) and channels. When a restricted user logs in, the dashboard only shows data and filter options for their allowed scope — enforced at the UI and data layers (client-side). The enforcement is intentional client-side filtering; this is an internal analytics tool and not a security boundary against a determined attacker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### User type (`src/types/index.ts`)
|
||||||
|
|
||||||
|
Add two fields to the existing `User` interface (keeping PascalCase to match NocoDB conventions used throughout the codebase):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface User {
|
||||||
|
Id?: number;
|
||||||
|
Name: string;
|
||||||
|
PIN: string;
|
||||||
|
Role: 'admin' | 'viewer';
|
||||||
|
AllowedMuseums: string; // JSON-serialized string array, e.g. '["Museum A","Museum B"]'
|
||||||
|
AllowedChannels: string; // JSON-serialized string array
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Parsed into a runtime shape used in app state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ParsedUser {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
role: 'admin' | 'viewer';
|
||||||
|
allowedMuseums: string[] | null; // [] = unrestricted, null = parse error (show nothing)
|
||||||
|
allowedChannels: string[] | null; // [] = unrestricted, null = parse error (show nothing)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Convention:** `[]` = full access (admins), `string[]` = restricted to list, `null` = corrupted value (fail-closed: no data shown). Existing users require no migration (missing fields parsed as `[]`).
|
||||||
|
|
||||||
|
### NocoDB Users table
|
||||||
|
|
||||||
|
Add two new fields:
|
||||||
|
- `AllowedMuseums` — Text field, stores JSON string (e.g. `'["Museum A"]'`)
|
||||||
|
- `AllowedChannels` — Text field, stores JSON string
|
||||||
|
|
||||||
|
Both default to `"[]"` (unrestricted).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components & Changes
|
||||||
|
|
||||||
|
### 1. `src/services/usersService.ts`
|
||||||
|
|
||||||
|
- Update `fetchUsers()` to parse `AllowedMuseums` and `AllowedChannels` JSON strings into `string[]`; on parse error return `null` (fail-closed — no data shown to the user)
|
||||||
|
- Update `createUser()` to serialize `allowedMuseums`/`allowedChannels` arrays as JSON strings
|
||||||
|
- **Add `updateUser(id, fields)`** — new function required (see Prerequisites below)
|
||||||
|
|
||||||
|
### 2. `server/src/routes/users.ts` + `nocodbClient.ts`
|
||||||
|
|
||||||
|
**Prerequisite:** `updateUser` does not exist yet. Required additions:
|
||||||
|
- `nocodbClient.updateRecord(tableId, rowId, fields)` — calls NocoDB `PATCH /api/v2/tables/{tableId}/records`
|
||||||
|
- `PUT /api/users/:id` route on the server — validates fields and calls `updateRecord`
|
||||||
|
- `updateUser(id, fields)` in `usersService.ts` — calls the new route
|
||||||
|
|
||||||
|
### 3. `server/src/routes/auth.ts`
|
||||||
|
|
||||||
|
The session object currently stores only `{ name, role, createdAt }`. Extend it to also persist `allowedMuseums` and `allowedChannels` at login time:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// On POST /auth/login — after matching user by PIN:
|
||||||
|
session.allowedMuseums = parsedUser.allowedMuseums;
|
||||||
|
session.allowedChannels = parsedUser.allowedChannels;
|
||||||
|
|
||||||
|
// On GET /auth/check — return alongside existing fields:
|
||||||
|
res.json({
|
||||||
|
authenticated: true,
|
||||||
|
name: session.name,
|
||||||
|
role: session.role,
|
||||||
|
allowedMuseums: session.allowedMuseums ?? [],
|
||||||
|
allowedChannels: session.allowedChannels ?? [],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures page reload restores the correct access scope without re-fetching NocoDB.
|
||||||
|
|
||||||
|
### 4. `src/App.tsx`
|
||||||
|
|
||||||
|
- Store `allowedMuseums` and `allowedChannels` in app state (alongside `userRole`, `userName`)
|
||||||
|
- Set them from both the `/auth/login` response and the `/auth/check` response
|
||||||
|
- Pass them as props to **both** `Dashboard` and `Comparison` components
|
||||||
|
- Also pass `allMuseums` and `allChannels` (unique values extracted from raw `data`) as props to `Settings`
|
||||||
|
|
||||||
|
### 5. `src/components/Settings.tsx`
|
||||||
|
|
||||||
|
Accept two new props: `allMuseums: string[]` and `allChannels: string[]`.
|
||||||
|
|
||||||
|
In the add/edit user form, add two checkbox picker sections (hidden for admin users):
|
||||||
|
|
||||||
|
- **Allowed Events** — checkbox list from `allMuseums`
|
||||||
|
- **Allowed Channels** — checkbox list from `allChannels`
|
||||||
|
|
||||||
|
UI behavior:
|
||||||
|
- Empty selection = full access, shown as `"All access"` label
|
||||||
|
- Partial selection shown as `"N events"` / `"N channels"` badge
|
||||||
|
- Admin users: section hidden, shown as `"Full access (admin)"` static label
|
||||||
|
|
||||||
|
### 6. `src/components/Dashboard.tsx`
|
||||||
|
|
||||||
|
Accept two new props: `allowedMuseums: string[]` and `allowedChannels: string[]`.
|
||||||
|
|
||||||
|
Two enforcement layers applied before any render:
|
||||||
|
|
||||||
|
**Layer 1 — Filter options restricted:**
|
||||||
|
```typescript
|
||||||
|
const visibleMuseums = allowedMuseums.length > 0
|
||||||
|
? availableMuseums.filter(m => allowedMuseums.includes(m))
|
||||||
|
: availableMuseums;
|
||||||
|
|
||||||
|
const visibleChannels = allowedChannels.length > 0
|
||||||
|
? channels.filter(c => allowedChannels.includes(c))
|
||||||
|
: channels;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layer 2 — Data filtered at base:**
|
||||||
|
```typescript
|
||||||
|
const permissionFilteredData = useMemo(() => {
|
||||||
|
// null = corrupted stored value → show nothing (fail-closed)
|
||||||
|
if (allowedMuseums === null || allowedChannels === null) return [];
|
||||||
|
let d = data;
|
||||||
|
if (allowedMuseums.length > 0)
|
||||||
|
d = d.filter(r => allowedMuseums.includes(r.museum_name));
|
||||||
|
if (allowedChannels.length > 0)
|
||||||
|
d = d.filter(r => allowedChannels.includes(r.channel));
|
||||||
|
return d;
|
||||||
|
}, [data, allowedMuseums, allowedChannels]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `data` with `permissionFilteredData` as the base for all subsequent filtering and chart rendering.
|
||||||
|
|
||||||
|
### 7. `src/components/Comparison.tsx`
|
||||||
|
|
||||||
|
Apply the same Layer 2 base filter to `Comparison` (same props: `allowedMuseums`, `allowedChannels`). Restricted users must not see unfiltered data on the comparison page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User enters PIN
|
||||||
|
→ POST /auth/login → server matches user, stores allowedMuseums/allowedChannels in session
|
||||||
|
→ App stores them in state, passes to Dashboard + Comparison
|
||||||
|
|
||||||
|
Page reload
|
||||||
|
→ GET /auth/check → server returns allowedMuseums/allowedChannels from session
|
||||||
|
→ App restores state correctly
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| User has 1 allowed museum | Filter dropdown shows with 1 option only |
|
||||||
|
| User has all museums allowed (empty array) | No change from today |
|
||||||
|
| Admin user | `allowedMuseums: []`, `allowedChannels: []` — full access |
|
||||||
|
| URL param references disallowed museum | Base filter removes it silently |
|
||||||
|
| New museum added to data, not in user's list | Not visible to restricted user |
|
||||||
|
| JSON parse error on stored value | `null` returned → no data shown (fail-closed) |
|
||||||
|
| Page reload | Session restores access lists from server |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites (must be built first)
|
||||||
|
|
||||||
|
1. `nocodbClient.updateRecord()` method
|
||||||
|
2. `PUT /api/users/:id` server route
|
||||||
|
3. `updateUser()` in `usersService.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- District-level access control
|
||||||
|
- Role-based permission templates
|
||||||
|
- Audit logging of access
|
||||||
|
- Server-side data API enforcement
|
||||||
11
index.html
11
index.html
@@ -4,17 +4,12 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#f8fafc" />
|
||||||
<meta
|
<meta name="description" content="HiHala Data Dashboard — Event analytics, visitor tracking, and revenue insights" />
|
||||||
name="description"
|
|
||||||
content="Web site created using create-react-app"
|
|
||||||
/>
|
|
||||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<title>HiHala Data – Museums</title>
|
<title>HiHala Data</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
305
package-lock.json
generated
305
package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"chartjs-plugin-annotation": "^3.1.0",
|
||||||
"chartjs-plugin-datalabels": "^2.2.0",
|
"chartjs-plugin-datalabels": "^2.2.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
@@ -1426,6 +1428,22 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/aria-query": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||||
@@ -1512,6 +1530,36 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chart.js": {
|
||||||
"version": "4.5.1",
|
"version": "4.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
@@ -1524,6 +1572,15 @@
|
|||||||
"pnpm": ">=8"
|
"pnpm": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chartjs-plugin-annotation": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chartjs-plugin-datalabels": {
|
"node_modules/chartjs-plugin-datalabels": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",
|
||||||
@@ -1533,6 +1590,66 @@
|
|||||||
"chart.js": ">=3.0.0"
|
"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": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -1608,6 +1725,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
@@ -1703,6 +1827,26 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/html2canvas": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
@@ -1737,6 +1881,16 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -2084,6 +2238,16 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
@@ -2129,6 +2293,16 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -2157,6 +2331,19 @@
|
|||||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -2167,6 +2354,34 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/strip-indent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||||
@@ -2179,6 +2394,22 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/text-segmentation": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
@@ -2205,6 +2436,23 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -2353,12 +2601,69 @@
|
|||||||
"integrity": "sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==",
|
"integrity": "sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==",
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"chartjs-plugin-annotation": "^3.1.0",
|
||||||
"chartjs-plugin-datalabels": "^2.2.0",
|
"chartjs-plugin-datalabels": "^2.2.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
@@ -18,6 +19,9 @@
|
|||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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",
|
"start": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
@@ -28,6 +32,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
|
|||||||
278
scripts/nocodb-migrate.py
Normal file
278
scripts/nocodb-migrate.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
NocoDB Migration Script
|
||||||
|
Exports data from a source NocoDB instance and imports it into a target instance.
|
||||||
|
Handles ID remapping so FK references stay correct regardless of auto-increment offsets.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/nocodb-migrate.py
|
||||||
|
|
||||||
|
Configure source/target via environment variables or edit the config below.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Configuration
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
SOURCE = {
|
||||||
|
"url": "http://localhost:8090",
|
||||||
|
"token": "Crn_mZnlStJ8BjB6a1fvx7_JhiEVFPPm_gI1AwVh",
|
||||||
|
"base_id": "pqbl1a3yie3inqj",
|
||||||
|
}
|
||||||
|
|
||||||
|
TARGET = {
|
||||||
|
"url": "https://nocodb.cloudron.hihala.com",
|
||||||
|
"token": "j6DBMb9vkebA6i_tY1TtctwAToAsi_xQ3kOn9q5C",
|
||||||
|
"workspace_id": "w0b7k8g8",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tables to migrate, in order (parents before children)
|
||||||
|
TABLES = [
|
||||||
|
{
|
||||||
|
"name": "Districts",
|
||||||
|
"columns": [
|
||||||
|
{"column_name": "Name", "title": "Name", "uidt": "SingleLineText", "pv": True},
|
||||||
|
{"column_name": "Description", "title": "Description", "uidt": "LongText"},
|
||||||
|
],
|
||||||
|
"fields": ["Name", "Description"],
|
||||||
|
"fk_mappings": {}, # No FK dependencies
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Museums",
|
||||||
|
"columns": [
|
||||||
|
{"column_name": "Code", "title": "Code", "uidt": "SingleLineText", "pv": True},
|
||||||
|
{"column_name": "Name", "title": "Name", "uidt": "SingleLineText"},
|
||||||
|
{"column_name": "Status", "title": "Status", "uidt": "SingleLineText"},
|
||||||
|
{"column_name": "DistrictId", "title": "DistrictId", "uidt": "Number"},
|
||||||
|
],
|
||||||
|
"fields": ["Code", "Name", "Status"],
|
||||||
|
"fk_mappings": {
|
||||||
|
# field_name: (source_fk_column_candidates, parent_table_name)
|
||||||
|
"DistrictId": (["DistrictId", "nc_epk____Districts_id"], "Districts"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DailyStats",
|
||||||
|
"columns": [
|
||||||
|
{"column_name": "Date", "title": "Date", "uidt": "Date"},
|
||||||
|
{"column_name": "Visits", "title": "Visits", "uidt": "Number"},
|
||||||
|
{"column_name": "Tickets", "title": "Tickets", "uidt": "Number"},
|
||||||
|
{"column_name": "GrossRevenue", "title": "GrossRevenue", "uidt": "Number"},
|
||||||
|
{"column_name": "NetRevenue", "title": "NetRevenue", "uidt": "Decimal"},
|
||||||
|
{"column_name": "MuseumId", "title": "MuseumId", "uidt": "Number"},
|
||||||
|
],
|
||||||
|
"fields": ["Date", "Visits", "Tickets", "GrossRevenue", "NetRevenue"],
|
||||||
|
"fk_mappings": {
|
||||||
|
"MuseumId": (["MuseumId", "nc_epk____Museums_id"], "Museums"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PilgrimStats",
|
||||||
|
"columns": [
|
||||||
|
{"column_name": "Year", "title": "Year", "uidt": "Number"},
|
||||||
|
{"column_name": "Quarter", "title": "Quarter", "uidt": "SingleLineText"},
|
||||||
|
{"column_name": "TotalPilgrims", "title": "TotalPilgrims", "uidt": "Number"},
|
||||||
|
],
|
||||||
|
"fields": ["Year", "Quarter", "TotalPilgrims"],
|
||||||
|
"fk_mappings": {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# API Helpers
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def api_request(base_url, token, path, method="GET", data=None):
|
||||||
|
url = f"{base_url}{path}"
|
||||||
|
headers = {"xc-token": token, "Content-Type": "application/json"}
|
||||||
|
body = json.dumps(data).encode() if data else None
|
||||||
|
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode()
|
||||||
|
print(f" ERROR {e.code}: {error_body}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_all_records(base_url, token, table_id, limit=1000):
|
||||||
|
"""Fetch all records from a table with pagination."""
|
||||||
|
all_records = []
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
data = api_request(base_url, token, f"/api/v2/tables/{table_id}/records?limit={limit}&offset={offset}")
|
||||||
|
records = data.get("list", [])
|
||||||
|
all_records.extend(records)
|
||||||
|
if len(records) < limit:
|
||||||
|
break
|
||||||
|
offset += limit
|
||||||
|
return all_records
|
||||||
|
|
||||||
|
|
||||||
|
def insert_records(base_url, token, table_id, records, batch_size=100):
|
||||||
|
"""Insert records in batches, return list of created IDs in order."""
|
||||||
|
all_ids = []
|
||||||
|
for i in range(0, len(records), batch_size):
|
||||||
|
batch = records[i:i + batch_size]
|
||||||
|
result = api_request(base_url, token, f"/api/v2/tables/{table_id}/records", method="POST", data=batch)
|
||||||
|
if isinstance(result, list):
|
||||||
|
all_ids.extend([r["Id"] for r in result])
|
||||||
|
elif isinstance(result, dict) and "Id" in result:
|
||||||
|
all_ids.append(result["Id"])
|
||||||
|
# Brief pause between batches to avoid rate limiting
|
||||||
|
if i + batch_size < len(records):
|
||||||
|
time.sleep(0.1)
|
||||||
|
return all_ids
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Discovery
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def discover_tables(base_url, token, base_id):
|
||||||
|
"""Get table name → table_id mapping."""
|
||||||
|
data = api_request(base_url, token, f"/api/v2/meta/bases/{base_id}/tables")
|
||||||
|
return {t["title"]: t["id"] for t in data["list"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Migration
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def run_migration():
|
||||||
|
print("=" * 60)
|
||||||
|
print("NocoDB Migration: Source → Target")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Step 1: Discover source tables
|
||||||
|
print("\n[1/5] Discovering source tables...")
|
||||||
|
source_tables = discover_tables(SOURCE["url"], SOURCE["token"], SOURCE["base_id"])
|
||||||
|
for name, tid in source_tables.items():
|
||||||
|
print(f" {name}: {tid}")
|
||||||
|
|
||||||
|
# Step 2: Create target base
|
||||||
|
print("\n[2/5] Creating target base...")
|
||||||
|
base = api_request(
|
||||||
|
TARGET["url"], TARGET["token"],
|
||||||
|
f"/api/v2/meta/workspaces/{TARGET['workspace_id']}/bases/",
|
||||||
|
method="POST",
|
||||||
|
data={"title": "HiHala Dashboard"}
|
||||||
|
)
|
||||||
|
target_base_id = base["id"]
|
||||||
|
print(f" Created base: {target_base_id}")
|
||||||
|
|
||||||
|
# Step 3: Create target tables
|
||||||
|
print("\n[3/5] Creating target tables...")
|
||||||
|
target_table_ids = {}
|
||||||
|
for table_cfg in TABLES:
|
||||||
|
name = table_cfg["name"]
|
||||||
|
result = api_request(
|
||||||
|
TARGET["url"], TARGET["token"],
|
||||||
|
f"/api/v2/meta/bases/{target_base_id}/tables/",
|
||||||
|
method="POST",
|
||||||
|
data={
|
||||||
|
"table_name": name,
|
||||||
|
"title": name,
|
||||||
|
"columns": table_cfg["columns"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
target_table_ids[name] = result["id"]
|
||||||
|
print(f" {name}: {result['id']}")
|
||||||
|
|
||||||
|
# Step 4: Export source data and import with ID remapping
|
||||||
|
print("\n[4/5] Migrating data...")
|
||||||
|
# id_maps[table_name] = {old_id: new_id}
|
||||||
|
id_maps = {}
|
||||||
|
|
||||||
|
for table_cfg in TABLES:
|
||||||
|
name = table_cfg["name"]
|
||||||
|
print(f"\n --- {name} ---")
|
||||||
|
|
||||||
|
if name not in source_tables:
|
||||||
|
print(f" SKIP: not found in source")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Export from source
|
||||||
|
source_records = fetch_all_records(SOURCE["url"], SOURCE["token"], source_tables[name])
|
||||||
|
print(f" Exported {len(source_records)} records from source")
|
||||||
|
|
||||||
|
if not source_records:
|
||||||
|
id_maps[name] = {}
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build clean records with FK remapping
|
||||||
|
clean_records = []
|
||||||
|
for r in source_records:
|
||||||
|
row = {}
|
||||||
|
# Copy plain fields
|
||||||
|
for field in table_cfg["fields"]:
|
||||||
|
if field in r:
|
||||||
|
row[field] = r[field]
|
||||||
|
|
||||||
|
# Remap FK fields
|
||||||
|
for fk_field, (source_candidates, parent_table) in table_cfg["fk_mappings"].items():
|
||||||
|
# Find the FK value from source (try multiple column name candidates)
|
||||||
|
old_fk = None
|
||||||
|
for candidate in source_candidates:
|
||||||
|
if candidate in r and r[candidate] is not None:
|
||||||
|
old_fk = r[candidate]
|
||||||
|
break
|
||||||
|
|
||||||
|
if old_fk is not None and parent_table in id_maps:
|
||||||
|
new_fk = id_maps[parent_table].get(old_fk)
|
||||||
|
if new_fk is not None:
|
||||||
|
row[fk_field] = new_fk
|
||||||
|
else:
|
||||||
|
print(f" WARNING: No mapping for {parent_table}.Id={old_fk}")
|
||||||
|
row[fk_field] = old_fk
|
||||||
|
elif old_fk is not None:
|
||||||
|
row[fk_field] = old_fk
|
||||||
|
|
||||||
|
clean_records.append(row)
|
||||||
|
|
||||||
|
# Insert into target
|
||||||
|
new_ids = insert_records(TARGET["url"], TARGET["token"], target_table_ids[name], clean_records)
|
||||||
|
print(f" Inserted {len(new_ids)} records into target")
|
||||||
|
|
||||||
|
# Build ID mapping (old_id → new_id) based on insertion order
|
||||||
|
old_ids = [r["Id"] for r in source_records]
|
||||||
|
id_maps[name] = {}
|
||||||
|
for old_id, new_id in zip(old_ids, new_ids):
|
||||||
|
id_maps[name][old_id] = new_id
|
||||||
|
|
||||||
|
if id_maps[name]:
|
||||||
|
sample = list(id_maps[name].items())[:3]
|
||||||
|
print(f" ID mapping sample: {sample}")
|
||||||
|
|
||||||
|
# Step 5: Summary
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("[5/5] Migration complete!")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"\n Target base ID: {target_base_id}")
|
||||||
|
print(f"\n Target tables:")
|
||||||
|
for name, tid in target_table_ids.items():
|
||||||
|
print(f" {name}: {tid}")
|
||||||
|
print(f"\n ID mappings:")
|
||||||
|
for name, mapping in id_maps.items():
|
||||||
|
print(f" {name}: {len(mapping)} records ({list(mapping.items())[:2]}...)")
|
||||||
|
|
||||||
|
print(f"\n Add this to your Gitea secrets:")
|
||||||
|
print(f" VITE_NOCODB_BASE_ID = {target_base_id}")
|
||||||
|
print(f"\n The VITE_NOCODB_URL and VITE_NOCODB_TOKEN secrets should point to Cloudron.")
|
||||||
|
|
||||||
|
return target_base_id, target_table_ids
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
run_migration()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nFATAL: {e}")
|
||||||
|
sys.exit(1)
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
# Salla OAuth Credentials (from Salla Partners dashboard)
|
# Server
|
||||||
SALLA_CLIENT_ID=your_client_id_here
|
SERVER_PORT=3002
|
||||||
SALLA_CLIENT_SECRET=your_client_secret_here
|
|
||||||
SALLA_REDIRECT_URI=http://localhost:3001/auth/callback
|
|
||||||
|
|
||||||
# Server port
|
# Hono ERP API (museum sales data)
|
||||||
SALLA_SERVER_PORT=3001
|
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
|
# NocoDB (for ETL writes)
|
||||||
# SALLA_ACCESS_TOKEN=
|
NOCODB_URL=http://localhost:8090
|
||||||
# SALLA_REFRESH_TOKEN=
|
NOCODB_TOKEN=your-token
|
||||||
|
NOCODB_BASE_ID=your-base-id
|
||||||
|
|
||||||
|
# ETL sync secret (for cron auth)
|
||||||
|
ETL_SECRET=your-secret-here
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
ADMIN_PIN=your-pin-code
|
||||||
|
SESSION_SECRET=your-random-session-secret
|
||||||
|
|||||||
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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
732
server/package-lock.json
generated
732
server/package-lock.json
generated
@@ -1,17 +1,605 @@
|
|||||||
{
|
{
|
||||||
"name": "hihala-salla-server",
|
"name": "hihala-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hihala-salla-server",
|
"name": "hihala-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2"
|
"express": "^4.18.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
|
"@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/cookie-parser": {
|
||||||
|
"version": "1.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||||
|
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {
|
"node_modules/accepts": {
|
||||||
@@ -154,6 +742,25 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-parser": {
|
||||||
|
"version": "1.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||||
|
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.7.2",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
@@ -300,6 +907,48 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -433,6 +1082,21 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -479,6 +1143,19 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -767,6 +1444,16 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -934,6 +1621,26 @@
|
|||||||
"node": ">=0.6"
|
"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": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
@@ -947,6 +1654,27 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "hihala-salla-server",
|
"name": "hihala-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Backend server for Salla OAuth and API integration",
|
"type": "module",
|
||||||
"main": "index.js",
|
"description": "Backend server for ERP proxy and Salla integration",
|
||||||
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "tsx src/index.ts",
|
||||||
"dev": "node --watch index.js"
|
"dev": "tsx watch src/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2"
|
"express": "^4.18.2",
|
||||||
|
"tsx": "^4.19.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
server/src/config.ts
Normal file
39
server/src/config.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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 || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const auth = {
|
||||||
|
adminPin: process.env.ADMIN_PIN || '',
|
||||||
|
};
|
||||||
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;
|
||||||
|
}
|
||||||
51
server/src/index.ts
Normal file
51
server/src/index.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import { server, erp, nocodb } from './config';
|
||||||
|
import authRoutes from './routes/auth';
|
||||||
|
import erpRoutes from './routes/erp';
|
||||||
|
import etlRoutes from './routes/etl';
|
||||||
|
import seasonsRoutes from './routes/seasons';
|
||||||
|
import usersRoutes from './routes/users';
|
||||||
|
import { discoverTableIds, ensureTableFields } from './services/nocodbClient';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors({ origin: true, credentials: true }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Mount routes
|
||||||
|
app.use('/auth', authRoutes);
|
||||||
|
app.use('/api/erp', erpRoutes);
|
||||||
|
app.use('/api/etl', etlRoutes);
|
||||||
|
app.use('/api/seasons', seasonsRoutes);
|
||||||
|
app.use('/api/users', usersRoutes);
|
||||||
|
|
||||||
|
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');
|
||||||
|
// Ensure Users table has permission fields
|
||||||
|
// Delay slightly to ensure NocoDB is fully ready before migrating
|
||||||
|
setTimeout(() => {
|
||||||
|
discoverTableIds().then(tables => {
|
||||||
|
if (tables['Users']) {
|
||||||
|
return ensureTableFields(tables['Users'], [
|
||||||
|
{ title: 'AllowedMuseums', uidt: 'LongText' },
|
||||||
|
{ title: 'AllowedChannels', uidt: 'LongText' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}).catch(err => console.warn(' NocoDB migration warning:', err.message));
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
console.log(' NocoDB: WARNING — not configured');
|
||||||
|
}
|
||||||
|
});
|
||||||
113
server/src/routes/auth.ts
Normal file
113
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { auth } from '../config';
|
||||||
|
import { discoverTableIds, fetchAllRecords } from '../services/nocodbClient';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
interface UserRecord {
|
||||||
|
Id: number;
|
||||||
|
Name: string;
|
||||||
|
PIN: string;
|
||||||
|
Role: string;
|
||||||
|
AllowedMuseums?: string;
|
||||||
|
AllowedChannels?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: number;
|
||||||
|
allowedMuseums: string;
|
||||||
|
allowedChannels: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, Session>();
|
||||||
|
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
|
||||||
|
function generateSessionId(): string {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSession(sessionId: string): Session | null {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
if (Date.now() - session.createdAt > SESSION_MAX_AGE) {
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /auth/login
|
||||||
|
router.post('/login', async (req: Request, res: Response) => {
|
||||||
|
const { pin } = req.body;
|
||||||
|
if (!pin) {
|
||||||
|
res.status(400).json({ error: 'PIN required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check super admin PIN from env first
|
||||||
|
if (auth.adminPin && pin === auth.adminPin) {
|
||||||
|
const sessionId = generateSessionId();
|
||||||
|
sessions.set(sessionId, { name: 'Admin', role: 'admin', createdAt: Date.now(), allowedMuseums: '[]', allowedChannels: '[]' });
|
||||||
|
res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' });
|
||||||
|
res.json({ ok: true, name: 'Admin', role: 'admin', allowedMuseums: '[]', allowedChannels: '[]' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check NocoDB Users table
|
||||||
|
try {
|
||||||
|
const tables = await discoverTableIds();
|
||||||
|
if (tables['Users']) {
|
||||||
|
const users = await fetchAllRecords<UserRecord>(tables['Users']);
|
||||||
|
const user = users.find(u => u.PIN === pin);
|
||||||
|
if (user) {
|
||||||
|
const sessionId = generateSessionId();
|
||||||
|
sessions.set(sessionId, {
|
||||||
|
name: user.Name,
|
||||||
|
role: user.Role || 'viewer',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
allowedMuseums: user.AllowedMuseums || '[]',
|
||||||
|
allowedChannels: user.AllowedChannels || '[]',
|
||||||
|
});
|
||||||
|
res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' });
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
name: user.Name,
|
||||||
|
role: user.Role || 'viewer',
|
||||||
|
allowedMuseums: user.AllowedMuseums || '[]',
|
||||||
|
allowedChannels: user.AllowedChannels || '[]',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to check Users table:', (err as Error).message);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json({ error: 'Invalid PIN' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /auth/check
|
||||||
|
router.get('/check', (req: Request, res: Response) => {
|
||||||
|
const sessionId = req.cookies?.hihala_session;
|
||||||
|
const session = sessionId ? getSession(sessionId) : null;
|
||||||
|
res.json({
|
||||||
|
authenticated: !!session,
|
||||||
|
name: session?.name || null,
|
||||||
|
role: session?.role || null,
|
||||||
|
allowedMuseums: session?.allowedMuseums ?? '[]',
|
||||||
|
allowedChannels: session?.allowedChannels ?? '[]',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /auth/logout
|
||||||
|
router.post('/logout', (req: Request, res: Response) => {
|
||||||
|
const sessionId = req.cookies?.hihala_session;
|
||||||
|
if (sessionId) sessions.delete(sessionId);
|
||||||
|
res.clearCookie('hihala_session', { path: '/' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
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;
|
||||||
63
server/src/routes/seasons.ts
Normal file
63
server/src/routes/seasons.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { discoverTableIds, fetchAllRecords, createRecord, updateRecord, deleteRecord } from '../services/nocodbClient';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
async function getSeasonsTableId(): Promise<string> {
|
||||||
|
const tables = await discoverTableIds();
|
||||||
|
const id = tables['Seasons'];
|
||||||
|
if (!id) throw new Error("NocoDB table 'Seasons' not found");
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/seasons
|
||||||
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getSeasonsTableId();
|
||||||
|
const records = await fetchAllRecords(tableId);
|
||||||
|
res.json(records);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch seasons:', (err as Error).message);
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/seasons
|
||||||
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getSeasonsTableId();
|
||||||
|
const result = await createRecord(tableId, req.body);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create season:', (err as Error).message);
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/seasons/:id
|
||||||
|
router.put('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getSeasonsTableId();
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const result = await updateRecord(tableId, id, req.body);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update season:', (err as Error).message);
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/seasons/:id
|
||||||
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getSeasonsTableId();
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
await deleteRecord(tableId, id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete season:', (err as Error).message);
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
57
server/src/routes/users.ts
Normal file
57
server/src/routes/users.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { discoverTableIds, fetchAllRecords, createRecord, updateRecord, deleteRecord } from '../services/nocodbClient';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
async function getUsersTableId(): Promise<string> {
|
||||||
|
const tables = await discoverTableIds();
|
||||||
|
const id = tables['Users'];
|
||||||
|
if (!id) throw new Error("NocoDB table 'Users' not found");
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/users
|
||||||
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getUsersTableId();
|
||||||
|
const records = await fetchAllRecords(tableId);
|
||||||
|
res.json(records);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/users
|
||||||
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getUsersTableId();
|
||||||
|
const result = await createRecord(tableId, req.body);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/users/:id
|
||||||
|
router.patch('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getUsersTableId();
|
||||||
|
await updateRecord(tableId, parseInt(req.params.id), req.body);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/users/:id
|
||||||
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getUsersTableId();
|
||||||
|
await deleteRecord(tableId, parseInt(req.params.id));
|
||||||
|
res.json({ ok: true });
|
||||||
|
} 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
164
server/src/services/nocodbClient.ts
Normal file
164
server/src/services/nocodbClient.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { nocodb } from '../config';
|
||||||
|
|
||||||
|
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<T extends Record<string, unknown>>(tableId: string, records: T[]): Promise<number> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic CRUD helpers
|
||||||
|
|
||||||
|
export async function fetchAllRecords<T>(tableId: string): Promise<T[]> {
|
||||||
|
let all: T[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const json = await fetchJson(
|
||||||
|
`${nocodb.url}/api/v2/tables/${tableId}/records?limit=1000&offset=${offset}`
|
||||||
|
) as { list: T[] };
|
||||||
|
|
||||||
|
all = all.concat(json.list);
|
||||||
|
if (json.list.length < 1000) break;
|
||||||
|
offset += 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureTableFields(tableId: string, fields: Array<{ title: string; uidt: string }>): Promise<void> {
|
||||||
|
// GET /api/v2/meta/tables/{id} returns table with columns array
|
||||||
|
const table = await fetchJson(
|
||||||
|
`${nocodb.url}/api/v2/meta/tables/${tableId}`
|
||||||
|
) as { columns: Array<{ title: string }> };
|
||||||
|
const existing = new Set((table.columns || []).map(f => f.title));
|
||||||
|
for (const field of fields) {
|
||||||
|
if (!existing.has(field.title)) {
|
||||||
|
await fetchJson(`${nocodb.url}/api/v2/meta/tables/${tableId}/columns`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(field),
|
||||||
|
});
|
||||||
|
console.log(` NocoDB: created field '${field.title}' on table ${tableId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRecord<T extends Record<string, unknown>>(tableId: string, record: T): Promise<T> {
|
||||||
|
return await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(record),
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRecord<T extends Record<string, unknown>>(tableId: string, id: number, record: T): Promise<T> {
|
||||||
|
return await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ Id: id, ...record }),
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRecord(tableId: string, id: number): Promise<void> {
|
||||||
|
await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify([{ Id: id }]),
|
||||||
|
});
|
||||||
|
}
|
||||||
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"]
|
||||||
|
}
|
||||||
622
src/App.css
622
src/App.css
@@ -17,11 +17,86 @@
|
|||||||
--danger: #dc2626;
|
--danger: #dc2626;
|
||||||
--danger-light: #fee2e2;
|
--danger-light: #fee2e2;
|
||||||
--gold: #b8860b;
|
--gold: #b8860b;
|
||||||
|
--brand-icon: #3b82f6;
|
||||||
|
--brand-text: #1e3a5f;
|
||||||
|
--text-inverse: #ffffff;
|
||||||
|
--warning-bg: #fef3c7;
|
||||||
|
--warning-text: #92400e;
|
||||||
|
--warning-border: #fcd34d;
|
||||||
|
--accent-hover: #1d4ed8;
|
||||||
|
--purple: #7c3aed;
|
||||||
|
--muted-light: #f1f5f9;
|
||||||
|
--dark-surface: #1e293b;
|
||||||
|
--dark-muted: #94a3b8;
|
||||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
|
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
|
||||||
--shadow: 0 4px 12px rgba(0,0,0,0.08);
|
--shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||||
--radius: 12px;
|
--radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--bg: #0f172a;
|
||||||
|
--surface: #1e293b;
|
||||||
|
--border: #334155;
|
||||||
|
--text-primary: #f1f5f9;
|
||||||
|
--text-secondary: #cbd5e1;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--accent-light: #1e3a5f;
|
||||||
|
--success: #34d399;
|
||||||
|
--success-light: #064e3b;
|
||||||
|
--danger: #f87171;
|
||||||
|
--danger-light: #7f1d1d;
|
||||||
|
--brand-icon: #60a5fa;
|
||||||
|
--brand-text: #93c5fd;
|
||||||
|
--text-inverse: #0f172a;
|
||||||
|
--warning-bg: #451a03;
|
||||||
|
--warning-text: #fbbf24;
|
||||||
|
--warning-border: #78350f;
|
||||||
|
--accent-hover: #60a5fa;
|
||||||
|
--purple: #a78bfa;
|
||||||
|
--muted-light: #1e293b;
|
||||||
|
--dark-surface: #0f172a;
|
||||||
|
--dark-muted: #64748b;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
|
||||||
|
--shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual theme override */
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #0f172a;
|
||||||
|
--surface: #1e293b;
|
||||||
|
--border: #334155;
|
||||||
|
--text-primary: #f1f5f9;
|
||||||
|
--text-secondary: #cbd5e1;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--accent-light: #1e3a5f;
|
||||||
|
--success: #34d399;
|
||||||
|
--success-light: #064e3b;
|
||||||
|
--danger: #f87171;
|
||||||
|
--danger-light: #7f1d1d;
|
||||||
|
--brand-icon: #60a5fa;
|
||||||
|
--brand-text: #93c5fd;
|
||||||
|
--text-inverse: #0f172a;
|
||||||
|
--warning-bg: #451a03;
|
||||||
|
--warning-text: #fbbf24;
|
||||||
|
--warning-border: #78350f;
|
||||||
|
--accent-hover: #60a5fa;
|
||||||
|
--purple: #a78bfa;
|
||||||
|
--muted-light: #1e293b;
|
||||||
|
--dark-surface: #0f172a;
|
||||||
|
--dark-muted: #64748b;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
|
||||||
|
--shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -72,7 +147,7 @@ html[dir="rtl"] {
|
|||||||
.error-container button {
|
.error-container button {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background: var(--text-primary);
|
background: var(--text-primary);
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -84,6 +159,17 @@ html[dir="rtl"] {
|
|||||||
background: var(--text-secondary);
|
background: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-container button:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -118,7 +204,7 @@ html[dir="rtl"] {
|
|||||||
.empty-state-action {
|
.empty-state-action {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -128,29 +214,10 @@ html[dir="rtl"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-state-action:hover {
|
.empty-state-action:hover {
|
||||||
background: #1d4ed8;
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Skeleton Loader */
|
|
||||||
.skeleton {
|
|
||||||
background: linear-gradient(90deg, var(--border) 25%, var(--bg) 50%, var(--border) 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: skeleton-loading 1.5s infinite;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes skeleton-loading {
|
|
||||||
0% { background-position: 200% 0; }
|
|
||||||
100% { background-position: -200% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-text {
|
|
||||||
height: 1em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-text.lg { height: 2em; width: 60%; }
|
|
||||||
.skeleton-text.sm { height: 0.75em; width: 40%; }
|
|
||||||
|
|
||||||
/* Navigation */
|
/* Navigation */
|
||||||
.nav-bar {
|
.nav-bar {
|
||||||
@@ -181,14 +248,14 @@ html[dir="rtl"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-brand-icon {
|
.nav-brand-icon {
|
||||||
color: #3b82f6;
|
color: var(--brand-icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-brand-text {
|
.nav-brand-text {
|
||||||
font-family: 'DM Sans', 'Inter', -apple-system, sans-serif;
|
font-family: 'DM Sans', 'Inter', -apple-system, sans-serif;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1e3a5f;
|
color: var(--brand-text);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,13 +264,13 @@ html[dir="rtl"] {
|
|||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #3b82f6;
|
color: var(--brand-icon);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 20px 2px 6px;
|
padding: 2px 20px 2px 6px;
|
||||||
margin-left: 4px;
|
margin-inline-start: 4px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%233b82f6' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%233b82f6' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
@@ -216,18 +283,19 @@ html[dir="rtl"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.data-source-select:focus {
|
.data-source-select:focus {
|
||||||
outline: none;
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
background-color: rgba(59, 130, 246, 0.12);
|
background-color: rgba(59, 130, 246, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-source-select option {
|
.data-source-select option {
|
||||||
color: #1e3a5f;
|
color: var(--brand-text);
|
||||||
background: white;
|
background: var(--surface);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-source-select option:disabled {
|
.data-source-select option:disabled {
|
||||||
color: #94a3b8;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
@@ -267,7 +335,7 @@ html[dir="rtl"] {
|
|||||||
|
|
||||||
.nav-link.active {
|
.nav-link.active {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,12 +383,12 @@ html[dir="rtl"] .nav-lang-toggle {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: #fef3c7;
|
background: var(--warning-bg);
|
||||||
color: #92400e;
|
color: var(--warning-text);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border: 1px solid #fcd34d;
|
border: 1px solid var(--warning-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.offline-badge svg {
|
.offline-badge svg {
|
||||||
@@ -441,6 +509,12 @@ html[dir="rtl"] .nav-lang-toggle {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card h3 {
|
.stat-card h3 {
|
||||||
@@ -476,14 +550,24 @@ html[dir="rtl"] .nav-lang-toggle {
|
|||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
.chart-card {
|
.chart-card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
animation: fadeUp 0.4s ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-card:nth-child(2) { animation-delay: 0.05s; }
|
||||||
|
.chart-card:nth-child(3) { animation-delay: 0.1s; }
|
||||||
|
.chart-card:nth-child(4) { animation-delay: 0.15s; }
|
||||||
|
|
||||||
.toggle-corner {
|
.toggle-corner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
@@ -526,7 +610,7 @@ html[dir="rtl"] .nav-lang-toggle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
height: 380px;
|
height: clamp(280px, 30vw, 420px);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,7 +636,7 @@ table th {
|
|||||||
border-bottom: 2px solid var(--border);
|
border-bottom: 2px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
table th:first-child { text-align: left; }
|
table th:first-child { text-align: start; }
|
||||||
|
|
||||||
table td {
|
table td {
|
||||||
padding: 14px 12px;
|
padding: 14px 12px;
|
||||||
@@ -561,7 +645,7 @@ table td {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
table td:first-child { text-align: left; }
|
table td:first-child { text-align: start; }
|
||||||
|
|
||||||
table tbody tr:hover {
|
table tbody tr:hover {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@@ -570,7 +654,7 @@ table tbody tr:hover {
|
|||||||
.bold { font-weight: 700; color: var(--text-primary); }
|
.bold { font-weight: 700; color: var(--text-primary); }
|
||||||
.muted { color: var(--text-muted); }
|
.muted { color: var(--text-muted); }
|
||||||
.primary { color: var(--accent); font-weight: 600; }
|
.primary { color: var(--accent); font-weight: 600; }
|
||||||
.purple { color: #7c3aed; font-weight: 600; }
|
.purple { color: var(--purple); font-weight: 600; }
|
||||||
.positive { color: var(--success); font-weight: 600; }
|
.positive { color: var(--success); font-weight: 600; }
|
||||||
.negative { color: var(--danger); font-weight: 600; }
|
.negative { color: var(--danger); font-weight: 600; }
|
||||||
|
|
||||||
@@ -673,10 +757,321 @@ table tbody tr:hover {
|
|||||||
|
|
||||||
.control-group select:focus,
|
.control-group select:focus,
|
||||||
.control-group input[type="date"]:focus {
|
.control-group input[type="date"]:focus {
|
||||||
outline: none;
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -1px;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Multi-select */
|
||||||
|
.multi-select {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-trigger:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-arrow {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
opacity: 0.4;
|
||||||
|
margin-inline-start: 8px;
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-trigger[aria-expanded="true"] .multi-select-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 200px;
|
||||||
|
width: max-content;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
z-index: 50;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-dropdown,
|
||||||
|
.multi-select-dropdown * {
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login page */
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card input {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card input:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card button {
|
||||||
|
padding: 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card button:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
color: var(--danger, #dc2626);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-link {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-link a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-link a:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings page */
|
||||||
|
.settings-page {
|
||||||
|
padding: 32px;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-edit-name {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-edit-name input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-edit-name input[type="color"] {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface-raised, #f0f0f0);
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-badge--full {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small.btn-danger {
|
||||||
|
color: var(--danger, #dc2626);
|
||||||
|
border-color: var(--danger, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small.btn-danger:hover {
|
||||||
|
background: var(--danger, #dc2626);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.add-row td {
|
||||||
|
border-top: 2px dashed var(--border);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.editing td {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-page input[type="text"],
|
||||||
|
.settings-page input[type="number"],
|
||||||
|
.settings-page input[type="date"],
|
||||||
|
.settings-page select {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.period-display {
|
.period-display {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -766,6 +1161,12 @@ table tbody tr:hover {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card h4 {
|
.metric-card h4 {
|
||||||
@@ -831,8 +1232,8 @@ table tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.metric-change.pending {
|
.metric-change.pending {
|
||||||
background: var(--muted-light, #f1f5f9);
|
background: var(--muted-light);
|
||||||
color: var(--text-muted, #64748b);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-change .pending-msg {
|
.metric-change .pending-msg {
|
||||||
@@ -1019,9 +1420,15 @@ table tbody tr:hover {
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0;
|
padding: 8px;
|
||||||
|
background-clip: content-box;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-dot .dot-label {
|
.carousel-dot .dot-label {
|
||||||
@@ -1070,7 +1477,7 @@ table tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.carousel-dots.labeled .carousel-dot.active .dot-label {
|
.carousel-dots.labeled .carousel-dot.active .dot-label {
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chart Sections */
|
/* Chart Sections */
|
||||||
@@ -1507,11 +1914,11 @@ table tbody tr:hover {
|
|||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background: #2563eb;
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@@ -1555,7 +1962,7 @@ table tbody tr:hover {
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1641,8 +2048,8 @@ table tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.slide-actions button.delete:hover {
|
.slide-actions button.delete:hover {
|
||||||
background: #fee2e2;
|
background: var(--danger-light);
|
||||||
color: #dc2626;
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Slide Editor */
|
/* Slide Editor */
|
||||||
@@ -1679,7 +2086,8 @@ table tbody tr:hover {
|
|||||||
|
|
||||||
.editor-section input:focus,
|
.editor-section input:focus,
|
||||||
.editor-section select:focus {
|
.editor-section select:focus {
|
||||||
outline: none;
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -1px;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1779,7 +2187,7 @@ table tbody tr:hover {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
background: linear-gradient(135deg, var(--text-primary) 0%, var(--dark-surface) 100%);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1795,7 +2203,7 @@ table tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-title {
|
.preview-title {
|
||||||
color: #f8fafc;
|
color: var(--bg);
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
@@ -1824,11 +2232,11 @@ table tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-content .preview-kpis .kpi-label {
|
.preview-content .preview-kpis .kpi-label {
|
||||||
color: #94a3b8;
|
color: var(--dark-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-footer {
|
.preview-footer {
|
||||||
color: #64748b;
|
color: var(--text-muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
}
|
}
|
||||||
@@ -1844,7 +2252,7 @@ table tbody tr:hover {
|
|||||||
.preview-controls button {
|
.preview-controls button {
|
||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: white;
|
color: var(--text-inverse);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1937,12 +2345,12 @@ html[dir="rtl"] .chart-header-with-export {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Download button - always top corner, outside normal flow */
|
/* Download button - always top corner, outside normal flow */
|
||||||
.chart-export-btn,
|
.exportable-chart-wrapper .chart-export-btn,
|
||||||
.chart-export-btn.visible {
|
.exportable-chart-wrapper .chart-export-btn.visible {
|
||||||
position: absolute !important;
|
position: absolute;
|
||||||
top: 0 !important;
|
top: 0;
|
||||||
right: 0 !important;
|
right: 0;
|
||||||
left: auto !important;
|
left: auto;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -1959,10 +2367,10 @@ html[dir="rtl"] .chart-header-with-export {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[dir="rtl"] .chart-export-btn,
|
html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn,
|
||||||
html[dir="rtl"] .chart-export-btn.visible {
|
html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
||||||
right: auto !important;
|
right: auto;
|
||||||
left: 0 !important;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-export-btn:hover {
|
.chart-export-btn:hover {
|
||||||
@@ -1987,3 +2395,89 @@ html[dir="rtl"] .chart-export-btn.visible {
|
|||||||
direction: ltr !important;
|
direction: ltr !important;
|
||||||
text-align: left !important;
|
text-align: left !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Reduced Motion
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
.carousel-track {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Loading Skeleton
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
@keyframes skeleton-pulse {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-container {
|
||||||
|
padding: 80px 24px 24px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card {
|
||||||
|
background: var(--muted-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card-wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line-short {
|
||||||
|
width: 40%;
|
||||||
|
height: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line-tall {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card-wide .skeleton-line-tall {
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-charts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.skeleton-stats {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.skeleton-charts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.skeleton-card-wide {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
187
src/App.tsx
187
src/App.tsx
@@ -1,11 +1,17 @@
|
|||||||
import React, { useState, useEffect, useCallback, ReactNode } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, ReactNode, lazy, Suspense } from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||||
import Dashboard from './components/Dashboard';
|
|
||||||
import Comparison from './components/Comparison';
|
const Dashboard = lazy(() => import('./components/Dashboard'));
|
||||||
import Slides from './components/Slides';
|
const Comparison = lazy(() => import('./components/Comparison'));
|
||||||
import { fetchData, getCacheStatus, refreshData } from './services/dataService';
|
const Settings = lazy(() => import('./components/Settings'));
|
||||||
|
import Login from './components/Login';
|
||||||
|
import LoadingSkeleton from './components/shared/LoadingSkeleton';
|
||||||
|
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService';
|
||||||
|
import { fetchSeasons } from './services/seasonsService';
|
||||||
|
import { parseAllowed } from './services/usersService';
|
||||||
import { useLanguage } from './contexts/LanguageContext';
|
import { useLanguage } from './contexts/LanguageContext';
|
||||||
import type { MuseumRecord, CacheStatus } from './types';
|
import type { MuseumRecord, Season, CacheStatus, DataErrorType } from './types';
|
||||||
|
import { DataError } from './types';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
interface NavLinkProps {
|
interface NavLinkProps {
|
||||||
@@ -32,15 +38,47 @@ interface DataSource {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t, dir, switchLanguage } = useLanguage();
|
const { t, dir, switchLanguage } = useLanguage();
|
||||||
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||||
|
const [userRole, setUserRole] = useState<string>('viewer');
|
||||||
|
const [userName, setUserName] = useState<string>('');
|
||||||
|
const [allowedMuseums, setAllowedMuseums] = useState<string[] | null>([]);
|
||||||
|
const [allowedChannels, setAllowedChannels] = useState<string[] | null>([]);
|
||||||
const [data, setData] = useState<MuseumRecord[]>([]);
|
const [data, setData] = useState<MuseumRecord[]>([]);
|
||||||
|
const allMuseumsList = useMemo(() => getUniqueMuseums(data), [data]);
|
||||||
|
const allChannelsList = useMemo(() => getUniqueChannels(data), [data]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<{ message: string; type: DataErrorType } | null>(null);
|
||||||
const [isOffline, setIsOffline] = useState<boolean>(false);
|
const [isOffline, setIsOffline] = useState<boolean>(false);
|
||||||
const [cacheInfo, setCacheInfo] = useState<CacheStatus | null>(null);
|
const [cacheInfo, setCacheInfo] = useState<CacheStatus | null>(null);
|
||||||
const [showDataLabels, setShowDataLabels] = useState<boolean>(false);
|
const [showDataLabels, setShowDataLabels] = useState<boolean>(false);
|
||||||
const [includeVAT, setIncludeVAT] = useState<boolean>(true);
|
const [includeVAT, setIncludeVAT] = useState<boolean>(true);
|
||||||
const [dataSource, setDataSource] = useState<string>('museums');
|
const [dataSource, setDataSource] = useState<string>('museums');
|
||||||
|
const [seasons, setSeasons] = useState<Season[]>([]);
|
||||||
|
const [theme, setTheme] = useState<string>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return localStorage.getItem('hihala_theme') || 'light';
|
||||||
|
}
|
||||||
|
return 'light';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (theme === 'system') {
|
||||||
|
root.removeAttribute('data-theme');
|
||||||
|
} else {
|
||||||
|
root.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
localStorage.setItem('hihala_theme', theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme(prev => {
|
||||||
|
if (prev === 'system') return 'dark';
|
||||||
|
if (prev === 'dark') return 'light';
|
||||||
|
return 'system';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const dataSources: DataSource[] = [
|
const dataSources: DataSource[] = [
|
||||||
{ id: 'museums', labelKey: 'dataSources.museums', enabled: true },
|
{ id: 'museums', labelKey: 'dataSources.museums', enabled: true },
|
||||||
@@ -62,7 +100,8 @@ function App() {
|
|||||||
const status = getCacheStatus();
|
const status = getCacheStatus();
|
||||||
setCacheInfo(status);
|
setCacheInfo(status);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
const type = err instanceof DataError ? err.type : 'unknown';
|
||||||
|
setError({ message: (err as Error).message, type });
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -70,20 +109,66 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadSeasons = useCallback(async () => {
|
||||||
|
const s = await fetchSeasons();
|
||||||
|
setSeasons(s);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Check auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
fetch('/auth/check', { credentials: 'include' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
setAuthenticated(d.authenticated);
|
||||||
|
if (d.authenticated) {
|
||||||
|
setUserRole(d.role || 'viewer');
|
||||||
|
setUserName(d.name || '');
|
||||||
|
setAllowedMuseums(parseAllowed(d.allowedMuseums));
|
||||||
|
setAllowedChannels(parseAllowed(d.allowedChannels));
|
||||||
|
loadData();
|
||||||
|
loadSeasons();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setAuthenticated(false));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = (name: string, role: string, rawMuseums: string, rawChannels: string) => {
|
||||||
|
setAuthenticated(true);
|
||||||
|
setUserName(name);
|
||||||
|
setUserRole(role);
|
||||||
|
setAllowedMuseums(parseAllowed(rawMuseums));
|
||||||
|
setAllowedChannels(parseAllowed(rawChannels));
|
||||||
|
loadData();
|
||||||
|
loadSeasons();
|
||||||
|
};
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
loadData(true);
|
loadData(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auth check loading
|
||||||
|
if (authenticated === null) {
|
||||||
|
return (
|
||||||
|
<div className="app" dir={dir}>
|
||||||
|
<LoadingSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not authenticated — show login
|
||||||
|
if (!authenticated) {
|
||||||
|
return (
|
||||||
|
<div className="app" dir={dir}>
|
||||||
|
<Login onLogin={handleLogin} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="loading-container" dir={dir}>
|
<div className="app" dir={dir}>
|
||||||
<div className="loading-spinner"></div>
|
<LoadingSkeleton />
|
||||||
<p>{t('app.loading')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,8 +177,10 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="error-container" dir={dir}>
|
<div className="error-container" dir={dir}>
|
||||||
<h2>{t('app.error')}</h2>
|
<h2>{t('app.error')}</h2>
|
||||||
<p style={{maxWidth: '400px', textAlign: 'center', color: '#64748b'}}>{error}</p>
|
<p className="error-message">
|
||||||
<button onClick={() => window.location.reload()}>{t('app.retry')}</button>
|
{t(`errors.${error.type}`)}
|
||||||
|
</p>
|
||||||
|
<button onClick={() => loadData()}>{t('app.retry')}</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -101,10 +188,10 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<div className="app" dir={dir}>
|
<div className="app" dir={dir}>
|
||||||
<nav className="nav-bar">
|
<nav className="nav-bar" aria-label={t('nav.dashboard')}>
|
||||||
<div className="nav-content">
|
<div className="nav-content">
|
||||||
<div className="nav-brand">
|
<div className="nav-brand">
|
||||||
<svg className="nav-brand-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="nav-brand-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||||
<rect x="14" y="3" width="7" height="4" rx="1"/>
|
<rect x="14" y="3" width="7" height="4" rx="1"/>
|
||||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||||
@@ -112,10 +199,11 @@ function App() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span className="nav-brand-text">
|
<span className="nav-brand-text">
|
||||||
HiHala Data
|
HiHala Data
|
||||||
<select
|
<select
|
||||||
className="data-source-select"
|
className="data-source-select"
|
||||||
value={dataSource}
|
value={dataSource}
|
||||||
onChange={e => setDataSource(e.target.value)}
|
onChange={e => setDataSource(e.target.value)}
|
||||||
|
aria-label={t('dataSources.museums')}
|
||||||
>
|
>
|
||||||
{dataSources.map(src => (
|
{dataSources.map(src => (
|
||||||
<option key={src.id} value={src.id} disabled={!src.enabled}>
|
<option key={src.id} value={src.id} disabled={!src.enabled}>
|
||||||
@@ -127,7 +215,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
<NavLink to="/">
|
<NavLink to="/">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
<rect x="3" y="3" width="7" height="9" rx="1"/>
|
<rect x="3" y="3" width="7" height="9" rx="1"/>
|
||||||
<rect x="14" y="3" width="7" height="5" rx="1"/>
|
<rect x="14" y="3" width="7" height="5" rx="1"/>
|
||||||
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
||||||
@@ -136,7 +224,7 @@ function App() {
|
|||||||
{t('nav.dashboard')}
|
{t('nav.dashboard')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/comparison">
|
<NavLink to="/comparison">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||||
@@ -159,24 +247,42 @@ function App() {
|
|||||||
{t('app.offline') || 'Offline'}
|
{t('app.offline') || 'Offline'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className={`nav-refresh-btn ${refreshing ? 'refreshing' : ''}`}
|
className={`nav-refresh-btn ${refreshing ? 'refreshing' : ''}`}
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
|
aria-label={t('app.refresh') || 'Refresh data'}
|
||||||
title={t('app.refresh') || 'Refresh data'}
|
title={t('app.refresh') || 'Refresh data'}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
<polyline points="23 4 23 10 17 10"/>
|
<polyline points="23 4 23 10 17 10"/>
|
||||||
<polyline points="1 20 1 14 7 14"/>
|
<polyline points="1 20 1 14 7 14"/>
|
||||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
className="nav-lang-toggle"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={`Theme: ${theme}`}
|
||||||
|
title={`Theme: ${theme}`}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></>
|
||||||
|
) : theme === 'light' ? (
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||||
|
) : (
|
||||||
|
<><circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 0 0 20V2z"/></>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
className="nav-lang-toggle"
|
className="nav-lang-toggle"
|
||||||
onClick={switchLanguage}
|
onClick={switchLanguage}
|
||||||
|
aria-label={t('language.switch')}
|
||||||
title="Switch language"
|
title="Switch language"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="10"/>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||||
@@ -187,16 +293,20 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<Routes>
|
<main>
|
||||||
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
|
<Suspense fallback={<LoadingSkeleton />}>
|
||||||
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
|
<Routes>
|
||||||
<Route path="/slides" element={<Slides data={data} />} />
|
<Route path="/" element={<Dashboard data={data} seasons={seasons} userRole={userRole} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||||
</Routes>
|
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||||
|
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
|
||||||
{/* Mobile Bottom Navigation */}
|
{/* Mobile Bottom Navigation */}
|
||||||
<nav className="mobile-nav">
|
<nav className="mobile-nav" aria-label="Mobile navigation">
|
||||||
<NavLink to="/" className="mobile-nav-item">
|
<NavLink to="/" className="mobile-nav-item">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<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="3" y="3" width="7" height="9" rx="1"/>
|
<rect x="3" y="3" width="7" height="9" rx="1"/>
|
||||||
<rect x="14" y="3" width="7" height="5" rx="1"/>
|
<rect x="14" y="3" width="7" height="5" rx="1"/>
|
||||||
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
||||||
@@ -205,18 +315,27 @@ function App() {
|
|||||||
<span>{t('nav.dashboard')}</span>
|
<span>{t('nav.dashboard')}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/comparison" className="mobile-nav-item">
|
<NavLink to="/comparison" className="mobile-nav-item">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{t('nav.compare')}</span>
|
<span>{t('nav.compare')}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<button
|
{userRole === 'admin' && (
|
||||||
|
<NavLink to="/settings" 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">
|
||||||
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
<span>{t('nav.settings')}</span>
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
className="mobile-nav-item"
|
className="mobile-nav-item"
|
||||||
onClick={switchLanguage}
|
onClick={switchLanguage}
|
||||||
|
aria-label={t('language.switch')}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="10"/>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Line, Bar } from 'react-chartjs-2';
|
import { Line, Bar } from 'react-chartjs-2';
|
||||||
import { EmptyState, FilterControls } from './shared';
|
import { EmptyState, FilterControls, MultiSelect } from './shared';
|
||||||
import { ExportableChart } from './ChartExport';
|
import { ExportableChart } from './ChartExport';
|
||||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
@@ -11,14 +11,37 @@ import {
|
|||||||
formatCompact,
|
formatCompact,
|
||||||
formatCompactCurrency,
|
formatCompactCurrency,
|
||||||
umrahData,
|
umrahData,
|
||||||
|
getUniqueChannels,
|
||||||
|
getUniqueMuseums,
|
||||||
getUniqueDistricts,
|
getUniqueDistricts,
|
||||||
getDistrictMuseumMap,
|
|
||||||
getMuseumsForDistrict,
|
getMuseumsForDistrict,
|
||||||
getLatestYear
|
getLatestYear
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
|
import type { MuseumRecord, ComparisonProps, DateRangeFilters, Season } from '../types';
|
||||||
|
|
||||||
|
interface PresetDateRange {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresetDates {
|
||||||
|
[key: string]: PresetDateRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
title: string;
|
||||||
|
prev: number | null;
|
||||||
|
curr: number | null;
|
||||||
|
change: number | null;
|
||||||
|
isCurrency?: boolean;
|
||||||
|
isPercent?: boolean;
|
||||||
|
pendingMessage?: string;
|
||||||
|
prevYear: string;
|
||||||
|
currYear: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate preset dates for a given year
|
// Generate preset dates for a given year
|
||||||
const generatePresetDates = (year) => ({
|
const generatePresetDates = (year: number): PresetDates => ({
|
||||||
'jan': { start: `${year}-01-01`, end: `${year}-01-31` },
|
'jan': { start: `${year}-01-01`, end: `${year}-01-31` },
|
||||||
'feb': { start: `${year}-02-01`, end: `${year}-02-28` },
|
'feb': { start: `${year}-02-01`, end: `${year}-02-28` },
|
||||||
'mar': { start: `${year}-03-01`, end: `${year}-03-31` },
|
'mar': { start: `${year}-03-01`, end: `${year}-03-31` },
|
||||||
@@ -40,15 +63,24 @@ const generatePresetDates = (year) => ({
|
|||||||
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
|
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
|
||||||
});
|
});
|
||||||
|
|
||||||
function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }) {
|
function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: ComparisonProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
// Permission base filter — applied before any user-facing filter
|
||||||
|
const permissionFilteredData = useMemo(() => {
|
||||||
|
if (allowedMuseums === null || allowedChannels === null) return [];
|
||||||
|
let d = data;
|
||||||
|
if (allowedMuseums.length > 0) d = d.filter(r => allowedMuseums.includes(r.museum_name));
|
||||||
|
if (allowedChannels.length > 0) d = d.filter(r => allowedChannels.includes(r.channel));
|
||||||
|
return d;
|
||||||
|
}, [data, allowedMuseums, allowedChannels]);
|
||||||
|
|
||||||
// Get available years from data
|
// Get available years from data
|
||||||
const latestYear = useMemo(() => getLatestYear(data), [data]);
|
const latestYear = useMemo(() => parseInt(getLatestYear(permissionFilteredData)), [permissionFilteredData]);
|
||||||
const availableYears = useMemo((): number[] => {
|
const availableYears = useMemo((): number[] => {
|
||||||
const yearsSet = new Set<number>();
|
const yearsSet = new Set<number>();
|
||||||
data.forEach(r => {
|
permissionFilteredData.forEach((r: MuseumRecord) => {
|
||||||
const d = r.date || (r as any).Date;
|
const d = r.date || (r as any).Date;
|
||||||
if (d) yearsSet.add(new Date(d).getFullYear());
|
if (d) yearsSet.add(new Date(d).getFullYear());
|
||||||
});
|
});
|
||||||
@@ -57,7 +89,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
// Initialize state from URL or defaults
|
// Initialize state from URL or defaults
|
||||||
const [selectedYear, setSelectedYearState] = useState(() => {
|
const [selectedYear, setSelectedYearState] = useState<number>(() => {
|
||||||
const urlYear = searchParams.get('year');
|
const urlYear = searchParams.get('year');
|
||||||
return urlYear ? parseInt(urlYear) : latestYear;
|
return urlYear ? parseInt(urlYear) : latestYear;
|
||||||
});
|
});
|
||||||
@@ -66,25 +98,34 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
const [preset, setPresetState] = useState(() => searchParams.get('preset') || 'jan');
|
const [preset, setPresetState] = useState(() => searchParams.get('preset') || 'jan');
|
||||||
const [startDate, setStartDateState] = useState(() => {
|
const [startDate, setStartDateState] = useState(() => {
|
||||||
const urlPreset = searchParams.get('preset');
|
const urlPreset = searchParams.get('preset');
|
||||||
const year = searchParams.get('year') ? parseInt(searchParams.get('year')) : latestYear;
|
const yearParam = searchParams.get('year');
|
||||||
|
const year = yearParam ? parseInt(yearParam) : latestYear;
|
||||||
const dates = generatePresetDates(year);
|
const dates = generatePresetDates(year);
|
||||||
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
||||||
return dates[urlPreset].start;
|
return dates[urlPreset].start;
|
||||||
}
|
}
|
||||||
return searchParams.get('from') || `${year}-01-01`;
|
// Season presets store from/to in URL
|
||||||
|
const fromParam = searchParams.get('from');
|
||||||
|
if (fromParam) return fromParam;
|
||||||
|
return `${year}-01-01`;
|
||||||
});
|
});
|
||||||
const [endDate, setEndDateState] = useState(() => {
|
const [endDate, setEndDateState] = useState(() => {
|
||||||
const urlPreset = searchParams.get('preset');
|
const urlPreset = searchParams.get('preset');
|
||||||
const year = searchParams.get('year') ? parseInt(searchParams.get('year')) : latestYear;
|
const yearParam = searchParams.get('year');
|
||||||
|
const year = yearParam ? parseInt(yearParam) : latestYear;
|
||||||
const dates = generatePresetDates(year);
|
const dates = generatePresetDates(year);
|
||||||
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
||||||
return dates[urlPreset].end;
|
return dates[urlPreset].end;
|
||||||
}
|
}
|
||||||
return searchParams.get('to') || `${year}-01-31`;
|
// Season presets store from/to in URL
|
||||||
|
const toParam = searchParams.get('to');
|
||||||
|
if (toParam) return toParam;
|
||||||
|
return `${year}-01-31`;
|
||||||
});
|
});
|
||||||
const [filters, setFiltersState] = useState(() => ({
|
const [filters, setFiltersState] = useState(() => ({
|
||||||
district: searchParams.get('district') || 'all',
|
district: searchParams.get('district') || 'all',
|
||||||
museum: searchParams.get('museum') || 'all'
|
channel: searchParams.get('channel')?.split(',').filter(Boolean) || [],
|
||||||
|
museum: searchParams.get('museum')?.split(',').filter(Boolean) || []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const [chartMetric, setChartMetric] = useState('revenue');
|
const [chartMetric, setChartMetric] = useState('revenue');
|
||||||
@@ -93,51 +134,60 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
const [activeCard, setActiveCard] = useState(0);
|
const [activeCard, setActiveCard] = useState(0);
|
||||||
|
|
||||||
// Update URL with current state
|
// Update URL with current state
|
||||||
const updateUrl = useCallback((newPreset, newFrom, newTo, newFilters, newYear) => {
|
const updateUrl = useCallback((newPreset: string, newFrom: string | null, newTo: string | null, newFilters: DateRangeFilters | null, newYear: number) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (newPreset && newPreset !== 'jan') params.set('preset', newPreset);
|
if (newPreset && newPreset !== 'jan') params.set('preset', newPreset);
|
||||||
if (newYear && newYear !== latestYear) params.set('year', newYear.toString());
|
if (newYear && newYear !== latestYear) params.set('year', newYear.toString());
|
||||||
if (newPreset === 'custom') {
|
if (newPreset === 'custom' || newPreset.startsWith('season-')) {
|
||||||
if (newFrom) params.set('from', newFrom);
|
if (newFrom) params.set('from', newFrom);
|
||||||
if (newTo) params.set('to', newTo);
|
if (newTo) params.set('to', newTo);
|
||||||
}
|
}
|
||||||
if (newFilters?.district && newFilters.district !== 'all') params.set('district', newFilters.district);
|
if (newFilters?.district && newFilters.district !== 'all') params.set('district', newFilters.district);
|
||||||
if (newFilters?.museum && newFilters.museum !== 'all') params.set('museum', newFilters.museum);
|
if (newFilters?.channel && newFilters.channel.length > 0) params.set('channel', newFilters.channel.join(','));
|
||||||
|
if (newFilters?.museum && newFilters.museum.length > 0) params.set('museum', newFilters.museum.join(','));
|
||||||
setSearchParams(params, { replace: true });
|
setSearchParams(params, { replace: true });
|
||||||
}, [setSearchParams, latestYear]);
|
}, [setSearchParams, latestYear]);
|
||||||
|
|
||||||
const setSelectedYear = (year) => {
|
const setSelectedYear = (year: number) => {
|
||||||
setSelectedYearState(year);
|
setSelectedYearState(year);
|
||||||
const newDates = generatePresetDates(year);
|
const newDates = generatePresetDates(year);
|
||||||
if (preset !== 'custom' && newDates[preset]) {
|
if (preset !== 'custom' && !preset.startsWith('season-') && newDates[preset]) {
|
||||||
setStartDateState(newDates[preset].start);
|
setStartDateState(newDates[preset].start);
|
||||||
setEndDateState(newDates[preset].end);
|
setEndDateState(newDates[preset].end);
|
||||||
}
|
}
|
||||||
updateUrl(preset, null, null, filters, year);
|
updateUrl(preset, null, null, filters, year);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setPreset = (value) => {
|
const setPreset = (value: string) => {
|
||||||
setPresetState(value);
|
setPresetState(value);
|
||||||
if (value !== 'custom' && presetDates[value]) {
|
if (value.startsWith('season-')) {
|
||||||
|
const seasonId = parseInt(value.replace('season-', ''));
|
||||||
|
const season = seasons.find(s => s.Id === seasonId);
|
||||||
|
if (season) {
|
||||||
|
setStartDateState(season.StartDate);
|
||||||
|
setEndDateState(season.EndDate);
|
||||||
|
updateUrl(value, season.StartDate, season.EndDate, filters, selectedYear);
|
||||||
|
}
|
||||||
|
} else if (value !== 'custom' && presetDates[value]) {
|
||||||
setStartDateState(presetDates[value].start);
|
setStartDateState(presetDates[value].start);
|
||||||
setEndDateState(presetDates[value].end);
|
setEndDateState(presetDates[value].end);
|
||||||
updateUrl(value, null, null, filters, selectedYear);
|
updateUrl(value, null, null, filters, selectedYear);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setStartDate = (value) => {
|
const setStartDate = (value: string) => {
|
||||||
setStartDateState(value);
|
setStartDateState(value);
|
||||||
setPresetState('custom');
|
setPresetState('custom');
|
||||||
updateUrl('custom', value, endDate, filters, selectedYear);
|
updateUrl('custom', value, endDate, filters, selectedYear);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setEndDate = (value) => {
|
const setEndDate = (value: string) => {
|
||||||
setEndDateState(value);
|
setEndDateState(value);
|
||||||
setPresetState('custom');
|
setPresetState('custom');
|
||||||
updateUrl('custom', startDate, value, filters, selectedYear);
|
updateUrl('custom', startDate, value, filters, selectedYear);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setFilters = (newFilters) => {
|
const setFilters = (newFilters: DateRangeFilters | ((prev: DateRangeFilters) => DateRangeFilters)) => {
|
||||||
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
|
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
|
||||||
setFiltersState(updated);
|
setFiltersState(updated);
|
||||||
updateUrl(preset, startDate, endDate, updated, selectedYear);
|
updateUrl(preset, startDate, endDate, updated, selectedYear);
|
||||||
@@ -149,13 +199,13 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Touch swipe handlers
|
// Touch swipe handlers
|
||||||
const touchStartChart = useRef(null);
|
const touchStartChart = useRef<number | null>(null);
|
||||||
const touchStartCard = useRef(null);
|
const touchStartCard = useRef<number | null>(null);
|
||||||
|
|
||||||
const handleChartTouchStart = (e) => {
|
const handleChartTouchStart = (e: React.TouchEvent) => {
|
||||||
touchStartChart.current = e.touches[0].clientX;
|
touchStartChart.current = e.touches[0].clientX;
|
||||||
};
|
};
|
||||||
const handleChartTouchEnd = (e) => {
|
const handleChartTouchEnd = (e: React.TouchEvent) => {
|
||||||
if (!touchStartChart.current) return;
|
if (!touchStartChart.current) return;
|
||||||
const diff = touchStartChart.current - e.changedTouches[0].clientX;
|
const diff = touchStartChart.current - e.changedTouches[0].clientX;
|
||||||
if (Math.abs(diff) > 50) {
|
if (Math.abs(diff) > 50) {
|
||||||
@@ -183,52 +233,68 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
{ value: 'avgRevenue', label: t('metrics.avgRevenue'), field: null, format: 'currency' }
|
{ value: 'avgRevenue', label: t('metrics.avgRevenue'), field: null, format: 'currency' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const getMetricValue = useCallback((rows, metric) => {
|
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
|
||||||
if (metric === 'avgRevenue') {
|
if (metric === 'avgRevenue') {
|
||||||
const revenue = rows.reduce((s, r) => s + parseFloat(r[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, r) => s + parseInt(r.visits || 0), 0);
|
const visitors = rows.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
|
||||||
return visitors > 0 ? revenue / visitors : 0;
|
return visitors > 0 ? revenue / visitors : 0;
|
||||||
}
|
}
|
||||||
const fieldMap = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' };
|
const fieldMap: Record<string, string> = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' };
|
||||||
const field = fieldMap[metric];
|
const field = fieldMap[metric];
|
||||||
return rows.reduce((s, r) => s + parseFloat(r[field] || r.revenue_incl_tax || 0), 0);
|
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[field] || 0)), 0);
|
||||||
}, [revenueField]);
|
}, [revenueField]);
|
||||||
|
|
||||||
// Dynamic lists from data
|
// Dynamic lists from data
|
||||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
const channels = useMemo(() => getUniqueChannels(permissionFilteredData), [permissionFilteredData]);
|
||||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
const districts = useMemo(() => getUniqueDistricts(permissionFilteredData), [permissionFilteredData]);
|
||||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
|
const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]);
|
||||||
|
|
||||||
// Year-over-year comparison: same dates, previous year
|
// Year-over-year comparison: same dates, previous year
|
||||||
const ranges = useMemo(() => ({
|
// For season presets, try to find the same season name from the previous hijri year
|
||||||
curr: { start: startDate, end: endDate },
|
const ranges = useMemo(() => {
|
||||||
prev: {
|
const curr = { start: startDate, end: endDate };
|
||||||
start: startDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1),
|
let prev = {
|
||||||
end: endDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1)
|
start: startDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1)),
|
||||||
}
|
end: endDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1))
|
||||||
}), [startDate, endDate]);
|
};
|
||||||
|
|
||||||
const prevData = useMemo(() =>
|
if (preset.startsWith('season-')) {
|
||||||
filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters),
|
const seasonId = parseInt(preset.replace('season-', ''));
|
||||||
[data, ranges.prev, filters]
|
const currentSeason = seasons.find(s => s.Id === seasonId);
|
||||||
|
if (currentSeason) {
|
||||||
|
const prevSeason = seasons.find(
|
||||||
|
s => s.Name === currentSeason.Name && s.HijriYear === currentSeason.HijriYear - 1
|
||||||
|
);
|
||||||
|
if (prevSeason) {
|
||||||
|
prev = { start: prevSeason.StartDate, end: prevSeason.EndDate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { curr, prev };
|
||||||
|
}, [startDate, endDate, preset, seasons]);
|
||||||
|
|
||||||
|
const prevData = useMemo(() =>
|
||||||
|
filterDataByDateRange(permissionFilteredData, ranges.prev.start, ranges.prev.end, filters),
|
||||||
|
[permissionFilteredData, ranges.prev, filters]
|
||||||
);
|
);
|
||||||
|
|
||||||
const currData = useMemo(() =>
|
const currData = useMemo(() =>
|
||||||
filterDataByDateRange(data, ranges.curr.start, ranges.curr.end, filters),
|
filterDataByDateRange(permissionFilteredData, ranges.curr.start, ranges.curr.end, filters),
|
||||||
[data, ranges.curr, filters]
|
[permissionFilteredData, ranges.curr, filters]
|
||||||
);
|
);
|
||||||
|
|
||||||
const prevMetrics = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]);
|
const prevMetrics = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]);
|
||||||
const currMetrics = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]);
|
const currMetrics = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]);
|
||||||
|
|
||||||
const hasData = prevData.length > 0 || currData.length > 0;
|
const hasData = prevData.length > 0 || currData.length > 0;
|
||||||
const resetFilters = () => setFilters({ district: 'all', museum: 'all' });
|
const resetFilters = () => setFilters({ district: 'all', channel: [], museum: [] });
|
||||||
|
|
||||||
const calcChange = (prev, curr) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
const calcChange = (prev: number, curr: number) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
||||||
|
|
||||||
// Get quarter from date range (returns null if not a clean quarter)
|
// Get quarter from date range (returns null if not a clean quarter)
|
||||||
const getQuarterFromRange = (start, end) => {
|
const getQuarterFromRange = (start: string, end: string) => {
|
||||||
const quarterRanges = {
|
const quarterRanges: Record<number, { start: string; end: string }> = {
|
||||||
1: { start: '-01-01', end: '-03-31' },
|
1: { start: '-01-01', end: '-03-31' },
|
||||||
2: { start: '-04-01', end: '-06-30' },
|
2: { start: '-04-01', end: '-06-30' },
|
||||||
3: { start: '-07-01', end: '-09-30' },
|
3: { start: '-07-01', end: '-09-30' },
|
||||||
@@ -331,10 +397,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
return cards;
|
return cards;
|
||||||
}, [prevMetrics, currMetrics, pilgrimCounts, captureRates, t]);
|
}, [prevMetrics, currMetrics, pilgrimCounts, captureRates, t]);
|
||||||
|
|
||||||
const handleCardTouchStart = (e) => {
|
const handleCardTouchStart = (e: React.TouchEvent) => {
|
||||||
touchStartCard.current = e.touches[0].clientX;
|
touchStartCard.current = e.touches[0].clientX;
|
||||||
};
|
};
|
||||||
const handleCardTouchEnd = (e) => {
|
const handleCardTouchEnd = (e: React.TouchEvent) => {
|
||||||
if (!touchStartCard.current) return;
|
if (!touchStartCard.current) return;
|
||||||
const diff = touchStartCard.current - e.changedTouches[0].clientX;
|
const diff = touchStartCard.current - e.changedTouches[0].clientX;
|
||||||
if (Math.abs(diff) > 50) {
|
if (Math.abs(diff) > 50) {
|
||||||
@@ -347,7 +413,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
touchStartCard.current = null;
|
touchStartCard.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr: string) => {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
const [year, month, day] = dateStr.split('-').map(Number);
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
const d = new Date(year, month - 1, day);
|
const d = new Date(year, month - 1, day);
|
||||||
@@ -355,7 +421,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Generate period label - shows year if same year, or "MMM YY–MMM YY" if spans years
|
// Generate period label - shows year if same year, or "MMM YY–MMM YY" if spans years
|
||||||
const getPeriodLabel = useCallback((startDate, endDate) => {
|
const getPeriodLabel = useCallback((startDate: string, endDate: string) => {
|
||||||
if (!startDate || !endDate) return '';
|
if (!startDate || !endDate) return '';
|
||||||
const startYear = startDate.substring(0, 4);
|
const startYear = startDate.substring(0, 4);
|
||||||
const endYear = endDate.substring(0, 4);
|
const endYear = endDate.substring(0, 4);
|
||||||
@@ -374,11 +440,11 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
|
|
||||||
// Time series chart (daily or weekly)
|
// Time series chart (daily or weekly)
|
||||||
const timeSeriesChart = useMemo(() => {
|
const timeSeriesChart = useMemo(() => {
|
||||||
const groupByPeriod = (periodData, periodStart, metric, granularity) => {
|
const groupByPeriod = (periodData: MuseumRecord[], periodStart: string, metric: string, granularity: string) => {
|
||||||
const start = new Date(periodStart);
|
const start = new Date(periodStart);
|
||||||
const groupedRows = {};
|
const groupedRows: Record<number, MuseumRecord[]> = {};
|
||||||
|
|
||||||
periodData.forEach(row => {
|
periodData.forEach((row: MuseumRecord) => {
|
||||||
if (!row.date) return;
|
if (!row.date) return;
|
||||||
const rowDate = new Date(row.date);
|
const rowDate = new Date(row.date);
|
||||||
const daysDiff = Math.floor((rowDate.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
const daysDiff = Math.floor((rowDate.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
@@ -398,9 +464,9 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
groupedRows[key].push(row);
|
groupedRows[key].push(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = {};
|
const result: Record<number, number> = {};
|
||||||
Object.keys(groupedRows).forEach(key => {
|
Object.keys(groupedRows).forEach(key => {
|
||||||
result[key] = getMetricValue(groupedRows[key], metric);
|
result[Number(key)] = getMetricValue(groupedRows[Number(key)], metric);
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
@@ -454,7 +520,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
const museumChart = useMemo(() => {
|
const museumChart = useMemo(() => {
|
||||||
const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
|
const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
|
||||||
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
|
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
|
||||||
const allMuseums = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[];
|
const allMuseums = [...new Set(data.map((r: MuseumRecord) => r.museum_name))].filter(Boolean) as string[];
|
||||||
const prevByMuseum: Record<string, number> = {};
|
const prevByMuseum: Record<string, number> = {};
|
||||||
const currByMuseum: Record<string, number> = {};
|
const currByMuseum: Record<string, number> = {};
|
||||||
allMuseums.forEach(m => {
|
allMuseums.forEach(m => {
|
||||||
@@ -475,11 +541,53 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
}, [data, prevData, currData, ranges, chartMetric, getMetricValue, getPeriodLabel]);
|
}, [data, prevData, currData, ranges, chartMetric, getMetricValue, getPeriodLabel]);
|
||||||
|
|
||||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||||
|
|
||||||
|
// Map seasons to annotation bands on the current period's timeline
|
||||||
|
const seasonAnnotations = useMemo(() => {
|
||||||
|
if (!seasons.length) return {};
|
||||||
|
const currStart = new Date(ranges.curr.start);
|
||||||
|
const currEnd = new Date(ranges.curr.end);
|
||||||
|
const annotations: Record<string, unknown> = {};
|
||||||
|
const msPerDay = 1000 * 60 * 60 * 24;
|
||||||
|
const granDivisor = chartGranularity === 'month' ? 30 : chartGranularity === 'week' ? 7 : 1;
|
||||||
|
|
||||||
|
seasons.forEach((s, i) => {
|
||||||
|
const sStart = new Date(s.StartDate);
|
||||||
|
const sEnd = new Date(s.EndDate);
|
||||||
|
// Check overlap with current period
|
||||||
|
if (sEnd < currStart || sStart > currEnd) return;
|
||||||
|
|
||||||
|
const clampedStart = sStart < currStart ? currStart : sStart;
|
||||||
|
const clampedEnd = sEnd > currEnd ? currEnd : sEnd;
|
||||||
|
const startIdx = Math.floor((clampedStart.getTime() - currStart.getTime()) / msPerDay / granDivisor);
|
||||||
|
const endIdx = Math.floor((clampedEnd.getTime() - currStart.getTime()) / msPerDay / granDivisor);
|
||||||
|
|
||||||
|
annotations[`season${i}`] = {
|
||||||
|
type: 'box',
|
||||||
|
xMin: startIdx - 0.5,
|
||||||
|
xMax: endIdx + 0.5,
|
||||||
|
backgroundColor: s.Color + '20',
|
||||||
|
borderColor: s.Color + '40',
|
||||||
|
borderWidth: 1,
|
||||||
|
label: {
|
||||||
|
display: true,
|
||||||
|
content: `${s.Name} ${s.HijriYear}`,
|
||||||
|
position: 'start',
|
||||||
|
color: s.Color,
|
||||||
|
font: { size: 10, weight: '600' },
|
||||||
|
padding: 4
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return annotations;
|
||||||
|
}, [seasons, ranges.curr, chartGranularity]);
|
||||||
|
|
||||||
const chartOptions: any = {
|
const chartOptions: any = {
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
plugins: {
|
plugins: {
|
||||||
...baseOptions.plugins,
|
...baseOptions.plugins,
|
||||||
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } }
|
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } },
|
||||||
|
annotation: { annotations: seasonAnnotations }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -532,9 +640,18 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
<option value="h1">{t('time.h1')}</option>
|
<option value="h1">{t('time.h1')}</option>
|
||||||
<option value="h2">{t('time.h2')}</option>
|
<option value="h2">{t('time.h2')}</option>
|
||||||
<option value="full">{t('time.fullYear')}</option>
|
<option value="full">{t('time.fullYear')}</option>
|
||||||
|
{seasons.length > 0 && (
|
||||||
|
<optgroup label={t('comparison.seasons') || 'Seasons'}>
|
||||||
|
{seasons.map(s => (
|
||||||
|
<option key={s.Id} value={`season-${s.Id}`}>
|
||||||
|
{s.Name} {s.HijriYear}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
{preset !== 'custom' && (
|
{preset !== 'custom' && !preset.startsWith('season-') && (
|
||||||
<FilterControls.Group label={t('filters.year')}>
|
<FilterControls.Group label={t('filters.year')}>
|
||||||
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
|
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
|
||||||
{availableYears.map(y => (
|
{availableYears.map(y => (
|
||||||
@@ -543,7 +660,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
)}
|
)}
|
||||||
{preset === 'custom' && (
|
{(preset === 'custom' || preset.startsWith('season-')) && (
|
||||||
<>
|
<>
|
||||||
<FilterControls.Group label={t('comparison.from')}>
|
<FilterControls.Group label={t('comparison.from')}>
|
||||||
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
||||||
@@ -554,16 +671,26 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<FilterControls.Group label={t('filters.district')}>
|
<FilterControls.Group label={t('filters.district')}>
|
||||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: []})}>
|
||||||
<option value="all">{t('filters.allDistricts')}</option>
|
<option value="all">{t('filters.allDistricts')}</option>
|
||||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
|
<FilterControls.Group label={t('filters.channel')}>
|
||||||
|
<MultiSelect
|
||||||
|
options={channels}
|
||||||
|
selected={filters.channel}
|
||||||
|
onChange={selected => setFilters({...filters, channel: selected})}
|
||||||
|
allLabel={t('filters.allChannels')}
|
||||||
|
/>
|
||||||
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.museum')}>
|
<FilterControls.Group label={t('filters.museum')}>
|
||||||
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
|
<MultiSelect
|
||||||
<option value="all">{t('filters.allMuseums')}</option>
|
options={availableMuseums}
|
||||||
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
selected={filters.museum}
|
||||||
</select>
|
onChange={selected => setFilters({...filters, museum: selected})}
|
||||||
|
allLabel={t('filters.allMuseums')}
|
||||||
|
/>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
</FilterControls.Row>
|
</FilterControls.Row>
|
||||||
</FilterControls>
|
</FilterControls>
|
||||||
@@ -802,12 +929,12 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricCard({ title, prev, curr, change, isCurrency, isPercent, pendingMessage, prevYear, currYear }) {
|
function MetricCard({ title, prev, curr, change, isCurrency, isPercent, pendingMessage, prevYear, currYear }: MetricCardProps) {
|
||||||
const hasPending = prev === null || curr === null;
|
const hasPending = prev === null || curr === null;
|
||||||
const isPositive = change >= 0;
|
const isPositive = (change ?? 0) >= 0;
|
||||||
const changeText = (hasPending && pendingMessage) ? null : (change === Infinity || change === null ? '—' : `${isPositive ? '+' : ''}${change.toFixed(1)}%`);
|
const changeText = (hasPending && pendingMessage) ? null : (change === Infinity || change === null ? '—' : `${isPositive ? '+' : ''}${change.toFixed(1)}%`);
|
||||||
|
|
||||||
const formatValue = (val) => {
|
const formatValue = (val: number | null | undefined) => {
|
||||||
if (val === null || val === undefined) return '—';
|
if (val === null || val === undefined) return '—';
|
||||||
if (isPercent) return val.toFixed(2) + '%';
|
if (isPercent) return val.toFixed(2) + '%';
|
||||||
if (isCurrency) return formatCompactCurrency(val);
|
if (isCurrency) return formatCompactCurrency(val);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams, Link } from 'react-router-dom';
|
||||||
import { Line, Doughnut, Bar } from 'react-chartjs-2';
|
import { Line, Bar, Pie } from 'react-chartjs-2';
|
||||||
import { Carousel, EmptyState, FilterControls, StatCard } from './shared';
|
import { Carousel, EmptyState, FilterControls, MultiSelect, StatCard } from './shared';
|
||||||
import { ExportableChart } from './ChartExport';
|
import { ExportableChart } from './ChartExport';
|
||||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import {
|
import {
|
||||||
filterData,
|
filterData,
|
||||||
@@ -12,25 +12,29 @@ import {
|
|||||||
formatNumber,
|
formatNumber,
|
||||||
groupByWeek,
|
groupByWeek,
|
||||||
groupByMuseum,
|
groupByMuseum,
|
||||||
groupByDistrict,
|
groupByChannel,
|
||||||
umrahData,
|
umrahData,
|
||||||
fetchPilgrimStats,
|
fetchPilgrimStats,
|
||||||
getUniqueYears,
|
getUniqueYears,
|
||||||
|
getUniqueChannels,
|
||||||
|
getUniqueMuseums,
|
||||||
getUniqueDistricts,
|
getUniqueDistricts,
|
||||||
getDistrictMuseumMap,
|
getMuseumsForDistrict,
|
||||||
getMuseumsForDistrict
|
groupByDistrict
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
|
import type { DashboardProps, Filters, MuseumRecord, Season } from '../types';
|
||||||
|
|
||||||
const defaultFilters = {
|
const defaultFilters: Filters = {
|
||||||
year: 'all',
|
year: 'all',
|
||||||
district: 'all',
|
district: 'all',
|
||||||
museum: 'all',
|
channel: [],
|
||||||
|
museum: [],
|
||||||
quarter: 'all'
|
quarter: 'all'
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterKeys = ['year', 'district', 'museum', 'quarter'];
|
const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter'];
|
||||||
|
|
||||||
function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }) {
|
function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: DashboardProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [pilgrimLoaded, setPilgrimLoaded] = useState(false);
|
const [pilgrimLoaded, setPilgrimLoaded] = useState(false);
|
||||||
@@ -41,38 +45,74 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Initialize filters from URL or defaults
|
// Initialize filters from URL or defaults
|
||||||
const [filters, setFiltersState] = useState(() => {
|
const [filters, setFiltersState] = useState<Filters>(() => {
|
||||||
const initial = { ...defaultFilters };
|
const initial: Filters = { ...defaultFilters };
|
||||||
filterKeys.forEach(key => {
|
filterKeys.forEach(key => {
|
||||||
const value = searchParams.get(key);
|
const value = searchParams.get(key);
|
||||||
if (value) initial[key] = value;
|
if (value) (initial as Record<string, unknown>)[key] = value;
|
||||||
});
|
});
|
||||||
|
const museumParam = searchParams.get('museum');
|
||||||
|
if (museumParam) initial.museum = museumParam.split(',').filter(Boolean);
|
||||||
|
const channelParam = searchParams.get('channel');
|
||||||
|
if (channelParam) initial.channel = channelParam.split(',').filter(Boolean);
|
||||||
return initial;
|
return initial;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update both state and URL
|
// Update both state and URL
|
||||||
const setFilters = (newFilters) => {
|
const setFilters = (newFilters: Filters | ((prev: Filters) => Filters)) => {
|
||||||
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
|
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
|
||||||
setFiltersState(updated);
|
setFiltersState(updated);
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
filterKeys.forEach(key => {
|
filterKeys.forEach(key => {
|
||||||
if (updated[key] && updated[key] !== 'all') {
|
const val = (updated as Record<string, unknown>)[key] as string;
|
||||||
params.set(key, updated[key]);
|
if (val && val !== 'all') {
|
||||||
|
params.set(key, val);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (updated.museum.length > 0) params.set('museum', updated.museum.join(','));
|
||||||
|
if (updated.channel.length > 0) params.set('channel', updated.channel.join(','));
|
||||||
setSearchParams(params, { replace: true });
|
setSearchParams(params, { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const [activeStatCard, setActiveStatCard] = useState(0);
|
const [activeStatCard, setActiveStatCard] = useState(0);
|
||||||
const [activeChart, setActiveChart] = useState(0);
|
const [activeChart, setActiveChart] = useState(0);
|
||||||
const [trendGranularity, setTrendGranularity] = useState('week');
|
const [trendGranularity, setTrendGranularity] = useState('week');
|
||||||
|
const [selectedSeason, setSelectedSeason] = useState<string>('');
|
||||||
|
const [eventMetric, setEventMetric] = useState<'visitors' | 'revenue'>('revenue');
|
||||||
|
const [eventChartType, setEventChartType] = useState<'bar' | 'pie'>('pie');
|
||||||
|
const [channelChartType, setChannelChartType] = useState<'bar' | 'pie'>('pie');
|
||||||
|
const [channelDisplayMode, setChannelDisplayMode] = useState<'absolute' | 'percent'>('absolute');
|
||||||
|
const [eventDisplayMode, setEventDisplayMode] = useState<'absolute' | 'percent'>('absolute');
|
||||||
|
const [districtChartType, setDistrictChartType] = useState<'bar' | 'pie'>('pie');
|
||||||
|
const [districtDisplayMode, setDistrictDisplayMode] = useState<'absolute' | 'percent'>('absolute');
|
||||||
|
|
||||||
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
|
// Permission base filter — applied before any user-facing filter
|
||||||
const metrics = useMemo(() => calculateMetrics(filteredData, includeVAT), [filteredData, includeVAT]);
|
// null = corrupted value → fail-closed (show nothing)
|
||||||
const hasData = filteredData.length > 0;
|
const permissionFilteredData = useMemo(() => {
|
||||||
|
if (allowedMuseums === null || allowedChannels === null) return [];
|
||||||
|
let d = data;
|
||||||
|
if (allowedMuseums.length > 0) d = d.filter(r => allowedMuseums.includes(r.museum_name));
|
||||||
|
if (allowedChannels.length > 0) d = d.filter(r => allowedChannels.includes(r.channel));
|
||||||
|
return d;
|
||||||
|
}, [data, allowedMuseums, allowedChannels]);
|
||||||
|
|
||||||
const resetFilters = () => setFilters(defaultFilters);
|
const filteredData = useMemo(() => filterData(permissionFilteredData, filters), [permissionFilteredData, filters]);
|
||||||
|
|
||||||
|
const seasonFilteredData = useMemo(() => {
|
||||||
|
if (!selectedSeason) return filteredData;
|
||||||
|
const season = seasons.find(s => String(s.Id) === selectedSeason);
|
||||||
|
if (!season) return filteredData;
|
||||||
|
return filteredData.filter(row => row.date >= season.StartDate && row.date <= season.EndDate);
|
||||||
|
}, [filteredData, selectedSeason, seasons]);
|
||||||
|
|
||||||
|
const metrics = useMemo(() => calculateMetrics(seasonFilteredData, includeVAT), [seasonFilteredData, includeVAT]);
|
||||||
|
const hasData = seasonFilteredData.length > 0;
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setFilters(defaultFilters);
|
||||||
|
setSelectedSeason('');
|
||||||
|
};
|
||||||
|
|
||||||
// Stat cards for carousel
|
// Stat cards for carousel
|
||||||
const statCards = useMemo(() => [
|
const statCards = useMemo(() => [
|
||||||
@@ -84,29 +124,28 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
|
|
||||||
// Chart carousel labels
|
// Chart carousel labels
|
||||||
const chartLabels = useMemo(() => {
|
const chartLabels = useMemo(() => {
|
||||||
const labels = [t('charts.revenueTrend'), t('charts.visitors'), t('charts.revenue'), t('charts.quarterly'), t('charts.district'), t('charts.captureRate')];
|
return [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);
|
}, [t]);
|
||||||
}, [filters.museum, t]);
|
|
||||||
|
|
||||||
// Dynamic lists from data
|
// Dynamic lists from data
|
||||||
const years = useMemo(() => getUniqueYears(data), [data]);
|
const years = useMemo(() => getUniqueYears(permissionFilteredData), [permissionFilteredData]);
|
||||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
const districts = useMemo(() => getUniqueDistricts(permissionFilteredData), [permissionFilteredData]);
|
||||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
const channels = useMemo(() => getUniqueChannels(permissionFilteredData), [permissionFilteredData]);
|
||||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
|
const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]);
|
||||||
|
|
||||||
const yoyChange = useMemo(() => {
|
const yoyChange = useMemo(() => {
|
||||||
if (filters.year === 'all') return null;
|
if (filters.year === 'all') return null;
|
||||||
const prevYear = String(parseInt(filters.year) - 1);
|
const prevYear = String(parseInt(filters.year) - 1);
|
||||||
const prevData = data.filter(row => row.year === prevYear);
|
const prevData = permissionFilteredData.filter((row: MuseumRecord) => row.year === prevYear);
|
||||||
if (prevData.length === 0) return null;
|
if (prevData.length === 0) return null;
|
||||||
const prevMetrics = calculateMetrics(prevData, includeVAT);
|
const prevMetrics = calculateMetrics(prevData, includeVAT);
|
||||||
return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null;
|
return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null;
|
||||||
}, [data, filters.year, metrics.revenue, includeVAT]);
|
}, [permissionFilteredData, filters.year, metrics.revenue, includeVAT]);
|
||||||
|
|
||||||
// Revenue trend data (weekly or daily)
|
// Revenue trend data (weekly or daily)
|
||||||
const trendData = useMemo(() => {
|
const trendData = useMemo(() => {
|
||||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||||
const formatLabel = (dateStr) => {
|
const formatLabel = (dateStr: string) => {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
const [year, month, day] = dateStr.split('-').map(Number);
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
const d = new Date(year, month - 1, day);
|
const d = new Date(year, month - 1, day);
|
||||||
@@ -143,11 +182,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (trendGranularity === 'week') {
|
if (trendGranularity === 'week') {
|
||||||
const grouped = groupByWeek(filteredData, includeVAT);
|
const grouped = groupByWeek(seasonFilteredData, includeVAT);
|
||||||
const weeks = Object.keys(grouped).filter(w => w).sort();
|
const weeks = Object.keys(grouped).filter(w => w).sort();
|
||||||
const revenueValues = weeks.map(w => grouped[w].revenue);
|
const revenueValues = weeks.map(w => grouped[w].revenue);
|
||||||
return {
|
return {
|
||||||
labels: weeks.map(formatLabel),
|
labels: weeks.map(formatLabel),
|
||||||
|
rawDates: weeks,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
||||||
data: revenueValues,
|
data: revenueValues,
|
||||||
@@ -163,15 +203,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
} else {
|
} else {
|
||||||
// Daily granularity
|
// Daily granularity
|
||||||
const dailyData: Record<string, number> = {};
|
const dailyData: Record<string, number> = {};
|
||||||
filteredData.forEach(row => {
|
seasonFilteredData.forEach(row => {
|
||||||
const date = row.date;
|
const date = row.date;
|
||||||
if (!dailyData[date]) dailyData[date] = 0;
|
if (!dailyData[date]) dailyData[date] = 0;
|
||||||
dailyData[date] += Number(row[revenueField] || row.revenue_incl_tax || 0);
|
dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || 0);
|
||||||
});
|
});
|
||||||
const days = Object.keys(dailyData).sort();
|
const days = Object.keys(dailyData).sort();
|
||||||
const revenueValues = days.map(d => dailyData[d]);
|
const revenueValues = days.map(d => dailyData[d]);
|
||||||
return {
|
return {
|
||||||
labels: days.map(formatLabel),
|
labels: days.map(formatLabel),
|
||||||
|
rawDates: days,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
||||||
data: revenueValues,
|
data: revenueValues,
|
||||||
@@ -185,64 +226,110 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
}, trendlineDataset(revenueValues)]
|
}, trendlineDataset(revenueValues)]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [filteredData, trendGranularity, includeVAT]);
|
}, [seasonFilteredData, trendGranularity, includeVAT]);
|
||||||
|
|
||||||
// Museum data
|
// Museum data
|
||||||
const museumData = useMemo(() => {
|
const museumData = useMemo(() => {
|
||||||
const grouped = groupByMuseum(filteredData, includeVAT);
|
const grouped = groupByMuseum(seasonFilteredData, includeVAT);
|
||||||
const museums = Object.keys(grouped);
|
const museums = Object.keys(grouped);
|
||||||
return {
|
return {
|
||||||
visitors: {
|
visitors: {
|
||||||
labels: museums,
|
labels: museums,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: museums.map(m => grouped[m].visitors),
|
data: museums.map(m => grouped[m].visitors),
|
||||||
backgroundColor: [chartColors.primary + 'cc', chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
backgroundColor: museums.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'),
|
||||||
borderWidth: 0
|
borderWidth: 0,
|
||||||
|
borderRadius: 4
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
revenue: {
|
revenue: {
|
||||||
labels: museums,
|
labels: museums,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: museums.map(m => grouped[m].revenue),
|
data: museums.map(m => grouped[m].revenue),
|
||||||
backgroundColor: [chartColors.primary + 'cc', chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
backgroundColor: museums.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'),
|
||||||
borderRadius: 4
|
borderRadius: 4
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [filteredData, includeVAT]);
|
}, [seasonFilteredData, includeVAT]);
|
||||||
|
|
||||||
// District data
|
// Channel data
|
||||||
const districtData = useMemo(() => {
|
const channelData = useMemo(() => {
|
||||||
const grouped = groupByDistrict(filteredData, includeVAT);
|
const grouped = groupByChannel(seasonFilteredData, includeVAT);
|
||||||
const districts = Object.keys(grouped);
|
const channels = Object.keys(grouped);
|
||||||
return {
|
return {
|
||||||
labels: districts,
|
labels: channels,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: districts.map(d => grouped[d].revenue),
|
data: channels.map(d => grouped[d].revenue),
|
||||||
backgroundColor: [chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
backgroundColor: channels.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'),
|
||||||
borderRadius: 4
|
borderRadius: 4
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
}, [filteredData, includeVAT]);
|
}, [seasonFilteredData, includeVAT]);
|
||||||
|
|
||||||
|
const eventChartData = useMemo(() => {
|
||||||
|
const source = museumData[eventMetric];
|
||||||
|
if (eventDisplayMode === 'absolute') return source;
|
||||||
|
const total = source.datasets[0].data.reduce((s: number, v: number) => s + v, 0);
|
||||||
|
if (total === 0) return source;
|
||||||
|
return {
|
||||||
|
...source,
|
||||||
|
datasets: [{ ...source.datasets[0], data: source.datasets[0].data.map((v: number) => parseFloat(((v / total) * 100).toFixed(1))) }]
|
||||||
|
};
|
||||||
|
}, [museumData, eventMetric, eventDisplayMode]);
|
||||||
|
|
||||||
|
const channelChartData = useMemo(() => {
|
||||||
|
if (channelDisplayMode === 'absolute') return channelData;
|
||||||
|
const total = channelData.datasets[0].data.reduce((s: number, v: number) => s + v, 0);
|
||||||
|
if (total === 0) return channelData;
|
||||||
|
return {
|
||||||
|
...channelData,
|
||||||
|
datasets: [{ ...channelData.datasets[0], data: channelData.datasets[0].data.map((v: number) => parseFloat(((v / total) * 100).toFixed(1))) }]
|
||||||
|
};
|
||||||
|
}, [channelData, channelDisplayMode]);
|
||||||
|
|
||||||
|
// District data
|
||||||
|
const districtData = useMemo(() => {
|
||||||
|
const grouped = groupByDistrict(seasonFilteredData, includeVAT);
|
||||||
|
const districtNames = Object.keys(grouped);
|
||||||
|
return {
|
||||||
|
labels: districtNames,
|
||||||
|
datasets: [{
|
||||||
|
data: districtNames.map(d => grouped[d].revenue),
|
||||||
|
backgroundColor: districtNames.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'),
|
||||||
|
borderRadius: 4
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}, [seasonFilteredData, includeVAT]);
|
||||||
|
|
||||||
|
const districtChartData = useMemo(() => {
|
||||||
|
if (districtDisplayMode === 'absolute') return districtData;
|
||||||
|
const total = districtData.datasets[0].data.reduce((s: number, v: number) => s + v, 0);
|
||||||
|
if (total === 0) return districtData;
|
||||||
|
return {
|
||||||
|
...districtData,
|
||||||
|
datasets: [{ ...districtData.datasets[0], data: districtData.datasets[0].data.map((v: number) => parseFloat(((v / total) * 100).toFixed(1))) }]
|
||||||
|
};
|
||||||
|
}, [districtData, districtDisplayMode]);
|
||||||
|
|
||||||
// Quarterly YoY
|
// Quarterly YoY
|
||||||
const quarterlyYoYData = useMemo(() => {
|
const quarterlyYoYData = useMemo(() => {
|
||||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||||
const d2024 = data.filter(row => row.year === '2024');
|
const d2024 = data.filter((row: MuseumRecord) => row.year === '2024');
|
||||||
const d2025 = data.filter(row => row.year === '2025');
|
const d2025 = data.filter((row: MuseumRecord) => row.year === '2025');
|
||||||
const quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
const quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||||
return {
|
return {
|
||||||
labels: quarters,
|
labels: quarters,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: '2024',
|
label: '2024',
|
||||||
data: quarters.map(q => d2024.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0)),
|
data: quarters.map(q => d2024.filter((r: MuseumRecord) => r.quarter === q.slice(1)).reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0)),
|
||||||
backgroundColor: chartColors.muted,
|
backgroundColor: chartColors.muted,
|
||||||
borderRadius: 4
|
borderRadius: 4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '2025',
|
label: '2025',
|
||||||
data: quarters.map(q => d2025.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0)),
|
data: quarters.map(q => d2025.filter((r: MuseumRecord) => r.quarter === q.slice(1)).reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0)),
|
||||||
backgroundColor: chartColors.primary,
|
backgroundColor: chartColors.primary,
|
||||||
borderRadius: 4
|
borderRadius: 4
|
||||||
}
|
}
|
||||||
@@ -252,17 +339,18 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
|
|
||||||
// Capture rate
|
// Capture rate
|
||||||
const captureRateData = useMemo(() => {
|
const captureRateData = useMemo(() => {
|
||||||
const labels = [];
|
const labels: string[] = [];
|
||||||
const rates = [];
|
const rates: number[] = [];
|
||||||
const pilgrimCounts = [];
|
const pilgrimCounts: number[] = [];
|
||||||
[2024, 2025].forEach(year => {
|
[2024, 2025].forEach(year => {
|
||||||
[1, 2, 3, 4].forEach(q => {
|
[1, 2, 3, 4].forEach(q => {
|
||||||
const pilgrims = umrahData[year]?.[q];
|
const pilgrims = umrahData[year]?.[q];
|
||||||
if (!pilgrims) return;
|
if (!pilgrims) return;
|
||||||
let qData = data.filter(r => r.year === String(year) && r.quarter === String(q));
|
let qData = data.filter((r: MuseumRecord) => r.year === String(year) && r.quarter === String(q));
|
||||||
if (filters.district !== 'all') qData = qData.filter(r => r.district === filters.district);
|
if (filters.district !== 'all') qData = qData.filter((r: MuseumRecord) => r.district === filters.district);
|
||||||
if (filters.museum !== 'all') qData = qData.filter(r => r.museum_name === filters.museum);
|
if (filters.channel.length > 0) qData = qData.filter((r: MuseumRecord) => filters.channel.includes(r.channel));
|
||||||
const visitors = qData.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
if (filters.museum.length > 0) qData = qData.filter((r: MuseumRecord) => filters.museum.includes(r.museum_name));
|
||||||
|
const visitors = qData.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
|
||||||
labels.push(`Q${q} ${year}`);
|
labels.push(`Q${q} ${year}`);
|
||||||
rates.push((visitors / pilgrims * 100));
|
rates.push((visitors / pilgrims * 100));
|
||||||
pilgrimCounts.push(pilgrims);
|
pilgrimCounts.push(pilgrims);
|
||||||
@@ -286,7 +374,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
yAxisID: 'y',
|
yAxisID: 'y',
|
||||||
datalabels: {
|
datalabels: {
|
||||||
display: showDataLabels,
|
display: showDataLabels,
|
||||||
formatter: (value) => value.toFixed(2) + '%',
|
formatter: (value: number) => value.toFixed(2) + '%',
|
||||||
color: '#1e293b',
|
color: '#1e293b',
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
@@ -312,7 +400,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
order: 1,
|
order: 1,
|
||||||
datalabels: {
|
datalabels: {
|
||||||
display: showDataLabels,
|
display: showDataLabels,
|
||||||
formatter: (value) => (value / 1000000).toFixed(2) + 'M',
|
formatter: (value: number) => (value / 1000000).toFixed(2) + 'M',
|
||||||
color: '#1e293b',
|
color: '#1e293b',
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
@@ -324,38 +412,81 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}, [data, filters.district, filters.museum, showDataLabels]);
|
}, [data, filters.district, filters.channel, filters.museum, showDataLabels]);
|
||||||
|
|
||||||
// Quarterly table
|
// Quarterly table
|
||||||
const quarterlyTable = useMemo(() => {
|
const quarterlyTable = useMemo(() => {
|
||||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||||
const d2024 = data.filter(row => row.year === '2024');
|
const d2024 = data.filter((row: MuseumRecord) => row.year === '2024');
|
||||||
const d2025 = data.filter(row => row.year === '2025');
|
const d2025 = data.filter((row: MuseumRecord) => row.year === '2025');
|
||||||
return [1, 2, 3, 4].map(q => {
|
return [1, 2, 3, 4].map(q => {
|
||||||
let q2024 = d2024.filter(r => r.quarter === String(q));
|
let q2024 = d2024.filter((r: MuseumRecord) => r.quarter === String(q));
|
||||||
let q2025 = d2025.filter(r => r.quarter === String(q));
|
let q2025 = d2025.filter((r: MuseumRecord) => r.quarter === String(q));
|
||||||
if (filters.district !== 'all') {
|
if (filters.district !== 'all') {
|
||||||
q2024 = q2024.filter(r => r.district === filters.district);
|
q2024 = q2024.filter((r: MuseumRecord) => r.district === filters.district);
|
||||||
q2025 = q2025.filter(r => r.district === filters.district);
|
q2025 = q2025.filter((r: MuseumRecord) => r.district === filters.district);
|
||||||
}
|
}
|
||||||
if (filters.museum !== 'all') {
|
if (filters.channel.length > 0) {
|
||||||
q2024 = q2024.filter(r => r.museum_name === filters.museum);
|
q2024 = q2024.filter((r: MuseumRecord) => filters.channel.includes(r.channel));
|
||||||
q2025 = q2025.filter(r => r.museum_name === filters.museum);
|
q2025 = q2025.filter((r: MuseumRecord) => filters.channel.includes(r.channel));
|
||||||
}
|
}
|
||||||
const rev24 = q2024.reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0);
|
if (filters.museum.length > 0) {
|
||||||
const rev25 = q2025.reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0);
|
q2024 = q2024.filter((r: MuseumRecord) => filters.museum.includes(r.museum_name));
|
||||||
const vis24 = q2024.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
q2025 = q2025.filter((r: MuseumRecord) => filters.museum.includes(r.museum_name));
|
||||||
const vis25 = q2025.reduce((s, r) => s + parseInt(r.visits || 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;
|
const revChg = rev24 > 0 ? ((rev25 - rev24) / rev24 * 100) : 0;
|
||||||
const visChg = vis24 > 0 ? ((vis25 - vis24) / vis24 * 100) : 0;
|
const visChg = vis24 > 0 ? ((vis25 - vis24) / vis24 * 100) : 0;
|
||||||
const cap24 = umrahData[2024][q] ? (vis24 / umrahData[2024][q] * 100) : null;
|
const cap24 = umrahData[2024][q] ? (vis24 / umrahData[2024][q] * 100) : null;
|
||||||
const cap25 = umrahData[2025][q] ? (vis25 / umrahData[2025][q] * 100) : null;
|
const cap25 = umrahData[2025][q] ? (vis25 / umrahData[2025][q] * 100) : null;
|
||||||
return { q, rev24, rev25, revChg, vis24, vis25, visChg, cap24, cap25 };
|
return { q, rev24, rev25, revChg, vis24, vis25, visChg, cap24, cap25 };
|
||||||
});
|
});
|
||||||
}, [data, filters.district, filters.museum, includeVAT]);
|
}, [data, filters.district, filters.channel, filters.museum, includeVAT]);
|
||||||
|
|
||||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||||
|
|
||||||
|
const pieOptions = useMemo(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, position: 'right' as const, labels: { boxWidth: 12, padding: 10, font: { size: 11 }, color: '#64748b' } },
|
||||||
|
tooltip: baseOptions.plugins.tooltip,
|
||||||
|
datalabels: { display: false }
|
||||||
|
}
|
||||||
|
}), [baseOptions]);
|
||||||
|
|
||||||
|
// Season annotation bands for revenue trend chart
|
||||||
|
const seasonAnnotations = useMemo(() => {
|
||||||
|
const raw = trendData.rawDates;
|
||||||
|
if (!seasons.length || !raw?.length) return {};
|
||||||
|
const annotations: Record<string, unknown> = {};
|
||||||
|
seasons.forEach((s, i) => {
|
||||||
|
const startIdx = raw.findIndex(d => d >= s.StartDate);
|
||||||
|
const endIdx = raw.length - 1 - [...raw].reverse().findIndex(d => d <= s.EndDate);
|
||||||
|
if (startIdx === -1 || endIdx < startIdx) return;
|
||||||
|
annotations[`season${i}`] = {
|
||||||
|
type: 'box',
|
||||||
|
xMin: startIdx - 0.5,
|
||||||
|
xMax: endIdx + 0.5,
|
||||||
|
backgroundColor: s.Color + '20',
|
||||||
|
borderColor: s.Color + '40',
|
||||||
|
borderWidth: 1,
|
||||||
|
label: {
|
||||||
|
display: true,
|
||||||
|
content: `${s.Name} ${s.HijriYear}`,
|
||||||
|
position: 'start',
|
||||||
|
color: s.Color,
|
||||||
|
font: { size: 10, weight: '600' },
|
||||||
|
padding: 4
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return annotations;
|
||||||
|
}, [seasons, trendData.rawDates]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard" id="dashboard-container">
|
<div className="dashboard" id="dashboard-container">
|
||||||
<div className="page-title-with-actions">
|
<div className="page-title-with-actions">
|
||||||
@@ -390,16 +521,26 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.district')}>
|
<FilterControls.Group label={t('filters.district')}>
|
||||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: []})}>
|
||||||
<option value="all">{t('filters.allDistricts')}</option>
|
<option value="all">{t('filters.allDistricts')}</option>
|
||||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
|
<FilterControls.Group label={t('filters.channel')}>
|
||||||
|
<MultiSelect
|
||||||
|
options={channels}
|
||||||
|
selected={filters.channel}
|
||||||
|
onChange={channel => setFilters({...filters, channel})}
|
||||||
|
allLabel={t('filters.allChannels')}
|
||||||
|
/>
|
||||||
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.museum')}>
|
<FilterControls.Group label={t('filters.museum')}>
|
||||||
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
|
<MultiSelect
|
||||||
<option value="all">{t('filters.allMuseums')}</option>
|
options={availableMuseums}
|
||||||
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
selected={filters.museum}
|
||||||
</select>
|
onChange={museum => setFilters({...filters, museum})}
|
||||||
|
allLabel={t('filters.allMuseums')}
|
||||||
|
/>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.quarter')}>
|
<FilterControls.Group label={t('filters.quarter')}>
|
||||||
<select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}>
|
<select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}>
|
||||||
@@ -410,6 +551,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
<option value="4">{t('time.q4')}</option>
|
<option value="4">{t('time.q4')}</option>
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
|
<FilterControls.Group label={t('filters.season')}>
|
||||||
|
<select value={selectedSeason} onChange={e => setSelectedSeason(e.target.value)}>
|
||||||
|
<option value="">{t('filters.allSeasons')}</option>
|
||||||
|
{seasons.map(s => (
|
||||||
|
<option key={s.Id} value={String(s.Id)}>
|
||||||
|
{s.Name} {s.HijriYear}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FilterControls.Group>
|
||||||
</FilterControls.Row>
|
</FilterControls.Row>
|
||||||
</FilterControls>
|
</FilterControls>
|
||||||
|
|
||||||
@@ -503,25 +654,47 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 13}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
|
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, annotation: { annotations: seasonAnnotations }, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 13}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
|
||||||
</ExportableChart>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filters.museum === 'all' && (
|
<div className="chart-card half-width">
|
||||||
<div className="chart-card half-width">
|
<ExportableChart
|
||||||
<ExportableChart filename="visitors-by-museum" title={t('dashboard.visitorsByMuseum')} className="chart-container">
|
filename={eventMetric === 'visitors' ? 'visitors-by-event' : 'revenue-by-event'}
|
||||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 16, font: {size: 13}}}}}} />
|
title={eventMetric === 'visitors' ? t('dashboard.visitorsByMuseum') : t('dashboard.revenueByMuseum')}
|
||||||
</ExportableChart>
|
className="chart-container"
|
||||||
</div>
|
controls={
|
||||||
)}
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
<div className="toggle-switch">
|
||||||
{filters.museum === 'all' && (
|
<button className={eventMetric === 'visitors' ? 'active' : ''} onClick={() => setEventMetric('visitors')}>{t('metrics.visitors')}</button>
|
||||||
<div className="chart-card half-width">
|
<button className={eventMetric === 'revenue' ? 'active' : ''} onClick={() => setEventMetric('revenue')}>{t('metrics.revenue')}</button>
|
||||||
<ExportableChart filename="revenue-by-museum" title={t('dashboard.revenueByMuseum')} className="chart-container">
|
</div>
|
||||||
<Bar data={museumData.revenue} options={baseOptions} />
|
<div className="toggle-switch">
|
||||||
</ExportableChart>
|
<button className={eventChartType === 'bar' ? 'active' : ''} onClick={() => setEventChartType('bar')}>{t('metrics.bar')}</button>
|
||||||
</div>
|
<button className={eventChartType === 'pie' ? 'active' : ''} onClick={() => setEventChartType('pie')}>{t('metrics.pie')}</button>
|
||||||
)}
|
</div>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={eventDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setEventDisplayMode('absolute')}>#</button>
|
||||||
|
<button className={eventDisplayMode === 'percent' ? 'active' : ''} onClick={() => setEventDisplayMode('percent')}>%</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{eventChartType === 'bar'
|
||||||
|
? <Bar data={eventChartData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||||
|
: <Pie data={eventChartData} options={{
|
||||||
|
...pieOptions,
|
||||||
|
plugins: {
|
||||||
|
...pieOptions.plugins,
|
||||||
|
datalabels: eventDisplayMode === 'percent'
|
||||||
|
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
|
||||||
|
: { display: false },
|
||||||
|
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => eventDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
</ExportableChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<ExportableChart filename="quarterly-yoy" title={t('dashboard.quarterlyRevenue')} className="chart-container">
|
<ExportableChart filename="quarterly-yoy" title={t('dashboard.quarterlyRevenue')} className="chart-container">
|
||||||
@@ -530,8 +703,75 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<ExportableChart filename="district-performance" title={t('dashboard.districtPerformance')} className="chart-container">
|
<ExportableChart
|
||||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
filename="channel-performance"
|
||||||
|
title={t('dashboard.channelPerformance')}
|
||||||
|
className="chart-container"
|
||||||
|
controls={
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={channelChartType === 'bar' ? 'active' : ''} onClick={() => setChannelChartType('bar')}>{t('metrics.bar')}</button>
|
||||||
|
<button className={channelChartType === 'pie' ? 'active' : ''} onClick={() => setChannelChartType('pie')}>{t('metrics.pie')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={channelDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setChannelDisplayMode('absolute')}>#</button>
|
||||||
|
<button className={channelDisplayMode === 'percent' ? 'active' : ''} onClick={() => setChannelDisplayMode('percent')}>%</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{channelChartType === 'bar'
|
||||||
|
? <Bar data={channelChartData} options={{
|
||||||
|
...baseOptions,
|
||||||
|
indexAxis: 'y',
|
||||||
|
plugins: { ...baseOptions.plugins, datalabels: { ...baseOptions.plugins.datalabels, formatter: (v: number) => channelDisplayMode === 'percent' ? v.toFixed(1) + '%' : baseOptions.plugins.datalabels.formatter(v, {} as any) } },
|
||||||
|
scales: { ...baseOptions.scales, x: { ...baseOptions.scales.x, ticks: { ...baseOptions.scales.x.ticks, callback: (v: number | string) => channelDisplayMode === 'percent' ? v + '%' : v } } }
|
||||||
|
}} />
|
||||||
|
: <Pie data={channelChartData} options={{
|
||||||
|
...pieOptions,
|
||||||
|
plugins: {
|
||||||
|
...pieOptions.plugins,
|
||||||
|
datalabels: channelDisplayMode === 'percent'
|
||||||
|
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
|
||||||
|
: { display: false },
|
||||||
|
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => channelDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
</ExportableChart>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-card half-width">
|
||||||
|
<ExportableChart
|
||||||
|
filename="district-performance"
|
||||||
|
title={t('dashboard.districtPerformance')}
|
||||||
|
className="chart-container"
|
||||||
|
controls={
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={districtChartType === 'bar' ? 'active' : ''} onClick={() => setDistrictChartType('bar')}>{t('metrics.bar')}</button>
|
||||||
|
<button className={districtChartType === 'pie' ? 'active' : ''} onClick={() => setDistrictChartType('pie')}>{t('metrics.pie')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={districtDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setDistrictDisplayMode('absolute')}>#</button>
|
||||||
|
<button className={districtDisplayMode === 'percent' ? 'active' : ''} onClick={() => setDistrictDisplayMode('percent')}>%</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{districtChartType === 'bar'
|
||||||
|
? <Bar data={districtChartData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||||
|
: <Pie data={districtChartData} options={{
|
||||||
|
...pieOptions,
|
||||||
|
plugins: {
|
||||||
|
...pieOptions.plugins,
|
||||||
|
datalabels: districtDisplayMode === 'percent'
|
||||||
|
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
|
||||||
|
: { display: false },
|
||||||
|
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => districtDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
}
|
||||||
</ExportableChart>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -545,7 +785,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
tooltip: {
|
tooltip: {
|
||||||
...baseOptions.plugins.tooltip,
|
...baseOptions.plugins.tooltip,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: (ctx) => {
|
label: (ctx: { dataset: { label?: string }; parsed: { y: number } }) => {
|
||||||
if (ctx.dataset.label === 'Capture Rate (%)') {
|
if (ctx.dataset.label === 'Capture Rate (%)') {
|
||||||
return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`;
|
return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`;
|
||||||
}
|
}
|
||||||
@@ -560,7 +800,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
type: 'linear',
|
type: 'linear',
|
||||||
position: 'left',
|
position: 'left',
|
||||||
grid: { color: chartColors.grid },
|
grid: { color: chartColors.grid },
|
||||||
ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
|
ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v: number | string) => Number(v).toFixed(1) + '%' },
|
||||||
border: { display: false },
|
border: { display: false },
|
||||||
title: { display: true, text: 'Capture Rate (%)', font: { size: 12 }, color: chartColors.secondary }
|
title: { display: true, text: 'Capture Rate (%)', font: { size: 12 }, color: chartColors.secondary }
|
||||||
},
|
},
|
||||||
@@ -568,7 +808,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
type: 'linear',
|
type: 'linear',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
grid: { drawOnChartArea: false },
|
grid: { drawOnChartArea: false },
|
||||||
ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
|
ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v: number | string) => (Number(v) / 1000000).toFixed(0) + 'M' },
|
||||||
border: { display: false },
|
border: { display: false },
|
||||||
title: { display: true, text: 'Pilgrims', font: { size: 12 }, color: chartColors.tertiary }
|
title: { display: true, text: 'Pilgrims', font: { size: 12 }, color: chartColors.tertiary }
|
||||||
}
|
}
|
||||||
@@ -594,32 +834,45 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button>
|
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-container">
|
<div className="chart-container">
|
||||||
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 10, padding: 8, font: {size: 12}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} />
|
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, annotation: { annotations: seasonAnnotations }, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 10, padding: 8, font: {size: 12}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filters.museum === 'all' && (
|
<div className="carousel-slide">
|
||||||
<div className="carousel-slide">
|
<div className="chart-card">
|
||||||
<div className="chart-card">
|
<h2>{eventMetric === 'visitors' ? t('dashboard.visitorsByMuseum') : t('dashboard.revenueByMuseum')}</h2>
|
||||||
<h2>{t('dashboard.visitorsByMuseum')}</h2>
|
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
|
||||||
<div className="chart-container">
|
<div className="toggle-switch">
|
||||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 12, font: {size: 12}}}}}} />
|
<button className={eventMetric === 'visitors' ? 'active' : ''} onClick={() => setEventMetric('visitors')}>{t('metrics.visitors')}</button>
|
||||||
|
<button className={eventMetric === 'revenue' ? 'active' : ''} onClick={() => setEventMetric('revenue')}>{t('metrics.revenue')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={eventChartType === 'bar' ? 'active' : ''} onClick={() => setEventChartType('bar')}>{t('metrics.bar')}</button>
|
||||||
|
<button className={eventChartType === 'pie' ? 'active' : ''} onClick={() => setEventChartType('pie')}>{t('metrics.pie')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={eventDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setEventDisplayMode('absolute')}>#</button>
|
||||||
|
<button className={eventDisplayMode === 'percent' ? 'active' : ''} onClick={() => setEventDisplayMode('percent')}>%</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="chart-container">
|
||||||
)}
|
{eventChartType === 'bar'
|
||||||
|
? <Bar data={eventChartData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||||
{filters.museum === 'all' && (
|
: <Pie data={eventChartData} options={{
|
||||||
<div className="carousel-slide">
|
...pieOptions,
|
||||||
<div className="chart-card">
|
plugins: {
|
||||||
<h2>{t('dashboard.revenueByMuseum')}</h2>
|
...pieOptions.plugins,
|
||||||
<div className="chart-container">
|
datalabels: eventDisplayMode === 'percent'
|
||||||
<Bar data={museumData.revenue} options={baseOptions} />
|
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
|
||||||
</div>
|
: { display: false },
|
||||||
|
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => eventDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
@@ -632,9 +885,62 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
|
|
||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h2>{t('dashboard.districtPerformance')}</h2>
|
<h2>{t('dashboard.channelPerformance')}</h2>
|
||||||
|
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={channelChartType === 'bar' ? 'active' : ''} onClick={() => setChannelChartType('bar')}>{t('metrics.bar')}</button>
|
||||||
|
<button className={channelChartType === 'pie' ? 'active' : ''} onClick={() => setChannelChartType('pie')}>{t('metrics.pie')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={channelDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setChannelDisplayMode('absolute')}>#</button>
|
||||||
|
<button className={channelDisplayMode === 'percent' ? 'active' : ''} onClick={() => setChannelDisplayMode('percent')}>%</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="chart-container">
|
<div className="chart-container">
|
||||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
{channelChartType === 'bar'
|
||||||
|
? <Bar data={channelChartData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||||
|
: <Pie data={channelChartData} options={{
|
||||||
|
...pieOptions,
|
||||||
|
plugins: {
|
||||||
|
...pieOptions.plugins,
|
||||||
|
datalabels: channelDisplayMode === 'percent'
|
||||||
|
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
|
||||||
|
: { display: false },
|
||||||
|
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => channelDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="carousel-slide">
|
||||||
|
<div className="chart-card">
|
||||||
|
<h2>{t('dashboard.districtPerformance')}</h2>
|
||||||
|
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={districtChartType === 'bar' ? 'active' : ''} onClick={() => setDistrictChartType('bar')}>{t('metrics.bar')}</button>
|
||||||
|
<button className={districtChartType === 'pie' ? 'active' : ''} onClick={() => setDistrictChartType('pie')}>{t('metrics.pie')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={districtDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setDistrictDisplayMode('absolute')}>#</button>
|
||||||
|
<button className={districtDisplayMode === 'percent' ? 'active' : ''} onClick={() => setDistrictDisplayMode('percent')}>%</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chart-container">
|
||||||
|
{districtChartType === 'bar'
|
||||||
|
? <Bar data={districtChartData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||||
|
: <Pie data={districtChartData} options={{
|
||||||
|
...pieOptions,
|
||||||
|
plugins: {
|
||||||
|
...pieOptions.plugins,
|
||||||
|
datalabels: districtDisplayMode === 'percent'
|
||||||
|
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
|
||||||
|
: { display: false },
|
||||||
|
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => districtDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -651,7 +957,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
tooltip: {
|
tooltip: {
|
||||||
...baseOptions.plugins.tooltip,
|
...baseOptions.plugins.tooltip,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: (ctx) => {
|
label: (ctx: { dataset: { label?: string }; parsed: { y: number } }) => {
|
||||||
if (ctx.dataset.label === 'Capture Rate (%)') {
|
if (ctx.dataset.label === 'Capture Rate (%)') {
|
||||||
return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`;
|
return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`;
|
||||||
}
|
}
|
||||||
@@ -666,14 +972,14 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
type: 'linear',
|
type: 'linear',
|
||||||
position: 'left',
|
position: 'left',
|
||||||
grid: { color: chartColors.grid },
|
grid: { color: chartColors.grid },
|
||||||
ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
|
ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v: number | string) => Number(v).toFixed(1) + '%' },
|
||||||
border: { display: false }
|
border: { display: false }
|
||||||
},
|
},
|
||||||
y1: {
|
y1: {
|
||||||
type: 'linear',
|
type: 'linear',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
grid: { drawOnChartArea: false },
|
grid: { drawOnChartArea: false },
|
||||||
ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
|
ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v: number | string) => (Number(v) / 1000000).toFixed(0) + 'M' },
|
||||||
border: { display: false }
|
border: { display: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -699,6 +1005,15 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{userRole === 'admin' && <div className="settings-link">
|
||||||
|
<Link to="/settings">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
{t('nav.settings')}
|
||||||
|
</Link>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
75
src/components/Login.tsx
Normal file
75
src/components/Login.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface LoginProps {
|
||||||
|
onLogin: (name: string, role: string, allowedMuseums: string, allowedChannels: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Login({ onLogin }: LoginProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [pin, setPin] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ pin }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(t('login.invalid'));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
onLogin(data.name || '', data.role || 'viewer', data.allowedMuseums ?? '[]', data.allowedChannels ?? '[]');
|
||||||
|
} catch {
|
||||||
|
setError(t('login.error'));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-brand">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="var(--accent)" aria-hidden="true">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="3" width="7" height="4" rx="1"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="11" width="7" height="10" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
<h1>HiHala Data</h1>
|
||||||
|
</div>
|
||||||
|
<p className="login-subtitle">{t('login.subtitle')}</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={pin}
|
||||||
|
onChange={e => setPin(e.target.value)}
|
||||||
|
placeholder={t('login.placeholder')}
|
||||||
|
autoFocus
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{error && <p className="login-error">{error}</p>}
|
||||||
|
<button type="submit" disabled={loading || !pin}>
|
||||||
|
{loading ? '...' : t('login.submit')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
||||||
339
src/components/Settings.tsx
Normal file
339
src/components/Settings.tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { fetchSeasons, createSeason, updateSeason, deleteSeason } from '../services/seasonsService';
|
||||||
|
import { fetchUsers, createUser, updateUser, deleteUser, type User } from '../services/usersService';
|
||||||
|
import type { Season } from '../types';
|
||||||
|
|
||||||
|
const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899'];
|
||||||
|
|
||||||
|
interface SeasonRowProps {
|
||||||
|
season: Season;
|
||||||
|
onSave: (id: number, data: Partial<Season>) => Promise<void>;
|
||||||
|
onDelete: (id: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeasonRow({ season, onSave, onDelete }: SeasonRowProps) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [form, setForm] = useState(season);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await onSave(season.Id!, form);
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span className="season-chip" style={{ backgroundColor: season.Color + '20', color: season.Color, borderColor: season.Color }}>
|
||||||
|
{season.Name} {season.HijriYear}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{season.StartDate}</td>
|
||||||
|
<td>{season.EndDate}</td>
|
||||||
|
<td>
|
||||||
|
<div className="season-actions">
|
||||||
|
<button className="btn-small" onClick={() => setEditing(true)}>Edit</button>
|
||||||
|
<button className="btn-small btn-danger" onClick={() => onDelete(season.Id!)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="editing">
|
||||||
|
<td>
|
||||||
|
<div className="season-edit-name">
|
||||||
|
<input type="text" value={form.Name} onChange={e => setForm({ ...form, Name: e.target.value })} placeholder="Name" />
|
||||||
|
<input type="number" value={form.HijriYear} onChange={e => setForm({ ...form, HijriYear: parseInt(e.target.value) || 0 })} placeholder="Year" style={{ width: 80 }} />
|
||||||
|
<input type="color" value={form.Color} onChange={e => setForm({ ...form, Color: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><input type="date" value={form.StartDate} onChange={e => setForm({ ...form, StartDate: e.target.value })} /></td>
|
||||||
|
<td><input type="date" value={form.EndDate} onChange={e => setForm({ ...form, EndDate: e.target.value })} /></td>
|
||||||
|
<td>
|
||||||
|
<div className="season-actions">
|
||||||
|
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
|
||||||
|
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserRowProps {
|
||||||
|
user: User;
|
||||||
|
allMuseums: string[];
|
||||||
|
allChannels: string[];
|
||||||
|
onUpdate: (id: number, fields: Partial<User>) => Promise<void>;
|
||||||
|
onDelete: (id: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowProps) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [allowedMuseums, setAllowedMuseums] = useState<string[]>(() => {
|
||||||
|
try { return JSON.parse(user.AllowedMuseums || '[]'); } catch { return []; }
|
||||||
|
});
|
||||||
|
const [allowedChannels, setAllowedChannels] = useState<string[]>(() => {
|
||||||
|
try { return JSON.parse(user.AllowedChannels || '[]'); } catch { return []; }
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleItem = (list: string[], setList: (v: string[]) => void, item: string) =>
|
||||||
|
setList(list.includes(item) ? list.filter(x => x !== item) : [...list, item]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await onUpdate(user.Id!, {
|
||||||
|
AllowedMuseums: JSON.stringify(allowedMuseums),
|
||||||
|
AllowedChannels: JSON.stringify(allowedChannels),
|
||||||
|
});
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAdmin = user.Role === 'admin';
|
||||||
|
|
||||||
|
const museumCount = (() => { try { const a = JSON.parse(user.AllowedMuseums || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
|
||||||
|
const channelCount = (() => { try { const a = JSON.parse(user.AllowedChannels || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<tr key={user.Id}>
|
||||||
|
<td>{user.Name}</td>
|
||||||
|
<td><code>{user.PIN}</code></td>
|
||||||
|
<td>{user.Role}</td>
|
||||||
|
<td>
|
||||||
|
{isAdmin ? (
|
||||||
|
<span className="access-badge access-badge--full">Full access</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="access-badge">{museumCount === 0 ? 'All events' : `${museumCount} events`}</span>
|
||||||
|
<span className="access-badge">{channelCount === 0 ? 'All channels' : `${channelCount} channels`}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="season-actions">
|
||||||
|
{!isAdmin && <button className="btn-small" onClick={() => setEditing(true)}>Edit access</button>}
|
||||||
|
<button className="btn-small btn-danger" onClick={() => onDelete(user.Id!)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="editing">
|
||||||
|
<td colSpan={5}>
|
||||||
|
<div style={{ padding: '12px 4px' }}>
|
||||||
|
<strong>{user.Name}</strong>
|
||||||
|
<div style={{ display: 'flex', gap: 32, marginTop: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||||
|
Allowed Events {allowedMuseums.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
||||||
|
</div>
|
||||||
|
{allMuseums.map(m => (
|
||||||
|
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={allowedMuseums.includes(m)} onChange={() => toggleItem(allowedMuseums, setAllowedMuseums, m)} />
|
||||||
|
{m}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||||
|
Allowed Channels {allowedChannels.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
||||||
|
</div>
|
||||||
|
{allChannels.map(c => (
|
||||||
|
<label key={c} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={allowedChannels.includes(c)} onChange={() => toggleItem(allowedChannels, setAllowedChannels, c)} />
|
||||||
|
{c}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||||||
|
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
|
||||||
|
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsProps {
|
||||||
|
onSeasonsChange: () => void;
|
||||||
|
allMuseums: string[];
|
||||||
|
allChannels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [seasons, setSeasons] = useState<Season[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [newSeason, setNewSeason] = useState<Omit<Season, 'Id'>>({
|
||||||
|
Name: '',
|
||||||
|
HijriYear: new Date().getFullYear() - 579, // rough Gregorian → Hijri
|
||||||
|
StartDate: '',
|
||||||
|
EndDate: '',
|
||||||
|
Color: DEFAULT_COLORS[0],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [newUser, setNewUser] = useState<Omit<User, 'Id'>>({ Name: '', PIN: '', Role: 'viewer', AllowedMuseums: '[]', AllowedChannels: '[]' });
|
||||||
|
|
||||||
|
const loadSeasons = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await fetchSeasons();
|
||||||
|
setSeasons(data);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
const data = await fetchUsers();
|
||||||
|
setUsers(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateUser = async (id: number, fields: Partial<User>) => {
|
||||||
|
await updateUser(id, fields);
|
||||||
|
await loadUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadSeasons(); loadUsers(); }, []);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate) return;
|
||||||
|
await createSeason(newSeason);
|
||||||
|
setNewSeason({
|
||||||
|
Name: '',
|
||||||
|
HijriYear: newSeason.HijriYear,
|
||||||
|
StartDate: '',
|
||||||
|
EndDate: '',
|
||||||
|
Color: DEFAULT_COLORS[(seasons.length + 1) % DEFAULT_COLORS.length],
|
||||||
|
});
|
||||||
|
await loadSeasons();
|
||||||
|
onSeasonsChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (id: number, data: Partial<Season>) => {
|
||||||
|
await updateSeason(id, data);
|
||||||
|
await loadSeasons();
|
||||||
|
onSeasonsChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
await deleteSeason(id);
|
||||||
|
await loadSeasons();
|
||||||
|
onSeasonsChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page">
|
||||||
|
<div className="page-title">
|
||||||
|
<h1>{t('settings.title')}</h1>
|
||||||
|
<p>{t('settings.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-card">
|
||||||
|
<h2>{t('settings.seasons')}</h2>
|
||||||
|
<p className="settings-hint">{t('settings.seasonsHint')}</p>
|
||||||
|
|
||||||
|
<div className="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('settings.seasonName')}</th>
|
||||||
|
<th>{t('settings.startDate')}</th>
|
||||||
|
<th>{t('settings.endDate')}</th>
|
||||||
|
<th>{t('settings.actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={4} style={{ textAlign: 'center', padding: 24 }}>Loading...</td></tr>
|
||||||
|
) : (
|
||||||
|
seasons.map(s => (
|
||||||
|
<SeasonRow key={s.Id} season={s} onSave={handleSave} onDelete={handleDelete} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<tr className="add-row">
|
||||||
|
<td>
|
||||||
|
<div className="season-edit-name">
|
||||||
|
<input type="text" value={newSeason.Name} onChange={e => setNewSeason({ ...newSeason, Name: e.target.value })} placeholder={t('settings.namePlaceholder')} />
|
||||||
|
<input type="number" value={newSeason.HijriYear} onChange={e => setNewSeason({ ...newSeason, HijriYear: parseInt(e.target.value) || 0 })} style={{ width: 80 }} />
|
||||||
|
<input type="color" value={newSeason.Color} onChange={e => setNewSeason({ ...newSeason, Color: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><input type="date" value={newSeason.StartDate} onChange={e => setNewSeason({ ...newSeason, StartDate: e.target.value })} /></td>
|
||||||
|
<td><input type="date" value={newSeason.EndDate} onChange={e => setNewSeason({ ...newSeason, EndDate: e.target.value })} /></td>
|
||||||
|
<td>
|
||||||
|
<button className="btn-small btn-primary" onClick={handleCreate} disabled={!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate}>
|
||||||
|
{t('settings.add')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-card" style={{ marginTop: 24 }}>
|
||||||
|
<h2>{t('settings.users')}</h2>
|
||||||
|
<p className="settings-hint">{t('settings.usersHint')}</p>
|
||||||
|
|
||||||
|
<div className="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('settings.userName')}</th>
|
||||||
|
<th>{t('settings.userPin')}</th>
|
||||||
|
<th>{t('settings.userRole')}</th>
|
||||||
|
<th>Access</th>
|
||||||
|
<th>{t('settings.actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map(u => (
|
||||||
|
<UserRow
|
||||||
|
key={u.Id}
|
||||||
|
user={u}
|
||||||
|
allMuseums={allMuseums}
|
||||||
|
allChannels={allChannels}
|
||||||
|
onUpdate={handleUpdateUser}
|
||||||
|
onDelete={async (id) => { await deleteUser(id); await loadUsers(); }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<tr className="add-row">
|
||||||
|
<td>
|
||||||
|
<input type="text" value={newUser.Name} onChange={e => setNewUser({ ...newUser, Name: e.target.value })} placeholder={t('settings.userNamePlaceholder')} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" value={newUser.PIN} onChange={e => setNewUser({ ...newUser, PIN: e.target.value })} placeholder="PIN" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select value={newUser.Role} onChange={e => setNewUser({ ...newUser, Role: e.target.value })}>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<button className="btn-small btn-primary" onClick={async () => {
|
||||||
|
if (!newUser.Name || !newUser.PIN) return;
|
||||||
|
await createUser(newUser);
|
||||||
|
setNewUser({ Name: '', PIN: '', Role: 'viewer', AllowedMuseums: '[]', AllowedChannels: '[]' });
|
||||||
|
await loadUsers();
|
||||||
|
}} disabled={!newUser.Name || !newUser.PIN}>
|
||||||
|
{t('settings.add')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
@@ -7,48 +7,84 @@ import {
|
|||||||
calculateMetrics,
|
calculateMetrics,
|
||||||
formatCompact,
|
formatCompact,
|
||||||
formatCompactCurrency,
|
formatCompactCurrency,
|
||||||
getUniqueDistricts,
|
getUniqueChannels,
|
||||||
getDistrictMuseumMap,
|
getUniqueMuseums
|
||||||
getMuseumsForDistrict
|
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
|
import type {
|
||||||
|
MuseumRecord,
|
||||||
|
SlideConfig,
|
||||||
|
ChartTypeOption,
|
||||||
|
MetricOption,
|
||||||
|
MetricFieldInfo,
|
||||||
|
SlidesProps
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
function Slides({ data }) {
|
interface SlideEditorProps {
|
||||||
|
slide: SlideConfig;
|
||||||
|
onUpdate: (updates: Partial<SlideConfig>) => void;
|
||||||
|
channels: string[];
|
||||||
|
museums: string[];
|
||||||
|
data: MuseumRecord[];
|
||||||
|
chartTypes: ChartTypeOption[];
|
||||||
|
metrics: MetricOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlidePreviewProps {
|
||||||
|
slide: SlideConfig;
|
||||||
|
data: MuseumRecord[];
|
||||||
|
channels: string[];
|
||||||
|
museums: string[];
|
||||||
|
metrics: MetricOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewModeProps {
|
||||||
|
slides: SlideConfig[];
|
||||||
|
data: MuseumRecord[];
|
||||||
|
channels: string[];
|
||||||
|
museums: string[];
|
||||||
|
currentSlide: number;
|
||||||
|
setCurrentSlide: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
onExit: () => void;
|
||||||
|
metrics: MetricOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function Slides({ data }: SlidesProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
const CHART_TYPES = useMemo(() => [
|
const CHART_TYPES: ChartTypeOption[] = useMemo(() => [
|
||||||
{ id: 'trend', label: t('slides.revenueTrend'), icon: '📈' },
|
{ id: 'trend', label: t('slides.revenueTrend'), icon: '📈' },
|
||||||
{ id: 'museum-bar', label: t('slides.byMuseum'), icon: '📊' },
|
{ id: 'museum-bar', label: t('slides.byMuseum'), icon: '📊' },
|
||||||
{ id: 'kpi-cards', label: t('slides.kpiSummary'), icon: '🎯' },
|
{ id: 'kpi-cards', label: t('slides.kpiSummary'), icon: '🎯' },
|
||||||
{ id: 'comparison', label: t('slides.yoyComparison'), icon: '⚖️' }
|
{ id: 'comparison', label: t('slides.yoyComparison'), icon: '⚖️' }
|
||||||
], [t]);
|
], [t]);
|
||||||
|
|
||||||
const METRICS = useMemo(() => [
|
const METRICS: MetricOption[] = useMemo(() => [
|
||||||
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax' },
|
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_gross' },
|
||||||
{ id: 'visitors', label: t('metrics.visitors'), field: 'visits' },
|
{ id: 'visitors', label: t('metrics.visitors'), field: 'visits' },
|
||||||
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
|
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
|
||||||
], [t]);
|
], [t]);
|
||||||
const [slides, setSlides] = useState([]);
|
const [slides, setSlides] = useState<SlideConfig[]>([]);
|
||||||
const [editingSlide, setEditingSlide] = useState(null);
|
const [editingSlide, setEditingSlide] = useState<number | null>(null);
|
||||||
const [previewMode, setPreviewMode] = useState(false);
|
const [previewMode, setPreviewMode] = useState(false);
|
||||||
const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0);
|
const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0);
|
||||||
|
|
||||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
const channels = useMemo(() => getUniqueChannels(data), [data]);
|
||||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
const museums = useMemo(() => getUniqueMuseums(data), [data]);
|
||||||
|
|
||||||
const defaultSlideConfig = {
|
const defaultSlideConfig: Omit<SlideConfig, 'id'> = {
|
||||||
title: 'Slide Title',
|
title: 'Slide Title',
|
||||||
chartType: 'trend',
|
chartType: 'trend',
|
||||||
metric: 'revenue',
|
metric: 'revenue',
|
||||||
startDate: '2026-01-01',
|
startDate: '2026-01-01',
|
||||||
endDate: '2026-01-31',
|
endDate: '2026-01-31',
|
||||||
district: 'all',
|
channel: 'all',
|
||||||
museum: 'all',
|
museum: 'all',
|
||||||
showComparison: false
|
showComparison: false
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSlide = () => {
|
const addSlide = () => {
|
||||||
const newSlide = {
|
const newSlide: SlideConfig = {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
...defaultSlideConfig,
|
...defaultSlideConfig,
|
||||||
title: `Slide ${slides.length + 1}`
|
title: `Slide ${slides.length + 1}`
|
||||||
@@ -57,16 +93,16 @@ function Slides({ data }) {
|
|||||||
setEditingSlide(newSlide.id);
|
setEditingSlide(newSlide.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSlide = (id, updates) => {
|
const updateSlide = (id: number, updates: Partial<SlideConfig>) => {
|
||||||
setSlides(slides.map(s => s.id === id ? { ...s, ...updates } : s));
|
setSlides(slides.map(s => s.id === id ? { ...s, ...updates } : s));
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeSlide = (id) => {
|
const removeSlide = (id: number) => {
|
||||||
setSlides(slides.filter(s => s.id !== id));
|
setSlides(slides.filter(s => s.id !== id));
|
||||||
if (editingSlide === id) setEditingSlide(null);
|
if (editingSlide === id) setEditingSlide(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveSlide = (id, direction) => {
|
const moveSlide = (id: number, direction: number) => {
|
||||||
const index = slides.findIndex(s => s.id === id);
|
const index = slides.findIndex(s => s.id === id);
|
||||||
if ((direction === -1 && index === 0) || (direction === 1 && index === slides.length - 1)) return;
|
if ((direction === -1 && index === 0) || (direction === 1 && index === slides.length - 1)) return;
|
||||||
const newSlides = [...slides];
|
const newSlides = [...slides];
|
||||||
@@ -74,10 +110,10 @@ function Slides({ data }) {
|
|||||||
setSlides(newSlides);
|
setSlides(newSlides);
|
||||||
};
|
};
|
||||||
|
|
||||||
const duplicateSlide = (id) => {
|
const duplicateSlide = (id: number) => {
|
||||||
const slide = slides.find(s => s.id === id);
|
const slide = slides.find(s => s.id === id);
|
||||||
if (slide) {
|
if (slide) {
|
||||||
const newSlide = { ...slide, id: Date.now(), title: `${slide.title} (copy)` };
|
const newSlide: SlideConfig = { ...slide, id: Date.now(), title: `${slide.title} (copy)` };
|
||||||
const index = slides.findIndex(s => s.id === id);
|
const index = slides.findIndex(s => s.id === id);
|
||||||
const newSlides = [...slides];
|
const newSlides = [...slides];
|
||||||
newSlides.splice(index + 1, 0, newSlide);
|
newSlides.splice(index + 1, 0, newSlide);
|
||||||
@@ -87,10 +123,10 @@ function Slides({ data }) {
|
|||||||
|
|
||||||
const exportAsHTML = async () => {
|
const exportAsHTML = async () => {
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
|
|
||||||
// Generate HTML for each slide
|
// Generate HTML for each slide
|
||||||
const slidesHTML = slides.map((slide, index) => {
|
const slidesHTML = slides.map((slide, index) => {
|
||||||
return generateSlideHTML(slide, index, data, districts, districtMuseumMap);
|
return generateSlideHTML(slide, index, data);
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
const fullHTML = `<!DOCTYPE html>
|
const fullHTML = `<!DOCTYPE html>
|
||||||
@@ -103,21 +139,21 @@ function Slides({ data }) {
|
|||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f172a; }
|
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f172a; }
|
||||||
.slide {
|
.slide {
|
||||||
width: 100vw; height: 100vh;
|
width: 100vw; height: 100vh;
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
justify-content: center; align-items: center;
|
justify-content: center; align-items: center;
|
||||||
padding: 60px; background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
padding: 60px; background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||||
page-break-after: always;
|
page-break-after: always;
|
||||||
}
|
}
|
||||||
.slide-title {
|
.slide-title {
|
||||||
color: #f8fafc; font-size: 2.5rem; font-weight: 600;
|
color: #f8fafc; font-size: 2.5rem; font-weight: 600;
|
||||||
margin-bottom: 40px; text-align: center;
|
margin-bottom: 40px; text-align: center;
|
||||||
}
|
}
|
||||||
.slide-subtitle {
|
.slide-subtitle {
|
||||||
color: #94a3b8; font-size: 1.1rem; margin-bottom: 30px;
|
color: #94a3b8; font-size: 1.1rem; margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
.chart-container {
|
.chart-container {
|
||||||
width: 100%; max-width: 900px; height: 400px;
|
width: 100%; max-width: 900px; height: 400px;
|
||||||
background: rgba(255,255,255,0.03); border-radius: 16px;
|
background: rgba(255,255,255,0.03); border-radius: 16px;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
@@ -134,8 +170,8 @@ function Slides({ data }) {
|
|||||||
.kpi-label { color: #94a3b8; font-size: 1rem; margin-top: 8px; }
|
.kpi-label { color: #94a3b8; font-size: 1rem; margin-top: 8px; }
|
||||||
.logo { position: absolute; bottom: 30px; right: 40px; opacity: 0.6; }
|
.logo { position: absolute; bottom: 30px; right: 40px; opacity: 0.6; }
|
||||||
.logo svg { height: 30px; }
|
.logo svg { height: 30px; }
|
||||||
.slide-number {
|
.slide-number {
|
||||||
position: absolute; bottom: 30px; left: 40px;
|
position: absolute; bottom: 30px; left: 40px;
|
||||||
color: #475569; font-size: 0.9rem;
|
color: #475569; font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
@media print {
|
@media print {
|
||||||
@@ -147,13 +183,13 @@ function Slides({ data }) {
|
|||||||
${slidesHTML}
|
${slidesHTML}
|
||||||
<script>
|
<script>
|
||||||
// Chart.js initialization scripts will be here
|
// Chart.js initialization scripts will be here
|
||||||
${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
${generateChartScripts(slides, data)}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
zip.file('presentation.html', fullHTML);
|
zip.file('presentation.html', fullHTML);
|
||||||
|
|
||||||
const blob = await zip.generateAsync({ type: 'blob' });
|
const blob = await zip.generateAsync({ type: 'blob' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -165,11 +201,11 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
|||||||
|
|
||||||
if (previewMode) {
|
if (previewMode) {
|
||||||
return (
|
return (
|
||||||
<PreviewMode
|
<PreviewMode
|
||||||
slides={slides}
|
slides={slides}
|
||||||
data={data}
|
data={data}
|
||||||
districts={districts}
|
channels={channels}
|
||||||
districtMuseumMap={districtMuseumMap}
|
museums={museums}
|
||||||
currentSlide={currentPreviewSlide}
|
currentSlide={currentPreviewSlide}
|
||||||
setCurrentSlide={setCurrentPreviewSlide}
|
setCurrentSlide={setCurrentPreviewSlide}
|
||||||
onExit={() => setPreviewMode(false)}
|
onExit={() => setPreviewMode(false)}
|
||||||
@@ -221,8 +257,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
|||||||
) : (
|
) : (
|
||||||
<div className="slides-thumbnails">
|
<div className="slides-thumbnails">
|
||||||
{slides.map((slide, index) => (
|
{slides.map((slide, index) => (
|
||||||
<div
|
<div
|
||||||
key={slide.id}
|
key={slide.id}
|
||||||
className={`slide-thumbnail ${editingSlide === slide.id ? 'active' : ''}`}
|
className={`slide-thumbnail ${editingSlide === slide.id ? 'active' : ''}`}
|
||||||
onClick={() => setEditingSlide(slide.id)}
|
onClick={() => setEditingSlide(slide.id)}
|
||||||
>
|
>
|
||||||
@@ -243,10 +279,10 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
|||||||
|
|
||||||
{editingSlide && (
|
{editingSlide && (
|
||||||
<SlideEditor
|
<SlideEditor
|
||||||
slide={slides.find(s => s.id === editingSlide)}
|
slide={slides.find(s => s.id === editingSlide)!}
|
||||||
onUpdate={(updates) => updateSlide(editingSlide, updates)}
|
onUpdate={(updates) => updateSlide(editingSlide, updates)}
|
||||||
districts={districts}
|
channels={channels}
|
||||||
districtMuseumMap={districtMuseumMap}
|
museums={museums}
|
||||||
data={data}
|
data={data}
|
||||||
chartTypes={CHART_TYPES}
|
chartTypes={CHART_TYPES}
|
||||||
metrics={METRICS}
|
metrics={METRICS}
|
||||||
@@ -257,20 +293,16 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, chartTypes, metrics }) {
|
function SlideEditor({ slide, onUpdate, channels, museums, data, chartTypes, metrics }: SlideEditorProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const availableMuseums = useMemo(() =>
|
|
||||||
getMuseumsForDistrict(districtMuseumMap, slide.district),
|
|
||||||
[districtMuseumMap, slide.district]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="slide-editor">
|
<div className="slide-editor">
|
||||||
<div className="editor-section">
|
<div className="editor-section">
|
||||||
<label>{t('slides.slideTitle')}</label>
|
<label>{t('slides.slideTitle')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={slide.title}
|
value={slide.title}
|
||||||
onChange={e => onUpdate({ title: e.target.value })}
|
onChange={e => onUpdate({ title: e.target.value })}
|
||||||
placeholder={t('slides.slideTitle')}
|
placeholder={t('slides.slideTitle')}
|
||||||
/>
|
/>
|
||||||
@@ -279,7 +311,7 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
|||||||
<div className="editor-section">
|
<div className="editor-section">
|
||||||
<label>{t('slides.chartType')}</label>
|
<label>{t('slides.chartType')}</label>
|
||||||
<div className="chart-type-grid">
|
<div className="chart-type-grid">
|
||||||
{chartTypes.map(type => (
|
{chartTypes.map((type: ChartTypeOption) => (
|
||||||
<button
|
<button
|
||||||
key={type.id}
|
key={type.id}
|
||||||
className={`chart-type-btn ${slide.chartType === type.id ? 'active' : ''}`}
|
className={`chart-type-btn ${slide.chartType === type.id ? 'active' : ''}`}
|
||||||
@@ -295,7 +327,7 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
|||||||
<div className="editor-section">
|
<div className="editor-section">
|
||||||
<label>{t('slides.metric')}</label>
|
<label>{t('slides.metric')}</label>
|
||||||
<select value={slide.metric} onChange={e => onUpdate({ metric: e.target.value })}>
|
<select value={slide.metric} onChange={e => onUpdate({ metric: e.target.value })}>
|
||||||
{metrics.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
|
{metrics.map((m: MetricOption) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -312,17 +344,17 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
|||||||
|
|
||||||
<div className="editor-row">
|
<div className="editor-row">
|
||||||
<div className="editor-section">
|
<div className="editor-section">
|
||||||
<label>{t('filters.district')}</label>
|
<label>{t('filters.channel')}</label>
|
||||||
<select value={slide.district} onChange={e => onUpdate({ district: e.target.value, museum: 'all' })}>
|
<select value={slide.channel} onChange={e => onUpdate({ channel: e.target.value, museum: 'all' })}>
|
||||||
<option value="all">{t('filters.allDistricts')}</option>
|
<option value="all">{t('filters.allChannels')}</option>
|
||||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
{channels.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-section">
|
<div className="editor-section">
|
||||||
<label>{t('filters.museum')}</label>
|
<label>{t('filters.museum')}</label>
|
||||||
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
|
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
|
||||||
<option value="all">{t('filters.allMuseums')}</option>
|
<option value="all">{t('filters.allMuseums')}</option>
|
||||||
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
{museums.map((m: string) => <option key={m} value={m}>{m}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,9 +362,9 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
|||||||
{slide.chartType === 'comparison' && (
|
{slide.chartType === 'comparison' && (
|
||||||
<div className="editor-section">
|
<div className="editor-section">
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={slide.showComparison}
|
checked={slide.showComparison}
|
||||||
onChange={e => onUpdate({ showComparison: e.target.checked })}
|
onChange={e => onUpdate({ showComparison: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
{t('slides.showYoY')}
|
{t('slides.showYoY')}
|
||||||
@@ -342,48 +374,48 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
|||||||
|
|
||||||
<div className="slide-preview-box">
|
<div className="slide-preview-box">
|
||||||
<h4>{t('slides.preview')}</h4>
|
<h4>{t('slides.preview')}</h4>
|
||||||
<SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} metrics={metrics} />
|
<SlidePreview slide={slide} data={data} channels={channels} museums={museums} metrics={metrics} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static field mapping for charts (Chart.js labels don't need i18n)
|
// Static field mapping for charts (Chart.js labels don't need i18n)
|
||||||
const METRIC_FIELDS = {
|
const METRIC_FIELDS: Record<string, MetricFieldInfo> = {
|
||||||
revenue: { field: 'revenue_incl_tax', label: 'Revenue' },
|
revenue: { field: 'revenue_gross', label: 'Revenue' },
|
||||||
visitors: { field: 'visits', label: 'Visitors' },
|
visitors: { field: 'visits', label: 'Visitors' },
|
||||||
tickets: { field: 'tickets', label: 'Tickets' }
|
tickets: { field: 'tickets', label: 'Tickets' }
|
||||||
};
|
};
|
||||||
|
|
||||||
function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
|
function SlidePreview({ slide, data, channels, museums, metrics }: SlidePreviewProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const filteredData = useMemo(() =>
|
const filteredData = useMemo(() =>
|
||||||
filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||||
district: slide.district,
|
channel: slide.channel,
|
||||||
museum: slide.museum
|
museum: slide.museum
|
||||||
}),
|
}),
|
||||||
[data, slide.startDate, slide.endDate, slide.district, slide.museum]
|
[data, slide.startDate, slide.endDate, slide.channel, slide.museum]
|
||||||
);
|
);
|
||||||
|
|
||||||
const metricsData = useMemo(() => calculateMetrics(filteredData), [filteredData]);
|
const metricsData = useMemo(() => calculateMetrics(filteredData), [filteredData]);
|
||||||
const baseOptions = useMemo(() => createBaseOptions(false), []);
|
const baseOptions = useMemo(() => createBaseOptions(false), []);
|
||||||
|
|
||||||
const getMetricValue = useCallback((rows, metric) => {
|
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
|
||||||
const fieldMap = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
|
const fieldMap: Record<string, string> = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' };
|
||||||
return rows.reduce((s, r) => s + parseFloat(r[fieldMap[metric]] || 0), 0);
|
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as unknown as Record<string, unknown>)[fieldMap[metric]] || 0)), 0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const trendData = useMemo(() => {
|
const trendData = useMemo(() => {
|
||||||
const grouped = {};
|
const grouped: Record<string, MuseumRecord[]> = {};
|
||||||
filteredData.forEach(row => {
|
filteredData.forEach(row => {
|
||||||
if (!row.date) return;
|
if (!row.date) return;
|
||||||
const weekStart = row.date.substring(0, 10);
|
const weekStart = row.date.substring(0, 10);
|
||||||
if (!grouped[weekStart]) grouped[weekStart] = [];
|
if (!grouped[weekStart]) grouped[weekStart] = [];
|
||||||
grouped[weekStart].push(row);
|
grouped[weekStart].push(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
const sortedDates = Object.keys(grouped).sort();
|
const sortedDates = Object.keys(grouped).sort();
|
||||||
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
|
const metricLabel = metrics?.find((m: MetricOption) => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
|
||||||
return {
|
return {
|
||||||
labels: sortedDates.map(d => d.substring(5)),
|
labels: sortedDates.map(d => d.substring(5)),
|
||||||
datasets: [{
|
datasets: [{
|
||||||
@@ -398,15 +430,15 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
|
|||||||
}, [filteredData, slide.metric, getMetricValue, metrics]);
|
}, [filteredData, slide.metric, getMetricValue, metrics]);
|
||||||
|
|
||||||
const museumData = useMemo(() => {
|
const museumData = useMemo(() => {
|
||||||
const byMuseum = {};
|
const byMuseum: Record<string, MuseumRecord[]> = {};
|
||||||
filteredData.forEach(row => {
|
filteredData.forEach(row => {
|
||||||
if (!row.museum_name) return;
|
if (!row.museum_name) return;
|
||||||
if (!byMuseum[row.museum_name]) byMuseum[row.museum_name] = [];
|
if (!byMuseum[row.museum_name]) byMuseum[row.museum_name] = [];
|
||||||
byMuseum[row.museum_name].push(row);
|
byMuseum[row.museum_name].push(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
const museums = Object.keys(byMuseum).sort();
|
const museums = Object.keys(byMuseum).sort();
|
||||||
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
|
const metricLabel = metrics?.find((m: MetricOption) => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
|
||||||
return {
|
return {
|
||||||
labels: museums,
|
labels: museums,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
@@ -452,13 +484,13 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide, setCurrentSlide, onExit, metrics }) {
|
function PreviewMode({ slides, data, channels, museums, currentSlide, setCurrentSlide, onExit, metrics }: PreviewModeProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const handleKeyDown = useCallback((e) => {
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
if (e.key === 'ArrowRight' || e.key === ' ') {
|
if (e.key === 'ArrowRight' || e.key === ' ') {
|
||||||
setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1));
|
setCurrentSlide((prev: number) => Math.min(prev + 1, slides.length - 1));
|
||||||
} else if (e.key === 'ArrowLeft') {
|
} else if (e.key === 'ArrowLeft') {
|
||||||
setCurrentSlide(prev => Math.max(prev - 1, 0));
|
setCurrentSlide((prev: number) => Math.max(prev - 1, 0));
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
onExit();
|
onExit();
|
||||||
}
|
}
|
||||||
@@ -476,15 +508,15 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
|
|||||||
<div className="preview-slide">
|
<div className="preview-slide">
|
||||||
<h1 className="preview-title">{slide?.title}</h1>
|
<h1 className="preview-title">{slide?.title}</h1>
|
||||||
<div className="preview-content">
|
<div className="preview-content">
|
||||||
{slide && <SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} metrics={metrics} />}
|
{slide && <SlidePreview slide={slide} data={data} channels={channels} museums={museums} metrics={metrics} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="preview-footer">
|
<div className="preview-footer">
|
||||||
<span>{currentSlide + 1} / {slides.length}</span>
|
<span>{currentSlide + 1} / {slides.length}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="preview-controls">
|
<div className="preview-controls">
|
||||||
<button onClick={() => setCurrentSlide(prev => Math.max(prev - 1, 0))} disabled={currentSlide === 0}>←</button>
|
<button onClick={() => setCurrentSlide((prev: number) => Math.max(prev - 1, 0))} disabled={currentSlide === 0}>←</button>
|
||||||
<button onClick={() => setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1))} disabled={currentSlide === slides.length - 1}>→</button>
|
<button onClick={() => setCurrentSlide((prev: number) => Math.min(prev + 1, slides.length - 1))} disabled={currentSlide === slides.length - 1}>→</button>
|
||||||
<button onClick={onExit}>{t('slides.exit')}</button>
|
<button onClick={onExit}>{t('slides.exit')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -492,10 +524,10 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for HTML export
|
// Helper functions for HTML export
|
||||||
function generateSlideHTML(slide, index, data, districts, districtMuseumMap) {
|
function generateSlideHTML(slide: SlideConfig, index: number, data: MuseumRecord[]): string {
|
||||||
const chartType = slide.chartType;
|
const chartType = slide.chartType;
|
||||||
const canvasId = `chart-${index}`;
|
const canvasId = `chart-${index}`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="slide" id="slide-${index}">
|
<div class="slide" id="slide-${index}">
|
||||||
<h1 class="slide-title">${slide.title}</h1>
|
<h1 class="slide-title">${slide.title}</h1>
|
||||||
@@ -510,13 +542,13 @@ function generateSlideHTML(slide, index, data, districts, districtMuseumMap) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateKPIHTML(slide, data) {
|
function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string {
|
||||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||||
district: slide.district,
|
channel: slide.channel,
|
||||||
museum: slide.museum
|
museum: slide.museum
|
||||||
});
|
});
|
||||||
const metrics = calculateMetrics(filtered);
|
const metrics = calculateMetrics(filtered);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="kpi-grid">
|
<div class="kpi-grid">
|
||||||
<div class="kpi-card">
|
<div class="kpi-card">
|
||||||
@@ -534,40 +566,40 @@ function generateKPIHTML(slide, data) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateChartScripts(slides, data, districts, districtMuseumMap) {
|
function generateChartScripts(slides: SlideConfig[], data: MuseumRecord[]): string {
|
||||||
return slides.map((slide, index) => {
|
return slides.map((slide: SlideConfig, index: number) => {
|
||||||
if (slide.chartType === 'kpi-cards') return '';
|
if (slide.chartType === 'kpi-cards') return '';
|
||||||
|
|
||||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||||
district: slide.district,
|
channel: slide.channel,
|
||||||
museum: slide.museum
|
museum: slide.museum
|
||||||
});
|
});
|
||||||
|
|
||||||
const chartConfig = generateChartConfig(slide, filtered);
|
const chartConfig = generateChartConfig(slide, filtered);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
new Chart(document.getElementById('chart-${index}'), ${JSON.stringify(chartConfig)});
|
new Chart(document.getElementById('chart-${index}'), ${JSON.stringify(chartConfig)});
|
||||||
`;
|
`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateChartConfig(slide, data) {
|
function generateChartConfig(slide: SlideConfig, data: MuseumRecord[]): object {
|
||||||
const fieldMap = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
|
const fieldMap: Record<string, keyof MuseumRecord> = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' };
|
||||||
const field = fieldMap[slide.metric];
|
const field = fieldMap[slide.metric];
|
||||||
|
|
||||||
if (slide.chartType === 'museum-bar') {
|
if (slide.chartType === 'museum-bar') {
|
||||||
const byMuseum = {};
|
const byMuseum: Record<string, number> = {};
|
||||||
data.forEach(row => {
|
data.forEach((row: MuseumRecord) => {
|
||||||
if (!row.museum_name) return;
|
if (!row.museum_name) return;
|
||||||
byMuseum[row.museum_name] = (byMuseum[row.museum_name] || 0) + parseFloat(row[field] || 0);
|
byMuseum[row.museum_name] = (byMuseum[row.museum_name] || 0) + parseFloat(String(row[field] || 0));
|
||||||
});
|
});
|
||||||
const museums = Object.keys(byMuseum).sort();
|
const museums = Object.keys(byMuseum).sort();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: museums,
|
labels: museums,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: museums.map(m => byMuseum[m]),
|
data: museums.map(m => byMuseum[m]),
|
||||||
backgroundColor: '#3b82f6',
|
backgroundColor: '#3b82f6',
|
||||||
borderRadius: 6
|
borderRadius: 6
|
||||||
@@ -576,15 +608,15 @@ function generateChartConfig(slide, data) {
|
|||||||
options: { indexAxis: 'y', plugins: { legend: { display: false } } }
|
options: { indexAxis: 'y', plugins: { legend: { display: false } } }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: trend line
|
// Default: trend line
|
||||||
const grouped = {};
|
const grouped: Record<string, number> = {};
|
||||||
data.forEach(row => {
|
data.forEach((row: MuseumRecord) => {
|
||||||
if (!row.date) return;
|
if (!row.date) return;
|
||||||
grouped[row.date] = (grouped[row.date] || 0) + parseFloat(row[field] || 0);
|
grouped[row.date] = (grouped[row.date] || 0) + parseFloat(String(row[field] || 0));
|
||||||
});
|
});
|
||||||
const dates = Object.keys(grouped).sort();
|
const dates = Object.keys(grouped).sort();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
27
src/components/shared/LoadingSkeleton.tsx
Normal file
27
src/components/shared/LoadingSkeleton.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function SkeletonCard({ wide = false }: { wide?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={`skeleton-card ${wide ? 'skeleton-card-wide' : ''}`}>
|
||||||
|
<div className="skeleton-line skeleton-line-short" />
|
||||||
|
<div className="skeleton-line skeleton-line-tall" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="skeleton-container">
|
||||||
|
<div className="skeleton-stats">
|
||||||
|
<SkeletonCard />
|
||||||
|
<SkeletonCard />
|
||||||
|
<SkeletonCard />
|
||||||
|
<SkeletonCard />
|
||||||
|
</div>
|
||||||
|
<div className="skeleton-charts">
|
||||||
|
<SkeletonCard wide />
|
||||||
|
<SkeletonCard wide />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/shared/MultiSelect.tsx
Normal file
82
src/components/shared/MultiSelect.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface MultiSelectProps {
|
||||||
|
options: string[];
|
||||||
|
selected: string[];
|
||||||
|
onChange: (selected: string[]) => void;
|
||||||
|
allLabel: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiSelect({ options, selected, onChange, allLabel, placeholder }: MultiSelectProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isAll = selected.length === 0;
|
||||||
|
|
||||||
|
const toggle = (value: string) => {
|
||||||
|
if (selected.includes(value)) {
|
||||||
|
onChange(selected.filter(v => v !== value));
|
||||||
|
} else {
|
||||||
|
onChange([...selected, value]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => onChange([]);
|
||||||
|
|
||||||
|
const displayText = isAll
|
||||||
|
? allLabel
|
||||||
|
: selected.length === 1
|
||||||
|
? selected[0]
|
||||||
|
: `${selected.length} selected`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="multi-select" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="multi-select-trigger"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="multi-select-text">{displayText}</span>
|
||||||
|
<span className="multi-select-arrow">▼</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="multi-select-dropdown">
|
||||||
|
<label className="multi-select-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isAll}
|
||||||
|
onChange={selectAll}
|
||||||
|
/>
|
||||||
|
<span>{allLabel}</span>
|
||||||
|
</label>
|
||||||
|
{options.map(opt => (
|
||||||
|
<label key={opt} className="multi-select-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.includes(opt)}
|
||||||
|
onChange={() => toggle(opt)}
|
||||||
|
/>
|
||||||
|
<span>{opt}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MultiSelect;
|
||||||
@@ -2,5 +2,6 @@ export { default as Carousel } from './Carousel';
|
|||||||
export { default as ChartCard } from './ChartCard';
|
export { default as ChartCard } from './ChartCard';
|
||||||
export { default as EmptyState } from './EmptyState';
|
export { default as EmptyState } from './EmptyState';
|
||||||
export { default as FilterControls } from './FilterControls';
|
export { default as FilterControls } from './FilterControls';
|
||||||
|
export { default as MultiSelect } from './MultiSelect';
|
||||||
export { default as StatCard } from './StatCard';
|
export { default as StatCard } from './StatCard';
|
||||||
export { default as ToggleSwitch } from './ToggleSwitch';
|
export { default as ToggleSwitch } from './ToggleSwitch';
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Filler
|
Filler
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||||
|
import Annotation from 'chartjs-plugin-annotation';
|
||||||
|
|
||||||
// Register ChartJS components once
|
// Register ChartJS components once
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
@@ -25,7 +26,8 @@ ChartJS.register(
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
Filler,
|
Filler,
|
||||||
ChartDataLabels
|
ChartDataLabels,
|
||||||
|
Annotation
|
||||||
);
|
);
|
||||||
|
|
||||||
export const chartColors = {
|
export const chartColors = {
|
||||||
@@ -38,6 +40,20 @@ export const chartColors = {
|
|||||||
grid: '#f1f5f9'
|
grid: '#f1f5f9'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Extended palette for charts with many categories (events, channels)
|
||||||
|
export const chartPalette = [
|
||||||
|
'#2563eb', // blue
|
||||||
|
'#7c3aed', // purple
|
||||||
|
'#0891b2', // cyan
|
||||||
|
'#059669', // emerald
|
||||||
|
'#d97706', // amber
|
||||||
|
'#e11d48', // rose
|
||||||
|
'#4f46e5', // indigo
|
||||||
|
'#0d9488', // teal
|
||||||
|
'#c026d3', // fuchsia
|
||||||
|
'#ea580c', // orange
|
||||||
|
];
|
||||||
|
|
||||||
export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
||||||
display: showDataLabels,
|
display: showDataLabels,
|
||||||
color: '#1e293b',
|
color: '#1e293b',
|
||||||
@@ -49,7 +65,7 @@ export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
|||||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment
|
textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment
|
||||||
formatter: (value) => {
|
formatter: (value: number | null) => {
|
||||||
if (value == null) return '';
|
if (value == null) return '';
|
||||||
if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
|
if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
|
||||||
if (value >= 1000) return (value / 1000).toFixed(2) + 'K';
|
if (value >= 1000) return (value / 1000).toFixed(2) + 'K';
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { useUrlState } from './useUrlState';
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { useEffect, useCallback } from 'react';
|
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync state with URL search params
|
|
||||||
* @param {Object} state - Current state object
|
|
||||||
* @param {Function} setState - State setter function
|
|
||||||
* @param {Object} defaultState - Default state values
|
|
||||||
* @param {Array<string>} keys - Keys to sync with URL
|
|
||||||
*/
|
|
||||||
export function useUrlState(state, setState, defaultState, keys) {
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
|
|
||||||
// Initialize state from URL on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const urlState = {};
|
|
||||||
let hasUrlParams = false;
|
|
||||||
|
|
||||||
keys.forEach(key => {
|
|
||||||
const value = searchParams.get(key);
|
|
||||||
if (value !== null) {
|
|
||||||
urlState[key] = value;
|
|
||||||
hasUrlParams = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasUrlParams) {
|
|
||||||
setState(prev => ({ ...prev, ...urlState }));
|
|
||||||
}
|
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
// Update URL when state changes
|
|
||||||
const updateUrl = useCallback((newState) => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
keys.forEach(key => {
|
|
||||||
const value = newState[key];
|
|
||||||
if (value && value !== defaultState[key]) {
|
|
||||||
params.set(key, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setSearchParams(params, { replace: true });
|
|
||||||
}, [keys, defaultState, setSearchParams]);
|
|
||||||
|
|
||||||
// Wrap setState to also update URL
|
|
||||||
const setStateWithUrl = useCallback((updater) => {
|
|
||||||
setState(prev => {
|
|
||||||
const newState = typeof updater === 'function' ? updater(prev) : updater;
|
|
||||||
updateUrl(newState);
|
|
||||||
return newState;
|
|
||||||
});
|
|
||||||
}, [setState, updateUrl]);
|
|
||||||
|
|
||||||
return setStateWithUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useUrlState;
|
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"comparison": "المقارنة",
|
"comparison": "المقارنة",
|
||||||
"compare": "مقارنة",
|
"compare": "مقارنة",
|
||||||
"slides": "الشرائح",
|
"slides": "الشرائح",
|
||||||
|
"settings": "الإعدادات",
|
||||||
"labels": "التسميات",
|
"labels": "التسميات",
|
||||||
"labelsOn": "التسميات مفعّلة",
|
"labelsOn": "التسميات مفعّلة",
|
||||||
"labelsOff": "التسميات معطّلة",
|
"labelsOff": "التسميات معطّلة",
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
"excl": "بدون"
|
"excl": "بدون"
|
||||||
},
|
},
|
||||||
"dataSources": {
|
"dataSources": {
|
||||||
"museums": "المتاحف",
|
"museums": "الفعاليات",
|
||||||
"coffees": "المقاهي",
|
"coffees": "المقاهي",
|
||||||
"ecommerce": "التجارة الإلكترونية",
|
"ecommerce": "التجارة الإلكترونية",
|
||||||
"soon": "قريباً"
|
"soon": "قريباً"
|
||||||
@@ -34,12 +35,16 @@
|
|||||||
"title": "الفلاتر",
|
"title": "الفلاتر",
|
||||||
"year": "السنة",
|
"year": "السنة",
|
||||||
"district": "المنطقة",
|
"district": "المنطقة",
|
||||||
"museum": "المتحف",
|
"channel": "القناة",
|
||||||
|
"museum": "الفعالية",
|
||||||
"quarter": "الربع",
|
"quarter": "الربع",
|
||||||
"allYears": "كل السنوات",
|
"allYears": "كل السنوات",
|
||||||
"allDistricts": "كل المناطق",
|
"allDistricts": "كل المناطق",
|
||||||
"allMuseums": "كل المتاحف",
|
"allChannels": "جميع القنوات",
|
||||||
|
"allMuseums": "كل الفعاليات",
|
||||||
"allQuarters": "كل الأرباع",
|
"allQuarters": "كل الأرباع",
|
||||||
|
"season": "الموسم",
|
||||||
|
"allSeasons": "كل المواسم",
|
||||||
"reset": "إعادة تعيين الفلاتر"
|
"reset": "إعادة تعيين الفلاتر"
|
||||||
},
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
@@ -52,19 +57,22 @@
|
|||||||
"avgRevenue": "متوسط الإيراد/زائر",
|
"avgRevenue": "متوسط الإيراد/زائر",
|
||||||
"avgRevenuePerVisitor": "متوسط الإيراد لكل زائر",
|
"avgRevenuePerVisitor": "متوسط الإيراد لكل زائر",
|
||||||
"pilgrims": "المعتمرون",
|
"pilgrims": "المعتمرون",
|
||||||
"captureRate": "نسبة الاستقطاب"
|
"captureRate": "نسبة الاستقطاب",
|
||||||
|
"bar": "أعمدة",
|
||||||
|
"pie": "دائري"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "لوحة التحكم",
|
"title": "لوحة التحكم",
|
||||||
"subtitle": "تحليلات المتاحف المباشرة من جداول بيانات Google",
|
"subtitle": "تحليلات الفعاليات من نظام ERP",
|
||||||
"noData": "لا توجد بيانات",
|
"noData": "لا توجد بيانات",
|
||||||
"noDataMessage": "لا توجد سجلات تطابق الفلاتر الحالية. حاول تعديل اختيارك.",
|
"noDataMessage": "لا توجد سجلات تطابق الفلاتر الحالية. حاول تعديل اختيارك.",
|
||||||
"quarterlyComparison": "مقارنة ربع سنوية: 2024 مقابل 2025",
|
"quarterlyComparison": "مقارنة ربع سنوية: 2024 مقابل 2025",
|
||||||
"revenueTrends": "اتجاهات الإيرادات",
|
"revenueTrends": "اتجاهات الإيرادات",
|
||||||
"visitorsByMuseum": "الزوار حسب المتحف",
|
"visitorsByMuseum": "الزوار حسب الفعالية",
|
||||||
"revenueByMuseum": "الإيرادات حسب المتحف",
|
"revenueByMuseum": "الإيرادات حسب الفعالية",
|
||||||
"quarterlyRevenue": "الإيرادات الربعية (سنوي)",
|
"quarterlyRevenue": "الإيرادات الربعية (سنوي)",
|
||||||
"districtPerformance": "أداء المناطق",
|
"districtPerformance": "أداء المناطق",
|
||||||
|
"channelPerformance": "أداء القنوات",
|
||||||
"captureRateChart": "نسبة الاستقطاب مقابل المعتمرين"
|
"captureRateChart": "نسبة الاستقطاب مقابل المعتمرين"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
@@ -117,7 +125,7 @@
|
|||||||
"noData": "لا توجد بيانات لهذه الفترة",
|
"noData": "لا توجد بيانات لهذه الفترة",
|
||||||
"noDataMessage": "لم يتم العثور على سجلات للنطاق الزمني والفلاتر المحددة.",
|
"noDataMessage": "لم يتم العثور على سجلات للنطاق الزمني والفلاتر المحددة.",
|
||||||
"trend": "الاتجاه",
|
"trend": "الاتجاه",
|
||||||
"byMuseum": "حسب المتحف",
|
"byMuseum": "حسب الفعالية",
|
||||||
"pendingData": "البيانات لم تُنشر بعد"
|
"pendingData": "البيانات لم تُنشر بعد"
|
||||||
},
|
},
|
||||||
"slides": {
|
"slides": {
|
||||||
@@ -137,7 +145,7 @@
|
|||||||
"showYoY": "إظهار مقارنة سنة بسنة",
|
"showYoY": "إظهار مقارنة سنة بسنة",
|
||||||
"exit": "خروج",
|
"exit": "خروج",
|
||||||
"revenueTrend": "اتجاه الإيرادات",
|
"revenueTrend": "اتجاه الإيرادات",
|
||||||
"byMuseum": "حسب المتحف",
|
"byMuseum": "حسب الفعالية",
|
||||||
"kpiSummary": "ملخص مؤشرات الأداء",
|
"kpiSummary": "ملخص مؤشرات الأداء",
|
||||||
"yoyComparison": "مقارنة سنوية"
|
"yoyComparison": "مقارنة سنوية"
|
||||||
},
|
},
|
||||||
@@ -147,8 +155,42 @@
|
|||||||
"revenue": "الإيرادات",
|
"revenue": "الإيرادات",
|
||||||
"quarterly": "ربع سنوي",
|
"quarterly": "ربع سنوي",
|
||||||
"district": "المنطقة",
|
"district": "المنطقة",
|
||||||
|
"channel": "القناة",
|
||||||
"captureRate": "نسبة الاستقطاب"
|
"captureRate": "نسبة الاستقطاب"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "الإعدادات",
|
||||||
|
"subtitle": "إعدادات لوحة التحكم والمواسم الهجرية",
|
||||||
|
"seasons": "المواسم الهجرية",
|
||||||
|
"seasonsHint": "حدد المواسم مع تواريخها الميلادية. تظهر كفلاتر مسبقة وتراكبات على الرسوم البيانية.",
|
||||||
|
"seasonName": "الموسم",
|
||||||
|
"startDate": "تاريخ البداية",
|
||||||
|
"endDate": "تاريخ النهاية",
|
||||||
|
"actions": "الإجراءات",
|
||||||
|
"namePlaceholder": "مثال: رمضان",
|
||||||
|
"add": "إضافة",
|
||||||
|
"delete": "حذف",
|
||||||
|
"users": "المستخدمون",
|
||||||
|
"usersHint": "أضف مستخدمين برمز PIN. المشاهدون يمكنهم رؤية لوحة التحكم فقط.",
|
||||||
|
"userName": "الاسم",
|
||||||
|
"userNamePlaceholder": "مثال: أحمد",
|
||||||
|
"userPin": "رمز PIN",
|
||||||
|
"userRole": "الدور"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"subtitle": "أدخل رمز PIN للوصول إلى لوحة التحكم",
|
||||||
|
"placeholder": "رمز PIN",
|
||||||
|
"submit": "تسجيل الدخول",
|
||||||
|
"invalid": "رمز PIN غير صحيح",
|
||||||
|
"error": "خطأ في الاتصال. يرجى المحاولة مرة أخرى."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال ERP API.",
|
||||||
|
"network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.",
|
||||||
|
"auth": "تم رفض الوصول. قد يكون رمز API غير صالح أو منتهي الصلاحية.",
|
||||||
|
"timeout": "خادم قاعدة البيانات يستغرق وقتاً طويلاً للاستجابة. يرجى المحاولة مرة أخرى.",
|
||||||
|
"unknown": "حدث خطأ أثناء تحميل البيانات. يرجى المحاولة مرة أخرى."
|
||||||
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"switch": "EN"
|
"switch": "EN"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"comparison": "Comparison",
|
"comparison": "Comparison",
|
||||||
"compare": "Compare",
|
"compare": "Compare",
|
||||||
"slides": "Slides",
|
"slides": "Slides",
|
||||||
|
"settings": "Settings",
|
||||||
"labels": "Labels",
|
"labels": "Labels",
|
||||||
"labelsOn": "Labels On",
|
"labelsOn": "Labels On",
|
||||||
"labelsOff": "Labels Off",
|
"labelsOff": "Labels Off",
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
"excl": "Excl"
|
"excl": "Excl"
|
||||||
},
|
},
|
||||||
"dataSources": {
|
"dataSources": {
|
||||||
"museums": "Museums",
|
"museums": "Events",
|
||||||
"coffees": "Coffees",
|
"coffees": "Coffees",
|
||||||
"ecommerce": "eCommerce",
|
"ecommerce": "eCommerce",
|
||||||
"soon": "soon"
|
"soon": "soon"
|
||||||
@@ -34,12 +35,16 @@
|
|||||||
"title": "Filters",
|
"title": "Filters",
|
||||||
"year": "Year",
|
"year": "Year",
|
||||||
"district": "District",
|
"district": "District",
|
||||||
"museum": "Museum",
|
"channel": "Channel",
|
||||||
|
"museum": "Event",
|
||||||
"quarter": "Quarter",
|
"quarter": "Quarter",
|
||||||
"allYears": "All Years",
|
"allYears": "All Years",
|
||||||
"allDistricts": "All Districts",
|
"allDistricts": "All Districts",
|
||||||
"allMuseums": "All Museums",
|
"allChannels": "All Channels",
|
||||||
|
"allMuseums": "All Events",
|
||||||
"allQuarters": "All Quarters",
|
"allQuarters": "All Quarters",
|
||||||
|
"season": "Season",
|
||||||
|
"allSeasons": "All Seasons",
|
||||||
"reset": "Reset Filters"
|
"reset": "Reset Filters"
|
||||||
},
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
@@ -52,19 +57,22 @@
|
|||||||
"avgRevenue": "Avg Rev/Visitor",
|
"avgRevenue": "Avg Rev/Visitor",
|
||||||
"avgRevenuePerVisitor": "Avg Revenue/Visitor",
|
"avgRevenuePerVisitor": "Avg Revenue/Visitor",
|
||||||
"pilgrims": "Pilgrims",
|
"pilgrims": "Pilgrims",
|
||||||
"captureRate": "Capture Rate"
|
"captureRate": "Capture Rate",
|
||||||
|
"bar": "Bar",
|
||||||
|
"pie": "Pie"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"subtitle": "Real-time museum analytics from Google Sheets",
|
"subtitle": "Event analytics from ERP",
|
||||||
"noData": "No data found",
|
"noData": "No data found",
|
||||||
"noDataMessage": "No records match your current filters. Try adjusting your selection.",
|
"noDataMessage": "No records match your current filters. Try adjusting your selection.",
|
||||||
"quarterlyComparison": "Quarterly Comparison: 2024 vs 2025",
|
"quarterlyComparison": "Quarterly Comparison: 2024 vs 2025",
|
||||||
"revenueTrends": "Revenue Trends",
|
"revenueTrends": "Revenue Trends",
|
||||||
"visitorsByMuseum": "Visitors by Museum",
|
"visitorsByMuseum": "Visitors by Event",
|
||||||
"revenueByMuseum": "Revenue by Museum",
|
"revenueByMuseum": "Revenue by Event",
|
||||||
"quarterlyRevenue": "Quarterly Revenue (YoY)",
|
"quarterlyRevenue": "Quarterly Revenue (YoY)",
|
||||||
"districtPerformance": "District Performance",
|
"districtPerformance": "District Performance",
|
||||||
|
"channelPerformance": "Channel Performance",
|
||||||
"captureRateChart": "Capture Rate vs Umrah Pilgrims"
|
"captureRateChart": "Capture Rate vs Umrah Pilgrims"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
@@ -117,7 +125,7 @@
|
|||||||
"noData": "No data for this period",
|
"noData": "No data for this period",
|
||||||
"noDataMessage": "No records found for the selected date range and filters.",
|
"noDataMessage": "No records found for the selected date range and filters.",
|
||||||
"trend": "Trend",
|
"trend": "Trend",
|
||||||
"byMuseum": "By Museum",
|
"byMuseum": "By Event",
|
||||||
"pendingData": "Data not published yet"
|
"pendingData": "Data not published yet"
|
||||||
},
|
},
|
||||||
"slides": {
|
"slides": {
|
||||||
@@ -137,7 +145,7 @@
|
|||||||
"showYoY": "Show Year-over-Year Comparison",
|
"showYoY": "Show Year-over-Year Comparison",
|
||||||
"exit": "Exit",
|
"exit": "Exit",
|
||||||
"revenueTrend": "Revenue Trend",
|
"revenueTrend": "Revenue Trend",
|
||||||
"byMuseum": "By Museum",
|
"byMuseum": "By Event",
|
||||||
"kpiSummary": "KPI Summary",
|
"kpiSummary": "KPI Summary",
|
||||||
"yoyComparison": "YoY Comparison"
|
"yoyComparison": "YoY Comparison"
|
||||||
},
|
},
|
||||||
@@ -147,8 +155,42 @@
|
|||||||
"revenue": "Revenue",
|
"revenue": "Revenue",
|
||||||
"quarterly": "Quarterly",
|
"quarterly": "Quarterly",
|
||||||
"district": "District",
|
"district": "District",
|
||||||
|
"channel": "Channel",
|
||||||
"captureRate": "Capture Rate"
|
"captureRate": "Capture Rate"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"subtitle": "Configure dashboard settings and hijri seasons",
|
||||||
|
"seasons": "Hijri Seasons",
|
||||||
|
"seasonsHint": "Define seasons with their Gregorian date ranges. These appear as filter presets and chart overlays.",
|
||||||
|
"seasonName": "Season",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"actions": "Actions",
|
||||||
|
"namePlaceholder": "e.g. Ramadan",
|
||||||
|
"add": "Add",
|
||||||
|
"delete": "Delete",
|
||||||
|
"users": "Users",
|
||||||
|
"usersHint": "Add users with a PIN code. Viewers can see the dashboard but not settings.",
|
||||||
|
"userName": "Name",
|
||||||
|
"userNamePlaceholder": "e.g. Ahmed",
|
||||||
|
"userPin": "PIN",
|
||||||
|
"userRole": "Role"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"subtitle": "Enter your PIN to access the dashboard",
|
||||||
|
"placeholder": "PIN code",
|
||||||
|
"submit": "Login",
|
||||||
|
"invalid": "Invalid PIN code",
|
||||||
|
"error": "Connection error. Please try again."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"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.",
|
||||||
|
"unknown": "Something went wrong while loading data. Please try again."
|
||||||
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"switch": "عربي"
|
"switch": "عربي"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
// Offline mode: caches data to localStorage for resilience
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -10,28 +10,64 @@ import type {
|
|||||||
CacheResult,
|
CacheResult,
|
||||||
FetchResult,
|
FetchResult,
|
||||||
GroupedData,
|
GroupedData,
|
||||||
DistrictMuseumMap,
|
|
||||||
UmrahData,
|
UmrahData,
|
||||||
NocoDBDistrict,
|
NocoDBDailySale,
|
||||||
NocoDBMuseum,
|
DataErrorType
|
||||||
NocoDBDailyStat
|
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import { DataError } from '../types';
|
||||||
|
import { fetchWithRetry } from '../utils/fetchHelpers';
|
||||||
|
|
||||||
const NOCODB_URL = import.meta.env.VITE_NOCODB_URL || '';
|
const NOCODB_URL = import.meta.env.VITE_NOCODB_URL || '';
|
||||||
const NOCODB_TOKEN = import.meta.env.VITE_NOCODB_TOKEN || '';
|
const NOCODB_TOKEN = import.meta.env.VITE_NOCODB_TOKEN || '';
|
||||||
|
const NOCODB_BASE_ID = import.meta.env.VITE_NOCODB_BASE_ID || '';
|
||||||
|
|
||||||
// Table IDs (Cloudron NocoDB)
|
// Table IDs discovered dynamically from NocoDB meta API
|
||||||
const NOCODB_TABLES = {
|
let discoveredTables: Record<string, string> | null = null;
|
||||||
districts: 'mddorhm0boab99m',
|
|
||||||
museums: 'm1os227987acanj',
|
async function discoverTableIds(): Promise<Record<string, string>> {
|
||||||
dailyStats: 'mbp0qntf9h6qth1',
|
if (discoveredTables) return discoveredTables;
|
||||||
pilgrimStats: 'mi90dy6w7mt0vp0'
|
if (!NOCODB_BASE_ID) throw new Error('NocoDB not configured');
|
||||||
};
|
|
||||||
|
const res = await fetchWithRetry(
|
||||||
|
`${NOCODB_URL}/api/v2/meta/bases/${NOCODB_BASE_ID}/tables`,
|
||||||
|
{ headers: { 'xc-token': NOCODB_TOKEN } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
const tables: Record<string, string> = {};
|
||||||
|
for (const t of json.list) {
|
||||||
|
tables[t.title] = t.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveredTables = tables;
|
||||||
|
return tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNocoDBTable<T>(tableId: string, limit: number = 1000): Promise<T[]> {
|
||||||
|
let allRecords: T[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const response = await fetchWithRetry(
|
||||||
|
`${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`,
|
||||||
|
{ headers: { 'xc-token': NOCODB_TOKEN } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
const records: T[] = json.list || [];
|
||||||
|
allRecords = allRecords.concat(records);
|
||||||
|
|
||||||
|
if (records.length < limit) break;
|
||||||
|
offset += limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allRecords;
|
||||||
|
}
|
||||||
|
|
||||||
// Cache keys
|
// Cache keys
|
||||||
const CACHE_KEY = 'hihala_data_cache';
|
const CACHE_KEY = 'hihala_data_cache';
|
||||||
const CACHE_TIMESTAMP_KEY = 'hihala_data_cache_timestamp';
|
const CACHE_TIMESTAMP_KEY = 'hihala_data_cache_timestamp';
|
||||||
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
|
||||||
// Default umrah data (overridden by NocoDB PilgrimStats when available)
|
// Default umrah data (overridden by NocoDB PilgrimStats when available)
|
||||||
export let umrahData: UmrahData = {
|
export let umrahData: UmrahData = {
|
||||||
@@ -39,19 +75,22 @@ export let umrahData: UmrahData = {
|
|||||||
2025: { 1: 15222497, 2: 5443393, 3: 26643148, 4: 31591871 }
|
2025: { 1: 15222497, 2: 5443393, 3: 26643148, 4: 31591871 }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch pilgrim stats from NocoDB and update umrahData
|
|
||||||
export async function fetchPilgrimStats(): Promise<UmrahData> {
|
export async function fetchPilgrimStats(): Promise<UmrahData> {
|
||||||
try {
|
try {
|
||||||
const url = `${NOCODB_URL}/api/v2/tables/${NOCODB_TABLES.pilgrimStats}/records?limit=50`;
|
const tables = await discoverTableIds();
|
||||||
const res = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
if (!tables['PilgrimStats']) {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
console.warn('PilgrimStats table not found, using defaults');
|
||||||
|
return umrahData;
|
||||||
|
}
|
||||||
|
const url = `${NOCODB_URL}/api/v2/tables/${tables['PilgrimStats']}/records?limit=50`;
|
||||||
|
const res = await fetchWithRetry(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
const records = json.list || [];
|
const records = json.list || [];
|
||||||
|
|
||||||
const data: UmrahData = { 2024: {}, 2025: {} };
|
const data: UmrahData = { 2024: {}, 2025: {} };
|
||||||
for (const r of records) {
|
for (const r of records) {
|
||||||
const year = r.Year as number;
|
const year = r.Year as number;
|
||||||
const qStr = r.Quarter as string; // "Q1", "Q2", etc.
|
const qStr = r.Quarter as string;
|
||||||
const qNum = parseInt(qStr.replace('Q', ''));
|
const qNum = parseInt(qStr.replace('Q', ''));
|
||||||
const total = r.TotalPilgrims as number;
|
const total = r.TotalPilgrims as number;
|
||||||
if (year && qNum && total) {
|
if (year && qNum && total) {
|
||||||
@@ -59,8 +98,7 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
|
|||||||
data[year][qNum] = total;
|
data[year][qNum] = total;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the global umrahData
|
|
||||||
umrahData = data;
|
umrahData = data;
|
||||||
console.log('PilgrimStats loaded from NocoDB:', data);
|
console.log('PilgrimStats loaded from NocoDB:', data);
|
||||||
return data;
|
return data;
|
||||||
@@ -70,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
|
// Offline Cache Functions
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -88,15 +162,15 @@ function loadFromCache(): CacheResult | null {
|
|||||||
try {
|
try {
|
||||||
const cached = localStorage.getItem(CACHE_KEY);
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
||||||
|
|
||||||
if (!cached) return null;
|
if (!cached) return null;
|
||||||
|
|
||||||
const data: MuseumRecord[] = JSON.parse(cached);
|
const data: MuseumRecord[] = JSON.parse(cached);
|
||||||
const age = timestamp ? Date.now() - parseInt(timestamp) : Infinity;
|
const age = timestamp ? Date.now() - parseInt(timestamp) : Infinity;
|
||||||
const isStale = age > CACHE_MAX_AGE_MS;
|
const isStale = age > CACHE_MAX_AGE_MS;
|
||||||
|
|
||||||
console.log(`Loaded ${data.length} rows from cache (age: ${Math.round(age / 1000 / 60)} min, stale: ${isStale})`);
|
console.log(`Loaded ${data.length} rows from cache (age: ${Math.round(age / 1000 / 60)} min, stale: ${isStale})`);
|
||||||
|
|
||||||
return { data, isStale, timestamp: parseInt(timestamp || '0') };
|
return { data, isStale, timestamp: parseInt(timestamp || '0') };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to load from cache:', (err as Error).message);
|
console.warn('Failed to load from cache:', (err as Error).message);
|
||||||
@@ -107,14 +181,14 @@ function loadFromCache(): CacheResult | null {
|
|||||||
export function getCacheStatus(): CacheStatus {
|
export function getCacheStatus(): CacheStatus {
|
||||||
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
||||||
const cached = localStorage.getItem(CACHE_KEY);
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
|
|
||||||
if (!cached || !timestamp) {
|
if (!cached || !timestamp) {
|
||||||
return { available: false, timestamp: null, age: null, rows: 0 };
|
return { available: false, timestamp: null, age: null, rows: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ts = parseInt(timestamp);
|
const ts = parseInt(timestamp);
|
||||||
const data: MuseumRecord[] = JSON.parse(cached);
|
const data: MuseumRecord[] = JSON.parse(cached);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
available: true,
|
available: true,
|
||||||
timestamp: new Date(ts).toISOString(),
|
timestamp: new Date(ts).toISOString(),
|
||||||
@@ -131,90 +205,16 @@ export function clearCache(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// NocoDB Data Fetching
|
// Error Classification
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
async function fetchNocoDBTable<T>(tableId: string, limit: number = 1000): Promise<T[]> {
|
function classifyError(err: Error): DataErrorType {
|
||||||
let allRecords: T[] = [];
|
const msg = err.message.toLowerCase();
|
||||||
let offset = 0;
|
if (msg.includes('not configured')) return 'config';
|
||||||
|
if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout';
|
||||||
while (true) {
|
if (msg.includes('401') || msg.includes('403') || msg.includes('authentication') || msg.includes('unauthorized')) return 'auth';
|
||||||
const response = await fetch(
|
if (msg.includes('failed to fetch') || msg.includes('network') || msg.includes('econnrefused') || msg.includes('err_connection')) return 'network';
|
||||||
`${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`,
|
return 'unknown';
|
||||||
{ headers: { 'xc-token': NOCODB_TOKEN } }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
||||||
|
|
||||||
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...');
|
|
||||||
|
|
||||||
// Fetch all three tables in parallel
|
|
||||||
const [districts, museums, dailyStats] = await Promise.all([
|
|
||||||
fetchNocoDBTable<NocoDBDistrict>(NOCODB_TABLES.districts),
|
|
||||||
fetchNocoDBTable<NocoDBMuseum>(NOCODB_TABLES.museums),
|
|
||||||
fetchNocoDBTable<NocoDBDailyStat>(NOCODB_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']] || 'Unknown'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Join data into flat structure
|
|
||||||
const data: MuseumRecord[] = dailyStats.map(row => {
|
|
||||||
const museum = museumMap[row.MuseumId || row['nc_epk____Museums_id']] || { 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 / 1.15);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -222,45 +222,46 @@ async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
export async function fetchData(): Promise<FetchResult> {
|
export async function fetchData(): Promise<FetchResult> {
|
||||||
// Check if NocoDB is configured
|
if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) {
|
||||||
if (!NOCODB_URL || !NOCODB_TOKEN) {
|
|
||||||
// Try cache
|
|
||||||
const cached = loadFromCache();
|
const cached = loadFromCache();
|
||||||
if (cached) {
|
if (cached) {
|
||||||
console.warn('NocoDB not configured, using cached data');
|
console.warn('NocoDB not configured, using cached data');
|
||||||
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp };
|
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp };
|
||||||
}
|
}
|
||||||
throw new Error('NocoDB not configured and no cached data available. Set VITE_NOCODB_URL and VITE_NOCODB_TOKEN in .env.local');
|
throw new DataError('NocoDB not configured', 'config');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to fetch fresh data
|
|
||||||
const data = await fetchFromNocoDB();
|
const data = await fetchFromNocoDB();
|
||||||
|
|
||||||
// Save to cache on success
|
// 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);
|
saveToCache(data);
|
||||||
|
|
||||||
return { data, fromCache: false };
|
return { data, fromCache: false };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('NocoDB fetch failed:', (err as Error).message);
|
console.error('NocoDB fetch failed:', (err as Error).message);
|
||||||
|
|
||||||
// Try to load from cache
|
|
||||||
const cached = loadFromCache();
|
const cached = loadFromCache();
|
||||||
if (cached) {
|
if (cached) {
|
||||||
console.warn(`Using cached data from ${new Date(cached.timestamp).toLocaleString()} (offline mode)`);
|
console.warn(`Using cached data from ${new Date(cached.timestamp).toLocaleString()} (offline mode)`);
|
||||||
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp };
|
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp };
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Database unavailable and no cached data: ${(err as Error).message}`);
|
const errorType = classifyError(err as Error);
|
||||||
|
throw new DataError((err as Error).message, errorType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force refresh (bypass cache read, but still write to cache)
|
|
||||||
export async function refreshData(): Promise<FetchResult> {
|
export async function refreshData(): Promise<FetchResult> {
|
||||||
if (!NOCODB_URL || !NOCODB_TOKEN) {
|
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();
|
const data = await fetchFromNocoDB();
|
||||||
saveToCache(data);
|
saveToCache(data);
|
||||||
return { data, fromCache: false };
|
return { data, fromCache: false };
|
||||||
@@ -274,30 +275,32 @@ export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord
|
|||||||
return data.filter(row => {
|
return data.filter(row => {
|
||||||
if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false;
|
if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false;
|
||||||
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
||||||
if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
|
if (filters.channel.length > 0 && !filters.channel.includes(row.channel)) return false;
|
||||||
|
if (filters.museum.length > 0 && !filters.museum.includes(row.museum_name)) return false;
|
||||||
if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false;
|
if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterDataByDateRange(
|
export function filterDataByDateRange(
|
||||||
data: MuseumRecord[],
|
data: MuseumRecord[],
|
||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string,
|
endDate: string,
|
||||||
filters: Partial<DateRangeFilters> = {}
|
filters: Partial<DateRangeFilters> = {}
|
||||||
): MuseumRecord[] {
|
): MuseumRecord[] {
|
||||||
return data.filter(row => {
|
return data.filter(row => {
|
||||||
if (!row.date) return false;
|
if (!row.date) return false;
|
||||||
if (row.date < startDate || row.date > endDate) return false;
|
if (row.date < startDate || row.date > endDate) return false;
|
||||||
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
||||||
if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
|
if (filters.channel && filters.channel.length > 0 && !filters.channel.includes(row.channel)) return false;
|
||||||
|
if (filters.museum && filters.museum.length > 0 && !filters.museum.includes(row.museum_name)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateMetrics(data: MuseumRecord[], includeVAT: boolean = true): Metrics {
|
export function calculateMetrics(data: MuseumRecord[], includeVAT: boolean = true): Metrics {
|
||||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||||
const revenue = data.reduce((sum, row) => sum + (row[revenueField] || row.revenue_incl_tax || 0), 0);
|
const revenue = data.reduce((sum, row) => sum + (row[revenueField] || 0), 0);
|
||||||
const visitors = data.reduce((sum, row) => sum + (row.visits || 0), 0);
|
const visitors = data.reduce((sum, row) => sum + (row.visits || 0), 0);
|
||||||
const tickets = data.reduce((sum, row) => sum + (row.tickets || 0), 0);
|
const tickets = data.reduce((sum, row) => sum + (row.tickets || 0), 0);
|
||||||
const avgRevPerVisitor = visitors > 0 ? revenue / visitors : 0;
|
const avgRevPerVisitor = visitors > 0 ? revenue / visitors : 0;
|
||||||
@@ -344,17 +347,17 @@ export function formatCompactCurrency(num: number): string {
|
|||||||
|
|
||||||
export function getWeekStart(dateStr: string): string | null {
|
export function getWeekStart(dateStr: string): string | null {
|
||||||
if (!dateStr || !dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) return null;
|
if (!dateStr || !dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) return null;
|
||||||
|
|
||||||
const [year, month, day] = dateStr.split('-').map(Number);
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
const date = new Date(year, month - 1, day);
|
const date = new Date(year, month - 1, day);
|
||||||
const dayOfWeek = date.getDay();
|
const dayOfWeek = date.getDay();
|
||||||
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
||||||
|
|
||||||
const monday = new Date(year, month - 1, day + diff);
|
const monday = new Date(year, month - 1, day + diff);
|
||||||
const y = monday.getFullYear();
|
const y = monday.getFullYear();
|
||||||
const m = String(monday.getMonth() + 1).padStart(2, '0');
|
const m = String(monday.getMonth() + 1).padStart(2, '0');
|
||||||
const d = String(monday.getDate()).padStart(2, '0');
|
const d = String(monday.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
return `${y}-${m}-${d}`;
|
return `${y}-${m}-${d}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +369,7 @@ export function groupByWeek(data: MuseumRecord[], includeVAT: boolean = true): R
|
|||||||
const weekStart = getWeekStart(row.date);
|
const weekStart = getWeekStart(row.date);
|
||||||
if (!weekStart) return;
|
if (!weekStart) return;
|
||||||
if (!grouped[weekStart]) grouped[weekStart] = { revenue: 0, visitors: 0, tickets: 0 };
|
if (!grouped[weekStart]) grouped[weekStart] = { revenue: 0, visitors: 0, tickets: 0 };
|
||||||
grouped[weekStart].revenue += row[revenueField] || row.revenue_incl_tax || 0;
|
grouped[weekStart].revenue += row[revenueField] || 0;
|
||||||
grouped[weekStart].visitors += row.visits || 0;
|
grouped[weekStart].visitors += row.visits || 0;
|
||||||
grouped[weekStart].tickets += row.tickets || 0;
|
grouped[weekStart].tickets += row.tickets || 0;
|
||||||
});
|
});
|
||||||
@@ -379,22 +382,22 @@ export function groupByMuseum(data: MuseumRecord[], includeVAT: boolean = true):
|
|||||||
data.forEach(row => {
|
data.forEach(row => {
|
||||||
if (!row.museum_name) return;
|
if (!row.museum_name) return;
|
||||||
if (!grouped[row.museum_name]) grouped[row.museum_name] = { revenue: 0, visitors: 0, tickets: 0 };
|
if (!grouped[row.museum_name]) grouped[row.museum_name] = { revenue: 0, visitors: 0, tickets: 0 };
|
||||||
grouped[row.museum_name].revenue += row[revenueField] || row.revenue_incl_tax || 0;
|
grouped[row.museum_name].revenue += row[revenueField] || 0;
|
||||||
grouped[row.museum_name].visitors += row.visits || 0;
|
grouped[row.museum_name].visitors += row.visits || 0;
|
||||||
grouped[row.museum_name].tickets += row.tickets || 0;
|
grouped[row.museum_name].tickets += row.tickets || 0;
|
||||||
});
|
});
|
||||||
return grouped;
|
return grouped;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function groupByDistrict(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
|
export function groupByChannel(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
|
||||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||||
const grouped: Record<string, GroupedData> = {};
|
const grouped: Record<string, GroupedData> = {};
|
||||||
data.forEach(row => {
|
data.forEach(row => {
|
||||||
if (!row.district) return;
|
if (!row.channel) return;
|
||||||
if (!grouped[row.district]) grouped[row.district] = { revenue: 0, visitors: 0, tickets: 0 };
|
if (!grouped[row.channel]) grouped[row.channel] = { revenue: 0, visitors: 0, tickets: 0 };
|
||||||
grouped[row.district].revenue += row[revenueField] || row.revenue_incl_tax || 0;
|
grouped[row.channel].revenue += row[revenueField] || 0;
|
||||||
grouped[row.district].visitors += row.visits || 0;
|
grouped[row.channel].visitors += row.visits || 0;
|
||||||
grouped[row.district].tickets += row.tickets || 0;
|
grouped[row.channel].tickets += row.tickets || 0;
|
||||||
});
|
});
|
||||||
return grouped;
|
return grouped;
|
||||||
}
|
}
|
||||||
@@ -412,25 +415,30 @@ export function getUniqueDistricts(data: MuseumRecord[]): string[] {
|
|||||||
return [...new Set(data.map(r => r.district).filter(Boolean))].sort();
|
return [...new Set(data.map(r => r.district).filter(Boolean))].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDistrictMuseumMap(data: MuseumRecord[]): DistrictMuseumMap {
|
export function groupByDistrict(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
|
||||||
const map: Record<string, Set<string>> = {};
|
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||||
|
const grouped: Record<string, GroupedData> = {};
|
||||||
data.forEach(row => {
|
data.forEach(row => {
|
||||||
if (!row.district || !row.museum_name) return;
|
if (!row.district) return;
|
||||||
if (!map[row.district]) map[row.district] = new Set();
|
if (!grouped[row.district]) grouped[row.district] = { revenue: 0, visitors: 0, tickets: 0 };
|
||||||
map[row.district].add(row.museum_name);
|
grouped[row.district].revenue += row[revenueField] || 0;
|
||||||
|
grouped[row.district].visitors += row.visits || 0;
|
||||||
|
grouped[row.district].tickets += row.tickets || 0;
|
||||||
});
|
});
|
||||||
const result: DistrictMuseumMap = {};
|
return grouped;
|
||||||
Object.keys(map).forEach(d => {
|
|
||||||
result[d] = [...map[d]].sort();
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMuseumsForDistrict(districtMuseumMap: DistrictMuseumMap, district: string): string[] {
|
export function getMuseumsForDistrict(data: MuseumRecord[], district: string): string[] {
|
||||||
if (district === 'all') {
|
if (district === 'all') return getUniqueMuseums(data);
|
||||||
return Object.values(districtMuseumMap).flat().sort();
|
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 {
|
export function getLatestYear(data: MuseumRecord[]): string {
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
// Salla Integration Service
|
|
||||||
// Connects to the local Salla backend server
|
|
||||||
|
|
||||||
const SALLA_SERVER_URL = import.meta.env.VITE_SALLA_SERVER_URL || 'http://localhost:3001';
|
|
||||||
|
|
||||||
export interface SallaAuthStatus {
|
|
||||||
connected: boolean;
|
|
||||||
hasRefreshToken: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SallaOrder {
|
|
||||||
id: number;
|
|
||||||
reference_id: string;
|
|
||||||
status: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
customized: { id: string; name: string };
|
|
||||||
};
|
|
||||||
amounts: {
|
|
||||||
total: { amount: number; currency: string };
|
|
||||||
sub_total: { amount: number; currency: string };
|
|
||||||
};
|
|
||||||
customer: {
|
|
||||||
id: number;
|
|
||||||
first_name: string;
|
|
||||||
last_name: string;
|
|
||||||
email: string;
|
|
||||||
mobile: string;
|
|
||||||
};
|
|
||||||
items: Array<{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
quantity: number;
|
|
||||||
amounts: { total: { amount: number } };
|
|
||||||
}>;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SallaProduct {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
sku: string;
|
|
||||||
price: { amount: number; currency: string };
|
|
||||||
quantity: number;
|
|
||||||
status: string;
|
|
||||||
sold_quantity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SallaSummary {
|
|
||||||
orders: { total: number; recent: number };
|
|
||||||
products: { total: number };
|
|
||||||
revenue: { total: number; average_order: number; currency: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SallaStore {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
domain: string;
|
|
||||||
plan: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// API Functions
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export async function checkSallaAuth(): Promise<SallaAuthStatus> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${SALLA_SERVER_URL}/auth/status`);
|
|
||||||
return response.json();
|
|
||||||
} catch (err) {
|
|
||||||
return { connected: false, hasRefreshToken: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSallaLoginUrl(): string {
|
|
||||||
return `${SALLA_SERVER_URL}/auth/login`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSallaStore(): Promise<SallaStore | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${SALLA_SERVER_URL}/api/store`);
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch store');
|
|
||||||
const data = await response.json();
|
|
||||||
return data.data;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching store:', err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSallaOrders(page = 1, perPage = 50): Promise<{ data: SallaOrder[]; pagination: any }> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${SALLA_SERVER_URL}/api/orders?page=${page}&per_page=${perPage}`);
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch orders');
|
|
||||||
return response.json();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching orders:', err);
|
|
||||||
return { data: [], pagination: {} };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSallaProducts(page = 1, perPage = 50): Promise<{ data: SallaProduct[]; pagination: any }> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${SALLA_SERVER_URL}/api/products?page=${page}&per_page=${perPage}`);
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch products');
|
|
||||||
return response.json();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching products:', err);
|
|
||||||
return { data: [], pagination: {} };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSallaSummary(): Promise<SallaSummary | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${SALLA_SERVER_URL}/api/analytics/summary`);
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch summary');
|
|
||||||
return response.json();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching summary:', err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Data Transformation for Dashboard
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export function transformOrdersForChart(orders: SallaOrder[]): {
|
|
||||||
labels: string[];
|
|
||||||
datasets: { label: string; data: number[] }[];
|
|
||||||
} {
|
|
||||||
// Group orders by date
|
|
||||||
const byDate: Record<string, number> = {};
|
|
||||||
|
|
||||||
orders.forEach(order => {
|
|
||||||
const date = order.created_at.split('T')[0];
|
|
||||||
byDate[date] = (byDate[date] || 0) + (order.amounts?.total?.amount || 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedDates = Object.keys(byDate).sort();
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels: sortedDates,
|
|
||||||
datasets: [{
|
|
||||||
label: 'Daily Revenue (SAR)',
|
|
||||||
data: sortedDates.map(d => byDate[d])
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOrderStatusSummary(orders: SallaOrder[]): Record<string, number> {
|
|
||||||
const byStatus: Record<string, number> = {};
|
|
||||||
|
|
||||||
orders.forEach(order => {
|
|
||||||
const status = order.status?.name || 'Unknown';
|
|
||||||
byStatus[status] = (byStatus[status] || 0) + 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
return byStatus;
|
|
||||||
}
|
|
||||||
37
src/services/seasonsService.ts
Normal file
37
src/services/seasonsService.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Season } from '../types';
|
||||||
|
|
||||||
|
export async function fetchSeasons(): Promise<Season[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/seasons');
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return res.json();
|
||||||
|
} catch {
|
||||||
|
console.warn('Failed to load seasons, using empty list');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSeason(season: Omit<Season, 'Id'>): Promise<Season> {
|
||||||
|
const res = await fetch('/api/seasons', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(season),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to create season');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSeason(id: number, season: Partial<Season>): Promise<Season> {
|
||||||
|
const res = await fetch(`/api/seasons/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(season),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to update season');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSeason(id: number): Promise<void> {
|
||||||
|
const res = await fetch(`/api/seasons/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error('Failed to delete season');
|
||||||
|
}
|
||||||
58
src/services/usersService.ts
Normal file
58
src/services/usersService.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
export interface User {
|
||||||
|
Id?: number;
|
||||||
|
Name: string;
|
||||||
|
PIN: string;
|
||||||
|
Role: string;
|
||||||
|
AllowedMuseums: string; // JSON-serialized string[], '[]' = unrestricted
|
||||||
|
AllowedChannels: string; // JSON-serialized string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// null = parse error → fail-closed (show nothing)
|
||||||
|
// [] = unrestricted (admin or no restriction set)
|
||||||
|
// string[] = restricted to this list
|
||||||
|
export function parseAllowed(raw: string | undefined | null): string[] | null {
|
||||||
|
if (raw == null) return []; // field not set → unrestricted
|
||||||
|
if (raw === '[]') return []; // explicit empty → unrestricted
|
||||||
|
if (raw === '') return null; // blank string → corrupted → fail-closed
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return null;
|
||||||
|
return parsed as string[];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUsers(): Promise<User[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users');
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return res.json();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(user: Omit<User, 'Id'>): Promise<User> {
|
||||||
|
const res = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(user),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to create user');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id: number, fields: Partial<User>): Promise<void> {
|
||||||
|
const res = await fetch(`/api/users/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(fields),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to update user');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: number): Promise<void> {
|
||||||
|
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error('Failed to delete user');
|
||||||
|
}
|
||||||
@@ -2,14 +2,13 @@
|
|||||||
|
|
||||||
export interface MuseumRecord {
|
export interface MuseumRecord {
|
||||||
date: string;
|
date: string;
|
||||||
museum_code: string;
|
|
||||||
museum_name: string;
|
|
||||||
district: string;
|
district: string;
|
||||||
|
museum_name: string;
|
||||||
|
channel: string;
|
||||||
visits: number;
|
visits: number;
|
||||||
tickets: number;
|
tickets: number;
|
||||||
revenue_gross: number;
|
revenue_gross: number;
|
||||||
revenue_net: number;
|
revenue_net: number;
|
||||||
revenue_incl_tax: number; // Legacy field
|
|
||||||
year: string;
|
year: string;
|
||||||
quarter: string;
|
quarter: string;
|
||||||
}
|
}
|
||||||
@@ -24,13 +23,15 @@ export interface Metrics {
|
|||||||
export interface Filters {
|
export interface Filters {
|
||||||
year: string;
|
year: string;
|
||||||
district: string;
|
district: string;
|
||||||
museum: string;
|
channel: string[];
|
||||||
|
museum: string[];
|
||||||
quarter: string;
|
quarter: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateRangeFilters {
|
export interface DateRangeFilters {
|
||||||
district: string;
|
district: string;
|
||||||
museum: string;
|
channel: string[];
|
||||||
|
museum: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CacheStatus {
|
export interface CacheStatus {
|
||||||
@@ -53,22 +54,54 @@ export interface FetchResult {
|
|||||||
cacheTimestamp?: number;
|
cacheTimestamp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DataErrorType = 'config' | 'network' | 'auth' | 'timeout' | 'unknown';
|
||||||
|
|
||||||
|
export class DataError extends Error {
|
||||||
|
type: DataErrorType;
|
||||||
|
constructor(message: string, type: DataErrorType) {
|
||||||
|
super(message);
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupedData {
|
export interface GroupedData {
|
||||||
revenue: number;
|
revenue: number;
|
||||||
visitors: number;
|
visitors: number;
|
||||||
tickets: number;
|
tickets: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DistrictMuseumMap {
|
|
||||||
[district: string]: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UmrahData {
|
export interface UmrahData {
|
||||||
[year: number]: {
|
[year: number]: {
|
||||||
[quarter: number]: number | null;
|
[quarter: number]: number | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Season (hijri calendar overlay)
|
||||||
|
export interface Season {
|
||||||
|
Id?: number;
|
||||||
|
Name: string;
|
||||||
|
HijriYear: number;
|
||||||
|
StartDate: string;
|
||||||
|
EndDate: string;
|
||||||
|
Color: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Chart data types
|
// Chart data types
|
||||||
export interface ChartDataset {
|
export interface ChartDataset {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -97,18 +130,25 @@ export interface ChartData {
|
|||||||
// Component props
|
// Component props
|
||||||
export interface DashboardProps {
|
export interface DashboardProps {
|
||||||
data: MuseumRecord[];
|
data: MuseumRecord[];
|
||||||
|
seasons: Season[];
|
||||||
|
userRole: string;
|
||||||
showDataLabels: boolean;
|
showDataLabels: boolean;
|
||||||
setShowDataLabels: (value: boolean) => void;
|
setShowDataLabels: (value: boolean) => void;
|
||||||
includeVAT: boolean;
|
includeVAT: boolean;
|
||||||
setIncludeVAT: (value: boolean) => void;
|
setIncludeVAT: (value: boolean) => void;
|
||||||
|
allowedMuseums: string[] | null;
|
||||||
|
allowedChannels: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComparisonProps {
|
export interface ComparisonProps {
|
||||||
data: MuseumRecord[];
|
data: MuseumRecord[];
|
||||||
|
seasons: Season[];
|
||||||
showDataLabels: boolean;
|
showDataLabels: boolean;
|
||||||
setShowDataLabels: (value: boolean) => void;
|
setShowDataLabels: (value: boolean) => void;
|
||||||
includeVAT: boolean;
|
includeVAT: boolean;
|
||||||
setIncludeVAT: (value: boolean) => void;
|
setIncludeVAT: (value: boolean) => void;
|
||||||
|
allowedMuseums: string[] | null;
|
||||||
|
allowedChannels: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SlidesProps {
|
export interface SlidesProps {
|
||||||
@@ -139,29 +179,34 @@ export interface MetricCardData {
|
|||||||
pendingMessage?: string;
|
pendingMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NocoDB raw types
|
// Slide types
|
||||||
export interface NocoDBDistrict {
|
export interface SlideConfig {
|
||||||
Id: number;
|
id: number;
|
||||||
Name: string;
|
title: string;
|
||||||
|
chartType: string;
|
||||||
|
metric: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
channel: string;
|
||||||
|
museum: string;
|
||||||
|
showComparison: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NocoDBMuseum {
|
export interface ChartTypeOption {
|
||||||
Id: number;
|
id: string;
|
||||||
Code: string;
|
label: string;
|
||||||
Name: string;
|
icon: string;
|
||||||
DistrictId?: number;
|
|
||||||
'nc_epk____Districts_id'?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NocoDBDailyStat {
|
export interface MetricOption {
|
||||||
Id: number;
|
id: string;
|
||||||
Date: string;
|
label: string;
|
||||||
Visits: number;
|
field: string;
|
||||||
Tickets: number;
|
}
|
||||||
GrossRevenue: number;
|
|
||||||
NetRevenue: number;
|
export interface MetricFieldInfo {
|
||||||
MuseumId?: number;
|
field: string;
|
||||||
'nc_epk____Museums_id'?: number;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translation function type
|
// Translation function type
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
39
start.sh
39
start.sh
@@ -1,46 +1,45 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Launch both NocoDB (backend) and React (frontend)
|
# Start local dev environment: NocoDB + Express server + Vite
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
echo ""
|
echo ""
|
||||||
echo "Shutting down..."
|
echo "Shutting down..."
|
||||||
if [ -n "$REACT_PID" ]; then
|
kill $SERVER_PID $CLIENT_PID 2>/dev/null
|
||||||
kill "$REACT_PID" 2>/dev/null
|
|
||||||
fi
|
|
||||||
docker stop nocodb 2>/dev/null
|
docker stop nocodb 2>/dev/null
|
||||||
echo "Done."
|
echo "Done."
|
||||||
}
|
}
|
||||||
|
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
# Start NocoDB container
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# Start NocoDB
|
||||||
if docker ps --format '{{.Names}}' | grep -q '^nocodb$'; then
|
if docker ps --format '{{.Names}}' | grep -q '^nocodb$'; then
|
||||||
echo "NocoDB already running on port 8090"
|
echo "NocoDB already running on port 8090"
|
||||||
else
|
else
|
||||||
echo "Starting NocoDB..."
|
echo "Starting NocoDB..."
|
||||||
docker start nocodb 2>/dev/null || docker run -d \
|
docker start nocodb 2>/dev/null || docker run -d \
|
||||||
--name nocodb \
|
--name nocodb -p 8090:8080 nocodb/nocodb:latest
|
||||||
-p 8090:8080 \
|
|
||||||
nocodb/nocodb:latest
|
|
||||||
echo "NocoDB started on port 8090"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Wait for NocoDB to be ready
|
|
||||||
echo "Waiting for NocoDB..."
|
echo "Waiting for NocoDB..."
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if curl -s http://localhost:8090/api/v1/health >/dev/null 2>&1; then
|
curl -s http://localhost:8090/api/v1/health >/dev/null 2>&1 && echo "NocoDB ready" && break
|
||||||
echo "NocoDB is ready"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
# Start React dev server
|
# Start Express server (port 3002)
|
||||||
echo "Starting React dev server..."
|
echo "Starting Express server..."
|
||||||
cd "$(dirname "$0")"
|
(cd server && npm run dev) &
|
||||||
npm start &
|
SERVER_PID=$!
|
||||||
REACT_PID=$!
|
|
||||||
|
|
||||||
wait $REACT_PID
|
sleep 2
|
||||||
|
|
||||||
|
# Start Vite (port 3000)
|
||||||
|
echo "Starting Vite..."
|
||||||
|
npx vite &
|
||||||
|
CLIENT_PID=$!
|
||||||
|
|
||||||
|
wait $CLIENT_PID
|
||||||
|
|||||||
@@ -6,9 +6,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"strict": false,
|
"strict": true,
|
||||||
"noImplicitAny": false,
|
|
||||||
"strictNullChecks": false,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
|
|||||||
@@ -6,7 +6,27 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/auth': {
|
||||||
|
target: 'http://localhost:3002',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/api/erp': {
|
||||||
|
target: 'http://localhost:3002',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/api/etl': {
|
||||||
|
target: 'http://localhost:3002',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/api/users': {
|
||||||
|
target: 'http://localhost:3002',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/api/seasons': {
|
||||||
|
target: 'http://localhost:3002',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/api/v2': {
|
||||||
target: 'http://localhost:8090',
|
target: 'http://localhost:8090',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user