Compare commits

...

50 Commits

Author SHA1 Message Date
fahed
b8d33f4f8c feat: deploy Express server via CI/CD with systemd + nginx proxy
Some checks failed
Deploy HiHala Dashboard / deploy (push) Failing after 51m18s
- Update deploy.yml to rsync server/, install deps, write .env from
  Gitea secrets, and restart hihala-dashboard.service
- Move tsx to regular dependencies for production use
- Remove unused SESSION_SECRET from config
- Accept PORT env var as fallback for SERVER_PORT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:59:34 +03:00
fahed
f3ce7705d6 fix: style select input in settings, fix user name placeholder
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 22:20:39 +03:00
fahed
70af4962a6 feat: multi-user auth with role-based access
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
- Server checks PIN against env (super admin) + NocoDB Users table
- Session stores name + role (admin/viewer)
- Admin: sees Settings page (seasons + users management)
- Viewer: sees Dashboard + Comparison only, no Settings
- Users CRUD on Settings page: add name + PIN + role, delete
- Settings link + nav hidden for non-admin users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 22:17:44 +03:00
fahed
8cf6f9eedd feat: add PIN-based login with server-side cookie sessions
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
- Server: POST /auth/login (verify PIN, set httpOnly cookie)
- Server: GET /auth/check, POST /auth/logout
- Client: Login page shown when not authenticated
- Session persists 7 days via httpOnly cookie
- PIN stored server-side only (ADMIN_PIN env var)
- Dashboard loads data only after successful auth

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 22:02:34 +03:00
fahed
c99f2abe10 fix: center settings page to match dashboard layout
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:01:55 +03:00
fahed
a06436baac fix: change NocoDB proxy from /api to /api/v2 to avoid route collision
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 9s
The catch-all /api proxy was swallowing /api/seasons requests before
the specific proxy rule could match. Narrowing to /api/v2 fixes this
since all NocoDB REST calls use /api/v2/ paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:58:41 +03:00
fahed
9657a9d221 ci: trigger rebuild with new NocoDB base ID
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:45:24 +03:00
fahed
3c19dee236 feat: add season annotation bands to Comparison trend chart
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Seasons that overlap the current comparison period appear as
colored bands on the Revenue Trend chart, same as Dashboard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:23:35 +03:00
fahed
b4c436f909 feat: add settings link at bottom of dashboard
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:20:02 +03:00
fahed
db6a6ac609 feat: season filter + chart bands on Dashboard and Comparison
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Dashboard:
- Season dropdown filter (filters data by season date range)
- Revenue trend chart shows colored annotation bands for each season
- All downstream memos use season-filtered data

Comparison:
- Season presets in period selector (optgroup)
- Auto-compares with same season from previous hijri year if defined
- Season preset persists start/end dates in URL

Added chartjs-plugin-annotation for chart bands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:10:49 +03:00
fahed
ef48372033 feat: add Settings page with hijri seasons CRUD
- Server: seasons CRUD routes + generic NocoDB helpers
- Client: Settings page at /settings with inline add/edit/delete
- Seasons stored in NocoDB Seasons table
- Vite proxy: /api/seasons routed to Express server
- Nav links added (desktop + mobile)
- Locale keys for EN + AR
- Seasons loaded non-blocking on app mount

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:03:50 +03:00
fahed
1dd216f933 docs: add hijri seasons feature design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:54:52 +03:00
fahed
ac5b23326c fix: stable multi-select trigger width (no layout shift on selection)
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:28:58 +03:00
fahed
3912b3dd41 polish: fix page title, multi-select styling, chart colors
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
- Page title: "HiHala Data - Museums" -> "HiHala Data"
- Meta description: updated to "Event analytics"
- Multi-select dropdown: fix inherited uppercase, wider to fit labels
- Multi-select arrow: smooth CSS rotation instead of swapping characters
- Chart colors: 10-color palette for events/channels (was 3)
- Remove unused ArcElement (Doughnut) from Chart.js registration (-5KB)
- District chart uses dynamic palette instead of hardcoded 2 colors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:16:20 +03:00
fahed
9332cae350 feat: always-visible visitors bar chart, replace doughnut
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
- Visitors by Event and Revenue by Event are now horizontal bar charts
- Both always visible (no longer hidden when events are filtered)
- Free attractions (Trail To Hira Cave, Makkah Greets Us) now visible
- Removed Doughnut chart and unused import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:55:06 +03:00
fahed
aa9813aed4 feat: multi-select filters for events and channels
- New MultiSelect component with checkbox dropdown
- Event and channel filters now accept multiple selections
- Empty array = all selected (no filter applied)
- URL params store selections as comma-separated values
- District and quarter remain single-select

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:53:23 +03:00
fahed
fba72692ee feat: rename "Museum" to "Event" across all UI labels
Display labels only — internal code references unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:49:03 +03:00
fahed
04789ea9a1 fix: B2C visitor count uses UnitQuantity (1 ticket = 1 visitor)
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 8s
B2C generates one PDF ticket per person, so UnitQuantity = visitors.
Other channels (POS, Safiyyah POS, etc.) use PeopleCount for visitors
since group tickets cover multiple people.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:46:31 +03:00
fahed
f6b7d4ba8d feat: migrate museum sales from NocoDB to Hono ERP API
- Replace NocoDB museum data (Districts/Museums/DailyStats) with ERP API
- Client fetches via server proxy (/api/erp/sales) — no credentials in browser
- Aggregate transaction-level ERP data into daily/museum/channel records
- Replace "district" dimension with "channel" (B2C/HiHala, POS, B2B, etc.)
- Add product-to-museum mapping (46 products → 6 museums)
- NocoDB retained only for PilgrimStats
- Remove old server/index.js (replaced by modular TS in server/src/)
- Update all components, types, and locale files

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:43:20 +03:00
fahed
802ff28754 Update default theme to light and fix data source subtitle
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Default to light theme instead of system preference, and update
dashboard subtitle to reflect VivaTicket as the data source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:03:17 +03:00
fahed
7d919979cc Delight: Add micro-interactions and entrance animations
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
- Stat cards lift on hover (translateY -2px + shadow elevation)
- Metric cards lift on hover
- Chart cards fade-up with staggered delays on mount
- All animations respect prefers-reduced-motion (already in place)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:30:21 +03:00
fahed
784a914ebf Colorize: Add dark mode with system/dark/light toggle
- Add prefers-color-scheme: dark media query for automatic dark mode
- Add data-theme attribute for manual override (persisted to localStorage)
- 3-state cycle: system → dark → light → system
- Theme toggle button in nav with contextual icon (sun/moon/half)
- Dark palette: slate-900 bg, slate-800 surfaces, adjusted text/accent/success/danger

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:29:39 +03:00
fahed
0df13abfee Polish + Optimize: Clean metadata, remove !important, prune unused CSS
- Update theme-color to match brand (#f8fafc), fix stale CRA description
- Remove manifest.json and logo192.png references (not used)
- Replace !important on chart export buttons with higher specificity selectors
- Remove unused .skeleton-text and .skeleton-loading CSS classes
- Remove duplicate skeleton animation keyframes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:28:34 +03:00
fahed
cf169b6b69 Normalize + Adapt: CSS tokens, RTL fixes, mobile nav, fluid charts
Normalize:
- Add 10+ new CSS variables (brand-icon, brand-text, text-inverse, warning-*, etc.)
- Replace 35+ hardcoded hex colors with CSS variable references

Adapt:
- Add Slides link to mobile bottom nav (was unreachable on mobile)
- Fix RTL table alignment: text-align left → start
- Make chart height fluid: 380px → clamp(280px, 30vw, 420px)
- Fix carousel dot touch targets to minimum 24px
- Fix margin-left → margin-inline-start for RTL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:27:27 +03:00
fahed
25066af67c Harden: Fix critical accessibility issues
- Add aria-labels to icon-only buttons (refresh, language toggle)
- Add aria-hidden to decorative SVGs
- Add aria-label to data source select
- Replace outline:none with visible focus rings on all inputs/selects
- Add <main> landmark for screen reader navigation
- Add prefers-reduced-motion: disable all animations for vestibular safety
- Move error message inline style to CSS class
- Add aria-label to both nav landmarks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:23:04 +03:00
fahed
c8567da75f Enable TypeScript strict mode and fix all type errors
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
- Enable strict: true in tsconfig.json (was false)
- Add proper interfaces for all component props (Dashboard, Comparison, Slides)
- Add SlideConfig, ChartTypeOption, MetricOption types
- Type all function parameters, callbacks, and state variables
- Fix dynamic property access with proper keyof assertions
- 233 type errors resolved across 5 files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:17:09 +03:00
fahed
30ea4b6ecb Add route-based code splitting and loading skeletons
- Lazy-load Dashboard, Comparison, Slides via React.lazy + Suspense
- Main bundle reduced from 606KB to 256KB
- Replace full-screen spinner with skeleton cards during load
- Skeleton used for both initial data fetch and route transitions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:10:42 +03:00
fahed
cd1e395ffa Remove unused useUrlState hook and sallaService
Both were implemented but never imported by any component.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:09:18 +03:00
fahed
8934ba1e51 Add fetch timeout/retry, friendly error messages, and VAT rate constant
- fetchWithTimeout (10s) + fetchWithRetry (3 attempts, exponential backoff)
- DataError class with type classification (config/network/auth/timeout/unknown)
- User-friendly error messages in EN/AR instead of raw error strings
- Extract VAT_RATE constant (was hardcoded 1.15)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:08:24 +03:00
fahed
ed29e7c22c Remove CI/CD debug output
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:58:02 +03:00
fahed
39b36bf6d9 Add debug output to CI/CD to verify secrets are injected
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:55:23 +03:00
fahed
bf996749e5 Discover NocoDB table IDs dynamically instead of hardcoding them
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Table IDs are now fetched at runtime via the NocoDB meta API using
VITE_NOCODB_BASE_ID, so the same code works against any NocoDB instance
(local or Cloudron). Also adds a migration script for moving data between
instances with correct FK remapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:50:44 +03:00
fahed
db2617f37d Support both link columns and plain foreign key columns
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
Production NocoDB uses DistrictId/MuseumId columns instead of
nc_epk____ link columns. Code now checks both column names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:03:07 +03:00
fahed
a720b4a0aa Retrigger build with updated NocoDB token
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
2026-03-09 17:47:31 +03:00
fahed
d98fd2cd36 Retrigger CI/CD build
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
2026-03-09 17:41:16 +03:00
fahed
7c1a8fa31a Add NocoDB env vars to CI/CD build step
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Vite inlines env vars at build time, so they must be available
during the build step via Gitea secrets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:39:43 +03:00
fahed
6cf0bf626b Simplify CI/CD to deploy static frontend only
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
Remove server deployment, dependency install, and systemd restart.
The app connects directly to NocoDB from the browser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:36:46 +03:00
fahed
b7ad978e29 Migrate from Create React App to Vite
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
CRA (react-scripts 5.0.1) is abandoned and incompatible with TypeScript 5.x.
Vite provides faster builds, active maintenance, and native TS5 support.

- Replace react-scripts with vite + @vitejs/plugin-react
- Move index.html to root with script module entry point
- Replace setupProxy.js with vite.config.ts proxy config
- Rename env vars from REACT_APP_ to VITE_ prefix
- Update tsconfig for bundler module resolution
- Add nginx setup script for deployment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:24:11 +03:00
57 changed files with 6501 additions and 17140 deletions

View File

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

View File

@@ -13,18 +13,51 @@ jobs:
with:
node-version: '20'
# --- Frontend ---
- name: Build frontend
env:
VITE_NOCODB_URL: ${{ secrets.VITE_NOCODB_URL }}
VITE_NOCODB_TOKEN: ${{ secrets.VITE_NOCODB_TOKEN }}
VITE_NOCODB_BASE_ID: ${{ secrets.VITE_NOCODB_BASE_ID }}
run: |
npm ci
npm run build
- name: Deploy to server
run: |
rsync -a --delete build/ /opt/apps/hihala-dashboard/build/
rsync -a server/ /opt/apps/hihala-dashboard/server/ \
--exclude node_modules --exclude .env
- name: Deploy frontend
run: rsync -a --delete build/ /opt/apps/hihala-dashboard/build/
- name: Install deps & restart
# --- 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: |
cd /opt/apps/hihala-dashboard/server && npm ci --production
sudo systemctl restart hihala-dashboard
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
- name: Restart server service
run: sudo systemctl restart hihala-dashboard.service

View File

@@ -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

View File

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

View File

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

View 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

19
index.html Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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="#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</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

16952
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,13 +2,13 @@
"name": "hihala-dashboard",
"version": "0.1.0",
"private": true,
"proxy": "http://localhost:8090",
"dependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@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",
@@ -16,38 +16,24 @@
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"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"
},
"devDependencies": {
"@types/node": "^25.2.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"typescript": "^5.9.3"
"@vitejs/plugin-react": "^5.1.4",
"concurrently": "^9.2.1",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}

View File

@@ -1,46 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/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="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<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">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>HiHala Data Museums</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

278
scripts/nocodb-migrate.py Normal file
View File

@@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""
NocoDB Migration Script
Exports data from a source NocoDB instance and imports it into a target instance.
Handles ID remapping so FK references stay correct regardless of auto-increment offsets.
Usage:
python3 scripts/nocodb-migrate.py
Configure source/target via environment variables or edit the config below.
"""
import json
import urllib.request
import urllib.error
import sys
import time
# ============================================
# Configuration
# ============================================
SOURCE = {
"url": "http://localhost:8090",
"token": "Crn_mZnlStJ8BjB6a1fvx7_JhiEVFPPm_gI1AwVh",
"base_id": "pqbl1a3yie3inqj",
}
TARGET = {
"url": "https://nocodb.cloudron.hihala.com",
"token": "j6DBMb9vkebA6i_tY1TtctwAToAsi_xQ3kOn9q5C",
"workspace_id": "w0b7k8g8",
}
# Tables to migrate, in order (parents before children)
TABLES = [
{
"name": "Districts",
"columns": [
{"column_name": "Name", "title": "Name", "uidt": "SingleLineText", "pv": True},
{"column_name": "Description", "title": "Description", "uidt": "LongText"},
],
"fields": ["Name", "Description"],
"fk_mappings": {}, # No FK dependencies
},
{
"name": "Museums",
"columns": [
{"column_name": "Code", "title": "Code", "uidt": "SingleLineText", "pv": True},
{"column_name": "Name", "title": "Name", "uidt": "SingleLineText"},
{"column_name": "Status", "title": "Status", "uidt": "SingleLineText"},
{"column_name": "DistrictId", "title": "DistrictId", "uidt": "Number"},
],
"fields": ["Code", "Name", "Status"],
"fk_mappings": {
# field_name: (source_fk_column_candidates, parent_table_name)
"DistrictId": (["DistrictId", "nc_epk____Districts_id"], "Districts"),
},
},
{
"name": "DailyStats",
"columns": [
{"column_name": "Date", "title": "Date", "uidt": "Date"},
{"column_name": "Visits", "title": "Visits", "uidt": "Number"},
{"column_name": "Tickets", "title": "Tickets", "uidt": "Number"},
{"column_name": "GrossRevenue", "title": "GrossRevenue", "uidt": "Number"},
{"column_name": "NetRevenue", "title": "NetRevenue", "uidt": "Decimal"},
{"column_name": "MuseumId", "title": "MuseumId", "uidt": "Number"},
],
"fields": ["Date", "Visits", "Tickets", "GrossRevenue", "NetRevenue"],
"fk_mappings": {
"MuseumId": (["MuseumId", "nc_epk____Museums_id"], "Museums"),
},
},
{
"name": "PilgrimStats",
"columns": [
{"column_name": "Year", "title": "Year", "uidt": "Number"},
{"column_name": "Quarter", "title": "Quarter", "uidt": "SingleLineText"},
{"column_name": "TotalPilgrims", "title": "TotalPilgrims", "uidt": "Number"},
],
"fields": ["Year", "Quarter", "TotalPilgrims"],
"fk_mappings": {},
},
]
# ============================================
# API Helpers
# ============================================
def api_request(base_url, token, path, method="GET", data=None):
url = f"{base_url}{path}"
headers = {"xc-token": token, "Content-Type": "application/json"}
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
error_body = e.read().decode()
print(f" ERROR {e.code}: {error_body}")
raise
def fetch_all_records(base_url, token, table_id, limit=1000):
"""Fetch all records from a table with pagination."""
all_records = []
offset = 0
while True:
data = api_request(base_url, token, f"/api/v2/tables/{table_id}/records?limit={limit}&offset={offset}")
records = data.get("list", [])
all_records.extend(records)
if len(records) < limit:
break
offset += limit
return all_records
def insert_records(base_url, token, table_id, records, batch_size=100):
"""Insert records in batches, return list of created IDs in order."""
all_ids = []
for i in range(0, len(records), batch_size):
batch = records[i:i + batch_size]
result = api_request(base_url, token, f"/api/v2/tables/{table_id}/records", method="POST", data=batch)
if isinstance(result, list):
all_ids.extend([r["Id"] for r in result])
elif isinstance(result, dict) and "Id" in result:
all_ids.append(result["Id"])
# Brief pause between batches to avoid rate limiting
if i + batch_size < len(records):
time.sleep(0.1)
return all_ids
# ============================================
# Discovery
# ============================================
def discover_tables(base_url, token, base_id):
"""Get table name → table_id mapping."""
data = api_request(base_url, token, f"/api/v2/meta/bases/{base_id}/tables")
return {t["title"]: t["id"] for t in data["list"]}
# ============================================
# Migration
# ============================================
def run_migration():
print("=" * 60)
print("NocoDB Migration: Source → Target")
print("=" * 60)
# Step 1: Discover source tables
print("\n[1/5] Discovering source tables...")
source_tables = discover_tables(SOURCE["url"], SOURCE["token"], SOURCE["base_id"])
for name, tid in source_tables.items():
print(f" {name}: {tid}")
# Step 2: Create target base
print("\n[2/5] Creating target base...")
base = api_request(
TARGET["url"], TARGET["token"],
f"/api/v2/meta/workspaces/{TARGET['workspace_id']}/bases/",
method="POST",
data={"title": "HiHala Dashboard"}
)
target_base_id = base["id"]
print(f" Created base: {target_base_id}")
# Step 3: Create target tables
print("\n[3/5] Creating target tables...")
target_table_ids = {}
for table_cfg in TABLES:
name = table_cfg["name"]
result = api_request(
TARGET["url"], TARGET["token"],
f"/api/v2/meta/bases/{target_base_id}/tables/",
method="POST",
data={
"table_name": name,
"title": name,
"columns": table_cfg["columns"],
}
)
target_table_ids[name] = result["id"]
print(f" {name}: {result['id']}")
# Step 4: Export source data and import with ID remapping
print("\n[4/5] Migrating data...")
# id_maps[table_name] = {old_id: new_id}
id_maps = {}
for table_cfg in TABLES:
name = table_cfg["name"]
print(f"\n --- {name} ---")
if name not in source_tables:
print(f" SKIP: not found in source")
continue
# Export from source
source_records = fetch_all_records(SOURCE["url"], SOURCE["token"], source_tables[name])
print(f" Exported {len(source_records)} records from source")
if not source_records:
id_maps[name] = {}
continue
# Build clean records with FK remapping
clean_records = []
for r in source_records:
row = {}
# Copy plain fields
for field in table_cfg["fields"]:
if field in r:
row[field] = r[field]
# Remap FK fields
for fk_field, (source_candidates, parent_table) in table_cfg["fk_mappings"].items():
# Find the FK value from source (try multiple column name candidates)
old_fk = None
for candidate in source_candidates:
if candidate in r and r[candidate] is not None:
old_fk = r[candidate]
break
if old_fk is not None and parent_table in id_maps:
new_fk = id_maps[parent_table].get(old_fk)
if new_fk is not None:
row[fk_field] = new_fk
else:
print(f" WARNING: No mapping for {parent_table}.Id={old_fk}")
row[fk_field] = old_fk
elif old_fk is not None:
row[fk_field] = old_fk
clean_records.append(row)
# Insert into target
new_ids = insert_records(TARGET["url"], TARGET["token"], target_table_ids[name], clean_records)
print(f" Inserted {len(new_ids)} records into target")
# Build ID mapping (old_id → new_id) based on insertion order
old_ids = [r["Id"] for r in source_records]
id_maps[name] = {}
for old_id, new_id in zip(old_ids, new_ids):
id_maps[name][old_id] = new_id
if id_maps[name]:
sample = list(id_maps[name].items())[:3]
print(f" ID mapping sample: {sample}")
# Step 5: Summary
print("\n" + "=" * 60)
print("[5/5] Migration complete!")
print("=" * 60)
print(f"\n Target base ID: {target_base_id}")
print(f"\n Target tables:")
for name, tid in target_table_ids.items():
print(f" {name}: {tid}")
print(f"\n ID mappings:")
for name, mapping in id_maps.items():
print(f" {name}: {len(mapping)} records ({list(mapping.items())[:2]}...)")
print(f"\n Add this to your Gitea secrets:")
print(f" VITE_NOCODB_BASE_ID = {target_base_id}")
print(f"\n The VITE_NOCODB_URL and VITE_NOCODB_TOKEN secrets should point to Cloudron.")
return target_base_id, target_table_ids
if __name__ == "__main__":
try:
run_migration()
except Exception as e:
print(f"\nFATAL: {e}")
sys.exit(1)

View File

@@ -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

View File

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

732
server/package-lock.json generated
View File

@@ -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",

View File

@@ -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
View 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 || '',
};

View File

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

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

@@ -0,0 +1,38 @@
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';
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');
} else {
console.log(' NocoDB: WARNING — not configured');
}
});

95
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,95 @@
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;
}
interface Session {
name: string;
role: string;
createdAt: number;
}
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() });
res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' });
res.json({ ok: true, name: 'Admin', role: 'admin' });
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() });
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' });
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,
});
});
// 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
View File

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

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

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

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

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

View File

@@ -0,0 +1,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;

View File

@@ -0,0 +1,46 @@
import { Router, Request, Response } from 'express';
import { discoverTableIds, fetchAllRecords, createRecord, 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 });
}
});
// 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;

View File

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

View File

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

View File

@@ -0,0 +1,147 @@
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 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 }]),
});
}

View File

@@ -0,0 +1,60 @@
import axios from 'axios';
import { salla } from '../config';
let accessToken = salla.accessToken || null;
let refreshToken = salla.refreshToken || null;
export function getAuthStatus() {
return { connected: !!accessToken, hasRefreshToken: !!refreshToken };
}
export function setTokens(access: string, refresh?: string) {
accessToken = access;
if (refresh) refreshToken = refresh;
}
export async function refreshAccessToken(): Promise<string> {
if (!refreshToken) throw new Error('No refresh token available');
const response = await axios.post('https://accounts.salla.sa/oauth2/token', {
client_id: salla.clientId,
client_secret: salla.clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
accessToken = response.data.access_token;
if (response.data.refresh_token) {
refreshToken = response.data.refresh_token;
}
return accessToken!;
}
export async function callSallaAPI(
endpoint: string,
method: 'GET' | 'POST' = 'GET',
data: unknown = null
): Promise<unknown> {
if (!accessToken) throw new Error('Not authenticated. Visit /auth/login first.');
try {
const response = await axios({
method,
url: `https://api.salla.dev/admin/v2${endpoint}`,
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
data,
});
return response.data;
} catch (err: unknown) {
const axiosErr = err as { response?: { status: number } };
if (axiosErr.response?.status === 401) {
await refreshAccessToken();
return callSallaAPI(endpoint, method, data);
}
throw err;
}
}

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

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

13
server/tsconfig.json Normal file
View File

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

37
setup-nginx.sh Normal file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
# Create nginx config for dashboard.tools.hihala.com
sudo tee /etc/nginx/sites-available/dashboard.tools.hihala.com > /dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name dashboard.tools.hihala.com;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
EOF
sudo ln -sf /etc/nginx/sites-available/dashboard.tools.hihala.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
# Test ACME challenge path
sudo mkdir -p /var/www/html/.well-known/acme-challenge/
echo "test" | sudo tee /var/www/html/.well-known/acme-challenge/test
curl -s http://dashboard.tools.hihala.com/.well-known/acme-challenge/test && echo " - OK"
rm -f /var/www/html/.well-known/acme-challenge/test
# Run certbot
sudo certbot --nginx -d dashboard.tools.hihala.com

View File

@@ -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,306 @@ 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;
}
.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 +1146,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 +1217,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 +1405,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 +1462,7 @@ table tbody tr:hover {
}
.carousel-dots.labeled .carousel-dot.active .dot-label {
color: white;
color: var(--text-inverse);
}
/* Chart Sections */
@@ -1507,11 +1899,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 +1947,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 +2033,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 +2071,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 +2172,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 +2188,7 @@ table tbody tr:hover {
}
.preview-title {
color: #f8fafc;
color: var(--bg);
font-size: 2.5rem;
font-weight: 600;
margin-bottom: 40px;
@@ -1824,11 +2217,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 +2237,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 +2330,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 +2352,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 +2380,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;
}
}

View File

@@ -1,11 +1,16 @@
import React, { useState, useEffect, useCallback, ReactNode } from 'react';
import React, { useState, useEffect, useCallback, 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';
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 } from './services/dataService';
import { fetchSeasons } from './services/seasonsService';
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 +37,43 @@ 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 [data, setData] = useState<MuseumRecord[]>([]);
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 +95,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 +104,62 @@ function App() {
}
}, []);
const loadSeasons = useCallback(async () => {
const s = await fetchSeasons();
setSeasons(s);
}, []);
// Check auth on mount
useEffect(() => {
fetch('/auth/check', { credentials: 'include' })
.then(r => r.json())
.then(d => {
setAuthenticated(d.authenticated);
if (d.authenticated) {
setUserRole(d.role || 'viewer');
setUserName(d.name || '');
loadData();
loadSeasons();
}
})
.catch(() => setAuthenticated(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleLogin = (name: string, role: string) => {
setAuthenticated(true);
setUserName(name);
setUserRole(role);
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 +168,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 +179,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"/>
@@ -116,6 +194,7 @@ function App() {
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 +206,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 +215,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"/>
@@ -163,20 +242,38 @@ function App() {
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
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 +284,20 @@ function App() {
</div>
</nav>
<main>
<Suspense fallback={<LoadingSkeleton />}>
<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} />} />
<Route path="/" element={<Dashboard data={data} seasons={seasons} userRole={userRole} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} />} />}
</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 +306,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>
{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"/>

View File

@@ -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,15 @@ 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 }: ComparisonProps) {
const { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams();
// Get available years from data
const latestYear = useMemo(() => getLatestYear(data), [data]);
const latestYear = useMemo(() => parseInt(getLatestYear(data)), [data]);
const availableYears = useMemo((): number[] => {
const yearsSet = new Set<number>();
data.forEach(r => {
data.forEach((r: MuseumRecord) => {
const d = r.date || (r as any).Date;
if (d) yearsSet.add(new Date(d).getFullYear());
});
@@ -57,7 +80,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 +89,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 +125,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 +190,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,30 +224,46 @@ 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 channels = useMemo(() => getUniqueChannels(data), [data]);
const districts = useMemo(() => getUniqueDistricts(data), [data]);
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
// Year-over-year comparison: same dates, previous year
const ranges = useMemo(() => ({
curr: { start: startDate, end: endDate },
prev: {
start: startDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1),
end: endDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1)
// 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))
};
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 };
}
}), [startDate, endDate]);
}
}
return { curr, prev };
}, [startDate, endDate, preset, seasons]);
const prevData = useMemo(() =>
filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters),
@@ -222,13 +279,13 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const currMetrics = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]);
const hasData = prevData.length > 0 || currData.length > 0;
const resetFilters = () => setFilters({ district: 'all', museum: 'all' });
const resetFilters = () => setFilters({ district: 'all', channel: [], 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 +388,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 +404,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 +412,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
};
// Generate period label - shows year if same year, or "MMM YYMMM 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 +431,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 = {};
const groupedRows: Record<number, MuseumRecord[]> = {};
periodData.forEach(row => {
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 +455,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 +511,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 +532,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 +631,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 +651,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 +662,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 +920,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);

View File

@@ -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 } 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 }: DashboardProps) {
const { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams();
const [pilgrimLoaded, setPilgrimLoaded] = useState(false);
@@ -41,38 +45,57 @@ 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 filteredData = useMemo(() => filterData(data, filters), [data, filters]);
const metrics = useMemo(() => calculateMetrics(filteredData, includeVAT), [filteredData, includeVAT]);
const hasData = filteredData.length > 0;
const resetFilters = () => setFilters(defaultFilters);
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,20 +107,19 @@ 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 channels = useMemo(() => getUniqueChannels(data), [data]);
const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, 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 = data.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;
@@ -106,7 +128,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
// 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 +165,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 +186,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 +209,79 @@ 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]);
// 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]);
// 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 +291,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 +326,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 +352,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 +364,71 @@ 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]);
// 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 +463,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 +493,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 +596,21 @@ 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 filename="visitors-by-event" title={t('dashboard.visitorsByMuseum')} className="chart-container">
<Bar data={museumData.visitors} options={{...baseOptions, indexAxis: 'y'}} />
</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 filename="revenue-by-event" title={t('dashboard.revenueByMuseum')} className="chart-container">
<Bar data={museumData.revenue} options={{...baseOptions, indexAxis: 'y'}} />
</ExportableChart>
</div>
)}
<div className="chart-card half-width">
<ExportableChart filename="quarterly-yoy" title={t('dashboard.quarterlyRevenue')} className="chart-container">
@@ -529,6 +618,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
</ExportableChart>
</div>
<div className="chart-card half-width">
<ExportableChart filename="channel-performance" title={t('dashboard.channelPerformance')} className="chart-container">
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
</ExportableChart>
</div>
<div className="chart-card half-width">
<ExportableChart filename="district-performance" title={t('dashboard.districtPerformance')} className="chart-container">
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
@@ -545,7 +640,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 +655,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 +663,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 +689,28 @@ 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}}}}}} />
<Bar data={museumData.visitors} options={{...baseOptions, indexAxis: 'y'}} />
</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} />
<Bar data={museumData.revenue} options={{...baseOptions, indexAxis: 'y'}} />
</div>
</div>
</div>
)}
<div className="carousel-slide">
<div className="chart-card">
@@ -630,6 +721,15 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
</div>
</div>
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.channelPerformance')}</h2>
<div className="chart-container">
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
</div>
</div>
</div>
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.districtPerformance')}</h2>
@@ -651,7 +751,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 +766,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 +799,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
View File

@@ -0,0 +1,75 @@
import React, { useState } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
interface LoginProps {
onLogin: (name: string, role: 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');
} 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;

234
src/components/Settings.tsx Normal file
View File

@@ -0,0 +1,234 @@
import React, { useState, useEffect } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
import { fetchSeasons, createSeason, updateSeason, deleteSeason } from '../services/seasonsService';
import { fetchUsers, createUser, 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 SettingsProps {
onSeasonsChange: () => void;
}
function Settings({ onSeasonsChange }: 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' });
const loadSeasons = async () => {
setLoading(true);
const data = await fetchSeasons();
setSeasons(data);
setLoading(false);
};
const loadUsers = async () => {
const data = await fetchUsers();
setUsers(data);
};
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>{t('settings.actions')}</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.Id}>
<td>{u.Name}</td>
<td><code>{u.PIN}</code></td>
<td>{u.Role}</td>
<td>
<button className="btn-small btn-danger" onClick={async () => { await deleteUser(u.Id!); await loadUsers(); }}>
{t('settings.delete') || 'Delete'}
</button>
</td>
</tr>
))}
<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>
<button className="btn-small btn-primary" onClick={async () => {
if (!newUser.Name || !newUser.PIN) return;
await createUser(newUser);
setNewUser({ Name: '', PIN: '', Role: 'viewer' });
await loadUsers();
}} disabled={!newUser.Name || !newUser.PIN}>
{t('settings.add')}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
);
}
export default Settings;

View File

@@ -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);
@@ -90,7 +126,7 @@ function Slides({ data }) {
// 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>
@@ -147,7 +183,7 @@ function Slides({ data }) {
${slidesHTML}
<script>
// Chart.js initialization scripts will be here
${generateChartScripts(slides, data, districts, districtMuseumMap)}
${generateChartScripts(slides, data)}
</script>
</body>
</html>`;
@@ -168,8 +204,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
<PreviewMode
slides={slides}
data={data}
districts={districts}
districtMuseumMap={districtMuseumMap}
channels={channels}
museums={museums}
currentSlide={currentPreviewSlide}
setCurrentSlide={setCurrentPreviewSlide}
onExit={() => setPreviewMode(false)}
@@ -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,12 +293,8 @@ ${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">
@@ -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>
@@ -342,39 +374,39 @@ 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(() =>
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);
@@ -383,7 +415,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
});
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,7 +430,7 @@ 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] = [];
@@ -406,7 +438,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
});
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,7 +524,7 @@ 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}`;
@@ -510,9 +542,9 @@ 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);
@@ -534,12 +566,12 @@ 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
});
@@ -551,15 +583,15 @@ function generateChartScripts(slides, data, districts, districtMuseumMap) {
}).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();
@@ -578,10 +610,10 @@ function generateChartConfig(slide, data) {
}
// 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();

View 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>
);
}

View 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;

View File

@@ -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';

View File

@@ -5,13 +5,14 @@ import {
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import Annotation from 'chartjs-plugin-annotation';
// Register ChartJS components once
ChartJS.register(
@@ -20,12 +21,13 @@ ChartJS.register(
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
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';

View File

@@ -1 +0,0 @@
export { useUrlState } from './useUrlState';

View File

@@ -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;

View File

@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom/client';
import { LanguageProvider } from './contexts/LanguageContext';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
<React.StrictMode>
<LanguageProvider>

View File

@@ -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": {
@@ -56,15 +61,16 @@
},
"dashboard": {
"title": "لوحة التحكم",
"subtitle": "تحليلات المتاحف المباشرة من جداول بيانات Google",
"subtitle": "تحليلات الفعاليات من نظام ERP",
"noData": "لا توجد بيانات",
"noDataMessage": "لا توجد سجلات تطابق الفلاتر الحالية. حاول تعديل اختيارك.",
"quarterlyComparison": "مقارنة ربع سنوية: 2024 مقابل 2025",
"revenueTrends": "اتجاهات الإيرادات",
"visitorsByMuseum": "الزوار حسب المتحف",
"revenueByMuseum": "الإيرادات حسب المتحف",
"visitorsByMuseum": "الزوار حسب الفعالية",
"revenueByMuseum": "الإيرادات حسب الفعالية",
"quarterlyRevenue": "الإيرادات الربعية (سنوي)",
"districtPerformance": "أداء المناطق",
"channelPerformance": "أداء القنوات",
"captureRateChart": "نسبة الاستقطاب مقابل المعتمرين"
},
"table": {
@@ -117,7 +123,7 @@
"noData": "لا توجد بيانات لهذه الفترة",
"noDataMessage": "لم يتم العثور على سجلات للنطاق الزمني والفلاتر المحددة.",
"trend": "الاتجاه",
"byMuseum": "حسب المتحف",
"byMuseum": "حسب الفعالية",
"pendingData": "البيانات لم تُنشر بعد"
},
"slides": {
@@ -137,7 +143,7 @@
"showYoY": "إظهار مقارنة سنة بسنة",
"exit": "خروج",
"revenueTrend": "اتجاه الإيرادات",
"byMuseum": "حسب المتحف",
"byMuseum": "حسب الفعالية",
"kpiSummary": "ملخص مؤشرات الأداء",
"yoyComparison": "مقارنة سنوية"
},
@@ -147,8 +153,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"
},

View File

@@ -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": {
@@ -56,15 +61,16 @@
},
"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 +123,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 +143,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 +153,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": "عربي"
},

View File

@@ -1,4 +1,4 @@
// Data source: NocoDB only
// Data source: NocoDB (DailySales populated by server-side ETL, PilgrimStats)
// Offline mode: caches data to localStorage for resilience
import type {
@@ -10,23 +10,59 @@ 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 = process.env.REACT_APP_NOCODB_URL || '';
const NOCODB_TOKEN = process.env.REACT_APP_NOCODB_TOKEN || '';
const NOCODB_URL = import.meta.env.VITE_NOCODB_URL || '';
const NOCODB_TOKEN = import.meta.env.VITE_NOCODB_TOKEN || '';
const NOCODB_BASE_ID = import.meta.env.VITE_NOCODB_BASE_ID || '';
// Table IDs (Cloudron NocoDB)
const NOCODB_TABLES = {
districts: 'mddorhm0boab99m',
museums: 'm1os227987acanj',
dailyStats: 'mbp0qntf9h6qth1',
pilgrimStats: 'mi90dy6w7mt0vp0'
};
// Table IDs discovered dynamically from NocoDB meta API
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');
const res = await fetchWithRetry(
`${NOCODB_URL}/api/v2/meta/bases/${NOCODB_BASE_ID}/tables`,
{ headers: { 'xc-token': NOCODB_TOKEN } }
);
const json = await res.json();
const tables: Record<string, string> = {};
for (const t of json.list) {
tables[t.title] = t.id;
}
discoveredTables = tables;
return tables;
}
async function fetchNocoDBTable<T>(tableId: string, limit: number = 1000): Promise<T[]> {
let allRecords: T[] = [];
let offset = 0;
while (true) {
const response = await fetchWithRetry(
`${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`,
{ headers: { 'xc-token': NOCODB_TOKEN } }
);
const json = await response.json();
const records: T[] = json.list || [];
allRecords = allRecords.concat(records);
if (records.length < limit) break;
offset += limit;
}
return allRecords;
}
// Cache keys
const CACHE_KEY = 'hihala_data_cache';
@@ -39,19 +75,22 @@ 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 url = `${NOCODB_URL}/api/v2/tables/${NOCODB_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 tables = await discoverTableIds();
if (!tables['PilgrimStats']) {
console.warn('PilgrimStats table not found, using defaults');
return umrahData;
}
const url = `${NOCODB_URL}/api/v2/tables/${tables['PilgrimStats']}/records?limit=50`;
const res = await fetchWithRetry(url, { headers: { 'xc-token': NOCODB_TOKEN } });
const json = await res.json();
const 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) {
@@ -60,7 +99,6 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
}
}
// Update the global umrahData
umrahData = data;
console.log('PilgrimStats loaded from NocoDB:', data);
return data;
@@ -70,6 +108,42 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
}
}
// ============================================
// NocoDB DailySales Fetching
// ============================================
async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
console.log('Fetching from NocoDB DailySales...');
const tables = await discoverTableIds();
if (!tables['DailySales']) throw new Error("NocoDB table 'DailySales' not found — run ETL sync first");
const rows = await fetchNocoDBTable<NocoDBDailySale>(tables['DailySales']);
const data: MuseumRecord[] = rows.map(row => {
const date = row.Date;
const year = date ? date.substring(0, 4) : '';
const month = date ? parseInt(date.substring(5, 7)) : 0;
const quarter = month <= 3 ? '1' : month <= 6 ? '2' : month <= 9 ? '3' : '4';
return {
date,
district: row.District,
museum_name: row.MuseumName,
channel: row.Channel,
visits: row.Visits,
tickets: row.Tickets,
revenue_gross: row.GrossRevenue,
revenue_net: row.NetRevenue,
year,
quarter,
};
}).filter(r => r.date && r.museum_name);
console.log(`Loaded ${data.length} rows from NocoDB DailySales`);
return data;
}
// ============================================
// Offline Cache Functions
// ============================================
@@ -131,90 +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...');
// Fetch all three tables in parallel
const [districts, museums, dailyStats] = await Promise.all([
fetchNocoDBTable<NocoDBDistrict>(NOCODB_TABLES.districts),
fetchNocoDBTable<NocoDBMuseum>(NOCODB_TABLES.museums),
fetchNocoDBTable<NocoDBDailyStat>(NOCODB_TABLES.dailyStats)
]);
// Build lookup maps
const districtMap: Record<number, string> = {};
districts.forEach(d => { districtMap[d.Id] = d.Name; });
const museumMap: Record<number, MuseumMapEntry> = {};
museums.forEach(m => {
museumMap[m.Id] = {
code: m.Code,
name: m.Name,
district: districtMap[m['nc_epk____Districts_id']] || 'Unknown'
};
});
// Join data into flat structure
const data: MuseumRecord[] = dailyStats.map(row => {
const museum = museumMap[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';
}
// ============================================
@@ -222,43 +222,44 @@ async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
// ============================================
export async function fetchData(): Promise<FetchResult> {
// Check if NocoDB is configured
if (!NOCODB_URL || !NOCODB_TOKEN) {
// Try cache
if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) {
const cached = loadFromCache();
if (cached) {
console.warn('NocoDB not configured, using cached data');
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp };
}
throw new Error('NocoDB not configured and no cached data available. Set REACT_APP_NOCODB_URL and REACT_APP_NOCODB_TOKEN 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
saveToCache(data);
// 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) {
throw new Error('NocoDB not configured');
if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) {
throw new DataError('NocoDB not configured', 'config');
}
const data = await fetchFromNocoDB();
@@ -274,7 +275,8 @@ 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;
});
@@ -290,14 +292,15 @@ export function filterDataByDateRange(
if (!row.date) return false;
if (row.date < startDate || row.date > endDate) return false;
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
if (filters.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;
@@ -366,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;
});
@@ -379,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;
}
@@ -412,25 +415,30 @@ export function getUniqueDistricts(data: MuseumRecord[]): string[] {
return [...new Set(data.map(r => r.district).filter(Boolean))].sort();
}
export function getDistrictMuseumMap(data: MuseumRecord[]): DistrictMuseumMap {
const map: Record<string, Set<string>> = {};
export function groupByDistrict(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const grouped: Record<string, GroupedData> = {};
data.forEach(row => {
if (!row.district || !row.museum_name) return;
if (!map[row.district]) map[row.district] = new Set();
map[row.district].add(row.museum_name);
if (!row.district) return;
if (!grouped[row.district]) grouped[row.district] = { revenue: 0, visitors: 0, tickets: 0 };
grouped[row.district].revenue += row[revenueField] || 0;
grouped[row.district].visitors += row.visits || 0;
grouped[row.district].tickets += row.tickets || 0;
});
const result: DistrictMuseumMap = {};
Object.keys(map).forEach(d => {
result[d] = [...map[d]].sort();
});
return result;
return grouped;
}
export function getMuseumsForDistrict(districtMuseumMap: DistrictMuseumMap, district: string): string[] {
if (district === 'all') {
return Object.values(districtMuseumMap).flat().sort();
export function getMuseumsForDistrict(data: MuseumRecord[], district: string): string[] {
if (district === 'all') return getUniqueMuseums(data);
return [...new Set(data.filter(r => r.district === district).map(r => r.museum_name).filter(Boolean))].sort();
}
return districtMuseumMap[district] || [];
export function getUniqueChannels(data: MuseumRecord[]): string[] {
return [...new Set(data.map(r => r.channel).filter(Boolean))].sort();
}
export function getUniqueMuseums(data: MuseumRecord[]): string[] {
return [...new Set(data.map(r => r.museum_name).filter(Boolean))].sort();
}
export function getLatestYear(data: MuseumRecord[]): string {

View File

@@ -1,161 +0,0 @@
// Salla Integration Service
// Connects to the local Salla backend server
const SALLA_SERVER_URL = process.env.REACT_APP_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;
}

View 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');
}

View File

@@ -0,0 +1,31 @@
export interface User {
Id?: number;
Name: string;
PIN: string;
Role: string;
}
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 deleteUser(id: number): Promise<void> {
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete user');
}

View File

@@ -1,11 +0,0 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
app.use(
'/api',
createProxyMiddleware({
target: 'http://localhost:8090',
changeOrigin: true,
})
);
};

View File

@@ -2,14 +2,13 @@
export interface MuseumRecord {
date: string;
museum_code: string;
museum_name: string;
district: string;
museum_name: string;
channel: string;
visits: number;
tickets: number;
revenue_gross: number;
revenue_net: number;
revenue_incl_tax: number; // Legacy field
year: string;
quarter: string;
}
@@ -24,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,6 +130,8 @@ export interface ChartData {
// Component props
export interface DashboardProps {
data: MuseumRecord[];
seasons: Season[];
userRole: string;
showDataLabels: boolean;
setShowDataLabels: (value: boolean) => void;
includeVAT: boolean;
@@ -105,6 +140,7 @@ export interface DashboardProps {
export interface ComparisonProps {
data: MuseumRecord[];
seasons: Season[];
showDataLabels: boolean;
setShowDataLabels: (value: boolean) => void;
includeVAT: boolean;
@@ -139,27 +175,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;
'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;
'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
View File

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

45
start-dev.sh Executable file
View File

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

View File

@@ -6,17 +6,16 @@
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": false,
"noImplicitAny": false,
"strictNullChecks": false,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src"]
}

38
vite.config.ts Normal file
View File

@@ -0,0 +1,38 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/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,
},
},
},
build: {
outDir: 'build',
},
});