Compare commits
22 Commits
db6a6ac609
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35771595dc | ||
|
|
e09c3f8190 | ||
|
|
e41cff831b | ||
|
|
d4ce5b6478 | ||
|
|
aa143dfacd | ||
|
|
f615407bba | ||
|
|
47122b5445 | ||
|
|
e373363e75 | ||
|
|
0a80103cfc | ||
|
|
ebdf90c8ab | ||
|
|
cb4fb6071a | ||
|
|
e70d9b92c6 | ||
|
|
418eb2c17c | ||
|
|
b8d33f4f8c | ||
|
|
f3ce7705d6 | ||
|
|
70af4962a6 | ||
|
|
8cf6f9eedd | ||
|
|
c99f2abe10 | ||
|
|
a06436baac | ||
|
|
9657a9d221 | ||
|
|
3c19dee236 | ||
|
|
b4c436f909 |
@@ -8,11 +8,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
|
|
||||||
|
# --- Frontend ---
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
env:
|
env:
|
||||||
VITE_NOCODB_URL: ${{ secrets.VITE_NOCODB_URL }}
|
VITE_NOCODB_URL: ${{ secrets.VITE_NOCODB_URL }}
|
||||||
@@ -21,7 +22,41 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
- name: Deploy to server
|
- name: Deploy frontend
|
||||||
|
run: rsync -a --delete build/ /opt/apps/hihala-dashboard/build/
|
||||||
|
|
||||||
|
# --- Backend ---
|
||||||
|
- name: Deploy server
|
||||||
|
run: rsync -a --delete --exclude='.env' --exclude='node_modules' server/ /opt/apps/hihala-dashboard/server/
|
||||||
|
|
||||||
|
- name: Install server dependencies
|
||||||
|
run: cd /opt/apps/hihala-dashboard/server && npm ci
|
||||||
|
|
||||||
|
- name: Write server .env
|
||||||
|
env:
|
||||||
|
ADMIN_PIN: ${{ secrets.ADMIN_PIN }}
|
||||||
|
NOCODB_URL: ${{ secrets.VITE_NOCODB_URL }}
|
||||||
|
NOCODB_TOKEN: ${{ secrets.VITE_NOCODB_TOKEN }}
|
||||||
|
NOCODB_BASE_ID: ${{ secrets.VITE_NOCODB_BASE_ID }}
|
||||||
|
ERP_API_URL: ${{ secrets.ERP_API_URL }}
|
||||||
|
ERP_API_CODE: ${{ secrets.ERP_API_CODE }}
|
||||||
|
ERP_USERNAME: ${{ secrets.ERP_USERNAME }}
|
||||||
|
ERP_PASSWORD: ${{ secrets.ERP_PASSWORD }}
|
||||||
|
ETL_SECRET: ${{ secrets.ETL_SECRET }}
|
||||||
run: |
|
run: |
|
||||||
rsync -a --delete build/ /opt/apps/hihala-dashboard/build/
|
cat > /opt/apps/hihala-dashboard/server/.env << EOF
|
||||||
|
NODE_ENV=production
|
||||||
|
SERVER_PORT=3002
|
||||||
|
ADMIN_PIN=${ADMIN_PIN}
|
||||||
|
NOCODB_URL=${NOCODB_URL}
|
||||||
|
NOCODB_TOKEN=${NOCODB_TOKEN}
|
||||||
|
NOCODB_BASE_ID=${NOCODB_BASE_ID}
|
||||||
|
ERP_API_URL=${ERP_API_URL}
|
||||||
|
ERP_API_CODE=${ERP_API_CODE}
|
||||||
|
ERP_USERNAME=${ERP_USERNAME}
|
||||||
|
ERP_PASSWORD=${ERP_PASSWORD}
|
||||||
|
ETL_SECRET=${ETL_SECRET}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Restart manually: sudo systemctl restart hihala-dashboard.service
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
@@ -14,3 +14,7 @@ NOCODB_BASE_ID=your-base-id
|
|||||||
|
|
||||||
# ETL sync secret (for cron auth)
|
# ETL sync secret (for cron auth)
|
||||||
ETL_SECRET=your-secret-here
|
ETL_SECRET=your-secret-here
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
ADMIN_PIN=your-pin-code
|
||||||
|
SESSION_SECRET=your-random-session-secret
|
||||||
|
|||||||
31
server/package-lock.json
generated
31
server/package-lock.json
generated
@@ -9,11 +9,13 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2"
|
"express": "^4.18.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
@@ -483,6 +485,16 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/cors": {
|
||||||
"version": "2.8.19",
|
"version": "2.8.19",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||||
@@ -730,6 +742,25 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-parser": {
|
||||||
|
"version": "1.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||||
|
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.7.2",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
|||||||
@@ -10,14 +10,16 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2"
|
"express": "^4.18.2",
|
||||||
|
"tsx": "^4.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"tsx": "^4.19.0",
|
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,3 +33,7 @@ export const nocodb = {
|
|||||||
export const etl = {
|
export const etl = {
|
||||||
secret: process.env.ETL_SECRET || '',
|
secret: process.env.ETL_SECRET || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const auth = {
|
||||||
|
adminPin: process.env.ADMIN_PIN || '',
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
import { server, erp, nocodb } from './config';
|
import { server, erp, nocodb } from './config';
|
||||||
|
import authRoutes from './routes/auth';
|
||||||
import erpRoutes from './routes/erp';
|
import erpRoutes from './routes/erp';
|
||||||
import etlRoutes from './routes/etl';
|
import etlRoutes from './routes/etl';
|
||||||
import seasonsRoutes from './routes/seasons';
|
import seasonsRoutes from './routes/seasons';
|
||||||
|
import usersRoutes from './routes/users';
|
||||||
|
import { discoverTableIds, ensureTableFields } from './services/nocodbClient';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors({ origin: true, credentials: true }));
|
||||||
|
app.use(cookieParser());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Mount routes
|
// Mount routes
|
||||||
|
app.use('/auth', authRoutes);
|
||||||
app.use('/api/erp', erpRoutes);
|
app.use('/api/erp', erpRoutes);
|
||||||
app.use('/api/etl', etlRoutes);
|
app.use('/api/etl', etlRoutes);
|
||||||
app.use('/api/seasons', seasonsRoutes);
|
app.use('/api/seasons', seasonsRoutes);
|
||||||
|
app.use('/api/users', usersRoutes);
|
||||||
|
|
||||||
app.listen(server.port, () => {
|
app.listen(server.port, () => {
|
||||||
console.log(`\nServer running on http://localhost:${server.port}`);
|
console.log(`\nServer running on http://localhost:${server.port}`);
|
||||||
@@ -26,6 +33,18 @@ app.listen(server.port, () => {
|
|||||||
if (nocodb.url && nocodb.token) {
|
if (nocodb.url && nocodb.token) {
|
||||||
console.log(' NocoDB: configured');
|
console.log(' NocoDB: configured');
|
||||||
console.log(' POST /api/etl/sync?mode=full|incremental');
|
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 {
|
} else {
|
||||||
console.log(' NocoDB: WARNING — not configured');
|
console.log(' NocoDB: WARNING — not configured');
|
||||||
}
|
}
|
||||||
|
|||||||
113
server/src/routes/auth.ts
Normal file
113
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { auth } from '../config';
|
||||||
|
import { discoverTableIds, fetchAllRecords } from '../services/nocodbClient';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
interface UserRecord {
|
||||||
|
Id: number;
|
||||||
|
Name: string;
|
||||||
|
PIN: string;
|
||||||
|
Role: string;
|
||||||
|
AllowedMuseums?: string;
|
||||||
|
AllowedChannels?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: number;
|
||||||
|
allowedMuseums: string;
|
||||||
|
allowedChannels: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, Session>();
|
||||||
|
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
|
||||||
|
function generateSessionId(): string {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSession(sessionId: string): Session | null {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
if (Date.now() - session.createdAt > SESSION_MAX_AGE) {
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /auth/login
|
||||||
|
router.post('/login', async (req: Request, res: Response) => {
|
||||||
|
const { pin } = req.body;
|
||||||
|
if (!pin) {
|
||||||
|
res.status(400).json({ error: 'PIN required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check super admin PIN from env first
|
||||||
|
if (auth.adminPin && pin === auth.adminPin) {
|
||||||
|
const sessionId = generateSessionId();
|
||||||
|
sessions.set(sessionId, { name: 'Admin', role: 'admin', createdAt: Date.now(), allowedMuseums: '[]', allowedChannels: '[]' });
|
||||||
|
res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' });
|
||||||
|
res.json({ ok: true, name: 'Admin', role: 'admin', allowedMuseums: '[]', allowedChannels: '[]' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check NocoDB Users table
|
||||||
|
try {
|
||||||
|
const tables = await discoverTableIds();
|
||||||
|
if (tables['Users']) {
|
||||||
|
const users = await fetchAllRecords<UserRecord>(tables['Users']);
|
||||||
|
const user = users.find(u => u.PIN === pin);
|
||||||
|
if (user) {
|
||||||
|
const sessionId = generateSessionId();
|
||||||
|
sessions.set(sessionId, {
|
||||||
|
name: user.Name,
|
||||||
|
role: user.Role || 'viewer',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
allowedMuseums: user.AllowedMuseums || '[]',
|
||||||
|
allowedChannels: user.AllowedChannels || '[]',
|
||||||
|
});
|
||||||
|
res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' });
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
name: user.Name,
|
||||||
|
role: user.Role || 'viewer',
|
||||||
|
allowedMuseums: user.AllowedMuseums || '[]',
|
||||||
|
allowedChannels: user.AllowedChannels || '[]',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to check Users table:', (err as Error).message);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json({ error: 'Invalid PIN' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /auth/check
|
||||||
|
router.get('/check', (req: Request, res: Response) => {
|
||||||
|
const sessionId = req.cookies?.hihala_session;
|
||||||
|
const session = sessionId ? getSession(sessionId) : null;
|
||||||
|
res.json({
|
||||||
|
authenticated: !!session,
|
||||||
|
name: session?.name || null,
|
||||||
|
role: session?.role || null,
|
||||||
|
allowedMuseums: session?.allowedMuseums ?? '[]',
|
||||||
|
allowedChannels: session?.allowedChannels ?? '[]',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /auth/logout
|
||||||
|
router.post('/logout', (req: Request, res: Response) => {
|
||||||
|
const sessionId = req.cookies?.hihala_session;
|
||||||
|
if (sessionId) sessions.delete(sessionId);
|
||||||
|
res.clearCookie('hihala_session', { path: '/' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
57
server/src/routes/users.ts
Normal file
57
server/src/routes/users.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { discoverTableIds, fetchAllRecords, createRecord, updateRecord, deleteRecord } from '../services/nocodbClient';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
async function getUsersTableId(): Promise<string> {
|
||||||
|
const tables = await discoverTableIds();
|
||||||
|
const id = tables['Users'];
|
||||||
|
if (!id) throw new Error("NocoDB table 'Users' not found");
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/users
|
||||||
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getUsersTableId();
|
||||||
|
const records = await fetchAllRecords(tableId);
|
||||||
|
res.json(records);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/users
|
||||||
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getUsersTableId();
|
||||||
|
const result = await createRecord(tableId, req.body);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/users/:id
|
||||||
|
router.patch('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getUsersTableId();
|
||||||
|
await updateRecord(tableId, parseInt(req.params.id), req.body);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/users/:id
|
||||||
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getUsersTableId();
|
||||||
|
await deleteRecord(tableId, parseInt(req.params.id));
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -125,6 +125,23 @@ export async function fetchAllRecords<T>(tableId: string): Promise<T[]> {
|
|||||||
return all;
|
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> {
|
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`, {
|
return await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
131
src/App.css
131
src/App.css
@@ -851,10 +851,119 @@ table tbody tr:hover {
|
|||||||
accent-color: var(--accent);
|
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 */
|
||||||
.settings-page {
|
.settings-page {
|
||||||
padding: 24px;
|
padding: 32px;
|
||||||
max-width: 900px;
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-hint {
|
.settings-hint {
|
||||||
@@ -897,6 +1006,21 @@ table tbody tr:hover {
|
|||||||
gap: 6px;
|
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 {
|
.btn-small {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -938,7 +1062,8 @@ tr.editing td {
|
|||||||
|
|
||||||
.settings-page input[type="text"],
|
.settings-page input[type="text"],
|
||||||
.settings-page input[type="number"],
|
.settings-page input[type="number"],
|
||||||
.settings-page input[type="date"] {
|
.settings-page input[type="date"],
|
||||||
|
.settings-page select {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|||||||
80
src/App.tsx
80
src/App.tsx
@@ -1,12 +1,14 @@
|
|||||||
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';
|
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
const Dashboard = lazy(() => import('./components/Dashboard'));
|
const Dashboard = lazy(() => import('./components/Dashboard'));
|
||||||
const Comparison = lazy(() => import('./components/Comparison'));
|
const Comparison = lazy(() => import('./components/Comparison'));
|
||||||
const Settings = lazy(() => import('./components/Settings'));
|
const Settings = lazy(() => import('./components/Settings'));
|
||||||
|
import Login from './components/Login';
|
||||||
import LoadingSkeleton from './components/shared/LoadingSkeleton';
|
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 { fetchSeasons } from './services/seasonsService';
|
||||||
|
import { parseAllowed } from './services/usersService';
|
||||||
import { useLanguage } from './contexts/LanguageContext';
|
import { useLanguage } from './contexts/LanguageContext';
|
||||||
import type { MuseumRecord, Season, CacheStatus, DataErrorType } from './types';
|
import type { MuseumRecord, Season, CacheStatus, DataErrorType } from './types';
|
||||||
import { DataError } from './types';
|
import { DataError } from './types';
|
||||||
@@ -36,7 +38,14 @@ interface DataSource {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t, dir, switchLanguage } = useLanguage();
|
const { t, dir, switchLanguage } = useLanguage();
|
||||||
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||||
|
const [userRole, setUserRole] = useState<string>('viewer');
|
||||||
|
const [userName, setUserName] = useState<string>('');
|
||||||
|
const [allowedMuseums, setAllowedMuseums] = useState<string[] | null>([]);
|
||||||
|
const [allowedChannels, setAllowedChannels] = useState<string[] | null>([]);
|
||||||
const [data, setData] = useState<MuseumRecord[]>([]);
|
const [data, setData] = useState<MuseumRecord[]>([]);
|
||||||
|
const allMuseumsList = useMemo(() => getUniqueMuseums(data), [data]);
|
||||||
|
const allChannelsList = useMemo(() => getUniqueChannels(data), [data]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<{ message: string; type: DataErrorType } | null>(null);
|
const [error, setError] = useState<{ message: string; type: DataErrorType } | null>(null);
|
||||||
@@ -105,16 +114,57 @@ function App() {
|
|||||||
setSeasons(s);
|
setSeasons(s);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Check auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
fetch('/auth/check', { credentials: 'include' })
|
||||||
loadSeasons();
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
setAuthenticated(d.authenticated);
|
||||||
|
if (d.authenticated) {
|
||||||
|
setUserRole(d.role || 'viewer');
|
||||||
|
setUserName(d.name || '');
|
||||||
|
setAllowedMuseums(parseAllowed(d.allowedMuseums));
|
||||||
|
setAllowedChannels(parseAllowed(d.allowedChannels));
|
||||||
|
loadData();
|
||||||
|
loadSeasons();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setAuthenticated(false));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = (name: string, role: string, rawMuseums: string, rawChannels: string) => {
|
||||||
|
setAuthenticated(true);
|
||||||
|
setUserName(name);
|
||||||
|
setUserRole(role);
|
||||||
|
setAllowedMuseums(parseAllowed(rawMuseums));
|
||||||
|
setAllowedChannels(parseAllowed(rawChannels));
|
||||||
|
loadData();
|
||||||
|
loadSeasons();
|
||||||
|
};
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
loadData(true);
|
loadData(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auth check loading
|
||||||
|
if (authenticated === null) {
|
||||||
|
return (
|
||||||
|
<div className="app" dir={dir}>
|
||||||
|
<LoadingSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not authenticated — show login
|
||||||
|
if (!authenticated) {
|
||||||
|
return (
|
||||||
|
<div className="app" dir={dir}>
|
||||||
|
<Login onLogin={handleLogin} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="app" dir={dir}>
|
<div className="app" dir={dir}>
|
||||||
@@ -246,9 +296,9 @@ function App() {
|
|||||||
<main>
|
<main>
|
||||||
<Suspense fallback={<LoadingSkeleton />}>
|
<Suspense fallback={<LoadingSkeleton />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard data={data} seasons={seasons} 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} />} />
|
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||||
<Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} />} />
|
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
@@ -272,12 +322,14 @@ function App() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{t('nav.compare')}</span>
|
<span>{t('nav.compare')}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/settings" className="mobile-nav-item">
|
{userRole === 'admin' && (
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
<NavLink to="/settings" className="mobile-nav-item">
|
||||||
<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 width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
</svg>
|
<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"/>
|
||||||
<span>{t('nav.settings')}</span>
|
</svg>
|
||||||
</NavLink>
|
<span>{t('nav.settings')}</span>
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="mobile-nav-item"
|
className="mobile-nav-item"
|
||||||
onClick={switchLanguage}
|
onClick={switchLanguage}
|
||||||
|
|||||||
@@ -63,15 +63,24 @@ const generatePresetDates = (year: number): PresetDates => ({
|
|||||||
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
|
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
|
||||||
});
|
});
|
||||||
|
|
||||||
function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: ComparisonProps) {
|
function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: ComparisonProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
// Permission base filter — applied before any user-facing filter
|
||||||
|
const permissionFilteredData = useMemo(() => {
|
||||||
|
if (allowedMuseums === null || allowedChannels === null) return [];
|
||||||
|
let d = data;
|
||||||
|
if (allowedMuseums.length > 0) d = d.filter(r => allowedMuseums.includes(r.museum_name));
|
||||||
|
if (allowedChannels.length > 0) d = d.filter(r => allowedChannels.includes(r.channel));
|
||||||
|
return d;
|
||||||
|
}, [data, allowedMuseums, allowedChannels]);
|
||||||
|
|
||||||
// Get available years from data
|
// Get available years from data
|
||||||
const latestYear = useMemo(() => parseInt(getLatestYear(data)), [data]);
|
const latestYear = useMemo(() => parseInt(getLatestYear(permissionFilteredData)), [permissionFilteredData]);
|
||||||
const availableYears = useMemo((): number[] => {
|
const availableYears = useMemo((): number[] => {
|
||||||
const yearsSet = new Set<number>();
|
const yearsSet = new Set<number>();
|
||||||
data.forEach((r: MuseumRecord) => {
|
permissionFilteredData.forEach((r: MuseumRecord) => {
|
||||||
const d = r.date || (r as any).Date;
|
const d = r.date || (r as any).Date;
|
||||||
if (d) yearsSet.add(new Date(d).getFullYear());
|
if (d) yearsSet.add(new Date(d).getFullYear());
|
||||||
});
|
});
|
||||||
@@ -236,9 +245,9 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV
|
|||||||
}, [revenueField]);
|
}, [revenueField]);
|
||||||
|
|
||||||
// Dynamic lists from data
|
// Dynamic lists from data
|
||||||
const channels = useMemo(() => getUniqueChannels(data), [data]);
|
const channels = useMemo(() => getUniqueChannels(permissionFilteredData), [permissionFilteredData]);
|
||||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
const districts = useMemo(() => getUniqueDistricts(permissionFilteredData), [permissionFilteredData]);
|
||||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
|
const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]);
|
||||||
|
|
||||||
// Year-over-year comparison: same dates, previous year
|
// Year-over-year comparison: same dates, previous year
|
||||||
// For season presets, try to find the same season name from the previous hijri year
|
// For season presets, try to find the same season name from the previous hijri year
|
||||||
@@ -265,14 +274,14 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV
|
|||||||
return { curr, prev };
|
return { curr, prev };
|
||||||
}, [startDate, endDate, preset, seasons]);
|
}, [startDate, endDate, preset, seasons]);
|
||||||
|
|
||||||
const prevData = useMemo(() =>
|
const prevData = useMemo(() =>
|
||||||
filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters),
|
filterDataByDateRange(permissionFilteredData, ranges.prev.start, ranges.prev.end, filters),
|
||||||
[data, ranges.prev, filters]
|
[permissionFilteredData, ranges.prev, filters]
|
||||||
);
|
);
|
||||||
|
|
||||||
const currData = useMemo(() =>
|
const currData = useMemo(() =>
|
||||||
filterDataByDateRange(data, ranges.curr.start, ranges.curr.end, filters),
|
filterDataByDateRange(permissionFilteredData, ranges.curr.start, ranges.curr.end, filters),
|
||||||
[data, ranges.curr, filters]
|
[permissionFilteredData, ranges.curr, filters]
|
||||||
);
|
);
|
||||||
|
|
||||||
const prevMetrics = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]);
|
const prevMetrics = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]);
|
||||||
@@ -532,11 +541,53 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV
|
|||||||
}, [data, prevData, currData, ranges, chartMetric, getMetricValue, getPeriodLabel]);
|
}, [data, prevData, currData, ranges, chartMetric, getMetricValue, getPeriodLabel]);
|
||||||
|
|
||||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||||
|
|
||||||
|
// Map seasons to annotation bands on the current period's timeline
|
||||||
|
const seasonAnnotations = useMemo(() => {
|
||||||
|
if (!seasons.length) return {};
|
||||||
|
const currStart = new Date(ranges.curr.start);
|
||||||
|
const currEnd = new Date(ranges.curr.end);
|
||||||
|
const annotations: Record<string, unknown> = {};
|
||||||
|
const msPerDay = 1000 * 60 * 60 * 24;
|
||||||
|
const granDivisor = chartGranularity === 'month' ? 30 : chartGranularity === 'week' ? 7 : 1;
|
||||||
|
|
||||||
|
seasons.forEach((s, i) => {
|
||||||
|
const sStart = new Date(s.StartDate);
|
||||||
|
const sEnd = new Date(s.EndDate);
|
||||||
|
// Check overlap with current period
|
||||||
|
if (sEnd < currStart || sStart > currEnd) return;
|
||||||
|
|
||||||
|
const clampedStart = sStart < currStart ? currStart : sStart;
|
||||||
|
const clampedEnd = sEnd > currEnd ? currEnd : sEnd;
|
||||||
|
const startIdx = Math.floor((clampedStart.getTime() - currStart.getTime()) / msPerDay / granDivisor);
|
||||||
|
const endIdx = Math.floor((clampedEnd.getTime() - currStart.getTime()) / msPerDay / granDivisor);
|
||||||
|
|
||||||
|
annotations[`season${i}`] = {
|
||||||
|
type: 'box',
|
||||||
|
xMin: startIdx - 0.5,
|
||||||
|
xMax: endIdx + 0.5,
|
||||||
|
backgroundColor: s.Color + '20',
|
||||||
|
borderColor: s.Color + '40',
|
||||||
|
borderWidth: 1,
|
||||||
|
label: {
|
||||||
|
display: true,
|
||||||
|
content: `${s.Name} ${s.HijriYear}`,
|
||||||
|
position: 'start',
|
||||||
|
color: s.Color,
|
||||||
|
font: { size: 10, weight: '600' },
|
||||||
|
padding: 4
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return annotations;
|
||||||
|
}, [seasons, ranges.curr, chartGranularity]);
|
||||||
|
|
||||||
const chartOptions: any = {
|
const chartOptions: any = {
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
plugins: {
|
plugins: {
|
||||||
...baseOptions.plugins,
|
...baseOptions.plugins,
|
||||||
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } }
|
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } },
|
||||||
|
annotation: { annotations: seasonAnnotations }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams, Link } from 'react-router-dom';
|
||||||
import { Line, Bar } from 'react-chartjs-2';
|
import { Line, Bar, Pie } from 'react-chartjs-2';
|
||||||
import { Carousel, EmptyState, FilterControls, MultiSelect, StatCard } from './shared';
|
import { Carousel, EmptyState, FilterControls, MultiSelect, StatCard } from './shared';
|
||||||
import { ExportableChart } from './ChartExport';
|
import { ExportableChart } from './ChartExport';
|
||||||
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
|
||||||
@@ -34,7 +34,7 @@ const defaultFilters: Filters = {
|
|||||||
|
|
||||||
const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter'];
|
const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter'];
|
||||||
|
|
||||||
function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
|
function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: DashboardProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [pilgrimLoaded, setPilgrimLoaded] = useState(false);
|
const [pilgrimLoaded, setPilgrimLoaded] = useState(false);
|
||||||
@@ -79,8 +79,25 @@ function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVA
|
|||||||
const [activeChart, setActiveChart] = useState(0);
|
const [activeChart, setActiveChart] = useState(0);
|
||||||
const [trendGranularity, setTrendGranularity] = useState('week');
|
const [trendGranularity, setTrendGranularity] = useState('week');
|
||||||
const [selectedSeason, setSelectedSeason] = useState<string>('');
|
const [selectedSeason, setSelectedSeason] = useState<string>('');
|
||||||
|
const [eventMetric, setEventMetric] = useState<'visitors' | 'revenue'>('revenue');
|
||||||
|
const [eventChartType, setEventChartType] = useState<'bar' | 'pie'>('pie');
|
||||||
|
const [channelChartType, setChannelChartType] = useState<'bar' | 'pie'>('pie');
|
||||||
|
const [channelDisplayMode, setChannelDisplayMode] = useState<'absolute' | 'percent'>('absolute');
|
||||||
|
const [eventDisplayMode, setEventDisplayMode] = useState<'absolute' | 'percent'>('absolute');
|
||||||
|
const [districtChartType, setDistrictChartType] = useState<'bar' | 'pie'>('pie');
|
||||||
|
const [districtDisplayMode, setDistrictDisplayMode] = useState<'absolute' | 'percent'>('absolute');
|
||||||
|
|
||||||
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
|
// Permission base filter — applied before any user-facing filter
|
||||||
|
// 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 filteredData = useMemo(() => filterData(permissionFilteredData, filters), [permissionFilteredData, filters]);
|
||||||
|
|
||||||
const seasonFilteredData = useMemo(() => {
|
const seasonFilteredData = useMemo(() => {
|
||||||
if (!selectedSeason) return filteredData;
|
if (!selectedSeason) return filteredData;
|
||||||
@@ -111,19 +128,19 @@ function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVA
|
|||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
// Dynamic lists from data
|
// Dynamic lists from data
|
||||||
const years = useMemo(() => getUniqueYears(data), [data]);
|
const years = useMemo(() => getUniqueYears(permissionFilteredData), [permissionFilteredData]);
|
||||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
const districts = useMemo(() => getUniqueDistricts(permissionFilteredData), [permissionFilteredData]);
|
||||||
const channels = useMemo(() => getUniqueChannels(data), [data]);
|
const channels = useMemo(() => getUniqueChannels(permissionFilteredData), [permissionFilteredData]);
|
||||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
|
const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]);
|
||||||
|
|
||||||
const yoyChange = useMemo(() => {
|
const yoyChange = useMemo(() => {
|
||||||
if (filters.year === 'all') return null;
|
if (filters.year === 'all') return null;
|
||||||
const prevYear = String(parseInt(filters.year) - 1);
|
const prevYear = String(parseInt(filters.year) - 1);
|
||||||
const prevData = data.filter((row: MuseumRecord) => row.year === prevYear);
|
const prevData = permissionFilteredData.filter((row: MuseumRecord) => row.year === prevYear);
|
||||||
if (prevData.length === 0) return null;
|
if (prevData.length === 0) return null;
|
||||||
const prevMetrics = calculateMetrics(prevData, includeVAT);
|
const prevMetrics = calculateMetrics(prevData, includeVAT);
|
||||||
return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null;
|
return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null;
|
||||||
}, [data, filters.year, metrics.revenue, includeVAT]);
|
}, [permissionFilteredData, filters.year, metrics.revenue, includeVAT]);
|
||||||
|
|
||||||
// Revenue trend data (weekly or daily)
|
// Revenue trend data (weekly or daily)
|
||||||
const trendData = useMemo(() => {
|
const trendData = useMemo(() => {
|
||||||
@@ -250,6 +267,27 @@ function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVA
|
|||||||
};
|
};
|
||||||
}, [seasonFilteredData, 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
|
// District data
|
||||||
const districtData = useMemo(() => {
|
const districtData = useMemo(() => {
|
||||||
const grouped = groupByDistrict(seasonFilteredData, includeVAT);
|
const grouped = groupByDistrict(seasonFilteredData, includeVAT);
|
||||||
@@ -264,6 +302,16 @@ function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVA
|
|||||||
};
|
};
|
||||||
}, [seasonFilteredData, 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
|
// Quarterly YoY
|
||||||
const quarterlyYoYData = useMemo(() => {
|
const quarterlyYoYData = useMemo(() => {
|
||||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||||
@@ -400,6 +448,16 @@ function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVA
|
|||||||
|
|
||||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||||
|
|
||||||
|
const pieOptions = useMemo(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, position: 'right' as const, labels: { boxWidth: 12, padding: 10, font: { size: 11 }, color: '#64748b' } },
|
||||||
|
tooltip: baseOptions.plugins.tooltip,
|
||||||
|
datalabels: { display: false }
|
||||||
|
}
|
||||||
|
}), [baseOptions]);
|
||||||
|
|
||||||
// Season annotation bands for revenue trend chart
|
// Season annotation bands for revenue trend chart
|
||||||
const seasonAnnotations = useMemo(() => {
|
const seasonAnnotations = useMemo(() => {
|
||||||
const raw = trendData.rawDates;
|
const raw = trendData.rawDates;
|
||||||
@@ -601,14 +659,40 @@ function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVA
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<ExportableChart filename="visitors-by-event" title={t('dashboard.visitorsByMuseum')} className="chart-container">
|
<ExportableChart
|
||||||
<Bar data={museumData.visitors} options={{...baseOptions, indexAxis: 'y'}} />
|
filename={eventMetric === 'visitors' ? 'visitors-by-event' : 'revenue-by-event'}
|
||||||
</ExportableChart>
|
title={eventMetric === 'visitors' ? t('dashboard.visitorsByMuseum') : t('dashboard.revenueByMuseum')}
|
||||||
</div>
|
className="chart-container"
|
||||||
|
controls={
|
||||||
<div className="chart-card half-width">
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
<ExportableChart filename="revenue-by-event" title={t('dashboard.revenueByMuseum')} className="chart-container">
|
<div className="toggle-switch">
|
||||||
<Bar data={museumData.revenue} options={{...baseOptions, indexAxis: 'y'}} />
|
<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>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -619,14 +703,75 @@ function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVA
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<ExportableChart filename="channel-performance" title={t('dashboard.channelPerformance')} className="chart-container">
|
<ExportableChart
|
||||||
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
|
filename="channel-performance"
|
||||||
|
title={t('dashboard.channelPerformance')}
|
||||||
|
className="chart-container"
|
||||||
|
controls={
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={channelChartType === 'bar' ? 'active' : ''} onClick={() => setChannelChartType('bar')}>{t('metrics.bar')}</button>
|
||||||
|
<button className={channelChartType === 'pie' ? 'active' : ''} onClick={() => setChannelChartType('pie')}>{t('metrics.pie')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={channelDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setChannelDisplayMode('absolute')}>#</button>
|
||||||
|
<button className={channelDisplayMode === 'percent' ? 'active' : ''} onClick={() => setChannelDisplayMode('percent')}>%</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{channelChartType === 'bar'
|
||||||
|
? <Bar data={channelChartData} options={{
|
||||||
|
...baseOptions,
|
||||||
|
indexAxis: 'y',
|
||||||
|
plugins: { ...baseOptions.plugins, datalabels: { ...baseOptions.plugins.datalabels, formatter: (v: number) => channelDisplayMode === 'percent' ? v.toFixed(1) + '%' : baseOptions.plugins.datalabels.formatter(v, {} as any) } },
|
||||||
|
scales: { ...baseOptions.scales, x: { ...baseOptions.scales.x, ticks: { ...baseOptions.scales.x.ticks, callback: (v: number | string) => channelDisplayMode === 'percent' ? v + '%' : v } } }
|
||||||
|
}} />
|
||||||
|
: <Pie data={channelChartData} options={{
|
||||||
|
...pieOptions,
|
||||||
|
plugins: {
|
||||||
|
...pieOptions.plugins,
|
||||||
|
datalabels: channelDisplayMode === 'percent'
|
||||||
|
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
|
||||||
|
: { display: false },
|
||||||
|
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => channelDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
}
|
||||||
</ExportableChart>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<ExportableChart filename="district-performance" title={t('dashboard.districtPerformance')} className="chart-container">
|
<ExportableChart
|
||||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
filename="district-performance"
|
||||||
|
title={t('dashboard.districtPerformance')}
|
||||||
|
className="chart-container"
|
||||||
|
controls={
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={districtChartType === 'bar' ? 'active' : ''} onClick={() => setDistrictChartType('bar')}>{t('metrics.bar')}</button>
|
||||||
|
<button className={districtChartType === 'pie' ? 'active' : ''} onClick={() => setDistrictChartType('pie')}>{t('metrics.pie')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={districtDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setDistrictDisplayMode('absolute')}>#</button>
|
||||||
|
<button className={districtDisplayMode === 'percent' ? 'active' : ''} onClick={() => setDistrictDisplayMode('percent')}>%</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{districtChartType === 'bar'
|
||||||
|
? <Bar data={districtChartData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||||
|
: <Pie data={districtChartData} options={{
|
||||||
|
...pieOptions,
|
||||||
|
plugins: {
|
||||||
|
...pieOptions.plugins,
|
||||||
|
datalabels: districtDisplayMode === 'percent'
|
||||||
|
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
|
||||||
|
: { display: false },
|
||||||
|
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => districtDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
}
|
||||||
</ExportableChart>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -696,18 +841,35 @@ function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVA
|
|||||||
|
|
||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h2>{t('dashboard.visitorsByMuseum')}</h2>
|
<h2>{eventMetric === 'visitors' ? t('dashboard.visitorsByMuseum') : t('dashboard.revenueByMuseum')}</h2>
|
||||||
<div className="chart-container">
|
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
|
||||||
<Bar data={museumData.visitors} options={{...baseOptions, indexAxis: 'y'}} />
|
<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>
|
|
||||||
|
|
||||||
<div className="carousel-slide">
|
|
||||||
<div className="chart-card">
|
|
||||||
<h2>{t('dashboard.revenueByMuseum')}</h2>
|
|
||||||
<div className="chart-container">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -724,8 +886,30 @@ function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVA
|
|||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h2>{t('dashboard.channelPerformance')}</h2>
|
<h2>{t('dashboard.channelPerformance')}</h2>
|
||||||
|
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={channelChartType === 'bar' ? 'active' : ''} onClick={() => setChannelChartType('bar')}>{t('metrics.bar')}</button>
|
||||||
|
<button className={channelChartType === 'pie' ? 'active' : ''} onClick={() => setChannelChartType('pie')}>{t('metrics.pie')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-switch">
|
||||||
|
<button className={channelDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setChannelDisplayMode('absolute')}>#</button>
|
||||||
|
<button className={channelDisplayMode === 'percent' ? 'active' : ''} onClick={() => setChannelDisplayMode('percent')}>%</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="chart-container">
|
<div className="chart-container">
|
||||||
<Bar data={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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -733,8 +917,30 @@ function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVA
|
|||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h2>{t('dashboard.districtPerformance')}</h2>
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -799,6 +1005,15 @@ function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVA
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{userRole === 'admin' && <div className="settings-link">
|
||||||
|
<Link to="/settings">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
{t('nav.settings')}
|
||||||
|
</Link>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
75
src/components/Login.tsx
Normal file
75
src/components/Login.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface LoginProps {
|
||||||
|
onLogin: (name: string, role: string, allowedMuseums: string, allowedChannels: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Login({ onLogin }: LoginProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [pin, setPin] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ pin }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(t('login.invalid'));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
onLogin(data.name || '', data.role || 'viewer', data.allowedMuseums ?? '[]', data.allowedChannels ?? '[]');
|
||||||
|
} catch {
|
||||||
|
setError(t('login.error'));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-brand">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="var(--accent)" aria-hidden="true">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="3" width="7" height="4" rx="1"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="11" width="7" height="10" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
<h1>HiHala Data</h1>
|
||||||
|
</div>
|
||||||
|
<p className="login-subtitle">{t('login.subtitle')}</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={pin}
|
||||||
|
onChange={e => setPin(e.target.value)}
|
||||||
|
placeholder={t('login.placeholder')}
|
||||||
|
autoFocus
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{error && <p className="login-error">{error}</p>}
|
||||||
|
<button type="submit" disabled={loading || !pin}>
|
||||||
|
{loading ? '...' : t('login.submit')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { fetchSeasons, createSeason, updateSeason, deleteSeason } from '../services/seasonsService';
|
import { fetchSeasons, createSeason, updateSeason, deleteSeason } from '../services/seasonsService';
|
||||||
|
import { fetchUsers, createUser, updateUser, deleteUser, type User } from '../services/usersService';
|
||||||
import type { Season } from '../types';
|
import type { Season } from '../types';
|
||||||
|
|
||||||
const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899'];
|
const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899'];
|
||||||
@@ -61,11 +62,111 @@ function SeasonRow({ season, onSave, onDelete }: SeasonRowProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsProps {
|
interface UserRowProps {
|
||||||
onSeasonsChange: () => void;
|
user: User;
|
||||||
|
allMuseums: string[];
|
||||||
|
allChannels: string[];
|
||||||
|
onUpdate: (id: number, fields: Partial<User>) => Promise<void>;
|
||||||
|
onDelete: (id: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Settings({ onSeasonsChange }: SettingsProps) {
|
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 { t } = useLanguage();
|
||||||
const [seasons, setSeasons] = useState<Season[]>([]);
|
const [seasons, setSeasons] = useState<Season[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -78,6 +179,9 @@ function Settings({ onSeasonsChange }: SettingsProps) {
|
|||||||
Color: DEFAULT_COLORS[0],
|
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 () => {
|
const loadSeasons = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await fetchSeasons();
|
const data = await fetchSeasons();
|
||||||
@@ -85,7 +189,17 @@ function Settings({ onSeasonsChange }: SettingsProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { loadSeasons(); }, []);
|
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 () => {
|
const handleCreate = async () => {
|
||||||
if (!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate) return;
|
if (!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate) return;
|
||||||
@@ -162,6 +276,62 @@ function Settings({ onSeasonsChange }: SettingsProps) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
PointElement,
|
PointElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
BarElement,
|
BarElement,
|
||||||
|
ArcElement,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
@@ -21,7 +21,7 @@ ChartJS.register(
|
|||||||
PointElement,
|
PointElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
BarElement,
|
BarElement,
|
||||||
|
ArcElement,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
|
|||||||
@@ -57,7 +57,9 @@
|
|||||||
"avgRevenue": "متوسط الإيراد/زائر",
|
"avgRevenue": "متوسط الإيراد/زائر",
|
||||||
"avgRevenuePerVisitor": "متوسط الإيراد لكل زائر",
|
"avgRevenuePerVisitor": "متوسط الإيراد لكل زائر",
|
||||||
"pilgrims": "المعتمرون",
|
"pilgrims": "المعتمرون",
|
||||||
"captureRate": "نسبة الاستقطاب"
|
"captureRate": "نسبة الاستقطاب",
|
||||||
|
"bar": "أعمدة",
|
||||||
|
"pie": "دائري"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "لوحة التحكم",
|
"title": "لوحة التحكم",
|
||||||
@@ -166,7 +168,21 @@
|
|||||||
"endDate": "تاريخ النهاية",
|
"endDate": "تاريخ النهاية",
|
||||||
"actions": "الإجراءات",
|
"actions": "الإجراءات",
|
||||||
"namePlaceholder": "مثال: رمضان",
|
"namePlaceholder": "مثال: رمضان",
|
||||||
"add": "إضافة"
|
"add": "إضافة",
|
||||||
|
"delete": "حذف",
|
||||||
|
"users": "المستخدمون",
|
||||||
|
"usersHint": "أضف مستخدمين برمز PIN. المشاهدون يمكنهم رؤية لوحة التحكم فقط.",
|
||||||
|
"userName": "الاسم",
|
||||||
|
"userNamePlaceholder": "مثال: أحمد",
|
||||||
|
"userPin": "رمز PIN",
|
||||||
|
"userRole": "الدور"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"subtitle": "أدخل رمز PIN للوصول إلى لوحة التحكم",
|
||||||
|
"placeholder": "رمز PIN",
|
||||||
|
"submit": "تسجيل الدخول",
|
||||||
|
"invalid": "رمز PIN غير صحيح",
|
||||||
|
"error": "خطأ في الاتصال. يرجى المحاولة مرة أخرى."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال ERP API.",
|
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال ERP API.",
|
||||||
|
|||||||
@@ -57,7 +57,9 @@
|
|||||||
"avgRevenue": "Avg Rev/Visitor",
|
"avgRevenue": "Avg Rev/Visitor",
|
||||||
"avgRevenuePerVisitor": "Avg Revenue/Visitor",
|
"avgRevenuePerVisitor": "Avg Revenue/Visitor",
|
||||||
"pilgrims": "Pilgrims",
|
"pilgrims": "Pilgrims",
|
||||||
"captureRate": "Capture Rate"
|
"captureRate": "Capture Rate",
|
||||||
|
"bar": "Bar",
|
||||||
|
"pie": "Pie"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -166,7 +168,21 @@
|
|||||||
"endDate": "End Date",
|
"endDate": "End Date",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"namePlaceholder": "e.g. Ramadan",
|
"namePlaceholder": "e.g. Ramadan",
|
||||||
"add": "Add"
|
"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": {
|
"errors": {
|
||||||
"config": "The dashboard is not configured. Please set up the ERP API connection.",
|
"config": "The dashboard is not configured. Please set up the ERP API connection.",
|
||||||
|
|||||||
58
src/services/usersService.ts
Normal file
58
src/services/usersService.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
export interface User {
|
||||||
|
Id?: number;
|
||||||
|
Name: string;
|
||||||
|
PIN: string;
|
||||||
|
Role: string;
|
||||||
|
AllowedMuseums: string; // JSON-serialized string[], '[]' = unrestricted
|
||||||
|
AllowedChannels: string; // JSON-serialized string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// null = parse error → fail-closed (show nothing)
|
||||||
|
// [] = unrestricted (admin or no restriction set)
|
||||||
|
// string[] = restricted to this list
|
||||||
|
export function parseAllowed(raw: string | undefined | null): string[] | null {
|
||||||
|
if (raw == null) return []; // field not set → unrestricted
|
||||||
|
if (raw === '[]') return []; // explicit empty → unrestricted
|
||||||
|
if (raw === '') return null; // blank string → corrupted → fail-closed
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return null;
|
||||||
|
return parsed as string[];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUsers(): Promise<User[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users');
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return res.json();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(user: Omit<User, 'Id'>): Promise<User> {
|
||||||
|
const res = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(user),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to create user');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id: number, fields: Partial<User>): Promise<void> {
|
||||||
|
const res = await fetch(`/api/users/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(fields),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to update user');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: number): Promise<void> {
|
||||||
|
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error('Failed to delete user');
|
||||||
|
}
|
||||||
@@ -131,10 +131,13 @@ export interface ChartData {
|
|||||||
export interface DashboardProps {
|
export interface DashboardProps {
|
||||||
data: MuseumRecord[];
|
data: MuseumRecord[];
|
||||||
seasons: Season[];
|
seasons: Season[];
|
||||||
|
userRole: string;
|
||||||
showDataLabels: boolean;
|
showDataLabels: boolean;
|
||||||
setShowDataLabels: (value: boolean) => void;
|
setShowDataLabels: (value: boolean) => void;
|
||||||
includeVAT: boolean;
|
includeVAT: boolean;
|
||||||
setIncludeVAT: (value: boolean) => void;
|
setIncludeVAT: (value: boolean) => void;
|
||||||
|
allowedMuseums: string[] | null;
|
||||||
|
allowedChannels: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComparisonProps {
|
export interface ComparisonProps {
|
||||||
@@ -144,6 +147,8 @@ export interface ComparisonProps {
|
|||||||
setShowDataLabels: (value: boolean) => void;
|
setShowDataLabels: (value: boolean) => void;
|
||||||
includeVAT: boolean;
|
includeVAT: boolean;
|
||||||
setIncludeVAT: (value: boolean) => void;
|
setIncludeVAT: (value: boolean) => void;
|
||||||
|
allowedMuseums: string[] | null;
|
||||||
|
allowedChannels: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SlidesProps {
|
export interface SlidesProps {
|
||||||
|
|||||||
45
start-dev.sh
45
start-dev.sh
@@ -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
|
|
||||||
39
start.sh
39
start.sh
@@ -1,46 +1,45 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Launch both NocoDB (backend) and React (frontend)
|
# Start local dev environment: NocoDB + Express server + Vite
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
echo ""
|
echo ""
|
||||||
echo "Shutting down..."
|
echo "Shutting down..."
|
||||||
if [ -n "$REACT_PID" ]; then
|
kill $SERVER_PID $CLIENT_PID 2>/dev/null
|
||||||
kill "$REACT_PID" 2>/dev/null
|
|
||||||
fi
|
|
||||||
docker stop nocodb 2>/dev/null
|
docker stop nocodb 2>/dev/null
|
||||||
echo "Done."
|
echo "Done."
|
||||||
}
|
}
|
||||||
|
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
# Start NocoDB container
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# Start NocoDB
|
||||||
if docker ps --format '{{.Names}}' | grep -q '^nocodb$'; then
|
if docker ps --format '{{.Names}}' | grep -q '^nocodb$'; then
|
||||||
echo "NocoDB already running on port 8090"
|
echo "NocoDB already running on port 8090"
|
||||||
else
|
else
|
||||||
echo "Starting NocoDB..."
|
echo "Starting NocoDB..."
|
||||||
docker start nocodb 2>/dev/null || docker run -d \
|
docker start nocodb 2>/dev/null || docker run -d \
|
||||||
--name nocodb \
|
--name nocodb -p 8090:8080 nocodb/nocodb:latest
|
||||||
-p 8090:8080 \
|
|
||||||
nocodb/nocodb:latest
|
|
||||||
echo "NocoDB started on port 8090"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Wait for NocoDB to be ready
|
|
||||||
echo "Waiting for NocoDB..."
|
echo "Waiting for NocoDB..."
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if curl -s http://localhost:8090/api/v1/health >/dev/null 2>&1; then
|
curl -s http://localhost:8090/api/v1/health >/dev/null 2>&1 && echo "NocoDB ready" && break
|
||||||
echo "NocoDB is ready"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
# Start React dev server
|
# Start Express server (port 3002)
|
||||||
echo "Starting React dev server..."
|
echo "Starting Express server..."
|
||||||
cd "$(dirname "$0")"
|
(cd server && npm run dev) &
|
||||||
npm start &
|
SERVER_PID=$!
|
||||||
REACT_PID=$!
|
|
||||||
|
|
||||||
wait $REACT_PID
|
sleep 2
|
||||||
|
|
||||||
|
# Start Vite (port 3000)
|
||||||
|
echo "Starting Vite..."
|
||||||
|
npx vite &
|
||||||
|
CLIENT_PID=$!
|
||||||
|
|
||||||
|
wait $CLIENT_PID
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
'/auth': {
|
||||||
|
target: 'http://localhost:3002',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
'/api/erp': {
|
'/api/erp': {
|
||||||
target: 'http://localhost:3002',
|
target: 'http://localhost:3002',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
@@ -14,11 +18,15 @@ export default defineConfig({
|
|||||||
target: 'http://localhost:3002',
|
target: 'http://localhost:3002',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
'/api/users': {
|
||||||
|
target: 'http://localhost:3002',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
'/api/seasons': {
|
'/api/seasons': {
|
||||||
target: 'http://localhost:3002',
|
target: 'http://localhost:3002',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/api': {
|
'/api/v2': {
|
||||||
target: 'http://localhost:8090',
|
target: 'http://localhost:8090',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user