Compare commits
28 Commits
36df0065ed
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f51280d1c | |||
| 89689c5979 | |||
| 49bda53598 | |||
| 2888936d54 | |||
| 131868a280 | |||
| 7365bc808b | |||
| 26bb69c76c | |||
| 1070490ad2 | |||
| c858075232 | |||
| 648365348f | |||
| 594321738a | |||
| b6bd3bcff5 | |||
| d59af22329 | |||
| 640538bcbd | |||
| 553928a3a9 | |||
| d925d41a79 | |||
| d7d035adb0 | |||
| cf6a4c0b3d | |||
| 2f90753f57 | |||
| 65025d7f3c | |||
| ab94d33868 | |||
| 64955f0f51 | |||
| c9cfb58896 | |||
| 30cdb5064a | |||
| 25cb91e31b | |||
| ef9a960e5d | |||
| 9138ac1098 | |||
| d3f9a6cd43 |
@@ -0,0 +1,148 @@
|
|||||||
|
# Report Builder — Design Spec
|
||||||
|
**Date:** 2026-04-28
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A dedicated `/report` page (admin-only) where users configure a client-facing PDF report from scratch. The report is a professional business document — no app interface visible — downloadable as a `.pdf` file via `@react-pdf/renderer`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page Structure
|
||||||
|
|
||||||
|
Two-column layout on desktop, stacked on mobile:
|
||||||
|
- **Left panel (form):** all configuration fields, grouped into sections
|
||||||
|
- **Right panel:** a static document preview mockup (not a live render — too expensive). Shows real text fields (title, client name, period) updating in real time, but charts and metrics are represented as grey placeholder shapes. Gives the user a sense of page structure and section order.
|
||||||
|
- **Footer bar:** "Generate PDF" button (triggers download), estimated page count
|
||||||
|
|
||||||
|
The route is `/report`, protected the same way `/settings` is (admin only, via `userRole === 'admin'` check in `App.tsx`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Form Sections
|
||||||
|
|
||||||
|
### 1. Client Info
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Report title | Text input | e.g. "Q1 2025 Visitor Performance" |
|
||||||
|
| Prepared for (company) | Text input | Shown in report header |
|
||||||
|
| Contact name | Text input | Optional — "Attention: …" line |
|
||||||
|
| Client logo | File upload (PNG/JPG/SVG, max 2MB) | Base64-encoded, embedded in PDF header |
|
||||||
|
| Accent color | Color picker | Defaults to HiHala blue `#2563eb`; used for section headers, borders |
|
||||||
|
|
||||||
|
### 2. Data Selection
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Period start / end | Date inputs | Defaults to current month |
|
||||||
|
| Museums | Multi-select (same component as dashboard) | Empty = all |
|
||||||
|
| Channels | Multi-select | Empty = all |
|
||||||
|
| VAT | Toggle: Excl / Incl | Defaults to Incl |
|
||||||
|
| Include comparison | Checkbox | Adds previous-year column to metrics table |
|
||||||
|
|
||||||
|
### 3. Content Sections (toggleable)
|
||||||
|
Each is a checkbox, all on by default:
|
||||||
|
- Executive summary (3–4 sentences auto-generated from numbers)
|
||||||
|
- Key metrics table (Revenue, Visitors, Tickets, Avg Rev/Visitor, optionally Pilgrim Capture Rate)
|
||||||
|
- Revenue & visitor trend chart
|
||||||
|
- Breakdown by museum
|
||||||
|
- Breakdown by channel
|
||||||
|
- Pilgrim capture rate section (only shown if toggle is on AND data exists)
|
||||||
|
|
||||||
|
### 4. Presentation
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Language | Toggle: EN / AR | Controls all PDF text and direction |
|
||||||
|
| Confidentiality footer | Select: Confidential / Internal / Public | Shown in page footer |
|
||||||
|
| Page orientation | Toggle: Portrait / Landscape | Portrait default |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PDF Document Design
|
||||||
|
|
||||||
|
Built with `@react-pdf/renderer`. All layout is code — no DOM capture, no html2canvas.
|
||||||
|
|
||||||
|
**Page 1 — Cover**
|
||||||
|
- HiHala logo (top-left) + client logo (top-right)
|
||||||
|
- Large report title
|
||||||
|
- "Prepared for: [Company]" / "Attention: [Contact]"
|
||||||
|
- Period label (e.g. "January – March 2025")
|
||||||
|
- Generation date
|
||||||
|
- Accent color bar at bottom
|
||||||
|
|
||||||
|
**Page 2+ — Content pages**
|
||||||
|
- Shared header: small HiHala wordmark + report title + page number
|
||||||
|
- Sections in the order the user toggled them on
|
||||||
|
- Each section starts with a colored heading bar (accent color)
|
||||||
|
- Confidentiality level in page footer
|
||||||
|
|
||||||
|
**Chart rendering:**
|
||||||
|
Charts do not use the live Chart.js instances. Instead, `@react-pdf/renderer` draws simplified chart equivalents natively using SVG primitives (`<Svg>`, `<Rect>`, `<Path>`, `<Line>`) — no canvas capture needed. This produces crisp vector output at any print resolution.
|
||||||
|
|
||||||
|
Simplified charts to implement:
|
||||||
|
- **Trend line chart:** SVG polyline over a grid
|
||||||
|
- **Bar chart (museum/channel breakdown):** horizontal SVG bars with labels
|
||||||
|
|
||||||
|
**Executive summary generation:**
|
||||||
|
Computed from the metrics — a template string filled with actual numbers. Example (EN):
|
||||||
|
> "During [period], [selected museums] recorded [X] visitors and [Y SAR] in revenue, representing a [Z%] change versus the same period last year. The top-performing channel was [channel] with [N%] of total tickets."
|
||||||
|
|
||||||
|
The same template exists in Arabic (stored in the locale file alongside EN/AR strings already in the codebase). Falls back gracefully if comparison data is absent (omits the change sentence).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
/report page
|
||||||
|
→ user fills form
|
||||||
|
→ clicks "Generate PDF"
|
||||||
|
→ reads filtered data from already-loaded app state (passed as prop from App.tsx)
|
||||||
|
OR re-fetches if needed (dataService.fetchData())
|
||||||
|
→ applies period + museum + channel filters client-side
|
||||||
|
→ computes metrics (reuses existing calculateMetrics())
|
||||||
|
→ passes computed values to <ReportDocument /> (react-pdf component)
|
||||||
|
→ pdf.download('hihala-report.pdf')
|
||||||
|
```
|
||||||
|
|
||||||
|
No new API endpoints required. All computation is client-side using existing `dataService` functions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
components/
|
||||||
|
Report/
|
||||||
|
index.tsx — the /report page (form + preview layout)
|
||||||
|
ReportForm.tsx — the left-panel form
|
||||||
|
ReportPreview.tsx — the right-panel static mockup
|
||||||
|
ReportDocument.tsx — the @react-pdf/renderer document
|
||||||
|
reportCharts.tsx — SVG chart primitives for PDF
|
||||||
|
reportHelpers.ts — executive summary generator, data filters
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
- Desktop nav: gear icon already links to `/settings`; add a "Report" link (document icon) next to it, admin-only
|
||||||
|
- Mobile bottom nav: add Report icon between Comparison and Settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `@react-pdf/renderer` — PDF generation
|
||||||
|
- No other new dependencies (reuses existing AltMultiSelect, form inputs, data service)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Scheduled / emailed reports
|
||||||
|
- Saving report configurations
|
||||||
|
- Non-admin users generating reports
|
||||||
|
- Live chart preview in the right panel (static mockup only)
|
||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
<meta name="description" content="HiHala Data Dashboard — Event 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 rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap">
|
||||||
<title>HiHala Data</title>
|
<title>HiHala Data</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+589
-1
@@ -8,6 +8,7 @@
|
|||||||
"name": "hihala-dashboard",
|
"name": "hihala-dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -827,6 +828,207 @@
|
|||||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/ciphers": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/fns": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/font": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/pdfkit": "^5.1.1",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"fontkit": "^2.0.2",
|
||||||
|
"is-url": "^1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/image": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/svg": "^1.1.0",
|
||||||
|
"jay-peg": "^1.1.1",
|
||||||
|
"png-js": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/layout": {
|
||||||
|
"version": "4.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.6.1.tgz",
|
||||||
|
"integrity": "sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/image": "^3.1.0",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/stylesheet": "^6.2.1",
|
||||||
|
"@react-pdf/textkit": "^6.3.0",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"emoji-regex-xs": "^1.0.0",
|
||||||
|
"queue": "^6.0.1",
|
||||||
|
"yoga-layout": "^3.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/pdfkit": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@noble/ciphers": "^1.0.0",
|
||||||
|
"@noble/hashes": "^1.6.0",
|
||||||
|
"browserify-zlib": "^0.2.0",
|
||||||
|
"fontkit": "^2.0.2",
|
||||||
|
"jay-peg": "^1.1.1",
|
||||||
|
"js-md5": "^0.8.3",
|
||||||
|
"linebreak": "^1.1.0",
|
||||||
|
"png-js": "^2.0.0",
|
||||||
|
"vite-compatible-readable-stream": "^3.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/primitives": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/reconciler": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"scheduler": "0.25.0-rc-603e6108-20241029"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
|
||||||
|
"version": "0.25.0-rc-603e6108-20241029",
|
||||||
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
|
||||||
|
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/render": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/textkit": "^6.3.0",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"abs-svg-path": "^0.1.1",
|
||||||
|
"color-string": "^2.1.4",
|
||||||
|
"normalize-svg-path": "^1.1.0",
|
||||||
|
"parse-svg-path": "^0.1.2",
|
||||||
|
"svg-arc-to-cubic-bezier": "^3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/renderer": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/font": "^4.0.8",
|
||||||
|
"@react-pdf/layout": "^4.6.1",
|
||||||
|
"@react-pdf/pdfkit": "^5.1.1",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/reconciler": "^2.0.0",
|
||||||
|
"@react-pdf/render": "^4.5.1",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"prop-types": "^15.6.2",
|
||||||
|
"queue": "^6.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/stylesheet": {
|
||||||
|
"version": "6.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.2.1.tgz",
|
||||||
|
"integrity": "sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"color-string": "^2.1.4",
|
||||||
|
"hsl-to-hex": "^1.0.0",
|
||||||
|
"media-engine": "^1.0.3",
|
||||||
|
"postcss-value-parser": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/svg": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/svg/-/svg-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/primitives": "^4.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/textkit": {
|
||||||
|
"version": "6.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.3.0.tgz",
|
||||||
|
"integrity": "sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"bidi-js": "^1.0.2",
|
||||||
|
"hyphen": "^1.6.4",
|
||||||
|
"unicode-properties": "^1.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/types": {
|
||||||
|
"version": "2.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.11.1.tgz",
|
||||||
|
"integrity": "sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/font": "^4.0.8",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/stylesheet": "^6.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.3",
|
"version": "1.0.0-rc.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||||
@@ -1184,6 +1386,15 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz",
|
||||||
|
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
@@ -1419,6 +1630,12 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/abs-svg-path": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
@@ -1462,6 +1679,26 @@
|
|||||||
"node": ">= 0.6.0"
|
"node": ">= 0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||||
@@ -1475,6 +1712,33 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/brotli": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/browserify-zlib": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "~1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.1",
|
"version": "4.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
@@ -1605,6 +1869,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -1625,6 +1898,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/color-string": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-string/node_modules/color-name": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concurrently": {
|
"node_modules/concurrently": {
|
||||||
"version": "9.2.1",
|
"version": "9.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||||
@@ -1712,6 +2006,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dfa": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dom-accessibility-api": {
|
"node_modules/dom-accessibility-api": {
|
||||||
"version": "0.5.16",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
@@ -1732,6 +2032,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex-xs": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
@@ -1784,6 +2090,21 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/events": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1802,6 +2123,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fontkit": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.12",
|
||||||
|
"brotli": "^1.3.2",
|
||||||
|
"clone": "^2.1.2",
|
||||||
|
"dfa": "^1.2.0",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"restructure": "^3.0.0",
|
||||||
|
"tiny-inflate": "^1.0.3",
|
||||||
|
"unicode-properties": "^1.4.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1847,6 +2191,21 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hsl-to-hex": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hsl-to-rgb-for-reals": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hsl-to-rgb-for-reals": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/html2canvas": {
|
"node_modules/html2canvas": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
@@ -1860,6 +2219,12 @@
|
|||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hyphen": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/immediate": {
|
"node_modules/immediate": {
|
||||||
"version": "3.0.6",
|
"version": "3.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
@@ -1891,6 +2256,27 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-url": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jay-peg": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"restructure": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/js-md5": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -1980,6 +2366,37 @@
|
|||||||
"immediate": "~3.0.5"
|
"immediate": "~3.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linebreak": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "0.0.8",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linebreak/node_modules/base64-js": {
|
||||||
|
"version": "0.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||||
|
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/loose-envify": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"loose-envify": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -1999,6 +2416,12 @@
|
|||||||
"lz-string": "bin/bin.js"
|
"lz-string": "bin/bin.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/media-engine": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/min-indent": {
|
"node_modules/min-indent": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||||
@@ -2041,12 +2464,36 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/normalize-svg-path": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"svg-arc-to-cubic-bezier": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pako": {
|
"node_modules/pako": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
"license": "(MIT AND Zlib)"
|
"license": "(MIT AND Zlib)"
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-svg-path": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2066,6 +2513,14 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/png-js": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
|
||||||
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
@@ -2095,6 +2550,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss-value-parser": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pretty-format": {
|
"node_modules/pretty-format": {
|
||||||
"version": "27.5.1",
|
"version": "27.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||||
@@ -2127,6 +2588,32 @@
|
|||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/prop-types": {
|
||||||
|
"version": "15.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"react-is": "^16.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prop-types/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/queue": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "~2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
@@ -2248,6 +2735,21 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/restructure": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
@@ -2303,6 +2805,26 @@
|
|||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -2354,6 +2876,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
@@ -2410,6 +2941,12 @@
|
|||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svg-arc-to-cubic-bezier": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/text-segmentation": {
|
"node_modules/text-segmentation": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
@@ -2419,6 +2956,12 @@
|
|||||||
"utrie": "^1.0.2"
|
"utrie": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-inflate": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -2450,7 +2993,6 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
@@ -2474,6 +3016,32 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/unicode-properties": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^0.2.5",
|
||||||
|
"tiny-inflate": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie/node_modules/pako": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -2595,6 +3163,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-compatible-readable-stream": {
|
||||||
|
"version": "3.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
|
||||||
|
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/web-vitals": {
|
"node_modules/web-vitals": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
|
||||||
@@ -2664,6 +3246,12 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yoga-layout": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+961
-462
File diff suppressed because it is too large
Load Diff
+39
@@ -4,6 +4,7 @@ import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react
|
|||||||
const Settings = lazy(() => import('./components/Settings'));
|
const Settings = lazy(() => import('./components/Settings'));
|
||||||
const Comparison = lazy(() => import('./components/Comparison'));
|
const Comparison = lazy(() => import('./components/Comparison'));
|
||||||
const Dashboard = lazy(() => import('./components/Dashboard'));
|
const Dashboard = lazy(() => import('./components/Dashboard'));
|
||||||
|
const Report = lazy(() => import('./components/Report'));
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
import LoadingSkeleton from './components/shared/LoadingSkeleton';
|
import LoadingSkeleton from './components/shared/LoadingSkeleton';
|
||||||
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService';
|
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService';
|
||||||
@@ -237,6 +238,26 @@ function App() {
|
|||||||
</svg>
|
</svg>
|
||||||
{t('nav.comparison')}
|
{t('nav.comparison')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
{userRole === 'admin' && (
|
||||||
|
<NavLink to="/settings">
|
||||||
|
<svg width="16" height="16" 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>
|
||||||
|
{t('nav.settings')}
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
{userRole === 'admin' && (
|
||||||
|
<NavLink to="/report">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
|
</svg>
|
||||||
|
Report
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
<span className="nav-sep" aria-hidden="true" />
|
<span className="nav-sep" aria-hidden="true" />
|
||||||
{isOffline && (
|
{isOffline && (
|
||||||
<span className="offline-badge" title={cacheInfo ? `Cached: ${new Date(cacheInfo.timestamp || '').toLocaleString()}` : ''}>
|
<span className="offline-badge" title={cacheInfo ? `Cached: ${new Date(cacheInfo.timestamp || '').toLocaleString()}` : ''}>
|
||||||
@@ -250,6 +271,11 @@ function App() {
|
|||||||
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('app.offline') || 'Offline'}
|
{t('app.offline') || 'Offline'}
|
||||||
|
{cacheInfo && (
|
||||||
|
<span className="sr-only">
|
||||||
|
{` (cached ${new Date(cacheInfo.timestamp || '').toLocaleString()})`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -304,6 +330,7 @@ function App() {
|
|||||||
<Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
<Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||||
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||||
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
|
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
|
||||||
|
{userRole === 'admin' && <Route path="/report" element={<Report data={data} />} />}
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
@@ -327,6 +354,18 @@ function App() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{t('nav.compare')}</span>
|
<span>{t('nav.compare')}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
{userRole === 'admin' && (
|
||||||
|
<NavLink to="/report" 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">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
|
</svg>
|
||||||
|
<span>Report</span>
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
{userRole === 'admin' && (
|
{userRole === 'admin' && (
|
||||||
<NavLink to="/settings" className="mobile-nav-item">
|
<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">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
|||||||
+136
-449
@@ -2,13 +2,19 @@ import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Line, Bar } from 'react-chartjs-2';
|
import { Line, Bar } from 'react-chartjs-2';
|
||||||
import {
|
import {
|
||||||
filterDataByDateRange, calculateMetrics, formatCurrency, formatNumber,
|
filterDataByDateRange, calculateMetrics,
|
||||||
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
|
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
|
||||||
umrahData
|
umrahData
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, chartPalette, createBaseOptions, TOTAL_COLOR } from '../config/chartConfig';
|
||||||
import type { MuseumRecord, Season } from '../types';
|
import type { MuseumRecord, Season } from '../types';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import type { LC } from '../lib/locale';
|
||||||
|
import { EN, AR } from '../lib/locale';
|
||||||
|
import { currentMonth, shiftYear, periodNameL, dateRangeTextL } from '../lib/dateHelpers';
|
||||||
|
import { InlinePicker } from './shared/PeriodPicker';
|
||||||
|
import AltMultiSelect from './shared/AltMultiSelect';
|
||||||
|
import MetricCard from './shared/MetricCard';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: MuseumRecord[];
|
data: MuseumRecord[];
|
||||||
@@ -19,222 +25,6 @@ interface Props {
|
|||||||
lang?: 'en' | 'ar';
|
lang?: 'en' | 'ar';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── language config ──────────────────────────────────────────────
|
|
||||||
interface LC {
|
|
||||||
dir: 'ltr' | 'rtl';
|
|
||||||
fontImport: string;
|
|
||||||
bodyFont: string;
|
|
||||||
displayFont: string;
|
|
||||||
monoFont: string;
|
|
||||||
monthFull: string[];
|
|
||||||
monthShort: string[];
|
|
||||||
periods: Record<string, string>;
|
|
||||||
fullYearLabel: (y: number) => string;
|
|
||||||
dateRangeSep: string;
|
|
||||||
backLink: string;
|
|
||||||
backTo: string;
|
|
||||||
pageTitle: string;
|
|
||||||
pageSub: string;
|
|
||||||
currentRole: string; previousRole: string;
|
|
||||||
currentHint: string; previousHint: string;
|
|
||||||
changePeriod: string; close: string; apply: string;
|
|
||||||
vs: string;
|
|
||||||
filter: string;
|
|
||||||
allDistricts: string; allChannels: string; allMuseums: string;
|
|
||||||
countDistricts: (n: number) => string;
|
|
||||||
countChannels: (n: number) => string;
|
|
||||||
countMuseums: (n: number) => string;
|
|
||||||
reset: string;
|
|
||||||
keyMetrics: string;
|
|
||||||
revenue: string; visitors: string; tickets: string; avgRev: string;
|
|
||||||
pilgrims: string; captureRate: string;
|
|
||||||
trendTitle: string; museumTitle: string;
|
|
||||||
daily: string; weekly: string; monthly: string;
|
|
||||||
newLabel: string; clearSel: string;
|
|
||||||
monthSection: string; periodSection: string;
|
|
||||||
from: string; to: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EN: LC = {
|
|
||||||
dir: 'ltr',
|
|
||||||
fontImport: `@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&display=swap');`,
|
|
||||||
bodyFont: "'Outfit', sans-serif",
|
|
||||||
displayFont: "'DM Serif Display', serif",
|
|
||||||
monoFont: "ui-monospace, 'Cascadia Code', monospace",
|
|
||||||
monthFull: ['January','February','March','April','May','June','July','August','September','October','November','December'],
|
|
||||||
monthShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
|
|
||||||
periods: { q1:'Q1', q2:'Q2', q3:'Q3', q4:'Q4', h1:'H1', h2:'H2', full:'Full Year' },
|
|
||||||
fullYearLabel: (y) => String(y),
|
|
||||||
dateRangeSep: '→',
|
|
||||||
backLink: '← Overview', backTo: '/',
|
|
||||||
pageTitle: 'Period Comparison', pageSub: 'Compare any two periods side by side.',
|
|
||||||
currentRole: 'This period', previousRole: 'Compared to',
|
|
||||||
currentHint: 'primary', previousHint: 'auto year −1',
|
|
||||||
changePeriod: 'Change period', close: 'Cancel', apply: 'Apply',
|
|
||||||
vs: 'vs',
|
|
||||||
filter: 'Filter',
|
|
||||||
allDistricts: 'All districts', allChannels: 'All channels', allMuseums: 'All museums',
|
|
||||||
countDistricts: (n) => `${n} districts`,
|
|
||||||
countChannels: (n) => `${n} channels`,
|
|
||||||
countMuseums: (n) => `${n} museums`,
|
|
||||||
reset: 'Reset',
|
|
||||||
keyMetrics: 'Key Metrics',
|
|
||||||
revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets',
|
|
||||||
avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %',
|
|
||||||
trendTitle: 'Trend over time', museumTitle: 'By museum',
|
|
||||||
daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
|
|
||||||
newLabel: 'New', clearSel: 'Clear selection',
|
|
||||||
monthSection: 'Month', periodSection: 'Quarter · Half · Year',
|
|
||||||
from: 'From', to: 'To',
|
|
||||||
};
|
|
||||||
|
|
||||||
const AR: LC = {
|
|
||||||
dir: 'rtl',
|
|
||||||
fontImport: `@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');`,
|
|
||||||
bodyFont: "'IBM Plex Sans Arabic', sans-serif",
|
|
||||||
displayFont: "'IBM Plex Sans Arabic', sans-serif",
|
|
||||||
monoFont: "'IBM Plex Sans Arabic', sans-serif",
|
|
||||||
monthFull: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'],
|
|
||||||
monthShort: ['ينا','فبر','مار','أبر','ماي','يون','يول','أغس','سبت','أكت','نوف','ديس'],
|
|
||||||
periods: { q1:'ر١', q2:'ر٢', q3:'ر٣', q4:'ر٤', h1:'ن١', h2:'ن٢', full:'السنة' },
|
|
||||||
fullYearLabel: (y) => `${y} كاملاً`,
|
|
||||||
dateRangeSep: '–',
|
|
||||||
backLink: '← نظرة عامة', backTo: '/ar',
|
|
||||||
pageTitle: 'مقارنة الفترات', pageSub: 'قارن بين فترتين زمنيتين.',
|
|
||||||
currentRole: 'الفترة الحالية', previousRole: 'مقارنةً بـ',
|
|
||||||
currentHint: 'رئيسية', previousHint: 'تلقائياً −١ سنة',
|
|
||||||
changePeriod: 'تغيير الفترة', close: 'إلغاء', apply: 'تطبيق',
|
|
||||||
vs: 'مقابل',
|
|
||||||
filter: 'تصفية',
|
|
||||||
allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف',
|
|
||||||
countDistricts: (n) => `${n} مناطق`,
|
|
||||||
countChannels: (n) => `${n} قنوات`,
|
|
||||||
countMuseums: (n) => `${n} متاحف`,
|
|
||||||
reset: 'إعادة ضبط',
|
|
||||||
keyMetrics: 'المؤشرات الرئيسية',
|
|
||||||
revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر',
|
|
||||||
avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %',
|
|
||||||
trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف',
|
|
||||||
daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري',
|
|
||||||
newLabel: 'جديد', clearSel: 'مسح التحديد',
|
|
||||||
monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة',
|
|
||||||
from: 'من', to: 'إلى',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── date helpers ─────────────────────────────────────────────────
|
|
||||||
const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
|
|
||||||
|
|
||||||
function isLeap(y: number) { return (y%4===0 && y%100!==0) || y%400===0; }
|
|
||||||
|
|
||||||
function makePresets(y: number): Record<string, { start: string; end: string }> {
|
|
||||||
const feb = isLeap(y) ? 29 : 28;
|
|
||||||
return {
|
|
||||||
jan:{start:`${y}-01-01`,end:`${y}-01-31`}, feb:{start:`${y}-02-01`,end:`${y}-02-${String(feb).padStart(2,'0')}`},
|
|
||||||
mar:{start:`${y}-03-01`,end:`${y}-03-31`}, apr:{start:`${y}-04-01`,end:`${y}-04-30`},
|
|
||||||
may:{start:`${y}-05-01`,end:`${y}-05-31`}, jun:{start:`${y}-06-01`,end:`${y}-06-30`},
|
|
||||||
jul:{start:`${y}-07-01`,end:`${y}-07-31`}, aug:{start:`${y}-08-01`,end:`${y}-08-31`},
|
|
||||||
sep:{start:`${y}-09-01`,end:`${y}-09-30`}, oct:{start:`${y}-10-01`,end:`${y}-10-31`},
|
|
||||||
nov:{start:`${y}-11-01`,end:`${y}-11-30`}, dec:{start:`${y}-12-01`,end:`${y}-12-31`},
|
|
||||||
q1:{start:`${y}-01-01`,end:`${y}-03-31`}, q2:{start:`${y}-04-01`,end:`${y}-06-30`},
|
|
||||||
q3:{start:`${y}-07-01`,end:`${y}-09-30`}, q4:{start:`${y}-10-01`,end:`${y}-12-31`},
|
|
||||||
h1:{start:`${y}-01-01`,end:`${y}-06-30`}, h2:{start:`${y}-07-01`,end:`${y}-12-31`},
|
|
||||||
full:{start:`${y}-01-01`,end:`${y}-12-31`},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function guessPreset(start: string, end: string) {
|
|
||||||
const year = parseInt(start.slice(0,4));
|
|
||||||
const presets = makePresets(year);
|
|
||||||
for (const [key, r] of Object.entries(presets)) {
|
|
||||||
if (r.start===start && r.end===end) return { key, year };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function periodNameL(start: string, end: string, L: LC): string {
|
|
||||||
const year = parseInt(start.slice(0,4));
|
|
||||||
const g = guessPreset(start, end);
|
|
||||||
if (!g) {
|
|
||||||
const fmt = (d: string) => { const [,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]}`; };
|
|
||||||
const ey = parseInt(end.slice(0,4));
|
|
||||||
return year===ey ? `${fmt(start)} – ${fmt(end)} ${year}` : `${fmt(start)} ${year} – ${fmt(end)} ${ey}`;
|
|
||||||
}
|
|
||||||
const mi = MONTH_KEYS.indexOf(g.key);
|
|
||||||
if (mi >= 0) return `${L.monthFull[mi]} ${g.year}`;
|
|
||||||
if (g.key==='full') return L.fullYearLabel(g.year);
|
|
||||||
return `${L.periods[g.key]??g.key.toUpperCase()} ${g.year}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateRangeTextL(start: string, end: string, L: LC): string {
|
|
||||||
const fmt = (d: string) => { const [y,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]} ${y}`; };
|
|
||||||
return `${fmt(start)} ${L.dateRangeSep} ${fmt(end)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentMonth() {
|
|
||||||
const now = new Date(); const y=now.getFullYear(), m=now.getMonth()+1;
|
|
||||||
const p = (n: number) => String(n).padStart(2,'0');
|
|
||||||
return { start:`${y}-${p(m)}-01`, end:`${y}-${p(m)}-${p(new Date(y,m,0).getDate())}` };
|
|
||||||
}
|
|
||||||
function shiftYear(s: string) { return s.replace(/^(\d{4})/, (_,y) => String(parseInt(y)-1)); }
|
|
||||||
|
|
||||||
// ─── inline picker ────────────────────────────────────────────────
|
|
||||||
function InlinePicker({ start, end, onChange, onClose, availableYears, L }: {
|
|
||||||
start: string; end: string; onChange: (s: string, e: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
availableYears: number[]; L: LC;
|
|
||||||
}) {
|
|
||||||
const g = guessPreset(start, end);
|
|
||||||
const [year, setYear] = useState(g?.year ?? parseInt(start.slice(0,4)));
|
|
||||||
const [active, setActive] = useState<string|null>(g?.key ?? null);
|
|
||||||
const [draftStart, setDraftStart] = useState(start);
|
|
||||||
const [draftEnd, setDraftEnd] = useState(end);
|
|
||||||
const minY = Math.min(...availableYears), maxY = Math.max(...availableYears);
|
|
||||||
|
|
||||||
const pick = (key: string) => { const r=makePresets(year)[key]; if(!r) return; setActive(key); setDraftStart(r.start); setDraftEnd(r.end); };
|
|
||||||
const shift = (d: number) => {
|
|
||||||
const ny=year+d; if(ny<minY||ny>maxY) return; setYear(ny);
|
|
||||||
if(active && makePresets(ny)[active]) { setDraftStart(makePresets(ny)[active].start); setDraftEnd(makePresets(ny)[active].end); }
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="alt-picker">
|
|
||||||
<div className="alt-picker-year">
|
|
||||||
<button type="button" onClick={() => shift(L.dir==='rtl' ? 1 : -1)} disabled={L.dir==='rtl' ? year>=maxY : year<=minY} className="alt-yr-btn">
|
|
||||||
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M5.5 9.5L1.5 5.5L5.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
||||||
</button>
|
|
||||||
<span className="alt-yr-val">{year}</span>
|
|
||||||
<button type="button" onClick={() => shift(L.dir==='rtl' ? -1 : 1)} disabled={L.dir==='rtl' ? year<=minY : year>=maxY} className="alt-yr-btn">
|
|
||||||
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M1.5 1.5L5.5 5.5L1.5 9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="alt-picker-section">{L.monthSection}</p>
|
|
||||||
<div className="alt-chips">
|
|
||||||
{MONTH_KEYS.map((k,i) => (
|
|
||||||
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.monthShort[i]}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="alt-picker-section">{L.periodSection}</p>
|
|
||||||
<div className="alt-chips">
|
|
||||||
{['q1','q2','q3','q4','h1','h2'].map(k => (
|
|
||||||
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.periods[k]}</button>
|
|
||||||
))}
|
|
||||||
<button type="button" className={`alt-chip alt-chip-wide${active==='full'?' alt-chip-on':''}`} onClick={() => pick('full')}>{L.periods.full}</button>
|
|
||||||
</div>
|
|
||||||
<div className="alt-picker-div" />
|
|
||||||
<div className="alt-custom">
|
|
||||||
<div className="alt-custom-f"><label>{L.from}</label><input type="date" value={draftStart} onChange={e => { setActive(null); setDraftStart(e.target.value); }} /></div>
|
|
||||||
<span className="alt-custom-arrow">{L.dateRangeSep}</span>
|
|
||||||
<div className="alt-custom-f"><label>{L.to}</label><input type="date" value={draftEnd} onChange={e => { setActive(null); setDraftEnd(e.target.value); }} /></div>
|
|
||||||
</div>
|
|
||||||
<div className="alt-picker-div" />
|
|
||||||
<div className="alt-footer">
|
|
||||||
<button type="button" className="alt-cancel" onClick={onClose}>{L.close}</button>
|
|
||||||
<button type="button" className="alt-apply" onClick={() => { onChange(draftStart, draftEnd); onClose(); }}>{L.apply}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── period card ──────────────────────────────────────────────────
|
// ─── period card ──────────────────────────────────────────────────
|
||||||
function PeriodCard({ role, hint, start, end, variant, onChange, availableYears, L }: {
|
function PeriodCard({ role, hint, start, end, variant, onChange, availableYears, L }: {
|
||||||
role: string; hint: string; start: string; end: string;
|
role: string; hint: string; start: string; end: string;
|
||||||
@@ -262,7 +52,7 @@ function PeriodCard({ role, hint, start, end, variant, onChange, availableYears,
|
|||||||
</div>
|
</div>
|
||||||
<div className="alt-period-name">{periodNameL(start, end, L)}</div>
|
<div className="alt-period-name">{periodNameL(start, end, L)}</div>
|
||||||
<div className="alt-date-range">{dateRangeTextL(start, end, L)}</div>
|
<div className="alt-date-range">{dateRangeTextL(start, end, L)}</div>
|
||||||
<button type="button" className="alt-change-btn" onClick={() => setOpen(v => !v)} aria-expanded={open}>
|
<button type="button" className="alt-change-btn" onClick={() => setOpen(v => !v)} aria-expanded={open} aria-controls="period-picker-panel">
|
||||||
{open ? L.close : L.changePeriod}
|
{open ? L.close : L.changePeriod}
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ transform:open?'rotate(180deg)':'none', transition:'transform 0.2s' }}>
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ transform:open?'rotate(180deg)':'none', transition:'transform 0.2s' }}>
|
||||||
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
@@ -274,70 +64,6 @@ function PeriodCard({ role, hint, start, end, variant, onChange, availableYears,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── multi-select ─────────────────────────────────────────────────
|
|
||||||
function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
|
|
||||||
value: string[]; options: string[];
|
|
||||||
onChange: (vals: string[]) => void;
|
|
||||||
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const h = (e: MouseEvent) => { if(ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
|
||||||
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v!==opt) : [...value, opt]);
|
|
||||||
const label = value.length===0 ? allLabel : value.length===1 ? value[0] : countLabel(value.length);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="altms">
|
|
||||||
<button type="button" className={`altms-trigger${value.length>0?' altms-trigger--active':''}`} onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox">
|
|
||||||
<span className="altms-label">{label}</span>
|
|
||||||
<svg className={`altms-chevron${open?' altms-chevron--open':''}`} width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
||||||
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="altms-dropdown" role="listbox" aria-multiselectable="true">
|
|
||||||
<div className="altms-list">
|
|
||||||
{options.map(opt => (
|
|
||||||
<label key={opt} className={`altms-option${value.includes(opt)?' altms-option--checked':''}`}>
|
|
||||||
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
|
|
||||||
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
|
|
||||||
<span className="altms-opt-label">{opt}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{value.length>0 && <button type="button" className="altms-clear" onClick={() => { onChange([]); setOpen(false); }}>{clearLabel}</button>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── metric card ──────────────────────────────────────────────────
|
|
||||||
function MetricCard({ title, curr, prev, isCurrency, newLabel }: {
|
|
||||||
title: string; curr: number; prev: number; isCurrency?: boolean; newLabel?: string;
|
|
||||||
}) {
|
|
||||||
const fmt = (n: number) => isCurrency ? formatCurrency(n) : formatNumber(n);
|
|
||||||
const change = prev===0 ? (curr>0 ? Infinity : 0) : ((curr-prev)/prev*100);
|
|
||||||
const isPos = change>0, isNeg = change<0;
|
|
||||||
return (
|
|
||||||
<div className="alt-metric">
|
|
||||||
<p className="alt-metric-title">{title}</p>
|
|
||||||
<div className="alt-metric-value">{fmt(curr)}</div>
|
|
||||||
<div className="alt-metric-footer">
|
|
||||||
{isFinite(change)
|
|
||||||
? <span className={`alt-change ${isPos?'alt-change--up':isNeg?'alt-change--down':'alt-change--flat'}`}>{isPos?'▲':isNeg?'▼':'—'} {Math.abs(change).toFixed(1)}%</span>
|
|
||||||
: <span className="alt-change alt-change--up">{newLabel??'New'}</span>}
|
|
||||||
<span className="alt-metric-prev">{fmt(prev)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── main page ────────────────────────────────────────────────────
|
// ─── main page ────────────────────────────────────────────────────
|
||||||
export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels }: Props) {
|
export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels }: Props) {
|
||||||
const { lang: activeLang, setLanguage } = useLanguage();
|
const { lang: activeLang, setLanguage } = useLanguage();
|
||||||
@@ -350,8 +76,9 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
const [selDistricts, setSelDistricts] = useState<string[]>([]);
|
const [selDistricts, setSelDistricts] = useState<string[]>([]);
|
||||||
const [selChannels, setSelChannels] = useState<string[]>([]);
|
const [selChannels, setSelChannels] = useState<string[]>([]);
|
||||||
const [selMuseums, setSelMuseums] = useState<string[]>([]);
|
const [selMuseums, setSelMuseums] = useState<string[]>([]);
|
||||||
const [metric, setMetric] = useState('revenue');
|
const [metric, setMetric] = useState('revenue');
|
||||||
const [gran, setGran] = useState('week');
|
const [gran, setGran] = useState('week');
|
||||||
|
const [showLabels, setShowLabels] = useState(false);
|
||||||
|
|
||||||
const perm = useMemo(() => {
|
const perm = useMemo(() => {
|
||||||
if (!allowedMuseums || !allowedChannels) return [];
|
if (!allowedMuseums || !allowedChannels) return [];
|
||||||
@@ -402,7 +129,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
return sy===ey ? sy : `${L.monthShort[parseInt(s.slice(5,7))-1]} '${sy.slice(-2)}–${L.monthShort[parseInt(e.slice(5,7))-1]} '${ey.slice(-2)}`;
|
return sy===ey ? sy : `${L.monthShort[parseInt(s.slice(5,7))-1]} '${sy.slice(-2)}–${L.monthShort[parseInt(e.slice(5,7))-1]} '${ey.slice(-2)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const trendData = useMemo(() => {
|
const trendResult = useMemo(() => {
|
||||||
const group = (rows: MuseumRecord[], ps: string) => {
|
const group = (rows: MuseumRecord[], ps: string) => {
|
||||||
const s=new Date(ps); const acc: Record<number,MuseumRecord[]> = {};
|
const s=new Date(ps); const acc: Record<number,MuseumRecord[]> = {};
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
@@ -416,17 +143,56 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
};
|
};
|
||||||
const pg = group(prevData, prevStart), cg = group(currData, currStart);
|
const pg = group(prevData, prevStart), cg = group(currData, currStart);
|
||||||
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
|
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
|
||||||
|
const cs0 = new Date(currStart);
|
||||||
|
const fmt = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||||
const labels = Array.from({length:maxK}, (_,i) =>
|
const labels = Array.from({length:maxK}, (_,i) =>
|
||||||
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(new Date(currStart).getMonth()+i)%12] : `D${i+1}`
|
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(cs0.getMonth()+i)%12] : `D${i+1}`
|
||||||
);
|
);
|
||||||
|
const tooltipLabels = Array.from({length:maxK}, (_,i) => {
|
||||||
|
if (gran==='week') {
|
||||||
|
const ws = new Date(cs0.getTime() + i * 7 * 86400000);
|
||||||
|
const we = new Date(cs0.getTime() + (i+1) * 7 * 86400000 - 86400000);
|
||||||
|
return `Week ${i+1} · ${fmt(ws)} – ${fmt(we)}`;
|
||||||
|
}
|
||||||
|
if (gran==='month') {
|
||||||
|
const ms = new Date(cs0.getFullYear(), cs0.getMonth() + i, 1);
|
||||||
|
return ms.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
const ds = new Date(cs0.getTime() + i * 86400000);
|
||||||
|
return ds.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
});
|
||||||
|
const museumList = (selMuseums.length > 0 ? selMuseums : museums)
|
||||||
|
.filter(museum => currData.some(r => r.museum_name === museum));
|
||||||
|
const multiMuseum = museumList.length >= 2;
|
||||||
|
const museumDatasets = museumList.map((museum, idx) => {
|
||||||
|
const mg = group(currData.filter(r => r.museum_name === museum), currStart);
|
||||||
|
return {
|
||||||
|
label: museum,
|
||||||
|
data: labels.map((_,i) => mg[i+1]||0),
|
||||||
|
borderColor: chartPalette[idx % chartPalette.length],
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: false,
|
||||||
|
pointRadius: gran==='week' ? 3 : 1,
|
||||||
|
pointBackgroundColor: chartPalette[idx % chartPalette.length],
|
||||||
|
_isMuseumLine: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
labels,
|
tooltipLabels,
|
||||||
datasets: [
|
multiMuseum,
|
||||||
{ label:periodLabel(prevStart,prevEnd), data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:2, tension:0.4, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.muted },
|
data: {
|
||||||
{ label:periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'15', borderWidth:2, tension:0.4, fill:true, pointRadius:gran==='week'?4:2, pointBackgroundColor:chartColors.primary },
|
labels,
|
||||||
]
|
datasets: [
|
||||||
|
{ label:periodLabel(prevStart,prevEnd), data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:2, tension:0.4, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.muted },
|
||||||
|
...museumDatasets,
|
||||||
|
{ label: multiMuseum ? `Total · ${periodLabel(currStart,currEnd)}` : periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:TOTAL_COLOR, backgroundColor: multiMuseum ? 'transparent' : TOTAL_COLOR+'15', borderWidth:2.5, tension:0.4, fill: !multiMuseum, pointRadius:gran==='week'?4:2, pointBackgroundColor:TOTAL_COLOR },
|
||||||
|
]
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L]);
|
}, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L, selMuseums, museums]);
|
||||||
|
const trendData = trendResult.data;
|
||||||
|
|
||||||
const museumData = useMemo(() => {
|
const museumData = useMemo(() => {
|
||||||
const all = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[];
|
const all = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[];
|
||||||
@@ -442,8 +208,50 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
};
|
};
|
||||||
}, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal]);
|
}, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal]);
|
||||||
|
|
||||||
const baseOpts = useMemo(() => createBaseOptions(false), []);
|
const baseOpts = useMemo(() => createBaseOptions(showLabels), [showLabels]);
|
||||||
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } };
|
const { chartOpts } = useMemo(() => {
|
||||||
|
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } };
|
||||||
|
return { chartOpts };
|
||||||
|
}, [baseOpts]);
|
||||||
|
const trendOpts: any = useMemo(() => ({
|
||||||
|
...chartOpts,
|
||||||
|
interaction: { mode: 'nearest', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
...chartOpts.plugins,
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'right' as const,
|
||||||
|
labels: {
|
||||||
|
padding: 14,
|
||||||
|
font: { size: 11, weight: 'bold' as const },
|
||||||
|
usePointStyle: true,
|
||||||
|
generateLabels: (chart: any) =>
|
||||||
|
chart.data.datasets.map((ds: any, i: number) => {
|
||||||
|
const color: string = ds.borderColor || '#64748b';
|
||||||
|
const pill = document.createElement('canvas');
|
||||||
|
pill.width = 10; pill.height = 10;
|
||||||
|
const pCtx = pill.getContext('2d');
|
||||||
|
if (pCtx) {
|
||||||
|
pCtx.strokeStyle = color;
|
||||||
|
pCtx.lineWidth = 1;
|
||||||
|
pCtx.beginPath();
|
||||||
|
pCtx.arc(5, 5, 4, 0, Math.PI * 2);
|
||||||
|
pCtx.stroke();
|
||||||
|
}
|
||||||
|
return { text: ds.label, fillStyle: color, strokeStyle: color,
|
||||||
|
fontColor: color, lineWidth: 0, pointStyle: pill,
|
||||||
|
hidden: !chart.isDatasetVisible(i), datasetIndex: i };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...chartOpts.plugins.tooltip,
|
||||||
|
callbacks: {
|
||||||
|
title: (items: any[]) => trendResult.tooltipLabels[items[0]?.dataIndex] ?? items[0]?.label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), [chartOpts, trendResult.tooltipLabels]);
|
||||||
|
|
||||||
const metricOpts = [
|
const metricOpts = [
|
||||||
{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors },
|
{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors },
|
||||||
@@ -471,148 +279,19 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
|
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
|
||||||
|
|
||||||
const hasFilters = selDistricts.length>0 || selChannels.length>0 || selMuseums.length>0;
|
const hasFilters = selDistricts.length>0 || selChannels.length>0 || selMuseums.length>0;
|
||||||
|
const activeFilterCount = selDistricts.length + selChannels.length + selMuseums.length;
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="alt-page" dir={L.dir}>
|
<div
|
||||||
<style>{`
|
className="alt-page"
|
||||||
${L.fontImport}
|
dir={L.dir}
|
||||||
|
style={{
|
||||||
.alt-page { max-width:1100px; margin:0 auto; padding:48px 24px 80px; font-family:${L.bodyFont}; width:100%; box-sizing:border-box; }
|
'--alt-body-font': L.bodyFont,
|
||||||
|
'--alt-display-font': L.displayFont,
|
||||||
/* ── header ── */
|
'--alt-mono-font': L.monoFont,
|
||||||
.alt-back { display:inline-flex; align-items:center; gap:6px; font-size:.8125rem; color:var(--text-muted); text-decoration:none; margin-bottom:28px; transition:color .15s; }
|
} as React.CSSProperties}
|
||||||
.alt-back:hover { color:var(--accent); }
|
>
|
||||||
.alt-page-title { font-family:${L.displayFont}; font-size:2.25rem; font-weight:400; color:var(--text-primary); margin:0 0 6px; letter-spacing:-.03em; line-height:1.15; }
|
|
||||||
.alt-page-sub { font-size:.9375rem; color:var(--text-muted); margin:0 0 40px; font-weight:300; }
|
|
||||||
|
|
||||||
/* ── period row ── */
|
|
||||||
.alt-period-row { display:grid; grid-template-columns:1fr auto 1fr; align-items:stretch; margin-bottom:32px; }
|
|
||||||
.alt-vs { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:0 20px; position:relative; }
|
|
||||||
.alt-vs-line { position:absolute; top:0; bottom:0; left:50%; width:1px; background:var(--border); }
|
|
||||||
.alt-vs-badge { font-family:${L.displayFont}; font-size:.9rem; font-style:italic; color:var(--text-muted); background:var(--bg); padding:6px 10px; border:1px solid var(--border); border-radius:20px; position:relative; z-index:1; }
|
|
||||||
|
|
||||||
/* ── period card ── */
|
|
||||||
.alt-card { border:1px solid var(--border); border-radius:var(--radius); background:var(--surface); overflow:hidden; transition:border-color .2s,box-shadow .2s; display:flex; flex-direction:column; }
|
|
||||||
.alt-card--current { border-radius:var(--radius) 0 0 var(--radius); }
|
|
||||||
.alt-card--previous { border-radius:0 var(--radius) var(--radius) 0; }
|
|
||||||
[dir="rtl"] .alt-card--current { border-radius:0 var(--radius) var(--radius) 0; }
|
|
||||||
[dir="rtl"] .alt-card--previous { border-radius:var(--radius) 0 0 var(--radius); }
|
|
||||||
.alt-card:hover { box-shadow:var(--shadow); }
|
|
||||||
.alt-card--current:hover,.alt-card--current.alt-card--open { border-color:var(--accent); }
|
|
||||||
.alt-card--previous:hover,.alt-card--previous.alt-card--open { border-color:#94a3b8; }
|
|
||||||
.alt-card-bar { height:3px; width:100%; }
|
|
||||||
.alt-card--current .alt-card-bar { background:var(--accent); }
|
|
||||||
.alt-card--previous .alt-card-bar { background:#94a3b8; }
|
|
||||||
.alt-card-body { padding:24px 28px 20px; flex:1; }
|
|
||||||
.alt-role-row { display:flex; align-items:baseline; gap:8px; margin-bottom:12px; }
|
|
||||||
.alt-role { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.1em; }
|
|
||||||
.alt-card--current .alt-role { color:var(--accent); }
|
|
||||||
.alt-card--previous .alt-role { color:#64748b; }
|
|
||||||
.alt-role-hint { font-size:.75rem; color:var(--text-muted); font-weight:300; }
|
|
||||||
.alt-period-name { font-family:${L.displayFont}; font-size:2.25rem; font-weight:400; color:var(--text-primary); line-height:1.1; letter-spacing:-.02em; margin-bottom:8px; }
|
|
||||||
.alt-date-range { font-family:${L.monoFont}; font-size:.8125rem; color:var(--text-muted); letter-spacing:.01em; margin-bottom:20px; }
|
|
||||||
.alt-change-btn { display:inline-flex; align-items:center; gap:5px; font-family:${L.bodyFont}; font-size:.8125rem; font-weight:500; color:var(--text-muted); background:none; border:none; padding:0; cursor:pointer; transition:color .15s; }
|
|
||||||
.alt-card--current .alt-change-btn:hover { color:var(--accent); }
|
|
||||||
.alt-card--previous .alt-change-btn:hover { color:var(--text-primary); }
|
|
||||||
|
|
||||||
/* ── picker ── */
|
|
||||||
.alt-picker { border-top:1px solid var(--border); padding:16px 24px 20px; background:var(--bg); animation:altPickIn 180ms cubic-bezier(.16,1,.3,1); }
|
|
||||||
@keyframes altPickIn { from{opacity:0;transform:translateY(-8px)} to{opacity:1;transform:translateY(0)} }
|
|
||||||
.alt-picker-year { display:flex; align-items:center; gap:16px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--border); }
|
|
||||||
.alt-yr-val { font-family:${L.displayFont}; font-size:1.25rem; color:var(--text-primary); min-width:50px; text-align:center; }
|
|
||||||
.alt-yr-btn { width:28px; height:28px; display:flex; align-items:center; justify-content:center; border:1px solid var(--border); border-radius:7px; background:var(--surface); color:var(--text-secondary); cursor:pointer; transition:background .12s,border-color .12s,color .12s; }
|
|
||||||
.alt-yr-btn:hover:not(:disabled) { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
|
|
||||||
.alt-yr-btn:disabled { opacity:.3; cursor:not-allowed; }
|
|
||||||
.alt-picker-section { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:10px 0 6px; }
|
|
||||||
.alt-chips { display:flex; flex-wrap:wrap; gap:5px; margin-bottom:4px; }
|
|
||||||
.alt-chip { font-family:${L.bodyFont}; padding:4px 9px; border:1px solid var(--border); border-radius:6px; background:var(--surface); color:var(--text-secondary); font-size:.8rem; font-weight:500; cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
|
|
||||||
.alt-chip:hover { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
|
|
||||||
.alt-chip-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; font-weight:600!important; }
|
|
||||||
.alt-chip-wide { padding-left:14px; padding-right:14px; }
|
|
||||||
.alt-picker-div { height:1px; background:var(--border); margin:12px 0 10px; }
|
|
||||||
.alt-custom { display:flex; align-items:flex-end; gap:8px; }
|
|
||||||
.alt-custom-f { flex:1; display:flex; flex-direction:column; gap:4px; }
|
|
||||||
.alt-custom-f label { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--text-muted); }
|
|
||||||
.alt-custom-f input[type="date"] { padding:7px 9px; border:1px solid var(--border); border-radius:7px; font-size:.825rem; background:var(--surface); color:var(--text-primary); width:100%; }
|
|
||||||
.alt-custom-f input[type="date"]:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 2px rgba(37,99,235,.12); }
|
|
||||||
.alt-custom-arrow { font-size:.75rem; color:var(--text-muted); padding-bottom:9px; flex-shrink:0; }
|
|
||||||
.alt-footer { display:flex; justify-content:flex-end; gap:8px; }
|
|
||||||
.alt-cancel,.alt-apply { padding:7px 16px; border-radius:7px; font-size:.825rem; font-weight:600; cursor:pointer; font-family:${L.bodyFont}; transition:background .12s,color .12s; }
|
|
||||||
.alt-cancel { background:transparent; border:1px solid var(--border); color:var(--text-secondary); }
|
|
||||||
.alt-cancel:hover { background:var(--bg-secondary); }
|
|
||||||
.alt-apply { background:var(--accent); border:1px solid transparent; color:#fff; }
|
|
||||||
.alt-apply:hover { opacity:.88; }
|
|
||||||
|
|
||||||
/* ── multi-select ── */
|
|
||||||
.altms { position:relative; }
|
|
||||||
.altms-trigger { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border:1px solid var(--border); border-radius:8px; background:var(--surface); color:var(--text-secondary); font-family:${L.bodyFont}; font-size:.875rem; cursor:pointer; transition:border-color .15s,color .15s,background .15s; white-space:nowrap; }
|
|
||||||
.altms-trigger:hover { border-color:var(--accent); color:var(--accent); }
|
|
||||||
.altms-trigger--active { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
|
|
||||||
.altms-label { max-width:140px; overflow:hidden; text-overflow:ellipsis; }
|
|
||||||
.altms-chevron { transition:transform .18s; flex-shrink:0; }
|
|
||||||
.altms-chevron--open { transform:rotate(180deg); }
|
|
||||||
.altms-dropdown { position:absolute; top:calc(100% + 6px); left:0; z-index:200; min-width:200px; background:var(--surface); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.12); overflow:hidden; animation:altPickIn 140ms cubic-bezier(.16,1,.3,1); }
|
|
||||||
[dir="rtl"] .altms-dropdown { left:auto; right:0; }
|
|
||||||
.altms-list { max-height:220px; overflow-y:auto; padding:6px; display:flex; flex-direction:column; gap:2px; }
|
|
||||||
.altms-option { display:flex; align-items:center; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; transition:background .1s; }
|
|
||||||
.altms-option:hover { background:var(--bg); }
|
|
||||||
.altms-option--checked { background:var(--accent-light); }
|
|
||||||
.altms-check { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
|
|
||||||
.altms-check-box { width:16px; height:16px; border:1.5px solid var(--border); border-radius:4px; display:flex; align-items:center; justify-content:center; flex-shrink:0; transition:background .1s,border-color .1s; }
|
|
||||||
.altms-option--checked .altms-check-box { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
|
|
||||||
.altms-opt-label { font-family:${L.bodyFont}; font-size:.875rem; color:var(--text-primary); }
|
|
||||||
.altms-clear { width:100%; padding:8px 14px; border-top:1px solid var(--border); background:none; border-left:none; border-right:none; border-bottom:none; font-family:${L.bodyFont}; font-size:.8125rem; color:var(--danger); cursor:pointer; text-align:start; transition:background .1s; }
|
|
||||||
.altms-clear:hover { background:var(--danger-light); }
|
|
||||||
|
|
||||||
/* ── filter bar ── */
|
|
||||||
.alt-filter-bar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:36px; padding:14px 20px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); }
|
|
||||||
.alt-filter-label { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); white-space:nowrap; }
|
|
||||||
.alt-filter-sep { width:1px; height:20px; background:var(--border); flex-shrink:0; }
|
|
||||||
.alt-filter-reset { margin-inline-start:auto; font-size:.8125rem; color:var(--text-muted); background:none; border:none; cursor:pointer; padding:4px 6px; transition:color .15s; font-family:${L.bodyFont}; }
|
|
||||||
.alt-filter-reset:hover { color:var(--danger); }
|
|
||||||
|
|
||||||
/* ── metrics ── */
|
|
||||||
.alt-metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:1px; background:var(--border); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; margin-bottom:40px; }
|
|
||||||
.alt-metric { background:var(--surface); padding:24px 22px; }
|
|
||||||
.alt-metric-title { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:0 0 12px; }
|
|
||||||
.alt-metric-value { font-family:${L.displayFont}; font-size:1.875rem; font-weight:400; color:var(--text-primary); line-height:1; margin-bottom:10px; letter-spacing:-.02em; }
|
|
||||||
.alt-metric-footer { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
||||||
.alt-change { font-size:.75rem; font-weight:600; padding:2px 8px; border-radius:20px; white-space:nowrap; font-family:${L.bodyFont}; }
|
|
||||||
.alt-change--up { background:var(--success-light); color:var(--success); }
|
|
||||||
.alt-change--down { background:var(--danger-light); color:var(--danger); }
|
|
||||||
.alt-change--flat { background:var(--muted-light); color:var(--text-muted); }
|
|
||||||
.alt-metric-prev { font-size:.75rem; color:var(--text-muted); font-family:${L.monoFont}; }
|
|
||||||
|
|
||||||
/* ── charts ── */
|
|
||||||
.alt-charts { display:grid; grid-template-columns:1fr; gap:24px; }
|
|
||||||
.alt-chart-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:28px 28px 24px; min-width:0; overflow:hidden; }
|
|
||||||
.alt-chart-header { display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:24px; gap:16px; flex-wrap:wrap; }
|
|
||||||
.alt-chart-title { font-family:${L.displayFont}; font-size:1.375rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; font-style:italic; }
|
|
||||||
.alt-chart-controls { display:flex; gap:6px; flex-wrap:wrap; }
|
|
||||||
.alt-ctrl { font-family:${L.bodyFont}; font-size:.75rem; font-weight:500; padding:4px 10px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text-secondary); cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
|
|
||||||
.alt-ctrl:hover { border-color:var(--accent); color:var(--accent); }
|
|
||||||
.alt-ctrl-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; }
|
|
||||||
.alt-ctrl-sep { width:1px; height:20px; background:var(--border); align-self:center; }
|
|
||||||
.alt-chart-wrap { position:relative; height:280px; overflow:hidden; direction:ltr; width:100%; }
|
|
||||||
|
|
||||||
/* ── section heading ── */
|
|
||||||
.alt-section-heading { display:flex; align-items:center; gap:12px; margin:0 0 20px; }
|
|
||||||
.alt-section-heading h2 { font-family:${L.displayFont}; font-size:1.375rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; }
|
|
||||||
.alt-section-heading::after { content:''; flex:1; height:1px; background:var(--border); }
|
|
||||||
|
|
||||||
/* ── responsive ── */
|
|
||||||
@media (max-width:680px) {
|
|
||||||
.alt-period-row { grid-template-columns:1fr; }
|
|
||||||
.alt-card--current,.alt-card--previous { border-radius:var(--radius); }
|
|
||||||
.alt-vs { flex-direction:row; padding:10px 0; }
|
|
||||||
.alt-vs-line { position:static; width:100%; height:1px; }
|
|
||||||
.alt-period-name { font-size:1.75rem; }
|
|
||||||
.alt-metrics { grid-template-columns:1fr 1fr; }
|
|
||||||
.alt-page-title { font-size:1.75rem; }
|
|
||||||
.alt-chart-header { flex-direction:column; }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
<Link to={L.backTo} className="alt-back">
|
<Link to={L.backTo} className="alt-back">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{ transform: L.dir==='rtl' ? 'scaleX(-1)' : undefined }}>
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{ transform: L.dir==='rtl' ? 'scaleX(-1)' : undefined }}>
|
||||||
<path d="M9 2L4 7L9 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M9 2L4 7L9 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
@@ -633,16 +312,22 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
onChange={(s,e) => { setPrevStart(s); setPrevEnd(e); }} availableYears={availableYears} L={L} />
|
onChange={(s,e) => { setPrevStart(s); setPrevEnd(e); }} availableYears={availableYears} L={L} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="alt-filter-bar">
|
<div className={`alt-filter-bar${filtersOpen ? ' alt-filter-bar--open' : ''}`}>
|
||||||
<span className="alt-filter-label">{L.filter}</span>
|
<div className="alt-filter-head">
|
||||||
<div className="alt-filter-sep" />
|
<span className="alt-filter-label">{L.filter}</span>
|
||||||
<AltMultiSelect value={selDistricts} options={districts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
|
{activeFilterCount > 0 && <span className="alt-filter-badge">{activeFilterCount}</span>}
|
||||||
<AltMultiSelect value={selChannels} options={channels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
|
<button type="button" className="alt-filter-toggle" onClick={() => setFiltersOpen(v => !v)} aria-expanded={filtersOpen} aria-label="Toggle filters">
|
||||||
<AltMultiSelect value={selMuseums} options={museums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} />
|
<svg className={`alt-filter-chevron${filtersOpen ? ' alt-filter-chevron--open' : ''}`} width="14" height="14" viewBox="0 0 10 10" fill="none">
|
||||||
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
|
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
<div className="alt-vat-toggle" style={{ marginInlineStart: 'auto' }}>
|
</svg>
|
||||||
<button type="button" className={`alt-vat-opt${activeLang==='en'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('en')}>EN</button>
|
</button>
|
||||||
<button type="button" className={`alt-vat-opt${activeLang==='ar'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('ar')}>AR</button>
|
</div>
|
||||||
|
<div className="alt-filter-body">
|
||||||
|
<div className="alt-filter-sep" />
|
||||||
|
<AltMultiSelect value={selDistricts} options={districts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
|
||||||
|
<AltMultiSelect value={selChannels} options={channels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
|
||||||
|
<AltMultiSelect value={selMuseums} options={museums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} />
|
||||||
|
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -663,18 +348,20 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
<div className="alt-chart-header">
|
<div className="alt-chart-header">
|
||||||
<h3 className="alt-chart-title">{L.trendTitle}</h3>
|
<h3 className="alt-chart-title">{L.trendTitle}</h3>
|
||||||
<div className="alt-chart-controls">
|
<div className="alt-chart-controls">
|
||||||
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||||
<div className="alt-ctrl-sep" />
|
<div className="alt-ctrl-sep" />
|
||||||
{granOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
||||||
|
<div className="alt-ctrl-sep" />
|
||||||
|
<button type="button" aria-pressed={showLabels} className={`alt-ctrl${showLabels?' alt-ctrl-on':''}`} onClick={() => setShowLabels(v => !v)}>{'Labels'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="alt-chart-wrap"><Line data={trendData} options={chartOpts} /></div>
|
<div className="alt-chart-wrap"><Line data={trendData} options={trendOpts} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="alt-chart-card">
|
<div className="alt-chart-card">
|
||||||
<div className="alt-chart-header">
|
<div className="alt-chart-header">
|
||||||
<h3 className="alt-chart-title">{L.museumTitle}</h3>
|
<h3 className="alt-chart-title">{L.museumTitle}</h3>
|
||||||
<div className="alt-chart-controls">
|
<div className="alt-chart-controls">
|
||||||
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="alt-chart-wrap"><Bar data={museumData} options={chartOpts} /></div>
|
<div className="alt-chart-wrap"><Bar data={museumData} options={chartOpts} /></div>
|
||||||
|
|||||||
+156
-489
@@ -1,15 +1,19 @@
|
|||||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Line, Bar, Pie } from 'react-chartjs-2';
|
import { Line, Bar, Pie } from 'react-chartjs-2';
|
||||||
import {
|
import {
|
||||||
filterDataByDateRange, calculateMetrics, formatCurrency, formatNumber,
|
filterDataByDateRange, calculateMetrics,
|
||||||
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
|
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
|
||||||
groupByMuseum, groupByChannel, groupByDistrict,
|
groupByMuseum, groupByChannel, groupByDistrict,
|
||||||
umrahData,
|
umrahData,
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, chartPalette, createBaseOptions, TOTAL_COLOR } from '../config/chartConfig';
|
||||||
import type { MuseumRecord, Season } from '../types';
|
import type { MuseumRecord, Season } from '../types';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { EN, AR } from '../lib/locale';
|
||||||
|
import { currentMonth, shiftYear } from '../lib/dateHelpers';
|
||||||
|
import PeriodHero from './shared/PeriodPicker';
|
||||||
|
import AltMultiSelect from './shared/AltMultiSelect';
|
||||||
|
import MetricCard from './shared/MetricCard';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: MuseumRecord[];
|
data: MuseumRecord[];
|
||||||
@@ -21,325 +25,6 @@ interface Props {
|
|||||||
lang?: 'en' | 'ar';
|
lang?: 'en' | 'ar';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── language config ──────────────────────────────────────────────
|
|
||||||
interface LC {
|
|
||||||
dir: 'ltr' | 'rtl';
|
|
||||||
fontImport: string;
|
|
||||||
bodyFont: string;
|
|
||||||
displayFont: string;
|
|
||||||
monoFont: string;
|
|
||||||
monthFull: string[];
|
|
||||||
monthShort: string[];
|
|
||||||
periods: Record<string, string>;
|
|
||||||
fullYearLabel: (y: number) => string;
|
|
||||||
dateRangeSep: string;
|
|
||||||
backLink: string;
|
|
||||||
backTo: string;
|
|
||||||
pageTitle: string;
|
|
||||||
pageSub: string;
|
|
||||||
changePeriod: string;
|
|
||||||
close: string;
|
|
||||||
apply: string;
|
|
||||||
filter: string;
|
|
||||||
allDistricts: string; allChannels: string; allMuseums: string;
|
|
||||||
countDistricts: (n: number) => string;
|
|
||||||
countChannels: (n: number) => string;
|
|
||||||
countMuseums: (n: number) => string;
|
|
||||||
reset: string;
|
|
||||||
exclVAT: string; inclVAT: string;
|
|
||||||
keyMetrics: string;
|
|
||||||
revenue: string; visitors: string; tickets: string; avgRev: string;
|
|
||||||
pilgrims: string; captureRate: string;
|
|
||||||
charts: string;
|
|
||||||
trendTitle: string; museumTitle: string; channelTitle: string; districtTitle: string;
|
|
||||||
daily: string; weekly: string; monthly: string;
|
|
||||||
newLabel: string;
|
|
||||||
clearSel: string;
|
|
||||||
monthSection: string; periodSection: string;
|
|
||||||
from: string; to: string;
|
|
||||||
vsLabel: string;
|
|
||||||
barLabel: string; pieLabel: string;
|
|
||||||
absLabel: string; pctLabel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EN: LC = {
|
|
||||||
dir: 'ltr',
|
|
||||||
fontImport: `@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&display=swap');`,
|
|
||||||
bodyFont: "'Outfit', sans-serif",
|
|
||||||
displayFont: "'DM Serif Display', serif",
|
|
||||||
monoFont: "ui-monospace, 'Cascadia Code', monospace",
|
|
||||||
monthFull: ['January','February','March','April','May','June','July','August','September','October','November','December'],
|
|
||||||
monthShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
|
|
||||||
periods: { q1:'Q1', q2:'Q2', q3:'Q3', q4:'Q4', h1:'H1', h2:'H2', full:'Full Year' },
|
|
||||||
fullYearLabel: (y) => String(y),
|
|
||||||
dateRangeSep: '→',
|
|
||||||
backLink: 'Back to Dashboard', backTo: '/',
|
|
||||||
pageTitle: 'Overview', pageSub: 'Museum performance at a glance.',
|
|
||||||
changePeriod: 'Change period', close: 'Cancel', apply: 'Apply',
|
|
||||||
filter: 'Filter',
|
|
||||||
allDistricts: 'All districts', allChannels: 'All channels', allMuseums: 'All museums',
|
|
||||||
countDistricts: (n) => `${n} districts`,
|
|
||||||
countChannels: (n) => `${n} channels`,
|
|
||||||
countMuseums: (n) => `${n} museums`,
|
|
||||||
reset: 'Reset', exclVAT: 'Excl. VAT', inclVAT: 'Incl. VAT',
|
|
||||||
keyMetrics: 'Key Metrics',
|
|
||||||
revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets',
|
|
||||||
avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %',
|
|
||||||
charts: 'Charts',
|
|
||||||
trendTitle: 'Trend over time', museumTitle: 'By museum',
|
|
||||||
channelTitle: 'By channel', districtTitle: 'By district',
|
|
||||||
daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
|
|
||||||
newLabel: 'New', clearSel: 'Clear selection',
|
|
||||||
monthSection: 'Month', periodSection: 'Quarter · Half · Year',
|
|
||||||
from: 'From', to: 'To', vsLabel: 'vs',
|
|
||||||
barLabel: 'Bar', pieLabel: 'Pie', absLabel: '#', pctLabel: '%',
|
|
||||||
};
|
|
||||||
|
|
||||||
const AR: LC = {
|
|
||||||
dir: 'rtl',
|
|
||||||
fontImport: `@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');`,
|
|
||||||
bodyFont: "'IBM Plex Sans Arabic', sans-serif",
|
|
||||||
displayFont: "'IBM Plex Sans Arabic', sans-serif",
|
|
||||||
monoFont: "'IBM Plex Sans Arabic', sans-serif",
|
|
||||||
monthFull: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'],
|
|
||||||
monthShort: ['ينا','فبر','مار','أبر','ماي','يون','يول','أغس','سبت','أكت','نوف','ديس'],
|
|
||||||
periods: { q1:'ر١', q2:'ر٢', q3:'ر٣', q4:'ر٤', h1:'ن١', h2:'ن٢', full:'السنة' },
|
|
||||||
fullYearLabel: (y) => `${y} كاملاً`,
|
|
||||||
dateRangeSep: '–',
|
|
||||||
backLink: 'العودة إلى لوحة التحكم', backTo: '/ar',
|
|
||||||
pageTitle: 'نظرة عامة', pageSub: 'أداء المتاحف في لمحة.',
|
|
||||||
changePeriod: 'تغيير الفترة', close: 'إلغاء', apply: 'تطبيق',
|
|
||||||
filter: 'تصفية',
|
|
||||||
allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف',
|
|
||||||
countDistricts: (n) => `${n} مناطق`,
|
|
||||||
countChannels: (n) => `${n} قنوات`,
|
|
||||||
countMuseums: (n) => `${n} متاحف`,
|
|
||||||
reset: 'إعادة ضبط', exclVAT: 'بدون ضريبة', inclVAT: 'مع ضريبة',
|
|
||||||
keyMetrics: 'المؤشرات الرئيسية',
|
|
||||||
revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر',
|
|
||||||
avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %',
|
|
||||||
charts: 'المخططات',
|
|
||||||
trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف',
|
|
||||||
channelTitle: 'حسب القناة', districtTitle: 'حسب المنطقة',
|
|
||||||
daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري',
|
|
||||||
newLabel: 'جديد', clearSel: 'مسح التحديد',
|
|
||||||
monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة',
|
|
||||||
from: 'من', to: 'إلى', vsLabel: 'مقابل',
|
|
||||||
barLabel: 'أعمدة', pieLabel: 'دائرة', absLabel: '#', pctLabel: '%',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── date helpers ─────────────────────────────────────────────────
|
|
||||||
const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
|
|
||||||
|
|
||||||
function isLeap(y: number) { return (y%4===0 && y%100!==0) || y%400===0; }
|
|
||||||
|
|
||||||
function makePresets(y: number): Record<string, { start: string; end: string }> {
|
|
||||||
const feb = isLeap(y) ? 29 : 28;
|
|
||||||
return {
|
|
||||||
jan:{start:`${y}-01-01`,end:`${y}-01-31`}, feb:{start:`${y}-02-01`,end:`${y}-02-${String(feb).padStart(2,'0')}`},
|
|
||||||
mar:{start:`${y}-03-01`,end:`${y}-03-31`}, apr:{start:`${y}-04-01`,end:`${y}-04-30`},
|
|
||||||
may:{start:`${y}-05-01`,end:`${y}-05-31`}, jun:{start:`${y}-06-01`,end:`${y}-06-30`},
|
|
||||||
jul:{start:`${y}-07-01`,end:`${y}-07-31`}, aug:{start:`${y}-08-01`,end:`${y}-08-31`},
|
|
||||||
sep:{start:`${y}-09-01`,end:`${y}-09-30`}, oct:{start:`${y}-10-01`,end:`${y}-10-31`},
|
|
||||||
nov:{start:`${y}-11-01`,end:`${y}-11-30`}, dec:{start:`${y}-12-01`,end:`${y}-12-31`},
|
|
||||||
q1:{start:`${y}-01-01`,end:`${y}-03-31`}, q2:{start:`${y}-04-01`,end:`${y}-06-30`},
|
|
||||||
q3:{start:`${y}-07-01`,end:`${y}-09-30`}, q4:{start:`${y}-10-01`,end:`${y}-12-31`},
|
|
||||||
h1:{start:`${y}-01-01`,end:`${y}-06-30`}, h2:{start:`${y}-07-01`,end:`${y}-12-31`},
|
|
||||||
full:{start:`${y}-01-01`,end:`${y}-12-31`},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function guessPreset(start: string, end: string) {
|
|
||||||
const year = parseInt(start.slice(0,4));
|
|
||||||
const presets = makePresets(year);
|
|
||||||
for (const [key, r] of Object.entries(presets)) {
|
|
||||||
if (r.start === start && r.end === end) return { key, year };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function periodNameL(start: string, end: string, L: LC): string {
|
|
||||||
const year = parseInt(start.slice(0,4));
|
|
||||||
const g = guessPreset(start, end);
|
|
||||||
if (!g) {
|
|
||||||
const fmt = (d: string) => { const [,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]}`; };
|
|
||||||
const ey = parseInt(end.slice(0,4));
|
|
||||||
return year === ey ? `${fmt(start)} – ${fmt(end)} ${year}` : `${fmt(start)} ${year} – ${fmt(end)} ${ey}`;
|
|
||||||
}
|
|
||||||
const mi = MONTH_KEYS.indexOf(g.key);
|
|
||||||
if (mi >= 0) return `${L.monthFull[mi]} ${g.year}`;
|
|
||||||
if (g.key === 'full') return L.fullYearLabel(g.year);
|
|
||||||
return `${L.periods[g.key] ?? g.key.toUpperCase()} ${g.year}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateRangeTextL(start: string, end: string, L: LC): string {
|
|
||||||
const fmt = (d: string) => { const [y,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]} ${y}`; };
|
|
||||||
return `${fmt(start)} ${L.dateRangeSep} ${fmt(end)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentMonth() {
|
|
||||||
const now = new Date(); const y = now.getFullYear(), m = now.getMonth()+1;
|
|
||||||
const p = (n: number) => String(n).padStart(2,'0');
|
|
||||||
return { start: `${y}-${p(m)}-01`, end: `${y}-${p(m)}-${p(new Date(y, m, 0).getDate())}` };
|
|
||||||
}
|
|
||||||
function shiftYear(s: string) { return s.replace(/^(\d{4})/, (_,y) => String(parseInt(y)-1)); }
|
|
||||||
|
|
||||||
// ─── inline picker ────────────────────────────────────────────────
|
|
||||||
function InlinePicker({ start, end, onChange, onClose, availableYears, L }: {
|
|
||||||
start: string; end: string; onChange: (s: string, e: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
availableYears: number[]; L: LC;
|
|
||||||
}) {
|
|
||||||
const g = guessPreset(start, end);
|
|
||||||
const [year, setYear] = useState(g?.year ?? parseInt(start.slice(0,4)));
|
|
||||||
const [active, setActive] = useState<string|null>(g?.key ?? null);
|
|
||||||
const [draftStart, setDraftStart] = useState(start);
|
|
||||||
const [draftEnd, setDraftEnd] = useState(end);
|
|
||||||
const minY = Math.min(...availableYears), maxY = Math.max(...availableYears);
|
|
||||||
|
|
||||||
const pick = (key: string) => { const r = makePresets(year)[key]; if (!r) return; setActive(key); setDraftStart(r.start); setDraftEnd(r.end); };
|
|
||||||
const shift = (d: number) => {
|
|
||||||
const ny = year+d; if (ny < minY || ny > maxY) return; setYear(ny);
|
|
||||||
if (active && makePresets(ny)[active]) { setDraftStart(makePresets(ny)[active].start); setDraftEnd(makePresets(ny)[active].end); }
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="alt-picker">
|
|
||||||
<div className="alt-picker-year">
|
|
||||||
<button type="button" onClick={() => shift(L.dir==='rtl' ? 1 : -1)} disabled={L.dir==='rtl' ? year>=maxY : year<=minY} className="alt-yr-btn">
|
|
||||||
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M5.5 9.5L1.5 5.5L5.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
||||||
</button>
|
|
||||||
<span className="alt-yr-val">{year}</span>
|
|
||||||
<button type="button" onClick={() => shift(L.dir==='rtl' ? -1 : 1)} disabled={L.dir==='rtl' ? year<=minY : year>=maxY} className="alt-yr-btn">
|
|
||||||
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M1.5 1.5L5.5 5.5L1.5 9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="alt-picker-section">{L.monthSection}</p>
|
|
||||||
<div className="alt-chips">
|
|
||||||
{MONTH_KEYS.map((k,i) => (
|
|
||||||
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.monthShort[i]}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="alt-picker-section">{L.periodSection}</p>
|
|
||||||
<div className="alt-chips">
|
|
||||||
{['q1','q2','q3','q4','h1','h2'].map(k => (
|
|
||||||
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.periods[k]}</button>
|
|
||||||
))}
|
|
||||||
<button type="button" className={`alt-chip alt-chip-wide${active==='full'?' alt-chip-on':''}`} onClick={() => pick('full')}>{L.periods.full}</button>
|
|
||||||
</div>
|
|
||||||
<div className="alt-picker-div" />
|
|
||||||
<div className="alt-custom">
|
|
||||||
<div className="alt-custom-f"><label>{L.from}</label><input type="date" value={draftStart} onChange={e => { setActive(null); setDraftStart(e.target.value); }} /></div>
|
|
||||||
<span className="alt-custom-arrow">{L.dateRangeSep}</span>
|
|
||||||
<div className="alt-custom-f"><label>{L.to}</label><input type="date" value={draftEnd} onChange={e => { setActive(null); setDraftEnd(e.target.value); }} /></div>
|
|
||||||
</div>
|
|
||||||
<div className="alt-picker-div" />
|
|
||||||
<div className="alt-footer">
|
|
||||||
<button type="button" className="alt-cancel" onClick={onClose}>{L.close}</button>
|
|
||||||
<button type="button" className="alt-apply" onClick={() => { onChange(draftStart, draftEnd); onClose(); }}>{L.apply}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── period hero ──────────────────────────────────────────────────
|
|
||||||
function PeriodHero({ start, end, onChange, availableYears, L }: {
|
|
||||||
start: string; end: string; onChange: (s: string, e: string) => void;
|
|
||||||
availableYears: number[]; L: LC;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const onM = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
|
||||||
const onK = (e: KeyboardEvent) => { if (e.key==='Escape') setOpen(false); };
|
|
||||||
document.addEventListener('mousedown', onM); document.addEventListener('keydown', onK);
|
|
||||||
return () => { document.removeEventListener('mousedown', onM); document.removeEventListener('keydown', onK); };
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="dalt-hero">
|
|
||||||
<div className="dalt-hero-inner">
|
|
||||||
<div>
|
|
||||||
<div className="dalt-hero-name">{periodNameL(start, end, L)}</div>
|
|
||||||
<div className="dalt-hero-range">{dateRangeTextL(start, end, L)}</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="dalt-hero-btn" onClick={() => setOpen(v => !v)} aria-expanded={open}>
|
|
||||||
{open ? L.close : L.changePeriod}
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ transform: open?'rotate(180deg)':'none', transition:'transform 0.2s' }}>
|
|
||||||
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{open && <InlinePicker start={start} end={end} onChange={onChange} onClose={() => setOpen(false)} availableYears={availableYears} L={L} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── multi-select ─────────────────────────────────────────────────
|
|
||||||
function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
|
|
||||||
value: string[]; options: string[];
|
|
||||||
onChange: (vals: string[]) => void;
|
|
||||||
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
|
||||||
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v!==opt) : [...value, opt]);
|
|
||||||
const label = value.length===0 ? allLabel : value.length===1 ? value[0] : countLabel(value.length);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="altms">
|
|
||||||
<button type="button" className={`altms-trigger${value.length>0?' altms-trigger--active':''}`} onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox">
|
|
||||||
<span className="altms-label">{label}</span>
|
|
||||||
<svg className={`altms-chevron${open?' altms-chevron--open':''}`} width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
||||||
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="altms-dropdown" role="listbox" aria-multiselectable="true">
|
|
||||||
<div className="altms-list">
|
|
||||||
{options.map(opt => (
|
|
||||||
<label key={opt} className={`altms-option${value.includes(opt)?' altms-option--checked':''}`}>
|
|
||||||
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
|
|
||||||
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
|
|
||||||
<span className="altms-opt-label">{opt}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{value.length>0 && <button type="button" className="altms-clear" onClick={() => { onChange([]); setOpen(false); }}>{clearLabel}</button>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── metric card ──────────────────────────────────────────────────
|
|
||||||
function MetricCard({ title, curr, prev, isCurrency, newLabel }: {
|
|
||||||
title: string; curr: number; prev: number; isCurrency?: boolean; newLabel?: string;
|
|
||||||
}) {
|
|
||||||
const fmt = (n: number) => isCurrency ? formatCurrency(n) : formatNumber(n);
|
|
||||||
const change = prev===0 ? (curr>0 ? Infinity : 0) : ((curr-prev)/prev*100);
|
|
||||||
const isPos = change>0, isNeg = change<0;
|
|
||||||
return (
|
|
||||||
<div className="alt-metric">
|
|
||||||
<p className="alt-metric-title">{title}</p>
|
|
||||||
<div className="alt-metric-value">{fmt(curr)}</div>
|
|
||||||
<div className="alt-metric-footer">
|
|
||||||
{isFinite(change)
|
|
||||||
? <span className={`alt-change ${isPos?'alt-change--up':isNeg?'alt-change--down':'alt-change--flat'}`}>{isPos?'▲':isNeg?'▼':'—'} {Math.abs(change).toFixed(1)}%</span>
|
|
||||||
: <span className="alt-change alt-change--up">{newLabel??'New'}</span>}
|
|
||||||
<span className="alt-metric-prev">{fmt(prev)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── main page ────────────────────────────────────────────────────
|
// ─── main page ────────────────────────────────────────────────────
|
||||||
export default function DashboardDemo({ data, seasons: _seasons, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: Props) {
|
export default function DashboardDemo({ data, seasons: _seasons, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: Props) {
|
||||||
const { lang: activeLang, setLanguage } = useLanguage();
|
const { lang: activeLang, setLanguage } = useLanguage();
|
||||||
@@ -350,8 +35,9 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
const [selDistricts, setSelDistricts] = useState<string[]>([]);
|
const [selDistricts, setSelDistricts] = useState<string[]>([]);
|
||||||
const [selChannels, setSelChannels] = useState<string[]>([]);
|
const [selChannels, setSelChannels] = useState<string[]>([]);
|
||||||
const [selMuseums, setSelMuseums] = useState<string[]>([]);
|
const [selMuseums, setSelMuseums] = useState<string[]>([]);
|
||||||
const [metric, setMetric] = useState('revenue');
|
const [metric, setMetric] = useState('revenue');
|
||||||
const [gran, setGran] = useState('week');
|
const [gran, setGran] = useState('week');
|
||||||
|
const [showLabels, setShowLabels] = useState(false);
|
||||||
const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar');
|
const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar');
|
||||||
const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie');
|
const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie');
|
||||||
const [districtChartType, setDistrictChartType] = useState<'bar'|'pie'>('pie');
|
const [districtChartType, setDistrictChartType] = useState<'bar'|'pie'>('pie');
|
||||||
@@ -403,7 +89,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0);
|
return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0);
|
||||||
}, [revenueField]);
|
}, [revenueField]);
|
||||||
|
|
||||||
const trendData = useMemo(() => {
|
const trendResult = useMemo(() => {
|
||||||
const group = (rows: MuseumRecord[], ps: string) => {
|
const group = (rows: MuseumRecord[], ps: string) => {
|
||||||
const s = new Date(ps); const acc: Record<number, MuseumRecord[]> = {};
|
const s = new Date(ps); const acc: Record<number, MuseumRecord[]> = {};
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
@@ -417,18 +103,57 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
};
|
};
|
||||||
const pg = group(prevData, prevStart), cg = group(filteredData, start);
|
const pg = group(prevData, prevStart), cg = group(filteredData, start);
|
||||||
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
|
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
|
||||||
|
const s0 = new Date(start);
|
||||||
|
const fmt = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||||
const labels = Array.from({length:maxK}, (_,i) =>
|
const labels = Array.from({length:maxK}, (_,i) =>
|
||||||
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(new Date(start).getMonth()+i)%12] : `D${i+1}`
|
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(s0.getMonth()+i)%12] : `D${i+1}`
|
||||||
);
|
);
|
||||||
|
const tooltipLabels = Array.from({length:maxK}, (_,i) => {
|
||||||
|
if (gran==='week') {
|
||||||
|
const ws = new Date(s0.getTime() + i * 7 * 86400000);
|
||||||
|
const we = new Date(s0.getTime() + (i+1) * 7 * 86400000 - 86400000);
|
||||||
|
return `Week ${i+1} · ${fmt(ws)} – ${fmt(we)}`;
|
||||||
|
}
|
||||||
|
if (gran==='month') {
|
||||||
|
const ms = new Date(s0.getFullYear(), s0.getMonth() + i, 1);
|
||||||
|
return ms.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
const ds = new Date(s0.getTime() + i * 86400000);
|
||||||
|
return ds.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
});
|
||||||
const prevYear = parseInt(start.slice(0,4))-1;
|
const prevYear = parseInt(start.slice(0,4))-1;
|
||||||
|
const museumList = (selMuseums.length > 0 ? selMuseums : allMuseums)
|
||||||
|
.filter(museum => filteredData.some(r => r.museum_name === museum));
|
||||||
|
const multiMuseum = museumList.length >= 2;
|
||||||
|
const museumDatasets = museumList.map((museum, idx) => {
|
||||||
|
const mg = group(filteredData.filter(r => r.museum_name === museum), start);
|
||||||
|
return {
|
||||||
|
label: museum,
|
||||||
|
data: labels.map((_,i) => mg[i+1]||0),
|
||||||
|
borderColor: chartPalette[idx % chartPalette.length],
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: false,
|
||||||
|
pointRadius: gran==='week' ? 3 : 1,
|
||||||
|
pointBackgroundColor: chartPalette[idx % chartPalette.length],
|
||||||
|
_isMuseumLine: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
labels,
|
tooltipLabels,
|
||||||
datasets: [
|
multiMuseum,
|
||||||
{ label:`${prevYear}`, data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:1.5, tension:0.4, pointRadius:0, borderDash:[4,3] },
|
data: {
|
||||||
{ label:start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'18', borderWidth:2.5, tension:0.4, fill:true, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.primary },
|
labels,
|
||||||
]
|
datasets: [
|
||||||
|
{ label:`${prevYear}`, data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:1.5, tension:0.4, pointRadius:0, borderDash:[4,3] },
|
||||||
|
...museumDatasets,
|
||||||
|
{ label: multiMuseum ? `Total ${start.slice(0,4)}` : start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:TOTAL_COLOR, backgroundColor: multiMuseum ? 'transparent' : TOTAL_COLOR+'18', borderWidth:2.5, tension:0.4, fill: !multiMuseum, pointRadius:gran==='week'?3:1, pointBackgroundColor:TOTAL_COLOR },
|
||||||
|
]
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [filteredData, prevData, prevStart, start, metric, gran, getVal, L]);
|
}, [filteredData, prevData, prevStart, start, metric, gran, getVal, L, selMuseums, allMuseums]);
|
||||||
|
const trendData = trendResult.data;
|
||||||
|
|
||||||
const museumData = useMemo(() => {
|
const museumData = useMemo(() => {
|
||||||
const g = groupByMuseum(filteredData, includeVAT);
|
const g = groupByMuseum(filteredData, includeVAT);
|
||||||
@@ -480,10 +205,53 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null;
|
const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null;
|
||||||
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
|
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
|
||||||
|
|
||||||
const baseOpts = useMemo(() => createBaseOptions(false), []);
|
const baseOpts = useMemo(() => createBaseOptions(showLabels), [showLabels]);
|
||||||
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:10, padding:10, font:{ size:11 } } } } };
|
const { chartOpts, barHorizOpts, barNoLegend } = useMemo(() => {
|
||||||
const barHorizOpts: any = { ...chartOpts, indexAxis:'y', plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
|
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:10, padding:10, font:{ size:11 } } } } };
|
||||||
const barNoLegend: any = { ...chartOpts, plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
|
const barHorizOpts: any = { ...chartOpts, indexAxis:'y', plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
|
||||||
|
const barNoLegend: any = { ...chartOpts, plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
|
||||||
|
return { chartOpts, barHorizOpts, barNoLegend };
|
||||||
|
}, [baseOpts]);
|
||||||
|
const trendOpts: any = useMemo(() => ({
|
||||||
|
...chartOpts,
|
||||||
|
interaction: { mode: 'nearest', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
...chartOpts.plugins,
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'right' as const,
|
||||||
|
labels: {
|
||||||
|
padding: 14,
|
||||||
|
font: { size: 11, weight: 'bold' as const },
|
||||||
|
usePointStyle: true,
|
||||||
|
generateLabels: (chart: any) =>
|
||||||
|
chart.data.datasets.map((ds: any, i: number) => {
|
||||||
|
const color: string = ds.borderColor || '#64748b';
|
||||||
|
const pill = document.createElement('canvas');
|
||||||
|
pill.width = 10; pill.height = 10;
|
||||||
|
const pCtx = pill.getContext('2d');
|
||||||
|
if (pCtx) {
|
||||||
|
pCtx.strokeStyle = color;
|
||||||
|
pCtx.lineWidth = 1;
|
||||||
|
pCtx.beginPath();
|
||||||
|
pCtx.arc(5, 5, 4, 0, Math.PI * 2);
|
||||||
|
pCtx.stroke();
|
||||||
|
}
|
||||||
|
return { text: ds.label, fillStyle: color, strokeStyle: color,
|
||||||
|
fontColor: color, lineWidth: 0, pointStyle: pill,
|
||||||
|
hidden: !chart.isDatasetVisible(i), datasetIndex: i };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...chartOpts.plugins.tooltip,
|
||||||
|
callbacks: {
|
||||||
|
title: (items: any[]) => trendResult.tooltipLabels[items[0]?.dataIndex] ?? items[0]?.label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), [chartOpts, trendResult.tooltipLabels]);
|
||||||
|
|
||||||
const pieOptions: any = useMemo(() => ({
|
const pieOptions: any = useMemo(() => ({
|
||||||
responsive: true, maintainAspectRatio: false,
|
responsive: true, maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -496,148 +264,45 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
const metricOpts = [{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors }, { value:'tickets', label:L.tickets }];
|
const metricOpts = [{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors }, { value:'tickets', label:L.tickets }];
|
||||||
const granOpts = [{ value:'day', label:L.daily }, { value:'week', label:L.weekly }, { value:'month', label:L.monthly }];
|
const granOpts = [{ value:'day', label:L.daily }, { value:'week', label:L.weekly }, { value:'month', label:L.monthly }];
|
||||||
const hasFilters = selDistricts.length>0 || selChannels.length>0 || selMuseums.length>0;
|
const hasFilters = selDistricts.length>0 || selChannels.length>0 || selMuseums.length>0;
|
||||||
|
const activeFilterCount = selDistricts.length + selChannels.length + selMuseums.length;
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="alt-page" dir={L.dir}>
|
<div
|
||||||
<style>{`
|
className="alt-page"
|
||||||
${L.fontImport}
|
dir={L.dir}
|
||||||
|
style={{
|
||||||
.alt-page { max-width:1100px; margin:0 auto; padding:48px 24px 80px; font-family:${L.bodyFont}; width:100%; box-sizing:border-box; }
|
'--alt-body-font': L.bodyFont,
|
||||||
|
'--alt-display-font': L.displayFont,
|
||||||
/* ── header ── */
|
'--alt-mono-font': L.monoFont,
|
||||||
.alt-back { display:inline-flex; align-items:center; gap:6px; font-size:.8125rem; color:var(--text-muted); text-decoration:none; margin-bottom:28px; transition:color .15s; }
|
} as React.CSSProperties}
|
||||||
.alt-back:hover { color:var(--accent); }
|
>
|
||||||
.alt-page-title { font-family:${L.displayFont}; font-size:2.25rem; font-weight:400; color:var(--text-primary); margin:0 0 6px; letter-spacing:-.03em; line-height:1.15; }
|
|
||||||
.alt-page-sub { font-size:.9375rem; color:var(--text-muted); margin:0 0 40px; font-weight:300; }
|
|
||||||
|
|
||||||
/* ── hero ── */
|
|
||||||
.dalt-hero { border:1px solid var(--border); border-radius:var(--radius); background:var(--surface); overflow:hidden; margin-bottom:24px; }
|
|
||||||
.dalt-hero-inner { display:flex; align-items:center; justify-content:space-between; padding:24px 28px; gap:16px; flex-wrap:wrap; }
|
|
||||||
.dalt-hero-name { font-family:${L.displayFont}; font-size:2.5rem; font-weight:400; color:var(--text-primary); line-height:1; letter-spacing:-.025em; margin-bottom:6px; }
|
|
||||||
.dalt-hero-range { font-family:${L.monoFont}; font-size:.875rem; color:var(--text-muted); letter-spacing:.01em; }
|
|
||||||
.dalt-hero-btn { display:inline-flex; align-items:center; gap:5px; font-family:${L.bodyFont}; font-size:.8125rem; font-weight:500; color:var(--text-muted); background:none; border:1px solid var(--border); border-radius:8px; padding:7px 12px; cursor:pointer; transition:color .15s,border-color .15s; white-space:nowrap; }
|
|
||||||
.dalt-hero-btn:hover { color:var(--accent); border-color:var(--accent); }
|
|
||||||
|
|
||||||
/* ── picker ── */
|
|
||||||
.alt-picker { border-top:1px solid var(--border); padding:16px 24px 20px; background:var(--bg); animation:altPickIn 180ms cubic-bezier(.16,1,.3,1); }
|
|
||||||
@keyframes altPickIn { from{opacity:0;transform:translateY(-8px)} to{opacity:1;transform:translateY(0)} }
|
|
||||||
.alt-picker-year { display:flex; align-items:center; gap:16px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--border); }
|
|
||||||
.alt-yr-val { font-family:${L.displayFont}; font-size:1.25rem; color:var(--text-primary); min-width:50px; text-align:center; }
|
|
||||||
.alt-yr-btn { width:28px; height:28px; display:flex; align-items:center; justify-content:center; border:1px solid var(--border); border-radius:7px; background:var(--surface); color:var(--text-secondary); cursor:pointer; transition:background .12s,border-color .12s,color .12s; }
|
|
||||||
.alt-yr-btn:hover:not(:disabled) { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
|
|
||||||
.alt-yr-btn:disabled { opacity:.3; cursor:not-allowed; }
|
|
||||||
.alt-picker-section { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:10px 0 6px; }
|
|
||||||
.alt-chips { display:flex; flex-wrap:wrap; gap:5px; margin-bottom:4px; }
|
|
||||||
.alt-chip { font-family:${L.bodyFont}; padding:4px 9px; border:1px solid var(--border); border-radius:6px; background:var(--surface); color:var(--text-secondary); font-size:.8rem; font-weight:500; cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
|
|
||||||
.alt-chip:hover { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
|
|
||||||
.alt-chip-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; font-weight:600!important; }
|
|
||||||
.alt-chip-wide { padding-left:14px; padding-right:14px; }
|
|
||||||
.alt-picker-div { height:1px; background:var(--border); margin:12px 0 10px; }
|
|
||||||
.alt-custom { display:flex; align-items:flex-end; gap:8px; }
|
|
||||||
.alt-custom-f { flex:1; display:flex; flex-direction:column; gap:4px; }
|
|
||||||
.alt-custom-f label { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--text-muted); }
|
|
||||||
.alt-custom-f input[type="date"] { padding:7px 9px; border:1px solid var(--border); border-radius:7px; font-size:.825rem; background:var(--surface); color:var(--text-primary); width:100%; }
|
|
||||||
.alt-custom-f input[type="date"]:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 2px rgba(37,99,235,.12); }
|
|
||||||
.alt-custom-arrow { font-size:.75rem; color:var(--text-muted); padding-bottom:9px; flex-shrink:0; }
|
|
||||||
.alt-footer { display:flex; justify-content:flex-end; gap:8px; }
|
|
||||||
.alt-cancel,.alt-apply { padding:7px 16px; border-radius:7px; font-size:.825rem; font-weight:600; cursor:pointer; font-family:${L.bodyFont}; transition:background .12s,color .12s; }
|
|
||||||
.alt-cancel { background:transparent; border:1px solid var(--border); color:var(--text-secondary); }
|
|
||||||
.alt-cancel:hover { background:var(--bg-secondary); }
|
|
||||||
.alt-apply { background:var(--accent); border:1px solid transparent; color:#fff; }
|
|
||||||
.alt-apply:hover { opacity:.88; }
|
|
||||||
|
|
||||||
/* ── multi-select ── */
|
|
||||||
.altms { position:relative; }
|
|
||||||
.altms-trigger { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border:1px solid var(--border); border-radius:8px; background:var(--surface); color:var(--text-secondary); font-family:${L.bodyFont}; font-size:.875rem; cursor:pointer; transition:border-color .15s,color .15s,background .15s; white-space:nowrap; }
|
|
||||||
.altms-trigger:hover { border-color:var(--accent); color:var(--accent); }
|
|
||||||
.altms-trigger--active { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
|
|
||||||
.altms-label { max-width:140px; overflow:hidden; text-overflow:ellipsis; }
|
|
||||||
.altms-chevron { transition:transform .18s; flex-shrink:0; color:currentColor; }
|
|
||||||
.altms-chevron--open { transform:rotate(180deg); }
|
|
||||||
.altms-dropdown { position:absolute; top:calc(100% + 6px); left:0; z-index:200; min-width:200px; background:var(--surface); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.12); overflow:hidden; animation:altPickIn 140ms cubic-bezier(.16,1,.3,1); }
|
|
||||||
[dir="rtl"] .altms-dropdown { left:auto; right:0; }
|
|
||||||
.altms-list { max-height:220px; overflow-y:auto; padding:6px; display:flex; flex-direction:column; gap:2px; }
|
|
||||||
.altms-option { display:flex; align-items:center; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; transition:background .1s; }
|
|
||||||
.altms-option:hover { background:var(--bg); }
|
|
||||||
.altms-option--checked { background:var(--accent-light); }
|
|
||||||
.altms-check { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
|
|
||||||
.altms-check-box { width:16px; height:16px; border:1.5px solid var(--border); border-radius:4px; display:flex; align-items:center; justify-content:center; flex-shrink:0; transition:background .1s,border-color .1s; }
|
|
||||||
.altms-option--checked .altms-check-box { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
|
|
||||||
.altms-opt-label { font-family:${L.bodyFont}; font-size:.875rem; color:var(--text-primary); }
|
|
||||||
.altms-clear { width:100%; padding:8px 14px; border-top:1px solid var(--border); background:none; border-left:none; border-right:none; border-bottom:none; font-family:${L.bodyFont}; font-size:.8125rem; color:var(--danger); cursor:pointer; text-align:start; transition:background .1s; }
|
|
||||||
.altms-clear:hover { background:var(--danger-light); }
|
|
||||||
|
|
||||||
/* ── filter bar ── */
|
|
||||||
.alt-filter-bar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:32px; padding:14px 20px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); }
|
|
||||||
.alt-filter-label { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); white-space:nowrap; }
|
|
||||||
.alt-filter-sep { width:1px; height:20px; background:var(--border); flex-shrink:0; }
|
|
||||||
.alt-vat-toggle { margin-inline-start:auto; display:flex; align-items:center; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
|
|
||||||
.alt-vat-opt { font-family:${L.bodyFont}; font-size:.75rem; font-weight:500; padding:5px 10px; background:var(--surface); color:var(--text-muted); cursor:pointer; border:none; transition:background .1s,color .1s; }
|
|
||||||
.alt-vat-opt--on { background:var(--accent); color:var(--text-inverse); }
|
|
||||||
.alt-filter-reset { font-size:.8125rem; color:var(--text-muted); background:none; border:none; cursor:pointer; padding:4px 6px; transition:color .15s; font-family:${L.bodyFont}; }
|
|
||||||
.alt-filter-reset:hover { color:var(--danger); }
|
|
||||||
|
|
||||||
/* ── metrics ── */
|
|
||||||
.alt-metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:1px; background:var(--border); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; margin-bottom:40px; }
|
|
||||||
.alt-metric { background:var(--surface); padding:24px 22px; }
|
|
||||||
.alt-metric-title { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:0 0 12px; }
|
|
||||||
.alt-metric-value { font-family:${L.displayFont}; font-size:1.875rem; font-weight:400; color:var(--text-primary); line-height:1; margin-bottom:10px; letter-spacing:-.02em; }
|
|
||||||
.alt-metric-footer { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
||||||
.alt-change { font-size:.75rem; font-weight:600; padding:2px 8px; border-radius:20px; white-space:nowrap; font-family:${L.bodyFont}; }
|
|
||||||
.alt-change--up { background:var(--success-light); color:var(--success); }
|
|
||||||
.alt-change--down { background:var(--danger-light); color:var(--danger); }
|
|
||||||
.alt-change--flat { background:var(--muted-light); color:var(--text-muted); }
|
|
||||||
.alt-metric-prev { font-size:.75rem; color:var(--text-muted); font-family:${L.monoFont}; }
|
|
||||||
|
|
||||||
/* ── section heading ── */
|
|
||||||
.alt-section-heading { display:flex; align-items:center; gap:12px; margin:0 0 20px; }
|
|
||||||
.alt-section-heading h2 { font-family:${L.displayFont}; font-size:1.375rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; }
|
|
||||||
.alt-section-heading::after { content:''; flex:1; height:1px; background:var(--border); }
|
|
||||||
|
|
||||||
/* ── charts ── */
|
|
||||||
.dalt-charts-grid { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
|
|
||||||
.dalt-chart-full { grid-column:1/-1; }
|
|
||||||
.alt-chart-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:24px 24px 20px; min-width:0; overflow:hidden; }
|
|
||||||
.alt-chart-header { display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:20px; gap:12px; flex-wrap:wrap; }
|
|
||||||
.alt-chart-title { font-family:${L.displayFont}; font-size:1.25rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; font-style:italic; }
|
|
||||||
.alt-chart-controls { display:flex; gap:5px; flex-wrap:wrap; }
|
|
||||||
.alt-ctrl { font-family:${L.bodyFont}; font-size:.75rem; font-weight:500; padding:4px 10px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text-secondary); cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
|
|
||||||
.alt-ctrl:hover { border-color:var(--accent); color:var(--accent); }
|
|
||||||
.alt-ctrl-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; }
|
|
||||||
.alt-ctrl-sep { width:1px; height:20px; background:var(--border); align-self:center; }
|
|
||||||
.alt-chart-wrap { position:relative; height:260px; overflow:hidden; direction:ltr; width:100%; }
|
|
||||||
.alt-chart-wrap--tall { height:320px; }
|
|
||||||
|
|
||||||
/* ── responsive ── */
|
|
||||||
@media (max-width:700px) {
|
|
||||||
.dalt-hero-name { font-size:1.875rem; }
|
|
||||||
.dalt-charts-grid { grid-template-columns:1fr; }
|
|
||||||
.dalt-chart-full { grid-column:auto; }
|
|
||||||
.alt-metrics { grid-template-columns:1fr 1fr; }
|
|
||||||
.alt-page-title { font-size:1.75rem; }
|
|
||||||
.altms-label { max-width:100px; }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
<h1 className="alt-page-title">{L.pageTitle}</h1>
|
<h1 className="alt-page-title">{L.pageTitle}</h1>
|
||||||
<p className="alt-page-sub">{L.pageSub}</p>
|
<p className="alt-page-sub">{L.pageSub}</p>
|
||||||
|
|
||||||
<PeriodHero start={start} end={end} onChange={(s,e) => { setStart(s); setEnd(e); }} availableYears={availableYears} L={L} />
|
<PeriodHero start={start} end={end} onChange={(s,e) => { setStart(s); setEnd(e); }} availableYears={availableYears} L={L} />
|
||||||
|
|
||||||
<div className="alt-filter-bar">
|
<div className={`alt-filter-bar${filtersOpen ? ' alt-filter-bar--open' : ''}`}>
|
||||||
<span className="alt-filter-label">{L.filter}</span>
|
<div className="alt-filter-head">
|
||||||
<div className="alt-filter-sep" />
|
<span className="alt-filter-label">{L.filter}</span>
|
||||||
<AltMultiSelect value={selDistricts} options={allDistricts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
|
{activeFilterCount > 0 && <span className="alt-filter-badge">{activeFilterCount}</span>}
|
||||||
<AltMultiSelect value={selChannels} options={allChannels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
|
<button type="button" className="alt-filter-toggle" onClick={() => setFiltersOpen(v => !v)} aria-expanded={filtersOpen} aria-label="Toggle filters">
|
||||||
<AltMultiSelect value={selMuseums} options={allMuseums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} />
|
<svg className={`alt-filter-chevron${filtersOpen ? ' alt-filter-chevron--open' : ''}`} width="14" height="14" viewBox="0 0 10 10" fill="none">
|
||||||
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
|
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
<div className="alt-vat-toggle">
|
</svg>
|
||||||
<button type="button" className={`alt-vat-opt${!includeVAT?' alt-vat-opt--on':''}`} onClick={() => setIncludeVAT(false)}>{L.exclVAT}</button>
|
</button>
|
||||||
<button type="button" className={`alt-vat-opt${includeVAT ?' alt-vat-opt--on':''}`} onClick={() => setIncludeVAT(true)}>{L.inclVAT}</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="alt-vat-toggle">
|
<div className="alt-filter-body">
|
||||||
<button type="button" className={`alt-vat-opt${activeLang==='en'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('en')}>EN</button>
|
<div className="alt-filter-sep" />
|
||||||
<button type="button" className={`alt-vat-opt${activeLang==='ar'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('ar')}>AR</button>
|
<AltMultiSelect value={selDistricts} options={allDistricts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
|
||||||
|
<AltMultiSelect value={selChannels} options={allChannels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
|
||||||
|
<AltMultiSelect value={selMuseums} options={allMuseums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} />
|
||||||
|
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
|
||||||
|
<div className="alt-filter-spacer" />
|
||||||
|
<div className="alt-vat-toggle">
|
||||||
|
<button type="button" className={`alt-vat-opt${!includeVAT?' alt-vat-opt--on':''}`} onClick={() => setIncludeVAT(false)}>{L.exclVAT}</button>
|
||||||
|
<button type="button" className={`alt-vat-opt${includeVAT ?' alt-vat-opt--on':''}`} onClick={() => setIncludeVAT(true)}>{L.inclVAT}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -660,25 +325,27 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
<div className="alt-chart-header">
|
<div className="alt-chart-header">
|
||||||
<h3 className="alt-chart-title">{L.trendTitle}</h3>
|
<h3 className="alt-chart-title">{L.trendTitle}</h3>
|
||||||
<div className="alt-chart-controls">
|
<div className="alt-chart-controls">
|
||||||
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||||
<div className="alt-ctrl-sep" />
|
<div className="alt-ctrl-sep" />
|
||||||
{granOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
||||||
|
<div className="alt-ctrl-sep" />
|
||||||
|
<button type="button" aria-pressed={showLabels} className={`alt-ctrl${showLabels?' alt-ctrl-on':''}`} onClick={() => setShowLabels(v => !v)}>{'Labels'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={chartOpts} /></div>
|
<div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={trendOpts} /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="alt-chart-card">
|
<div className="alt-chart-card">
|
||||||
<div className="alt-chart-header">
|
<div className="alt-chart-header">
|
||||||
<h3 className="alt-chart-title">{L.museumTitle}</h3>
|
<h3 className="alt-chart-title">{L.museumTitle}</h3>
|
||||||
<div className="alt-chart-controls">
|
<div className="alt-chart-controls">
|
||||||
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||||
<div className="alt-ctrl-sep" />
|
<div className="alt-ctrl-sep" />
|
||||||
<button type="button" className={`alt-ctrl${museumChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('bar')}>{L.barLabel}</button>
|
<button type="button" aria-pressed={museumChartType==='bar'} className={`alt-ctrl${museumChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('bar')}>{L.barLabel}</button>
|
||||||
<button type="button" className={`alt-ctrl${museumChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('pie')}>{L.pieLabel}</button>
|
<button type="button" aria-pressed={museumChartType==='pie'} className={`alt-ctrl${museumChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('pie')}>{L.pieLabel}</button>
|
||||||
<div className="alt-ctrl-sep" />
|
<div className="alt-ctrl-sep" />
|
||||||
<button type="button" className={`alt-ctrl${museumDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('absolute')}>{L.absLabel}</button>
|
<button type="button" aria-pressed={museumDisplayMode==='absolute'} className={`alt-ctrl${museumDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('absolute')}>{L.absLabel}</button>
|
||||||
<button type="button" className={`alt-ctrl${museumDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('percent')}>{L.pctLabel}</button>
|
<button type="button" aria-pressed={museumDisplayMode==='percent'} className={`alt-ctrl${museumDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('percent')}>{L.pctLabel}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="alt-chart-wrap alt-chart-wrap--tall">
|
<div className="alt-chart-wrap alt-chart-wrap--tall">
|
||||||
@@ -690,13 +357,13 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
<div className="alt-chart-header">
|
<div className="alt-chart-header">
|
||||||
<h3 className="alt-chart-title">{L.channelTitle}</h3>
|
<h3 className="alt-chart-title">{L.channelTitle}</h3>
|
||||||
<div className="alt-chart-controls">
|
<div className="alt-chart-controls">
|
||||||
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||||
<div className="alt-ctrl-sep" />
|
<div className="alt-ctrl-sep" />
|
||||||
<button type="button" className={`alt-ctrl${channelChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('bar')}>{L.barLabel}</button>
|
<button type="button" aria-pressed={channelChartType==='bar'} className={`alt-ctrl${channelChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('bar')}>{L.barLabel}</button>
|
||||||
<button type="button" className={`alt-ctrl${channelChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('pie')}>{L.pieLabel}</button>
|
<button type="button" aria-pressed={channelChartType==='pie'} className={`alt-ctrl${channelChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('pie')}>{L.pieLabel}</button>
|
||||||
<div className="alt-ctrl-sep" />
|
<div className="alt-ctrl-sep" />
|
||||||
<button type="button" className={`alt-ctrl${channelDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('absolute')}>{L.absLabel}</button>
|
<button type="button" aria-pressed={channelDisplayMode==='absolute'} className={`alt-ctrl${channelDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('absolute')}>{L.absLabel}</button>
|
||||||
<button type="button" className={`alt-ctrl${channelDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('percent')}>{L.pctLabel}</button>
|
<button type="button" aria-pressed={channelDisplayMode==='percent'} className={`alt-ctrl${channelDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('percent')}>{L.pctLabel}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="alt-chart-wrap">
|
<div className="alt-chart-wrap">
|
||||||
@@ -708,13 +375,13 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
<div className="alt-chart-header">
|
<div className="alt-chart-header">
|
||||||
<h3 className="alt-chart-title">{L.districtTitle}</h3>
|
<h3 className="alt-chart-title">{L.districtTitle}</h3>
|
||||||
<div className="alt-chart-controls">
|
<div className="alt-chart-controls">
|
||||||
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||||
<div className="alt-ctrl-sep" />
|
<div className="alt-ctrl-sep" />
|
||||||
<button type="button" className={`alt-ctrl${districtChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('bar')}>{L.barLabel}</button>
|
<button type="button" aria-pressed={districtChartType==='bar'} className={`alt-ctrl${districtChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('bar')}>{L.barLabel}</button>
|
||||||
<button type="button" className={`alt-ctrl${districtChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('pie')}>{L.pieLabel}</button>
|
<button type="button" aria-pressed={districtChartType==='pie'} className={`alt-ctrl${districtChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('pie')}>{L.pieLabel}</button>
|
||||||
<div className="alt-ctrl-sep" />
|
<div className="alt-ctrl-sep" />
|
||||||
<button type="button" className={`alt-ctrl${districtDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('absolute')}>{L.absLabel}</button>
|
<button type="button" aria-pressed={districtDisplayMode==='absolute'} className={`alt-ctrl${districtDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('absolute')}>{L.absLabel}</button>
|
||||||
<button type="button" className={`alt-ctrl${districtDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('percent')}>{L.pctLabel}</button>
|
<button type="button" aria-pressed={districtDisplayMode==='percent'} className={`alt-ctrl${districtDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('percent')}>{L.pctLabel}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="alt-chart-wrap">
|
<div className="alt-chart-wrap">
|
||||||
|
|||||||
@@ -0,0 +1,633 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Document, Page, View, Text, Image, StyleSheet, Font
|
||||||
|
} from '@react-pdf/renderer';
|
||||||
|
import { PdfTrendChart, PdfHBarChart, CHART_PALETTE } from './reportCharts';
|
||||||
|
import {
|
||||||
|
ReportData, MuseumDataRow, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary
|
||||||
|
} from './reportHelpers';
|
||||||
|
|
||||||
|
Font.register({
|
||||||
|
family: 'IBMPlexArabic',
|
||||||
|
fonts: [
|
||||||
|
{ src: '/fonts/IBMPlexSansArabic-Regular.woff2', fontWeight: 400 },
|
||||||
|
{ src: '/fonts/IBMPlexSansArabic-Bold.woff2', fontWeight: 700 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const TOTAL_LINE_COLOR = '#1e293b';
|
||||||
|
|
||||||
|
// A4 content width minus chart-wrap padding (14×2)
|
||||||
|
// Portrait: 595 - 44 - 44 - 28 = 479
|
||||||
|
// Landscape: 842 - 44 - 44 - 28 = 726
|
||||||
|
const CHART_W = { portrait: 479, landscape: 726 } as const;
|
||||||
|
|
||||||
|
const S = StyleSheet.create({
|
||||||
|
page: { fontFamily: 'Helvetica', fontSize: 10, color: '#0f172a', backgroundColor: '#ffffff' },
|
||||||
|
|
||||||
|
// ── Cover ──────────────────────────────────────────────
|
||||||
|
coverPage: { flexDirection: 'column', padding: 0 },
|
||||||
|
coverHeader: { paddingTop: 56, paddingRight: 52, paddingBottom: 52, paddingLeft: 52 },
|
||||||
|
coverHeaderTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 48 },
|
||||||
|
coverBrand: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', letterSpacing: 0.8 },
|
||||||
|
coverLogoBox: { width: 90, height: 44, justifyContent: 'flex-end', alignItems: 'flex-end' },
|
||||||
|
coverClientLogo: { width: 90, height: 44, objectFit: 'contain' as const },
|
||||||
|
coverTitle: { fontSize: 36, fontFamily: 'Helvetica-Bold', color: '#ffffff', lineHeight: 1.2 },
|
||||||
|
coverBody: { flex: 1, paddingTop: 44, paddingRight: 52, paddingBottom: 44, paddingLeft: 52, flexDirection: 'column' },
|
||||||
|
coverClientName: { fontSize: 15, color: '#0f172a', fontFamily: 'Helvetica-Bold', marginBottom: 5 },
|
||||||
|
coverContactName: { fontSize: 11, color: '#64748b', marginBottom: 32 },
|
||||||
|
coverBodySpacer: { flex: 1 },
|
||||||
|
coverPeriodRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 5 },
|
||||||
|
coverPeriodDot: { width: 6, height: 6, borderRadius: 3, marginRight: 8 },
|
||||||
|
coverPeriod: { fontSize: 12, color: '#334155', fontFamily: 'Helvetica-Oblique' },
|
||||||
|
coverDate: { fontSize: 9, color: '#94a3b8', marginBottom: 20 },
|
||||||
|
coverConfidential: { fontSize: 7.5, color: '#94a3b8', letterSpacing: 2, paddingTop: 10, borderTopWidth: 1, borderTopColor: '#e2e8f0' },
|
||||||
|
|
||||||
|
// ── Content pages ──────────────────────────────────────
|
||||||
|
contentPage: { paddingTop: 34, paddingRight: 44, paddingBottom: 54, paddingLeft: 44 },
|
||||||
|
pageHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderBottomWidth: 1.5, borderBottomColor: '#e2e8f0', paddingBottom: 10, marginBottom: 26 },
|
||||||
|
pageHeaderLogo: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: '#2563eb' },
|
||||||
|
pageHeaderTitle: { fontSize: 9, color: '#94a3b8' },
|
||||||
|
pageHeaderNum: { fontSize: 9, color: '#94a3b8' },
|
||||||
|
pageFooter: { position: 'absolute', bottom: 22, left: 44, right: 44, flexDirection: 'row', justifyContent: 'space-between', borderTopWidth: 1, borderTopColor: '#f1f5f9', paddingTop: 6 },
|
||||||
|
pageFooterText: { fontSize: 7.5, color: '#b0bec5' },
|
||||||
|
|
||||||
|
// ── Section headings ───────────────────────────────────
|
||||||
|
sectionHeading: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', paddingTop: 8, paddingRight: 14, paddingBottom: 8, paddingLeft: 14, marginBottom: 16, borderRadius: 4 },
|
||||||
|
sectionGap: { marginBottom: 28 },
|
||||||
|
|
||||||
|
// ── Executive summary ──────────────────────────────────
|
||||||
|
summaryText: { fontSize: 10.5, color: '#334155', lineHeight: 1.7 },
|
||||||
|
|
||||||
|
// ── Key metrics table ──────────────────────────────────
|
||||||
|
metricsTable: { marginBottom: 8 },
|
||||||
|
metricsHeaderRow: { flexDirection: 'row', backgroundColor: '#f1f5f9', paddingTop: 5, paddingBottom: 5, marginBottom: 2, borderRadius: 3 },
|
||||||
|
metricsHeaderLabel: { flex: 1.8, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', paddingLeft: 8 },
|
||||||
|
metricsHeaderCell: { flex: 1, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right', paddingRight: 6 },
|
||||||
|
metricsRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 7 },
|
||||||
|
metricsRowAlt: { backgroundColor: '#fafbfd' },
|
||||||
|
metricsLabel: { flex: 1.8, fontSize: 10, color: '#334155', fontFamily: 'Helvetica-Bold', paddingLeft: 8 },
|
||||||
|
metricsValue: { flex: 1, fontSize: 10, color: '#0f172a', textAlign: 'right', paddingRight: 6 },
|
||||||
|
metricsChange: { flex: 0.8, fontSize: 9, textAlign: 'right', paddingRight: 6 },
|
||||||
|
metricsChangeUp: { color: '#059669' },
|
||||||
|
metricsChangeDown: { color: '#dc2626' },
|
||||||
|
|
||||||
|
// ── Trend chart ────────────────────────────────────────
|
||||||
|
chartWrap: { marginBottom: 8, backgroundColor: '#f8fafc', paddingTop: 14, paddingRight: 14, paddingBottom: 14, paddingLeft: 14, borderRadius: 6, borderWidth: 1, borderColor: '#f1f5f9' },
|
||||||
|
legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 10 },
|
||||||
|
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18, marginBottom: 4 },
|
||||||
|
legendDot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
|
legendLabel: { fontSize: 8, color: '#64748b', marginLeft: 5 },
|
||||||
|
|
||||||
|
// ── Museum mini-reports ────────────────────────────────
|
||||||
|
museumBlock: { marginBottom: 20, borderLeftWidth: 3, paddingLeft: 12 },
|
||||||
|
museumBlockName: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#0f172a', marginBottom: 4 },
|
||||||
|
museumIntroText: { fontSize: 9.5, color: '#64748b', fontFamily: 'Helvetica-Oblique', marginBottom: 10 },
|
||||||
|
miniTable: { marginBottom: 4 },
|
||||||
|
miniHeaderRow: { flexDirection: 'row', backgroundColor: '#f1f5f9', paddingTop: 4, paddingBottom: 4, marginBottom: 1, borderRadius: 2 },
|
||||||
|
miniHeaderLabel: { flex: 2, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', paddingLeft: 6 },
|
||||||
|
miniHeaderCell: { flex: 2, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right', paddingRight: 6 },
|
||||||
|
miniHeaderChangeCell: { flex: 1, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right', paddingRight: 6 },
|
||||||
|
miniRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 5 },
|
||||||
|
miniRowAlt: { backgroundColor: '#fafafa' },
|
||||||
|
miniLabel: { flex: 2, fontSize: 9.5, color: '#334155', fontFamily: 'Helvetica-Bold', paddingLeft: 6 },
|
||||||
|
miniValue: { flex: 2, fontSize: 9.5, color: '#0f172a', textAlign: 'right', paddingRight: 6 },
|
||||||
|
miniChange: { flex: 1, fontSize: 9, textAlign: 'right', paddingRight: 6 },
|
||||||
|
miniChangeUp: { color: '#059669' },
|
||||||
|
miniChangeDown: { color: '#dc2626' },
|
||||||
|
|
||||||
|
// ── Global summary table ───────────────────────────────
|
||||||
|
summarySubLabel: { fontSize: 9, color: '#64748b', marginBottom: 14 },
|
||||||
|
summaryHeaderRow: { flexDirection: 'row', backgroundColor: '#0f172a', paddingTop: 8, paddingBottom: 8, borderRadius: 4 },
|
||||||
|
summaryHeaderMuseum: { flex: 3, fontSize: 8.5, fontFamily: 'Helvetica-Bold', color: '#ffffff', paddingLeft: 10 },
|
||||||
|
summaryHeaderMetric: { flex: 2, fontSize: 8.5, fontFamily: 'Helvetica-Bold', color: '#ffffff', textAlign: 'right', paddingRight: 6 },
|
||||||
|
summaryHeaderDelta: { flex: 1, fontSize: 8.5, fontFamily: 'Helvetica-Bold', color: '#ffffff', textAlign: 'right', paddingRight: 6 },
|
||||||
|
summaryRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 6 },
|
||||||
|
summaryRowAlt: { backgroundColor: '#f8fafc' },
|
||||||
|
summaryTotalRow: { flexDirection: 'row', borderTopWidth: 2, borderTopColor: '#0f172a', paddingTop: 8, paddingBottom: 5, marginTop: 2 },
|
||||||
|
summaryMuseum: { flex: 3, fontSize: 9.5, color: '#0f172a', paddingLeft: 10 },
|
||||||
|
summaryMuseumTotal: { flex: 3, fontSize: 9.5, fontFamily: 'Helvetica-Bold', color: '#0f172a', paddingLeft: 10 },
|
||||||
|
summaryMetric: { flex: 2, fontSize: 9.5, color: '#0f172a', textAlign: 'right', paddingRight: 6 },
|
||||||
|
summaryMetricTotal: { flex: 2, fontSize: 9.5, fontFamily: 'Helvetica-Bold', color: '#0f172a', textAlign: 'right', paddingRight: 6 },
|
||||||
|
summaryDelta: { flex: 1, fontSize: 9, textAlign: 'right', paddingRight: 6 },
|
||||||
|
summaryDeltaUp: { color: '#059669' },
|
||||||
|
summaryDeltaDown: { color: '#dc2626' },
|
||||||
|
summaryDeltaTotal: { flex: 1, fontSize: 9, fontFamily: 'Helvetica-Bold', textAlign: 'right', paddingRight: 6 },
|
||||||
|
});
|
||||||
|
|
||||||
|
function pctChange(curr: number, prev: number): number {
|
||||||
|
if (prev === 0) return 0;
|
||||||
|
return Math.round(((curr - prev) / prev) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function museumIntro(row: MuseumDataRow, lang: 'en' | 'ar', compLabel: string): string {
|
||||||
|
if (!row.prev) return '';
|
||||||
|
const revChg = pctChange(row.curr.revenue, row.prev.revenue);
|
||||||
|
const visChg = pctChange(row.curr.visitors, row.prev.visitors);
|
||||||
|
if (lang === 'en') {
|
||||||
|
return `Revenue ${revChg >= 0 ? 'up' : 'down'} ${Math.abs(revChg)}%, visitors ${visChg >= 0 ? 'up' : 'down'} ${Math.abs(visChg)}% vs ${compLabel}.`;
|
||||||
|
}
|
||||||
|
return `الإيرادات ${revChg >= 0 ? 'ارتفعت' : 'انخفضت'} ${Math.abs(revChg)}%، الزوار ${visChg >= 0 ? 'ارتفعوا' : 'انخفضوا'} ${Math.abs(visChg)}% مقارنةً بـ${compLabel}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageHeaderProps { title: string; page: number; isAr: boolean; arB: any; }
|
||||||
|
function PageHeader({ title, page, isAr, arB }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<View style={S.pageHeader}>
|
||||||
|
<Text style={[S.pageHeaderLogo, arB]}>HiHala Data</Text>
|
||||||
|
<Text style={[S.pageHeaderTitle, isAr ? { fontFamily: 'IBMPlexArabic' } : {}]}>{title}</Text>
|
||||||
|
<Text style={S.pageHeaderNum}>{page}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageFooterProps { confidentiality: string; generatedAt: string; }
|
||||||
|
function PageFooter({ confidentiality, generatedAt }: PageFooterProps) {
|
||||||
|
return (
|
||||||
|
<View style={S.pageFooter}>
|
||||||
|
<Text style={S.pageFooterText}>{confidentiality}</Text>
|
||||||
|
<Text style={S.pageFooterText}>Generated {generatedAt}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionProps { title: string; color: string; arB: any; }
|
||||||
|
function SectionHeading({ title, color, arB }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<View style={[S.sectionHeading, { backgroundColor: color }]}>
|
||||||
|
<Text style={arB}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props { data: ReportData; }
|
||||||
|
|
||||||
|
export function ReportDocument({ data }: Props) {
|
||||||
|
const { config: cfg, metrics, prevMetrics, comparisonPeriodLabel,
|
||||||
|
trendCharts,
|
||||||
|
museumData, channelBreakdown, districtBreakdown,
|
||||||
|
pilgrimCapture, generatedAt } = data;
|
||||||
|
|
||||||
|
const lang = cfg.language;
|
||||||
|
const isAr = lang === 'ar';
|
||||||
|
const color = cfg.accentColor;
|
||||||
|
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
|
||||||
|
const isLandscape = cfg.orientation === 'landscape';
|
||||||
|
const orientation = isLandscape ? 'landscape' : 'portrait';
|
||||||
|
const T = lang === 'en' ? LABELS_EN : LABELS_AR;
|
||||||
|
|
||||||
|
// Arabic font overrides — Helvetica has no Arabic glyphs; cast as any so style arrays stay compatible
|
||||||
|
const arN: any = isAr ? { fontFamily: 'IBMPlexArabic', fontWeight: 400 } : {};
|
||||||
|
const arB: any = isAr ? { fontFamily: 'IBMPlexArabic', fontWeight: 700 } : {};
|
||||||
|
// direction: 'rtl' flips flex-row children right-to-left; fontFamily cascades to elements without an explicit one
|
||||||
|
const arPageExtra: any = isAr ? { direction: 'rtl', fontFamily: 'IBMPlexArabic' } : {};
|
||||||
|
|
||||||
|
const chartW = isLandscape ? CHART_W.landscape : CHART_W.portrait;
|
||||||
|
|
||||||
|
const avgTicketPrice = metrics.tickets > 0 ? metrics.revenue / metrics.tickets : 0;
|
||||||
|
const prevAvgTicketPrice = prevMetrics && prevMetrics.tickets > 0
|
||||||
|
? prevMetrics.revenue / prevMetrics.tickets : null;
|
||||||
|
|
||||||
|
const metricsRows = [
|
||||||
|
{ label: T.revenue, curr: formatCurrency(metrics.revenue, cfg.includeVAT),
|
||||||
|
prev: prevMetrics ? formatCurrency(prevMetrics.revenue, cfg.includeVAT) : null,
|
||||||
|
chg: prevMetrics ? pctChange(metrics.revenue, prevMetrics.revenue) : null },
|
||||||
|
{ label: T.visitors, curr: metrics.visitors.toLocaleString(),
|
||||||
|
prev: prevMetrics ? prevMetrics.visitors.toLocaleString() : null,
|
||||||
|
chg: prevMetrics ? pctChange(metrics.visitors, prevMetrics.visitors) : null },
|
||||||
|
{ label: T.tickets, curr: metrics.tickets.toLocaleString(),
|
||||||
|
prev: prevMetrics ? prevMetrics.tickets.toLocaleString() : null,
|
||||||
|
chg: prevMetrics ? pctChange(metrics.tickets, prevMetrics.tickets) : null },
|
||||||
|
{ label: T.avgRev, curr: formatCurrency(metrics.avgRevPerVisitor, false),
|
||||||
|
prev: prevMetrics ? formatCurrency(prevMetrics.avgRevPerVisitor, false) : null,
|
||||||
|
chg: prevMetrics ? pctChange(metrics.avgRevPerVisitor, prevMetrics.avgRevPerVisitor) : null },
|
||||||
|
{ label: T.avgTicketPrice, curr: formatCurrency(avgTicketPrice, false),
|
||||||
|
prev: prevAvgTicketPrice !== null ? formatCurrency(prevAvgTicketPrice, false) : null,
|
||||||
|
chg: prevAvgTicketPrice !== null ? pctChange(avgTicketPrice, prevAvgTicketPrice) : null },
|
||||||
|
...(cfg.showPilgrimCapture && pilgrimCapture ? [{
|
||||||
|
label: T.capture, curr: `${pilgrimCapture.current}%`,
|
||||||
|
prev: pilgrimCapture.previous !== null ? `${pilgrimCapture.previous}%` : null,
|
||||||
|
chg: pilgrimCapture.previous !== null ? pctChange(pilgrimCapture.current, pilgrimCapture.previous) : null,
|
||||||
|
}] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const showMuseumPage = cfg.showMuseumRevenue || cfg.showMuseumVisitors || cfg.showMuseumTickets;
|
||||||
|
const showChannelPage = cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets;
|
||||||
|
const showDistrictPage = cfg.showDistrictRevenue || cfg.showDistrictVisitors || cfg.showDistrictTickets;
|
||||||
|
const showSummaryPage = cfg.showGlobalSummary && cfg.includeComparison;
|
||||||
|
|
||||||
|
let pg = 1;
|
||||||
|
const mainPg = ++pg;
|
||||||
|
const museumPg = showMuseumPage ? ++pg : 0;
|
||||||
|
const channelPg = showChannelPage ? ++pg : 0;
|
||||||
|
const districtPg = showDistrictPage ? ++pg : 0;
|
||||||
|
const summaryPg = showSummaryPage ? ++pg : 0;
|
||||||
|
|
||||||
|
const museumMetricRows = (row: MuseumDataRow) => {
|
||||||
|
const rows = [];
|
||||||
|
if (cfg.showMuseumRevenue) rows.push({
|
||||||
|
label: T.revenue,
|
||||||
|
curr: formatCurrency(row.curr.revenue, cfg.includeVAT),
|
||||||
|
prev: row.prev ? formatCurrency(row.prev.revenue, cfg.includeVAT) : null,
|
||||||
|
chg: row.prev ? pctChange(row.curr.revenue, row.prev.revenue) : null,
|
||||||
|
});
|
||||||
|
if (cfg.showMuseumVisitors) rows.push({
|
||||||
|
label: T.visitors,
|
||||||
|
curr: row.curr.visitors.toLocaleString(),
|
||||||
|
prev: row.prev ? row.prev.visitors.toLocaleString() : null,
|
||||||
|
chg: row.prev ? pctChange(row.curr.visitors, row.prev.visitors) : null,
|
||||||
|
});
|
||||||
|
if (cfg.showMuseumTickets) rows.push({
|
||||||
|
label: T.tickets,
|
||||||
|
curr: row.curr.tickets.toLocaleString(),
|
||||||
|
prev: row.prev ? row.prev.tickets.toLocaleString() : null,
|
||||||
|
chg: row.prev ? pctChange(row.curr.tickets, row.prev.tickets) : null,
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Document title={cfg.title || 'HiHala Report'} author="HiHala Data">
|
||||||
|
|
||||||
|
{/* ── Cover ─────────────────────────────────────────── */}
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage, arPageExtra]}>
|
||||||
|
<View style={[S.coverHeader, { backgroundColor: color }]}>
|
||||||
|
<View style={S.coverHeaderTop}>
|
||||||
|
<Text style={[S.coverBrand, arB]}>HiHala Data</Text>
|
||||||
|
{cfg.clientLogoBase64 && (
|
||||||
|
<View style={S.coverLogoBox}>
|
||||||
|
<Image src={cfg.clientLogoBase64} style={S.coverClientLogo} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={[S.coverTitle, arB]}>{cfg.title || T.defaultTitle}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={S.coverBody}>
|
||||||
|
{cfg.clientName && (
|
||||||
|
<Text style={[S.coverClientName, arB]}>{T.preparedFor}: {cfg.clientName}</Text>
|
||||||
|
)}
|
||||||
|
{cfg.contactName && (
|
||||||
|
<Text style={[S.coverContactName, arN]}>{T.attention}: {cfg.contactName}</Text>
|
||||||
|
)}
|
||||||
|
<View style={S.coverBodySpacer} />
|
||||||
|
<View style={S.coverPeriodRow}>
|
||||||
|
<View style={[S.coverPeriodDot, { backgroundColor: color }]} />
|
||||||
|
<Text style={[S.coverPeriod, arN]}>{period}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={S.coverDate}>{T.generated}: {generatedAt}</Text>
|
||||||
|
{cfg.confidentiality !== 'Public' && (
|
||||||
|
<Text style={S.coverConfidential}>{cfg.confidentiality.toUpperCase()}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
|
||||||
|
{/* ── Summary + Metrics + Trend ──────────────────────── */}
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={mainPg} isAr={isAr} arB={arB} />
|
||||||
|
|
||||||
|
{cfg.showExecutiveSummary && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.execSummary} color={color} arB={arB} />
|
||||||
|
<Text style={[S.summaryText, arN]}>{generateExecutiveSummary(data)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cfg.showMetricsTable && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={`${T.keyMetrics} — ${cfg.includeVAT ? T.inclVAT : T.exclVAT}`} color={color} arB={arB} />
|
||||||
|
<View style={S.metricsTable}>
|
||||||
|
<View style={S.metricsHeaderRow}>
|
||||||
|
<Text style={[S.metricsHeaderLabel, arB]}> </Text>
|
||||||
|
<Text style={[S.metricsHeaderCell, arB]}>{period}</Text>
|
||||||
|
{prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
|
||||||
|
{prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{T.change}</Text>}
|
||||||
|
</View>
|
||||||
|
{metricsRows.map((row, i) => (
|
||||||
|
<View key={row.label} style={[S.metricsRow, i % 2 === 1 ? S.metricsRowAlt : {}]}>
|
||||||
|
<Text style={[S.metricsLabel, arB]}>{row.label}</Text>
|
||||||
|
<Text style={[S.metricsValue, arN]}>{row.curr}</Text>
|
||||||
|
{prevMetrics && <Text style={[S.metricsValue, arN]}>{row.prev ?? '—'}</Text>}
|
||||||
|
{prevMetrics && row.chg !== null && (
|
||||||
|
<Text style={[S.metricsChange, row.chg >= 0 ? S.metricsChangeUp : S.metricsChangeDown]}>
|
||||||
|
{formatPct(row.chg)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cfg.showTrendChart && trendCharts.map((tc, tci) => {
|
||||||
|
const trendTitle = tc.metric === 'visitors' ? T.trendVisitors
|
||||||
|
: tc.metric === 'tickets' ? T.trendTickets
|
||||||
|
: T.trendRevenue;
|
||||||
|
return (
|
||||||
|
<View key={tci} style={S.sectionGap}>
|
||||||
|
<SectionHeading title={trendTitle} color={color} arB={arB} />
|
||||||
|
<View style={S.legendRow}>
|
||||||
|
{tc.museums.length >= 2 && tc.museums.map((m, i) => (
|
||||||
|
<View key={m.name} style={S.legendItem}>
|
||||||
|
<View style={[S.legendDot, { backgroundColor: CHART_PALETTE[i % CHART_PALETTE.length] }]} />
|
||||||
|
<Text style={[S.legendLabel, arN]}>{m.name}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<View style={S.legendItem}>
|
||||||
|
<View style={[S.legendDot, { backgroundColor: TOTAL_LINE_COLOR }]} />
|
||||||
|
<Text style={[S.legendLabel, arN]}>{tc.museums.length >= 2 ? `Total · ${period}` : period}</Text>
|
||||||
|
</View>
|
||||||
|
{cfg.includeComparison && tc.previous && (
|
||||||
|
<View style={S.legendItem}>
|
||||||
|
<View style={[S.legendDot, { backgroundColor: '#94a3b8' }]} />
|
||||||
|
<Text style={[S.legendLabel, arN]}>{comparisonPeriodLabel}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfTrendChart
|
||||||
|
labels={tc.labels}
|
||||||
|
current={tc.current}
|
||||||
|
previous={tc.previous}
|
||||||
|
color={TOTAL_LINE_COLOR}
|
||||||
|
width={chartW}
|
||||||
|
height={155}
|
||||||
|
series={tc.museums.length >= 2 ? tc.museums.map((m, i) => ({
|
||||||
|
label: m.name,
|
||||||
|
color: CHART_PALETTE[i % CHART_PALETTE.length],
|
||||||
|
data: m.values,
|
||||||
|
})) : undefined}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
|
</Page>
|
||||||
|
|
||||||
|
{/* ── Museum Mini-Reports ────────────────────────────── */}
|
||||||
|
{showMuseumPage && museumData.length > 0 && (
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={museumPg} isAr={isAr} arB={arB} />
|
||||||
|
<SectionHeading title={T.museumBreakdowns} color={color} arB={arB} />
|
||||||
|
|
||||||
|
{museumData.map((row, mi) => {
|
||||||
|
const mRows = museumMetricRows(row);
|
||||||
|
const hasPrev = row.prev !== null;
|
||||||
|
return (
|
||||||
|
<View key={row.name} style={[S.museumBlock, { borderLeftColor: CHART_PALETTE[mi % CHART_PALETTE.length] }]}>
|
||||||
|
<Text style={[S.museumBlockName, arB]}>{row.name}</Text>
|
||||||
|
{hasPrev && (
|
||||||
|
<Text style={[S.museumIntroText, arN]}>
|
||||||
|
{museumIntro(row, lang, comparisonPeriodLabel)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View style={S.miniTable}>
|
||||||
|
<View style={S.miniHeaderRow}>
|
||||||
|
<Text style={[S.miniHeaderLabel, arB]}> </Text>
|
||||||
|
<Text style={[S.miniHeaderCell, arB]}>{period}</Text>
|
||||||
|
{hasPrev && <Text style={[S.miniHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
|
||||||
|
{hasPrev && <Text style={[S.miniHeaderChangeCell, arB]}>{T.change}</Text>}
|
||||||
|
</View>
|
||||||
|
{mRows.map((mr, ri) => (
|
||||||
|
<View key={mr.label} style={[S.miniRow, ri % 2 === 1 ? S.miniRowAlt : {}]}>
|
||||||
|
<Text style={[S.miniLabel, arB]}>{mr.label}</Text>
|
||||||
|
<Text style={[S.miniValue, arN]}>{mr.curr}</Text>
|
||||||
|
{hasPrev && <Text style={[S.miniValue, arN]}>{mr.prev ?? '—'}</Text>}
|
||||||
|
{hasPrev && mr.chg !== null && (
|
||||||
|
<Text style={[S.miniChange, mr.chg >= 0 ? S.miniChangeUp : S.miniChangeDown]}>
|
||||||
|
{formatPct(mr.chg)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
|
</Page>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Channel Breakdowns ─────────────────────────────── */}
|
||||||
|
{showChannelPage && (
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={channelPg} isAr={isAr} arB={arB} />
|
||||||
|
|
||||||
|
{cfg.showChannelRevenue && channelBreakdown.revenue.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byChannelRevenue} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={channelBreakdown.revenue} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{cfg.showChannelVisitors && channelBreakdown.visitors.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byChannelVisitors} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={channelBreakdown.visitors} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{cfg.showChannelTickets && channelBreakdown.tickets.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byChannelTickets} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={channelBreakdown.tickets} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
|
</Page>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── District Breakdowns ────────────────────────────── */}
|
||||||
|
{showDistrictPage && (
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={districtPg} isAr={isAr} arB={arB} />
|
||||||
|
|
||||||
|
{cfg.showDistrictRevenue && districtBreakdown.revenue.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byDistrictRevenue} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={districtBreakdown.revenue} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{cfg.showDistrictVisitors && districtBreakdown.visitors.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byDistrictVisitors} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={districtBreakdown.visitors} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{cfg.showDistrictTickets && districtBreakdown.tickets.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byDistrictTickets} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={districtBreakdown.tickets} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
|
</Page>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Global Performance Summary ─────────────────────── */}
|
||||||
|
{showSummaryPage && museumData.length > 0 && (
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={summaryPg} isAr={isAr} arB={arB} />
|
||||||
|
<SectionHeading title={T.globalSummary} color={color} arB={arB} />
|
||||||
|
|
||||||
|
<Text style={[S.summarySubLabel, arN]}>
|
||||||
|
{period} — {T.comparedTo} {comparisonPeriodLabel}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={S.summaryHeaderRow}>
|
||||||
|
<Text style={[S.summaryHeaderMuseum, arB]}>{T.museum}</Text>
|
||||||
|
{cfg.showMuseumRevenue && <>
|
||||||
|
<Text style={[S.summaryHeaderMetric, arB]}>{T.revenue}</Text>
|
||||||
|
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumVisitors && <>
|
||||||
|
<Text style={[S.summaryHeaderMetric, arB]}>{T.visitors}</Text>
|
||||||
|
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumTickets && <>
|
||||||
|
<Text style={[S.summaryHeaderMetric, arB]}>{T.tickets}</Text>
|
||||||
|
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
|
||||||
|
</>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{museumData.map((row, i) => {
|
||||||
|
const hasPrev = row.prev !== null;
|
||||||
|
return (
|
||||||
|
<View key={row.name} style={[S.summaryRow, i % 2 === 1 ? S.summaryRowAlt : {}]}>
|
||||||
|
<Text style={[S.summaryMuseum, arN]}>{row.name.length > 30 ? row.name.slice(0, 30) + '…' : row.name}</Text>
|
||||||
|
{cfg.showMuseumRevenue && <>
|
||||||
|
<Text style={[S.summaryMetric, arN]}>{formatCurrency(row.curr.revenue, cfg.includeVAT)}</Text>
|
||||||
|
{hasPrev && row.prev ? (() => {
|
||||||
|
const c = pctChange(row.curr.revenue, row.prev!.revenue);
|
||||||
|
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumVisitors && <>
|
||||||
|
<Text style={[S.summaryMetric, arN]}>{row.curr.visitors.toLocaleString()}</Text>
|
||||||
|
{hasPrev && row.prev ? (() => {
|
||||||
|
const c = pctChange(row.curr.visitors, row.prev!.visitors);
|
||||||
|
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumTickets && <>
|
||||||
|
<Text style={[S.summaryMetric, arN]}>{row.curr.tickets.toLocaleString()}</Text>
|
||||||
|
{hasPrev && row.prev ? (() => {
|
||||||
|
const c = pctChange(row.curr.tickets, row.prev!.tickets);
|
||||||
|
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||||
|
</>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<View style={S.summaryTotalRow}>
|
||||||
|
<Text style={[S.summaryMuseumTotal, arB]}>{T.total}</Text>
|
||||||
|
{cfg.showMuseumRevenue && <>
|
||||||
|
<Text style={[S.summaryMetricTotal, arB]}>{formatCurrency(metrics.revenue, cfg.includeVAT)}</Text>
|
||||||
|
{prevMetrics ? (() => {
|
||||||
|
const c = pctChange(metrics.revenue, prevMetrics.revenue);
|
||||||
|
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumVisitors && <>
|
||||||
|
<Text style={[S.summaryMetricTotal, arB]}>{metrics.visitors.toLocaleString()}</Text>
|
||||||
|
{prevMetrics ? (() => {
|
||||||
|
const c = pctChange(metrics.visitors, prevMetrics.visitors);
|
||||||
|
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumTickets && <>
|
||||||
|
<Text style={[S.summaryMetricTotal, arB]}>{metrics.tickets.toLocaleString()}</Text>
|
||||||
|
{prevMetrics ? (() => {
|
||||||
|
const c = pctChange(metrics.tickets, prevMetrics.tickets);
|
||||||
|
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||||
|
</>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
|
</Page>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS_EN = {
|
||||||
|
defaultTitle: 'Performance Report',
|
||||||
|
preparedFor: 'Prepared for',
|
||||||
|
attention: 'Attention',
|
||||||
|
generated: 'Generated',
|
||||||
|
execSummary: 'Executive Summary',
|
||||||
|
keyMetrics: 'Key Metrics',
|
||||||
|
inclVAT: 'Incl. VAT',
|
||||||
|
exclVAT: 'Excl. VAT',
|
||||||
|
change: 'Change',
|
||||||
|
comparedTo: 'vs.',
|
||||||
|
trendRevenue: 'Revenue Trend',
|
||||||
|
trendVisitors: 'Visitor Trend',
|
||||||
|
trendTickets: 'Ticket Trend',
|
||||||
|
museumBreakdowns: 'Museum Breakdown',
|
||||||
|
byChannelRevenue: 'Revenue by Channel',
|
||||||
|
byChannelVisitors: 'Visitors by Channel',
|
||||||
|
byChannelTickets: 'Tickets by Channel',
|
||||||
|
byDistrictRevenue: 'Revenue by District',
|
||||||
|
byDistrictVisitors: 'Visitors by District',
|
||||||
|
byDistrictTickets: 'Tickets by District',
|
||||||
|
globalSummary: 'Performance Summary',
|
||||||
|
museum: 'Museum',
|
||||||
|
total: 'TOTAL',
|
||||||
|
revenue: 'Revenue',
|
||||||
|
visitors: 'Visitors',
|
||||||
|
tickets: 'Tickets',
|
||||||
|
avgRev: 'Avg Rev / Visitor',
|
||||||
|
avgTicketPrice: 'Avg Ticket Price',
|
||||||
|
capture: 'Pilgrim Capture Rate',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LABELS_AR = {
|
||||||
|
defaultTitle: 'تقرير الأداء',
|
||||||
|
preparedFor: 'مُعدّ لـ',
|
||||||
|
attention: 'عناية',
|
||||||
|
generated: 'تاريخ الإصدار',
|
||||||
|
execSummary: 'الملخص التنفيذي',
|
||||||
|
keyMetrics: 'المؤشرات الرئيسية',
|
||||||
|
inclVAT: 'شامل ضريبة القيمة المضافة',
|
||||||
|
exclVAT: 'غير شامل ضريبة القيمة المضافة',
|
||||||
|
change: 'التغيّر',
|
||||||
|
comparedTo: 'مقابل',
|
||||||
|
trendRevenue: 'اتجاه الإيرادات',
|
||||||
|
trendVisitors: 'اتجاه الزوار',
|
||||||
|
trendTickets: 'اتجاه التذاكر',
|
||||||
|
museumBreakdowns: 'تفاصيل المتاحف',
|
||||||
|
byChannelRevenue: 'الإيرادات حسب القناة',
|
||||||
|
byChannelVisitors: 'الزوار حسب القناة',
|
||||||
|
byChannelTickets: 'التذاكر حسب القناة',
|
||||||
|
byDistrictRevenue: 'الإيرادات حسب الحي',
|
||||||
|
byDistrictVisitors: 'الزوار حسب الحي',
|
||||||
|
byDistrictTickets: 'التذاكر حسب الحي',
|
||||||
|
globalSummary: 'ملخص الأداء',
|
||||||
|
museum: 'المتحف',
|
||||||
|
total: 'الإجمالي',
|
||||||
|
revenue: 'الإيرادات',
|
||||||
|
visitors: 'الزوار',
|
||||||
|
tickets: 'التذاكر',
|
||||||
|
avgRev: 'متوسط الإيراد / زائر',
|
||||||
|
avgTicketPrice: 'متوسط سعر التذكرة',
|
||||||
|
capture: 'معدل استيعاب الحجاج',
|
||||||
|
};
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
|
import AltMultiSelect from '../shared/AltMultiSelect';
|
||||||
|
import type { ReportConfig, TrendMetric } from './reportHelpers';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
config: ReportConfig;
|
||||||
|
onChange: (patch: Partial<ReportConfig>) => void;
|
||||||
|
allMuseums: string[];
|
||||||
|
allChannels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="rf-field">
|
||||||
|
<span className="rf-label">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C1+C3: role="group" + aria-label + aria-pressed on every button
|
||||||
|
function PillGroup({ options, value, onChange, label }: {
|
||||||
|
options: Array<{ label: string; value: string }>;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rf-metric-pills" role="group" aria-label={label}>
|
||||||
|
{options.map(opt => (
|
||||||
|
<button key={opt.value} type="button"
|
||||||
|
className={`rf-metric-pill${value === opt.value ? ' rf-metric-pill--on' : ''}`}
|
||||||
|
aria-pressed={value === opt.value}
|
||||||
|
onClick={() => onChange(opt.value)}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndeterminateCheckbox({ checked, indeterminate, onChange, className }: {
|
||||||
|
checked: boolean; indeterminate: boolean; onChange: (v: boolean) => void; className?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) ref.current.indeterminate = indeterminate;
|
||||||
|
}, [indeterminate]);
|
||||||
|
return (
|
||||||
|
<input ref={ref} type="checkbox" checked={checked}
|
||||||
|
onChange={e => onChange(e.target.checked)} className={className} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C1: aria-hidden badge (visual only), role/aria on header label provides the accessible name
|
||||||
|
function ModuleCard({ title, badge, enabled, onToggle, children }: {
|
||||||
|
title: string;
|
||||||
|
badge?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: (v: boolean) => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`rf-module${enabled ? ' rf-module--on' : ''}`}>
|
||||||
|
<label className="rf-module-header">
|
||||||
|
<input type="checkbox" checked={enabled}
|
||||||
|
onChange={e => onToggle(e.target.checked)} className="rf-checkbox" />
|
||||||
|
<span className="rf-module-title">{title}</span>
|
||||||
|
{/* aria-hidden: badge is visual state feedback, not part of checkbox label */}
|
||||||
|
<span className={`rf-module-badge${enabled ? ' rf-module-badge--on' : ''}`} aria-hidden="true">
|
||||||
|
{badge ?? (enabled ? 'Included' : 'Excluded')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{enabled && children && (
|
||||||
|
<div className="rf-module-body">{children}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricPatch = { revenue?: boolean; visitors?: boolean; tickets?: boolean };
|
||||||
|
|
||||||
|
function BreakdownModule({ title, revenue, visitors, tickets, onChange }: {
|
||||||
|
title: string;
|
||||||
|
revenue: boolean; visitors: boolean; tickets: boolean;
|
||||||
|
onChange: (patch: MetricPatch) => void;
|
||||||
|
}) {
|
||||||
|
const anyOn = revenue || visitors || tickets;
|
||||||
|
const allOn = revenue && visitors && tickets;
|
||||||
|
const badge = anyOn
|
||||||
|
? [revenue && 'Revenue', visitors && 'Visitors', tickets && 'Tickets'].filter(Boolean).join(' · ')
|
||||||
|
: 'Excluded';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rf-module${anyOn ? ' rf-module--on' : ''}`}>
|
||||||
|
<label className="rf-module-header">
|
||||||
|
<IndeterminateCheckbox
|
||||||
|
checked={anyOn}
|
||||||
|
indeterminate={anyOn && !allOn}
|
||||||
|
onChange={v => onChange({ revenue: v, visitors: v, tickets: v })}
|
||||||
|
className="rf-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="rf-module-title">{title}</span>
|
||||||
|
{/* aria-hidden: badge is visual only */}
|
||||||
|
<span className={`rf-module-badge${anyOn ? ' rf-module-badge--on' : ''}`} aria-hidden="true">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{anyOn && (
|
||||||
|
<div className="rf-module-body">
|
||||||
|
{/* C1+C3: role="group" + aria-label + aria-pressed */}
|
||||||
|
<div className="rf-metric-pills" role="group" aria-label={`${title} metrics to include`}>
|
||||||
|
{([
|
||||||
|
{ label: 'Revenue', on: revenue, key: 'revenue' as keyof MetricPatch },
|
||||||
|
{ label: 'Visitors', on: visitors, key: 'visitors' as keyof MetricPatch },
|
||||||
|
{ label: 'Tickets', on: tickets, key: 'tickets' as keyof MetricPatch },
|
||||||
|
]).map(({ label, on, key }) => (
|
||||||
|
<button key={label} type="button"
|
||||||
|
className={`rf-metric-pill${on ? ' rf-metric-pill--on' : ''}`}
|
||||||
|
aria-pressed={on}
|
||||||
|
onClick={() => onChange({ [key]: !on } as MetricPatch)}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportForm({ config: cfg, onChange, allMuseums, allChannels }: Props) {
|
||||||
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// C2: inline error instead of alert()
|
||||||
|
const [logoError, setLogoError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
setLogoError('File must be under 2 MB.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLogoError(null);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => onChange({ clientLogoBase64: reader.result as string });
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="report-form">
|
||||||
|
<div className="rf-two-col">
|
||||||
|
|
||||||
|
{/* ── Left: setup ── */}
|
||||||
|
<div className="rf-col">
|
||||||
|
{/* M2: semantic h2 instead of div — visually identical via CSS */}
|
||||||
|
<h2 className="rf-group-label">Client</h2>
|
||||||
|
|
||||||
|
<Field label="Report title">
|
||||||
|
<input className="rf-input" type="text" value={cfg.title}
|
||||||
|
onChange={e => onChange({ title: e.target.value })}
|
||||||
|
placeholder="Q1 2025 Visitor Performance" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Prepared for">
|
||||||
|
<input className="rf-input" type="text" value={cfg.clientName}
|
||||||
|
onChange={e => onChange({ clientName: e.target.value })}
|
||||||
|
placeholder="Acme Group" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Contact (optional)">
|
||||||
|
<input className="rf-input" type="text" value={cfg.contactName}
|
||||||
|
onChange={e => onChange({ contactName: e.target.value })}
|
||||||
|
placeholder="Mohammed Al-..." />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="rf-branding-row">
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">Accent color</span>
|
||||||
|
<div className="rf-color-row">
|
||||||
|
<input type="color" value={cfg.accentColor}
|
||||||
|
onChange={e => onChange({ accentColor: e.target.value })}
|
||||||
|
className="rf-color-input"
|
||||||
|
aria-label="Report accent color" />
|
||||||
|
<span className="rf-color-val">{cfg.accentColor}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">Logo (PNG/JPG, max 2 MB)</span>
|
||||||
|
<div className="rf-logo-row">
|
||||||
|
{/* H6: descriptive aria-label on upload button */}
|
||||||
|
<button type="button" className="rf-upload-btn"
|
||||||
|
aria-label={cfg.clientLogoBase64 ? 'Change client logo' : 'Upload client logo'}
|
||||||
|
onClick={() => logoInputRef.current?.click()}>
|
||||||
|
{cfg.clientLogoBase64 ? 'Change' : 'Upload'}
|
||||||
|
</button>
|
||||||
|
{cfg.clientLogoBase64 && (
|
||||||
|
<>
|
||||||
|
{/* M1: meaningful alt text */}
|
||||||
|
<img src={cfg.clientLogoBase64} alt="Uploaded client logo" className="rf-logo-preview" />
|
||||||
|
{/* H6: descriptive aria-label on remove button */}
|
||||||
|
<button type="button" className="rf-remove-btn"
|
||||||
|
aria-label="Remove client logo"
|
||||||
|
onClick={() => onChange({ clientLogoBase64: null })}>✕</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<input ref={logoInputRef} type="file" accept="image/png,image/jpeg"
|
||||||
|
style={{ display: 'none' }} onChange={handleLogoUpload} />
|
||||||
|
</div>
|
||||||
|
{/* C2: inline logo error */}
|
||||||
|
{logoError && <span className="rf-field-error" role="alert">{logoError}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Data</h2>
|
||||||
|
|
||||||
|
<div className="rf-date-row">
|
||||||
|
<Field label="Period start">
|
||||||
|
<input className="rf-input" type="date" value={cfg.startDate}
|
||||||
|
onChange={e => onChange({ startDate: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Period end">
|
||||||
|
<input className="rf-input" type="date" value={cfg.endDate}
|
||||||
|
onChange={e => onChange({ endDate: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Museums">
|
||||||
|
<AltMultiSelect value={cfg.selectedMuseums} options={allMuseums}
|
||||||
|
onChange={v => onChange({ selectedMuseums: v })}
|
||||||
|
allLabel="All museums" countLabel={n => `${n} museums`} clearLabel="Clear" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Channels">
|
||||||
|
<AltMultiSelect value={cfg.selectedChannels} options={allChannels}
|
||||||
|
onChange={v => onChange({ selectedChannels: v })}
|
||||||
|
allLabel="All channels" countLabel={n => `${n} channels`} clearLabel="Clear" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">VAT</span>
|
||||||
|
<PillGroup
|
||||||
|
label="VAT"
|
||||||
|
options={[{ label: 'Excl. VAT', value: 'excl' }, { label: 'Incl. VAT', value: 'incl' }]}
|
||||||
|
value={cfg.includeVAT ? 'incl' : 'excl'}
|
||||||
|
onChange={v => onChange({ includeVAT: v === 'incl' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="rf-check-row">
|
||||||
|
<input type="checkbox" checked={cfg.includeComparison}
|
||||||
|
onChange={e => onChange({ includeComparison: e.target.checked })} className="rf-checkbox" />
|
||||||
|
<span>Include comparison period</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{cfg.includeComparison && (
|
||||||
|
<div className="rf-comparison-block">
|
||||||
|
<div className="rf-comparison-label" aria-hidden="true">vs. period</div>
|
||||||
|
<div className="rf-date-row">
|
||||||
|
<Field label="From">
|
||||||
|
<input className="rf-input" type="date" value={cfg.comparisonStartDate}
|
||||||
|
onChange={e => onChange({ comparisonStartDate: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="To">
|
||||||
|
<input className="rf-input" type="date" value={cfg.comparisonEndDate}
|
||||||
|
onChange={e => onChange({ comparisonEndDate: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Format</h2>
|
||||||
|
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">Language</span>
|
||||||
|
<PillGroup
|
||||||
|
label="Language"
|
||||||
|
options={[{ label: 'English', value: 'en' }, { label: 'العربية', value: 'ar' }]}
|
||||||
|
value={cfg.language}
|
||||||
|
onChange={v => onChange({ language: v as 'en' | 'ar' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">Orientation</span>
|
||||||
|
<div className="rf-orient-row" role="group" aria-label="Page orientation">
|
||||||
|
<button type="button"
|
||||||
|
className={`rf-orient-btn${cfg.orientation === 'portrait' ? ' rf-orient-btn--on' : ''}`}
|
||||||
|
aria-pressed={cfg.orientation === 'portrait'}
|
||||||
|
onClick={() => onChange({ orientation: 'portrait' })}>
|
||||||
|
<div className="rf-orient-page rf-orient-page--portrait" aria-hidden="true" />
|
||||||
|
<span>Portrait</span>
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
className={`rf-orient-btn${cfg.orientation === 'landscape' ? ' rf-orient-btn--on' : ''}`}
|
||||||
|
aria-pressed={cfg.orientation === 'landscape'}
|
||||||
|
onClick={() => onChange({ orientation: 'landscape' })}>
|
||||||
|
<div className="rf-orient-page rf-orient-page--landscape" aria-hidden="true" />
|
||||||
|
<span>Landscape</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">Confidentiality</span>
|
||||||
|
<PillGroup
|
||||||
|
label="Confidentiality"
|
||||||
|
options={[
|
||||||
|
{ label: 'Confidential', value: 'Confidential' },
|
||||||
|
{ label: 'Internal', value: 'Internal' },
|
||||||
|
{ label: 'Public', value: 'Public' },
|
||||||
|
]}
|
||||||
|
value={cfg.confidentiality}
|
||||||
|
onChange={v => onChange({ confidentiality: v as ReportConfig['confidentiality'] })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right: content selection ── */}
|
||||||
|
<div className="rf-col">
|
||||||
|
<h2 className="rf-group-label">Report Sections</h2>
|
||||||
|
|
||||||
|
<ModuleCard title="Executive Summary"
|
||||||
|
enabled={cfg.showExecutiveSummary} onToggle={v => onChange({ showExecutiveSummary: v })} />
|
||||||
|
<ModuleCard title="Key Metrics Table"
|
||||||
|
enabled={cfg.showMetricsTable} onToggle={v => onChange({ showMetricsTable: v })} />
|
||||||
|
<ModuleCard title="Pilgrim Capture Rate"
|
||||||
|
enabled={cfg.showPilgrimCapture} onToggle={v => onChange({ showPilgrimCapture: v })} />
|
||||||
|
|
||||||
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Trend</h2>
|
||||||
|
|
||||||
|
<ModuleCard
|
||||||
|
title="Trend Chart"
|
||||||
|
enabled={cfg.showTrendChart}
|
||||||
|
onToggle={v => onChange({ showTrendChart: v })}
|
||||||
|
badge={cfg.showTrendChart && cfg.trendMetrics.length
|
||||||
|
? cfg.trendMetrics.map(m => m.charAt(0).toUpperCase() + m.slice(1)).join(' · ')
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
<div className="rf-metric-pills" role="group" aria-label="Trend metrics to include">
|
||||||
|
{(['revenue', 'visitors', 'tickets'] as TrendMetric[]).map(m => {
|
||||||
|
const on = cfg.trendMetrics.includes(m);
|
||||||
|
return (
|
||||||
|
<button key={m} type="button"
|
||||||
|
className={`rf-metric-pill${on ? ' rf-metric-pill--on' : ''}`}
|
||||||
|
aria-pressed={on}
|
||||||
|
onClick={() => {
|
||||||
|
const next = on
|
||||||
|
? cfg.trendMetrics.filter(x => x !== m)
|
||||||
|
: [...cfg.trendMetrics, m];
|
||||||
|
onChange({ trendMetrics: next.length ? next : [m] });
|
||||||
|
}}>
|
||||||
|
{m.charAt(0).toUpperCase() + m.slice(1)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ModuleCard>
|
||||||
|
|
||||||
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Breakdowns</h2>
|
||||||
|
|
||||||
|
<BreakdownModule title="Museums"
|
||||||
|
revenue={cfg.showMuseumRevenue} visitors={cfg.showMuseumVisitors} tickets={cfg.showMuseumTickets}
|
||||||
|
onChange={p => onChange({
|
||||||
|
showMuseumRevenue: p.revenue ?? cfg.showMuseumRevenue,
|
||||||
|
showMuseumVisitors: p.visitors ?? cfg.showMuseumVisitors,
|
||||||
|
showMuseumTickets: p.tickets ?? cfg.showMuseumTickets,
|
||||||
|
})} />
|
||||||
|
|
||||||
|
<BreakdownModule title="Channels"
|
||||||
|
revenue={cfg.showChannelRevenue} visitors={cfg.showChannelVisitors} tickets={cfg.showChannelTickets}
|
||||||
|
onChange={p => onChange({
|
||||||
|
showChannelRevenue: p.revenue ?? cfg.showChannelRevenue,
|
||||||
|
showChannelVisitors: p.visitors ?? cfg.showChannelVisitors,
|
||||||
|
showChannelTickets: p.tickets ?? cfg.showChannelTickets,
|
||||||
|
})} />
|
||||||
|
|
||||||
|
<BreakdownModule title="Districts"
|
||||||
|
revenue={cfg.showDistrictRevenue} visitors={cfg.showDistrictVisitors} tickets={cfg.showDistrictTickets}
|
||||||
|
onChange={p => onChange({
|
||||||
|
showDistrictRevenue: p.revenue ?? cfg.showDistrictRevenue,
|
||||||
|
showDistrictVisitors: p.visitors ?? cfg.showDistrictVisitors,
|
||||||
|
showDistrictTickets: p.tickets ?? cfg.showDistrictTickets,
|
||||||
|
})} />
|
||||||
|
|
||||||
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Summary</h2>
|
||||||
|
|
||||||
|
<ModuleCard title="Global Performance Table"
|
||||||
|
enabled={cfg.showGlobalSummary} onToggle={v => onChange({ showGlobalSummary: v })}>
|
||||||
|
{!cfg.includeComparison && (
|
||||||
|
<p className="rf-module-note">
|
||||||
|
Enable a comparison period to show progression data.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ModuleCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { ReportConfig } from './reportHelpers';
|
||||||
|
import { formatPeriodLabel, estimatePageCount } from './reportHelpers';
|
||||||
|
|
||||||
|
interface Props { config: ReportConfig; }
|
||||||
|
|
||||||
|
export default function ReportPreview({ config: cfg }: Props) {
|
||||||
|
const color = cfg.accentColor;
|
||||||
|
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, cfg.language);
|
||||||
|
const pages = estimatePageCount(cfg);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="report-preview">
|
||||||
|
<div className="report-preview-label">Preview · ~{pages} pages</div>
|
||||||
|
|
||||||
|
{/* Cover page mockup */}
|
||||||
|
<div className="rp-page">
|
||||||
|
<div className="rp-cover-top">
|
||||||
|
<span className="rp-brand">HiHala Data</span>
|
||||||
|
{cfg.clientLogoBase64 && (
|
||||||
|
<img src={cfg.clientLogoBase64} alt="Client logo" className="rp-client-logo" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rp-cover-body">
|
||||||
|
<div className="rp-cover-title">
|
||||||
|
{cfg.title || <span className="rp-placeholder-text">Report Title</span>}
|
||||||
|
</div>
|
||||||
|
{cfg.clientName && (
|
||||||
|
<div className="rp-cover-for">Prepared for: <strong>{cfg.clientName}</strong></div>
|
||||||
|
)}
|
||||||
|
{cfg.contactName && (
|
||||||
|
<div className="rp-cover-contact">Attention: {cfg.contactName}</div>
|
||||||
|
)}
|
||||||
|
<div className="rp-cover-period">{period}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rp-cover-bar" style={{ backgroundColor: color }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content page mockup */}
|
||||||
|
<div className="rp-page rp-page--content">
|
||||||
|
<div className="rp-page-header">
|
||||||
|
<span className="rp-brand-small" style={{ color }}>HiHala Data</span>
|
||||||
|
<span className="rp-page-title-small">{cfg.title || 'Report'}</span>
|
||||||
|
<span className="rp-page-num">2</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cfg.showExecutiveSummary && (
|
||||||
|
<div className="rp-section">
|
||||||
|
<div className="rp-section-heading" style={{ backgroundColor: color }}>Executive Summary</div>
|
||||||
|
<div className="rp-placeholder-lines">
|
||||||
|
<div className="rp-ph-line" style={{ width: '100%' }} />
|
||||||
|
<div className="rp-ph-line" style={{ width: '90%' }} />
|
||||||
|
<div className="rp-ph-line" style={{ width: '80%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cfg.showMetricsTable && (
|
||||||
|
<div className="rp-section">
|
||||||
|
<div className="rp-section-heading" style={{ backgroundColor: color }}>Key Metrics</div>
|
||||||
|
<div className="rp-ph-table">
|
||||||
|
{['Revenue', 'Visitors', 'Tickets', 'Avg Rev'].map(r => (
|
||||||
|
<div key={r} className="rp-ph-row">
|
||||||
|
<span className="rp-ph-row-label">{r}</span>
|
||||||
|
<div className="rp-ph-row-val" />
|
||||||
|
{cfg.includeComparison && <div className="rp-ph-row-val rp-ph-row-val--sm" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cfg.showTrendChart && (
|
||||||
|
<div className="rp-section">
|
||||||
|
<div className="rp-section-heading" style={{ backgroundColor: color }}>Trend</div>
|
||||||
|
<div className="rp-ph-chart" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
|
import { pdf } from '@react-pdf/renderer';
|
||||||
|
import type { MuseumRecord } from '../../types';
|
||||||
|
import { DEFAULT_CONFIG, computeReportData } from './reportHelpers';
|
||||||
|
import type { ReportConfig } from './reportHelpers';
|
||||||
|
import { ReportDocument } from './ReportDocument';
|
||||||
|
import ReportForm from './ReportForm';
|
||||||
|
import { getUniqueMuseums, getUniqueChannels } from '../../services/dataService';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: MuseumRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportPage({ data }: Props) {
|
||||||
|
const [config, setConfig] = useState<ReportConfig>(DEFAULT_CONFIG);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// H8: memoize — these scan the full records array; re-running on every patch is wasteful
|
||||||
|
const allMuseums = useMemo(() => getUniqueMuseums(data), [data]);
|
||||||
|
const allChannels = useMemo(() => getUniqueChannels(data), [data]);
|
||||||
|
|
||||||
|
const patch = useCallback((p: Partial<ReportConfig>) => setConfig(c => ({ ...c, ...p })), []);
|
||||||
|
|
||||||
|
// C2: auto-clear inline error after 6 s
|
||||||
|
useEffect(() => {
|
||||||
|
if (!errorMsg) return;
|
||||||
|
const t = setTimeout(() => setErrorMsg(null), 6000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [errorMsg]);
|
||||||
|
|
||||||
|
const sectionCount = useMemo(() => [
|
||||||
|
config.showExecutiveSummary,
|
||||||
|
config.showMetricsTable,
|
||||||
|
config.showPilgrimCapture,
|
||||||
|
config.showTrendChart,
|
||||||
|
config.showMuseumRevenue || config.showMuseumVisitors || config.showMuseumTickets,
|
||||||
|
config.showChannelRevenue || config.showChannelVisitors || config.showChannelTickets,
|
||||||
|
config.showDistrictRevenue || config.showDistrictVisitors || config.showDistrictTickets,
|
||||||
|
config.showGlobalSummary && config.includeComparison,
|
||||||
|
].filter(Boolean).length, [config]);
|
||||||
|
|
||||||
|
const periodLabel = config.startDate && config.endDate
|
||||||
|
? `${config.startDate.slice(0, 7)} to ${config.endDate.slice(0, 7)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (config.startDate > config.endDate) {
|
||||||
|
// C2: inline error instead of alert()
|
||||||
|
setErrorMsg('End date must be after start date.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGenerating(true);
|
||||||
|
setErrorMsg(null);
|
||||||
|
try {
|
||||||
|
const reportData = computeReportData(data, config);
|
||||||
|
const blob = await pdf(<ReportDocument data={reportData} />).toBlob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
const slug = (config.clientName || 'report').toLowerCase().replace(/\s+/g, '-');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `hihala-${slug}-${config.startDate.slice(0, 7)}.pdf`;
|
||||||
|
try {
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PDF generation failed:', err);
|
||||||
|
// C2: inline error instead of alert()
|
||||||
|
setErrorMsg('Failed to generate PDF. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="report-page">
|
||||||
|
{/* L2: aria-live region for screen reader status announcements */}
|
||||||
|
<div role="status" aria-live="polite" className="sr-only">
|
||||||
|
{generating ? 'Generating PDF, please wait.' : ''}
|
||||||
|
{errorMsg ? `Error: ${errorMsg}` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="report-header">
|
||||||
|
<h1 className="report-title">Report Builder</h1>
|
||||||
|
{/* M5: removed generic filler subtitle */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="report-body">
|
||||||
|
<div className="report-form-col">
|
||||||
|
<ReportForm config={config} onChange={patch} allMuseums={allMuseums} allChannels={allChannels} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="report-footer-bar">
|
||||||
|
<div className="report-footer-meta">
|
||||||
|
{/* H5: report-footer-chip--count stays visible on mobile; others hide */}
|
||||||
|
<span className="report-footer-chip report-footer-chip--count">
|
||||||
|
{sectionCount} section{sectionCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{periodLabel && (
|
||||||
|
<>
|
||||||
|
{/* L1: aria-hidden on decorative separators */}
|
||||||
|
<span className="report-footer-dot" aria-hidden="true" />
|
||||||
|
<span className="report-footer-chip">{periodLabel}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="report-footer-dot" aria-hidden="true" />
|
||||||
|
<span className="report-footer-chip">
|
||||||
|
{config.orientation === 'landscape' ? 'Landscape' : 'Portrait'}
|
||||||
|
</span>
|
||||||
|
{config.includeComparison && (
|
||||||
|
<>
|
||||||
|
<span className="report-footer-dot" aria-hidden="true" />
|
||||||
|
<span className="report-footer-chip report-footer-chip--compare">With comparison</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* C2: inline error message */}
|
||||||
|
{errorMsg && (
|
||||||
|
<span className="report-footer-error" role="alert">{errorMsg}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="report-generate-btn"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={generating}
|
||||||
|
aria-busy={generating}
|
||||||
|
>
|
||||||
|
{generating ? (
|
||||||
|
<>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="report-spin" aria-hidden="true">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||||
|
</svg>
|
||||||
|
Generating…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
Download PDF
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Svg, Line, Polyline, Rect, Text as SvgText, G } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
export const CHART_PALETTE = [
|
||||||
|
'#2563eb', '#0891b2', '#7c3aed', '#059669',
|
||||||
|
'#d97706', '#dc2626', '#db2777', '#f59e0b',
|
||||||
|
'#10b981', '#6366f1', '#0284c7', '#65a30d',
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtAxis(v: number): string {
|
||||||
|
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (v >= 10_000) return `${Math.round(v / 1_000)}K`;
|
||||||
|
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
|
||||||
|
return String(Math.round(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrendChartProps {
|
||||||
|
labels: string[];
|
||||||
|
current: number[];
|
||||||
|
previous: number[] | null;
|
||||||
|
color: string;
|
||||||
|
series?: Array<{ label: string; color: string; data: number[] }>;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfTrendChart({ labels, current, previous, color, series, width = 470, height = 155 }: TrendChartProps) {
|
||||||
|
const seriesValues = (series ?? []).flatMap(s => s.data);
|
||||||
|
const allValues = [...current, ...(previous ?? []), ...seriesValues].filter(v => v > 0);
|
||||||
|
const max = allValues.length > 0 ? Math.max(...allValues) : 1;
|
||||||
|
// padL wide enough for y-axis labels like "1.2M"
|
||||||
|
const padL = 38, padR = 8, padT = 10, padB = 20;
|
||||||
|
const w = width - padL - padR;
|
||||||
|
const h = height - padT - padB;
|
||||||
|
|
||||||
|
const sx = (i: number) => padL + (labels.length > 1 ? (i / (labels.length - 1)) * w : w / 2);
|
||||||
|
const sy = (v: number) => padT + h - (v / max) * h;
|
||||||
|
|
||||||
|
const toPoints = (data: number[]) =>
|
||||||
|
data.map((v, i) => `${sx(i).toFixed(1)},${sy(v).toFixed(1)}`).join(' ');
|
||||||
|
|
||||||
|
const gridLines = [0.25, 0.5, 0.75, 1.0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={height}>
|
||||||
|
{/* Baseline */}
|
||||||
|
<Line x1={padL} y1={(padT + h).toFixed(1)} x2={width - padR} y2={(padT + h).toFixed(1)}
|
||||||
|
stroke="#cbd5e1" strokeWidth={0.75} />
|
||||||
|
|
||||||
|
{/* Grid lines + Y-axis labels */}
|
||||||
|
{gridLines.map(f => {
|
||||||
|
const yPos = sy(max * f);
|
||||||
|
return (
|
||||||
|
<G key={f}>
|
||||||
|
<Line x1={padL} y1={yPos.toFixed(1)} x2={width - padR} y2={yPos.toFixed(1)}
|
||||||
|
stroke="#e2e8f0" strokeWidth={0.5} />
|
||||||
|
<SvgText x={(padL - 5).toFixed(1)} y={(yPos + 2.5).toFixed(1)}
|
||||||
|
fill="#94a3b8" style={{ fontSize: 6.5, textAnchor: 'end' }}>
|
||||||
|
{fmtAxis(max * f)}
|
||||||
|
</SvgText>
|
||||||
|
</G>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Comparison line (dashed) */}
|
||||||
|
{previous && previous.some(v => v > 0) && (
|
||||||
|
<Polyline points={toPoints(previous)}
|
||||||
|
stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 3" fill="none" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Per-museum series */}
|
||||||
|
{(series ?? []).map(s => s.data.some(v => v > 0) && (
|
||||||
|
<Polyline key={s.label} points={toPoints(s.data)}
|
||||||
|
stroke={s.color} strokeWidth={1.5} fill="none" />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Current period total line */}
|
||||||
|
{current.some(v => v > 0) && (
|
||||||
|
<Polyline points={toPoints(current)}
|
||||||
|
stroke={color} strokeWidth={series && series.length >= 2 ? 2 : 2.5} fill="none" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* X-axis week labels */}
|
||||||
|
{labels
|
||||||
|
.filter((_, i) => labels.length <= 8 || i % Math.ceil(labels.length / 8) === 0)
|
||||||
|
.map((label) => {
|
||||||
|
const origIdx = labels.indexOf(label);
|
||||||
|
return (
|
||||||
|
<SvgText key={label}
|
||||||
|
x={sx(origIdx).toFixed(1)} y={height - 5}
|
||||||
|
style={{ fontSize: 7, fill: '#94a3b8', textAnchor: 'middle' }}>
|
||||||
|
{label}
|
||||||
|
</SvgText>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HBarChartProps {
|
||||||
|
items: Array<{ name: string; value: number }>;
|
||||||
|
color: string;
|
||||||
|
usepalette?: boolean;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfHBarChart({ items, color, usepalette = false, width = 470 }: HBarChartProps) {
|
||||||
|
const barH = 17;
|
||||||
|
const gap = 10;
|
||||||
|
const labelW = 160;
|
||||||
|
const barAreaW = width - labelW - 20;
|
||||||
|
const max = Math.max(...items.map(i => i.value), 1);
|
||||||
|
const totalH = items.length * (barH + gap);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={totalH}>
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const y = i * (barH + gap);
|
||||||
|
const bw = Math.max((item.value / max) * barAreaW, 2);
|
||||||
|
const shortName = item.name.length > 26 ? item.name.slice(0, 26) + '…' : item.name;
|
||||||
|
const valueStr = item.value.toLocaleString('en-SA', { maximumFractionDigits: 0 });
|
||||||
|
const barColor = usepalette ? CHART_PALETTE[i % CHART_PALETTE.length] : color;
|
||||||
|
const isShort = bw < 48;
|
||||||
|
return (
|
||||||
|
<G key={item.name + i}>
|
||||||
|
<SvgText x={0} y={y + barH - 4} fill="#334155" style={{ fontSize: 8.5 }}>{shortName}</SvgText>
|
||||||
|
<Rect x={labelW} y={y} width={bw} height={barH} fill={barColor} rx={3} />
|
||||||
|
{isShort ? (
|
||||||
|
<SvgText x={labelW + bw + 6} y={y + barH - 4} fill="#334155"
|
||||||
|
style={{ fontSize: 8.5 }}>{valueStr}</SvgText>
|
||||||
|
) : (
|
||||||
|
<SvgText x={labelW + bw - 6} y={y + barH - 4} fill="#ffffff"
|
||||||
|
style={{ fontSize: 8.5, textAnchor: 'end' }}>{valueStr}</SvgText>
|
||||||
|
)}
|
||||||
|
</G>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { filterDataByDateRange, calculateMetrics, groupByMuseum, groupByChannel, groupByDistrict, umrahData } from '../../services/dataService';
|
||||||
|
import { shiftYear } from '../../lib/dateHelpers';
|
||||||
|
import type { MuseumRecord, Metrics } from '../../types';
|
||||||
|
|
||||||
|
// ─── config ───────────────────────────────────────────────────────
|
||||||
|
export type TrendMetric = 'revenue' | 'visitors' | 'tickets';
|
||||||
|
export type TrendGranularity = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
function inferGranularity(start: string, end: string): TrendGranularity {
|
||||||
|
const days = Math.round((new Date(end).getTime() - new Date(start).getTime()) / 86400000);
|
||||||
|
if (days > 180) return 'month';
|
||||||
|
if (days >= 14) return 'week';
|
||||||
|
return 'day';
|
||||||
|
}
|
||||||
|
|
||||||
|
const _start = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10);
|
||||||
|
const _end = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
export interface ReportConfig {
|
||||||
|
title: string;
|
||||||
|
clientName: string;
|
||||||
|
contactName: string;
|
||||||
|
clientLogoBase64: string | null;
|
||||||
|
accentColor: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
selectedMuseums: string[];
|
||||||
|
selectedChannels: string[];
|
||||||
|
includeVAT: boolean;
|
||||||
|
includeComparison: boolean;
|
||||||
|
comparisonStartDate: string;
|
||||||
|
comparisonEndDate: string;
|
||||||
|
// Summary & metrics
|
||||||
|
showExecutiveSummary: boolean;
|
||||||
|
showMetricsTable: boolean;
|
||||||
|
showPilgrimCapture: boolean;
|
||||||
|
// Trend chart
|
||||||
|
showTrendChart: boolean;
|
||||||
|
trendMetrics: TrendMetric[];
|
||||||
|
// Museum mini-reports
|
||||||
|
showMuseumRevenue: boolean;
|
||||||
|
showMuseumVisitors: boolean;
|
||||||
|
showMuseumTickets: boolean;
|
||||||
|
// Channel breakdowns
|
||||||
|
showChannelRevenue: boolean;
|
||||||
|
showChannelVisitors: boolean;
|
||||||
|
showChannelTickets: boolean;
|
||||||
|
// District breakdowns
|
||||||
|
showDistrictRevenue: boolean;
|
||||||
|
showDistrictVisitors: boolean;
|
||||||
|
showDistrictTickets: boolean;
|
||||||
|
// Global summary table
|
||||||
|
showGlobalSummary: boolean;
|
||||||
|
// Presentation
|
||||||
|
language: 'en' | 'ar';
|
||||||
|
confidentiality: 'Confidential' | 'Internal' | 'Public';
|
||||||
|
orientation: 'portrait' | 'landscape';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIG: ReportConfig = {
|
||||||
|
title: '',
|
||||||
|
clientName: '',
|
||||||
|
contactName: '',
|
||||||
|
clientLogoBase64: null,
|
||||||
|
accentColor: '#2563eb',
|
||||||
|
startDate: _start,
|
||||||
|
endDate: _end,
|
||||||
|
selectedMuseums: [],
|
||||||
|
selectedChannels: [],
|
||||||
|
includeVAT: true,
|
||||||
|
includeComparison: true,
|
||||||
|
comparisonStartDate: shiftYear(_start),
|
||||||
|
comparisonEndDate: shiftYear(_end),
|
||||||
|
showExecutiveSummary: true,
|
||||||
|
showMetricsTable: true,
|
||||||
|
showPilgrimCapture: true,
|
||||||
|
showTrendChart: true,
|
||||||
|
trendMetrics: ['revenue'],
|
||||||
|
showMuseumRevenue: true,
|
||||||
|
showMuseumVisitors: true,
|
||||||
|
showMuseumTickets: false,
|
||||||
|
showChannelRevenue: false,
|
||||||
|
showChannelVisitors: true,
|
||||||
|
showChannelTickets: false,
|
||||||
|
showDistrictRevenue: false,
|
||||||
|
showDistrictVisitors: false,
|
||||||
|
showDistrictTickets: false,
|
||||||
|
showGlobalSummary: true,
|
||||||
|
language: 'en',
|
||||||
|
confidentiality: 'Confidential',
|
||||||
|
orientation: 'portrait',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── computed report data ─────────────────────────────────────────
|
||||||
|
export interface BreakdownItem { name: string; value: number; }
|
||||||
|
|
||||||
|
export interface DimensionBreakdown {
|
||||||
|
revenue: BreakdownItem[];
|
||||||
|
visitors: BreakdownItem[];
|
||||||
|
tickets: BreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MuseumDataRow {
|
||||||
|
name: string;
|
||||||
|
curr: { revenue: number; visitors: number; tickets: number };
|
||||||
|
prev: { revenue: number; visitors: number; tickets: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendChart {
|
||||||
|
metric: TrendMetric;
|
||||||
|
labels: string[];
|
||||||
|
current: number[];
|
||||||
|
previous: number[] | null;
|
||||||
|
museums: Array<{ name: string; values: number[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportData {
|
||||||
|
config: ReportConfig;
|
||||||
|
metrics: Metrics;
|
||||||
|
prevMetrics: Metrics | null;
|
||||||
|
comparisonPeriodLabel: string;
|
||||||
|
trendCharts: TrendChart[];
|
||||||
|
museumData: MuseumDataRow[];
|
||||||
|
museumBreakdown: DimensionBreakdown;
|
||||||
|
channelBreakdown: DimensionBreakdown;
|
||||||
|
districtBreakdown: DimensionBreakdown;
|
||||||
|
pilgrimCapture: { current: number; previous: number | null } | null;
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── data computation ─────────────────────────────────────────────
|
||||||
|
function applyDimFilters(rows: MuseumRecord[], cfg: ReportConfig): MuseumRecord[] {
|
||||||
|
let d = rows;
|
||||||
|
if (cfg.selectedMuseums.length) d = d.filter(r => cfg.selectedMuseums.includes(r.museum_name));
|
||||||
|
if (cfg.selectedChannels.length) d = d.filter(r => cfg.selectedChannels.includes(r.channel));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimatePilgrims(start: string, end: string): number | null {
|
||||||
|
const sd = new Date(start), ed = new Date(end);
|
||||||
|
let total = 0, has = false;
|
||||||
|
for (let y = sd.getFullYear(); y <= ed.getFullYear(); y++) {
|
||||||
|
for (let q = 1; q <= 4; q++) {
|
||||||
|
const qs = new Date(y, (q - 1) * 3, 1), qe = new Date(y, q * 3, 0);
|
||||||
|
if (qe < sd || qs > ed) continue;
|
||||||
|
const p = (umrahData as any)[y]?.[q];
|
||||||
|
if (!p) continue;
|
||||||
|
const os = new Date(Math.max(qs.getTime(), sd.getTime()));
|
||||||
|
const oe = new Date(Math.min(qe.getTime(), ed.getTime()));
|
||||||
|
total += p * ((oe.getTime() - os.getTime()) / 86400000 + 1) /
|
||||||
|
((qe.getTime() - qs.getTime()) / 86400000 + 1);
|
||||||
|
has = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return has ? Math.round(total) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetricVal(r: MuseumRecord, metric: TrendMetric, includeVAT: boolean): number {
|
||||||
|
if (metric === 'visitors') return r.visits || 0;
|
||||||
|
if (metric === 'tickets') return r.tickets || 0;
|
||||||
|
return (includeVAT ? r.revenue_gross : r.revenue_net) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTH_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
|
||||||
|
function buildTrend(rows: MuseumRecord[], start: string, metric: TrendMetric, includeVAT: boolean, gran: TrendGranularity): { labels: string[]; values: number[] } {
|
||||||
|
const s = new Date(start);
|
||||||
|
const acc: Record<number, MuseumRecord[]> = {};
|
||||||
|
rows.forEach(r => {
|
||||||
|
if (!r.date) return;
|
||||||
|
const diff = Math.floor((new Date(r.date).getTime() - s.getTime()) / 86400000);
|
||||||
|
const key = gran === 'month' ? Math.floor(diff / 30) + 1 : gran === 'week' ? Math.floor(diff / 7) + 1 : diff + 1;
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(r);
|
||||||
|
});
|
||||||
|
const maxK = Math.max(...Object.keys(acc).map(Number), 1);
|
||||||
|
const labels = Array.from({ length: maxK }, (_, i) => {
|
||||||
|
if (gran === 'month') return MONTH_SHORT[(s.getMonth() + i) % 12];
|
||||||
|
if (gran === 'week') return `W${i + 1}`;
|
||||||
|
return `${i + 1}`;
|
||||||
|
});
|
||||||
|
const values = labels.map((_, i) => {
|
||||||
|
const group = acc[i + 1] || [];
|
||||||
|
return group.reduce((sum, r) => sum + getMetricVal(r, metric, includeVAT), 0);
|
||||||
|
});
|
||||||
|
return { labels, values };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDimensionBreakdown(g: Record<string, { revenue: number; visitors: number; tickets: number }>, limit = 10): DimensionBreakdown {
|
||||||
|
const entries = Object.entries(g);
|
||||||
|
const sort = (key: 'revenue' | 'visitors' | 'tickets') =>
|
||||||
|
entries.map(([name, v]) => ({ name, value: v[key] })).sort((a, b) => b.value - a.value).slice(0, limit);
|
||||||
|
return { revenue: sort('revenue'), visitors: sort('visitors'), tickets: sort('tickets') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): ReportData {
|
||||||
|
const currRows = applyDimFilters(filterDataByDateRange(allData, cfg.startDate, cfg.endDate, {}), cfg);
|
||||||
|
const metrics = calculateMetrics(currRows, cfg.includeVAT);
|
||||||
|
|
||||||
|
const prevRows = cfg.includeComparison
|
||||||
|
? applyDimFilters(filterDataByDateRange(allData, cfg.comparisonStartDate, cfg.comparisonEndDate, {}), cfg)
|
||||||
|
: [];
|
||||||
|
const prevMetrics = cfg.includeComparison ? calculateMetrics(prevRows, cfg.includeVAT) : null;
|
||||||
|
|
||||||
|
const comparisonPeriodLabel = cfg.includeComparison
|
||||||
|
? formatPeriodLabel(cfg.comparisonStartDate, cfg.comparisonEndDate, cfg.language)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const gran = inferGranularity(cfg.startDate, cfg.endDate);
|
||||||
|
const museumNames = Object.keys(groupByMuseum(currRows, cfg.includeVAT))
|
||||||
|
.filter(name => currRows.some(r => r.museum_name === name));
|
||||||
|
|
||||||
|
const trendCharts: TrendChart[] = cfg.trendMetrics.map(metric => {
|
||||||
|
const currT = buildTrend(currRows, cfg.startDate, metric, cfg.includeVAT, gran);
|
||||||
|
const prevT = cfg.includeComparison
|
||||||
|
? buildTrend(prevRows, cfg.comparisonStartDate, metric, cfg.includeVAT, gran)
|
||||||
|
: null;
|
||||||
|
const maxLen = Math.max(currT.labels.length, prevT ? prevT.values.length : 0, 1);
|
||||||
|
const labels = Array.from({ length: maxLen }, (_, i) => currT.labels[i] ?? `${i + 1}`);
|
||||||
|
const current = Array.from({ length: maxLen }, (_, i) => currT.values[i] ?? 0);
|
||||||
|
const previous = prevT ? Array.from({ length: maxLen }, (_, i) => prevT.values[i] ?? 0) : null;
|
||||||
|
const museums = museumNames.map(name => {
|
||||||
|
const mt = buildTrend(currRows.filter(r => r.museum_name === name), cfg.startDate, metric, cfg.includeVAT, gran);
|
||||||
|
return { name, values: Array.from({ length: maxLen }, (_, i) => mt.values[i] ?? 0) };
|
||||||
|
}).filter(m => m.values.some(v => v > 0));
|
||||||
|
return { metric, labels, current, previous, museums };
|
||||||
|
});
|
||||||
|
|
||||||
|
const currMuseumGroups = groupByMuseum(currRows, cfg.includeVAT);
|
||||||
|
const prevMuseumGroups = cfg.includeComparison ? groupByMuseum(prevRows, cfg.includeVAT) : {};
|
||||||
|
const museumData: MuseumDataRow[] = Object.entries(currMuseumGroups)
|
||||||
|
.map(([name, curr]) => ({ name, curr, prev: prevMuseumGroups[name] ?? null }))
|
||||||
|
.sort((a, b) => b.curr.revenue - a.curr.revenue);
|
||||||
|
|
||||||
|
const museumBreakdown = makeDimensionBreakdown(currMuseumGroups);
|
||||||
|
const channelBreakdown = makeDimensionBreakdown(groupByChannel(currRows, cfg.includeVAT), 20);
|
||||||
|
const districtBreakdown = makeDimensionBreakdown(groupByDistrict(currRows, cfg.includeVAT));
|
||||||
|
|
||||||
|
const currPilgrims = estimatePilgrims(cfg.startDate, cfg.endDate);
|
||||||
|
const prevPilgrims = cfg.includeComparison
|
||||||
|
? estimatePilgrims(cfg.comparisonStartDate, cfg.comparisonEndDate)
|
||||||
|
: null;
|
||||||
|
const pilgrimCapture = currPilgrims !== null
|
||||||
|
? {
|
||||||
|
current: parseFloat(((metrics.visitors / currPilgrims) * 100).toFixed(2)),
|
||||||
|
previous: prevPilgrims && prevMetrics
|
||||||
|
? parseFloat(((prevMetrics.visitors / prevPilgrims) * 100).toFixed(2))
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: cfg,
|
||||||
|
metrics,
|
||||||
|
prevMetrics,
|
||||||
|
comparisonPeriodLabel,
|
||||||
|
trendCharts,
|
||||||
|
museumData,
|
||||||
|
museumBreakdown,
|
||||||
|
channelBreakdown,
|
||||||
|
districtBreakdown,
|
||||||
|
pilgrimCapture,
|
||||||
|
generatedAt: new Date().toLocaleDateString('en-GB'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── formatters ───────────────────────────────────────────────────
|
||||||
|
export function formatCurrency(n: number, inclVAT: boolean): string {
|
||||||
|
return `SAR ${n.toLocaleString('en-SA', { maximumFractionDigits: 0 })}${inclVAT ? '' : ' (ex-VAT)'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPct(change: number): string {
|
||||||
|
return `${change >= 0 ? '+' : ''}${change.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPeriodLabel(start: string, end: string, lang: 'en' | 'ar'): string {
|
||||||
|
const months = lang === 'en'
|
||||||
|
? ['January','February','March','April','May','June','July','August','September','October','November','December']
|
||||||
|
: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'];
|
||||||
|
const s = new Date(start), e = new Date(end);
|
||||||
|
const sm = months[s.getMonth()], em = months[e.getMonth()];
|
||||||
|
const sy = s.getFullYear(), ey = e.getFullYear();
|
||||||
|
if (sy === ey && sm === em) return `${sm} ${sy}`;
|
||||||
|
if (sy === ey) return `${sm} – ${em} ${sy}`;
|
||||||
|
return `${sm} ${sy} – ${em} ${ey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── executive summary ────────────────────────────────────────────
|
||||||
|
export function generateExecutiveSummary(data: ReportData): string {
|
||||||
|
const { config: cfg, metrics, prevMetrics, channelBreakdown, comparisonPeriodLabel } = data;
|
||||||
|
const lang = cfg.language;
|
||||||
|
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
|
||||||
|
const revenue = formatCurrency(metrics.revenue, cfg.includeVAT);
|
||||||
|
const topChannel = channelBreakdown.visitors[0]?.name ?? '';
|
||||||
|
const totalVisitors = channelBreakdown.visitors.reduce((s, i) => s + i.value, 0);
|
||||||
|
const topPct = totalVisitors > 0 && channelBreakdown.visitors[0]
|
||||||
|
? Math.round((channelBreakdown.visitors[0].value / totalVisitors) * 100)
|
||||||
|
: 0;
|
||||||
|
const museumLabel = cfg.selectedMuseums.length > 0
|
||||||
|
? cfg.selectedMuseums.join(', ')
|
||||||
|
: (lang === 'en' ? 'all museums' : 'جميع المتاحف');
|
||||||
|
|
||||||
|
if (lang === 'en') {
|
||||||
|
let s = `During ${period}, ${museumLabel} recorded ${metrics.visitors.toLocaleString()} visitors and ${revenue} in revenue.`;
|
||||||
|
if (prevMetrics && prevMetrics.revenue > 0) {
|
||||||
|
const chg = Math.round(((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue) * 100);
|
||||||
|
s += ` This represents a ${formatPct(chg)} change in revenue versus ${comparisonPeriodLabel}.`;
|
||||||
|
}
|
||||||
|
if (topChannel) s += ` The top-performing channel was ${topChannel} with ${topPct}% of total visitors.`;
|
||||||
|
return s;
|
||||||
|
} else {
|
||||||
|
let s = `خلال ${period}، سجّلت ${museumLabel} ${metrics.visitors.toLocaleString()} زائراً وإيرادات بلغت ${revenue}.`;
|
||||||
|
if (prevMetrics && prevMetrics.revenue > 0) {
|
||||||
|
const chg = Math.round(((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue) * 100);
|
||||||
|
s += ` يمثّل ذلك تغيّراً بنسبة ${formatPct(chg)} في الإيرادات مقارنةً بـ${comparisonPeriodLabel}.`;
|
||||||
|
}
|
||||||
|
if (topChannel) s += ` كانت ${topChannel} أعلى القنوات أداءً بنسبة ${topPct}% من إجمالي الزوار.`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── page count estimator ─────────────────────────────────────────
|
||||||
|
export function estimatePageCount(cfg: ReportConfig): number {
|
||||||
|
let pages = 2; // cover + summary/metrics/trend
|
||||||
|
if (cfg.showMuseumRevenue || cfg.showMuseumVisitors || cfg.showMuseumTickets) pages += 1;
|
||||||
|
if (cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets) pages += 1;
|
||||||
|
if (cfg.showDistrictRevenue || cfg.showDistrictVisitors || cfg.showDistrictTickets) pages += 1;
|
||||||
|
if (cfg.showGlobalSummary && cfg.includeComparison) pages += 1;
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
+128
-161
@@ -6,13 +6,13 @@ import type { Season } from '../types';
|
|||||||
|
|
||||||
const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899'];
|
const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899'];
|
||||||
|
|
||||||
interface SeasonRowProps {
|
interface SeasonItemProps {
|
||||||
season: Season;
|
season: Season;
|
||||||
onSave: (id: number, data: Partial<Season>) => Promise<void>;
|
onSave: (id: number, data: Partial<Season>) => Promise<void>;
|
||||||
onDelete: (id: number) => Promise<void>;
|
onDelete: (id: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SeasonRow({ season, onSave, onDelete }: SeasonRowProps) {
|
function SeasonItem({ season, onSave, onDelete }: SeasonItemProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [form, setForm] = useState(season);
|
const [form, setForm] = useState(season);
|
||||||
|
|
||||||
@@ -21,48 +21,46 @@ function SeasonRow({ season, onSave, onDelete }: SeasonRowProps) {
|
|||||||
setEditing(false);
|
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 (
|
return (
|
||||||
<tr className="editing">
|
<div className={`settings-item${editing ? ' settings-item--editing' : ''}`}>
|
||||||
<td>
|
<div className="settings-item-row">
|
||||||
<div className="season-edit-name">
|
<span className="season-chip" style={{ backgroundColor: season.Color + '20', color: season.Color, borderColor: season.Color }}>
|
||||||
<input type="text" value={form.Name} onChange={e => setForm({ ...form, Name: e.target.value })} placeholder="Name" />
|
{season.Name} {season.HijriYear}
|
||||||
<input type="number" value={form.HijriYear} onChange={e => setForm({ ...form, HijriYear: parseInt(e.target.value) || 0 })} placeholder="Year" style={{ width: 80 }} />
|
</span>
|
||||||
<input type="color" value={form.Color} onChange={e => setForm({ ...form, Color: e.target.value })} />
|
<span className="settings-dates">{season.StartDate} → {season.EndDate}</span>
|
||||||
|
<div className="settings-item-actions">
|
||||||
|
<button className="btn-small" onClick={() => { setForm(season); setEditing(true); }}>Edit</button>
|
||||||
|
<button className="btn-small btn-danger" onClick={() => onDelete(season.Id!)}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td><input type="date" value={form.StartDate} onChange={e => setForm({ ...form, StartDate: e.target.value })} /></td>
|
{editing && (
|
||||||
<td><input type="date" value={form.EndDate} onChange={e => setForm({ ...form, EndDate: e.target.value })} /></td>
|
<div className="settings-item-form">
|
||||||
<td>
|
<div className="form-row">
|
||||||
<div className="season-actions">
|
<input className="form-input" type="text" value={form.Name} onChange={e => setForm({ ...form, Name: e.target.value })} placeholder="Name" />
|
||||||
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
|
<input className="form-input form-input--sm" type="number" value={form.HijriYear} onChange={e => setForm({ ...form, HijriYear: parseInt(e.target.value) || 0 })} placeholder="Hijri Year" />
|
||||||
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
|
<input type="color" className="form-color" value={form.Color} onChange={e => setForm({ ...form, Color: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-field">
|
||||||
|
<span className="form-label">Start</span>
|
||||||
|
<input className="form-input" type="date" value={form.StartDate} onChange={e => setForm({ ...form, StartDate: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
<span className="form-label">End</span>
|
||||||
|
<input className="form-input" type="date" value={form.EndDate} onChange={e => setForm({ ...form, EndDate: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
|
||||||
|
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
)}
|
||||||
</tr>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserRowProps {
|
interface UserItemProps {
|
||||||
user: User;
|
user: User;
|
||||||
allMuseums: string[];
|
allMuseums: string[];
|
||||||
allChannels: string[];
|
allChannels: string[];
|
||||||
@@ -70,7 +68,7 @@ interface UserRowProps {
|
|||||||
onDelete: (id: number) => Promise<void>;
|
onDelete: (id: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowProps) {
|
function UserItem({ user, allMuseums, allChannels, onUpdate, onDelete }: UserItemProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [allowedMuseums, setAllowedMuseums] = useState<string[]>(() => {
|
const [allowedMuseums, setAllowedMuseums] = useState<string[]>(() => {
|
||||||
try { return JSON.parse(user.AllowedMuseums || '[]'); } catch { return []; }
|
try { return JSON.parse(user.AllowedMuseums || '[]'); } catch { return []; }
|
||||||
@@ -91,17 +89,18 @@ function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowP
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isAdmin = user.Role === 'admin';
|
const isAdmin = user.Role === 'admin';
|
||||||
|
|
||||||
const museumCount = (() => { try { const a = JSON.parse(user.AllowedMuseums || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
|
const museumCount = (() => { try { const a = JSON.parse(user.AllowedMuseums || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
|
||||||
const channelCount = (() => { try { const a = JSON.parse(user.AllowedChannels || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
|
const channelCount = (() => { try { const a = JSON.parse(user.AllowedChannels || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
|
||||||
|
|
||||||
if (!editing) {
|
return (
|
||||||
return (
|
<div className={`settings-item${editing ? ' settings-item--editing' : ''}`}>
|
||||||
<tr key={user.Id}>
|
<div className="settings-item-row">
|
||||||
<td>{user.Name}</td>
|
<div className="settings-user-info">
|
||||||
<td><code>{user.PIN}</code></td>
|
<span className="settings-user-name">{user.Name}</span>
|
||||||
<td>{user.Role}</td>
|
<code className="settings-user-pin">{user.PIN}</code>
|
||||||
<td>
|
<span className="settings-user-role">{user.Role}</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-user-access">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<span className="access-badge access-badge--full">Full access</span>
|
<span className="access-badge access-badge--full">Full access</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -110,53 +109,45 @@ function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowP
|
|||||||
<span className="access-badge">{channelCount === 0 ? 'All channels' : `${channelCount} channels`}</span>
|
<span className="access-badge">{channelCount === 0 ? 'All channels' : `${channelCount} channels`}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
<td>
|
<div className="settings-item-actions">
|
||||||
<div className="season-actions">
|
{!isAdmin && <button className="btn-small" onClick={() => setEditing(true)}>Edit access</button>}
|
||||||
{!isAdmin && <button className="btn-small" onClick={() => setEditing(true)}>Edit access</button>}
|
<button className="btn-small btn-danger" onClick={() => onDelete(user.Id!)}>Delete</button>
|
||||||
<button className="btn-small btn-danger" onClick={() => onDelete(user.Id!)}>Delete</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
{editing && (
|
||||||
</tr>
|
<div className="settings-item-form">
|
||||||
);
|
<div className="access-columns">
|
||||||
}
|
<div className="access-col">
|
||||||
|
<div className="access-col-title">
|
||||||
return (
|
Events {allowedMuseums.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
||||||
<tr className="editing">
|
|
||||||
<td colSpan={5}>
|
|
||||||
<div style={{ padding: '12px 4px' }}>
|
|
||||||
<strong>{user.Name}</strong>
|
|
||||||
<div style={{ display: 'flex', gap: 32, marginTop: 12, flexWrap: 'wrap' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
|
||||||
Allowed Events {allowedMuseums.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
|
||||||
</div>
|
</div>
|
||||||
{allMuseums.map(m => (
|
{allMuseums.map(m => (
|
||||||
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, cursor: 'pointer' }}>
|
<label key={m} className="access-check">
|
||||||
<input type="checkbox" checked={allowedMuseums.includes(m)} onChange={() => toggleItem(allowedMuseums, setAllowedMuseums, m)} />
|
<input type="checkbox" checked={allowedMuseums.includes(m)} onChange={() => toggleItem(allowedMuseums, setAllowedMuseums, m)} />
|
||||||
{m}
|
{m}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="access-col">
|
||||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
<div className="access-col-title">
|
||||||
Allowed Channels {allowedChannels.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
Channels {allowedChannels.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
||||||
</div>
|
</div>
|
||||||
{allChannels.map(c => (
|
{allChannels.map(c => (
|
||||||
<label key={c} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, cursor: 'pointer' }}>
|
<label key={c} className="access-check">
|
||||||
<input type="checkbox" checked={allowedChannels.includes(c)} onChange={() => toggleItem(allowedChannels, setAllowedChannels, c)} />
|
<input type="checkbox" checked={allowedChannels.includes(c)} onChange={() => toggleItem(allowedChannels, setAllowedChannels, c)} />
|
||||||
{c}
|
{c}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
<div className="form-actions">
|
||||||
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
|
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
|
||||||
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
|
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
)}
|
||||||
</tr>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +164,7 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
|||||||
|
|
||||||
const [newSeason, setNewSeason] = useState<Omit<Season, 'Id'>>({
|
const [newSeason, setNewSeason] = useState<Omit<Season, 'Id'>>({
|
||||||
Name: '',
|
Name: '',
|
||||||
HijriYear: new Date().getFullYear() - 579, // rough Gregorian → Hijri
|
HijriYear: new Date().getFullYear() - 579,
|
||||||
StartDate: '',
|
StartDate: '',
|
||||||
EndDate: '',
|
EndDate: '',
|
||||||
Color: DEFAULT_COLORS[0],
|
Color: DEFAULT_COLORS[0],
|
||||||
@@ -238,42 +229,36 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
|||||||
<h2>{t('settings.seasons')}</h2>
|
<h2>{t('settings.seasons')}</h2>
|
||||||
<p className="settings-hint">{t('settings.seasonsHint')}</p>
|
<p className="settings-hint">{t('settings.seasonsHint')}</p>
|
||||||
|
|
||||||
<div className="table-container">
|
<div className="settings-list">
|
||||||
<table>
|
{loading ? (
|
||||||
<thead>
|
<div className="settings-loading">Loading...</div>
|
||||||
<tr>
|
) : (
|
||||||
<th>{t('settings.seasonName')}</th>
|
seasons.map(s => (
|
||||||
<th>{t('settings.startDate')}</th>
|
<SeasonItem key={s.Id} season={s} onSave={handleSave} onDelete={handleDelete} />
|
||||||
<th>{t('settings.endDate')}</th>
|
))
|
||||||
<th>{t('settings.actions')}</th>
|
)}
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
|
||||||
<tbody>
|
<div className="settings-add-form">
|
||||||
{loading ? (
|
<div className="settings-add-title">{t('settings.add')} Season</div>
|
||||||
<tr><td colSpan={4} style={{ textAlign: 'center', padding: 24 }}>Loading...</td></tr>
|
<div className="form-row">
|
||||||
) : (
|
<input className="form-input" type="text" value={newSeason.Name} onChange={e => setNewSeason({ ...newSeason, Name: e.target.value })} placeholder={t('settings.namePlaceholder')} />
|
||||||
seasons.map(s => (
|
<input className="form-input form-input--sm" type="number" value={newSeason.HijriYear} onChange={e => setNewSeason({ ...newSeason, HijriYear: parseInt(e.target.value) || 0 })} />
|
||||||
<SeasonRow key={s.Id} season={s} onSave={handleSave} onDelete={handleDelete} />
|
<input type="color" className="form-color" value={newSeason.Color} onChange={e => setNewSeason({ ...newSeason, Color: e.target.value })} />
|
||||||
))
|
</div>
|
||||||
)}
|
<div className="form-row">
|
||||||
<tr className="add-row">
|
<label className="form-field">
|
||||||
<td>
|
<span className="form-label">{t('settings.startDate')}</span>
|
||||||
<div className="season-edit-name">
|
<input className="form-input" type="date" value={newSeason.StartDate} onChange={e => setNewSeason({ ...newSeason, StartDate: e.target.value })} />
|
||||||
<input type="text" value={newSeason.Name} onChange={e => setNewSeason({ ...newSeason, Name: e.target.value })} placeholder={t('settings.namePlaceholder')} />
|
</label>
|
||||||
<input type="number" value={newSeason.HijriYear} onChange={e => setNewSeason({ ...newSeason, HijriYear: parseInt(e.target.value) || 0 })} style={{ width: 80 }} />
|
<label className="form-field">
|
||||||
<input type="color" value={newSeason.Color} onChange={e => setNewSeason({ ...newSeason, Color: e.target.value })} />
|
<span className="form-label">{t('settings.endDate')}</span>
|
||||||
</div>
|
<input className="form-input" type="date" value={newSeason.EndDate} onChange={e => setNewSeason({ ...newSeason, EndDate: e.target.value })} />
|
||||||
</td>
|
</label>
|
||||||
<td><input type="date" value={newSeason.StartDate} onChange={e => setNewSeason({ ...newSeason, StartDate: e.target.value })} /></td>
|
</div>
|
||||||
<td><input type="date" value={newSeason.EndDate} onChange={e => setNewSeason({ ...newSeason, EndDate: e.target.value })} /></td>
|
<button className="btn-small btn-primary" onClick={handleCreate} disabled={!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate}>
|
||||||
<td>
|
{t('settings.add')}
|
||||||
<button className="btn-small btn-primary" onClick={handleCreate} disabled={!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate}>
|
</button>
|
||||||
{t('settings.add')}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -281,55 +266,37 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
|||||||
<h2>{t('settings.users')}</h2>
|
<h2>{t('settings.users')}</h2>
|
||||||
<p className="settings-hint">{t('settings.usersHint')}</p>
|
<p className="settings-hint">{t('settings.usersHint')}</p>
|
||||||
|
|
||||||
<div className="table-container">
|
<div className="settings-list">
|
||||||
<table>
|
{users.map(u => (
|
||||||
<thead>
|
<UserItem
|
||||||
<tr>
|
key={u.Id}
|
||||||
<th>{t('settings.userName')}</th>
|
user={u}
|
||||||
<th>{t('settings.userPin')}</th>
|
allMuseums={allMuseums}
|
||||||
<th>{t('settings.userRole')}</th>
|
allChannels={allChannels}
|
||||||
<th>Access</th>
|
onUpdate={handleUpdateUser}
|
||||||
<th>{t('settings.actions')}</th>
|
onDelete={async (id) => { await deleteUser(id); await loadUsers(); }}
|
||||||
</tr>
|
/>
|
||||||
</thead>
|
))}
|
||||||
<tbody>
|
</div>
|
||||||
{users.map(u => (
|
|
||||||
<UserRow
|
<div className="settings-add-form">
|
||||||
key={u.Id}
|
<div className="settings-add-title">{t('settings.add')} User</div>
|
||||||
user={u}
|
<div className="form-row">
|
||||||
allMuseums={allMuseums}
|
<input className="form-input" type="text" value={newUser.Name} onChange={e => setNewUser({ ...newUser, Name: e.target.value })} placeholder={t('settings.userNamePlaceholder')} />
|
||||||
allChannels={allChannels}
|
<input className="form-input form-input--sm" type="text" value={newUser.PIN} onChange={e => setNewUser({ ...newUser, PIN: e.target.value })} placeholder="PIN" />
|
||||||
onUpdate={handleUpdateUser}
|
<select className="form-input form-input--sm" value={newUser.Role} onChange={e => setNewUser({ ...newUser, Role: e.target.value })}>
|
||||||
onDelete={async (id) => { await deleteUser(id); await loadUsers(); }}
|
<option value="viewer">Viewer</option>
|
||||||
/>
|
<option value="admin">Admin</option>
|
||||||
))}
|
</select>
|
||||||
<tr className="add-row">
|
</div>
|
||||||
<td>
|
<button className="btn-small btn-primary" onClick={async () => {
|
||||||
<input type="text" value={newUser.Name} onChange={e => setNewUser({ ...newUser, Name: e.target.value })} placeholder={t('settings.userNamePlaceholder')} />
|
if (!newUser.Name || !newUser.PIN) return;
|
||||||
</td>
|
await createUser(newUser);
|
||||||
<td>
|
setNewUser({ Name: '', PIN: '', Role: 'viewer', AllowedMuseums: '[]', AllowedChannels: '[]' });
|
||||||
<input type="text" value={newUser.PIN} onChange={e => setNewUser({ ...newUser, PIN: e.target.value })} placeholder="PIN" />
|
await loadUsers();
|
||||||
</td>
|
}} disabled={!newUser.Name || !newUser.PIN}>
|
||||||
<td>
|
{t('settings.add')}
|
||||||
<select value={newUser.Role} onChange={e => setNewUser({ ...newUser, Role: e.target.value })}>
|
</button>
|
||||||
<option value="viewer">Viewer</option>
|
|
||||||
<option value="admin">Admin</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
<td>
|
|
||||||
<button className="btn-small btn-primary" onClick={async () => {
|
|
||||||
if (!newUser.Name || !newUser.PIN) return;
|
|
||||||
await createUser(newUser);
|
|
||||||
setNewUser({ Name: '', PIN: '', Role: 'viewer', AllowedMuseums: '[]', AllowedChannels: '[]' });
|
|
||||||
await loadUsers();
|
|
||||||
}} disabled={!newUser.Name || !newUser.PIN}>
|
|
||||||
{t('settings.add')}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
// ─── multi-select ─────────────────────────────────────────────────
|
||||||
|
export default function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
|
||||||
|
value: string[]; options: string[];
|
||||||
|
onChange: (vals: string[]) => void;
|
||||||
|
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v !== opt) : [...value, opt]);
|
||||||
|
const label = value.length === 0 ? allLabel : value.length === 1 ? value[0] : countLabel(value.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="altms">
|
||||||
|
<button type="button" className={`altms-trigger${value.length > 0 ? ' altms-trigger--active' : ''}`} onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox">
|
||||||
|
<span className="altms-label">{label}</span>
|
||||||
|
<svg className={`altms-chevron${open ? ' altms-chevron--open' : ''}`} width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||||
|
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="altms-dropdown" role="listbox" aria-multiselectable="true">
|
||||||
|
<div className="altms-list">
|
||||||
|
{options.map(opt => (
|
||||||
|
<label key={opt} role="option" aria-selected={value.includes(opt)} className={`altms-option${value.includes(opt) ? ' altms-option--checked' : ''}`}>
|
||||||
|
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
|
||||||
|
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
|
||||||
|
<span className="altms-opt-label">{opt}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{value.length > 0 && <button type="button" className="altms-clear" onClick={() => { onChange([]); setOpen(false); }}>{clearLabel}</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { formatCurrency, formatNumber } from '../../services/dataService';
|
||||||
|
|
||||||
|
// ─── metric card ──────────────────────────────────────────────────
|
||||||
|
export default function MetricCard({ title, curr, prev, isCurrency, newLabel }: {
|
||||||
|
title: string; curr: number; prev: number; isCurrency?: boolean; newLabel?: string;
|
||||||
|
}) {
|
||||||
|
const fmt = (n: number) => isCurrency ? formatCurrency(n) : formatNumber(n);
|
||||||
|
const change = prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
||||||
|
const isPos = change > 0, isNeg = change < 0;
|
||||||
|
return (
|
||||||
|
<div className="alt-metric">
|
||||||
|
<p className="alt-metric-title">{title}</p>
|
||||||
|
<div className="alt-metric-value">{fmt(curr)}</div>
|
||||||
|
<div className="alt-metric-footer">
|
||||||
|
{isFinite(change)
|
||||||
|
? <span className={`alt-change ${isPos ? 'alt-change--up' : isNeg ? 'alt-change--down' : 'alt-change--flat'}`}>{isPos ? '▲' : isNeg ? '▼' : '—'} {Math.abs(change).toFixed(1)}%</span>
|
||||||
|
: <span className="alt-change alt-change--up">{newLabel ?? 'New'}</span>}
|
||||||
|
<span className="alt-metric-prev">{fmt(prev)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import type { LC } from '../../lib/locale';
|
||||||
|
import { MONTH_KEYS, makePresets, guessPreset, periodNameL, dateRangeTextL } from '../../lib/dateHelpers';
|
||||||
|
|
||||||
|
// ─── inline picker ────────────────────────────────────────────────
|
||||||
|
export function InlinePicker({ start, end, onChange, onClose, availableYears, L }: {
|
||||||
|
start: string; end: string; onChange: (s: string, e: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
availableYears: number[]; L: LC;
|
||||||
|
}) {
|
||||||
|
const g = guessPreset(start, end);
|
||||||
|
const [year, setYear] = useState(g?.year ?? parseInt(start.slice(0, 4)));
|
||||||
|
const [active, setActive] = useState<string | null>(g?.key ?? null);
|
||||||
|
const [draftStart, setDraftStart] = useState(start);
|
||||||
|
const [draftEnd, setDraftEnd] = useState(end);
|
||||||
|
const minY = Math.min(...availableYears), maxY = Math.max(...availableYears);
|
||||||
|
|
||||||
|
const pick = (key: string) => { const r = makePresets(year)[key]; if (!r) return; setActive(key); setDraftStart(r.start); setDraftEnd(r.end); };
|
||||||
|
const shift = (d: number) => {
|
||||||
|
const ny = year + d; if (ny < minY || ny > maxY) return; setYear(ny);
|
||||||
|
if (active && makePresets(ny)[active]) { setDraftStart(makePresets(ny)[active].start); setDraftEnd(makePresets(ny)[active].end); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="alt-picker" id="period-picker-panel">
|
||||||
|
<div className="alt-picker-year">
|
||||||
|
<button type="button" onClick={() => shift(L.dir === 'rtl' ? 1 : -1)} disabled={L.dir === 'rtl' ? year >= maxY : year <= minY} className="alt-yr-btn">
|
||||||
|
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M5.5 9.5L1.5 5.5L5.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<span className="alt-yr-val">{year}</span>
|
||||||
|
<button type="button" onClick={() => shift(L.dir === 'rtl' ? -1 : 1)} disabled={L.dir === 'rtl' ? year <= minY : year >= maxY} className="alt-yr-btn">
|
||||||
|
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M1.5 1.5L5.5 5.5L1.5 9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="alt-picker-section">{L.monthSection}</p>
|
||||||
|
<div className="alt-chips">
|
||||||
|
{MONTH_KEYS.map((k, i) => (
|
||||||
|
<button key={k} type="button" className={`alt-chip${active === k ? ' alt-chip-on' : ''}`} onClick={() => pick(k)}>{L.monthShort[i]}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="alt-picker-section">{L.periodSection}</p>
|
||||||
|
<div className="alt-chips">
|
||||||
|
{['q1','q2','q3','q4','h1','h2'].map(k => (
|
||||||
|
<button key={k} type="button" className={`alt-chip${active === k ? ' alt-chip-on' : ''}`} onClick={() => pick(k)}>{L.periods[k]}</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className={`alt-chip alt-chip-wide${active === 'full' ? ' alt-chip-on' : ''}`} onClick={() => pick('full')}>{L.periods.full}</button>
|
||||||
|
</div>
|
||||||
|
<div className="alt-picker-div" />
|
||||||
|
<div className="alt-custom">
|
||||||
|
<div className="alt-custom-f"><label>{L.from}</label><input type="date" value={draftStart} onChange={e => { setActive(null); setDraftStart(e.target.value); }} /></div>
|
||||||
|
<span className="alt-custom-arrow">{L.dateRangeSep}</span>
|
||||||
|
<div className="alt-custom-f"><label>{L.to}</label><input type="date" value={draftEnd} onChange={e => { setActive(null); setDraftEnd(e.target.value); }} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="alt-picker-div" />
|
||||||
|
<div className="alt-footer">
|
||||||
|
<button type="button" className="alt-cancel" onClick={onClose}>{L.close}</button>
|
||||||
|
<button type="button" className="alt-apply" onClick={() => { onChange(draftStart, draftEnd); onClose(); }}>{L.apply}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── period hero ──────────────────────────────────────────────────
|
||||||
|
export default function PeriodHero({ start, end, onChange, availableYears, L }: {
|
||||||
|
start: string; end: string; onChange: (s: string, e: string) => void;
|
||||||
|
availableYears: number[]; L: LC;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onM = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||||
|
const onK = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', onM); document.addEventListener('keydown', onK);
|
||||||
|
return () => { document.removeEventListener('mousedown', onM); document.removeEventListener('keydown', onK); };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="dalt-hero">
|
||||||
|
<div className="dalt-hero-inner">
|
||||||
|
<div>
|
||||||
|
<div className="dalt-hero-name">{periodNameL(start, end, L)}</div>
|
||||||
|
<div className="dalt-hero-range">{dateRangeTextL(start, end, L)}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="dalt-hero-btn" onClick={() => setOpen(v => !v)} aria-expanded={open} aria-controls="period-picker-panel">
|
||||||
|
{open ? L.close : L.changePeriod}
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
|
||||||
|
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open && <InlinePicker start={start} end={end} onChange={onChange} onClose={() => setOpen(false)} availableYears={availableYears} L={L} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+92
-38
@@ -30,6 +30,9 @@ ChartJS.register(
|
|||||||
Annotation
|
Annotation
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Used for the "Total" line in multi-museum trend charts — always distinct from chartPalette.
|
||||||
|
export const TOTAL_COLOR = '#1e293b';
|
||||||
|
|
||||||
export const chartColors = {
|
export const chartColors = {
|
||||||
primary: '#2563eb',
|
primary: '#2563eb',
|
||||||
secondary: '#7c3aed',
|
secondary: '#7c3aed',
|
||||||
@@ -37,9 +40,21 @@ export const chartColors = {
|
|||||||
success: '#059669',
|
success: '#059669',
|
||||||
danger: '#dc2626',
|
danger: '#dc2626',
|
||||||
muted: '#94a3b8',
|
muted: '#94a3b8',
|
||||||
grid: '#f1f5f9'
|
grid: '#e2e8f0' // fallback only; use getChartTheme().border at runtime
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getChartTheme() {
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
const get = (v: string) => style.getPropertyValue(v).trim();
|
||||||
|
return {
|
||||||
|
surface: get('--surface') || '#ffffff',
|
||||||
|
textPrimary: get('--text-primary') || '#0f172a',
|
||||||
|
textMuted: get('--text-muted') || '#64748b',
|
||||||
|
border: get('--border') || '#e2e8f0',
|
||||||
|
textInverse: get('--text-inverse') || '#ffffff',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Extended palette for charts with many categories (events, channels)
|
// Extended palette for charts with many categories (events, channels)
|
||||||
export const chartPalette = [
|
export const chartPalette = [
|
||||||
'#2563eb', // blue
|
'#2563eb', // blue
|
||||||
@@ -54,15 +69,15 @@ export const chartPalette = [
|
|||||||
'#ea580c', // orange
|
'#ea580c', // orange
|
||||||
];
|
];
|
||||||
|
|
||||||
export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
export const createDataLabelConfig = (showDataLabels: boolean, overrides?: { color?: string; backgroundColor?: string }): any => ({
|
||||||
display: showDataLabels,
|
display: showDataLabels,
|
||||||
color: '#1e293b',
|
color: overrides?.color ?? '#1e293b',
|
||||||
font: { size: 10, weight: 600 },
|
font: { size: 10, weight: 600 },
|
||||||
anchor: 'end',
|
anchor: 'end',
|
||||||
align: 'end',
|
align: 'end',
|
||||||
offset: 4,
|
offset: 4,
|
||||||
padding: 4,
|
padding: 4,
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
backgroundColor: overrides?.backgroundColor ?? 'rgba(255, 255, 255, 0.85)',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment
|
textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment
|
||||||
formatter: (value: number | null) => {
|
formatter: (value: number | null) => {
|
||||||
@@ -74,43 +89,82 @@ export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createBaseOptions = (showDataLabels: boolean): any => ({
|
export const createBaseOptions = (showDataLabels: boolean): any => {
|
||||||
responsive: true,
|
const theme = getChartTheme();
|
||||||
maintainAspectRatio: false,
|
return {
|
||||||
locale: 'en-US', // Force LTR number formatting
|
responsive: true,
|
||||||
layout: {
|
maintainAspectRatio: false,
|
||||||
padding: {
|
locale: 'en-US', // Force LTR number formatting
|
||||||
top: showDataLabels ? 25 : 5,
|
layout: {
|
||||||
right: 5,
|
padding: {
|
||||||
bottom: 5,
|
top: showDataLabels ? 25 : 5,
|
||||||
left: 5
|
right: 5,
|
||||||
|
bottom: 5,
|
||||||
|
left: 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: theme.surface,
|
||||||
|
titleColor: theme.textPrimary,
|
||||||
|
bodyColor: theme.textMuted,
|
||||||
|
borderColor: theme.border,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 8,
|
||||||
|
titleFont: { size: 12 },
|
||||||
|
bodyFont: { size: 11 },
|
||||||
|
rtl: false,
|
||||||
|
textDirection: 'ltr',
|
||||||
|
usePointStyle: true,
|
||||||
|
boxPadding: 6,
|
||||||
|
},
|
||||||
|
datalabels: createDataLabelConfig(showDataLabels, {
|
||||||
|
color: theme.textPrimary,
|
||||||
|
backgroundColor: theme.surface + 'dd',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { font: { size: 10 }, color: theme.textMuted }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: theme.border },
|
||||||
|
ticks: { font: { size: 10 }, color: theme.textMuted },
|
||||||
|
border: { display: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hover-dim + end-of-line name labels for multi-museum trend charts.
|
||||||
|
// Only activates for charts that have datasets marked with _isMuseumLine.
|
||||||
|
const trendLinePlugin = {
|
||||||
|
id: 'trendLineOverlay',
|
||||||
|
|
||||||
|
// ── hover dim ──────────────────────────────────────────────────
|
||||||
|
beforeDatasetDraw(chart: any, args: any) {
|
||||||
|
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
|
||||||
|
const active = chart.getActiveElements();
|
||||||
|
if (active.length === 0) return;
|
||||||
|
if (active[0].datasetIndex !== args.index) {
|
||||||
|
chart.ctx.save();
|
||||||
|
chart.ctx.globalAlpha = 0.15;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: {
|
afterDatasetDraw(chart: any, args: any) {
|
||||||
legend: { display: false },
|
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
|
||||||
tooltip: {
|
const active = chart.getActiveElements();
|
||||||
backgroundColor: '#1e293b',
|
if (active.length > 0 && active[0].datasetIndex !== args.index) {
|
||||||
padding: 12,
|
chart.ctx.restore();
|
||||||
cornerRadius: 8,
|
|
||||||
titleFont: { size: 12 },
|
|
||||||
bodyFont: { size: 11 },
|
|
||||||
rtl: false,
|
|
||||||
textDirection: 'ltr'
|
|
||||||
},
|
|
||||||
datalabels: createDataLabelConfig(showDataLabels)
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
grid: { display: false },
|
|
||||||
ticks: { font: { size: 10 }, color: '#94a3b8' }
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
grid: { color: chartColors.grid },
|
|
||||||
ticks: { font: { size: 10 }, color: '#94a3b8' },
|
|
||||||
border: { display: false }
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
});
|
|
||||||
|
};
|
||||||
|
|
||||||
|
ChartJS.register(trendLinePlugin);
|
||||||
|
|
||||||
export const lineDatasetDefaults = {
|
export const lineDatasetDefaults = {
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { LC } from './locale';
|
||||||
|
|
||||||
|
// ─── date helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
|
||||||
|
|
||||||
|
export function isLeap(y: number): boolean {
|
||||||
|
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makePresets(y: number): Record<string, { start: string; end: string }> {
|
||||||
|
const feb = isLeap(y) ? 29 : 28;
|
||||||
|
return {
|
||||||
|
jan:{start:`${y}-01-01`,end:`${y}-01-31`}, feb:{start:`${y}-02-01`,end:`${y}-02-${String(feb).padStart(2,'0')}`},
|
||||||
|
mar:{start:`${y}-03-01`,end:`${y}-03-31`}, apr:{start:`${y}-04-01`,end:`${y}-04-30`},
|
||||||
|
may:{start:`${y}-05-01`,end:`${y}-05-31`}, jun:{start:`${y}-06-01`,end:`${y}-06-30`},
|
||||||
|
jul:{start:`${y}-07-01`,end:`${y}-07-31`}, aug:{start:`${y}-08-01`,end:`${y}-08-31`},
|
||||||
|
sep:{start:`${y}-09-01`,end:`${y}-09-30`}, oct:{start:`${y}-10-01`,end:`${y}-10-31`},
|
||||||
|
nov:{start:`${y}-11-01`,end:`${y}-11-30`}, dec:{start:`${y}-12-01`,end:`${y}-12-31`},
|
||||||
|
q1:{start:`${y}-01-01`,end:`${y}-03-31`}, q2:{start:`${y}-04-01`,end:`${y}-06-30`},
|
||||||
|
q3:{start:`${y}-07-01`,end:`${y}-09-30`}, q4:{start:`${y}-10-01`,end:`${y}-12-31`},
|
||||||
|
h1:{start:`${y}-01-01`,end:`${y}-06-30`}, h2:{start:`${y}-07-01`,end:`${y}-12-31`},
|
||||||
|
full:{start:`${y}-01-01`,end:`${y}-12-31`},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function guessPreset(start: string, end: string): { key: string; year: number } | null {
|
||||||
|
const year = parseInt(start.slice(0, 4));
|
||||||
|
const presets = makePresets(year);
|
||||||
|
for (const [key, r] of Object.entries(presets)) {
|
||||||
|
if (r.start === start && r.end === end) return { key, year };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function periodNameL(start: string, end: string, L: LC): string {
|
||||||
|
const year = parseInt(start.slice(0, 4));
|
||||||
|
const g = guessPreset(start, end);
|
||||||
|
if (!g) {
|
||||||
|
const fmt = (d: string) => { const [,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]}`; };
|
||||||
|
const ey = parseInt(end.slice(0, 4));
|
||||||
|
return year === ey ? `${fmt(start)} – ${fmt(end)} ${year}` : `${fmt(start)} ${year} – ${fmt(end)} ${ey}`;
|
||||||
|
}
|
||||||
|
const mi = MONTH_KEYS.indexOf(g.key);
|
||||||
|
if (mi >= 0) return `${L.monthFull[mi]} ${g.year}`;
|
||||||
|
if (g.key === 'full') return L.fullYearLabel(g.year);
|
||||||
|
return `${L.periods[g.key] ?? g.key.toUpperCase()} ${g.year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateRangeTextL(start: string, end: string, L: LC): string {
|
||||||
|
const fmt = (d: string) => { const [y,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]} ${y}`; };
|
||||||
|
return `${fmt(start)} ${L.dateRangeSep} ${fmt(end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentMonth(): { start: string; end: string } {
|
||||||
|
const now = new Date(); const y = now.getFullYear(), m = now.getMonth() + 1;
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return { start: `${y}-${p(m)}-01`, end: `${y}-${p(m)}-${p(new Date(y, m, 0).getDate())}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shiftYear(s: string): string {
|
||||||
|
return s.replace(/^(\d{4})/, (_, y) => String(parseInt(y) - 1));
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// ─── language config ──────────────────────────────────────────────
|
||||||
|
// Shared LC interface used by Dashboard and Comparison.
|
||||||
|
// Fields marked with a comment are only consumed by one page but kept
|
||||||
|
// here so both components share a single type.
|
||||||
|
export interface LC {
|
||||||
|
dir: 'ltr' | 'rtl';
|
||||||
|
/** @deprecated Fonts are now loaded from index.html; kept for compatibility */
|
||||||
|
fontImport: string;
|
||||||
|
bodyFont: string;
|
||||||
|
displayFont: string;
|
||||||
|
monoFont: string;
|
||||||
|
monthFull: string[];
|
||||||
|
monthShort: string[];
|
||||||
|
periods: Record<string, string>;
|
||||||
|
fullYearLabel: (y: number) => string;
|
||||||
|
dateRangeSep: string;
|
||||||
|
backLink: string;
|
||||||
|
backTo: string;
|
||||||
|
pageTitle: string;
|
||||||
|
pageSub: string;
|
||||||
|
// Dashboard
|
||||||
|
changePeriod: string;
|
||||||
|
close: string;
|
||||||
|
apply: string;
|
||||||
|
filter: string;
|
||||||
|
allDistricts: string;
|
||||||
|
allChannels: string;
|
||||||
|
allMuseums: string;
|
||||||
|
countDistricts: (n: number) => string;
|
||||||
|
countChannels: (n: number) => string;
|
||||||
|
countMuseums: (n: number) => string;
|
||||||
|
reset: string;
|
||||||
|
exclVAT: string;
|
||||||
|
inclVAT: string;
|
||||||
|
keyMetrics: string;
|
||||||
|
revenue: string;
|
||||||
|
visitors: string;
|
||||||
|
tickets: string;
|
||||||
|
avgRev: string;
|
||||||
|
pilgrims: string;
|
||||||
|
captureRate: string;
|
||||||
|
charts: string;
|
||||||
|
trendTitle: string;
|
||||||
|
museumTitle: string;
|
||||||
|
channelTitle: string;
|
||||||
|
districtTitle: string;
|
||||||
|
daily: string;
|
||||||
|
weekly: string;
|
||||||
|
monthly: string;
|
||||||
|
newLabel: string;
|
||||||
|
clearSel: string;
|
||||||
|
monthSection: string;
|
||||||
|
periodSection: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
vsLabel: string;
|
||||||
|
barLabel: string;
|
||||||
|
pieLabel: string;
|
||||||
|
absLabel: string;
|
||||||
|
pctLabel: string;
|
||||||
|
// Comparison-specific
|
||||||
|
currentRole: string;
|
||||||
|
previousRole: string;
|
||||||
|
currentHint: string;
|
||||||
|
previousHint: string;
|
||||||
|
vs: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EN: LC = {
|
||||||
|
dir: 'ltr',
|
||||||
|
fontImport: `@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&display=swap');`,
|
||||||
|
bodyFont: "'Outfit', sans-serif",
|
||||||
|
displayFont: "'DM Serif Display', serif",
|
||||||
|
monoFont: "ui-monospace, 'Cascadia Code', monospace",
|
||||||
|
monthFull: ['January','February','March','April','May','June','July','August','September','October','November','December'],
|
||||||
|
monthShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
|
||||||
|
periods: { q1:'Q1', q2:'Q2', q3:'Q3', q4:'Q4', h1:'H1', h2:'H2', full:'Full Year' },
|
||||||
|
fullYearLabel: (y) => String(y),
|
||||||
|
dateRangeSep: '→',
|
||||||
|
backLink: 'Back to Dashboard', backTo: '/',
|
||||||
|
pageTitle: 'Overview', pageSub: 'Museum performance at a glance.',
|
||||||
|
changePeriod: 'Change period', close: 'Cancel', apply: 'Apply',
|
||||||
|
filter: 'Filter',
|
||||||
|
allDistricts: 'All districts', allChannels: 'All channels', allMuseums: 'All museums',
|
||||||
|
countDistricts: (n) => `${n} districts`,
|
||||||
|
countChannels: (n) => `${n} channels`,
|
||||||
|
countMuseums: (n) => `${n} museums`,
|
||||||
|
reset: 'Reset', exclVAT: 'Excl. VAT', inclVAT: 'Incl. VAT',
|
||||||
|
keyMetrics: 'Key Metrics',
|
||||||
|
revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets',
|
||||||
|
avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %',
|
||||||
|
charts: 'Charts',
|
||||||
|
trendTitle: 'Trend over time', museumTitle: 'By museum',
|
||||||
|
channelTitle: 'By channel', districtTitle: 'By district',
|
||||||
|
daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
|
||||||
|
newLabel: 'New', clearSel: 'Clear selection',
|
||||||
|
monthSection: 'Month', periodSection: 'Quarter · Half · Year',
|
||||||
|
from: 'From', to: 'To', vsLabel: 'vs',
|
||||||
|
barLabel: 'Bar', pieLabel: 'Pie', absLabel: '#', pctLabel: '%',
|
||||||
|
currentRole: 'This period', previousRole: 'Compared to',
|
||||||
|
currentHint: 'primary', previousHint: 'auto year −1',
|
||||||
|
vs: 'vs',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AR: LC = {
|
||||||
|
dir: 'rtl',
|
||||||
|
fontImport: `@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');`,
|
||||||
|
bodyFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||||
|
displayFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||||
|
monoFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||||
|
monthFull: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'],
|
||||||
|
monthShort: ['ينا','فبر','مار','أبر','ماي','يون','يول','أغس','سبت','أكت','نوف','ديس'],
|
||||||
|
periods: { q1:'ر١', q2:'ر٢', q3:'ر٣', q4:'ر٤', h1:'ن١', h2:'ن٢', full:'السنة' },
|
||||||
|
fullYearLabel: (y) => `${y} كاملاً`,
|
||||||
|
dateRangeSep: '–',
|
||||||
|
backLink: 'العودة إلى لوحة التحكم', backTo: '/ar',
|
||||||
|
pageTitle: 'نظرة عامة', pageSub: 'أداء المتاحف في لمحة.',
|
||||||
|
changePeriod: 'تغيير الفترة', close: 'إلغاء', apply: 'تطبيق',
|
||||||
|
filter: 'تصفية',
|
||||||
|
allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف',
|
||||||
|
countDistricts: (n) => `${n} مناطق`,
|
||||||
|
countChannels: (n) => `${n} قنوات`,
|
||||||
|
countMuseums: (n) => `${n} متاحف`,
|
||||||
|
reset: 'إعادة ضبط', exclVAT: 'بدون ضريبة', inclVAT: 'مع ضريبة',
|
||||||
|
keyMetrics: 'المؤشرات الرئيسية',
|
||||||
|
revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر',
|
||||||
|
avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %',
|
||||||
|
charts: 'المخططات',
|
||||||
|
trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف',
|
||||||
|
channelTitle: 'حسب القناة', districtTitle: 'حسب المنطقة',
|
||||||
|
daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري',
|
||||||
|
newLabel: 'جديد', clearSel: 'مسح التحديد',
|
||||||
|
monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة',
|
||||||
|
from: 'من', to: 'إلى', vsLabel: 'مقابل',
|
||||||
|
barLabel: 'أعمدة', pieLabel: 'دائرة', absLabel: '#', pctLabel: '%',
|
||||||
|
currentRole: 'الفترة الحالية', previousRole: 'مقارنةً بـ',
|
||||||
|
currentHint: 'رئيسية', previousHint: 'تلقائياً −١ سنة',
|
||||||
|
vs: 'مقابل',
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user