Compare commits
22 Commits
c9cfb58896
...
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 |
@@ -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)
|
||||
Generated
+589
-1
@@ -8,6 +8,7 @@
|
||||
"name": "hihala-dashboard",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -827,6 +828,207 @@
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"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": {
|
||||
"version": "1.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||
@@ -1184,6 +1386,15 @@
|
||||
"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": {
|
||||
"version": "10.4.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -1462,6 +1679,26 @@
|
||||
"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": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
@@ -1475,6 +1712,33 @@
|
||||
"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": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
@@ -1605,6 +1869,15 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1625,6 +1898,27 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
@@ -1712,6 +2006,12 @@
|
||||
"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": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
@@ -1732,6 +2032,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
@@ -1784,6 +2090,21 @@
|
||||
"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": {
|
||||
"version": "6.5.0",
|
||||
"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": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1847,6 +2191,21 @@
|
||||
"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": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
@@ -1860,6 +2219,12 @@
|
||||
"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": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
@@ -1891,6 +2256,27 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -1980,6 +2366,37 @@
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -1999,6 +2416,12 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
@@ -2041,12 +2464,36 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2066,6 +2513,14 @@
|
||||
"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": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
@@ -2095,6 +2550,12 @@
|
||||
"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": {
|
||||
"version": "27.5.1",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
@@ -2248,6 +2735,21 @@
|
||||
"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": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
@@ -2303,6 +2805,26 @@
|
||||
"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": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -2354,6 +2876,15 @@
|
||||
"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": {
|
||||
"version": "4.2.3",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
@@ -2419,6 +2956,12 @@
|
||||
"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": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -2450,7 +2993,6 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
@@ -2474,6 +3016,32 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"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": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
|
||||
@@ -2664,6 +3246,12 @@
|
||||
"engines": {
|
||||
"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",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@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
+478
@@ -2854,6 +2854,484 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
||||
.alt-chart-header { flex-direction:column; }
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Report Builder page
|
||||
======================================== */
|
||||
|
||||
.report-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 100px;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.report-title {
|
||||
font-family: var(--alt-display-font, 'DM Serif Display', serif);
|
||||
font-size: 2rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 6px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.report-sub {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.report-body {}
|
||||
|
||||
.report-form-col {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Generate button bar */
|
||||
.report-footer-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 12px 24px;
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
z-index: 100;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.report-generate-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 24px;
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.report-generate-btn:hover { opacity: 0.88; }
|
||||
.report-generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.report-generate-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
@keyframes report-spin { to { transform: rotate(360deg); } }
|
||||
.report-spin { animation: report-spin 0.8s linear infinite; }
|
||||
|
||||
/* ── Report Form ── */
|
||||
.report-form {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rf-two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.rf-col {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.rf-col + .rf-col {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rf-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.rf-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.rf-input {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rf-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||
|
||||
.rf-date-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rf-check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rf-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rf-color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rf-color-input {
|
||||
width: 40px;
|
||||
height: 36px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rf-color-val {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--alt-mono-font, monospace);
|
||||
}
|
||||
|
||||
.rf-orient-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rf-orient-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.rf-orient-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.rf-orient-btn--on {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||
}
|
||||
|
||||
.rf-orient-page {
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 2px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.rf-orient-page--portrait {
|
||||
width: 18px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.rf-orient-page--landscape {
|
||||
width: 26px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.rf-logo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rf-upload-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--bg);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.rf-upload-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.rf-logo-preview {
|
||||
height: 32px;
|
||||
max-width: 80px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.rf-remove-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: var(--danger-light, #fee2e2);
|
||||
color: var(--danger, #dc2626);
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Group labels & dividers ── */
|
||||
.rf-group-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.rf-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
/* ── Branding row (accent color + logo side by side) ── */
|
||||
.rf-branding-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.rf-branding-row .rf-field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Comparison block ── */
|
||||
.rf-comparison-block {
|
||||
background: color-mix(in srgb, var(--accent) 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rf-comparison-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Module cards ── */
|
||||
.rf-module {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.rf-module--on {
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.rf-module-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rf-module-header:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.rf-module-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rf-module-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
background: var(--border);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rf-module-badge--on {
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.rf-module-body {
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rf-module-note {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Metric pill toggles ── */
|
||||
.rf-metric-pills {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rf-metric-pill {
|
||||
padding: 4px 12px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 20px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.rf-metric-pill--on {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rf-metric-pill:hover:not(.rf-metric-pill--on) {
|
||||
border-color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Footer meta strip ── */
|
||||
.report-footer-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.report-footer-chip {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.report-footer-chip--compare {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.report-footer-dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* H2: focus-visible on all custom interactive elements */
|
||||
.rf-metric-pill:focus-visible,
|
||||
.rf-orient-btn:focus-visible,
|
||||
.rf-upload-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.rf-remove-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* H3: H2 elements used as group labels reset browser heading defaults */
|
||||
h2.rf-group-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* H3: H2 badge-on buttons inside module cards (badge is aria-hidden, no h2 styling needed there) */
|
||||
|
||||
/* H3: rf-remove-btn touch target — min 36×36 */
|
||||
.rf-remove-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
/* C2: inline error messages */
|
||||
.rf-field-error {
|
||||
font-size: 0.75rem;
|
||||
color: var(--danger);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.report-footer-error {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--danger);
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.rf-two-col { grid-template-columns: 1fr; }
|
||||
.rf-col + .rf-col { border-left: none; border-top: 1px solid var(--border); }
|
||||
.report-page { padding: 20px 16px 90px; }
|
||||
/* H5: show only section count in footer on mobile, hide details */
|
||||
.report-footer-chip:not(.report-footer-chip--count),
|
||||
.report-footer-dot { display: none; }
|
||||
/* H4: larger touch targets for metric pills on mobile */
|
||||
.rf-metric-pill { padding: 8px 14px; }
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Reduced Motion
|
||||
======================================== */
|
||||
|
||||
+26
@@ -4,6 +4,7 @@ import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react
|
||||
const Settings = lazy(() => import('./components/Settings'));
|
||||
const Comparison = lazy(() => import('./components/Comparison'));
|
||||
const Dashboard = lazy(() => import('./components/Dashboard'));
|
||||
const Report = lazy(() => import('./components/Report'));
|
||||
import Login from './components/Login';
|
||||
import LoadingSkeleton from './components/shared/LoadingSkeleton';
|
||||
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService';
|
||||
@@ -245,6 +246,18 @@ function App() {
|
||||
{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" />
|
||||
{isOffline && (
|
||||
<span className="offline-badge" title={cacheInfo ? `Cached: ${new Date(cacheInfo.timestamp || '').toLocaleString()}` : ''}>
|
||||
@@ -317,6 +330,7 @@ function App() {
|
||||
<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} />} />
|
||||
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
|
||||
{userRole === 'admin' && <Route path="/report" element={<Report data={data} />} />}
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</main>
|
||||
@@ -340,6 +354,18 @@ function App() {
|
||||
</svg>
|
||||
<span>{t('nav.compare')}</span>
|
||||
</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' && (
|
||||
<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">
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
|
||||
umrahData
|
||||
} 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 { useLanguage } from '../contexts/LanguageContext';
|
||||
import type { LC } from '../lib/locale';
|
||||
@@ -78,6 +78,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
||||
const [selMuseums, setSelMuseums] = useState<string[]>([]);
|
||||
const [metric, setMetric] = useState('revenue');
|
||||
const [gran, setGran] = useState('week');
|
||||
const [showLabels, setShowLabels] = useState(false);
|
||||
|
||||
const perm = useMemo(() => {
|
||||
if (!allowedMuseums || !allowedChannels) return [];
|
||||
@@ -128,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)}`;
|
||||
};
|
||||
|
||||
const trendData = useMemo(() => {
|
||||
const trendResult = useMemo(() => {
|
||||
const group = (rows: MuseumRecord[], ps: string) => {
|
||||
const s=new Date(ps); const acc: Record<number,MuseumRecord[]> = {};
|
||||
rows.forEach(r => {
|
||||
@@ -142,17 +143,56 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
||||
};
|
||||
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 cs0 = new Date(currStart);
|
||||
const fmt = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||
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 {
|
||||
tooltipLabels,
|
||||
multiMuseum,
|
||||
data: {
|
||||
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 },
|
||||
{ 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 },
|
||||
...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 all = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[];
|
||||
@@ -168,11 +208,50 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
||||
};
|
||||
}, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal]);
|
||||
|
||||
const baseOpts = useMemo(() => createBaseOptions(false), []);
|
||||
const baseOpts = useMemo(() => createBaseOptions(showLabels), [showLabels]);
|
||||
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 = [
|
||||
{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors },
|
||||
@@ -272,9 +351,11 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
||||
{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" />
|
||||
{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 className="alt-chart-wrap"><Line data={trendData} options={chartOpts} /></div>
|
||||
<div className="alt-chart-wrap"><Line data={trendData} options={trendOpts} /></div>
|
||||
</div>
|
||||
<div className="alt-chart-card">
|
||||
<div className="alt-chart-header">
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
groupByMuseum, groupByChannel, groupByDistrict,
|
||||
umrahData,
|
||||
} 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 { useLanguage } from '../contexts/LanguageContext';
|
||||
import { EN, AR } from '../lib/locale';
|
||||
@@ -37,6 +37,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
const [selMuseums, setSelMuseums] = useState<string[]>([]);
|
||||
const [metric, setMetric] = useState('revenue');
|
||||
const [gran, setGran] = useState('week');
|
||||
const [showLabels, setShowLabels] = useState(false);
|
||||
const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar');
|
||||
const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie');
|
||||
const [districtChartType, setDistrictChartType] = useState<'bar'|'pie'>('pie');
|
||||
@@ -88,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);
|
||||
}, [revenueField]);
|
||||
|
||||
const trendData = useMemo(() => {
|
||||
const trendResult = useMemo(() => {
|
||||
const group = (rows: MuseumRecord[], ps: string) => {
|
||||
const s = new Date(ps); const acc: Record<number, MuseumRecord[]> = {};
|
||||
rows.forEach(r => {
|
||||
@@ -102,18 +103,57 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
};
|
||||
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 s0 = new Date(start);
|
||||
const fmt = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||
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 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 {
|
||||
tooltipLabels,
|
||||
multiMuseum,
|
||||
data: {
|
||||
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] },
|
||||
{ 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 },
|
||||
...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 g = groupByMuseum(filteredData, includeVAT);
|
||||
@@ -165,13 +205,53 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null;
|
||||
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
|
||||
|
||||
const baseOpts = useMemo(() => createBaseOptions(false), []);
|
||||
const baseOpts = useMemo(() => createBaseOptions(showLabels), [showLabels]);
|
||||
const { chartOpts, barHorizOpts, barNoLegend } = useMemo(() => {
|
||||
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:10, padding:10, font:{ size:11 } } } } };
|
||||
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(() => ({
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
@@ -248,9 +328,11 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
{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" />
|
||||
{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 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 className="alt-chart-card">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -30,6 +30,9 @@ ChartJS.register(
|
||||
Annotation
|
||||
);
|
||||
|
||||
// Used for the "Total" line in multi-museum trend charts — always distinct from chartPalette.
|
||||
export const TOTAL_COLOR = '#1e293b';
|
||||
|
||||
export const chartColors = {
|
||||
primary: '#2563eb',
|
||||
secondary: '#7c3aed',
|
||||
@@ -113,7 +116,9 @@ export const createBaseOptions = (showDataLabels: boolean): any => {
|
||||
titleFont: { size: 12 },
|
||||
bodyFont: { size: 11 },
|
||||
rtl: false,
|
||||
textDirection: 'ltr'
|
||||
textDirection: 'ltr',
|
||||
usePointStyle: true,
|
||||
boxPadding: 6,
|
||||
},
|
||||
datalabels: createDataLabelConfig(showDataLabels, {
|
||||
color: theme.textPrimary,
|
||||
@@ -134,6 +139,33 @@ export const createBaseOptions = (showDataLabels: boolean): any => {
|
||||
};
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
},
|
||||
afterDatasetDraw(chart: any, args: any) {
|
||||
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
|
||||
const active = chart.getActiveElements();
|
||||
if (active.length > 0 && active[0].datasetIndex !== args.index) {
|
||||
chart.ctx.restore();
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
ChartJS.register(trendLinePlugin);
|
||||
|
||||
export const lineDatasetDefaults = {
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
|
||||
Reference in New Issue
Block a user