Compare commits

...

25 Commits

Author SHA1 Message Date
fahed
35771595dc fix: correct NocoDB column creation endpoint and add startup delay
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 9s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:14:56 +03:00
fahed
e09c3f8190 fix: auto-create AllowedMuseums/AllowedChannels fields in NocoDB on startup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:08:34 +03:00
fahed
e41cff831b feat: per-user museum and channel access control
- PATCH /api/users/:id route to update user permissions
- Auth session stores and returns allowedMuseums/allowedChannels
- User type gains AllowedMuseums/AllowedChannels (JSON string fields)
- parseAllowed() with fail-closed semantics (empty string → null → no data)
- Dashboard/Comparison apply permission base filter before user filters
- Filter dropdowns (museums, channels, years, districts) derived from
  permission-filtered data — restricted users only see their allowed options
- Settings UserRow component with inline checkbox pickers for access config
- Access badges in users table showing current restriction summary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:03:19 +03:00
fahed
d4ce5b6478 docs: update access control spec — fail-closed on corrupted permissions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 17:38:26 +03:00
fahed
aa143dfacd docs: add per-user museum & channel access control spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 17:20:54 +03:00
fahed
f615407bba fix: default district performance chart to pie
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:19:47 +03:00
fahed
47122b5445 feat: add bar/pie and #/% toggles to district performance chart
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:17:58 +03:00
fahed
e373363e75 feat: add % toggle to revenue/visitors by event chart
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:16:15 +03:00
fahed
0a80103cfc feat: add % toggle to channel performance, default events and channel to pie chart
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:14:17 +03:00
fahed
ebdf90c8ab fix: use correct translation keys for visitors/revenue/bar/pie toggles
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:11:47 +03:00
fahed
cb4fb6071a feat: merge event charts with metric toggle, add pie chart option to events and channels
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:09:37 +03:00
fahed
e70d9b92c6 chore: consolidate start scripts — replace start.sh with start-dev.sh content
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 11:55:19 +03:00
fahed
418eb2c17c ci: remove sudo restart step — restart manually after deploy
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 11:16:24 +03:00
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
29 changed files with 2077 additions and 166 deletions

View File

@@ -8,11 +8,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
# --- Frontend ---
- name: Build frontend
env:
VITE_NOCODB_URL: ${{ secrets.VITE_NOCODB_URL }}
@@ -21,7 +22,41 @@ jobs:
run: |
npm ci
npm run build
- name: Deploy to server
- name: Deploy frontend
run: rsync -a --delete build/ /opt/apps/hihala-dashboard/build/
# --- Backend ---
- name: Deploy server
run: rsync -a --delete --exclude='.env' --exclude='node_modules' server/ /opt/apps/hihala-dashboard/server/
- name: Install server dependencies
run: cd /opt/apps/hihala-dashboard/server && npm ci
- name: Write server .env
env:
ADMIN_PIN: ${{ secrets.ADMIN_PIN }}
NOCODB_URL: ${{ secrets.VITE_NOCODB_URL }}
NOCODB_TOKEN: ${{ secrets.VITE_NOCODB_TOKEN }}
NOCODB_BASE_ID: ${{ secrets.VITE_NOCODB_BASE_ID }}
ERP_API_URL: ${{ secrets.ERP_API_URL }}
ERP_API_CODE: ${{ secrets.ERP_API_CODE }}
ERP_USERNAME: ${{ secrets.ERP_USERNAME }}
ERP_PASSWORD: ${{ secrets.ERP_PASSWORD }}
ETL_SECRET: ${{ secrets.ETL_SECRET }}
run: |
rsync -a --delete build/ /opt/apps/hihala-dashboard/build/
cat > /opt/apps/hihala-dashboard/server/.env << EOF
NODE_ENV=production
SERVER_PORT=3002
ADMIN_PIN=${ADMIN_PIN}
NOCODB_URL=${NOCODB_URL}
NOCODB_TOKEN=${NOCODB_TOKEN}
NOCODB_BASE_ID=${NOCODB_BASE_ID}
ERP_API_URL=${ERP_API_URL}
ERP_API_CODE=${ERP_API_CODE}
ERP_USERNAME=${ERP_USERNAME}
ERP_PASSWORD=${ERP_PASSWORD}
ETL_SECRET=${ETL_SECRET}
EOF
# Restart manually: sudo systemctl restart hihala-dashboard.service

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

View File

@@ -0,0 +1,190 @@
# Per-User Museum & Channel Access Control
**Date:** 2026-04-08
**Status:** Approved
## Overview
Allow admins to restrict client-facing users to specific museums (events) and channels. When a restricted user logs in, the dashboard only shows data and filter options for their allowed scope — enforced at the UI and data layers (client-side). The enforcement is intentional client-side filtering; this is an internal analytics tool and not a security boundary against a determined attacker.
---
## Data Model
### User type (`src/types/index.ts`)
Add two fields to the existing `User` interface (keeping PascalCase to match NocoDB conventions used throughout the codebase):
```typescript
interface User {
Id?: number;
Name: string;
PIN: string;
Role: 'admin' | 'viewer';
AllowedMuseums: string; // JSON-serialized string array, e.g. '["Museum A","Museum B"]'
AllowedChannels: string; // JSON-serialized string array
}
```
Parsed into a runtime shape used in app state:
```typescript
interface ParsedUser {
id: number;
name: string;
role: 'admin' | 'viewer';
allowedMuseums: string[] | null; // [] = unrestricted, null = parse error (show nothing)
allowedChannels: string[] | null; // [] = unrestricted, null = parse error (show nothing)
}
```
**Convention:** `[]` = full access (admins), `string[]` = restricted to list, `null` = corrupted value (fail-closed: no data shown). Existing users require no migration (missing fields parsed as `[]`).
### NocoDB Users table
Add two new fields:
- `AllowedMuseums` — Text field, stores JSON string (e.g. `'["Museum A"]'`)
- `AllowedChannels` — Text field, stores JSON string
Both default to `"[]"` (unrestricted).
---
## Components & Changes
### 1. `src/services/usersService.ts`
- Update `fetchUsers()` to parse `AllowedMuseums` and `AllowedChannels` JSON strings into `string[]`; on parse error return `null` (fail-closed — no data shown to the user)
- Update `createUser()` to serialize `allowedMuseums`/`allowedChannels` arrays as JSON strings
- **Add `updateUser(id, fields)`** — new function required (see Prerequisites below)
### 2. `server/src/routes/users.ts` + `nocodbClient.ts`
**Prerequisite:** `updateUser` does not exist yet. Required additions:
- `nocodbClient.updateRecord(tableId, rowId, fields)` — calls NocoDB `PATCH /api/v2/tables/{tableId}/records`
- `PUT /api/users/:id` route on the server — validates fields and calls `updateRecord`
- `updateUser(id, fields)` in `usersService.ts` — calls the new route
### 3. `server/src/routes/auth.ts`
The session object currently stores only `{ name, role, createdAt }`. Extend it to also persist `allowedMuseums` and `allowedChannels` at login time:
```typescript
// On POST /auth/login — after matching user by PIN:
session.allowedMuseums = parsedUser.allowedMuseums;
session.allowedChannels = parsedUser.allowedChannels;
// On GET /auth/check — return alongside existing fields:
res.json({
authenticated: true,
name: session.name,
role: session.role,
allowedMuseums: session.allowedMuseums ?? [],
allowedChannels: session.allowedChannels ?? [],
});
```
This ensures page reload restores the correct access scope without re-fetching NocoDB.
### 4. `src/App.tsx`
- Store `allowedMuseums` and `allowedChannels` in app state (alongside `userRole`, `userName`)
- Set them from both the `/auth/login` response and the `/auth/check` response
- Pass them as props to **both** `Dashboard` and `Comparison` components
- Also pass `allMuseums` and `allChannels` (unique values extracted from raw `data`) as props to `Settings`
### 5. `src/components/Settings.tsx`
Accept two new props: `allMuseums: string[]` and `allChannels: string[]`.
In the add/edit user form, add two checkbox picker sections (hidden for admin users):
- **Allowed Events** — checkbox list from `allMuseums`
- **Allowed Channels** — checkbox list from `allChannels`
UI behavior:
- Empty selection = full access, shown as `"All access"` label
- Partial selection shown as `"N events"` / `"N channels"` badge
- Admin users: section hidden, shown as `"Full access (admin)"` static label
### 6. `src/components/Dashboard.tsx`
Accept two new props: `allowedMuseums: string[]` and `allowedChannels: string[]`.
Two enforcement layers applied before any render:
**Layer 1 — Filter options restricted:**
```typescript
const visibleMuseums = allowedMuseums.length > 0
? availableMuseums.filter(m => allowedMuseums.includes(m))
: availableMuseums;
const visibleChannels = allowedChannels.length > 0
? channels.filter(c => allowedChannels.includes(c))
: channels;
```
**Layer 2 — Data filtered at base:**
```typescript
const permissionFilteredData = useMemo(() => {
// null = corrupted stored value → show nothing (fail-closed)
if (allowedMuseums === null || allowedChannels === null) return [];
let d = data;
if (allowedMuseums.length > 0)
d = d.filter(r => allowedMuseums.includes(r.museum_name));
if (allowedChannels.length > 0)
d = d.filter(r => allowedChannels.includes(r.channel));
return d;
}, [data, allowedMuseums, allowedChannels]);
```
Replace `data` with `permissionFilteredData` as the base for all subsequent filtering and chart rendering.
### 7. `src/components/Comparison.tsx`
Apply the same Layer 2 base filter to `Comparison` (same props: `allowedMuseums`, `allowedChannels`). Restricted users must not see unfiltered data on the comparison page.
---
## Auth Flow
```
User enters PIN
→ POST /auth/login → server matches user, stores allowedMuseums/allowedChannels in session
→ App stores them in state, passes to Dashboard + Comparison
Page reload
→ GET /auth/check → server returns allowedMuseums/allowedChannels from session
→ App restores state correctly
```
---
## Edge Cases
| Scenario | Behavior |
|----------|----------|
| User has 1 allowed museum | Filter dropdown shows with 1 option only |
| User has all museums allowed (empty array) | No change from today |
| Admin user | `allowedMuseums: []`, `allowedChannels: []` — full access |
| URL param references disallowed museum | Base filter removes it silently |
| New museum added to data, not in user's list | Not visible to restricted user |
| JSON parse error on stored value | `null` returned → no data shown (fail-closed) |
| Page reload | Session restores access lists from server |
---
## Prerequisites (must be built first)
1. `nocodbClient.updateRecord()` method
2. `PUT /api/users/:id` server route
3. `updateUser()` in `usersService.ts`
---
## Out of Scope
- District-level access control
- Role-based permission templates
- Audit logging of access
- Server-side data API enforcement

10
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^13.5.0",
"chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"chartjs-plugin-datalabels": "^2.2.0",
"html2canvas": "^1.4.1",
"jszip": "^3.10.1",
@@ -1571,6 +1572,15 @@
"pnpm": ">=8"
}
},
"node_modules/chartjs-plugin-annotation": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=4.0.0"
}
},
"node_modules/chartjs-plugin-datalabels": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",

View File

@@ -8,6 +8,7 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^13.5.0",
"chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"chartjs-plugin-datalabels": "^2.2.0",
"html2canvas": "^1.4.1",
"jszip": "^3.10.1",

View File

@@ -14,3 +14,7 @@ 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

@@ -9,11 +9,13 @@
"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",
@@ -483,6 +485,16 @@
"@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",
@@ -730,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",

View File

@@ -10,14 +10,16 @@
},
"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",
"tsx": "^4.19.0",
"typescript": "^5.9.3"
}
}

View File

@@ -33,3 +33,7 @@ export const nocodb = {
export const etl = {
secret: process.env.ETL_SECRET || '',
};
export const auth = {
adminPin: process.env.ADMIN_PIN || '',
};

View File

@@ -1,16 +1,25 @@
import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import { server, erp, nocodb } from './config';
import authRoutes from './routes/auth';
import erpRoutes from './routes/erp';
import etlRoutes from './routes/etl';
import seasonsRoutes from './routes/seasons';
import usersRoutes from './routes/users';
import { discoverTableIds, ensureTableFields } from './services/nocodbClient';
const app = express();
app.use(cors());
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}`);
@@ -24,6 +33,18 @@ app.listen(server.port, () => {
if (nocodb.url && nocodb.token) {
console.log(' NocoDB: configured');
console.log(' POST /api/etl/sync?mode=full|incremental');
// Ensure Users table has permission fields
// Delay slightly to ensure NocoDB is fully ready before migrating
setTimeout(() => {
discoverTableIds().then(tables => {
if (tables['Users']) {
return ensureTableFields(tables['Users'], [
{ title: 'AllowedMuseums', uidt: 'LongText' },
{ title: 'AllowedChannels', uidt: 'LongText' },
]);
}
}).catch(err => console.warn(' NocoDB migration warning:', err.message));
}, 3000);
} else {
console.log(' NocoDB: WARNING — not configured');
}

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

@@ -0,0 +1,113 @@
import { Router, Request, Response } from 'express';
import crypto from 'crypto';
import { auth } from '../config';
import { discoverTableIds, fetchAllRecords } from '../services/nocodbClient';
const router = Router();
interface UserRecord {
Id: number;
Name: string;
PIN: string;
Role: string;
AllowedMuseums?: string;
AllowedChannels?: string;
}
interface Session {
name: string;
role: string;
createdAt: number;
allowedMuseums: string;
allowedChannels: string;
}
const sessions = new Map<string, Session>();
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
function generateSessionId(): string {
return crypto.randomBytes(32).toString('hex');
}
function getSession(sessionId: string): Session | null {
const session = sessions.get(sessionId);
if (!session) return null;
if (Date.now() - session.createdAt > SESSION_MAX_AGE) {
sessions.delete(sessionId);
return null;
}
return session;
}
// POST /auth/login
router.post('/login', async (req: Request, res: Response) => {
const { pin } = req.body;
if (!pin) {
res.status(400).json({ error: 'PIN required' });
return;
}
// Check super admin PIN from env first
if (auth.adminPin && pin === auth.adminPin) {
const sessionId = generateSessionId();
sessions.set(sessionId, { name: 'Admin', role: 'admin', createdAt: Date.now(), allowedMuseums: '[]', allowedChannels: '[]' });
res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' });
res.json({ ok: true, name: 'Admin', role: 'admin', allowedMuseums: '[]', allowedChannels: '[]' });
return;
}
// Check NocoDB Users table
try {
const tables = await discoverTableIds();
if (tables['Users']) {
const users = await fetchAllRecords<UserRecord>(tables['Users']);
const user = users.find(u => u.PIN === pin);
if (user) {
const sessionId = generateSessionId();
sessions.set(sessionId, {
name: user.Name,
role: user.Role || 'viewer',
createdAt: Date.now(),
allowedMuseums: user.AllowedMuseums || '[]',
allowedChannels: user.AllowedChannels || '[]',
});
res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' });
res.json({
ok: true,
name: user.Name,
role: user.Role || 'viewer',
allowedMuseums: user.AllowedMuseums || '[]',
allowedChannels: user.AllowedChannels || '[]',
});
return;
}
}
} catch (err) {
console.warn('Failed to check Users table:', (err as Error).message);
}
res.status(401).json({ error: 'Invalid PIN' });
});
// GET /auth/check
router.get('/check', (req: Request, res: Response) => {
const sessionId = req.cookies?.hihala_session;
const session = sessionId ? getSession(sessionId) : null;
res.json({
authenticated: !!session,
name: session?.name || null,
role: session?.role || null,
allowedMuseums: session?.allowedMuseums ?? '[]',
allowedChannels: session?.allowedChannels ?? '[]',
});
});
// POST /auth/logout
router.post('/logout', (req: Request, res: Response) => {
const sessionId = req.cookies?.hihala_session;
if (sessionId) sessions.delete(sessionId);
res.clearCookie('hihala_session', { path: '/' });
res.json({ ok: true });
});
export default router;

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,57 @@
import { Router, Request, Response } from 'express';
import { discoverTableIds, fetchAllRecords, createRecord, updateRecord, deleteRecord } from '../services/nocodbClient';
const router = Router();
async function getUsersTableId(): Promise<string> {
const tables = await discoverTableIds();
const id = tables['Users'];
if (!id) throw new Error("NocoDB table 'Users' not found");
return id;
}
// GET /api/users
router.get('/', async (_req: Request, res: Response) => {
try {
const tableId = await getUsersTableId();
const records = await fetchAllRecords(tableId);
res.json(records);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/users
router.post('/', async (req: Request, res: Response) => {
try {
const tableId = await getUsersTableId();
const result = await createRecord(tableId, req.body);
res.json(result);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// PATCH /api/users/:id
router.patch('/:id', async (req: Request, res: Response) => {
try {
const tableId = await getUsersTableId();
await updateRecord(tableId, parseInt(req.params.id), req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// DELETE /api/users/:id
router.delete('/:id', async (req: Request, res: Response) => {
try {
const tableId = await getUsersTableId();
await deleteRecord(tableId, parseInt(req.params.id));
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
export default router;

View File

@@ -1,5 +1,4 @@
import { nocodb } from '../config';
import type { AggregatedRecord } from '../types';
let discoveredTables: Record<string, string> | null = null;
@@ -91,8 +90,7 @@ export async function deleteAllRows(tableId: string): Promise<number> {
return deleted;
}
export async function insertRecords(tableId: string, records: AggregatedRecord[]): Promise<number> {
// NocoDB bulk insert accepts max 100 records at a time
export async function insertRecords<T extends Record<string, unknown>>(tableId: string, records: T[]): Promise<number> {
const batchSize = 100;
let inserted = 0;
@@ -107,3 +105,60 @@ export async function insertRecords(tableId: string, records: AggregatedRecord[]
return inserted;
}
// Generic CRUD helpers
export async function fetchAllRecords<T>(tableId: string): Promise<T[]> {
let all: T[] = [];
let offset = 0;
while (true) {
const json = await fetchJson(
`${nocodb.url}/api/v2/tables/${tableId}/records?limit=1000&offset=${offset}`
) as { list: T[] };
all = all.concat(json.list);
if (json.list.length < 1000) break;
offset += 1000;
}
return all;
}
export async function ensureTableFields(tableId: string, fields: Array<{ title: string; uidt: string }>): Promise<void> {
// GET /api/v2/meta/tables/{id} returns table with columns array
const table = await fetchJson(
`${nocodb.url}/api/v2/meta/tables/${tableId}`
) as { columns: Array<{ title: string }> };
const existing = new Set((table.columns || []).map(f => f.title));
for (const field of fields) {
if (!existing.has(field.title)) {
await fetchJson(`${nocodb.url}/api/v2/meta/tables/${tableId}/columns`, {
method: 'POST',
body: JSON.stringify(field),
});
console.log(` NocoDB: created field '${field.title}' on table ${tableId}`);
}
}
}
export async function createRecord<T extends Record<string, unknown>>(tableId: string, record: T): Promise<T> {
return await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
method: 'POST',
body: JSON.stringify(record),
}) as T;
}
export async function updateRecord<T extends Record<string, unknown>>(tableId: string, id: number, record: T): Promise<T> {
return await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
method: 'PATCH',
body: JSON.stringify({ Id: id, ...record }),
}) as T;
}
export async function deleteRecord(tableId: string, id: number): Promise<void> {
await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
method: 'DELETE',
body: JSON.stringify([{ Id: id }]),
});
}

View File

@@ -851,6 +851,227 @@ table tbody tr:hover {
accent-color: var(--accent);
}
/* Login page */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg);
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 48px 40px;
width: 100%;
max-width: 380px;
text-align: center;
}
.login-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 8px;
}
.login-brand h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
}
.login-subtitle {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 32px;
}
.login-card form {
display: flex;
flex-direction: column;
gap: 12px;
}
.login-card input {
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 1.125rem;
text-align: center;
letter-spacing: 0.15em;
background: var(--bg);
color: var(--text-primary);
}
.login-card input:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent);
}
.login-card button {
padding: 14px;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
background: var(--accent);
color: white;
cursor: pointer;
transition: opacity 150ms ease;
}
.login-card button:hover:not(:disabled) {
opacity: 0.9;
}
.login-card button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login-error {
color: var(--danger, #dc2626);
font-size: 0.8125rem;
}
.settings-link {
text-align: center;
padding: 32px 0 16px;
}
.settings-link a {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
font-size: 0.8125rem;
text-decoration: none;
opacity: 0.6;
transition: opacity 150ms ease;
}
.settings-link a:hover {
opacity: 1;
}
/* Settings page */
.settings-page {
padding: 32px;
max-width: 1400px;
margin: 0 auto;
}
.settings-hint {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 16px;
}
.season-chip {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 600;
border: 1px solid;
}
.season-edit-name {
display: flex;
gap: 6px;
align-items: center;
}
.season-edit-name input[type="text"] {
flex: 1;
min-width: 80px;
}
.season-edit-name input[type="color"] {
width: 32px;
height: 32px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
}
.season-actions {
display: flex;
gap: 6px;
}
.access-badge {
display: inline-block;
font-size: 0.7rem;
padding: 2px 7px;
border-radius: 10px;
background: var(--surface-raised, #f0f0f0);
color: var(--text-secondary, #666);
margin-right: 4px;
}
.access-badge--full {
background: #d1fae5;
color: #065f46;
}
.btn-small {
padding: 4px 10px;
font-size: 0.75rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-primary);
cursor: pointer;
}
.btn-small:hover {
background: var(--hover);
}
.btn-small.btn-primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.btn-small.btn-danger {
color: var(--danger, #dc2626);
border-color: var(--danger, #dc2626);
}
.btn-small.btn-danger:hover {
background: var(--danger, #dc2626);
color: white;
}
tr.add-row td {
border-top: 2px dashed var(--border);
padding-top: 12px;
}
tr.editing td {
background: var(--hover);
}
.settings-page input[type="text"],
.settings-page input[type="number"],
.settings-page input[type="date"],
.settings-page select {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.8125rem;
background: var(--surface);
color: var(--text-primary);
}
.period-display {
background: var(--bg);
padding: 16px;

View File

@@ -1,12 +1,16 @@
import React, { useState, useEffect, useCallback, ReactNode, lazy, Suspense } from 'react';
import React, { useState, useEffect, useCallback, useMemo, ReactNode, lazy, Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
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 { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService';
import { fetchSeasons } from './services/seasonsService';
import { parseAllowed } from './services/usersService';
import { useLanguage } from './contexts/LanguageContext';
import type { MuseumRecord, CacheStatus, DataErrorType } from './types';
import type { MuseumRecord, Season, CacheStatus, DataErrorType } from './types';
import { DataError } from './types';
import './App.css';
@@ -34,7 +38,14 @@ interface DataSource {
function App() {
const { t, dir, switchLanguage } = useLanguage();
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [userRole, setUserRole] = useState<string>('viewer');
const [userName, setUserName] = useState<string>('');
const [allowedMuseums, setAllowedMuseums] = useState<string[] | null>([]);
const [allowedChannels, setAllowedChannels] = useState<string[] | null>([]);
const [data, setData] = useState<MuseumRecord[]>([]);
const allMuseumsList = useMemo(() => getUniqueMuseums(data), [data]);
const allChannelsList = useMemo(() => getUniqueChannels(data), [data]);
const [loading, setLoading] = useState<boolean>(true);
const [refreshing, setRefreshing] = useState<boolean>(false);
const [error, setError] = useState<{ message: string; type: DataErrorType } | null>(null);
@@ -43,6 +54,7 @@ function App() {
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';
@@ -97,15 +109,62 @@ function App() {
}
}, []);
const loadSeasons = useCallback(async () => {
const s = await fetchSeasons();
setSeasons(s);
}, []);
// Check auth on mount
useEffect(() => {
loadData();
fetch('/auth/check', { credentials: 'include' })
.then(r => r.json())
.then(d => {
setAuthenticated(d.authenticated);
if (d.authenticated) {
setUserRole(d.role || 'viewer');
setUserName(d.name || '');
setAllowedMuseums(parseAllowed(d.allowedMuseums));
setAllowedChannels(parseAllowed(d.allowedChannels));
loadData();
loadSeasons();
}
})
.catch(() => setAuthenticated(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleLogin = (name: string, role: string, rawMuseums: string, rawChannels: string) => {
setAuthenticated(true);
setUserName(name);
setUserRole(role);
setAllowedMuseums(parseAllowed(rawMuseums));
setAllowedChannels(parseAllowed(rawChannels));
loadData();
loadSeasons();
};
const handleRefresh = () => {
loadData(true);
};
// Auth check loading
if (authenticated === null) {
return (
<div className="app" dir={dir}>
<LoadingSkeleton />
</div>
);
}
// Not authenticated — show login
if (!authenticated) {
return (
<div className="app" dir={dir}>
<Login onLogin={handleLogin} />
</div>
);
}
if (loading) {
return (
<div className="app" dir={dir}>
@@ -237,8 +296,9 @@ function App() {
<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="/" element={<Dashboard data={data} seasons={seasons} userRole={userRole} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
</Routes>
</Suspense>
</main>
@@ -262,6 +322,14 @@ function App() {
</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}

View File

@@ -17,7 +17,7 @@ import {
getMuseumsForDistrict,
getLatestYear
} from '../services/dataService';
import type { MuseumRecord, ComparisonProps, DateRangeFilters } from '../types';
import type { MuseumRecord, ComparisonProps, DateRangeFilters, Season } from '../types';
interface PresetDateRange {
start: string;
@@ -63,15 +63,24 @@ const generatePresetDates = (year: number): PresetDates => ({
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
});
function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: ComparisonProps) {
function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: ComparisonProps) {
const { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams();
// Permission base filter — applied before any user-facing filter
const permissionFilteredData = useMemo(() => {
if (allowedMuseums === null || allowedChannels === null) return [];
let d = data;
if (allowedMuseums.length > 0) d = d.filter(r => allowedMuseums.includes(r.museum_name));
if (allowedChannels.length > 0) d = d.filter(r => allowedChannels.includes(r.channel));
return d;
}, [data, allowedMuseums, allowedChannels]);
// Get available years from data
const latestYear = useMemo(() => parseInt(getLatestYear(data)), [data]);
const latestYear = useMemo(() => parseInt(getLatestYear(permissionFilteredData)), [permissionFilteredData]);
const availableYears = useMemo((): number[] => {
const yearsSet = new Set<number>();
data.forEach((r: MuseumRecord) => {
permissionFilteredData.forEach((r: MuseumRecord) => {
const d = r.date || (r as any).Date;
if (d) yearsSet.add(new Date(d).getFullYear());
});
@@ -95,7 +104,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
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');
@@ -105,7 +117,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
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',
@@ -123,7 +138,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
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);
}
@@ -136,7 +151,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
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);
}
@@ -145,7 +160,15 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
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);
@@ -222,27 +245,43 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
}, [revenueField]);
// Dynamic lists from data
const channels = useMemo(() => getUniqueChannels(data), [data]);
const districts = useMemo(() => getUniqueDistricts(data), [data]);
const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
const channels = useMemo(() => getUniqueChannels(permissionFilteredData), [permissionFilteredData]);
const districts = useMemo(() => getUniqueDistricts(permissionFilteredData), [permissionFilteredData]);
const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]);
// Year-over-year comparison: same dates, previous year
const ranges = useMemo(() => ({
curr: { start: startDate, end: endDate },
prev: {
// 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))
}
}), [startDate, endDate]);
};
const prevData = useMemo(() =>
filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters),
[data, ranges.prev, filters]
if (preset.startsWith('season-')) {
const seasonId = parseInt(preset.replace('season-', ''));
const currentSeason = seasons.find(s => s.Id === seasonId);
if (currentSeason) {
const prevSeason = seasons.find(
s => s.Name === currentSeason.Name && s.HijriYear === currentSeason.HijriYear - 1
);
if (prevSeason) {
prev = { start: prevSeason.StartDate, end: prevSeason.EndDate };
}
}
}
return { curr, prev };
}, [startDate, endDate, preset, seasons]);
const prevData = useMemo(() =>
filterDataByDateRange(permissionFilteredData, ranges.prev.start, ranges.prev.end, filters),
[permissionFilteredData, ranges.prev, filters]
);
const currData = useMemo(() =>
filterDataByDateRange(data, ranges.curr.start, ranges.curr.end, filters),
[data, ranges.curr, filters]
const currData = useMemo(() =>
filterDataByDateRange(permissionFilteredData, ranges.curr.start, ranges.curr.end, filters),
[permissionFilteredData, ranges.curr, filters]
);
const prevMetrics = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]);
@@ -502,11 +541,53 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
}, [data, prevData, currData, ranges, chartMetric, getMetricValue, getPeriodLabel]);
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
// Map seasons to annotation bands on the current period's timeline
const seasonAnnotations = useMemo(() => {
if (!seasons.length) return {};
const currStart = new Date(ranges.curr.start);
const currEnd = new Date(ranges.curr.end);
const annotations: Record<string, unknown> = {};
const msPerDay = 1000 * 60 * 60 * 24;
const granDivisor = chartGranularity === 'month' ? 30 : chartGranularity === 'week' ? 7 : 1;
seasons.forEach((s, i) => {
const sStart = new Date(s.StartDate);
const sEnd = new Date(s.EndDate);
// Check overlap with current period
if (sEnd < currStart || sStart > currEnd) return;
const clampedStart = sStart < currStart ? currStart : sStart;
const clampedEnd = sEnd > currEnd ? currEnd : sEnd;
const startIdx = Math.floor((clampedStart.getTime() - currStart.getTime()) / msPerDay / granDivisor);
const endIdx = Math.floor((clampedEnd.getTime() - currStart.getTime()) / msPerDay / granDivisor);
annotations[`season${i}`] = {
type: 'box',
xMin: startIdx - 0.5,
xMax: endIdx + 0.5,
backgroundColor: s.Color + '20',
borderColor: s.Color + '40',
borderWidth: 1,
label: {
display: true,
content: `${s.Name} ${s.HijriYear}`,
position: 'start',
color: s.Color,
font: { size: 10, weight: '600' },
padding: 4
}
};
});
return annotations;
}, [seasons, ranges.curr, chartGranularity]);
const chartOptions: any = {
...baseOptions,
plugins: {
...baseOptions.plugins,
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } }
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } },
annotation: { annotations: seasonAnnotations }
}
};
@@ -559,9 +640,18 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
<option value="h1">{t('time.h1')}</option>
<option value="h2">{t('time.h2')}</option>
<option value="full">{t('time.fullYear')}</option>
{seasons.length > 0 && (
<optgroup label={t('comparison.seasons') || 'Seasons'}>
{seasons.map(s => (
<option key={s.Id} value={`season-${s.Id}`}>
{s.Name} {s.HijriYear}
</option>
))}
</optgroup>
)}
</select>
</FilterControls.Group>
{preset !== 'custom' && (
{preset !== 'custom' && !preset.startsWith('season-') && (
<FilterControls.Group label={t('filters.year')}>
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
{availableYears.map(y => (
@@ -570,7 +660,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
</select>
</FilterControls.Group>
)}
{preset === 'custom' && (
{(preset === 'custom' || preset.startsWith('season-')) && (
<>
<FilterControls.Group label={t('comparison.from')}>
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Line, Bar } from 'react-chartjs-2';
import { useSearchParams, Link } from 'react-router-dom';
import { Line, Bar, Pie } from 'react-chartjs-2';
import { Carousel, EmptyState, FilterControls, MultiSelect, StatCard } from './shared';
import { ExportableChart } from './ChartExport';
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
@@ -22,7 +22,7 @@ import {
getMuseumsForDistrict,
groupByDistrict
} from '../services/dataService';
import type { DashboardProps, Filters, MuseumRecord } from '../types';
import type { DashboardProps, Filters, MuseumRecord, Season } from '../types';
const defaultFilters: Filters = {
year: 'all',
@@ -34,7 +34,7 @@ const defaultFilters: Filters = {
const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter'];
function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: DashboardProps) {
const { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams();
const [pilgrimLoaded, setPilgrimLoaded] = useState(false);
@@ -78,12 +78,41 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
const [activeStatCard, setActiveStatCard] = useState(0);
const [activeChart, setActiveChart] = useState(0);
const [trendGranularity, setTrendGranularity] = useState('week');
const [selectedSeason, setSelectedSeason] = useState<string>('');
const [eventMetric, setEventMetric] = useState<'visitors' | 'revenue'>('revenue');
const [eventChartType, setEventChartType] = useState<'bar' | 'pie'>('pie');
const [channelChartType, setChannelChartType] = useState<'bar' | 'pie'>('pie');
const [channelDisplayMode, setChannelDisplayMode] = useState<'absolute' | 'percent'>('absolute');
const [eventDisplayMode, setEventDisplayMode] = useState<'absolute' | 'percent'>('absolute');
const [districtChartType, setDistrictChartType] = useState<'bar' | 'pie'>('pie');
const [districtDisplayMode, setDistrictDisplayMode] = useState<'absolute' | 'percent'>('absolute');
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
const metrics = useMemo(() => calculateMetrics(filteredData, includeVAT), [filteredData, includeVAT]);
const hasData = filteredData.length > 0;
// Permission base filter — applied before any user-facing filter
// null = corrupted value → fail-closed (show nothing)
const permissionFilteredData = useMemo(() => {
if (allowedMuseums === null || allowedChannels === null) return [];
let d = data;
if (allowedMuseums.length > 0) d = d.filter(r => allowedMuseums.includes(r.museum_name));
if (allowedChannels.length > 0) d = d.filter(r => allowedChannels.includes(r.channel));
return d;
}, [data, allowedMuseums, allowedChannels]);
const resetFilters = () => setFilters(defaultFilters);
const filteredData = useMemo(() => filterData(permissionFilteredData, filters), [permissionFilteredData, filters]);
const seasonFilteredData = useMemo(() => {
if (!selectedSeason) return filteredData;
const season = seasons.find(s => String(s.Id) === selectedSeason);
if (!season) return filteredData;
return filteredData.filter(row => row.date >= season.StartDate && row.date <= season.EndDate);
}, [filteredData, selectedSeason, seasons]);
const metrics = useMemo(() => calculateMetrics(seasonFilteredData, includeVAT), [seasonFilteredData, includeVAT]);
const hasData = seasonFilteredData.length > 0;
const resetFilters = () => {
setFilters(defaultFilters);
setSelectedSeason('');
};
// Stat cards for carousel
const statCards = useMemo(() => [
@@ -99,19 +128,19 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
}, [t]);
// Dynamic lists from data
const years = useMemo(() => getUniqueYears(data), [data]);
const districts = useMemo(() => getUniqueDistricts(data), [data]);
const channels = useMemo(() => getUniqueChannels(data), [data]);
const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
const years = useMemo(() => getUniqueYears(permissionFilteredData), [permissionFilteredData]);
const districts = useMemo(() => getUniqueDistricts(permissionFilteredData), [permissionFilteredData]);
const channels = useMemo(() => getUniqueChannels(permissionFilteredData), [permissionFilteredData]);
const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]);
const yoyChange = useMemo(() => {
if (filters.year === 'all') return null;
const prevYear = String(parseInt(filters.year) - 1);
const prevData = data.filter((row: MuseumRecord) => row.year === prevYear);
const prevData = permissionFilteredData.filter((row: MuseumRecord) => row.year === prevYear);
if (prevData.length === 0) return null;
const prevMetrics = calculateMetrics(prevData, includeVAT);
return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null;
}, [data, filters.year, metrics.revenue, includeVAT]);
}, [permissionFilteredData, filters.year, metrics.revenue, includeVAT]);
// Revenue trend data (weekly or daily)
const trendData = useMemo(() => {
@@ -153,11 +182,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
});
if (trendGranularity === 'week') {
const grouped = groupByWeek(filteredData, includeVAT);
const grouped = groupByWeek(seasonFilteredData, includeVAT);
const weeks = Object.keys(grouped).filter(w => w).sort();
const revenueValues = weeks.map(w => grouped[w].revenue);
return {
labels: weeks.map(formatLabel),
rawDates: weeks,
datasets: [{
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
data: revenueValues,
@@ -173,7 +203,7 @@ 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 as unknown as Record<string, unknown>)[revenueField] || 0);
@@ -182,6 +212,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
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,
@@ -195,11 +226,11 @@ 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: {
@@ -220,11 +251,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
}]
}
};
}, [filteredData, includeVAT]);
}, [seasonFilteredData, includeVAT]);
// Channel data
const channelData = useMemo(() => {
const grouped = groupByChannel(filteredData, includeVAT);
const grouped = groupByChannel(seasonFilteredData, includeVAT);
const channels = Object.keys(grouped);
return {
labels: channels,
@@ -234,11 +265,32 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
borderRadius: 4
}]
};
}, [filteredData, includeVAT]);
}, [seasonFilteredData, includeVAT]);
const eventChartData = useMemo(() => {
const source = museumData[eventMetric];
if (eventDisplayMode === 'absolute') return source;
const total = source.datasets[0].data.reduce((s: number, v: number) => s + v, 0);
if (total === 0) return source;
return {
...source,
datasets: [{ ...source.datasets[0], data: source.datasets[0].data.map((v: number) => parseFloat(((v / total) * 100).toFixed(1))) }]
};
}, [museumData, eventMetric, eventDisplayMode]);
const channelChartData = useMemo(() => {
if (channelDisplayMode === 'absolute') return channelData;
const total = channelData.datasets[0].data.reduce((s: number, v: number) => s + v, 0);
if (total === 0) return channelData;
return {
...channelData,
datasets: [{ ...channelData.datasets[0], data: channelData.datasets[0].data.map((v: number) => parseFloat(((v / total) * 100).toFixed(1))) }]
};
}, [channelData, channelDisplayMode]);
// District data
const districtData = useMemo(() => {
const grouped = groupByDistrict(filteredData, includeVAT);
const grouped = groupByDistrict(seasonFilteredData, includeVAT);
const districtNames = Object.keys(grouped);
return {
labels: districtNames,
@@ -248,7 +300,17 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
borderRadius: 4
}]
};
}, [filteredData, includeVAT]);
}, [seasonFilteredData, includeVAT]);
const districtChartData = useMemo(() => {
if (districtDisplayMode === 'absolute') return districtData;
const total = districtData.datasets[0].data.reduce((s: number, v: number) => s + v, 0);
if (total === 0) return districtData;
return {
...districtData,
datasets: [{ ...districtData.datasets[0], data: districtData.datasets[0].data.map((v: number) => parseFloat(((v / total) * 100).toFixed(1))) }]
};
}, [districtData, districtDisplayMode]);
// Quarterly YoY
const quarterlyYoYData = useMemo(() => {
@@ -386,6 +448,45 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
const pieOptions = useMemo(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: 'right' as const, labels: { boxWidth: 12, padding: 10, font: { size: 11 }, color: '#64748b' } },
tooltip: baseOptions.plugins.tooltip,
datalabels: { display: false }
}
}), [baseOptions]);
// Season annotation bands for revenue trend chart
const seasonAnnotations = useMemo(() => {
const raw = trendData.rawDates;
if (!seasons.length || !raw?.length) return {};
const annotations: Record<string, unknown> = {};
seasons.forEach((s, i) => {
const startIdx = raw.findIndex(d => d >= s.StartDate);
const endIdx = raw.length - 1 - [...raw].reverse().findIndex(d => d <= s.EndDate);
if (startIdx === -1 || endIdx < startIdx) return;
annotations[`season${i}`] = {
type: 'box',
xMin: startIdx - 0.5,
xMax: endIdx + 0.5,
backgroundColor: s.Color + '20',
borderColor: s.Color + '40',
borderWidth: 1,
label: {
display: true,
content: `${s.Name} ${s.HijriYear}`,
position: 'start',
color: s.Color,
font: { size: 10, weight: '600' },
padding: 4
}
};
});
return annotations;
}, [seasons, trendData.rawDates]);
return (
<div className="dashboard" id="dashboard-container">
<div className="page-title-with-actions">
@@ -450,6 +551,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
<option value="4">{t('time.q4')}</option>
</select>
</FilterControls.Group>
<FilterControls.Group label={t('filters.season')}>
<select value={selectedSeason} onChange={e => setSelectedSeason(e.target.value)}>
<option value="">{t('filters.allSeasons')}</option>
{seasons.map(s => (
<option key={s.Id} value={String(s.Id)}>
{s.Name} {s.HijriYear}
</option>
))}
</select>
</FilterControls.Group>
</FilterControls.Row>
</FilterControls>
@@ -543,19 +654,45 @@ 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>
<div className="chart-card half-width">
<ExportableChart filename="visitors-by-event" title={t('dashboard.visitorsByMuseum')} className="chart-container">
<Bar data={museumData.visitors} options={{...baseOptions, indexAxis: 'y'}} />
</ExportableChart>
</div>
<div className="chart-card half-width">
<ExportableChart filename="revenue-by-event" title={t('dashboard.revenueByMuseum')} className="chart-container">
<Bar data={museumData.revenue} options={{...baseOptions, indexAxis: 'y'}} />
<ExportableChart
filename={eventMetric === 'visitors' ? 'visitors-by-event' : 'revenue-by-event'}
title={eventMetric === 'visitors' ? t('dashboard.visitorsByMuseum') : t('dashboard.revenueByMuseum')}
className="chart-container"
controls={
<div style={{ display: 'flex', gap: '6px' }}>
<div className="toggle-switch">
<button className={eventMetric === 'visitors' ? 'active' : ''} onClick={() => setEventMetric('visitors')}>{t('metrics.visitors')}</button>
<button className={eventMetric === 'revenue' ? 'active' : ''} onClick={() => setEventMetric('revenue')}>{t('metrics.revenue')}</button>
</div>
<div className="toggle-switch">
<button className={eventChartType === 'bar' ? 'active' : ''} onClick={() => setEventChartType('bar')}>{t('metrics.bar')}</button>
<button className={eventChartType === 'pie' ? 'active' : ''} onClick={() => setEventChartType('pie')}>{t('metrics.pie')}</button>
</div>
<div className="toggle-switch">
<button className={eventDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setEventDisplayMode('absolute')}>#</button>
<button className={eventDisplayMode === 'percent' ? 'active' : ''} onClick={() => setEventDisplayMode('percent')}>%</button>
</div>
</div>
}
>
{eventChartType === 'bar'
? <Bar data={eventChartData} options={{...baseOptions, indexAxis: 'y'}} />
: <Pie data={eventChartData} options={{
...pieOptions,
plugins: {
...pieOptions.plugins,
datalabels: eventDisplayMode === 'percent'
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
: { display: false },
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => eventDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
}
}} />
}
</ExportableChart>
</div>
@@ -566,14 +703,75 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
</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
filename="channel-performance"
title={t('dashboard.channelPerformance')}
className="chart-container"
controls={
<div style={{ display: 'flex', gap: '6px' }}>
<div className="toggle-switch">
<button className={channelChartType === 'bar' ? 'active' : ''} onClick={() => setChannelChartType('bar')}>{t('metrics.bar')}</button>
<button className={channelChartType === 'pie' ? 'active' : ''} onClick={() => setChannelChartType('pie')}>{t('metrics.pie')}</button>
</div>
<div className="toggle-switch">
<button className={channelDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setChannelDisplayMode('absolute')}>#</button>
<button className={channelDisplayMode === 'percent' ? 'active' : ''} onClick={() => setChannelDisplayMode('percent')}>%</button>
</div>
</div>
}
>
{channelChartType === 'bar'
? <Bar data={channelChartData} options={{
...baseOptions,
indexAxis: 'y',
plugins: { ...baseOptions.plugins, datalabels: { ...baseOptions.plugins.datalabels, formatter: (v: number) => channelDisplayMode === 'percent' ? v.toFixed(1) + '%' : baseOptions.plugins.datalabels.formatter(v, {} as any) } },
scales: { ...baseOptions.scales, x: { ...baseOptions.scales.x, ticks: { ...baseOptions.scales.x.ticks, callback: (v: number | string) => channelDisplayMode === 'percent' ? v + '%' : v } } }
}} />
: <Pie data={channelChartData} options={{
...pieOptions,
plugins: {
...pieOptions.plugins,
datalabels: channelDisplayMode === 'percent'
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
: { display: false },
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => channelDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
}
}} />
}
</ExportableChart>
</div>
<div className="chart-card half-width">
<ExportableChart filename="district-performance" title={t('dashboard.districtPerformance')} className="chart-container">
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
<ExportableChart
filename="district-performance"
title={t('dashboard.districtPerformance')}
className="chart-container"
controls={
<div style={{ display: 'flex', gap: '6px' }}>
<div className="toggle-switch">
<button className={districtChartType === 'bar' ? 'active' : ''} onClick={() => setDistrictChartType('bar')}>{t('metrics.bar')}</button>
<button className={districtChartType === 'pie' ? 'active' : ''} onClick={() => setDistrictChartType('pie')}>{t('metrics.pie')}</button>
</div>
<div className="toggle-switch">
<button className={districtDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setDistrictDisplayMode('absolute')}>#</button>
<button className={districtDisplayMode === 'percent' ? 'active' : ''} onClick={() => setDistrictDisplayMode('percent')}>%</button>
</div>
</div>
}
>
{districtChartType === 'bar'
? <Bar data={districtChartData} options={{...baseOptions, indexAxis: 'y'}} />
: <Pie data={districtChartData} options={{
...pieOptions,
plugins: {
...pieOptions.plugins,
datalabels: districtDisplayMode === 'percent'
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
: { display: false },
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => districtDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
}
}} />
}
</ExportableChart>
</div>
@@ -636,25 +834,42 @@ 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>
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.visitorsByMuseum')}</h2>
<div className="chart-container">
<Bar data={museumData.visitors} options={{...baseOptions, indexAxis: 'y'}} />
<h2>{eventMetric === 'visitors' ? t('dashboard.visitorsByMuseum') : t('dashboard.revenueByMuseum')}</h2>
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
<div className="toggle-switch">
<button className={eventMetric === 'visitors' ? 'active' : ''} onClick={() => setEventMetric('visitors')}>{t('metrics.visitors')}</button>
<button className={eventMetric === 'revenue' ? 'active' : ''} onClick={() => setEventMetric('revenue')}>{t('metrics.revenue')}</button>
</div>
<div className="toggle-switch">
<button className={eventChartType === 'bar' ? 'active' : ''} onClick={() => setEventChartType('bar')}>{t('metrics.bar')}</button>
<button className={eventChartType === 'pie' ? 'active' : ''} onClick={() => setEventChartType('pie')}>{t('metrics.pie')}</button>
</div>
<div className="toggle-switch">
<button className={eventDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setEventDisplayMode('absolute')}>#</button>
<button className={eventDisplayMode === 'percent' ? 'active' : ''} onClick={() => setEventDisplayMode('percent')}>%</button>
</div>
</div>
</div>
</div>
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.revenueByMuseum')}</h2>
<div className="chart-container">
<Bar data={museumData.revenue} options={{...baseOptions, indexAxis: 'y'}} />
{eventChartType === 'bar'
? <Bar data={eventChartData} options={{...baseOptions, indexAxis: 'y'}} />
: <Pie data={eventChartData} options={{
...pieOptions,
plugins: {
...pieOptions.plugins,
datalabels: eventDisplayMode === 'percent'
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
: { display: false },
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => eventDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
}
}} />
}
</div>
</div>
</div>
@@ -671,8 +886,30 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.channelPerformance')}</h2>
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
<div className="toggle-switch">
<button className={channelChartType === 'bar' ? 'active' : ''} onClick={() => setChannelChartType('bar')}>{t('metrics.bar')}</button>
<button className={channelChartType === 'pie' ? 'active' : ''} onClick={() => setChannelChartType('pie')}>{t('metrics.pie')}</button>
</div>
<div className="toggle-switch">
<button className={channelDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setChannelDisplayMode('absolute')}>#</button>
<button className={channelDisplayMode === 'percent' ? 'active' : ''} onClick={() => setChannelDisplayMode('percent')}>%</button>
</div>
</div>
<div className="chart-container">
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
{channelChartType === 'bar'
? <Bar data={channelChartData} options={{...baseOptions, indexAxis: 'y'}} />
: <Pie data={channelChartData} options={{
...pieOptions,
plugins: {
...pieOptions.plugins,
datalabels: channelDisplayMode === 'percent'
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
: { display: false },
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => channelDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
}
}} />
}
</div>
</div>
</div>
@@ -680,8 +917,30 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.districtPerformance')}</h2>
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
<div className="toggle-switch">
<button className={districtChartType === 'bar' ? 'active' : ''} onClick={() => setDistrictChartType('bar')}>{t('metrics.bar')}</button>
<button className={districtChartType === 'pie' ? 'active' : ''} onClick={() => setDistrictChartType('pie')}>{t('metrics.pie')}</button>
</div>
<div className="toggle-switch">
<button className={districtDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setDistrictDisplayMode('absolute')}>#</button>
<button className={districtDisplayMode === 'percent' ? 'active' : ''} onClick={() => setDistrictDisplayMode('percent')}>%</button>
</div>
</div>
<div className="chart-container">
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
{districtChartType === 'bar'
? <Bar data={districtChartData} options={{...baseOptions, indexAxis: 'y'}} />
: <Pie data={districtChartData} options={{
...pieOptions,
plugins: {
...pieOptions.plugins,
datalabels: districtDisplayMode === 'percent'
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
: { display: false },
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => districtDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
}
}} />
}
</div>
</div>
</div>
@@ -746,6 +1005,15 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
</div>
</>
)}
{userRole === 'admin' && <div className="settings-link">
<Link to="/settings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
{t('nav.settings')}
</Link>
</div>}
</div>
);
}

75
src/components/Login.tsx Normal file
View File

@@ -0,0 +1,75 @@
import React, { useState } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
interface LoginProps {
onLogin: (name: string, role: string, allowedMuseums: string, allowedChannels: string) => void;
}
function Login({ onLogin }: LoginProps) {
const { t } = useLanguage();
const [pin, setPin] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ pin }),
});
if (!res.ok) {
setError(t('login.invalid'));
setLoading(false);
return;
}
const data = await res.json();
onLogin(data.name || '', data.role || 'viewer', data.allowedMuseums ?? '[]', data.allowedChannels ?? '[]');
} catch {
setError(t('login.error'));
setLoading(false);
}
};
return (
<div className="login-page">
<div className="login-card">
<div className="login-brand">
<svg width="32" height="32" viewBox="0 0 24 24" fill="var(--accent)" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="4" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="11" width="7" height="10" rx="1"/>
</svg>
<h1>HiHala Data</h1>
</div>
<p className="login-subtitle">{t('login.subtitle')}</p>
<form onSubmit={handleSubmit}>
<input
type="password"
inputMode="numeric"
value={pin}
onChange={e => setPin(e.target.value)}
placeholder={t('login.placeholder')}
autoFocus
disabled={loading}
/>
{error && <p className="login-error">{error}</p>}
<button type="submit" disabled={loading || !pin}>
{loading ? '...' : t('login.submit')}
</button>
</form>
</div>
</div>
);
}
export default Login;

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

@@ -0,0 +1,339 @@
import React, { useState, useEffect } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
import { fetchSeasons, createSeason, updateSeason, deleteSeason } from '../services/seasonsService';
import { fetchUsers, createUser, updateUser, deleteUser, type User } from '../services/usersService';
import type { Season } from '../types';
const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899'];
interface SeasonRowProps {
season: Season;
onSave: (id: number, data: Partial<Season>) => Promise<void>;
onDelete: (id: number) => Promise<void>;
}
function SeasonRow({ season, onSave, onDelete }: SeasonRowProps) {
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(season);
const handleSave = async () => {
await onSave(season.Id!, form);
setEditing(false);
};
if (!editing) {
return (
<tr>
<td>
<span className="season-chip" style={{ backgroundColor: season.Color + '20', color: season.Color, borderColor: season.Color }}>
{season.Name} {season.HijriYear}
</span>
</td>
<td>{season.StartDate}</td>
<td>{season.EndDate}</td>
<td>
<div className="season-actions">
<button className="btn-small" onClick={() => setEditing(true)}>Edit</button>
<button className="btn-small btn-danger" onClick={() => onDelete(season.Id!)}>Delete</button>
</div>
</td>
</tr>
);
}
return (
<tr className="editing">
<td>
<div className="season-edit-name">
<input type="text" value={form.Name} onChange={e => setForm({ ...form, Name: e.target.value })} placeholder="Name" />
<input type="number" value={form.HijriYear} onChange={e => setForm({ ...form, HijriYear: parseInt(e.target.value) || 0 })} placeholder="Year" style={{ width: 80 }} />
<input type="color" value={form.Color} onChange={e => setForm({ ...form, Color: e.target.value })} />
</div>
</td>
<td><input type="date" value={form.StartDate} onChange={e => setForm({ ...form, StartDate: e.target.value })} /></td>
<td><input type="date" value={form.EndDate} onChange={e => setForm({ ...form, EndDate: e.target.value })} /></td>
<td>
<div className="season-actions">
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
</div>
</td>
</tr>
);
}
interface UserRowProps {
user: User;
allMuseums: string[];
allChannels: string[];
onUpdate: (id: number, fields: Partial<User>) => Promise<void>;
onDelete: (id: number) => Promise<void>;
}
function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowProps) {
const [editing, setEditing] = useState(false);
const [allowedMuseums, setAllowedMuseums] = useState<string[]>(() => {
try { return JSON.parse(user.AllowedMuseums || '[]'); } catch { return []; }
});
const [allowedChannels, setAllowedChannels] = useState<string[]>(() => {
try { return JSON.parse(user.AllowedChannels || '[]'); } catch { return []; }
});
const toggleItem = (list: string[], setList: (v: string[]) => void, item: string) =>
setList(list.includes(item) ? list.filter(x => x !== item) : [...list, item]);
const handleSave = async () => {
await onUpdate(user.Id!, {
AllowedMuseums: JSON.stringify(allowedMuseums),
AllowedChannels: JSON.stringify(allowedChannels),
});
setEditing(false);
};
const isAdmin = user.Role === 'admin';
const museumCount = (() => { try { const a = JSON.parse(user.AllowedMuseums || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
const channelCount = (() => { try { const a = JSON.parse(user.AllowedChannels || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
if (!editing) {
return (
<tr key={user.Id}>
<td>{user.Name}</td>
<td><code>{user.PIN}</code></td>
<td>{user.Role}</td>
<td>
{isAdmin ? (
<span className="access-badge access-badge--full">Full access</span>
) : (
<>
<span className="access-badge">{museumCount === 0 ? 'All events' : `${museumCount} events`}</span>
<span className="access-badge">{channelCount === 0 ? 'All channels' : `${channelCount} channels`}</span>
</>
)}
</td>
<td>
<div className="season-actions">
{!isAdmin && <button className="btn-small" onClick={() => setEditing(true)}>Edit access</button>}
<button className="btn-small btn-danger" onClick={() => onDelete(user.Id!)}>Delete</button>
</div>
</td>
</tr>
);
}
return (
<tr className="editing">
<td colSpan={5}>
<div style={{ padding: '12px 4px' }}>
<strong>{user.Name}</strong>
<div style={{ display: 'flex', gap: 32, marginTop: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontWeight: 600, marginBottom: 8 }}>
Allowed Events {allowedMuseums.length === 0 && <span className="access-badge access-badge--full">All</span>}
</div>
{allMuseums.map(m => (
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={allowedMuseums.includes(m)} onChange={() => toggleItem(allowedMuseums, setAllowedMuseums, m)} />
{m}
</label>
))}
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 8 }}>
Allowed Channels {allowedChannels.length === 0 && <span className="access-badge access-badge--full">All</span>}
</div>
{allChannels.map(c => (
<label key={c} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={allowedChannels.includes(c)} onChange={() => toggleItem(allowedChannels, setAllowedChannels, c)} />
{c}
</label>
))}
</div>
</div>
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
</div>
</div>
</td>
</tr>
);
}
interface SettingsProps {
onSeasonsChange: () => void;
allMuseums: string[];
allChannels: string[];
}
function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
const { t } = useLanguage();
const [seasons, setSeasons] = useState<Season[]>([]);
const [loading, setLoading] = useState(true);
const [newSeason, setNewSeason] = useState<Omit<Season, 'Id'>>({
Name: '',
HijriYear: new Date().getFullYear() - 579, // rough Gregorian → Hijri
StartDate: '',
EndDate: '',
Color: DEFAULT_COLORS[0],
});
const [users, setUsers] = useState<User[]>([]);
const [newUser, setNewUser] = useState<Omit<User, 'Id'>>({ Name: '', PIN: '', Role: 'viewer', AllowedMuseums: '[]', AllowedChannels: '[]' });
const loadSeasons = async () => {
setLoading(true);
const data = await fetchSeasons();
setSeasons(data);
setLoading(false);
};
const loadUsers = async () => {
const data = await fetchUsers();
setUsers(data);
};
const handleUpdateUser = async (id: number, fields: Partial<User>) => {
await updateUser(id, fields);
await loadUsers();
};
useEffect(() => { loadSeasons(); loadUsers(); }, []);
const handleCreate = async () => {
if (!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate) return;
await createSeason(newSeason);
setNewSeason({
Name: '',
HijriYear: newSeason.HijriYear,
StartDate: '',
EndDate: '',
Color: DEFAULT_COLORS[(seasons.length + 1) % DEFAULT_COLORS.length],
});
await loadSeasons();
onSeasonsChange();
};
const handleSave = async (id: number, data: Partial<Season>) => {
await updateSeason(id, data);
await loadSeasons();
onSeasonsChange();
};
const handleDelete = async (id: number) => {
await deleteSeason(id);
await loadSeasons();
onSeasonsChange();
};
return (
<div className="settings-page">
<div className="page-title">
<h1>{t('settings.title')}</h1>
<p>{t('settings.subtitle')}</p>
</div>
<div className="chart-card">
<h2>{t('settings.seasons')}</h2>
<p className="settings-hint">{t('settings.seasonsHint')}</p>
<div className="table-container">
<table>
<thead>
<tr>
<th>{t('settings.seasonName')}</th>
<th>{t('settings.startDate')}</th>
<th>{t('settings.endDate')}</th>
<th>{t('settings.actions')}</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={4} style={{ textAlign: 'center', padding: 24 }}>Loading...</td></tr>
) : (
seasons.map(s => (
<SeasonRow key={s.Id} season={s} onSave={handleSave} onDelete={handleDelete} />
))
)}
<tr className="add-row">
<td>
<div className="season-edit-name">
<input type="text" value={newSeason.Name} onChange={e => setNewSeason({ ...newSeason, Name: e.target.value })} placeholder={t('settings.namePlaceholder')} />
<input type="number" value={newSeason.HijriYear} onChange={e => setNewSeason({ ...newSeason, HijriYear: parseInt(e.target.value) || 0 })} style={{ width: 80 }} />
<input type="color" value={newSeason.Color} onChange={e => setNewSeason({ ...newSeason, Color: e.target.value })} />
</div>
</td>
<td><input type="date" value={newSeason.StartDate} onChange={e => setNewSeason({ ...newSeason, StartDate: e.target.value })} /></td>
<td><input type="date" value={newSeason.EndDate} onChange={e => setNewSeason({ ...newSeason, EndDate: e.target.value })} /></td>
<td>
<button className="btn-small btn-primary" onClick={handleCreate} disabled={!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate}>
{t('settings.add')}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className="chart-card" style={{ marginTop: 24 }}>
<h2>{t('settings.users')}</h2>
<p className="settings-hint">{t('settings.usersHint')}</p>
<div className="table-container">
<table>
<thead>
<tr>
<th>{t('settings.userName')}</th>
<th>{t('settings.userPin')}</th>
<th>{t('settings.userRole')}</th>
<th>Access</th>
<th>{t('settings.actions')}</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<UserRow
key={u.Id}
user={u}
allMuseums={allMuseums}
allChannels={allChannels}
onUpdate={handleUpdateUser}
onDelete={async (id) => { await deleteUser(id); await loadUsers(); }}
/>
))}
<tr className="add-row">
<td>
<input type="text" value={newUser.Name} onChange={e => setNewUser({ ...newUser, Name: e.target.value })} placeholder={t('settings.userNamePlaceholder')} />
</td>
<td>
<input type="text" value={newUser.PIN} onChange={e => setNewUser({ ...newUser, PIN: e.target.value })} placeholder="PIN" />
</td>
<td>
<select value={newUser.Role} onChange={e => setNewUser({ ...newUser, Role: e.target.value })}>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
</select>
</td>
<td></td>
<td>
<button className="btn-small btn-primary" onClick={async () => {
if (!newUser.Name || !newUser.PIN) return;
await createUser(newUser);
setNewUser({ Name: '', PIN: '', Role: 'viewer', AllowedMuseums: '[]', AllowedChannels: '[]' });
await loadUsers();
}} disabled={!newUser.Name || !newUser.PIN}>
{t('settings.add')}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
);
}
export default Settings;

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 = {

View File

@@ -12,6 +12,7 @@
"comparison": "المقارنة",
"compare": "مقارنة",
"slides": "الشرائح",
"settings": "الإعدادات",
"labels": "التسميات",
"labelsOn": "التسميات مفعّلة",
"labelsOff": "التسميات معطّلة",
@@ -42,6 +43,8 @@
"allChannels": "جميع القنوات",
"allMuseums": "كل الفعاليات",
"allQuarters": "كل الأرباع",
"season": "الموسم",
"allSeasons": "كل المواسم",
"reset": "إعادة تعيين الفلاتر"
},
"metrics": {
@@ -54,7 +57,9 @@
"avgRevenue": "متوسط الإيراد/زائر",
"avgRevenuePerVisitor": "متوسط الإيراد لكل زائر",
"pilgrims": "المعتمرون",
"captureRate": "نسبة الاستقطاب"
"captureRate": "نسبة الاستقطاب",
"bar": "أعمدة",
"pie": "دائري"
},
"dashboard": {
"title": "لوحة التحكم",
@@ -153,6 +158,32 @@
"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": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.",

View File

@@ -12,6 +12,7 @@
"comparison": "Comparison",
"compare": "Compare",
"slides": "Slides",
"settings": "Settings",
"labels": "Labels",
"labelsOn": "Labels On",
"labelsOff": "Labels Off",
@@ -42,6 +43,8 @@
"allChannels": "All Channels",
"allMuseums": "All Events",
"allQuarters": "All Quarters",
"season": "Season",
"allSeasons": "All Seasons",
"reset": "Reset Filters"
},
"metrics": {
@@ -54,7 +57,9 @@
"avgRevenue": "Avg Rev/Visitor",
"avgRevenuePerVisitor": "Avg Revenue/Visitor",
"pilgrims": "Pilgrims",
"captureRate": "Capture Rate"
"captureRate": "Capture Rate",
"bar": "Bar",
"pie": "Pie"
},
"dashboard": {
"title": "Dashboard",
@@ -153,6 +158,32 @@
"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.",

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,58 @@
export interface User {
Id?: number;
Name: string;
PIN: string;
Role: string;
AllowedMuseums: string; // JSON-serialized string[], '[]' = unrestricted
AllowedChannels: string; // JSON-serialized string[]
}
// null = parse error → fail-closed (show nothing)
// [] = unrestricted (admin or no restriction set)
// string[] = restricted to this list
export function parseAllowed(raw: string | undefined | null): string[] | null {
if (raw == null) return []; // field not set → unrestricted
if (raw === '[]') return []; // explicit empty → unrestricted
if (raw === '') return null; // blank string → corrupted → fail-closed
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
return parsed as string[];
} catch {
return null;
}
}
export async function fetchUsers(): Promise<User[]> {
try {
const res = await fetch('/api/users');
if (!res.ok) return [];
return res.json();
} catch {
return [];
}
}
export async function createUser(user: Omit<User, 'Id'>): Promise<User> {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
});
if (!res.ok) throw new Error('Failed to create user');
return res.json();
}
export async function updateUser(id: number, fields: Partial<User>): Promise<void> {
const res = await fetch(`/api/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
});
if (!res.ok) throw new Error('Failed to update user');
}
export async function deleteUser(id: number): Promise<void> {
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete user');
}

View File

@@ -92,6 +92,16 @@ export interface NocoDBDailySale {
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;
@@ -120,18 +130,25 @@ export interface ChartData {
// Component props
export interface DashboardProps {
data: MuseumRecord[];
seasons: Season[];
userRole: string;
showDataLabels: boolean;
setShowDataLabels: (value: boolean) => void;
includeVAT: boolean;
setIncludeVAT: (value: boolean) => void;
allowedMuseums: string[] | null;
allowedChannels: string[] | null;
}
export interface ComparisonProps {
data: MuseumRecord[];
seasons: Season[];
showDataLabels: boolean;
setShowDataLabels: (value: boolean) => void;
includeVAT: boolean;
setIncludeVAT: (value: boolean) => void;
allowedMuseums: string[] | null;
allowedChannels: string[] | null;
}
export interface SlidesProps {

View File

@@ -1,45 +0,0 @@
#!/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

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

View File

@@ -6,11 +6,27 @@ export default defineConfig({
server: {
port: 3000,
proxy: {
'/auth': {
target: 'http://localhost:3002',
changeOrigin: true,
},
'/api/erp': {
target: 'http://localhost:3002',
changeOrigin: true,
},
'/api': {
'/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,
},