Compare commits
17 Commits
04789ea9a1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8d33f4f8c | ||
|
|
f3ce7705d6 | ||
|
|
70af4962a6 | ||
|
|
8cf6f9eedd | ||
|
|
c99f2abe10 | ||
|
|
a06436baac | ||
|
|
9657a9d221 | ||
|
|
3c19dee236 | ||
|
|
b4c436f909 | ||
|
|
db6a6ac609 | ||
|
|
ef48372033 | ||
|
|
1dd216f933 | ||
|
|
ac5b23326c | ||
|
|
3912b3dd41 | ||
|
|
9332cae350 | ||
|
|
aa9813aed4 | ||
|
|
fba72692ee |
@@ -13,6 +13,7 @@ jobs:
|
|||||||
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 }}
|
||||||
@@ -22,6 +23,41 @@ jobs:
|
|||||||
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
|
||||||
|
|
||||||
|
- name: Restart server service
|
||||||
|
run: sudo systemctl restart hihala-dashboard.service
|
||||||
|
|||||||
118
docs/superpowers/specs/2026-03-31-hijri-seasons-design.md
Normal file
118
docs/superpowers/specs/2026-03-31-hijri-seasons-design.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Hijri Seasons Feature
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add configurable hijri seasons (Ramadan, Hajj, etc.) to the dashboard as a presentation overlay. Seasons are user-defined with Gregorian date ranges (since hijri months shift ~11 days each year). They appear as filter presets, chart bands, and are managed through a settings page.
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
New NocoDB `Seasons` table:
|
||||||
|
|
||||||
|
| Column | Type | Example |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Name | string | `Ramadan` |
|
||||||
|
| HijriYear | number | `1446` |
|
||||||
|
| StartDate | string | `2025-02-28` |
|
||||||
|
| EndDate | string | `2025-03-30` |
|
||||||
|
| Color | string | `#10b981` |
|
||||||
|
|
||||||
|
Read on dashboard load alongside PilgrimStats. Written via server proxy to keep NocoDB credentials server-side.
|
||||||
|
|
||||||
|
**Loading lifecycle:** Seasons load independently of the main data fetch. A failure to load seasons degrades gracefully — seasons state defaults to `[]`, the dashboard renders normally without bands or season presets. Seasons are non-blocking and non-critical.
|
||||||
|
|
||||||
|
## Server Changes
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|----------------|
|
||||||
|
| `server/src/routes/seasons.ts` | `GET /api/seasons` (read all), `POST /api/seasons` (create), `PUT /api/seasons/:id` (update), `DELETE /api/seasons/:id` (delete) |
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `server/src/index.ts` | Mount seasons routes at `/api/seasons` |
|
||||||
|
| `server/src/services/nocodbClient.ts` | Add generic CRUD helpers typed as `<T extends Record<string, unknown>>` so both ETL and seasons routes can share them without coupling |
|
||||||
|
| `vite.config.ts` | Add `/api/seasons` proxy rule **before** the catch-all `/api` rule (same pattern as `/api/erp`). Order: `/api/erp` → `/api/etl` → `/api/seasons` → `/api` |
|
||||||
|
|
||||||
|
## Client Changes
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|----------------|
|
||||||
|
| `src/components/Settings.tsx` | Settings page with seasons CRUD table |
|
||||||
|
| `src/services/seasonsService.ts` | Fetch/create/update/delete seasons via server proxy |
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `src/types/index.ts` | Add `Season` interface |
|
||||||
|
| `src/App.tsx` | Add `/settings` route, nav link (both desktop and mobile bottom nav), load seasons on mount (non-blocking) |
|
||||||
|
| `src/components/Dashboard.tsx` | Add season filter dropdown, chart annotation bands |
|
||||||
|
| `src/components/Comparison.tsx` | Add season filter as period preset |
|
||||||
|
| `src/config/chartConfig.ts` | Import and register `chartjs-plugin-annotation` in the central `ChartJS.register()` call |
|
||||||
|
| `src/locales/en.json` | Settings page labels, season filter labels |
|
||||||
|
| `src/locales/ar.json` | Arabic translations |
|
||||||
|
| `package.json` | Add `chartjs-plugin-annotation` dependency |
|
||||||
|
|
||||||
|
## Season Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface Season {
|
||||||
|
Id?: number;
|
||||||
|
Name: string;
|
||||||
|
HijriYear: number;
|
||||||
|
StartDate: string;
|
||||||
|
EndDate: string;
|
||||||
|
Color: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Settings Page (`/settings`)
|
||||||
|
|
||||||
|
New route accessible from the nav bar (gear icon on desktop, gear in mobile bottom nav). Contains:
|
||||||
|
|
||||||
|
- **Seasons table**: lists all defined seasons with columns: Name, Hijri Year, Start Date, End Date, Color, Actions (edit/delete)
|
||||||
|
- **Add season form**: inline row at the bottom of the table with inputs for each field + color picker + save button
|
||||||
|
- **Edit**: click a row to edit inline
|
||||||
|
- **Delete**: delete button per row with confirmation
|
||||||
|
- **No empty state needed**: just show the empty table with the add form
|
||||||
|
|
||||||
|
## Period Filter Integration
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
Add a "Season" select in the filters section (after Quarter). Options populated from the loaded seasons list:
|
||||||
|
- `All Seasons` (default — no date filtering from season)
|
||||||
|
- `Ramadan 1446 (Feb 28 – Mar 30, 2025)`
|
||||||
|
- `Hajj 1446 (Jun 4 – Jun 9, 2025)`
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
Selecting a season sets a date range filter on the data — equivalent to filtering by start/end date. This works alongside existing year/district/channel/event filters.
|
||||||
|
|
||||||
|
Implementation: when a season is selected, filter data to `row.date >= season.StartDate && row.date <= season.EndDate`. Store the selected season ID in state (not URL params — seasons are dynamic).
|
||||||
|
|
||||||
|
### Comparison
|
||||||
|
|
||||||
|
Seasons appear as preset period options alongside months/quarters. Selecting "Ramadan 1446" sets the period dates and auto-compares with the same season name in the previous hijri year if defined (e.g. "Ramadan 1445").
|
||||||
|
|
||||||
|
## Chart Bands (Revenue Trend)
|
||||||
|
|
||||||
|
Uses `chartjs-plugin-annotation` to draw semi-transparent vertical bands on the revenue trend chart. Must be registered in `chartConfig.ts` via `ChartJS.register(Annotation)`.
|
||||||
|
|
||||||
|
For each season whose date range overlaps the chart's visible range:
|
||||||
|
- Draw a vertical box from `season.StartDate` to `season.EndDate`
|
||||||
|
- Fill with `season.Color` at 15% opacity
|
||||||
|
- Label at the top with season name + hijri year
|
||||||
|
|
||||||
|
Only the revenue trend chart gets bands (it's the only time-series chart where seasons make visual sense).
|
||||||
|
|
||||||
|
## What's NOT Changing
|
||||||
|
|
||||||
|
- ETL pipeline unchanged — seasons are a UI/presentation concern
|
||||||
|
- NocoDB DailySales schema unchanged
|
||||||
|
- All existing filters (year, district, channel, event, quarter) unchanged
|
||||||
|
- Seasons don't affect data aggregation or storage
|
||||||
@@ -5,11 +5,11 @@
|
|||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#f8fafc" />
|
<meta name="theme-color" content="#f8fafc" />
|
||||||
<meta name="description" content="HiHala Data Dashboard — Museum analytics, visitor tracking, and revenue insights" />
|
<meta name="description" content="HiHala Data Dashboard — Event analytics, visitor tracking, and revenue insights" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<title>HiHala Data – Museums</title>
|
<title>HiHala Data</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"chartjs-plugin-annotation": "^3.1.0",
|
||||||
"chartjs-plugin-datalabels": "^2.2.0",
|
"chartjs-plugin-datalabels": "^2.2.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
@@ -1571,6 +1572,15 @@
|
|||||||
"pnpm": ">=8"
|
"pnpm": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chartjs-plugin-annotation": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chartjs-plugin-datalabels": {
|
"node_modules/chartjs-plugin-datalabels": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"chartjs-plugin-annotation": "^3.1.0",
|
||||||
"chartjs-plugin-datalabels": "^2.2.0",
|
"chartjs-plugin-datalabels": "^2.2.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
@@ -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,16 +1,24 @@
|
|||||||
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 usersRoutes from './routes/users';
|
||||||
|
|
||||||
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/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}`);
|
||||||
|
|||||||
95
server/src/routes/auth.ts
Normal file
95
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { auth } from '../config';
|
||||||
|
import { discoverTableIds, fetchAllRecords } from '../services/nocodbClient';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
interface UserRecord {
|
||||||
|
Id: number;
|
||||||
|
Name: string;
|
||||||
|
PIN: string;
|
||||||
|
Role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, Session>();
|
||||||
|
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
|
||||||
|
function generateSessionId(): string {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSession(sessionId: string): Session | null {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
if (Date.now() - session.createdAt > SESSION_MAX_AGE) {
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /auth/login
|
||||||
|
router.post('/login', async (req: Request, res: Response) => {
|
||||||
|
const { pin } = req.body;
|
||||||
|
if (!pin) {
|
||||||
|
res.status(400).json({ error: 'PIN required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check super admin PIN from env first
|
||||||
|
if (auth.adminPin && pin === auth.adminPin) {
|
||||||
|
const sessionId = generateSessionId();
|
||||||
|
sessions.set(sessionId, { name: 'Admin', role: 'admin', createdAt: Date.now() });
|
||||||
|
res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' });
|
||||||
|
res.json({ ok: true, name: 'Admin', role: 'admin' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check NocoDB Users table
|
||||||
|
try {
|
||||||
|
const tables = await discoverTableIds();
|
||||||
|
if (tables['Users']) {
|
||||||
|
const users = await fetchAllRecords<UserRecord>(tables['Users']);
|
||||||
|
const user = users.find(u => u.PIN === pin);
|
||||||
|
if (user) {
|
||||||
|
const sessionId = generateSessionId();
|
||||||
|
sessions.set(sessionId, { name: user.Name, role: user.Role || 'viewer', createdAt: Date.now() });
|
||||||
|
res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' });
|
||||||
|
res.json({ ok: true, name: user.Name, role: user.Role || 'viewer' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to check Users table:', (err as Error).message);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json({ error: 'Invalid PIN' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /auth/check
|
||||||
|
router.get('/check', (req: Request, res: Response) => {
|
||||||
|
const sessionId = req.cookies?.hihala_session;
|
||||||
|
const session = sessionId ? getSession(sessionId) : null;
|
||||||
|
res.json({
|
||||||
|
authenticated: !!session,
|
||||||
|
name: session?.name || null,
|
||||||
|
role: session?.role || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /auth/logout
|
||||||
|
router.post('/logout', (req: Request, res: Response) => {
|
||||||
|
const sessionId = req.cookies?.hihala_session;
|
||||||
|
if (sessionId) sessions.delete(sessionId);
|
||||||
|
res.clearCookie('hihala_session', { path: '/' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
63
server/src/routes/seasons.ts
Normal file
63
server/src/routes/seasons.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { discoverTableIds, fetchAllRecords, createRecord, updateRecord, deleteRecord } from '../services/nocodbClient';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
async function getSeasonsTableId(): Promise<string> {
|
||||||
|
const tables = await discoverTableIds();
|
||||||
|
const id = tables['Seasons'];
|
||||||
|
if (!id) throw new Error("NocoDB table 'Seasons' not found");
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/seasons
|
||||||
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getSeasonsTableId();
|
||||||
|
const records = await fetchAllRecords(tableId);
|
||||||
|
res.json(records);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch seasons:', (err as Error).message);
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/seasons
|
||||||
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getSeasonsTableId();
|
||||||
|
const result = await createRecord(tableId, req.body);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create season:', (err as Error).message);
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/seasons/:id
|
||||||
|
router.put('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getSeasonsTableId();
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const result = await updateRecord(tableId, id, req.body);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update season:', (err as Error).message);
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/seasons/:id
|
||||||
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getSeasonsTableId();
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
await deleteRecord(tableId, id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete season:', (err as Error).message);
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
46
server/src/routes/users.ts
Normal file
46
server/src/routes/users.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { discoverTableIds, fetchAllRecords, createRecord, deleteRecord } from '../services/nocodbClient';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
async function getUsersTableId(): Promise<string> {
|
||||||
|
const tables = await discoverTableIds();
|
||||||
|
const id = tables['Users'];
|
||||||
|
if (!id) throw new Error("NocoDB table 'Users' not found");
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/users
|
||||||
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getUsersTableId();
|
||||||
|
const records = await fetchAllRecords(tableId);
|
||||||
|
res.json(records);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/users
|
||||||
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getUsersTableId();
|
||||||
|
const result = await createRecord(tableId, req.body);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/users/:id
|
||||||
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tableId = await getUsersTableId();
|
||||||
|
await deleteRecord(tableId, parseInt(req.params.id));
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { nocodb } from '../config';
|
import { nocodb } from '../config';
|
||||||
import type { AggregatedRecord } from '../types';
|
|
||||||
|
|
||||||
let discoveredTables: Record<string, string> | null = null;
|
let discoveredTables: Record<string, string> | null = null;
|
||||||
|
|
||||||
@@ -91,8 +90,7 @@ export async function deleteAllRows(tableId: string): Promise<number> {
|
|||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function insertRecords(tableId: string, records: AggregatedRecord[]): Promise<number> {
|
export async function insertRecords<T extends Record<string, unknown>>(tableId: string, records: T[]): Promise<number> {
|
||||||
// NocoDB bulk insert accepts max 100 records at a time
|
|
||||||
const batchSize = 100;
|
const batchSize = 100;
|
||||||
let inserted = 0;
|
let inserted = 0;
|
||||||
|
|
||||||
@@ -107,3 +105,43 @@ export async function insertRecords(tableId: string, records: AggregatedRecord[]
|
|||||||
|
|
||||||
return inserted;
|
return inserted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generic CRUD helpers
|
||||||
|
|
||||||
|
export async function fetchAllRecords<T>(tableId: string): Promise<T[]> {
|
||||||
|
let all: T[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const json = await fetchJson(
|
||||||
|
`${nocodb.url}/api/v2/tables/${tableId}/records?limit=1000&offset=${offset}`
|
||||||
|
) as { list: T[] };
|
||||||
|
|
||||||
|
all = all.concat(json.list);
|
||||||
|
if (json.list.length < 1000) break;
|
||||||
|
offset += 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRecord<T extends Record<string, unknown>>(tableId: string, record: T): Promise<T> {
|
||||||
|
return await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(record),
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRecord<T extends Record<string, unknown>>(tableId: string, id: number, record: T): Promise<T> {
|
||||||
|
return await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ Id: id, ...record }),
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRecord(tableId: string, id: number): Promise<void> {
|
||||||
|
await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify([{ Id: id }]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
295
src/App.css
295
src/App.css
@@ -762,6 +762,301 @@ table tbody tr:hover {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Multi-select */
|
||||||
|
.multi-select {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-trigger:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-arrow {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
opacity: 0.4;
|
||||||
|
margin-inline-start: 8px;
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-trigger[aria-expanded="true"] .multi-select-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 200px;
|
||||||
|
width: max-content;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
z-index: 50;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-dropdown,
|
||||||
|
.multi-select-dropdown * {
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login page */
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card input {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card input:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card button {
|
||||||
|
padding: 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card button:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
color: var(--danger, #dc2626);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-link {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-link a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-link a:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings page */
|
||||||
|
.settings-page {
|
||||||
|
padding: 32px;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-edit-name {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-edit-name input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-edit-name input[type="color"] {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small.btn-danger {
|
||||||
|
color: var(--danger, #dc2626);
|
||||||
|
border-color: var(--danger, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small.btn-danger:hover {
|
||||||
|
background: var(--danger, #dc2626);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.add-row td {
|
||||||
|
border-top: 2px dashed var(--border);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.editing td {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-page input[type="text"],
|
||||||
|
.settings-page input[type="number"],
|
||||||
|
.settings-page input[type="date"],
|
||||||
|
.settings-page select {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.period-display {
|
.period-display {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|||||||
67
src/App.tsx
67
src/App.tsx
@@ -3,10 +3,13 @@ import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react
|
|||||||
|
|
||||||
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'));
|
||||||
|
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 } from './services/dataService';
|
||||||
|
import { fetchSeasons } from './services/seasonsService';
|
||||||
import { useLanguage } from './contexts/LanguageContext';
|
import { useLanguage } from './contexts/LanguageContext';
|
||||||
import type { MuseumRecord, CacheStatus, DataErrorType } from './types';
|
import type { MuseumRecord, Season, CacheStatus, DataErrorType } from './types';
|
||||||
import { DataError } from './types';
|
import { DataError } from './types';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -34,6 +37,9 @@ 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 [data, setData] = useState<MuseumRecord[]>([]);
|
const [data, setData] = useState<MuseumRecord[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||||
@@ -43,6 +49,7 @@ function App() {
|
|||||||
const [showDataLabels, setShowDataLabels] = useState<boolean>(false);
|
const [showDataLabels, setShowDataLabels] = useState<boolean>(false);
|
||||||
const [includeVAT, setIncludeVAT] = useState<boolean>(true);
|
const [includeVAT, setIncludeVAT] = useState<boolean>(true);
|
||||||
const [dataSource, setDataSource] = useState<string>('museums');
|
const [dataSource, setDataSource] = useState<string>('museums');
|
||||||
|
const [seasons, setSeasons] = useState<Season[]>([]);
|
||||||
const [theme, setTheme] = useState<string>(() => {
|
const [theme, setTheme] = useState<string>(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
return localStorage.getItem('hihala_theme') || 'light';
|
return localStorage.getItem('hihala_theme') || 'light';
|
||||||
@@ -97,15 +104,58 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadSeasons = useCallback(async () => {
|
||||||
|
const s = await fetchSeasons();
|
||||||
|
setSeasons(s);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Check auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
fetch('/auth/check', { credentials: 'include' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
setAuthenticated(d.authenticated);
|
||||||
|
if (d.authenticated) {
|
||||||
|
setUserRole(d.role || 'viewer');
|
||||||
|
setUserName(d.name || '');
|
||||||
|
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) => {
|
||||||
|
setAuthenticated(true);
|
||||||
|
setUserName(name);
|
||||||
|
setUserRole(role);
|
||||||
|
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}>
|
||||||
@@ -237,8 +287,9 @@ function App() {
|
|||||||
<main>
|
<main>
|
||||||
<Suspense fallback={<LoadingSkeleton />}>
|
<Suspense fallback={<LoadingSkeleton />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
|
<Route path="/" element={<Dashboard data={data} seasons={seasons} userRole={userRole} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
|
||||||
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
|
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
|
||||||
|
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} />} />}
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
@@ -262,6 +313,14 @@ function App() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{t('nav.compare')}</span>
|
<span>{t('nav.compare')}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
{userRole === 'admin' && (
|
||||||
|
<NavLink to="/settings" className="mobile-nav-item">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
<span>{t('nav.settings')}</span>
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="mobile-nav-item"
|
className="mobile-nav-item"
|
||||||
onClick={switchLanguage}
|
onClick={switchLanguage}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Line, Bar } from 'react-chartjs-2';
|
import { Line, Bar } from 'react-chartjs-2';
|
||||||
import { EmptyState, FilterControls } from './shared';
|
import { EmptyState, FilterControls, MultiSelect } from './shared';
|
||||||
import { ExportableChart } from './ChartExport';
|
import { ExportableChart } from './ChartExport';
|
||||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
getMuseumsForDistrict,
|
getMuseumsForDistrict,
|
||||||
getLatestYear
|
getLatestYear
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import type { MuseumRecord, ComparisonProps, DateRangeFilters } from '../types';
|
import type { MuseumRecord, ComparisonProps, DateRangeFilters, Season } from '../types';
|
||||||
|
|
||||||
interface PresetDateRange {
|
interface PresetDateRange {
|
||||||
start: string;
|
start: string;
|
||||||
@@ -63,7 +63,7 @@ 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, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: ComparisonProps) {
|
function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: ComparisonProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
@@ -95,7 +95,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
||||||
return dates[urlPreset].start;
|
return dates[urlPreset].start;
|
||||||
}
|
}
|
||||||
return searchParams.get('from') || `${year}-01-01`;
|
// Season presets store from/to in URL
|
||||||
|
const fromParam = searchParams.get('from');
|
||||||
|
if (fromParam) return fromParam;
|
||||||
|
return `${year}-01-01`;
|
||||||
});
|
});
|
||||||
const [endDate, setEndDateState] = useState(() => {
|
const [endDate, setEndDateState] = useState(() => {
|
||||||
const urlPreset = searchParams.get('preset');
|
const urlPreset = searchParams.get('preset');
|
||||||
@@ -105,12 +108,15 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
||||||
return dates[urlPreset].end;
|
return dates[urlPreset].end;
|
||||||
}
|
}
|
||||||
return searchParams.get('to') || `${year}-01-31`;
|
// Season presets store from/to in URL
|
||||||
|
const toParam = searchParams.get('to');
|
||||||
|
if (toParam) return toParam;
|
||||||
|
return `${year}-01-31`;
|
||||||
});
|
});
|
||||||
const [filters, setFiltersState] = useState(() => ({
|
const [filters, setFiltersState] = useState(() => ({
|
||||||
district: searchParams.get('district') || 'all',
|
district: searchParams.get('district') || 'all',
|
||||||
channel: searchParams.get('channel') || 'all',
|
channel: searchParams.get('channel')?.split(',').filter(Boolean) || [],
|
||||||
museum: searchParams.get('museum') || 'all'
|
museum: searchParams.get('museum')?.split(',').filter(Boolean) || []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const [chartMetric, setChartMetric] = useState('revenue');
|
const [chartMetric, setChartMetric] = useState('revenue');
|
||||||
@@ -123,20 +129,20 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (newPreset && newPreset !== 'jan') params.set('preset', newPreset);
|
if (newPreset && newPreset !== 'jan') params.set('preset', newPreset);
|
||||||
if (newYear && newYear !== latestYear) params.set('year', newYear.toString());
|
if (newYear && newYear !== latestYear) params.set('year', newYear.toString());
|
||||||
if (newPreset === 'custom') {
|
if (newPreset === 'custom' || newPreset.startsWith('season-')) {
|
||||||
if (newFrom) params.set('from', newFrom);
|
if (newFrom) params.set('from', newFrom);
|
||||||
if (newTo) params.set('to', newTo);
|
if (newTo) params.set('to', newTo);
|
||||||
}
|
}
|
||||||
if (newFilters?.district && newFilters.district !== 'all') params.set('district', newFilters.district);
|
if (newFilters?.district && newFilters.district !== 'all') params.set('district', newFilters.district);
|
||||||
if (newFilters?.channel && newFilters.channel !== 'all') params.set('channel', newFilters.channel);
|
if (newFilters?.channel && newFilters.channel.length > 0) params.set('channel', newFilters.channel.join(','));
|
||||||
if (newFilters?.museum && newFilters.museum !== 'all') params.set('museum', newFilters.museum);
|
if (newFilters?.museum && newFilters.museum.length > 0) params.set('museum', newFilters.museum.join(','));
|
||||||
setSearchParams(params, { replace: true });
|
setSearchParams(params, { replace: true });
|
||||||
}, [setSearchParams, latestYear]);
|
}, [setSearchParams, latestYear]);
|
||||||
|
|
||||||
const setSelectedYear = (year: number) => {
|
const setSelectedYear = (year: number) => {
|
||||||
setSelectedYearState(year);
|
setSelectedYearState(year);
|
||||||
const newDates = generatePresetDates(year);
|
const newDates = generatePresetDates(year);
|
||||||
if (preset !== 'custom' && newDates[preset]) {
|
if (preset !== 'custom' && !preset.startsWith('season-') && newDates[preset]) {
|
||||||
setStartDateState(newDates[preset].start);
|
setStartDateState(newDates[preset].start);
|
||||||
setEndDateState(newDates[preset].end);
|
setEndDateState(newDates[preset].end);
|
||||||
}
|
}
|
||||||
@@ -145,7 +151,15 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
|
|
||||||
const setPreset = (value: string) => {
|
const setPreset = (value: string) => {
|
||||||
setPresetState(value);
|
setPresetState(value);
|
||||||
if (value !== 'custom' && presetDates[value]) {
|
if (value.startsWith('season-')) {
|
||||||
|
const seasonId = parseInt(value.replace('season-', ''));
|
||||||
|
const season = seasons.find(s => s.Id === seasonId);
|
||||||
|
if (season) {
|
||||||
|
setStartDateState(season.StartDate);
|
||||||
|
setEndDateState(season.EndDate);
|
||||||
|
updateUrl(value, season.StartDate, season.EndDate, filters, selectedYear);
|
||||||
|
}
|
||||||
|
} else if (value !== 'custom' && presetDates[value]) {
|
||||||
setStartDateState(presetDates[value].start);
|
setStartDateState(presetDates[value].start);
|
||||||
setEndDateState(presetDates[value].end);
|
setEndDateState(presetDates[value].end);
|
||||||
updateUrl(value, null, null, filters, selectedYear);
|
updateUrl(value, null, null, filters, selectedYear);
|
||||||
@@ -227,13 +241,29 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
|
const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
|
||||||
|
|
||||||
// Year-over-year comparison: same dates, previous year
|
// Year-over-year comparison: same dates, previous year
|
||||||
const ranges = useMemo(() => ({
|
// For season presets, try to find the same season name from the previous hijri year
|
||||||
curr: { start: startDate, end: endDate },
|
const ranges = useMemo(() => {
|
||||||
prev: {
|
const curr = { start: startDate, end: endDate };
|
||||||
|
let prev = {
|
||||||
start: startDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1)),
|
start: startDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1)),
|
||||||
end: endDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1))
|
end: endDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1))
|
||||||
|
};
|
||||||
|
|
||||||
|
if (preset.startsWith('season-')) {
|
||||||
|
const seasonId = parseInt(preset.replace('season-', ''));
|
||||||
|
const currentSeason = seasons.find(s => s.Id === seasonId);
|
||||||
|
if (currentSeason) {
|
||||||
|
const prevSeason = seasons.find(
|
||||||
|
s => s.Name === currentSeason.Name && s.HijriYear === currentSeason.HijriYear - 1
|
||||||
|
);
|
||||||
|
if (prevSeason) {
|
||||||
|
prev = { start: prevSeason.StartDate, end: prevSeason.EndDate };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}), [startDate, endDate]);
|
|
||||||
|
return { curr, prev };
|
||||||
|
}, [startDate, endDate, preset, seasons]);
|
||||||
|
|
||||||
const prevData = useMemo(() =>
|
const prevData = useMemo(() =>
|
||||||
filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters),
|
filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters),
|
||||||
@@ -249,7 +279,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
const currMetrics = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]);
|
const currMetrics = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]);
|
||||||
|
|
||||||
const hasData = prevData.length > 0 || currData.length > 0;
|
const hasData = prevData.length > 0 || currData.length > 0;
|
||||||
const resetFilters = () => setFilters({ district: 'all', channel: 'all', museum: 'all' });
|
const resetFilters = () => setFilters({ district: 'all', channel: [], museum: [] });
|
||||||
|
|
||||||
const calcChange = (prev: number, curr: number) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
const calcChange = (prev: number, curr: number) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
||||||
|
|
||||||
@@ -502,11 +532,53 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
}, [data, prevData, currData, ranges, chartMetric, getMetricValue, getPeriodLabel]);
|
}, [data, prevData, currData, ranges, chartMetric, getMetricValue, getPeriodLabel]);
|
||||||
|
|
||||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||||
|
|
||||||
|
// Map seasons to annotation bands on the current period's timeline
|
||||||
|
const seasonAnnotations = useMemo(() => {
|
||||||
|
if (!seasons.length) return {};
|
||||||
|
const currStart = new Date(ranges.curr.start);
|
||||||
|
const currEnd = new Date(ranges.curr.end);
|
||||||
|
const annotations: Record<string, unknown> = {};
|
||||||
|
const msPerDay = 1000 * 60 * 60 * 24;
|
||||||
|
const granDivisor = chartGranularity === 'month' ? 30 : chartGranularity === 'week' ? 7 : 1;
|
||||||
|
|
||||||
|
seasons.forEach((s, i) => {
|
||||||
|
const sStart = new Date(s.StartDate);
|
||||||
|
const sEnd = new Date(s.EndDate);
|
||||||
|
// Check overlap with current period
|
||||||
|
if (sEnd < currStart || sStart > currEnd) return;
|
||||||
|
|
||||||
|
const clampedStart = sStart < currStart ? currStart : sStart;
|
||||||
|
const clampedEnd = sEnd > currEnd ? currEnd : sEnd;
|
||||||
|
const startIdx = Math.floor((clampedStart.getTime() - currStart.getTime()) / msPerDay / granDivisor);
|
||||||
|
const endIdx = Math.floor((clampedEnd.getTime() - currStart.getTime()) / msPerDay / granDivisor);
|
||||||
|
|
||||||
|
annotations[`season${i}`] = {
|
||||||
|
type: 'box',
|
||||||
|
xMin: startIdx - 0.5,
|
||||||
|
xMax: endIdx + 0.5,
|
||||||
|
backgroundColor: s.Color + '20',
|
||||||
|
borderColor: s.Color + '40',
|
||||||
|
borderWidth: 1,
|
||||||
|
label: {
|
||||||
|
display: true,
|
||||||
|
content: `${s.Name} ${s.HijriYear}`,
|
||||||
|
position: 'start',
|
||||||
|
color: s.Color,
|
||||||
|
font: { size: 10, weight: '600' },
|
||||||
|
padding: 4
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return annotations;
|
||||||
|
}, [seasons, ranges.curr, chartGranularity]);
|
||||||
|
|
||||||
const chartOptions: any = {
|
const chartOptions: any = {
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
plugins: {
|
plugins: {
|
||||||
...baseOptions.plugins,
|
...baseOptions.plugins,
|
||||||
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } }
|
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } },
|
||||||
|
annotation: { annotations: seasonAnnotations }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -559,9 +631,18 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
<option value="h1">{t('time.h1')}</option>
|
<option value="h1">{t('time.h1')}</option>
|
||||||
<option value="h2">{t('time.h2')}</option>
|
<option value="h2">{t('time.h2')}</option>
|
||||||
<option value="full">{t('time.fullYear')}</option>
|
<option value="full">{t('time.fullYear')}</option>
|
||||||
|
{seasons.length > 0 && (
|
||||||
|
<optgroup label={t('comparison.seasons') || 'Seasons'}>
|
||||||
|
{seasons.map(s => (
|
||||||
|
<option key={s.Id} value={`season-${s.Id}`}>
|
||||||
|
{s.Name} {s.HijriYear}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
{preset !== 'custom' && (
|
{preset !== 'custom' && !preset.startsWith('season-') && (
|
||||||
<FilterControls.Group label={t('filters.year')}>
|
<FilterControls.Group label={t('filters.year')}>
|
||||||
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
|
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
|
||||||
{availableYears.map(y => (
|
{availableYears.map(y => (
|
||||||
@@ -570,7 +651,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
)}
|
)}
|
||||||
{preset === 'custom' && (
|
{(preset === 'custom' || preset.startsWith('season-')) && (
|
||||||
<>
|
<>
|
||||||
<FilterControls.Group label={t('comparison.from')}>
|
<FilterControls.Group label={t('comparison.from')}>
|
||||||
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
||||||
@@ -581,22 +662,26 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<FilterControls.Group label={t('filters.district')}>
|
<FilterControls.Group label={t('filters.district')}>
|
||||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: []})}>
|
||||||
<option value="all">{t('filters.allDistricts')}</option>
|
<option value="all">{t('filters.allDistricts')}</option>
|
||||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.channel')}>
|
<FilterControls.Group label={t('filters.channel')}>
|
||||||
<select value={filters.channel} onChange={e => setFilters({...filters, channel: e.target.value})}>
|
<MultiSelect
|
||||||
<option value="all">{t('filters.allChannels')}</option>
|
options={channels}
|
||||||
{channels.map(c => <option key={c} value={c}>{c}</option>)}
|
selected={filters.channel}
|
||||||
</select>
|
onChange={selected => setFilters({...filters, channel: selected})}
|
||||||
|
allLabel={t('filters.allChannels')}
|
||||||
|
/>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.museum')}>
|
<FilterControls.Group label={t('filters.museum')}>
|
||||||
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
|
<MultiSelect
|
||||||
<option value="all">{t('filters.allMuseums')}</option>
|
options={availableMuseums}
|
||||||
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
selected={filters.museum}
|
||||||
</select>
|
onChange={selected => setFilters({...filters, museum: selected})}
|
||||||
|
allLabel={t('filters.allMuseums')}
|
||||||
|
/>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
</FilterControls.Row>
|
</FilterControls.Row>
|
||||||
</FilterControls>
|
</FilterControls>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams, Link } from 'react-router-dom';
|
||||||
import { Line, Doughnut, Bar } from 'react-chartjs-2';
|
import { Line, Bar } from 'react-chartjs-2';
|
||||||
import { Carousel, EmptyState, FilterControls, StatCard } from './shared';
|
import { Carousel, EmptyState, FilterControls, MultiSelect, StatCard } from './shared';
|
||||||
import { ExportableChart } from './ChartExport';
|
import { ExportableChart } from './ChartExport';
|
||||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import {
|
import {
|
||||||
filterData,
|
filterData,
|
||||||
@@ -22,19 +22,19 @@ import {
|
|||||||
getMuseumsForDistrict,
|
getMuseumsForDistrict,
|
||||||
groupByDistrict
|
groupByDistrict
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import type { DashboardProps, Filters, MuseumRecord } from '../types';
|
import type { DashboardProps, Filters, MuseumRecord, Season } from '../types';
|
||||||
|
|
||||||
const defaultFilters: Filters = {
|
const defaultFilters: Filters = {
|
||||||
year: 'all',
|
year: 'all',
|
||||||
district: 'all',
|
district: 'all',
|
||||||
channel: 'all',
|
channel: [],
|
||||||
museum: 'all',
|
museum: [],
|
||||||
quarter: 'all'
|
quarter: 'all'
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterKeys: (keyof Filters)[] = ['year', 'district', 'channel', 'museum', 'quarter'];
|
const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter'];
|
||||||
|
|
||||||
function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
|
function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: 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);
|
||||||
@@ -45,12 +45,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Initialize filters from URL or defaults
|
// Initialize filters from URL or defaults
|
||||||
const [filters, setFiltersState] = useState(() => {
|
const [filters, setFiltersState] = useState<Filters>(() => {
|
||||||
const initial = { ...defaultFilters };
|
const initial: Filters = { ...defaultFilters };
|
||||||
filterKeys.forEach(key => {
|
filterKeys.forEach(key => {
|
||||||
const value = searchParams.get(key);
|
const value = searchParams.get(key);
|
||||||
if (value) initial[key] = value;
|
if (value) (initial as Record<string, unknown>)[key] = value;
|
||||||
});
|
});
|
||||||
|
const museumParam = searchParams.get('museum');
|
||||||
|
if (museumParam) initial.museum = museumParam.split(',').filter(Boolean);
|
||||||
|
const channelParam = searchParams.get('channel');
|
||||||
|
if (channelParam) initial.channel = channelParam.split(',').filter(Boolean);
|
||||||
return initial;
|
return initial;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,22 +65,37 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
filterKeys.forEach(key => {
|
filterKeys.forEach(key => {
|
||||||
if (updated[key] && updated[key] !== 'all') {
|
const val = (updated as Record<string, unknown>)[key] as string;
|
||||||
params.set(key, updated[key]);
|
if (val && val !== 'all') {
|
||||||
|
params.set(key, val);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (updated.museum.length > 0) params.set('museum', updated.museum.join(','));
|
||||||
|
if (updated.channel.length > 0) params.set('channel', updated.channel.join(','));
|
||||||
setSearchParams(params, { replace: true });
|
setSearchParams(params, { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const [activeStatCard, setActiveStatCard] = useState(0);
|
const [activeStatCard, setActiveStatCard] = useState(0);
|
||||||
const [activeChart, setActiveChart] = useState(0);
|
const [activeChart, setActiveChart] = useState(0);
|
||||||
const [trendGranularity, setTrendGranularity] = useState('week');
|
const [trendGranularity, setTrendGranularity] = useState('week');
|
||||||
|
const [selectedSeason, setSelectedSeason] = useState<string>('');
|
||||||
|
|
||||||
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
|
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
|
||||||
const metrics = useMemo(() => calculateMetrics(filteredData, includeVAT), [filteredData, includeVAT]);
|
|
||||||
const hasData = filteredData.length > 0;
|
|
||||||
|
|
||||||
const resetFilters = () => setFilters(defaultFilters);
|
const seasonFilteredData = useMemo(() => {
|
||||||
|
if (!selectedSeason) return filteredData;
|
||||||
|
const season = seasons.find(s => String(s.Id) === selectedSeason);
|
||||||
|
if (!season) return filteredData;
|
||||||
|
return filteredData.filter(row => row.date >= season.StartDate && row.date <= season.EndDate);
|
||||||
|
}, [filteredData, selectedSeason, seasons]);
|
||||||
|
|
||||||
|
const metrics = useMemo(() => calculateMetrics(seasonFilteredData, includeVAT), [seasonFilteredData, includeVAT]);
|
||||||
|
const hasData = seasonFilteredData.length > 0;
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setFilters(defaultFilters);
|
||||||
|
setSelectedSeason('');
|
||||||
|
};
|
||||||
|
|
||||||
// Stat cards for carousel
|
// Stat cards for carousel
|
||||||
const statCards = useMemo(() => [
|
const statCards = useMemo(() => [
|
||||||
@@ -88,9 +107,8 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
|
|
||||||
// Chart carousel labels
|
// Chart carousel labels
|
||||||
const chartLabels = useMemo(() => {
|
const chartLabels = useMemo(() => {
|
||||||
const labels = [t('charts.revenueTrend'), t('charts.visitors'), t('charts.revenue'), t('charts.quarterly'), t('charts.channel'), t('charts.district'), t('charts.captureRate')];
|
return [t('charts.revenueTrend'), t('charts.visitors'), t('charts.revenue'), t('charts.quarterly'), t('charts.channel'), t('charts.district'), t('charts.captureRate')];
|
||||||
return filters.museum === 'all' ? labels : labels.filter((_, i) => i !== 1 && i !== 2);
|
}, [t]);
|
||||||
}, [filters.museum, t]);
|
|
||||||
|
|
||||||
// Dynamic lists from data
|
// Dynamic lists from data
|
||||||
const years = useMemo(() => getUniqueYears(data), [data]);
|
const years = useMemo(() => getUniqueYears(data), [data]);
|
||||||
@@ -147,11 +165,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (trendGranularity === 'week') {
|
if (trendGranularity === 'week') {
|
||||||
const grouped = groupByWeek(filteredData, includeVAT);
|
const grouped = groupByWeek(seasonFilteredData, includeVAT);
|
||||||
const weeks = Object.keys(grouped).filter(w => w).sort();
|
const weeks = Object.keys(grouped).filter(w => w).sort();
|
||||||
const revenueValues = weeks.map(w => grouped[w].revenue);
|
const revenueValues = weeks.map(w => grouped[w].revenue);
|
||||||
return {
|
return {
|
||||||
labels: weeks.map(formatLabel),
|
labels: weeks.map(formatLabel),
|
||||||
|
rawDates: weeks,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
||||||
data: revenueValues,
|
data: revenueValues,
|
||||||
@@ -167,7 +186,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
} else {
|
} else {
|
||||||
// Daily granularity
|
// Daily granularity
|
||||||
const dailyData: Record<string, number> = {};
|
const dailyData: Record<string, number> = {};
|
||||||
filteredData.forEach(row => {
|
seasonFilteredData.forEach(row => {
|
||||||
const date = row.date;
|
const date = row.date;
|
||||||
if (!dailyData[date]) dailyData[date] = 0;
|
if (!dailyData[date]) dailyData[date] = 0;
|
||||||
dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || 0);
|
dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || 0);
|
||||||
@@ -176,6 +195,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
const revenueValues = days.map(d => dailyData[d]);
|
const revenueValues = days.map(d => dailyData[d]);
|
||||||
return {
|
return {
|
||||||
labels: days.map(formatLabel),
|
labels: days.map(formatLabel),
|
||||||
|
rawDates: days,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
||||||
data: revenueValues,
|
data: revenueValues,
|
||||||
@@ -189,59 +209,60 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
}, trendlineDataset(revenueValues)]
|
}, trendlineDataset(revenueValues)]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [filteredData, trendGranularity, includeVAT]);
|
}, [seasonFilteredData, trendGranularity, includeVAT]);
|
||||||
|
|
||||||
// Museum data
|
// Museum data
|
||||||
const museumData = useMemo(() => {
|
const museumData = useMemo(() => {
|
||||||
const grouped = groupByMuseum(filteredData, includeVAT);
|
const grouped = groupByMuseum(seasonFilteredData, includeVAT);
|
||||||
const museums = Object.keys(grouped);
|
const museums = Object.keys(grouped);
|
||||||
return {
|
return {
|
||||||
visitors: {
|
visitors: {
|
||||||
labels: museums,
|
labels: museums,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: museums.map(m => grouped[m].visitors),
|
data: museums.map(m => grouped[m].visitors),
|
||||||
backgroundColor: [chartColors.primary + 'cc', chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
backgroundColor: museums.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'),
|
||||||
borderWidth: 0
|
borderWidth: 0,
|
||||||
|
borderRadius: 4
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
revenue: {
|
revenue: {
|
||||||
labels: museums,
|
labels: museums,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: museums.map(m => grouped[m].revenue),
|
data: museums.map(m => grouped[m].revenue),
|
||||||
backgroundColor: [chartColors.primary + 'cc', chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
backgroundColor: museums.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'),
|
||||||
borderRadius: 4
|
borderRadius: 4
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [filteredData, includeVAT]);
|
}, [seasonFilteredData, includeVAT]);
|
||||||
|
|
||||||
// Channel data
|
// Channel data
|
||||||
const channelData = useMemo(() => {
|
const channelData = useMemo(() => {
|
||||||
const grouped = groupByChannel(filteredData, includeVAT);
|
const grouped = groupByChannel(seasonFilteredData, includeVAT);
|
||||||
const channels = Object.keys(grouped);
|
const channels = Object.keys(grouped);
|
||||||
return {
|
return {
|
||||||
labels: channels,
|
labels: channels,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: channels.map(d => grouped[d].revenue),
|
data: channels.map(d => grouped[d].revenue),
|
||||||
backgroundColor: [chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
backgroundColor: channels.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'),
|
||||||
borderRadius: 4
|
borderRadius: 4
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
}, [filteredData, includeVAT]);
|
}, [seasonFilteredData, includeVAT]);
|
||||||
|
|
||||||
// District data
|
// District data
|
||||||
const districtData = useMemo(() => {
|
const districtData = useMemo(() => {
|
||||||
const grouped = groupByDistrict(filteredData, includeVAT);
|
const grouped = groupByDistrict(seasonFilteredData, includeVAT);
|
||||||
const districtNames = Object.keys(grouped);
|
const districtNames = Object.keys(grouped);
|
||||||
return {
|
return {
|
||||||
labels: districtNames,
|
labels: districtNames,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: districtNames.map(d => grouped[d].revenue),
|
data: districtNames.map(d => grouped[d].revenue),
|
||||||
backgroundColor: [chartColors.primary + 'cc', chartColors.secondary + 'cc', chartColors.tertiary + 'cc', chartColors.muted + 'cc'],
|
backgroundColor: districtNames.map((_, i) => chartPalette[i % chartPalette.length] + 'cc'),
|
||||||
borderRadius: 4
|
borderRadius: 4
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
}, [filteredData, includeVAT]);
|
}, [seasonFilteredData, includeVAT]);
|
||||||
|
|
||||||
// Quarterly YoY
|
// Quarterly YoY
|
||||||
const quarterlyYoYData = useMemo(() => {
|
const quarterlyYoYData = useMemo(() => {
|
||||||
@@ -279,8 +300,8 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
if (!pilgrims) return;
|
if (!pilgrims) return;
|
||||||
let qData = data.filter((r: MuseumRecord) => r.year === String(year) && r.quarter === String(q));
|
let qData = data.filter((r: MuseumRecord) => r.year === String(year) && r.quarter === String(q));
|
||||||
if (filters.district !== 'all') qData = qData.filter((r: MuseumRecord) => r.district === filters.district);
|
if (filters.district !== 'all') qData = qData.filter((r: MuseumRecord) => r.district === filters.district);
|
||||||
if (filters.channel !== 'all') qData = qData.filter((r: MuseumRecord) => r.channel === filters.channel);
|
if (filters.channel.length > 0) qData = qData.filter((r: MuseumRecord) => filters.channel.includes(r.channel));
|
||||||
if (filters.museum !== 'all') qData = qData.filter((r: MuseumRecord) => r.museum_name === filters.museum);
|
if (filters.museum.length > 0) qData = qData.filter((r: MuseumRecord) => filters.museum.includes(r.museum_name));
|
||||||
const visitors = qData.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
|
const visitors = qData.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
|
||||||
labels.push(`Q${q} ${year}`);
|
labels.push(`Q${q} ${year}`);
|
||||||
rates.push((visitors / pilgrims * 100));
|
rates.push((visitors / pilgrims * 100));
|
||||||
@@ -357,13 +378,13 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
q2024 = q2024.filter((r: MuseumRecord) => r.district === filters.district);
|
q2024 = q2024.filter((r: MuseumRecord) => r.district === filters.district);
|
||||||
q2025 = q2025.filter((r: MuseumRecord) => r.district === filters.district);
|
q2025 = q2025.filter((r: MuseumRecord) => r.district === filters.district);
|
||||||
}
|
}
|
||||||
if (filters.channel !== 'all') {
|
if (filters.channel.length > 0) {
|
||||||
q2024 = q2024.filter((r: MuseumRecord) => r.channel === filters.channel);
|
q2024 = q2024.filter((r: MuseumRecord) => filters.channel.includes(r.channel));
|
||||||
q2025 = q2025.filter((r: MuseumRecord) => r.channel === filters.channel);
|
q2025 = q2025.filter((r: MuseumRecord) => filters.channel.includes(r.channel));
|
||||||
}
|
}
|
||||||
if (filters.museum !== 'all') {
|
if (filters.museum.length > 0) {
|
||||||
q2024 = q2024.filter((r: MuseumRecord) => r.museum_name === filters.museum);
|
q2024 = q2024.filter((r: MuseumRecord) => filters.museum.includes(r.museum_name));
|
||||||
q2025 = q2025.filter((r: MuseumRecord) => r.museum_name === filters.museum);
|
q2025 = q2025.filter((r: MuseumRecord) => filters.museum.includes(r.museum_name));
|
||||||
}
|
}
|
||||||
const rev24 = q2024.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0);
|
const rev24 = q2024.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0);
|
||||||
const rev25 = q2025.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0);
|
const rev25 = q2025.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 0)), 0);
|
||||||
@@ -379,6 +400,35 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
|
|
||||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||||
|
|
||||||
|
// Season annotation bands for revenue trend chart
|
||||||
|
const seasonAnnotations = useMemo(() => {
|
||||||
|
const raw = trendData.rawDates;
|
||||||
|
if (!seasons.length || !raw?.length) return {};
|
||||||
|
const annotations: Record<string, unknown> = {};
|
||||||
|
seasons.forEach((s, i) => {
|
||||||
|
const startIdx = raw.findIndex(d => d >= s.StartDate);
|
||||||
|
const endIdx = raw.length - 1 - [...raw].reverse().findIndex(d => d <= s.EndDate);
|
||||||
|
if (startIdx === -1 || endIdx < startIdx) return;
|
||||||
|
annotations[`season${i}`] = {
|
||||||
|
type: 'box',
|
||||||
|
xMin: startIdx - 0.5,
|
||||||
|
xMax: endIdx + 0.5,
|
||||||
|
backgroundColor: s.Color + '20',
|
||||||
|
borderColor: s.Color + '40',
|
||||||
|
borderWidth: 1,
|
||||||
|
label: {
|
||||||
|
display: true,
|
||||||
|
content: `${s.Name} ${s.HijriYear}`,
|
||||||
|
position: 'start',
|
||||||
|
color: s.Color,
|
||||||
|
font: { size: 10, weight: '600' },
|
||||||
|
padding: 4
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return annotations;
|
||||||
|
}, [seasons, trendData.rawDates]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard" id="dashboard-container">
|
<div className="dashboard" id="dashboard-container">
|
||||||
<div className="page-title-with-actions">
|
<div className="page-title-with-actions">
|
||||||
@@ -413,22 +463,26 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.district')}>
|
<FilterControls.Group label={t('filters.district')}>
|
||||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: []})}>
|
||||||
<option value="all">{t('filters.allDistricts')}</option>
|
<option value="all">{t('filters.allDistricts')}</option>
|
||||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.channel')}>
|
<FilterControls.Group label={t('filters.channel')}>
|
||||||
<select value={filters.channel} onChange={e => setFilters({...filters, channel: e.target.value})}>
|
<MultiSelect
|
||||||
<option value="all">{t('filters.allChannels')}</option>
|
options={channels}
|
||||||
{channels.map(c => <option key={c} value={c}>{c}</option>)}
|
selected={filters.channel}
|
||||||
</select>
|
onChange={channel => setFilters({...filters, channel})}
|
||||||
|
allLabel={t('filters.allChannels')}
|
||||||
|
/>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.museum')}>
|
<FilterControls.Group label={t('filters.museum')}>
|
||||||
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
|
<MultiSelect
|
||||||
<option value="all">{t('filters.allMuseums')}</option>
|
options={availableMuseums}
|
||||||
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
selected={filters.museum}
|
||||||
</select>
|
onChange={museum => setFilters({...filters, museum})}
|
||||||
|
allLabel={t('filters.allMuseums')}
|
||||||
|
/>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
<FilterControls.Group label={t('filters.quarter')}>
|
<FilterControls.Group label={t('filters.quarter')}>
|
||||||
<select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}>
|
<select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}>
|
||||||
@@ -439,6 +493,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
<option value="4">{t('time.q4')}</option>
|
<option value="4">{t('time.q4')}</option>
|
||||||
</select>
|
</select>
|
||||||
</FilterControls.Group>
|
</FilterControls.Group>
|
||||||
|
<FilterControls.Group label={t('filters.season')}>
|
||||||
|
<select value={selectedSeason} onChange={e => setSelectedSeason(e.target.value)}>
|
||||||
|
<option value="">{t('filters.allSeasons')}</option>
|
||||||
|
{seasons.map(s => (
|
||||||
|
<option key={s.Id} value={String(s.Id)}>
|
||||||
|
{s.Name} {s.HijriYear}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FilterControls.Group>
|
||||||
</FilterControls.Row>
|
</FilterControls.Row>
|
||||||
</FilterControls>
|
</FilterControls>
|
||||||
|
|
||||||
@@ -532,25 +596,21 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 13}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
|
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, annotation: { annotations: seasonAnnotations }, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 13}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
|
||||||
</ExportableChart>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filters.museum === 'all' && (
|
<div className="chart-card half-width">
|
||||||
<div className="chart-card half-width">
|
<ExportableChart filename="visitors-by-event" title={t('dashboard.visitorsByMuseum')} className="chart-container">
|
||||||
<ExportableChart filename="visitors-by-museum" title={t('dashboard.visitorsByMuseum')} className="chart-container">
|
<Bar data={museumData.visitors} options={{...baseOptions, indexAxis: 'y'}} />
|
||||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 16, font: {size: 13}}}}}} />
|
</ExportableChart>
|
||||||
</ExportableChart>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filters.museum === 'all' && (
|
<div className="chart-card half-width">
|
||||||
<div className="chart-card half-width">
|
<ExportableChart filename="revenue-by-event" title={t('dashboard.revenueByMuseum')} className="chart-container">
|
||||||
<ExportableChart filename="revenue-by-museum" title={t('dashboard.revenueByMuseum')} className="chart-container">
|
<Bar data={museumData.revenue} options={{...baseOptions, indexAxis: 'y'}} />
|
||||||
<Bar data={museumData.revenue} options={baseOptions} />
|
</ExportableChart>
|
||||||
</ExportableChart>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<ExportableChart filename="quarterly-yoy" title={t('dashboard.quarterlyRevenue')} className="chart-container">
|
<ExportableChart filename="quarterly-yoy" title={t('dashboard.quarterlyRevenue')} className="chart-container">
|
||||||
@@ -629,32 +689,28 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button>
|
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-container">
|
<div className="chart-container">
|
||||||
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 10, padding: 8, font: {size: 12}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} />
|
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, annotation: { annotations: seasonAnnotations }, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 10, padding: 8, font: {size: 12}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filters.museum === 'all' && (
|
<div className="carousel-slide">
|
||||||
<div className="carousel-slide">
|
<div className="chart-card">
|
||||||
<div className="chart-card">
|
<h2>{t('dashboard.visitorsByMuseum')}</h2>
|
||||||
<h2>{t('dashboard.visitorsByMuseum')}</h2>
|
<div className="chart-container">
|
||||||
<div className="chart-container">
|
<Bar data={museumData.visitors} options={{...baseOptions, indexAxis: 'y'}} />
|
||||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 12, font: {size: 12}}}}}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{filters.museum === 'all' && (
|
<div className="carousel-slide">
|
||||||
<div className="carousel-slide">
|
<div className="chart-card">
|
||||||
<div className="chart-card">
|
<h2>{t('dashboard.revenueByMuseum')}</h2>
|
||||||
<h2>{t('dashboard.revenueByMuseum')}</h2>
|
<div className="chart-container">
|
||||||
<div className="chart-container">
|
<Bar data={museumData.revenue} options={{...baseOptions, indexAxis: 'y'}} />
|
||||||
<Bar data={museumData.revenue} options={baseOptions} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
@@ -743,6 +799,15 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{userRole === 'admin' && <div className="settings-link">
|
||||||
|
<Link to="/settings">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
{t('nav.settings')}
|
||||||
|
</Link>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
75
src/components/Login.tsx
Normal file
75
src/components/Login.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface LoginProps {
|
||||||
|
onLogin: (name: string, role: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Login({ onLogin }: LoginProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [pin, setPin] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ pin }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(t('login.invalid'));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
onLogin(data.name || '', data.role || 'viewer');
|
||||||
|
} catch {
|
||||||
|
setError(t('login.error'));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-brand">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="var(--accent)" aria-hidden="true">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="3" width="7" height="4" rx="1"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="11" width="7" height="10" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
<h1>HiHala Data</h1>
|
||||||
|
</div>
|
||||||
|
<p className="login-subtitle">{t('login.subtitle')}</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={pin}
|
||||||
|
onChange={e => setPin(e.target.value)}
|
||||||
|
placeholder={t('login.placeholder')}
|
||||||
|
autoFocus
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{error && <p className="login-error">{error}</p>}
|
||||||
|
<button type="submit" disabled={loading || !pin}>
|
||||||
|
{loading ? '...' : t('login.submit')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
||||||
234
src/components/Settings.tsx
Normal file
234
src/components/Settings.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { fetchSeasons, createSeason, updateSeason, deleteSeason } from '../services/seasonsService';
|
||||||
|
import { fetchUsers, createUser, deleteUser, type User } from '../services/usersService';
|
||||||
|
import type { Season } from '../types';
|
||||||
|
|
||||||
|
const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899'];
|
||||||
|
|
||||||
|
interface SeasonRowProps {
|
||||||
|
season: Season;
|
||||||
|
onSave: (id: number, data: Partial<Season>) => Promise<void>;
|
||||||
|
onDelete: (id: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeasonRow({ season, onSave, onDelete }: SeasonRowProps) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [form, setForm] = useState(season);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await onSave(season.Id!, form);
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span className="season-chip" style={{ backgroundColor: season.Color + '20', color: season.Color, borderColor: season.Color }}>
|
||||||
|
{season.Name} {season.HijriYear}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{season.StartDate}</td>
|
||||||
|
<td>{season.EndDate}</td>
|
||||||
|
<td>
|
||||||
|
<div className="season-actions">
|
||||||
|
<button className="btn-small" onClick={() => setEditing(true)}>Edit</button>
|
||||||
|
<button className="btn-small btn-danger" onClick={() => onDelete(season.Id!)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="editing">
|
||||||
|
<td>
|
||||||
|
<div className="season-edit-name">
|
||||||
|
<input type="text" value={form.Name} onChange={e => setForm({ ...form, Name: e.target.value })} placeholder="Name" />
|
||||||
|
<input type="number" value={form.HijriYear} onChange={e => setForm({ ...form, HijriYear: parseInt(e.target.value) || 0 })} placeholder="Year" style={{ width: 80 }} />
|
||||||
|
<input type="color" value={form.Color} onChange={e => setForm({ ...form, Color: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><input type="date" value={form.StartDate} onChange={e => setForm({ ...form, StartDate: e.target.value })} /></td>
|
||||||
|
<td><input type="date" value={form.EndDate} onChange={e => setForm({ ...form, EndDate: e.target.value })} /></td>
|
||||||
|
<td>
|
||||||
|
<div className="season-actions">
|
||||||
|
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
|
||||||
|
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsProps {
|
||||||
|
onSeasonsChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Settings({ onSeasonsChange }: SettingsProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [seasons, setSeasons] = useState<Season[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [newSeason, setNewSeason] = useState<Omit<Season, 'Id'>>({
|
||||||
|
Name: '',
|
||||||
|
HijriYear: new Date().getFullYear() - 579, // rough Gregorian → Hijri
|
||||||
|
StartDate: '',
|
||||||
|
EndDate: '',
|
||||||
|
Color: DEFAULT_COLORS[0],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [newUser, setNewUser] = useState<Omit<User, 'Id'>>({ Name: '', PIN: '', Role: 'viewer' });
|
||||||
|
|
||||||
|
const loadSeasons = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await fetchSeasons();
|
||||||
|
setSeasons(data);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
const data = await fetchUsers();
|
||||||
|
setUsers(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadSeasons(); loadUsers(); }, []);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate) return;
|
||||||
|
await createSeason(newSeason);
|
||||||
|
setNewSeason({
|
||||||
|
Name: '',
|
||||||
|
HijriYear: newSeason.HijriYear,
|
||||||
|
StartDate: '',
|
||||||
|
EndDate: '',
|
||||||
|
Color: DEFAULT_COLORS[(seasons.length + 1) % DEFAULT_COLORS.length],
|
||||||
|
});
|
||||||
|
await loadSeasons();
|
||||||
|
onSeasonsChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (id: number, data: Partial<Season>) => {
|
||||||
|
await updateSeason(id, data);
|
||||||
|
await loadSeasons();
|
||||||
|
onSeasonsChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
await deleteSeason(id);
|
||||||
|
await loadSeasons();
|
||||||
|
onSeasonsChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page">
|
||||||
|
<div className="page-title">
|
||||||
|
<h1>{t('settings.title')}</h1>
|
||||||
|
<p>{t('settings.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-card">
|
||||||
|
<h2>{t('settings.seasons')}</h2>
|
||||||
|
<p className="settings-hint">{t('settings.seasonsHint')}</p>
|
||||||
|
|
||||||
|
<div className="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('settings.seasonName')}</th>
|
||||||
|
<th>{t('settings.startDate')}</th>
|
||||||
|
<th>{t('settings.endDate')}</th>
|
||||||
|
<th>{t('settings.actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={4} style={{ textAlign: 'center', padding: 24 }}>Loading...</td></tr>
|
||||||
|
) : (
|
||||||
|
seasons.map(s => (
|
||||||
|
<SeasonRow key={s.Id} season={s} onSave={handleSave} onDelete={handleDelete} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<tr className="add-row">
|
||||||
|
<td>
|
||||||
|
<div className="season-edit-name">
|
||||||
|
<input type="text" value={newSeason.Name} onChange={e => setNewSeason({ ...newSeason, Name: e.target.value })} placeholder={t('settings.namePlaceholder')} />
|
||||||
|
<input type="number" value={newSeason.HijriYear} onChange={e => setNewSeason({ ...newSeason, HijriYear: parseInt(e.target.value) || 0 })} style={{ width: 80 }} />
|
||||||
|
<input type="color" value={newSeason.Color} onChange={e => setNewSeason({ ...newSeason, Color: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><input type="date" value={newSeason.StartDate} onChange={e => setNewSeason({ ...newSeason, StartDate: e.target.value })} /></td>
|
||||||
|
<td><input type="date" value={newSeason.EndDate} onChange={e => setNewSeason({ ...newSeason, EndDate: e.target.value })} /></td>
|
||||||
|
<td>
|
||||||
|
<button className="btn-small btn-primary" onClick={handleCreate} disabled={!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate}>
|
||||||
|
{t('settings.add')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-card" style={{ marginTop: 24 }}>
|
||||||
|
<h2>{t('settings.users')}</h2>
|
||||||
|
<p className="settings-hint">{t('settings.usersHint')}</p>
|
||||||
|
|
||||||
|
<div className="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('settings.userName')}</th>
|
||||||
|
<th>{t('settings.userPin')}</th>
|
||||||
|
<th>{t('settings.userRole')}</th>
|
||||||
|
<th>{t('settings.actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map(u => (
|
||||||
|
<tr key={u.Id}>
|
||||||
|
<td>{u.Name}</td>
|
||||||
|
<td><code>{u.PIN}</code></td>
|
||||||
|
<td>{u.Role}</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn-small btn-danger" onClick={async () => { await deleteUser(u.Id!); await loadUsers(); }}>
|
||||||
|
{t('settings.delete') || 'Delete'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="add-row">
|
||||||
|
<td>
|
||||||
|
<input type="text" value={newUser.Name} onChange={e => setNewUser({ ...newUser, Name: e.target.value })} placeholder={t('settings.userNamePlaceholder')} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" value={newUser.PIN} onChange={e => setNewUser({ ...newUser, PIN: e.target.value })} placeholder="PIN" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select value={newUser.Role} onChange={e => setNewUser({ ...newUser, Role: e.target.value })}>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn-small btn-primary" onClick={async () => {
|
||||||
|
if (!newUser.Name || !newUser.PIN) return;
|
||||||
|
await createUser(newUser);
|
||||||
|
setNewUser({ Name: '', PIN: '', Role: 'viewer' });
|
||||||
|
await loadUsers();
|
||||||
|
}} disabled={!newUser.Name || !newUser.PIN}>
|
||||||
|
{t('settings.add')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
82
src/components/shared/MultiSelect.tsx
Normal file
82
src/components/shared/MultiSelect.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface MultiSelectProps {
|
||||||
|
options: string[];
|
||||||
|
selected: string[];
|
||||||
|
onChange: (selected: string[]) => void;
|
||||||
|
allLabel: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiSelect({ options, selected, onChange, allLabel, placeholder }: MultiSelectProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isAll = selected.length === 0;
|
||||||
|
|
||||||
|
const toggle = (value: string) => {
|
||||||
|
if (selected.includes(value)) {
|
||||||
|
onChange(selected.filter(v => v !== value));
|
||||||
|
} else {
|
||||||
|
onChange([...selected, value]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => onChange([]);
|
||||||
|
|
||||||
|
const displayText = isAll
|
||||||
|
? allLabel
|
||||||
|
: selected.length === 1
|
||||||
|
? selected[0]
|
||||||
|
: `${selected.length} selected`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="multi-select" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="multi-select-trigger"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="multi-select-text">{displayText}</span>
|
||||||
|
<span className="multi-select-arrow">▼</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="multi-select-dropdown">
|
||||||
|
<label className="multi-select-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isAll}
|
||||||
|
onChange={selectAll}
|
||||||
|
/>
|
||||||
|
<span>{allLabel}</span>
|
||||||
|
</label>
|
||||||
|
{options.map(opt => (
|
||||||
|
<label key={opt} className="multi-select-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.includes(opt)}
|
||||||
|
onChange={() => toggle(opt)}
|
||||||
|
/>
|
||||||
|
<span>{opt}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MultiSelect;
|
||||||
@@ -2,5 +2,6 @@ export { default as Carousel } from './Carousel';
|
|||||||
export { default as ChartCard } from './ChartCard';
|
export { default as ChartCard } from './ChartCard';
|
||||||
export { default as EmptyState } from './EmptyState';
|
export { default as EmptyState } from './EmptyState';
|
||||||
export { default as FilterControls } from './FilterControls';
|
export { default as FilterControls } from './FilterControls';
|
||||||
|
export { default as MultiSelect } from './MultiSelect';
|
||||||
export { default as StatCard } from './StatCard';
|
export { default as StatCard } from './StatCard';
|
||||||
export { default as ToggleSwitch } from './ToggleSwitch';
|
export { default as ToggleSwitch } from './ToggleSwitch';
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import {
|
|||||||
PointElement,
|
PointElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
BarElement,
|
BarElement,
|
||||||
ArcElement,
|
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
Filler
|
Filler
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||||
|
import Annotation from 'chartjs-plugin-annotation';
|
||||||
|
|
||||||
// Register ChartJS components once
|
// Register ChartJS components once
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
@@ -20,12 +21,13 @@ ChartJS.register(
|
|||||||
PointElement,
|
PointElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
BarElement,
|
BarElement,
|
||||||
ArcElement,
|
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
Filler,
|
Filler,
|
||||||
ChartDataLabels
|
ChartDataLabels,
|
||||||
|
Annotation
|
||||||
);
|
);
|
||||||
|
|
||||||
export const chartColors = {
|
export const chartColors = {
|
||||||
@@ -38,6 +40,20 @@ export const chartColors = {
|
|||||||
grid: '#f1f5f9'
|
grid: '#f1f5f9'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Extended palette for charts with many categories (events, channels)
|
||||||
|
export const chartPalette = [
|
||||||
|
'#2563eb', // blue
|
||||||
|
'#7c3aed', // purple
|
||||||
|
'#0891b2', // cyan
|
||||||
|
'#059669', // emerald
|
||||||
|
'#d97706', // amber
|
||||||
|
'#e11d48', // rose
|
||||||
|
'#4f46e5', // indigo
|
||||||
|
'#0d9488', // teal
|
||||||
|
'#c026d3', // fuchsia
|
||||||
|
'#ea580c', // orange
|
||||||
|
];
|
||||||
|
|
||||||
export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
||||||
display: showDataLabels,
|
display: showDataLabels,
|
||||||
color: '#1e293b',
|
color: '#1e293b',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"comparison": "المقارنة",
|
"comparison": "المقارنة",
|
||||||
"compare": "مقارنة",
|
"compare": "مقارنة",
|
||||||
"slides": "الشرائح",
|
"slides": "الشرائح",
|
||||||
|
"settings": "الإعدادات",
|
||||||
"labels": "التسميات",
|
"labels": "التسميات",
|
||||||
"labelsOn": "التسميات مفعّلة",
|
"labelsOn": "التسميات مفعّلة",
|
||||||
"labelsOff": "التسميات معطّلة",
|
"labelsOff": "التسميات معطّلة",
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
"excl": "بدون"
|
"excl": "بدون"
|
||||||
},
|
},
|
||||||
"dataSources": {
|
"dataSources": {
|
||||||
"museums": "المتاحف",
|
"museums": "الفعاليات",
|
||||||
"coffees": "المقاهي",
|
"coffees": "المقاهي",
|
||||||
"ecommerce": "التجارة الإلكترونية",
|
"ecommerce": "التجارة الإلكترونية",
|
||||||
"soon": "قريباً"
|
"soon": "قريباً"
|
||||||
@@ -35,13 +36,15 @@
|
|||||||
"year": "السنة",
|
"year": "السنة",
|
||||||
"district": "المنطقة",
|
"district": "المنطقة",
|
||||||
"channel": "القناة",
|
"channel": "القناة",
|
||||||
"museum": "المتحف",
|
"museum": "الفعالية",
|
||||||
"quarter": "الربع",
|
"quarter": "الربع",
|
||||||
"allYears": "كل السنوات",
|
"allYears": "كل السنوات",
|
||||||
"allDistricts": "كل المناطق",
|
"allDistricts": "كل المناطق",
|
||||||
"allChannels": "جميع القنوات",
|
"allChannels": "جميع القنوات",
|
||||||
"allMuseums": "كل المتاحف",
|
"allMuseums": "كل الفعاليات",
|
||||||
"allQuarters": "كل الأرباع",
|
"allQuarters": "كل الأرباع",
|
||||||
|
"season": "الموسم",
|
||||||
|
"allSeasons": "كل المواسم",
|
||||||
"reset": "إعادة تعيين الفلاتر"
|
"reset": "إعادة تعيين الفلاتر"
|
||||||
},
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
@@ -58,13 +61,13 @@
|
|||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "لوحة التحكم",
|
"title": "لوحة التحكم",
|
||||||
"subtitle": "تحليلات المتاحف من نظام Hono ERP",
|
"subtitle": "تحليلات الفعاليات من نظام ERP",
|
||||||
"noData": "لا توجد بيانات",
|
"noData": "لا توجد بيانات",
|
||||||
"noDataMessage": "لا توجد سجلات تطابق الفلاتر الحالية. حاول تعديل اختيارك.",
|
"noDataMessage": "لا توجد سجلات تطابق الفلاتر الحالية. حاول تعديل اختيارك.",
|
||||||
"quarterlyComparison": "مقارنة ربع سنوية: 2024 مقابل 2025",
|
"quarterlyComparison": "مقارنة ربع سنوية: 2024 مقابل 2025",
|
||||||
"revenueTrends": "اتجاهات الإيرادات",
|
"revenueTrends": "اتجاهات الإيرادات",
|
||||||
"visitorsByMuseum": "الزوار حسب المتحف",
|
"visitorsByMuseum": "الزوار حسب الفعالية",
|
||||||
"revenueByMuseum": "الإيرادات حسب المتحف",
|
"revenueByMuseum": "الإيرادات حسب الفعالية",
|
||||||
"quarterlyRevenue": "الإيرادات الربعية (سنوي)",
|
"quarterlyRevenue": "الإيرادات الربعية (سنوي)",
|
||||||
"districtPerformance": "أداء المناطق",
|
"districtPerformance": "أداء المناطق",
|
||||||
"channelPerformance": "أداء القنوات",
|
"channelPerformance": "أداء القنوات",
|
||||||
@@ -120,7 +123,7 @@
|
|||||||
"noData": "لا توجد بيانات لهذه الفترة",
|
"noData": "لا توجد بيانات لهذه الفترة",
|
||||||
"noDataMessage": "لم يتم العثور على سجلات للنطاق الزمني والفلاتر المحددة.",
|
"noDataMessage": "لم يتم العثور على سجلات للنطاق الزمني والفلاتر المحددة.",
|
||||||
"trend": "الاتجاه",
|
"trend": "الاتجاه",
|
||||||
"byMuseum": "حسب المتحف",
|
"byMuseum": "حسب الفعالية",
|
||||||
"pendingData": "البيانات لم تُنشر بعد"
|
"pendingData": "البيانات لم تُنشر بعد"
|
||||||
},
|
},
|
||||||
"slides": {
|
"slides": {
|
||||||
@@ -140,7 +143,7 @@
|
|||||||
"showYoY": "إظهار مقارنة سنة بسنة",
|
"showYoY": "إظهار مقارنة سنة بسنة",
|
||||||
"exit": "خروج",
|
"exit": "خروج",
|
||||||
"revenueTrend": "اتجاه الإيرادات",
|
"revenueTrend": "اتجاه الإيرادات",
|
||||||
"byMuseum": "حسب المتحف",
|
"byMuseum": "حسب الفعالية",
|
||||||
"kpiSummary": "ملخص مؤشرات الأداء",
|
"kpiSummary": "ملخص مؤشرات الأداء",
|
||||||
"yoyComparison": "مقارنة سنوية"
|
"yoyComparison": "مقارنة سنوية"
|
||||||
},
|
},
|
||||||
@@ -153,6 +156,32 @@
|
|||||||
"channel": "القناة",
|
"channel": "القناة",
|
||||||
"captureRate": "نسبة الاستقطاب"
|
"captureRate": "نسبة الاستقطاب"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "الإعدادات",
|
||||||
|
"subtitle": "إعدادات لوحة التحكم والمواسم الهجرية",
|
||||||
|
"seasons": "المواسم الهجرية",
|
||||||
|
"seasonsHint": "حدد المواسم مع تواريخها الميلادية. تظهر كفلاتر مسبقة وتراكبات على الرسوم البيانية.",
|
||||||
|
"seasonName": "الموسم",
|
||||||
|
"startDate": "تاريخ البداية",
|
||||||
|
"endDate": "تاريخ النهاية",
|
||||||
|
"actions": "الإجراءات",
|
||||||
|
"namePlaceholder": "مثال: رمضان",
|
||||||
|
"add": "إضافة",
|
||||||
|
"delete": "حذف",
|
||||||
|
"users": "المستخدمون",
|
||||||
|
"usersHint": "أضف مستخدمين برمز PIN. المشاهدون يمكنهم رؤية لوحة التحكم فقط.",
|
||||||
|
"userName": "الاسم",
|
||||||
|
"userNamePlaceholder": "مثال: أحمد",
|
||||||
|
"userPin": "رمز PIN",
|
||||||
|
"userRole": "الدور"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"subtitle": "أدخل رمز PIN للوصول إلى لوحة التحكم",
|
||||||
|
"placeholder": "رمز PIN",
|
||||||
|
"submit": "تسجيل الدخول",
|
||||||
|
"invalid": "رمز PIN غير صحيح",
|
||||||
|
"error": "خطأ في الاتصال. يرجى المحاولة مرة أخرى."
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال ERP API.",
|
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال ERP API.",
|
||||||
"network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.",
|
"network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"comparison": "Comparison",
|
"comparison": "Comparison",
|
||||||
"compare": "Compare",
|
"compare": "Compare",
|
||||||
"slides": "Slides",
|
"slides": "Slides",
|
||||||
|
"settings": "Settings",
|
||||||
"labels": "Labels",
|
"labels": "Labels",
|
||||||
"labelsOn": "Labels On",
|
"labelsOn": "Labels On",
|
||||||
"labelsOff": "Labels Off",
|
"labelsOff": "Labels Off",
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
"excl": "Excl"
|
"excl": "Excl"
|
||||||
},
|
},
|
||||||
"dataSources": {
|
"dataSources": {
|
||||||
"museums": "Museums",
|
"museums": "Events",
|
||||||
"coffees": "Coffees",
|
"coffees": "Coffees",
|
||||||
"ecommerce": "eCommerce",
|
"ecommerce": "eCommerce",
|
||||||
"soon": "soon"
|
"soon": "soon"
|
||||||
@@ -35,13 +36,15 @@
|
|||||||
"year": "Year",
|
"year": "Year",
|
||||||
"district": "District",
|
"district": "District",
|
||||||
"channel": "Channel",
|
"channel": "Channel",
|
||||||
"museum": "Museum",
|
"museum": "Event",
|
||||||
"quarter": "Quarter",
|
"quarter": "Quarter",
|
||||||
"allYears": "All Years",
|
"allYears": "All Years",
|
||||||
"allDistricts": "All Districts",
|
"allDistricts": "All Districts",
|
||||||
"allChannels": "All Channels",
|
"allChannels": "All Channels",
|
||||||
"allMuseums": "All Museums",
|
"allMuseums": "All Events",
|
||||||
"allQuarters": "All Quarters",
|
"allQuarters": "All Quarters",
|
||||||
|
"season": "Season",
|
||||||
|
"allSeasons": "All Seasons",
|
||||||
"reset": "Reset Filters"
|
"reset": "Reset Filters"
|
||||||
},
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
@@ -58,13 +61,13 @@
|
|||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"subtitle": "Museum analytics from Hono ERP",
|
"subtitle": "Event analytics from ERP",
|
||||||
"noData": "No data found",
|
"noData": "No data found",
|
||||||
"noDataMessage": "No records match your current filters. Try adjusting your selection.",
|
"noDataMessage": "No records match your current filters. Try adjusting your selection.",
|
||||||
"quarterlyComparison": "Quarterly Comparison: 2024 vs 2025",
|
"quarterlyComparison": "Quarterly Comparison: 2024 vs 2025",
|
||||||
"revenueTrends": "Revenue Trends",
|
"revenueTrends": "Revenue Trends",
|
||||||
"visitorsByMuseum": "Visitors by Museum",
|
"visitorsByMuseum": "Visitors by Event",
|
||||||
"revenueByMuseum": "Revenue by Museum",
|
"revenueByMuseum": "Revenue by Event",
|
||||||
"quarterlyRevenue": "Quarterly Revenue (YoY)",
|
"quarterlyRevenue": "Quarterly Revenue (YoY)",
|
||||||
"districtPerformance": "District Performance",
|
"districtPerformance": "District Performance",
|
||||||
"channelPerformance": "Channel Performance",
|
"channelPerformance": "Channel Performance",
|
||||||
@@ -120,7 +123,7 @@
|
|||||||
"noData": "No data for this period",
|
"noData": "No data for this period",
|
||||||
"noDataMessage": "No records found for the selected date range and filters.",
|
"noDataMessage": "No records found for the selected date range and filters.",
|
||||||
"trend": "Trend",
|
"trend": "Trend",
|
||||||
"byMuseum": "By Museum",
|
"byMuseum": "By Event",
|
||||||
"pendingData": "Data not published yet"
|
"pendingData": "Data not published yet"
|
||||||
},
|
},
|
||||||
"slides": {
|
"slides": {
|
||||||
@@ -140,7 +143,7 @@
|
|||||||
"showYoY": "Show Year-over-Year Comparison",
|
"showYoY": "Show Year-over-Year Comparison",
|
||||||
"exit": "Exit",
|
"exit": "Exit",
|
||||||
"revenueTrend": "Revenue Trend",
|
"revenueTrend": "Revenue Trend",
|
||||||
"byMuseum": "By Museum",
|
"byMuseum": "By Event",
|
||||||
"kpiSummary": "KPI Summary",
|
"kpiSummary": "KPI Summary",
|
||||||
"yoyComparison": "YoY Comparison"
|
"yoyComparison": "YoY Comparison"
|
||||||
},
|
},
|
||||||
@@ -153,6 +156,32 @@
|
|||||||
"channel": "Channel",
|
"channel": "Channel",
|
||||||
"captureRate": "Capture Rate"
|
"captureRate": "Capture Rate"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"subtitle": "Configure dashboard settings and hijri seasons",
|
||||||
|
"seasons": "Hijri Seasons",
|
||||||
|
"seasonsHint": "Define seasons with their Gregorian date ranges. These appear as filter presets and chart overlays.",
|
||||||
|
"seasonName": "Season",
|
||||||
|
"startDate": "Start Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"actions": "Actions",
|
||||||
|
"namePlaceholder": "e.g. Ramadan",
|
||||||
|
"add": "Add",
|
||||||
|
"delete": "Delete",
|
||||||
|
"users": "Users",
|
||||||
|
"usersHint": "Add users with a PIN code. Viewers can see the dashboard but not settings.",
|
||||||
|
"userName": "Name",
|
||||||
|
"userNamePlaceholder": "e.g. Ahmed",
|
||||||
|
"userPin": "PIN",
|
||||||
|
"userRole": "Role"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"subtitle": "Enter your PIN to access the dashboard",
|
||||||
|
"placeholder": "PIN code",
|
||||||
|
"submit": "Login",
|
||||||
|
"invalid": "Invalid PIN code",
|
||||||
|
"error": "Connection error. Please try again."
|
||||||
|
},
|
||||||
"errors": {
|
"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.",
|
||||||
"network": "Cannot reach the database server. Please check your internet connection.",
|
"network": "Cannot reach the database server. Please check your internet connection.",
|
||||||
|
|||||||
@@ -275,8 +275,8 @@ export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord
|
|||||||
return data.filter(row => {
|
return data.filter(row => {
|
||||||
if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false;
|
if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false;
|
||||||
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
||||||
if (filters.channel && filters.channel !== 'all' && row.channel !== filters.channel) return false;
|
if (filters.channel.length > 0 && !filters.channel.includes(row.channel)) return false;
|
||||||
if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
|
if (filters.museum.length > 0 && !filters.museum.includes(row.museum_name)) return false;
|
||||||
if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false;
|
if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -292,8 +292,8 @@ export function filterDataByDateRange(
|
|||||||
if (!row.date) return false;
|
if (!row.date) return false;
|
||||||
if (row.date < startDate || row.date > endDate) return false;
|
if (row.date < startDate || row.date > endDate) return false;
|
||||||
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
||||||
if (filters.channel && filters.channel !== 'all' && row.channel !== filters.channel) return false;
|
if (filters.channel && filters.channel.length > 0 && !filters.channel.includes(row.channel)) return false;
|
||||||
if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
|
if (filters.museum && filters.museum.length > 0 && !filters.museum.includes(row.museum_name)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
37
src/services/seasonsService.ts
Normal file
37
src/services/seasonsService.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Season } from '../types';
|
||||||
|
|
||||||
|
export async function fetchSeasons(): Promise<Season[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/seasons');
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return res.json();
|
||||||
|
} catch {
|
||||||
|
console.warn('Failed to load seasons, using empty list');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSeason(season: Omit<Season, 'Id'>): Promise<Season> {
|
||||||
|
const res = await fetch('/api/seasons', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(season),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to create season');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSeason(id: number, season: Partial<Season>): Promise<Season> {
|
||||||
|
const res = await fetch(`/api/seasons/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(season),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to update season');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSeason(id: number): Promise<void> {
|
||||||
|
const res = await fetch(`/api/seasons/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error('Failed to delete season');
|
||||||
|
}
|
||||||
31
src/services/usersService.ts
Normal file
31
src/services/usersService.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export interface User {
|
||||||
|
Id?: number;
|
||||||
|
Name: string;
|
||||||
|
PIN: string;
|
||||||
|
Role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUsers(): Promise<User[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users');
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return res.json();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(user: Omit<User, 'Id'>): Promise<User> {
|
||||||
|
const res = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(user),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to create user');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: number): Promise<void> {
|
||||||
|
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error('Failed to delete user');
|
||||||
|
}
|
||||||
@@ -23,15 +23,15 @@ export interface Metrics {
|
|||||||
export interface Filters {
|
export interface Filters {
|
||||||
year: string;
|
year: string;
|
||||||
district: string;
|
district: string;
|
||||||
channel: string;
|
channel: string[];
|
||||||
museum: string;
|
museum: string[];
|
||||||
quarter: string;
|
quarter: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateRangeFilters {
|
export interface DateRangeFilters {
|
||||||
district: string;
|
district: string;
|
||||||
channel: string;
|
channel: string[];
|
||||||
museum: string;
|
museum: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CacheStatus {
|
export interface CacheStatus {
|
||||||
@@ -92,6 +92,16 @@ export interface NocoDBDailySale {
|
|||||||
NetRevenue: number;
|
NetRevenue: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Season (hijri calendar overlay)
|
||||||
|
export interface Season {
|
||||||
|
Id?: number;
|
||||||
|
Name: string;
|
||||||
|
HijriYear: number;
|
||||||
|
StartDate: string;
|
||||||
|
EndDate: string;
|
||||||
|
Color: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Chart data types
|
// Chart data types
|
||||||
export interface ChartDataset {
|
export interface ChartDataset {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -120,6 +130,8 @@ export interface ChartData {
|
|||||||
// Component props
|
// Component props
|
||||||
export interface DashboardProps {
|
export interface DashboardProps {
|
||||||
data: MuseumRecord[];
|
data: MuseumRecord[];
|
||||||
|
seasons: Season[];
|
||||||
|
userRole: string;
|
||||||
showDataLabels: boolean;
|
showDataLabels: boolean;
|
||||||
setShowDataLabels: (value: boolean) => void;
|
setShowDataLabels: (value: boolean) => void;
|
||||||
includeVAT: boolean;
|
includeVAT: boolean;
|
||||||
@@ -128,6 +140,7 @@ export interface DashboardProps {
|
|||||||
|
|
||||||
export interface ComparisonProps {
|
export interface ComparisonProps {
|
||||||
data: MuseumRecord[];
|
data: MuseumRecord[];
|
||||||
|
seasons: Season[];
|
||||||
showDataLabels: boolean;
|
showDataLabels: boolean;
|
||||||
setShowDataLabels: (value: boolean) => void;
|
setShowDataLabels: (value: boolean) => void;
|
||||||
includeVAT: boolean;
|
includeVAT: boolean;
|
||||||
|
|||||||
@@ -6,11 +6,27 @@ 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,
|
||||||
},
|
},
|
||||||
'/api': {
|
'/api/etl': {
|
||||||
|
target: 'http://localhost:3002',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/api/users': {
|
||||||
|
target: 'http://localhost:3002',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/api/seasons': {
|
||||||
|
target: 'http://localhost:3002',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/api/v2': {
|
||||||
target: 'http://localhost:8090',
|
target: 'http://localhost:8090',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user