Compare commits
54 Commits
ed29e7c22c
...
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 |
10
.env.example
10
.env.example
@@ -1,8 +1,4 @@
|
||||
# NocoDB (primary data source)
|
||||
# NocoDB (PilgrimStats only — museum sales come from ERP API via server proxy)
|
||||
VITE_NOCODB_URL=http://localhost:8090
|
||||
VITE_NOCODB_TOKEN=your_token_here
|
||||
VITE_NOCODB_BASE_ID=your_base_id_here
|
||||
|
||||
# Google Sheets (fallback if NocoDB fails)
|
||||
VITE_SHEETS_ID=your_spreadsheet_id_here
|
||||
VITE_SHEETS_NAME=Consolidated Data
|
||||
VITE_NOCODB_TOKEN=your-token
|
||||
VITE_NOCODB_BASE_ID=your-base-id
|
||||
|
||||
@@ -8,11 +8,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
|
||||
# --- Frontend ---
|
||||
- name: Build frontend
|
||||
env:
|
||||
VITE_NOCODB_URL: ${{ secrets.VITE_NOCODB_URL }}
|
||||
@@ -21,7 +22,41 @@ jobs:
|
||||
run: |
|
||||
npm ci
|
||||
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: |
|
||||
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" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#f8fafc" />
|
||||
<meta name="description" content="HiHala Data Dashboard — Event analytics, visitor tracking, and revenue insights" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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">
|
||||
<title>HiHala Data – Museums</title>
|
||||
<title>HiHala Data</title>
|
||||
</head>
|
||||
<body>
|
||||
<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/user-event": "^13.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
@@ -28,6 +29,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"concurrently": "^9.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
@@ -1426,6 +1428,22 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
@@ -1512,6 +1530,36 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
@@ -1524,6 +1572,15 @@
|
||||
"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": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",
|
||||
@@ -1533,6 +1590,66 @@
|
||||
"chart.js": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1608,6 +1725,13 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
@@ -1703,6 +1827,26 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
@@ -1737,6 +1881,16 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -2084,6 +2238,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
@@ -2129,6 +2293,16 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -2157,6 +2331,19 @@
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2167,6 +2354,34 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
@@ -2179,6 +2394,22 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
@@ -2205,6 +2436,23 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -2353,12 +2601,69 @@
|
||||
"integrity": "sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
@@ -18,6 +19,9 @@
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||
"dev:client": "vite",
|
||||
"dev:server": "cd server && npm run dev",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
@@ -28,6 +32,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"concurrently": "^9.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
# Salla OAuth Credentials (from Salla Partners dashboard)
|
||||
SALLA_CLIENT_ID=your_client_id_here
|
||||
SALLA_CLIENT_SECRET=your_client_secret_here
|
||||
SALLA_REDIRECT_URI=http://localhost:3001/auth/callback
|
||||
# Server
|
||||
SERVER_PORT=3002
|
||||
|
||||
# Server port
|
||||
SALLA_SERVER_PORT=3001
|
||||
# Hono ERP API (museum sales data)
|
||||
ERP_API_URL=https://hono-erp.azurewebsites.net
|
||||
ERP_API_CODE=your-api-function-key
|
||||
ERP_USERNAME=your-username
|
||||
ERP_PASSWORD=your-password
|
||||
|
||||
# After OAuth, these will be populated automatically
|
||||
# SALLA_ACCESS_TOKEN=
|
||||
# SALLA_REFRESH_TOKEN=
|
||||
# NocoDB (for ETL writes)
|
||||
NOCODB_URL=http://localhost:8090
|
||||
NOCODB_TOKEN=your-token
|
||||
NOCODB_BASE_ID=your-base-id
|
||||
|
||||
# ETL sync secret (for cron auth)
|
||||
ETL_SECRET=your-secret-here
|
||||
|
||||
# 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",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hihala-salla-server",
|
||||
"name": "hihala-server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"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": {
|
||||
@@ -154,6 +742,25 @@
|
||||
"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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
@@ -300,6 +907,48 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@@ -433,6 +1082,21 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -479,6 +1143,19 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.7",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
|
||||
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -767,6 +1444,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -934,6 +1621,26 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
@@ -947,6 +1654,27 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
{
|
||||
"name": "hihala-salla-server",
|
||||
"name": "hihala-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend server for Salla OAuth and API integration",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"description": "Backend server for ERP proxy and Salla integration",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node --watch index.js"
|
||||
"start": "tsx src/index.ts",
|
||||
"dev": "tsx watch src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"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-light: #fee2e2;
|
||||
--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: 0 4px 12px rgba(0,0,0,0.08);
|
||||
--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;
|
||||
padding: 0;
|
||||
@@ -72,7 +147,7 @@ html[dir="rtl"] {
|
||||
.error-container button {
|
||||
padding: 10px 20px;
|
||||
background: var(--text-primary);
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
@@ -84,6 +159,17 @@ html[dir="rtl"] {
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -118,7 +204,7 @@ html[dir="rtl"] {
|
||||
.empty-state-action {
|
||||
padding: 10px 20px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
@@ -128,29 +214,10 @@ html[dir="rtl"] {
|
||||
}
|
||||
|
||||
.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 */
|
||||
.nav-bar {
|
||||
@@ -181,14 +248,14 @@ html[dir="rtl"] {
|
||||
}
|
||||
|
||||
.nav-brand-icon {
|
||||
color: #3b82f6;
|
||||
color: var(--brand-icon);
|
||||
}
|
||||
|
||||
.nav-brand-text {
|
||||
font-family: 'DM Sans', 'Inter', -apple-system, sans-serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #1e3a5f;
|
||||
color: var(--brand-text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
@@ -197,13 +264,13 @@ html[dir="rtl"] {
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #3b82f6;
|
||||
color: var(--brand-icon);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 2px 20px 2px 6px;
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
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-repeat: no-repeat;
|
||||
@@ -216,18 +283,19 @@ html[dir="rtl"] {
|
||||
}
|
||||
|
||||
.data-source-select:focus {
|
||||
outline: none;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
background-color: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.data-source-select option {
|
||||
color: #1e3a5f;
|
||||
background: white;
|
||||
color: var(--brand-text);
|
||||
background: var(--surface);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.data-source-select option:disabled {
|
||||
color: #94a3b8;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
@@ -267,7 +335,7 @@ html[dir="rtl"] {
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
@@ -315,12 +383,12 @@ html[dir="rtl"] .nav-lang-toggle {
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid #fcd34d;
|
||||
border: 1px solid var(--warning-border);
|
||||
}
|
||||
|
||||
.offline-badge svg {
|
||||
@@ -441,6 +509,12 @@ html[dir="rtl"] .nav-lang-toggle {
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
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 {
|
||||
@@ -476,14 +550,24 @@ html[dir="rtl"] .nav-lang-toggle {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: var(--surface);
|
||||
padding: 24px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
@@ -526,7 +610,7 @@ html[dir="rtl"] .nav-lang-toggle {
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 380px;
|
||||
height: clamp(280px, 30vw, 420px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -552,7 +636,7 @@ table th {
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
table th:first-child { text-align: left; }
|
||||
table th:first-child { text-align: start; }
|
||||
|
||||
table td {
|
||||
padding: 14px 12px;
|
||||
@@ -561,7 +645,7 @@ table td {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
table td:first-child { text-align: left; }
|
||||
table td:first-child { text-align: start; }
|
||||
|
||||
table tbody tr:hover {
|
||||
background: var(--bg);
|
||||
@@ -570,7 +654,7 @@ table tbody tr:hover {
|
||||
.bold { font-weight: 700; color: var(--text-primary); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.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; }
|
||||
.negative { color: var(--danger); font-weight: 600; }
|
||||
|
||||
@@ -673,10 +757,321 @@ table tbody tr:hover {
|
||||
|
||||
.control-group select:focus,
|
||||
.control-group input[type="date"]:focus {
|
||||
outline: none;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
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 {
|
||||
background: var(--bg);
|
||||
padding: 16px;
|
||||
@@ -766,6 +1161,12 @@ table tbody tr:hover {
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
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 {
|
||||
@@ -831,8 +1232,8 @@ table tbody tr:hover {
|
||||
}
|
||||
|
||||
.metric-change.pending {
|
||||
background: var(--muted-light, #f1f5f9);
|
||||
color: var(--text-muted, #64748b);
|
||||
background: var(--muted-light);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.metric-change .pending-msg {
|
||||
@@ -1019,9 +1420,15 @@ table tbody tr:hover {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
padding: 8px;
|
||||
background-clip: content-box;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.carousel-dot .dot-label {
|
||||
@@ -1070,7 +1477,7 @@ table tbody tr:hover {
|
||||
}
|
||||
|
||||
.carousel-dots.labeled .carousel-dot.active .dot-label {
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* Chart Sections */
|
||||
@@ -1507,11 +1914,11 @@ table tbody tr:hover {
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@@ -1555,7 +1962,7 @@ table tbody tr:hover {
|
||||
margin-top: 16px;
|
||||
padding: 8px 16px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
@@ -1641,8 +2048,8 @@ table tbody tr:hover {
|
||||
}
|
||||
|
||||
.slide-actions button.delete:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
background: var(--danger-light);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Slide Editor */
|
||||
@@ -1679,7 +2086,8 @@ table tbody tr:hover {
|
||||
|
||||
.editor-section input:focus,
|
||||
.editor-section select:focus {
|
||||
outline: none;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -1779,7 +2187,7 @@ table tbody tr:hover {
|
||||
left: 0;
|
||||
right: 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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1795,7 +2203,7 @@ table tbody tr:hover {
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
color: #f8fafc;
|
||||
color: var(--bg);
|
||||
font-size: 2.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 40px;
|
||||
@@ -1824,11 +2232,11 @@ table tbody tr:hover {
|
||||
}
|
||||
|
||||
.preview-content .preview-kpis .kpi-label {
|
||||
color: #94a3b8;
|
||||
color: var(--dark-muted);
|
||||
}
|
||||
|
||||
.preview-footer {
|
||||
color: #64748b;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
@@ -1844,7 +2252,7 @@ table tbody tr:hover {
|
||||
.preview-controls button {
|
||||
padding: 10px 24px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
color: var(--text-inverse);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
@@ -1937,12 +2345,12 @@ html[dir="rtl"] .chart-header-with-export {
|
||||
}
|
||||
|
||||
/* Download button - always top corner, outside normal flow */
|
||||
.chart-export-btn,
|
||||
.chart-export-btn.visible {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
right: 0 !important;
|
||||
left: auto !important;
|
||||
.exportable-chart-wrapper .chart-export-btn,
|
||||
.exportable-chart-wrapper .chart-export-btn.visible {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: auto;
|
||||
z-index: 10;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -1959,10 +2367,10 @@ html[dir="rtl"] .chart-header-with-export {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .chart-export-btn,
|
||||
html[dir="rtl"] .chart-export-btn.visible {
|
||||
right: auto !important;
|
||||
left: 0 !important;
|
||||
html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn,
|
||||
html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.chart-export-btn:hover {
|
||||
@@ -1987,3 +2395,89 @@ html[dir="rtl"] .chart-export-btn.visible {
|
||||
direction: ltr !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 Dashboard from './components/Dashboard';
|
||||
import Comparison from './components/Comparison';
|
||||
import Slides from './components/Slides';
|
||||
import { fetchData, getCacheStatus, refreshData } from './services/dataService';
|
||||
|
||||
const Dashboard = lazy(() => import('./components/Dashboard'));
|
||||
const Comparison = lazy(() => import('./components/Comparison'));
|
||||
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 type { MuseumRecord, CacheStatus } from './types';
|
||||
import type { MuseumRecord, Season, CacheStatus, DataErrorType } from './types';
|
||||
import { DataError } from './types';
|
||||
import './App.css';
|
||||
|
||||
interface NavLinkProps {
|
||||
@@ -32,15 +38,47 @@ interface DataSource {
|
||||
|
||||
function App() {
|
||||
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 allMuseumsList = useMemo(() => getUniqueMuseums(data), [data]);
|
||||
const allChannelsList = useMemo(() => getUniqueChannels(data), [data]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
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 [cacheInfo, setCacheInfo] = useState<CacheStatus | null>(null);
|
||||
const [showDataLabels, setShowDataLabels] = useState<boolean>(false);
|
||||
const [includeVAT, setIncludeVAT] = useState<boolean>(true);
|
||||
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[] = [
|
||||
{ id: 'museums', labelKey: 'dataSources.museums', enabled: true },
|
||||
@@ -62,7 +100,8 @@ function App() {
|
||||
const status = getCacheStatus();
|
||||
setCacheInfo(status);
|
||||
} 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);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -70,20 +109,66 @@ function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSeasons = useCallback(async () => {
|
||||
const s = await fetchSeasons();
|
||||
setSeasons(s);
|
||||
}, []);
|
||||
|
||||
// Check auth on mount
|
||||
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
|
||||
}, []);
|
||||
|
||||
|
||||
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 = () => {
|
||||
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) {
|
||||
return (
|
||||
<div className="loading-container" dir={dir}>
|
||||
<div className="loading-spinner"></div>
|
||||
<p>{t('app.loading')}</p>
|
||||
<div className="app" dir={dir}>
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -92,8 +177,10 @@ function App() {
|
||||
return (
|
||||
<div className="error-container" dir={dir}>
|
||||
<h2>{t('app.error')}</h2>
|
||||
<p style={{maxWidth: '400px', textAlign: 'center', color: '#64748b'}}>{error}</p>
|
||||
<button onClick={() => window.location.reload()}>{t('app.retry')}</button>
|
||||
<p className="error-message">
|
||||
{t(`errors.${error.type}`)}
|
||||
</p>
|
||||
<button onClick={() => loadData()}>{t('app.retry')}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -101,10 +188,10 @@ function App() {
|
||||
return (
|
||||
<Router>
|
||||
<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-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="14" y="3" width="7" height="4" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
@@ -112,10 +199,11 @@ function App() {
|
||||
</svg>
|
||||
<span className="nav-brand-text">
|
||||
HiHala Data
|
||||
<select
|
||||
<select
|
||||
className="data-source-select"
|
||||
value={dataSource}
|
||||
onChange={e => setDataSource(e.target.value)}
|
||||
aria-label={t('dataSources.museums')}
|
||||
>
|
||||
{dataSources.map(src => (
|
||||
<option key={src.id} value={src.id} disabled={!src.enabled}>
|
||||
@@ -127,7 +215,7 @@ function App() {
|
||||
</div>
|
||||
<div className="nav-links">
|
||||
<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="14" y="3" width="7" height="5" rx="1"/>
|
||||
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
||||
@@ -136,7 +224,7 @@ function App() {
|
||||
{t('nav.dashboard')}
|
||||
</NavLink>
|
||||
<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="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
@@ -159,24 +247,42 @@ function App() {
|
||||
{t('app.offline') || 'Offline'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
<button
|
||||
className={`nav-refresh-btn ${refreshing ? 'refreshing' : ''}`}
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
aria-label={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="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"/>
|
||||
</svg>
|
||||
</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"
|
||||
onClick={switchLanguage}
|
||||
aria-label={t('language.switch')}
|
||||
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"/>
|
||||
<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"/>
|
||||
@@ -187,16 +293,20 @@ function App() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
|
||||
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
|
||||
<Route path="/slides" element={<Slides data={data} />} />
|
||||
</Routes>
|
||||
<main>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard data={data} seasons={seasons} userRole={userRole} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||
<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 */}
|
||||
<nav className="mobile-nav">
|
||||
<nav className="mobile-nav" aria-label="Mobile navigation">
|
||||
<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="14" y="3" width="7" height="5" rx="1"/>
|
||||
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
||||
@@ -205,18 +315,27 @@ function App() {
|
||||
<span>{t('nav.dashboard')}</span>
|
||||
</NavLink>
|
||||
<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="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
<span>{t('nav.compare')}</span>
|
||||
</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"
|
||||
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"/>
|
||||
<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"/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Line, Bar } from 'react-chartjs-2';
|
||||
import { EmptyState, FilterControls } from './shared';
|
||||
import { EmptyState, FilterControls, MultiSelect } from './shared';
|
||||
import { ExportableChart } from './ChartExport';
|
||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -11,14 +11,37 @@ import {
|
||||
formatCompact,
|
||||
formatCompactCurrency,
|
||||
umrahData,
|
||||
getUniqueChannels,
|
||||
getUniqueMuseums,
|
||||
getUniqueDistricts,
|
||||
getDistrictMuseumMap,
|
||||
getMuseumsForDistrict,
|
||||
getLatestYear
|
||||
} 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
|
||||
const generatePresetDates = (year) => ({
|
||||
const generatePresetDates = (year: number): PresetDates => ({
|
||||
'jan': { start: `${year}-01-01`, end: `${year}-01-31` },
|
||||
'feb': { start: `${year}-02-01`, end: `${year}-02-28` },
|
||||
'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` }
|
||||
});
|
||||
|
||||
function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }) {
|
||||
function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: ComparisonProps) {
|
||||
const { t } = useLanguage();
|
||||
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
|
||||
const latestYear = useMemo(() => getLatestYear(data), [data]);
|
||||
const latestYear = useMemo(() => parseInt(getLatestYear(permissionFilteredData)), [permissionFilteredData]);
|
||||
const availableYears = useMemo((): number[] => {
|
||||
const yearsSet = new Set<number>();
|
||||
data.forEach(r => {
|
||||
permissionFilteredData.forEach((r: MuseumRecord) => {
|
||||
const d = r.date || (r as any).Date;
|
||||
if (d) yearsSet.add(new Date(d).getFullYear());
|
||||
});
|
||||
@@ -57,7 +89,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
}, [data]);
|
||||
|
||||
// Initialize state from URL or defaults
|
||||
const [selectedYear, setSelectedYearState] = useState(() => {
|
||||
const [selectedYear, setSelectedYearState] = useState<number>(() => {
|
||||
const urlYear = searchParams.get('year');
|
||||
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 [startDate, setStartDateState] = useState(() => {
|
||||
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);
|
||||
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
||||
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 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);
|
||||
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
||||
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(() => ({
|
||||
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');
|
||||
@@ -93,51 +134,60 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
const [activeCard, setActiveCard] = useState(0);
|
||||
|
||||
// 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();
|
||||
if (newPreset && newPreset !== 'jan') params.set('preset', newPreset);
|
||||
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 (newTo) params.set('to', newTo);
|
||||
}
|
||||
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, latestYear]);
|
||||
|
||||
const setSelectedYear = (year) => {
|
||||
const setSelectedYear = (year: number) => {
|
||||
setSelectedYearState(year);
|
||||
const newDates = generatePresetDates(year);
|
||||
if (preset !== 'custom' && newDates[preset]) {
|
||||
if (preset !== 'custom' && !preset.startsWith('season-') && newDates[preset]) {
|
||||
setStartDateState(newDates[preset].start);
|
||||
setEndDateState(newDates[preset].end);
|
||||
}
|
||||
updateUrl(preset, null, null, filters, year);
|
||||
};
|
||||
|
||||
const setPreset = (value) => {
|
||||
const setPreset = (value: string) => {
|
||||
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);
|
||||
setEndDateState(presetDates[value].end);
|
||||
updateUrl(value, null, null, filters, selectedYear);
|
||||
}
|
||||
};
|
||||
|
||||
const setStartDate = (value) => {
|
||||
const setStartDate = (value: string) => {
|
||||
setStartDateState(value);
|
||||
setPresetState('custom');
|
||||
updateUrl('custom', value, endDate, filters, selectedYear);
|
||||
};
|
||||
|
||||
const setEndDate = (value) => {
|
||||
const setEndDate = (value: string) => {
|
||||
setEndDateState(value);
|
||||
setPresetState('custom');
|
||||
updateUrl('custom', startDate, value, filters, selectedYear);
|
||||
};
|
||||
|
||||
const setFilters = (newFilters) => {
|
||||
const setFilters = (newFilters: DateRangeFilters | ((prev: DateRangeFilters) => DateRangeFilters)) => {
|
||||
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
|
||||
setFiltersState(updated);
|
||||
updateUrl(preset, startDate, endDate, updated, selectedYear);
|
||||
@@ -149,13 +199,13 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
];
|
||||
|
||||
// Touch swipe handlers
|
||||
const touchStartChart = useRef(null);
|
||||
const touchStartCard = useRef(null);
|
||||
const touchStartChart = useRef<number | null>(null);
|
||||
const touchStartCard = useRef<number | null>(null);
|
||||
|
||||
const handleChartTouchStart = (e) => {
|
||||
const handleChartTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartChart.current = e.touches[0].clientX;
|
||||
};
|
||||
const handleChartTouchEnd = (e) => {
|
||||
const handleChartTouchEnd = (e: React.TouchEvent) => {
|
||||
if (!touchStartChart.current) return;
|
||||
const diff = touchStartChart.current - e.changedTouches[0].clientX;
|
||||
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' }
|
||||
];
|
||||
|
||||
const getMetricValue = useCallback((rows, metric) => {
|
||||
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
|
||||
if (metric === 'avgRevenue') {
|
||||
const revenue = rows.reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0);
|
||||
const visitors = rows.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
||||
const revenue = rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[revenueField] || 0)), 0);
|
||||
const visitors = rows.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
|
||||
return visitors > 0 ? revenue / visitors : 0;
|
||||
}
|
||||
const fieldMap = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' };
|
||||
const fieldMap: Record<string, string> = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' };
|
||||
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]);
|
||||
|
||||
// Dynamic lists from data
|
||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
|
||||
const channels = useMemo(() => getUniqueChannels(permissionFilteredData), [permissionFilteredData]);
|
||||
const districts = useMemo(() => getUniqueDistricts(permissionFilteredData), [permissionFilteredData]);
|
||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]);
|
||||
|
||||
// Year-over-year comparison: same dates, previous year
|
||||
const ranges = useMemo(() => ({
|
||||
curr: { start: startDate, end: endDate },
|
||||
prev: {
|
||||
start: startDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1),
|
||||
end: endDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1)
|
||||
}
|
||||
}), [startDate, endDate]);
|
||||
// For season presets, try to find the same season name from the previous hijri year
|
||||
const ranges = useMemo(() => {
|
||||
const curr = { start: startDate, end: endDate };
|
||||
let prev = {
|
||||
start: startDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1)),
|
||||
end: endDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1))
|
||||
};
|
||||
|
||||
const prevData = useMemo(() =>
|
||||
filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters),
|
||||
[data, ranges.prev, filters]
|
||||
if (preset.startsWith('season-')) {
|
||||
const seasonId = parseInt(preset.replace('season-', ''));
|
||||
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(() =>
|
||||
filterDataByDateRange(data, ranges.curr.start, ranges.curr.end, filters),
|
||||
[data, ranges.curr, filters]
|
||||
|
||||
const currData = useMemo(() =>
|
||||
filterDataByDateRange(permissionFilteredData, ranges.curr.start, ranges.curr.end, filters),
|
||||
[permissionFilteredData, ranges.curr, filters]
|
||||
);
|
||||
|
||||
const prevMetrics = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]);
|
||||
const currMetrics = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]);
|
||||
|
||||
const hasData = prevData.length > 0 || currData.length > 0;
|
||||
const resetFilters = () => setFilters({ district: 'all', museum: 'all' });
|
||||
const resetFilters = () => setFilters({ district: 'all', channel: [], 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)
|
||||
const getQuarterFromRange = (start, end) => {
|
||||
const quarterRanges = {
|
||||
const getQuarterFromRange = (start: string, end: string) => {
|
||||
const quarterRanges: Record<number, { start: string; end: string }> = {
|
||||
1: { start: '-01-01', end: '-03-31' },
|
||||
2: { start: '-04-01', end: '-06-30' },
|
||||
3: { start: '-07-01', end: '-09-30' },
|
||||
@@ -331,10 +397,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
return cards;
|
||||
}, [prevMetrics, currMetrics, pilgrimCounts, captureRates, t]);
|
||||
|
||||
const handleCardTouchStart = (e) => {
|
||||
const handleCardTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartCard.current = e.touches[0].clientX;
|
||||
};
|
||||
const handleCardTouchEnd = (e) => {
|
||||
const handleCardTouchEnd = (e: React.TouchEvent) => {
|
||||
if (!touchStartCard.current) return;
|
||||
const diff = touchStartCard.current - e.changedTouches[0].clientX;
|
||||
if (Math.abs(diff) > 50) {
|
||||
@@ -347,7 +413,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
touchStartCard.current = null;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
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
|
||||
const getPeriodLabel = useCallback((startDate, endDate) => {
|
||||
const getPeriodLabel = useCallback((startDate: string, endDate: string) => {
|
||||
if (!startDate || !endDate) return '';
|
||||
const startYear = startDate.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)
|
||||
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 groupedRows = {};
|
||||
|
||||
periodData.forEach(row => {
|
||||
const groupedRows: Record<number, MuseumRecord[]> = {};
|
||||
|
||||
periodData.forEach((row: MuseumRecord) => {
|
||||
if (!row.date) return;
|
||||
const rowDate = new Date(row.date);
|
||||
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);
|
||||
});
|
||||
|
||||
const result = {};
|
||||
const result: Record<number, number> = {};
|
||||
Object.keys(groupedRows).forEach(key => {
|
||||
result[key] = getMetricValue(groupedRows[key], metric);
|
||||
result[Number(key)] = getMetricValue(groupedRows[Number(key)], metric);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
@@ -454,7 +520,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
const museumChart = useMemo(() => {
|
||||
const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.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 currByMuseum: Record<string, number> = {};
|
||||
allMuseums.forEach(m => {
|
||||
@@ -475,11 +541,53 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
}, [data, prevData, currData, ranges, chartMetric, getMetricValue, getPeriodLabel]);
|
||||
|
||||
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 = {
|
||||
...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="h2">{t('time.h2')}</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>
|
||||
</FilterControls.Group>
|
||||
{preset !== 'custom' && (
|
||||
{preset !== 'custom' && !preset.startsWith('season-') && (
|
||||
<FilterControls.Group label={t('filters.year')}>
|
||||
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
|
||||
{availableYears.map(y => (
|
||||
@@ -543,7 +660,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
)}
|
||||
{preset === 'custom' && (
|
||||
{(preset === 'custom' || preset.startsWith('season-')) && (
|
||||
<>
|
||||
<FilterControls.Group label={t('comparison.from')}>
|
||||
<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')}>
|
||||
<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>
|
||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</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')}>
|
||||
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
|
||||
<option value="all">{t('filters.allMuseums')}</option>
|
||||
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
<MultiSelect
|
||||
options={availableMuseums}
|
||||
selected={filters.museum}
|
||||
onChange={selected => setFilters({...filters, museum: selected})}
|
||||
allLabel={t('filters.allMuseums')}
|
||||
/>
|
||||
</FilterControls.Group>
|
||||
</FilterControls.Row>
|
||||
</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 isPositive = change >= 0;
|
||||
const isPositive = (change ?? 0) >= 0;
|
||||
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 (isPercent) return val.toFixed(2) + '%';
|
||||
if (isCurrency) return formatCompactCurrency(val);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Line, Doughnut, Bar } from 'react-chartjs-2';
|
||||
import { Carousel, EmptyState, FilterControls, StatCard } from './shared';
|
||||
import { useSearchParams, Link } from 'react-router-dom';
|
||||
import { Line, Bar, Pie } from 'react-chartjs-2';
|
||||
import { Carousel, EmptyState, FilterControls, MultiSelect, StatCard } from './shared';
|
||||
import { ExportableChart } from './ChartExport';
|
||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import {
|
||||
filterData,
|
||||
@@ -12,25 +12,29 @@ import {
|
||||
formatNumber,
|
||||
groupByWeek,
|
||||
groupByMuseum,
|
||||
groupByDistrict,
|
||||
groupByChannel,
|
||||
umrahData,
|
||||
fetchPilgrimStats,
|
||||
getUniqueYears,
|
||||
getUniqueChannels,
|
||||
getUniqueMuseums,
|
||||
getUniqueDistricts,
|
||||
getDistrictMuseumMap,
|
||||
getMuseumsForDistrict
|
||||
getMuseumsForDistrict,
|
||||
groupByDistrict
|
||||
} from '../services/dataService';
|
||||
import type { DashboardProps, Filters, MuseumRecord, Season } from '../types';
|
||||
|
||||
const defaultFilters = {
|
||||
const defaultFilters: Filters = {
|
||||
year: 'all',
|
||||
district: 'all',
|
||||
museum: 'all',
|
||||
channel: [],
|
||||
museum: [],
|
||||
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 [searchParams, setSearchParams] = useSearchParams();
|
||||
const [pilgrimLoaded, setPilgrimLoaded] = useState(false);
|
||||
@@ -41,38 +45,74 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
}, []);
|
||||
|
||||
// Initialize filters from URL or defaults
|
||||
const [filters, setFiltersState] = useState(() => {
|
||||
const initial = { ...defaultFilters };
|
||||
const [filters, setFiltersState] = useState<Filters>(() => {
|
||||
const initial: Filters = { ...defaultFilters };
|
||||
filterKeys.forEach(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;
|
||||
});
|
||||
|
||||
// Update both state and URL
|
||||
const setFilters = (newFilters) => {
|
||||
const setFilters = (newFilters: Filters | ((prev: Filters) => Filters)) => {
|
||||
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
|
||||
setFiltersState(updated);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
filterKeys.forEach(key => {
|
||||
if (updated[key] && updated[key] !== 'all') {
|
||||
params.set(key, updated[key]);
|
||||
const val = (updated as Record<string, unknown>)[key] as string;
|
||||
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 });
|
||||
};
|
||||
|
||||
const [activeStatCard, setActiveStatCard] = useState(0);
|
||||
const [activeChart, setActiveChart] = useState(0);
|
||||
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]);
|
||||
const metrics = useMemo(() => calculateMetrics(filteredData, includeVAT), [filteredData, includeVAT]);
|
||||
const hasData = filteredData.length > 0;
|
||||
// Permission base filter — applied before any user-facing filter
|
||||
// null = corrupted value → fail-closed (show nothing)
|
||||
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
|
||||
const statCards = useMemo(() => [
|
||||
@@ -84,29 +124,28 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
|
||||
// Chart carousel labels
|
||||
const chartLabels = useMemo(() => {
|
||||
const labels = [t('charts.revenueTrend'), t('charts.visitors'), t('charts.revenue'), t('charts.quarterly'), t('charts.district'), t('charts.captureRate')];
|
||||
return filters.museum === 'all' ? labels : labels.filter((_, i) => i !== 1 && i !== 2);
|
||||
}, [filters.museum, t]);
|
||||
return [t('charts.revenueTrend'), t('charts.visitors'), t('charts.revenue'), t('charts.quarterly'), t('charts.channel'), t('charts.district'), t('charts.captureRate')];
|
||||
}, [t]);
|
||||
|
||||
// Dynamic lists from data
|
||||
const years = useMemo(() => getUniqueYears(data), [data]);
|
||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
|
||||
const years = useMemo(() => getUniqueYears(permissionFilteredData), [permissionFilteredData]);
|
||||
const districts = useMemo(() => getUniqueDistricts(permissionFilteredData), [permissionFilteredData]);
|
||||
const channels = useMemo(() => getUniqueChannels(permissionFilteredData), [permissionFilteredData]);
|
||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]);
|
||||
|
||||
const yoyChange = useMemo(() => {
|
||||
if (filters.year === 'all') return null;
|
||||
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;
|
||||
const prevMetrics = calculateMetrics(prevData, includeVAT);
|
||||
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)
|
||||
const trendData = useMemo(() => {
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
const formatLabel = (dateStr) => {
|
||||
const formatLabel = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
const d = new Date(year, month - 1, day);
|
||||
@@ -143,11 +182,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
});
|
||||
|
||||
if (trendGranularity === 'week') {
|
||||
const grouped = groupByWeek(filteredData, includeVAT);
|
||||
const grouped = groupByWeek(seasonFilteredData, includeVAT);
|
||||
const weeks = Object.keys(grouped).filter(w => w).sort();
|
||||
const revenueValues = weeks.map(w => grouped[w].revenue);
|
||||
return {
|
||||
labels: weeks.map(formatLabel),
|
||||
rawDates: weeks,
|
||||
datasets: [{
|
||||
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
||||
data: revenueValues,
|
||||
@@ -163,15 +203,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
} else {
|
||||
// Daily granularity
|
||||
const dailyData: Record<string, number> = {};
|
||||
filteredData.forEach(row => {
|
||||
seasonFilteredData.forEach(row => {
|
||||
const date = row.date;
|
||||
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 revenueValues = days.map(d => dailyData[d]);
|
||||
return {
|
||||
labels: days.map(formatLabel),
|
||||
rawDates: days,
|
||||
datasets: [{
|
||||
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
||||
data: revenueValues,
|
||||
@@ -185,64 +226,110 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
}, trendlineDataset(revenueValues)]
|
||||
};
|
||||
}
|
||||
}, [filteredData, trendGranularity, includeVAT]);
|
||||
}, [seasonFilteredData, trendGranularity, includeVAT]);
|
||||
|
||||
// Museum data
|
||||
const museumData = useMemo(() => {
|
||||
const grouped = groupByMuseum(filteredData, includeVAT);
|
||||
const grouped = groupByMuseum(seasonFilteredData, includeVAT);
|
||||
const museums = Object.keys(grouped);
|
||||
return {
|
||||
visitors: {
|
||||
labels: museums,
|
||||
datasets: [{
|
||||
data: museums.map(m => grouped[m].visitors),
|
||||
backgroundColor: [chartColors.primary + 'cc', chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
||||
borderWidth: 0
|
||||
backgroundColor: museums.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'),
|
||||
borderWidth: 0,
|
||||
borderRadius: 4
|
||||
}]
|
||||
},
|
||||
revenue: {
|
||||
labels: museums,
|
||||
datasets: [{
|
||||
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
|
||||
}]
|
||||
}
|
||||
};
|
||||
}, [filteredData, includeVAT]);
|
||||
}, [seasonFilteredData, includeVAT]);
|
||||
|
||||
// District data
|
||||
const districtData = useMemo(() => {
|
||||
const grouped = groupByDistrict(filteredData, includeVAT);
|
||||
const districts = Object.keys(grouped);
|
||||
// Channel data
|
||||
const channelData = useMemo(() => {
|
||||
const grouped = groupByChannel(seasonFilteredData, includeVAT);
|
||||
const channels = Object.keys(grouped);
|
||||
return {
|
||||
labels: districts,
|
||||
labels: channels,
|
||||
datasets: [{
|
||||
data: districts.map(d => grouped[d].revenue),
|
||||
backgroundColor: [chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
||||
data: channels.map(d => grouped[d].revenue),
|
||||
backgroundColor: channels.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'),
|
||||
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
|
||||
const quarterlyYoYData = useMemo(() => {
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
const d2024 = data.filter(row => row.year === '2024');
|
||||
const d2025 = data.filter(row => row.year === '2025');
|
||||
const d2024 = data.filter((row: MuseumRecord) => row.year === '2024');
|
||||
const d2025 = data.filter((row: MuseumRecord) => row.year === '2025');
|
||||
const quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
return {
|
||||
labels: quarters,
|
||||
datasets: [
|
||||
{
|
||||
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,
|
||||
borderRadius: 4
|
||||
},
|
||||
{
|
||||
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,
|
||||
borderRadius: 4
|
||||
}
|
||||
@@ -252,17 +339,18 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
|
||||
// Capture rate
|
||||
const captureRateData = useMemo(() => {
|
||||
const labels = [];
|
||||
const rates = [];
|
||||
const pilgrimCounts = [];
|
||||
const labels: string[] = [];
|
||||
const rates: number[] = [];
|
||||
const pilgrimCounts: number[] = [];
|
||||
[2024, 2025].forEach(year => {
|
||||
[1, 2, 3, 4].forEach(q => {
|
||||
const pilgrims = umrahData[year]?.[q];
|
||||
if (!pilgrims) return;
|
||||
let qData = data.filter(r => r.year === String(year) && r.quarter === String(q));
|
||||
if (filters.district !== 'all') qData = qData.filter(r => r.district === filters.district);
|
||||
if (filters.museum !== 'all') qData = qData.filter(r => r.museum_name === filters.museum);
|
||||
const visitors = qData.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
||||
let qData = data.filter((r: MuseumRecord) => r.year === String(year) && r.quarter === String(q));
|
||||
if (filters.district !== 'all') qData = qData.filter((r: MuseumRecord) => r.district === filters.district);
|
||||
if (filters.channel.length > 0) qData = qData.filter((r: MuseumRecord) => filters.channel.includes(r.channel));
|
||||
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}`);
|
||||
rates.push((visitors / pilgrims * 100));
|
||||
pilgrimCounts.push(pilgrims);
|
||||
@@ -286,7 +374,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
yAxisID: 'y',
|
||||
datalabels: {
|
||||
display: showDataLabels,
|
||||
formatter: (value) => value.toFixed(2) + '%',
|
||||
formatter: (value: number) => value.toFixed(2) + '%',
|
||||
color: '#1e293b',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 3,
|
||||
@@ -312,7 +400,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
order: 1,
|
||||
datalabels: {
|
||||
display: showDataLabels,
|
||||
formatter: (value) => (value / 1000000).toFixed(2) + 'M',
|
||||
formatter: (value: number) => (value / 1000000).toFixed(2) + 'M',
|
||||
color: '#1e293b',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
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
|
||||
const quarterlyTable = useMemo(() => {
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
const d2024 = data.filter(row => row.year === '2024');
|
||||
const d2025 = data.filter(row => row.year === '2025');
|
||||
const d2024 = data.filter((row: MuseumRecord) => row.year === '2024');
|
||||
const d2025 = data.filter((row: MuseumRecord) => row.year === '2025');
|
||||
return [1, 2, 3, 4].map(q => {
|
||||
let q2024 = d2024.filter(r => r.quarter === String(q));
|
||||
let q2025 = d2025.filter(r => r.quarter === String(q));
|
||||
let q2024 = d2024.filter((r: MuseumRecord) => r.quarter === String(q));
|
||||
let q2025 = d2025.filter((r: MuseumRecord) => r.quarter === String(q));
|
||||
if (filters.district !== 'all') {
|
||||
q2024 = q2024.filter(r => r.district === filters.district);
|
||||
q2025 = q2025.filter(r => r.district === filters.district);
|
||||
q2024 = q2024.filter((r: MuseumRecord) => r.district === filters.district);
|
||||
q2025 = q2025.filter((r: MuseumRecord) => r.district === filters.district);
|
||||
}
|
||||
if (filters.museum !== 'all') {
|
||||
q2024 = q2024.filter(r => r.museum_name === filters.museum);
|
||||
q2025 = q2025.filter(r => r.museum_name === filters.museum);
|
||||
if (filters.channel.length > 0) {
|
||||
q2024 = q2024.filter((r: MuseumRecord) => filters.channel.includes(r.channel));
|
||||
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);
|
||||
const rev25 = q2025.reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0);
|
||||
const vis24 = q2024.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
||||
const vis25 = q2025.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
||||
if (filters.museum.length > 0) {
|
||||
q2024 = q2024.filter((r: MuseumRecord) => filters.museum.includes(r.museum_name));
|
||||
q2025 = q2025.filter((r: MuseumRecord) => filters.museum.includes(r.museum_name));
|
||||
}
|
||||
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 visChg = vis24 > 0 ? ((vis25 - vis24) / vis24 * 100) : 0;
|
||||
const cap24 = umrahData[2024][q] ? (vis24 / umrahData[2024][q] * 100) : null;
|
||||
const cap25 = umrahData[2025][q] ? (vis25 / umrahData[2025][q] * 100) : null;
|
||||
return { q, rev24, rev25, revChg, vis24, vis25, visChg, cap24, cap25 };
|
||||
});
|
||||
}, [data, filters.district, filters.museum, includeVAT]);
|
||||
}, [data, filters.district, filters.channel, filters.museum, includeVAT]);
|
||||
|
||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||
|
||||
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 (
|
||||
<div className="dashboard" id="dashboard-container">
|
||||
<div className="page-title-with-actions">
|
||||
@@ -390,16 +521,26 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
<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>
|
||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</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')}>
|
||||
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
|
||||
<option value="all">{t('filters.allMuseums')}</option>
|
||||
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
<MultiSelect
|
||||
options={availableMuseums}
|
||||
selected={filters.museum}
|
||||
onChange={museum => setFilters({...filters, museum})}
|
||||
allLabel={t('filters.allMuseums')}
|
||||
/>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label={t('filters.quarter')}>
|
||||
<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>
|
||||
</select>
|
||||
</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>
|
||||
|
||||
@@ -503,25 +654,47 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{filters.museum === 'all' && (
|
||||
<div className="chart-card half-width">
|
||||
<ExportableChart filename="visitors-by-museum" title={t('dashboard.visitorsByMuseum')} className="chart-container">
|
||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 16, font: {size: 13}}}}}} />
|
||||
</ExportableChart>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filters.museum === 'all' && (
|
||||
<div className="chart-card half-width">
|
||||
<ExportableChart filename="revenue-by-museum" title={t('dashboard.revenueByMuseum')} className="chart-container">
|
||||
<Bar data={museumData.revenue} options={baseOptions} />
|
||||
</ExportableChart>
|
||||
</div>
|
||||
)}
|
||||
<div className="chart-card half-width">
|
||||
<ExportableChart
|
||||
filename={eventMetric === 'visitors' ? 'visitors-by-event' : 'revenue-by-event'}
|
||||
title={eventMetric === 'visitors' ? t('dashboard.visitorsByMuseum') : t('dashboard.revenueByMuseum')}
|
||||
className="chart-container"
|
||||
controls={
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<div className="toggle-switch">
|
||||
<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>
|
||||
}
|
||||
>
|
||||
{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">
|
||||
<ExportableChart filename="quarterly-yoy" title={t('dashboard.quarterlyRevenue')} className="chart-container">
|
||||
@@ -530,8 +703,75 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
</div>
|
||||
|
||||
<div className="chart-card half-width">
|
||||
<ExportableChart filename="district-performance" title={t('dashboard.districtPerformance')} className="chart-container">
|
||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||
<ExportableChart
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -545,7 +785,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
tooltip: {
|
||||
...baseOptions.plugins.tooltip,
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
label: (ctx: { dataset: { label?: string }; parsed: { y: number } }) => {
|
||||
if (ctx.dataset.label === 'Capture Rate (%)') {
|
||||
return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`;
|
||||
}
|
||||
@@ -560,7 +800,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
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 },
|
||||
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',
|
||||
position: 'right',
|
||||
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 },
|
||||
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>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{filters.museum === 'all' && (
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>{t('dashboard.visitorsByMuseum')}</h2>
|
||||
<div className="chart-container">
|
||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 12, font: {size: 12}}}}}} />
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>{eventMetric === 'visitors' ? t('dashboard.visitorsByMuseum') : t('dashboard.revenueByMuseum')}</h2>
|
||||
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
|
||||
<div className="toggle-switch">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{filters.museum === 'all' && (
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>{t('dashboard.revenueByMuseum')}</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={museumData.revenue} options={baseOptions} />
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
{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)}` } }
|
||||
}
|
||||
}} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
@@ -632,9 +885,62 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
|
||||
<div className="carousel-slide">
|
||||
<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">
|
||||
<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>
|
||||
@@ -651,7 +957,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
tooltip: {
|
||||
...baseOptions.plugins.tooltip,
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
label: (ctx: { dataset: { label?: string }; parsed: { y: number } }) => {
|
||||
if (ctx.dataset.label === 'Capture Rate (%)') {
|
||||
return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`;
|
||||
}
|
||||
@@ -666,14 +972,14 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
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 }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -699,6 +1005,15 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
formatCompact,
|
||||
formatCompactCurrency,
|
||||
getUniqueDistricts,
|
||||
getDistrictMuseumMap,
|
||||
getMuseumsForDistrict
|
||||
getUniqueChannels,
|
||||
getUniqueMuseums
|
||||
} from '../services/dataService';
|
||||
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 CHART_TYPES = useMemo(() => [
|
||||
|
||||
const CHART_TYPES: ChartTypeOption[] = useMemo(() => [
|
||||
{ id: 'trend', label: t('slides.revenueTrend'), icon: '📈' },
|
||||
{ id: 'museum-bar', label: t('slides.byMuseum'), icon: '📊' },
|
||||
{ id: 'kpi-cards', label: t('slides.kpiSummary'), icon: '🎯' },
|
||||
{ id: 'comparison', label: t('slides.yoyComparison'), icon: '⚖️' }
|
||||
], [t]);
|
||||
|
||||
const METRICS = useMemo(() => [
|
||||
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax' },
|
||||
const METRICS: MetricOption[] = useMemo(() => [
|
||||
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_gross' },
|
||||
{ id: 'visitors', label: t('metrics.visitors'), field: 'visits' },
|
||||
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
|
||||
], [t]);
|
||||
const [slides, setSlides] = useState([]);
|
||||
const [editingSlide, setEditingSlide] = useState(null);
|
||||
const [slides, setSlides] = useState<SlideConfig[]>([]);
|
||||
const [editingSlide, setEditingSlide] = useState<number | null>(null);
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0);
|
||||
|
||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
||||
const channels = useMemo(() => getUniqueChannels(data), [data]);
|
||||
const museums = useMemo(() => getUniqueMuseums(data), [data]);
|
||||
|
||||
const defaultSlideConfig = {
|
||||
const defaultSlideConfig: Omit<SlideConfig, 'id'> = {
|
||||
title: 'Slide Title',
|
||||
chartType: 'trend',
|
||||
metric: 'revenue',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-31',
|
||||
district: 'all',
|
||||
channel: 'all',
|
||||
museum: 'all',
|
||||
showComparison: false
|
||||
};
|
||||
|
||||
const addSlide = () => {
|
||||
const newSlide = {
|
||||
const newSlide: SlideConfig = {
|
||||
id: Date.now(),
|
||||
...defaultSlideConfig,
|
||||
title: `Slide ${slides.length + 1}`
|
||||
@@ -57,16 +93,16 @@ function Slides({ data }) {
|
||||
setEditingSlide(newSlide.id);
|
||||
};
|
||||
|
||||
const updateSlide = (id, updates) => {
|
||||
const updateSlide = (id: number, updates: Partial<SlideConfig>) => {
|
||||
setSlides(slides.map(s => s.id === id ? { ...s, ...updates } : s));
|
||||
};
|
||||
|
||||
const removeSlide = (id) => {
|
||||
const removeSlide = (id: number) => {
|
||||
setSlides(slides.filter(s => s.id !== id));
|
||||
if (editingSlide === id) setEditingSlide(null);
|
||||
};
|
||||
|
||||
const moveSlide = (id, direction) => {
|
||||
const moveSlide = (id: number, direction: number) => {
|
||||
const index = slides.findIndex(s => s.id === id);
|
||||
if ((direction === -1 && index === 0) || (direction === 1 && index === slides.length - 1)) return;
|
||||
const newSlides = [...slides];
|
||||
@@ -74,10 +110,10 @@ function Slides({ data }) {
|
||||
setSlides(newSlides);
|
||||
};
|
||||
|
||||
const duplicateSlide = (id) => {
|
||||
const duplicateSlide = (id: number) => {
|
||||
const slide = slides.find(s => s.id === id);
|
||||
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 newSlides = [...slides];
|
||||
newSlides.splice(index + 1, 0, newSlide);
|
||||
@@ -87,10 +123,10 @@ function Slides({ data }) {
|
||||
|
||||
const exportAsHTML = async () => {
|
||||
const zip = new JSZip();
|
||||
|
||||
|
||||
// Generate HTML for each slide
|
||||
const slidesHTML = slides.map((slide, index) => {
|
||||
return generateSlideHTML(slide, index, data, districts, districtMuseumMap);
|
||||
return generateSlideHTML(slide, index, data);
|
||||
}).join('\n');
|
||||
|
||||
const fullHTML = `<!DOCTYPE html>
|
||||
@@ -103,21 +139,21 @@ function Slides({ data }) {
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f172a; }
|
||||
.slide {
|
||||
width: 100vw; height: 100vh;
|
||||
.slide {
|
||||
width: 100vw; height: 100vh;
|
||||
display: flex; flex-direction: column;
|
||||
justify-content: center; align-items: center;
|
||||
padding: 60px; background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
page-break-after: always;
|
||||
}
|
||||
.slide-title {
|
||||
color: #f8fafc; font-size: 2.5rem; font-weight: 600;
|
||||
.slide-title {
|
||||
color: #f8fafc; font-size: 2.5rem; font-weight: 600;
|
||||
margin-bottom: 40px; text-align: center;
|
||||
}
|
||||
.slide-subtitle {
|
||||
color: #94a3b8; font-size: 1.1rem; margin-bottom: 30px;
|
||||
}
|
||||
.chart-container {
|
||||
.chart-container {
|
||||
width: 100%; max-width: 900px; height: 400px;
|
||||
background: rgba(255,255,255,0.03); border-radius: 16px;
|
||||
padding: 30px;
|
||||
@@ -134,8 +170,8 @@ function Slides({ data }) {
|
||||
.kpi-label { color: #94a3b8; font-size: 1rem; margin-top: 8px; }
|
||||
.logo { position: absolute; bottom: 30px; right: 40px; opacity: 0.6; }
|
||||
.logo svg { height: 30px; }
|
||||
.slide-number {
|
||||
position: absolute; bottom: 30px; left: 40px;
|
||||
.slide-number {
|
||||
position: absolute; bottom: 30px; left: 40px;
|
||||
color: #475569; font-size: 0.9rem;
|
||||
}
|
||||
@media print {
|
||||
@@ -147,13 +183,13 @@ function Slides({ data }) {
|
||||
${slidesHTML}
|
||||
<script>
|
||||
// Chart.js initialization scripts will be here
|
||||
${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
${generateChartScripts(slides, data)}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
zip.file('presentation.html', fullHTML);
|
||||
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -165,11 +201,11 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
|
||||
if (previewMode) {
|
||||
return (
|
||||
<PreviewMode
|
||||
<PreviewMode
|
||||
slides={slides}
|
||||
data={data}
|
||||
districts={districts}
|
||||
districtMuseumMap={districtMuseumMap}
|
||||
channels={channels}
|
||||
museums={museums}
|
||||
currentSlide={currentPreviewSlide}
|
||||
setCurrentSlide={setCurrentPreviewSlide}
|
||||
onExit={() => setPreviewMode(false)}
|
||||
@@ -221,8 +257,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
) : (
|
||||
<div className="slides-thumbnails">
|
||||
{slides.map((slide, index) => (
|
||||
<div
|
||||
key={slide.id}
|
||||
<div
|
||||
key={slide.id}
|
||||
className={`slide-thumbnail ${editingSlide === slide.id ? 'active' : ''}`}
|
||||
onClick={() => setEditingSlide(slide.id)}
|
||||
>
|
||||
@@ -243,10 +279,10 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
|
||||
{editingSlide && (
|
||||
<SlideEditor
|
||||
slide={slides.find(s => s.id === editingSlide)}
|
||||
slide={slides.find(s => s.id === editingSlide)!}
|
||||
onUpdate={(updates) => updateSlide(editingSlide, updates)}
|
||||
districts={districts}
|
||||
districtMuseumMap={districtMuseumMap}
|
||||
channels={channels}
|
||||
museums={museums}
|
||||
data={data}
|
||||
chartTypes={CHART_TYPES}
|
||||
metrics={METRICS}
|
||||
@@ -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 availableMuseums = useMemo(() =>
|
||||
getMuseumsForDistrict(districtMuseumMap, slide.district),
|
||||
[districtMuseumMap, slide.district]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="slide-editor">
|
||||
<div className="editor-section">
|
||||
<label>{t('slides.slideTitle')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slide.title}
|
||||
<input
|
||||
type="text"
|
||||
value={slide.title}
|
||||
onChange={e => onUpdate({ title: e.target.value })}
|
||||
placeholder={t('slides.slideTitle')}
|
||||
/>
|
||||
@@ -279,7 +311,7 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
||||
<div className="editor-section">
|
||||
<label>{t('slides.chartType')}</label>
|
||||
<div className="chart-type-grid">
|
||||
{chartTypes.map(type => (
|
||||
{chartTypes.map((type: ChartTypeOption) => (
|
||||
<button
|
||||
key={type.id}
|
||||
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">
|
||||
<label>{t('slides.metric')}</label>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -312,17 +344,17 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
||||
|
||||
<div className="editor-row">
|
||||
<div className="editor-section">
|
||||
<label>{t('filters.district')}</label>
|
||||
<select value={slide.district} onChange={e => onUpdate({ district: e.target.value, museum: 'all' })}>
|
||||
<option value="all">{t('filters.allDistricts')}</option>
|
||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
<label>{t('filters.channel')}</label>
|
||||
<select value={slide.channel} onChange={e => onUpdate({ channel: e.target.value, museum: 'all' })}>
|
||||
<option value="all">{t('filters.allChannels')}</option>
|
||||
{channels.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="editor-section">
|
||||
<label>{t('filters.museum')}</label>
|
||||
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
|
||||
<option value="all">{t('filters.allMuseums')}</option>
|
||||
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
{museums.map((m: string) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,9 +362,9 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
||||
{slide.chartType === 'comparison' && (
|
||||
<div className="editor-section">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={slide.showComparison}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={slide.showComparison}
|
||||
onChange={e => onUpdate({ showComparison: e.target.checked })}
|
||||
/>
|
||||
{t('slides.showYoY')}
|
||||
@@ -342,48 +374,48 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
||||
|
||||
<div className="slide-preview-box">
|
||||
<h4>{t('slides.preview')}</h4>
|
||||
<SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} metrics={metrics} />
|
||||
<SlidePreview slide={slide} data={data} channels={channels} museums={museums} metrics={metrics} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Static field mapping for charts (Chart.js labels don't need i18n)
|
||||
const METRIC_FIELDS = {
|
||||
revenue: { field: 'revenue_incl_tax', label: 'Revenue' },
|
||||
const METRIC_FIELDS: Record<string, MetricFieldInfo> = {
|
||||
revenue: { field: 'revenue_gross', label: 'Revenue' },
|
||||
visitors: { field: 'visits', label: 'Visitors' },
|
||||
tickets: { field: 'tickets', label: 'Tickets' }
|
||||
};
|
||||
|
||||
function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
|
||||
function SlidePreview({ slide, data, channels, museums, metrics }: SlidePreviewProps) {
|
||||
const { t } = useLanguage();
|
||||
const filteredData = useMemo(() =>
|
||||
const filteredData = useMemo(() =>
|
||||
filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||
district: slide.district,
|
||||
channel: slide.channel,
|
||||
museum: slide.museum
|
||||
}),
|
||||
[data, slide.startDate, slide.endDate, slide.district, slide.museum]
|
||||
[data, slide.startDate, slide.endDate, slide.channel, slide.museum]
|
||||
);
|
||||
|
||||
const metricsData = useMemo(() => calculateMetrics(filteredData), [filteredData]);
|
||||
const baseOptions = useMemo(() => createBaseOptions(false), []);
|
||||
|
||||
const getMetricValue = useCallback((rows, metric) => {
|
||||
const fieldMap = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
|
||||
return rows.reduce((s, r) => s + parseFloat(r[fieldMap[metric]] || 0), 0);
|
||||
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
|
||||
const fieldMap: Record<string, string> = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' };
|
||||
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as unknown as Record<string, unknown>)[fieldMap[metric]] || 0)), 0);
|
||||
}, []);
|
||||
|
||||
const trendData = useMemo(() => {
|
||||
const grouped = {};
|
||||
const grouped: Record<string, MuseumRecord[]> = {};
|
||||
filteredData.forEach(row => {
|
||||
if (!row.date) return;
|
||||
const weekStart = row.date.substring(0, 10);
|
||||
if (!grouped[weekStart]) grouped[weekStart] = [];
|
||||
grouped[weekStart].push(row);
|
||||
});
|
||||
|
||||
|
||||
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 {
|
||||
labels: sortedDates.map(d => d.substring(5)),
|
||||
datasets: [{
|
||||
@@ -398,15 +430,15 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
|
||||
}, [filteredData, slide.metric, getMetricValue, metrics]);
|
||||
|
||||
const museumData = useMemo(() => {
|
||||
const byMuseum = {};
|
||||
const byMuseum: Record<string, MuseumRecord[]> = {};
|
||||
filteredData.forEach(row => {
|
||||
if (!row.museum_name) return;
|
||||
if (!byMuseum[row.museum_name]) byMuseum[row.museum_name] = [];
|
||||
byMuseum[row.museum_name].push(row);
|
||||
});
|
||||
|
||||
|
||||
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 {
|
||||
labels: museums,
|
||||
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 handleKeyDown = useCallback((e) => {
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
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') {
|
||||
setCurrentSlide(prev => Math.max(prev - 1, 0));
|
||||
setCurrentSlide((prev: number) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Escape') {
|
||||
onExit();
|
||||
}
|
||||
@@ -476,15 +508,15 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
|
||||
<div className="preview-slide">
|
||||
<h1 className="preview-title">{slide?.title}</h1>
|
||||
<div className="preview-content">
|
||||
{slide && <SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} metrics={metrics} />}
|
||||
{slide && <SlidePreview slide={slide} data={data} channels={channels} museums={museums} metrics={metrics} />}
|
||||
</div>
|
||||
<div className="preview-footer">
|
||||
<span>{currentSlide + 1} / {slides.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-controls">
|
||||
<button onClick={() => setCurrentSlide(prev => 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.max(prev - 1, 0))} disabled={currentSlide === 0}>←</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -492,10 +524,10 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
|
||||
}
|
||||
|
||||
// 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 canvasId = `chart-${index}`;
|
||||
|
||||
|
||||
return `
|
||||
<div class="slide" id="slide-${index}">
|
||||
<h1 class="slide-title">${slide.title}</h1>
|
||||
@@ -510,13 +542,13 @@ function generateSlideHTML(slide, index, data, districts, districtMuseumMap) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function generateKPIHTML(slide, data) {
|
||||
function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string {
|
||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||
district: slide.district,
|
||||
channel: slide.channel,
|
||||
museum: slide.museum
|
||||
});
|
||||
const metrics = calculateMetrics(filtered);
|
||||
|
||||
|
||||
return `
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi-card">
|
||||
@@ -534,40 +566,40 @@ function generateKPIHTML(slide, data) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function generateChartScripts(slides, data, districts, districtMuseumMap) {
|
||||
return slides.map((slide, index) => {
|
||||
function generateChartScripts(slides: SlideConfig[], data: MuseumRecord[]): string {
|
||||
return slides.map((slide: SlideConfig, index: number) => {
|
||||
if (slide.chartType === 'kpi-cards') return '';
|
||||
|
||||
|
||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||
district: slide.district,
|
||||
channel: slide.channel,
|
||||
museum: slide.museum
|
||||
});
|
||||
|
||||
|
||||
const chartConfig = generateChartConfig(slide, filtered);
|
||||
|
||||
|
||||
return `
|
||||
new Chart(document.getElementById('chart-${index}'), ${JSON.stringify(chartConfig)});
|
||||
`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function generateChartConfig(slide, data) {
|
||||
const fieldMap = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
|
||||
function generateChartConfig(slide: SlideConfig, data: MuseumRecord[]): object {
|
||||
const fieldMap: Record<string, keyof MuseumRecord> = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' };
|
||||
const field = fieldMap[slide.metric];
|
||||
|
||||
|
||||
if (slide.chartType === 'museum-bar') {
|
||||
const byMuseum = {};
|
||||
data.forEach(row => {
|
||||
const byMuseum: Record<string, number> = {};
|
||||
data.forEach((row: MuseumRecord) => {
|
||||
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();
|
||||
|
||||
|
||||
return {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: museums,
|
||||
datasets: [{
|
||||
datasets: [{
|
||||
data: museums.map(m => byMuseum[m]),
|
||||
backgroundColor: '#3b82f6',
|
||||
borderRadius: 6
|
||||
@@ -576,15 +608,15 @@ function generateChartConfig(slide, data) {
|
||||
options: { indexAxis: 'y', plugins: { legend: { display: false } } }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Default: trend line
|
||||
const grouped = {};
|
||||
data.forEach(row => {
|
||||
const grouped: Record<string, number> = {};
|
||||
data.forEach((row: MuseumRecord) => {
|
||||
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();
|
||||
|
||||
|
||||
return {
|
||||
type: 'line',
|
||||
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 EmptyState } from './EmptyState';
|
||||
export { default as FilterControls } from './FilterControls';
|
||||
export { default as MultiSelect } from './MultiSelect';
|
||||
export { default as StatCard } from './StatCard';
|
||||
export { default as ToggleSwitch } from './ToggleSwitch';
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Filler
|
||||
} from 'chart.js';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import Annotation from 'chartjs-plugin-annotation';
|
||||
|
||||
// Register ChartJS components once
|
||||
ChartJS.register(
|
||||
@@ -25,7 +26,8 @@ ChartJS.register(
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
ChartDataLabels
|
||||
ChartDataLabels,
|
||||
Annotation
|
||||
);
|
||||
|
||||
export const chartColors = {
|
||||
@@ -38,6 +40,20 @@ export const chartColors = {
|
||||
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 => ({
|
||||
display: showDataLabels,
|
||||
color: '#1e293b',
|
||||
@@ -49,7 +65,7 @@ export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
||||
borderRadius: 3,
|
||||
textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment
|
||||
formatter: (value) => {
|
||||
formatter: (value: number | null) => {
|
||||
if (value == null) return '';
|
||||
if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
|
||||
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": "المقارنة",
|
||||
"compare": "مقارنة",
|
||||
"slides": "الشرائح",
|
||||
"settings": "الإعدادات",
|
||||
"labels": "التسميات",
|
||||
"labelsOn": "التسميات مفعّلة",
|
||||
"labelsOff": "التسميات معطّلة",
|
||||
@@ -25,7 +26,7 @@
|
||||
"excl": "بدون"
|
||||
},
|
||||
"dataSources": {
|
||||
"museums": "المتاحف",
|
||||
"museums": "الفعاليات",
|
||||
"coffees": "المقاهي",
|
||||
"ecommerce": "التجارة الإلكترونية",
|
||||
"soon": "قريباً"
|
||||
@@ -34,12 +35,16 @@
|
||||
"title": "الفلاتر",
|
||||
"year": "السنة",
|
||||
"district": "المنطقة",
|
||||
"museum": "المتحف",
|
||||
"channel": "القناة",
|
||||
"museum": "الفعالية",
|
||||
"quarter": "الربع",
|
||||
"allYears": "كل السنوات",
|
||||
"allDistricts": "كل المناطق",
|
||||
"allMuseums": "كل المتاحف",
|
||||
"allChannels": "جميع القنوات",
|
||||
"allMuseums": "كل الفعاليات",
|
||||
"allQuarters": "كل الأرباع",
|
||||
"season": "الموسم",
|
||||
"allSeasons": "كل المواسم",
|
||||
"reset": "إعادة تعيين الفلاتر"
|
||||
},
|
||||
"metrics": {
|
||||
@@ -52,19 +57,22 @@
|
||||
"avgRevenue": "متوسط الإيراد/زائر",
|
||||
"avgRevenuePerVisitor": "متوسط الإيراد لكل زائر",
|
||||
"pilgrims": "المعتمرون",
|
||||
"captureRate": "نسبة الاستقطاب"
|
||||
"captureRate": "نسبة الاستقطاب",
|
||||
"bar": "أعمدة",
|
||||
"pie": "دائري"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "لوحة التحكم",
|
||||
"subtitle": "تحليلات المتاحف المباشرة من جداول بيانات Google",
|
||||
"subtitle": "تحليلات الفعاليات من نظام ERP",
|
||||
"noData": "لا توجد بيانات",
|
||||
"noDataMessage": "لا توجد سجلات تطابق الفلاتر الحالية. حاول تعديل اختيارك.",
|
||||
"quarterlyComparison": "مقارنة ربع سنوية: 2024 مقابل 2025",
|
||||
"revenueTrends": "اتجاهات الإيرادات",
|
||||
"visitorsByMuseum": "الزوار حسب المتحف",
|
||||
"revenueByMuseum": "الإيرادات حسب المتحف",
|
||||
"visitorsByMuseum": "الزوار حسب الفعالية",
|
||||
"revenueByMuseum": "الإيرادات حسب الفعالية",
|
||||
"quarterlyRevenue": "الإيرادات الربعية (سنوي)",
|
||||
"districtPerformance": "أداء المناطق",
|
||||
"channelPerformance": "أداء القنوات",
|
||||
"captureRateChart": "نسبة الاستقطاب مقابل المعتمرين"
|
||||
},
|
||||
"table": {
|
||||
@@ -117,7 +125,7 @@
|
||||
"noData": "لا توجد بيانات لهذه الفترة",
|
||||
"noDataMessage": "لم يتم العثور على سجلات للنطاق الزمني والفلاتر المحددة.",
|
||||
"trend": "الاتجاه",
|
||||
"byMuseum": "حسب المتحف",
|
||||
"byMuseum": "حسب الفعالية",
|
||||
"pendingData": "البيانات لم تُنشر بعد"
|
||||
},
|
||||
"slides": {
|
||||
@@ -137,7 +145,7 @@
|
||||
"showYoY": "إظهار مقارنة سنة بسنة",
|
||||
"exit": "خروج",
|
||||
"revenueTrend": "اتجاه الإيرادات",
|
||||
"byMuseum": "حسب المتحف",
|
||||
"byMuseum": "حسب الفعالية",
|
||||
"kpiSummary": "ملخص مؤشرات الأداء",
|
||||
"yoyComparison": "مقارنة سنوية"
|
||||
},
|
||||
@@ -147,8 +155,42 @@
|
||||
"revenue": "الإيرادات",
|
||||
"quarterly": "ربع سنوي",
|
||||
"district": "المنطقة",
|
||||
"channel": "القناة",
|
||||
"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": {
|
||||
"switch": "EN"
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"comparison": "Comparison",
|
||||
"compare": "Compare",
|
||||
"slides": "Slides",
|
||||
"settings": "Settings",
|
||||
"labels": "Labels",
|
||||
"labelsOn": "Labels On",
|
||||
"labelsOff": "Labels Off",
|
||||
@@ -25,7 +26,7 @@
|
||||
"excl": "Excl"
|
||||
},
|
||||
"dataSources": {
|
||||
"museums": "Museums",
|
||||
"museums": "Events",
|
||||
"coffees": "Coffees",
|
||||
"ecommerce": "eCommerce",
|
||||
"soon": "soon"
|
||||
@@ -34,12 +35,16 @@
|
||||
"title": "Filters",
|
||||
"year": "Year",
|
||||
"district": "District",
|
||||
"museum": "Museum",
|
||||
"channel": "Channel",
|
||||
"museum": "Event",
|
||||
"quarter": "Quarter",
|
||||
"allYears": "All Years",
|
||||
"allDistricts": "All Districts",
|
||||
"allMuseums": "All Museums",
|
||||
"allChannels": "All Channels",
|
||||
"allMuseums": "All Events",
|
||||
"allQuarters": "All Quarters",
|
||||
"season": "Season",
|
||||
"allSeasons": "All Seasons",
|
||||
"reset": "Reset Filters"
|
||||
},
|
||||
"metrics": {
|
||||
@@ -52,19 +57,22 @@
|
||||
"avgRevenue": "Avg Rev/Visitor",
|
||||
"avgRevenuePerVisitor": "Avg Revenue/Visitor",
|
||||
"pilgrims": "Pilgrims",
|
||||
"captureRate": "Capture Rate"
|
||||
"captureRate": "Capture Rate",
|
||||
"bar": "Bar",
|
||||
"pie": "Pie"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"subtitle": "Real-time museum analytics from Google Sheets",
|
||||
"subtitle": "Event analytics from ERP",
|
||||
"noData": "No data found",
|
||||
"noDataMessage": "No records match your current filters. Try adjusting your selection.",
|
||||
"quarterlyComparison": "Quarterly Comparison: 2024 vs 2025",
|
||||
"revenueTrends": "Revenue Trends",
|
||||
"visitorsByMuseum": "Visitors by Museum",
|
||||
"revenueByMuseum": "Revenue by Museum",
|
||||
"visitorsByMuseum": "Visitors by Event",
|
||||
"revenueByMuseum": "Revenue by Event",
|
||||
"quarterlyRevenue": "Quarterly Revenue (YoY)",
|
||||
"districtPerformance": "District Performance",
|
||||
"channelPerformance": "Channel Performance",
|
||||
"captureRateChart": "Capture Rate vs Umrah Pilgrims"
|
||||
},
|
||||
"table": {
|
||||
@@ -117,7 +125,7 @@
|
||||
"noData": "No data for this period",
|
||||
"noDataMessage": "No records found for the selected date range and filters.",
|
||||
"trend": "Trend",
|
||||
"byMuseum": "By Museum",
|
||||
"byMuseum": "By Event",
|
||||
"pendingData": "Data not published yet"
|
||||
},
|
||||
"slides": {
|
||||
@@ -137,7 +145,7 @@
|
||||
"showYoY": "Show Year-over-Year Comparison",
|
||||
"exit": "Exit",
|
||||
"revenueTrend": "Revenue Trend",
|
||||
"byMuseum": "By Museum",
|
||||
"byMuseum": "By Event",
|
||||
"kpiSummary": "KPI Summary",
|
||||
"yoyComparison": "YoY Comparison"
|
||||
},
|
||||
@@ -147,8 +155,42 @@
|
||||
"revenue": "Revenue",
|
||||
"quarterly": "Quarterly",
|
||||
"district": "District",
|
||||
"channel": "Channel",
|
||||
"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": {
|
||||
"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
|
||||
|
||||
import type {
|
||||
@@ -10,12 +10,12 @@ import type {
|
||||
CacheResult,
|
||||
FetchResult,
|
||||
GroupedData,
|
||||
DistrictMuseumMap,
|
||||
UmrahData,
|
||||
NocoDBDistrict,
|
||||
NocoDBMuseum,
|
||||
NocoDBDailyStat
|
||||
NocoDBDailySale,
|
||||
DataErrorType
|
||||
} from '../types';
|
||||
import { DataError } from '../types';
|
||||
import { fetchWithRetry } from '../utils/fetchHelpers';
|
||||
|
||||
const NOCODB_URL = import.meta.env.VITE_NOCODB_URL || '';
|
||||
const NOCODB_TOKEN = import.meta.env.VITE_NOCODB_TOKEN || '';
|
||||
@@ -26,14 +26,12 @@ let discoveredTables: Record<string, string> | null = null;
|
||||
|
||||
async function discoverTableIds(): Promise<Record<string, string>> {
|
||||
if (discoveredTables) return discoveredTables;
|
||||
if (!NOCODB_BASE_ID) throw new Error('NocoDB not configured');
|
||||
|
||||
if (!NOCODB_BASE_ID) throw new Error('VITE_NOCODB_BASE_ID not configured');
|
||||
|
||||
const res = await fetch(
|
||||
const res = await fetchWithRetry(
|
||||
`${NOCODB_URL}/api/v2/meta/bases/${NOCODB_BASE_ID}/tables`,
|
||||
{ headers: { 'xc-token': NOCODB_TOKEN } }
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to discover tables: HTTP ${res.status}`);
|
||||
|
||||
const json = await res.json();
|
||||
const tables: Record<string, string> = {};
|
||||
@@ -41,20 +39,35 @@ async function discoverTableIds(): Promise<Record<string, string>> {
|
||||
tables[t.title] = t.id;
|
||||
}
|
||||
|
||||
const required = ['Districts', 'Museums', 'DailyStats'];
|
||||
for (const name of required) {
|
||||
if (!tables[name]) throw new Error(`Required table '${name}' not found in NocoDB base`);
|
||||
discoveredTables = tables;
|
||||
return tables;
|
||||
}
|
||||
|
||||
async function fetchNocoDBTable<T>(tableId: string, limit: number = 1000): Promise<T[]> {
|
||||
let allRecords: T[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const response = await fetchWithRetry(
|
||||
`${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`,
|
||||
{ headers: { 'xc-token': NOCODB_TOKEN } }
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
const records: T[] = json.list || [];
|
||||
allRecords = allRecords.concat(records);
|
||||
|
||||
if (records.length < limit) break;
|
||||
offset += limit;
|
||||
}
|
||||
|
||||
discoveredTables = tables;
|
||||
console.log('Discovered NocoDB tables:', Object.keys(tables).map(k => `${k}=${tables[k]}`).join(', '));
|
||||
return tables;
|
||||
return allRecords;
|
||||
}
|
||||
|
||||
// Cache keys
|
||||
const CACHE_KEY = 'hihala_data_cache';
|
||||
const CACHE_TIMESTAMP_KEY = 'hihala_data_cache_timestamp';
|
||||
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
// Default umrah data (overridden by NocoDB PilgrimStats when available)
|
||||
export let umrahData: UmrahData = {
|
||||
@@ -62,7 +75,6 @@ export let umrahData: UmrahData = {
|
||||
2025: { 1: 15222497, 2: 5443393, 3: 26643148, 4: 31591871 }
|
||||
};
|
||||
|
||||
// Fetch pilgrim stats from NocoDB and update umrahData
|
||||
export async function fetchPilgrimStats(): Promise<UmrahData> {
|
||||
try {
|
||||
const tables = await discoverTableIds();
|
||||
@@ -71,15 +83,14 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
|
||||
return umrahData;
|
||||
}
|
||||
const url = `${NOCODB_URL}/api/v2/tables/${tables['PilgrimStats']}/records?limit=50`;
|
||||
const res = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const res = await fetchWithRetry(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
||||
const json = await res.json();
|
||||
const records = json.list || [];
|
||||
|
||||
|
||||
const data: UmrahData = { 2024: {}, 2025: {} };
|
||||
for (const r of records) {
|
||||
const year = r.Year as number;
|
||||
const qStr = r.Quarter as string; // "Q1", "Q2", etc.
|
||||
const qStr = r.Quarter as string;
|
||||
const qNum = parseInt(qStr.replace('Q', ''));
|
||||
const total = r.TotalPilgrims as number;
|
||||
if (year && qNum && total) {
|
||||
@@ -87,8 +98,7 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
|
||||
data[year][qNum] = total;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the global umrahData
|
||||
|
||||
umrahData = data;
|
||||
console.log('PilgrimStats loaded from NocoDB:', data);
|
||||
return data;
|
||||
@@ -98,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
|
||||
// ============================================
|
||||
@@ -116,15 +162,15 @@ function loadFromCache(): CacheResult | null {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
||||
|
||||
|
||||
if (!cached) return null;
|
||||
|
||||
|
||||
const data: MuseumRecord[] = JSON.parse(cached);
|
||||
const age = timestamp ? Date.now() - parseInt(timestamp) : Infinity;
|
||||
const isStale = age > CACHE_MAX_AGE_MS;
|
||||
|
||||
|
||||
console.log(`Loaded ${data.length} rows from cache (age: ${Math.round(age / 1000 / 60)} min, stale: ${isStale})`);
|
||||
|
||||
|
||||
return { data, isStale, timestamp: parseInt(timestamp || '0') };
|
||||
} catch (err) {
|
||||
console.warn('Failed to load from cache:', (err as Error).message);
|
||||
@@ -135,14 +181,14 @@ function loadFromCache(): CacheResult | null {
|
||||
export function getCacheStatus(): CacheStatus {
|
||||
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
|
||||
|
||||
if (!cached || !timestamp) {
|
||||
return { available: false, timestamp: null, age: null, rows: 0 };
|
||||
}
|
||||
|
||||
|
||||
const ts = parseInt(timestamp);
|
||||
const data: MuseumRecord[] = JSON.parse(cached);
|
||||
|
||||
|
||||
return {
|
||||
available: true,
|
||||
timestamp: new Date(ts).toISOString(),
|
||||
@@ -159,92 +205,16 @@ export function clearCache(): void {
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NocoDB Data Fetching
|
||||
// Error Classification
|
||||
// ============================================
|
||||
|
||||
async function fetchNocoDBTable<T>(tableId: string, limit: number = 1000): Promise<T[]> {
|
||||
let allRecords: T[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const response = await fetch(
|
||||
`${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`,
|
||||
{ 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...');
|
||||
|
||||
const tables = await discoverTableIds();
|
||||
|
||||
// Fetch all three tables in parallel
|
||||
const [districts, museums, dailyStats] = await Promise.all([
|
||||
fetchNocoDBTable<NocoDBDistrict>(tables['Districts']),
|
||||
fetchNocoDBTable<NocoDBMuseum>(tables['Museums']),
|
||||
fetchNocoDBTable<NocoDBDailyStat>(tables['DailyStats'])
|
||||
]);
|
||||
|
||||
// Build lookup maps
|
||||
const districtMap: Record<number, string> = {};
|
||||
districts.forEach(d => { districtMap[d.Id] = d.Name; });
|
||||
|
||||
const museumMap: Record<number, MuseumMapEntry> = {};
|
||||
museums.forEach(m => {
|
||||
museumMap[m.Id] = {
|
||||
code: m.Code,
|
||||
name: m.Name,
|
||||
district: districtMap[m.DistrictId || m['nc_epk____Districts_id']] || '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;
|
||||
function classifyError(err: Error): DataErrorType {
|
||||
const msg = err.message.toLowerCase();
|
||||
if (msg.includes('not configured')) return 'config';
|
||||
if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout';
|
||||
if (msg.includes('401') || msg.includes('403') || msg.includes('authentication') || msg.includes('unauthorized')) return 'auth';
|
||||
if (msg.includes('failed to fetch') || msg.includes('network') || msg.includes('econnrefused') || msg.includes('err_connection')) return 'network';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -252,45 +222,46 @@ async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
|
||||
// ============================================
|
||||
|
||||
export async function fetchData(): Promise<FetchResult> {
|
||||
// Check if NocoDB is configured
|
||||
if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) {
|
||||
// Try cache
|
||||
const cached = loadFromCache();
|
||||
if (cached) {
|
||||
console.warn('NocoDB not configured, using cached data');
|
||||
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp };
|
||||
}
|
||||
throw new Error('NocoDB not configured and no cached data available. Set VITE_NOCODB_URL, VITE_NOCODB_TOKEN, and VITE_NOCODB_BASE_ID in .env.local');
|
||||
throw new DataError('NocoDB not configured', 'config');
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Try to fetch fresh data
|
||||
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);
|
||||
|
||||
return { data, fromCache: false };
|
||||
} catch (err) {
|
||||
console.error('NocoDB fetch failed:', (err as Error).message);
|
||||
|
||||
// Try to load from cache
|
||||
|
||||
const cached = loadFromCache();
|
||||
if (cached) {
|
||||
console.warn(`Using cached data from ${new Date(cached.timestamp).toLocaleString()} (offline mode)`);
|
||||
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> {
|
||||
if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) {
|
||||
throw new Error('NocoDB not configured');
|
||||
throw new DataError('NocoDB not configured', 'config');
|
||||
}
|
||||
|
||||
|
||||
const data = await fetchFromNocoDB();
|
||||
saveToCache(data);
|
||||
return { data, fromCache: false };
|
||||
@@ -304,30 +275,32 @@ export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord
|
||||
return data.filter(row => {
|
||||
if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false;
|
||||
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
||||
if (filters.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;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function filterDataByDateRange(
|
||||
data: MuseumRecord[],
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
data: MuseumRecord[],
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
filters: Partial<DateRangeFilters> = {}
|
||||
): MuseumRecord[] {
|
||||
return data.filter(row => {
|
||||
if (!row.date) return false;
|
||||
if (row.date < startDate || row.date > endDate) return false;
|
||||
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
||||
if (filters.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;
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateMetrics(data: MuseumRecord[], includeVAT: boolean = true): Metrics {
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
const revenue = data.reduce((sum, row) => sum + (row[revenueField] || row.revenue_incl_tax || 0), 0);
|
||||
const revenue = data.reduce((sum, row) => sum + (row[revenueField] || 0), 0);
|
||||
const visitors = data.reduce((sum, row) => sum + (row.visits || 0), 0);
|
||||
const tickets = data.reduce((sum, row) => sum + (row.tickets || 0), 0);
|
||||
const avgRevPerVisitor = visitors > 0 ? revenue / visitors : 0;
|
||||
@@ -374,17 +347,17 @@ export function formatCompactCurrency(num: number): string {
|
||||
|
||||
export function getWeekStart(dateStr: string): string | null {
|
||||
if (!dateStr || !dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) return null;
|
||||
|
||||
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
const dayOfWeek = date.getDay();
|
||||
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
||||
|
||||
|
||||
const monday = new Date(year, month - 1, day + diff);
|
||||
const y = monday.getFullYear();
|
||||
const m = String(monday.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(monday.getDate()).padStart(2, '0');
|
||||
|
||||
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
@@ -396,7 +369,7 @@ export function groupByWeek(data: MuseumRecord[], includeVAT: boolean = true): R
|
||||
const weekStart = getWeekStart(row.date);
|
||||
if (!weekStart) return;
|
||||
if (!grouped[weekStart]) grouped[weekStart] = { revenue: 0, visitors: 0, tickets: 0 };
|
||||
grouped[weekStart].revenue += row[revenueField] || row.revenue_incl_tax || 0;
|
||||
grouped[weekStart].revenue += row[revenueField] || 0;
|
||||
grouped[weekStart].visitors += row.visits || 0;
|
||||
grouped[weekStart].tickets += row.tickets || 0;
|
||||
});
|
||||
@@ -409,22 +382,22 @@ export function groupByMuseum(data: MuseumRecord[], includeVAT: boolean = true):
|
||||
data.forEach(row => {
|
||||
if (!row.museum_name) return;
|
||||
if (!grouped[row.museum_name]) grouped[row.museum_name] = { revenue: 0, visitors: 0, tickets: 0 };
|
||||
grouped[row.museum_name].revenue += row[revenueField] || row.revenue_incl_tax || 0;
|
||||
grouped[row.museum_name].revenue += row[revenueField] || 0;
|
||||
grouped[row.museum_name].visitors += row.visits || 0;
|
||||
grouped[row.museum_name].tickets += row.tickets || 0;
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function groupByDistrict(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
|
||||
export function groupByChannel(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
const grouped: Record<string, GroupedData> = {};
|
||||
data.forEach(row => {
|
||||
if (!row.district) return;
|
||||
if (!grouped[row.district]) grouped[row.district] = { revenue: 0, visitors: 0, tickets: 0 };
|
||||
grouped[row.district].revenue += row[revenueField] || row.revenue_incl_tax || 0;
|
||||
grouped[row.district].visitors += row.visits || 0;
|
||||
grouped[row.district].tickets += row.tickets || 0;
|
||||
if (!row.channel) return;
|
||||
if (!grouped[row.channel]) grouped[row.channel] = { revenue: 0, visitors: 0, tickets: 0 };
|
||||
grouped[row.channel].revenue += row[revenueField] || 0;
|
||||
grouped[row.channel].visitors += row.visits || 0;
|
||||
grouped[row.channel].tickets += row.tickets || 0;
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
@@ -442,25 +415,30 @@ export function getUniqueDistricts(data: MuseumRecord[]): string[] {
|
||||
return [...new Set(data.map(r => r.district).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
export function getDistrictMuseumMap(data: MuseumRecord[]): DistrictMuseumMap {
|
||||
const map: Record<string, Set<string>> = {};
|
||||
export function groupByDistrict(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
const grouped: Record<string, GroupedData> = {};
|
||||
data.forEach(row => {
|
||||
if (!row.district || !row.museum_name) return;
|
||||
if (!map[row.district]) map[row.district] = new Set();
|
||||
map[row.district].add(row.museum_name);
|
||||
if (!row.district) return;
|
||||
if (!grouped[row.district]) grouped[row.district] = { revenue: 0, visitors: 0, tickets: 0 };
|
||||
grouped[row.district].revenue += row[revenueField] || 0;
|
||||
grouped[row.district].visitors += row.visits || 0;
|
||||
grouped[row.district].tickets += row.tickets || 0;
|
||||
});
|
||||
const result: DistrictMuseumMap = {};
|
||||
Object.keys(map).forEach(d => {
|
||||
result[d] = [...map[d]].sort();
|
||||
});
|
||||
return result;
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function getMuseumsForDistrict(districtMuseumMap: DistrictMuseumMap, district: string): string[] {
|
||||
if (district === 'all') {
|
||||
return Object.values(districtMuseumMap).flat().sort();
|
||||
}
|
||||
return districtMuseumMap[district] || [];
|
||||
export function getMuseumsForDistrict(data: MuseumRecord[], district: string): string[] {
|
||||
if (district === 'all') return getUniqueMuseums(data);
|
||||
return [...new Set(data.filter(r => r.district === district).map(r => r.museum_name).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
export function getUniqueChannels(data: MuseumRecord[]): string[] {
|
||||
return [...new Set(data.map(r => r.channel).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
export function getUniqueMuseums(data: MuseumRecord[]): string[] {
|
||||
return [...new Set(data.map(r => r.museum_name).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
export function getLatestYear(data: MuseumRecord[]): string {
|
||||
|
||||
@@ -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 {
|
||||
date: string;
|
||||
museum_code: string;
|
||||
museum_name: string;
|
||||
district: string;
|
||||
museum_name: string;
|
||||
channel: string;
|
||||
visits: number;
|
||||
tickets: number;
|
||||
revenue_gross: number;
|
||||
revenue_net: number;
|
||||
revenue_incl_tax: number; // Legacy field
|
||||
year: string;
|
||||
quarter: string;
|
||||
}
|
||||
@@ -24,13 +23,15 @@ export interface Metrics {
|
||||
export interface Filters {
|
||||
year: string;
|
||||
district: string;
|
||||
museum: string;
|
||||
channel: string[];
|
||||
museum: string[];
|
||||
quarter: string;
|
||||
}
|
||||
|
||||
export interface DateRangeFilters {
|
||||
district: string;
|
||||
museum: string;
|
||||
channel: string[];
|
||||
museum: string[];
|
||||
}
|
||||
|
||||
export interface CacheStatus {
|
||||
@@ -53,22 +54,54 @@ export interface FetchResult {
|
||||
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 {
|
||||
revenue: number;
|
||||
visitors: number;
|
||||
tickets: number;
|
||||
}
|
||||
|
||||
export interface DistrictMuseumMap {
|
||||
[district: string]: string[];
|
||||
}
|
||||
|
||||
export interface UmrahData {
|
||||
[year: number]: {
|
||||
[quarter: number]: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
// NocoDB DailySales row (populated by server-side ETL)
|
||||
export interface NocoDBDailySale {
|
||||
Id: number;
|
||||
Date: string;
|
||||
District: string;
|
||||
MuseumName: string;
|
||||
Channel: string;
|
||||
TicketType: string;
|
||||
ComboMuseums: number;
|
||||
ComboWith: string;
|
||||
Visits: number;
|
||||
Tickets: number;
|
||||
GrossRevenue: number;
|
||||
NetRevenue: number;
|
||||
}
|
||||
|
||||
// Season (hijri calendar overlay)
|
||||
export interface Season {
|
||||
Id?: number;
|
||||
Name: string;
|
||||
HijriYear: number;
|
||||
StartDate: string;
|
||||
EndDate: string;
|
||||
Color: string;
|
||||
}
|
||||
|
||||
// Chart data types
|
||||
export interface ChartDataset {
|
||||
label?: string;
|
||||
@@ -97,18 +130,25 @@ export interface ChartData {
|
||||
// Component props
|
||||
export interface DashboardProps {
|
||||
data: MuseumRecord[];
|
||||
seasons: Season[];
|
||||
userRole: string;
|
||||
showDataLabels: boolean;
|
||||
setShowDataLabels: (value: boolean) => void;
|
||||
includeVAT: boolean;
|
||||
setIncludeVAT: (value: boolean) => void;
|
||||
allowedMuseums: string[] | null;
|
||||
allowedChannels: string[] | null;
|
||||
}
|
||||
|
||||
export interface ComparisonProps {
|
||||
data: MuseumRecord[];
|
||||
seasons: Season[];
|
||||
showDataLabels: boolean;
|
||||
setShowDataLabels: (value: boolean) => void;
|
||||
includeVAT: boolean;
|
||||
setIncludeVAT: (value: boolean) => void;
|
||||
allowedMuseums: string[] | null;
|
||||
allowedChannels: string[] | null;
|
||||
}
|
||||
|
||||
export interface SlidesProps {
|
||||
@@ -139,29 +179,34 @@ export interface MetricCardData {
|
||||
pendingMessage?: string;
|
||||
}
|
||||
|
||||
// NocoDB raw types
|
||||
export interface NocoDBDistrict {
|
||||
Id: number;
|
||||
Name: string;
|
||||
// Slide types
|
||||
export interface SlideConfig {
|
||||
id: number;
|
||||
title: string;
|
||||
chartType: string;
|
||||
metric: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
channel: string;
|
||||
museum: string;
|
||||
showComparison: boolean;
|
||||
}
|
||||
|
||||
export interface NocoDBMuseum {
|
||||
Id: number;
|
||||
Code: string;
|
||||
Name: string;
|
||||
DistrictId?: number;
|
||||
'nc_epk____Districts_id'?: number;
|
||||
export interface ChartTypeOption {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface NocoDBDailyStat {
|
||||
Id: number;
|
||||
Date: string;
|
||||
Visits: number;
|
||||
Tickets: number;
|
||||
GrossRevenue: number;
|
||||
NetRevenue: number;
|
||||
MuseumId?: number;
|
||||
'nc_epk____Museums_id'?: number;
|
||||
export interface MetricOption {
|
||||
id: string;
|
||||
label: string;
|
||||
field: string;
|
||||
}
|
||||
|
||||
export interface MetricFieldInfo {
|
||||
field: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 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
|
||||
# Launch both NocoDB (backend) and React (frontend)
|
||||
# Start local dev environment: NocoDB + Express server + Vite
|
||||
|
||||
set -e
|
||||
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "Shutting down..."
|
||||
if [ -n "$REACT_PID" ]; then
|
||||
kill "$REACT_PID" 2>/dev/null
|
||||
fi
|
||||
kill $SERVER_PID $CLIENT_PID 2>/dev/null
|
||||
docker stop nocodb 2>/dev/null
|
||||
echo "Done."
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Start NocoDB container
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Start NocoDB
|
||||
if docker ps --format '{{.Names}}' | grep -q '^nocodb$'; then
|
||||
echo "NocoDB already running on port 8090"
|
||||
else
|
||||
echo "Starting NocoDB..."
|
||||
docker start nocodb 2>/dev/null || docker run -d \
|
||||
--name nocodb \
|
||||
-p 8090:8080 \
|
||||
nocodb/nocodb:latest
|
||||
echo "NocoDB started on port 8090"
|
||||
--name nocodb -p 8090:8080 nocodb/nocodb:latest
|
||||
fi
|
||||
|
||||
# Wait for NocoDB to be ready
|
||||
echo "Waiting for NocoDB..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s http://localhost:8090/api/v1/health >/dev/null 2>&1; then
|
||||
echo "NocoDB is ready"
|
||||
break
|
||||
fi
|
||||
curl -s http://localhost:8090/api/v1/health >/dev/null 2>&1 && echo "NocoDB ready" && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Start React dev server
|
||||
echo "Starting React dev server..."
|
||||
cd "$(dirname "$0")"
|
||||
npm start &
|
||||
REACT_PID=$!
|
||||
# Start Express server (port 3002)
|
||||
echo "Starting Express server..."
|
||||
(cd server && npm run dev) &
|
||||
SERVER_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,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"strictNullChecks": false,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "ESNext",
|
||||
|
||||
@@ -6,7 +6,27 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
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',
|
||||
changeOrigin: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user