Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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",
|
"name": "hihala-dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -827,6 +828,207 @@
|
|||||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/ciphers": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/fns": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/font": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/pdfkit": "^5.1.1",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"fontkit": "^2.0.2",
|
||||||
|
"is-url": "^1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/image": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/svg": "^1.1.0",
|
||||||
|
"jay-peg": "^1.1.1",
|
||||||
|
"png-js": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/layout": {
|
||||||
|
"version": "4.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.6.1.tgz",
|
||||||
|
"integrity": "sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/image": "^3.1.0",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/stylesheet": "^6.2.1",
|
||||||
|
"@react-pdf/textkit": "^6.3.0",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"emoji-regex-xs": "^1.0.0",
|
||||||
|
"queue": "^6.0.1",
|
||||||
|
"yoga-layout": "^3.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/pdfkit": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@noble/ciphers": "^1.0.0",
|
||||||
|
"@noble/hashes": "^1.6.0",
|
||||||
|
"browserify-zlib": "^0.2.0",
|
||||||
|
"fontkit": "^2.0.2",
|
||||||
|
"jay-peg": "^1.1.1",
|
||||||
|
"js-md5": "^0.8.3",
|
||||||
|
"linebreak": "^1.1.0",
|
||||||
|
"png-js": "^2.0.0",
|
||||||
|
"vite-compatible-readable-stream": "^3.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/primitives": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/reconciler": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"scheduler": "0.25.0-rc-603e6108-20241029"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
|
||||||
|
"version": "0.25.0-rc-603e6108-20241029",
|
||||||
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
|
||||||
|
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/render": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/textkit": "^6.3.0",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"abs-svg-path": "^0.1.1",
|
||||||
|
"color-string": "^2.1.4",
|
||||||
|
"normalize-svg-path": "^1.1.0",
|
||||||
|
"parse-svg-path": "^0.1.2",
|
||||||
|
"svg-arc-to-cubic-bezier": "^3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/renderer": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/font": "^4.0.8",
|
||||||
|
"@react-pdf/layout": "^4.6.1",
|
||||||
|
"@react-pdf/pdfkit": "^5.1.1",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/reconciler": "^2.0.0",
|
||||||
|
"@react-pdf/render": "^4.5.1",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"prop-types": "^15.6.2",
|
||||||
|
"queue": "^6.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/stylesheet": {
|
||||||
|
"version": "6.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.2.1.tgz",
|
||||||
|
"integrity": "sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"color-string": "^2.1.4",
|
||||||
|
"hsl-to-hex": "^1.0.0",
|
||||||
|
"media-engine": "^1.0.3",
|
||||||
|
"postcss-value-parser": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/svg": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/svg/-/svg-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/primitives": "^4.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/textkit": {
|
||||||
|
"version": "6.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.3.0.tgz",
|
||||||
|
"integrity": "sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"bidi-js": "^1.0.2",
|
||||||
|
"hyphen": "^1.6.4",
|
||||||
|
"unicode-properties": "^1.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/types": {
|
||||||
|
"version": "2.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.11.1.tgz",
|
||||||
|
"integrity": "sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/font": "^4.0.8",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/stylesheet": "^6.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.3",
|
"version": "1.0.0-rc.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||||
@@ -1184,6 +1386,15 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz",
|
||||||
|
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
@@ -1419,6 +1630,12 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/abs-svg-path": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
@@ -1462,6 +1679,26 @@
|
|||||||
"node": ">= 0.6.0"
|
"node": ">= 0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||||
@@ -1475,6 +1712,33 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/brotli": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/browserify-zlib": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "~1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.1",
|
"version": "4.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
@@ -1605,6 +1869,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -1625,6 +1898,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/color-string": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-string/node_modules/color-name": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concurrently": {
|
"node_modules/concurrently": {
|
||||||
"version": "9.2.1",
|
"version": "9.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||||
@@ -1712,6 +2006,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dfa": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dom-accessibility-api": {
|
"node_modules/dom-accessibility-api": {
|
||||||
"version": "0.5.16",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
@@ -1732,6 +2032,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex-xs": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
@@ -1784,6 +2090,21 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/events": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1802,6 +2123,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fontkit": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.12",
|
||||||
|
"brotli": "^1.3.2",
|
||||||
|
"clone": "^2.1.2",
|
||||||
|
"dfa": "^1.2.0",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"restructure": "^3.0.0",
|
||||||
|
"tiny-inflate": "^1.0.3",
|
||||||
|
"unicode-properties": "^1.4.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1847,6 +2191,21 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hsl-to-hex": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hsl-to-rgb-for-reals": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hsl-to-rgb-for-reals": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/html2canvas": {
|
"node_modules/html2canvas": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
@@ -1860,6 +2219,12 @@
|
|||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hyphen": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/immediate": {
|
"node_modules/immediate": {
|
||||||
"version": "3.0.6",
|
"version": "3.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
@@ -1891,6 +2256,27 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-url": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jay-peg": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"restructure": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/js-md5": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -1980,6 +2366,37 @@
|
|||||||
"immediate": "~3.0.5"
|
"immediate": "~3.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linebreak": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "0.0.8",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linebreak/node_modules/base64-js": {
|
||||||
|
"version": "0.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||||
|
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/loose-envify": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"loose-envify": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -1999,6 +2416,12 @@
|
|||||||
"lz-string": "bin/bin.js"
|
"lz-string": "bin/bin.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/media-engine": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/min-indent": {
|
"node_modules/min-indent": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||||
@@ -2041,12 +2464,36 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/normalize-svg-path": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"svg-arc-to-cubic-bezier": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pako": {
|
"node_modules/pako": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
"license": "(MIT AND Zlib)"
|
"license": "(MIT AND Zlib)"
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-svg-path": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2066,6 +2513,14 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/png-js": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
|
||||||
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
@@ -2095,6 +2550,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss-value-parser": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pretty-format": {
|
"node_modules/pretty-format": {
|
||||||
"version": "27.5.1",
|
"version": "27.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||||
@@ -2127,6 +2588,32 @@
|
|||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/prop-types": {
|
||||||
|
"version": "15.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"react-is": "^16.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prop-types/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/queue": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "~2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
@@ -2248,6 +2735,21 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/restructure": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
@@ -2303,6 +2805,26 @@
|
|||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -2354,6 +2876,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
@@ -2410,6 +2941,12 @@
|
|||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svg-arc-to-cubic-bezier": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/text-segmentation": {
|
"node_modules/text-segmentation": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
@@ -2419,6 +2956,12 @@
|
|||||||
"utrie": "^1.0.2"
|
"utrie": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-inflate": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -2450,7 +2993,6 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
@@ -2474,6 +3016,32 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/unicode-properties": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^0.2.5",
|
||||||
|
"tiny-inflate": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie/node_modules/pako": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -2595,6 +3163,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-compatible-readable-stream": {
|
||||||
|
"version": "3.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
|
||||||
|
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/web-vitals": {
|
"node_modules/web-vitals": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
|
||||||
@@ -2664,6 +3246,12 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yoga-layout": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
|||||||
+365
@@ -2854,6 +2854,371 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
|||||||
.alt-chart-header { flex-direction:column; }
|
.alt-chart-header { flex-direction:column; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Report Builder page
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.report-page {
|
||||||
|
max-width: 1400px;
|
||||||
|
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 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 420px 1fr;
|
||||||
|
gap: 32px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-form-col {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-preview-col {}
|
||||||
|
|
||||||
|
.report-preview-sticky {
|
||||||
|
position: sticky;
|
||||||
|
top: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
justify-content: flex-end;
|
||||||
|
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 {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-section-title {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-section-title:first-child {
|
||||||
|
border-top: none;
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 rgba(37,99,235,.1); }
|
||||||
|
|
||||||
|
.rf-date-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-toggle-opt {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-toggle-opt--on {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Report Preview ── */
|
||||||
|
.report-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-preview-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-page {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 3px rgba(0,0,0,.08));
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 210 / 297;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 7px;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-cover-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-brand { font-weight: 700; color: #2563eb; font-size: 8px; }
|
||||||
|
.rp-brand-small { font-weight: 700; font-size: 6px; }
|
||||||
|
|
||||||
|
.rp-client-logo {
|
||||||
|
height: 20px;
|
||||||
|
max-width: 50px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-cover-body {
|
||||||
|
padding: 24px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-cover-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #0f172a;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-cover-for, .rp-cover-contact, .rp-cover-period {
|
||||||
|
font-size: 7px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-cover-bar { height: 4px; margin-top: auto; width: calc(100% + 32px); margin-left: -16px; }
|
||||||
|
|
||||||
|
.rp-placeholder-text { color: #cbd5e1; font-style: italic; }
|
||||||
|
|
||||||
|
.rp-page--content { padding: 12px 16px; }
|
||||||
|
|
||||||
|
.rp-page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-page-title-small, .rp-page-num { font-size: 5px; color: #94a3b8; }
|
||||||
|
|
||||||
|
.rp-section { margin-bottom: 10px; }
|
||||||
|
|
||||||
|
.rp-section-heading {
|
||||||
|
color: white;
|
||||||
|
font-size: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-placeholder-lines { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.rp-ph-line { height: 4px; background: #e2e8f0; border-radius: 2px; }
|
||||||
|
|
||||||
|
.rp-ph-table { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
|
.rp-ph-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-ph-row-label { font-size: 5.5px; color: #334155; flex: 1.5; }
|
||||||
|
.rp-ph-row-val { flex: 1; height: 4px; background: #e2e8f0; border-radius: 2px; }
|
||||||
|
.rp-ph-row-val--sm { flex: 0.8; }
|
||||||
|
|
||||||
|
.rp-ph-chart { height: 40px; background: #f8fafc; border-radius: 3px; border: 1px solid #e2e8f0; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.report-body { grid-template-columns: 1fr; }
|
||||||
|
.report-preview-sticky { position: static; }
|
||||||
|
.report-page { padding: 20px 16px 90px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
Reduced Motion
|
Reduced Motion
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|||||||
+26
@@ -4,6 +4,7 @@ import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react
|
|||||||
const Settings = lazy(() => import('./components/Settings'));
|
const Settings = lazy(() => import('./components/Settings'));
|
||||||
const Comparison = lazy(() => import('./components/Comparison'));
|
const Comparison = lazy(() => import('./components/Comparison'));
|
||||||
const Dashboard = lazy(() => import('./components/Dashboard'));
|
const Dashboard = lazy(() => import('./components/Dashboard'));
|
||||||
|
const Report = lazy(() => import('./components/Report'));
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
import LoadingSkeleton from './components/shared/LoadingSkeleton';
|
import LoadingSkeleton from './components/shared/LoadingSkeleton';
|
||||||
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService';
|
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService';
|
||||||
@@ -245,6 +246,18 @@ function App() {
|
|||||||
{t('nav.settings')}
|
{t('nav.settings')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
|
{userRole === 'admin' && (
|
||||||
|
<NavLink to="/report">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
|
</svg>
|
||||||
|
Report
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
<span className="nav-sep" aria-hidden="true" />
|
<span className="nav-sep" aria-hidden="true" />
|
||||||
{isOffline && (
|
{isOffline && (
|
||||||
<span className="offline-badge" title={cacheInfo ? `Cached: ${new Date(cacheInfo.timestamp || '').toLocaleString()}` : ''}>
|
<span className="offline-badge" title={cacheInfo ? `Cached: ${new Date(cacheInfo.timestamp || '').toLocaleString()}` : ''}>
|
||||||
@@ -317,6 +330,7 @@ function App() {
|
|||||||
<Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
<Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||||
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||||
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
|
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
|
||||||
|
{userRole === 'admin' && <Route path="/report" element={<Report data={data} />} />}
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
@@ -340,6 +354,18 @@ function App() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{t('nav.compare')}</span>
|
<span>{t('nav.compare')}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
{userRole === 'admin' && (
|
||||||
|
<NavLink to="/report" className="mobile-nav-item">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
|
</svg>
|
||||||
|
<span>Report</span>
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
{userRole === 'admin' && (
|
{userRole === 'admin' && (
|
||||||
<NavLink to="/settings" className="mobile-nav-item">
|
<NavLink to="/settings" className="mobile-nav-item">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Document, Page, View, Text, Image, StyleSheet
|
||||||
|
} from '@react-pdf/renderer';
|
||||||
|
import { PdfTrendChart, PdfHBarChart } from './reportCharts';
|
||||||
|
import {
|
||||||
|
ReportData, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary
|
||||||
|
} from './reportHelpers';
|
||||||
|
|
||||||
|
const S = StyleSheet.create({
|
||||||
|
page: { fontFamily: 'Helvetica', fontSize: 9, color: '#0f172a', backgroundColor: '#ffffff' },
|
||||||
|
coverPage: { flexDirection: 'column', padding: 0 },
|
||||||
|
coverTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', paddingTop: 40, paddingRight: 50, paddingBottom: 0, paddingLeft: 50 },
|
||||||
|
coverLogoBox: { width: 80, height: 40, justifyContent: 'center' },
|
||||||
|
coverClientLogo: { width: 80, height: 40, objectFit: 'contain' as const },
|
||||||
|
coverHiHala: { fontSize: 13, fontFamily: 'Helvetica-Bold', color: '#2563eb', letterSpacing: 0.5 },
|
||||||
|
coverMiddle: { flex: 1, justifyContent: 'center', paddingHorizontal: 50, paddingTop: 80 },
|
||||||
|
coverTitle: { fontSize: 28, fontFamily: 'Helvetica-Bold', marginBottom: 16, lineHeight: 1.2 },
|
||||||
|
coverFor: { fontSize: 11, color: '#334155', marginBottom: 4 },
|
||||||
|
coverContact: { fontSize: 10, color: '#64748b', marginBottom: 32 },
|
||||||
|
coverPeriod: { fontSize: 10, color: '#64748b', fontFamily: 'Helvetica-Oblique', marginBottom: 6 },
|
||||||
|
coverDate: { fontSize: 9, color: '#94a3b8' },
|
||||||
|
coverBar: { height: 6, flex: 1 },
|
||||||
|
contentPage: { paddingTop: 32, paddingRight: 44, paddingBottom: 48, paddingLeft: 44 },
|
||||||
|
pageHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderBottomWidth: 1, borderBottomColor: '#e2e8f0', paddingBottom: 8, marginBottom: 24 },
|
||||||
|
pageHeaderTitle: { fontSize: 8, color: '#94a3b8' },
|
||||||
|
pageHeaderLogo: { fontSize: 9, fontFamily: 'Helvetica-Bold', color: '#2563eb' },
|
||||||
|
pageHeaderNum: { fontSize: 8, color: '#94a3b8' },
|
||||||
|
pageFooter: { position: 'absolute', bottom: 20, left: 44, right: 44, flexDirection: 'row', justifyContent: 'space-between' },
|
||||||
|
pageFooterText: { fontSize: 7, color: '#94a3b8' },
|
||||||
|
sectionHeading: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: '#ffffff', paddingTop: 5, paddingRight: 10, paddingBottom: 5, paddingLeft: 10, marginBottom: 14, borderRadius: 3 },
|
||||||
|
summaryText: { fontSize: 9.5, color: '#334155', lineHeight: 1.6 },
|
||||||
|
metricsTable: { marginBottom: 8 },
|
||||||
|
metricsRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 6 },
|
||||||
|
metricsRowAlt: { backgroundColor: '#f8fafc' },
|
||||||
|
metricsLabel: { flex: 1.5, fontSize: 9, color: '#334155', fontFamily: 'Helvetica-Bold' },
|
||||||
|
metricsValue: { flex: 1, fontSize: 9, color: '#0f172a', textAlign: 'right' },
|
||||||
|
metricsChange: { flex: 0.8, fontSize: 8, textAlign: 'right' },
|
||||||
|
metricsChangeUp: { color: '#059669' },
|
||||||
|
metricsChangeDown: { color: '#dc2626' },
|
||||||
|
metricsHeaderRow: { flexDirection: 'row', backgroundColor: '#f1f5f9', paddingTop: 4, paddingBottom: 4, marginBottom: 2 },
|
||||||
|
metricsHeaderCell: { flex: 1, fontSize: 7.5, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right' },
|
||||||
|
metricsHeaderLabel: { flex: 1.5, fontSize: 7.5, fontFamily: 'Helvetica-Bold', color: '#64748b' },
|
||||||
|
chartWrap: { marginBottom: 8, backgroundColor: '#f8fafc', padding: 12, borderRadius: 4 },
|
||||||
|
sectionGap: { marginBottom: 24 },
|
||||||
|
legendRow: { flexDirection: 'row', marginBottom: 8 },
|
||||||
|
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 16 },
|
||||||
|
legendDot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
|
legendLabel: { fontSize: 7.5, color: '#64748b', marginLeft: 4 },
|
||||||
|
});
|
||||||
|
|
||||||
|
function pctChange(curr: number, prev: number): number {
|
||||||
|
if (prev === 0) return 0;
|
||||||
|
return Math.round(((curr - prev) / prev) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageHeaderProps { title: string; page: number; }
|
||||||
|
function PageHeader({ title, page }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<View style={S.pageHeader}>
|
||||||
|
<Text style={S.pageHeaderLogo}>HiHala Data</Text>
|
||||||
|
<Text style={S.pageHeaderTitle}>{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; }
|
||||||
|
function SectionHeading({ title, color }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<View style={[S.sectionHeading, { backgroundColor: color }]}>
|
||||||
|
<Text>{title}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props { data: ReportData; }
|
||||||
|
|
||||||
|
export function ReportDocument({ data }: Props) {
|
||||||
|
const { config: cfg, metrics, prevMetrics, trendLabels, trendCurrent, trendPrevious,
|
||||||
|
museumBreakdown, channelBreakdown, pilgrimCapture, generatedAt } = data;
|
||||||
|
|
||||||
|
const lang = cfg.language;
|
||||||
|
const color = cfg.accentColor;
|
||||||
|
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
|
||||||
|
const orientation = cfg.orientation === 'landscape' ? 'landscape' : 'portrait';
|
||||||
|
const T = lang === 'en' ? LABELS_EN : LABELS_AR;
|
||||||
|
|
||||||
|
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 },
|
||||||
|
...(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 prevYear = parseInt(cfg.startDate.slice(0, 4)) - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Document title={cfg.title || 'HiHala Report'} author="HiHala Data">
|
||||||
|
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage]}>
|
||||||
|
<View style={S.coverTop}>
|
||||||
|
<Text style={S.coverHiHala}>HiHala Data</Text>
|
||||||
|
{cfg.clientLogoBase64 && (
|
||||||
|
<View style={S.coverLogoBox}>
|
||||||
|
<Image src={cfg.clientLogoBase64} style={S.coverClientLogo} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={S.coverMiddle}>
|
||||||
|
<Text style={S.coverTitle}>{cfg.title || T.defaultTitle}</Text>
|
||||||
|
{cfg.clientName && <Text style={S.coverFor}>{T.preparedFor}: {cfg.clientName}</Text>}
|
||||||
|
{cfg.contactName && <Text style={S.coverContact}>{T.attention}: {cfg.contactName}</Text>}
|
||||||
|
<Text style={S.coverPeriod}>{period}</Text>
|
||||||
|
<Text style={S.coverDate}>{T.generated}: {generatedAt}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[S.coverBar, { backgroundColor: color }]} />
|
||||||
|
</Page>
|
||||||
|
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={2} />
|
||||||
|
|
||||||
|
{cfg.showExecutiveSummary && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.execSummary} color={color} />
|
||||||
|
<Text style={S.summaryText}>{generateExecutiveSummary(data)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cfg.showMetricsTable && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.keyMetrics} color={color} />
|
||||||
|
<View style={S.metricsTable}>
|
||||||
|
<View style={S.metricsHeaderRow}>
|
||||||
|
<Text style={S.metricsHeaderLabel}> </Text>
|
||||||
|
<Text style={S.metricsHeaderCell}>{period}</Text>
|
||||||
|
{prevMetrics && <Text style={S.metricsHeaderCell}>{prevYear}</Text>}
|
||||||
|
{prevMetrics && <Text style={S.metricsHeaderCell}>{T.change}</Text>}
|
||||||
|
</View>
|
||||||
|
{metricsRows.map((row, i) => (
|
||||||
|
<View key={row.label} style={[S.metricsRow, i % 2 === 1 ? S.metricsRowAlt : {}]}>
|
||||||
|
<Text style={S.metricsLabel}>{row.label}</Text>
|
||||||
|
<Text style={S.metricsValue}>{row.curr}</Text>
|
||||||
|
{prevMetrics && <Text style={S.metricsValue}>{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 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.trend} color={color} />
|
||||||
|
{cfg.includeComparison && (
|
||||||
|
<View style={S.legendRow}>
|
||||||
|
<View style={S.legendItem}>
|
||||||
|
<View style={[S.legendDot, { backgroundColor: color }]} />
|
||||||
|
<Text style={S.legendLabel}>{period}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={S.legendItem}>
|
||||||
|
<View style={[S.legendDot, { backgroundColor: '#94a3b8' }]} />
|
||||||
|
<Text style={S.legendLabel}>{prevYear}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfTrendChart labels={trendLabels} current={trendCurrent}
|
||||||
|
previous={trendPrevious} color={color} width={460} height={130} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
|
</Page>
|
||||||
|
|
||||||
|
{(cfg.showMuseumBreakdown || cfg.showChannelBreakdown) && (
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={3} />
|
||||||
|
|
||||||
|
{cfg.showMuseumBreakdown && museumBreakdown.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byMuseum} color={color} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={museumBreakdown} color={color} width={460} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cfg.showChannelBreakdown && channelBreakdown.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byChannel} color={color} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={channelBreakdown} color={color} width={460} />
|
||||||
|
</View>
|
||||||
|
</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',
|
||||||
|
change: 'vs Prior Year',
|
||||||
|
trend: 'Revenue Trend',
|
||||||
|
byMuseum: 'Revenue by Museum',
|
||||||
|
byChannel: 'Visitors by Channel',
|
||||||
|
revenue: 'Revenue',
|
||||||
|
visitors: 'Visitors',
|
||||||
|
tickets: 'Tickets',
|
||||||
|
avgRev: 'Avg Rev / Visitor',
|
||||||
|
capture: 'Pilgrim Capture Rate',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LABELS_AR = {
|
||||||
|
defaultTitle: 'تقرير الأداء',
|
||||||
|
preparedFor: 'مُعدّ لـ',
|
||||||
|
attention: 'عناية',
|
||||||
|
generated: 'تاريخ الإصدار',
|
||||||
|
execSummary: 'الملخص التنفيذي',
|
||||||
|
keyMetrics: 'المؤشرات الرئيسية',
|
||||||
|
change: 'مقابل العام السابق',
|
||||||
|
trend: 'اتجاه الإيرادات',
|
||||||
|
byMuseum: 'الإيرادات حسب المتحف',
|
||||||
|
byChannel: 'الزوار حسب القناة',
|
||||||
|
revenue: 'الإيرادات',
|
||||||
|
visitors: 'الزوار',
|
||||||
|
tickets: 'التذاكر',
|
||||||
|
avgRev: 'متوسط الإيراد / زائر',
|
||||||
|
capture: 'معدل استيعاب الحجاج',
|
||||||
|
};
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import AltMultiSelect from '../shared/AltMultiSelect';
|
||||||
|
import type { ReportConfig } from './reportHelpers';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
config: ReportConfig;
|
||||||
|
onChange: (patch: Partial<ReportConfig>) => void;
|
||||||
|
allMuseums: string[];
|
||||||
|
allChannels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="rf-section-title">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="rf-field">
|
||||||
|
<span className="rf-label">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({ left, right, value, onChange }: {
|
||||||
|
left: string; right: string; value: boolean; onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rf-toggle">
|
||||||
|
<button type="button" className={`rf-toggle-opt${!value ? ' rf-toggle-opt--on' : ''}`} onClick={() => onChange(false)}>{left}</button>
|
||||||
|
<button type="button" className={`rf-toggle-opt${value ? ' rf-toggle-opt--on' : ''}`} onClick={() => onChange(true)}>{right}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<label className="rf-check-row">
|
||||||
|
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} className="rf-checkbox" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportForm({ config: cfg, onChange, allMuseums, allChannels }: Props) {
|
||||||
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > 2 * 1024 * 1024) { alert('Logo must be under 2 MB'); return; }
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => onChange({ clientLogoBase64: reader.result as string });
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="report-form">
|
||||||
|
|
||||||
|
<SectionTitle>Client Info</SectionTitle>
|
||||||
|
|
||||||
|
<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 (company)">
|
||||||
|
<input className="rf-input" type="text" value={cfg.clientName}
|
||||||
|
onChange={e => onChange({ clientName: e.target.value })}
|
||||||
|
placeholder="Acme Group" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Contact name (optional)">
|
||||||
|
<input className="rf-input" type="text" value={cfg.contactName}
|
||||||
|
onChange={e => onChange({ contactName: e.target.value })}
|
||||||
|
placeholder="Mohammed Al-..." />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Client logo (PNG/JPG, max 2 MB)">
|
||||||
|
<div className="rf-logo-row">
|
||||||
|
<button type="button" className="rf-upload-btn" onClick={() => logoInputRef.current?.click()}>
|
||||||
|
{cfg.clientLogoBase64 ? 'Change logo' : 'Upload logo'}
|
||||||
|
</button>
|
||||||
|
{cfg.clientLogoBase64 && (
|
||||||
|
<>
|
||||||
|
<img src={cfg.clientLogoBase64} alt="preview" className="rf-logo-preview" />
|
||||||
|
<button type="button" className="rf-remove-btn" onClick={() => onChange({ clientLogoBase64: null })}>✕</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<input ref={logoInputRef} type="file" accept="image/png,image/jpeg"
|
||||||
|
style={{ display: 'none' }} onChange={handleLogoUpload} />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Accent color">
|
||||||
|
<div className="rf-color-row">
|
||||||
|
<input type="color" value={cfg.accentColor}
|
||||||
|
onChange={e => onChange({ accentColor: e.target.value })}
|
||||||
|
className="rf-color-input" />
|
||||||
|
<span className="rf-color-val">{cfg.accentColor}</span>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<SectionTitle>Data Selection</SectionTitle>
|
||||||
|
|
||||||
|
<div className="rf-date-row">
|
||||||
|
<Field label="Start date">
|
||||||
|
<input className="rf-input" type="date" value={cfg.startDate}
|
||||||
|
onChange={e => onChange({ startDate: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="End date">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Field label="VAT">
|
||||||
|
<Toggle left="Excl. VAT" right="Incl. VAT" value={cfg.includeVAT}
|
||||||
|
onChange={v => onChange({ includeVAT: v })} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<CheckRow label="Include previous year comparison"
|
||||||
|
checked={cfg.includeComparison} onChange={v => onChange({ includeComparison: v })} />
|
||||||
|
|
||||||
|
<SectionTitle>Content Sections</SectionTitle>
|
||||||
|
|
||||||
|
<CheckRow label="Executive summary" checked={cfg.showExecutiveSummary} onChange={v => onChange({ showExecutiveSummary: v })} />
|
||||||
|
<CheckRow label="Key metrics table" checked={cfg.showMetricsTable} onChange={v => onChange({ showMetricsTable: v })} />
|
||||||
|
<CheckRow label="Revenue trend chart" checked={cfg.showTrendChart} onChange={v => onChange({ showTrendChart: v })} />
|
||||||
|
<CheckRow label="Breakdown by museum" checked={cfg.showMuseumBreakdown} onChange={v => onChange({ showMuseumBreakdown: v })} />
|
||||||
|
<CheckRow label="Breakdown by channel" checked={cfg.showChannelBreakdown} onChange={v => onChange({ showChannelBreakdown: v })} />
|
||||||
|
<CheckRow label="Pilgrim capture rate" checked={cfg.showPilgrimCapture} onChange={v => onChange({ showPilgrimCapture: v })} />
|
||||||
|
|
||||||
|
<SectionTitle>Presentation</SectionTitle>
|
||||||
|
|
||||||
|
<Field label="Language">
|
||||||
|
<Toggle left="English" right="العربية" value={cfg.language === 'ar'}
|
||||||
|
onChange={v => onChange({ language: v ? 'ar' : 'en' })} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Orientation">
|
||||||
|
<Toggle left="Portrait" right="Landscape" value={cfg.orientation === 'landscape'}
|
||||||
|
onChange={v => onChange({ orientation: v ? 'landscape' : 'portrait' })} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Confidentiality">
|
||||||
|
<select className="rf-input" value={cfg.confidentiality}
|
||||||
|
onChange={e => onChange({ confidentiality: e.target.value as ReportConfig['confidentiality'] })}>
|
||||||
|
<option value="Confidential">Confidential</option>
|
||||||
|
<option value="Internal">Internal</option>
|
||||||
|
<option value="Public">Public</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
</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,99 @@
|
|||||||
|
import React, { useState, useCallback } 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 ReportPreview from './ReportPreview';
|
||||||
|
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 allMuseums = getUniqueMuseums(data);
|
||||||
|
const allChannels = getUniqueChannels(data);
|
||||||
|
|
||||||
|
const patch = useCallback((p: Partial<ReportConfig>) => setConfig(c => ({ ...c, ...p })), []);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (config.startDate > config.endDate) {
|
||||||
|
alert('End date must be after start date.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGenerating(true);
|
||||||
|
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);
|
||||||
|
alert('Failed to generate PDF. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="report-page">
|
||||||
|
<div className="report-header">
|
||||||
|
<h1 className="report-title">Report Builder</h1>
|
||||||
|
<p className="report-sub">Configure and download a client-ready PDF report.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="report-body">
|
||||||
|
<div className="report-form-col">
|
||||||
|
<ReportForm config={config} onChange={patch} allMuseums={allMuseums} allChannels={allChannels} />
|
||||||
|
</div>
|
||||||
|
<div className="report-preview-col">
|
||||||
|
<div className="report-preview-sticky">
|
||||||
|
<ReportPreview config={config} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="report-footer-bar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="report-generate-btn"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={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,94 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Svg, Line, Polyline, Rect, Text as SvgText, G } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
interface TrendChartProps {
|
||||||
|
labels: string[];
|
||||||
|
current: number[];
|
||||||
|
previous: number[] | null;
|
||||||
|
color: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfTrendChart({ labels, current, previous, color, width = 460, height = 140 }: TrendChartProps) {
|
||||||
|
const allValues = [...current, ...(previous ?? [])].filter(v => v > 0);
|
||||||
|
const max = allValues.length > 0 ? Math.max(...allValues) : 1;
|
||||||
|
const padL = 8, padR = 8, padT = 8, padB = 8;
|
||||||
|
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}>
|
||||||
|
{gridLines.map(f => (
|
||||||
|
<Line key={f}
|
||||||
|
x1={padL} y1={sy(max * f).toFixed(1)}
|
||||||
|
x2={width - padR} y2={sy(max * f).toFixed(1)}
|
||||||
|
stroke="#e2e8f0" strokeWidth={0.5} />
|
||||||
|
))}
|
||||||
|
{previous && previous.some(v => v > 0) && (
|
||||||
|
<Polyline points={toPoints(previous)}
|
||||||
|
stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 3" fill="none" />
|
||||||
|
)}
|
||||||
|
{current.some(v => v > 0) && (
|
||||||
|
<Polyline points={toPoints(current)}
|
||||||
|
stroke={color} strokeWidth={2.5} fill="none" />
|
||||||
|
)}
|
||||||
|
{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 - 1}
|
||||||
|
fill="#94a3b8"
|
||||||
|
textAnchor="middle"
|
||||||
|
style={{ fontSize: 7 }}>
|
||||||
|
{label}
|
||||||
|
</SvgText>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HBarChartProps {
|
||||||
|
items: Array<{ name: string; value: number }>;
|
||||||
|
color: string;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfHBarChart({ items, color, width = 460 }: HBarChartProps) {
|
||||||
|
const barH = 16;
|
||||||
|
const gap = 10;
|
||||||
|
const labelW = 150;
|
||||||
|
const valueW = 70;
|
||||||
|
const barAreaW = width - labelW - valueW - 8;
|
||||||
|
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 > 22 ? item.name.slice(0, 22) + '…' : item.name;
|
||||||
|
const valueStr = item.value.toLocaleString('en-SA', { maximumFractionDigits: 0 });
|
||||||
|
return (
|
||||||
|
<G key={item.name}>
|
||||||
|
<SvgText x={0} y={y + barH - 4} fill="#334155" style={{ fontSize: 8 }}>{shortName}</SvgText>
|
||||||
|
<Rect x={labelW} y={y} width={bw} height={barH} fill={color} rx={3} />
|
||||||
|
<SvgText x={labelW + bw + 4} y={y + barH - 4} fill="#64748b" style={{ fontSize: 8 }}>{valueStr}</SvgText>
|
||||||
|
</G>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { filterDataByDateRange, calculateMetrics, groupByMuseum, groupByChannel, umrahData } from '../../services/dataService';
|
||||||
|
import { shiftYear } from '../../lib/dateHelpers';
|
||||||
|
import type { MuseumRecord, Metrics } from '../../types';
|
||||||
|
|
||||||
|
// ─── config ───────────────────────────────────────────────────────
|
||||||
|
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;
|
||||||
|
showExecutiveSummary: boolean;
|
||||||
|
showMetricsTable: boolean;
|
||||||
|
showTrendChart: boolean;
|
||||||
|
showMuseumBreakdown: boolean;
|
||||||
|
showChannelBreakdown: boolean;
|
||||||
|
showPilgrimCapture: boolean;
|
||||||
|
language: 'en' | 'ar';
|
||||||
|
confidentiality: 'Confidential' | 'Internal' | 'Public';
|
||||||
|
orientation: 'portrait' | 'landscape';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIG: ReportConfig = {
|
||||||
|
title: '',
|
||||||
|
clientName: '',
|
||||||
|
contactName: '',
|
||||||
|
clientLogoBase64: null,
|
||||||
|
accentColor: '#2563eb',
|
||||||
|
startDate: new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10),
|
||||||
|
endDate: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString().slice(0, 10),
|
||||||
|
selectedMuseums: [],
|
||||||
|
selectedChannels: [],
|
||||||
|
includeVAT: true,
|
||||||
|
includeComparison: true,
|
||||||
|
showExecutiveSummary: true,
|
||||||
|
showMetricsTable: true,
|
||||||
|
showTrendChart: true,
|
||||||
|
showMuseumBreakdown: true,
|
||||||
|
showChannelBreakdown: true,
|
||||||
|
showPilgrimCapture: true,
|
||||||
|
language: 'en',
|
||||||
|
confidentiality: 'Confidential',
|
||||||
|
orientation: 'portrait',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── computed report data ─────────────────────────────────────────
|
||||||
|
export interface BreakdownItem { name: string; value: number; }
|
||||||
|
|
||||||
|
export interface ReportData {
|
||||||
|
config: ReportConfig;
|
||||||
|
metrics: Metrics;
|
||||||
|
prevMetrics: Metrics | null;
|
||||||
|
trendLabels: string[];
|
||||||
|
trendCurrent: number[];
|
||||||
|
trendPrevious: number[] | null;
|
||||||
|
museumBreakdown: BreakdownItem[];
|
||||||
|
channelBreakdown: BreakdownItem[];
|
||||||
|
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 buildTrend(rows: MuseumRecord[], start: string, cfg: ReportConfig): { 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 = Math.floor(diff / 7) + 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) => `W${i + 1}`);
|
||||||
|
const values = labels.map((_, i) => {
|
||||||
|
const group = acc[i + 1] || [];
|
||||||
|
return group.reduce((s, r) => s + (cfg.includeVAT ? r.revenue_gross : r.revenue_net) || 0, 0);
|
||||||
|
});
|
||||||
|
return { labels, values };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 prevStart = shiftYear(cfg.startDate);
|
||||||
|
const prevEnd = shiftYear(cfg.endDate);
|
||||||
|
const prevRows = cfg.includeComparison
|
||||||
|
? applyDimFilters(filterDataByDateRange(allData, prevStart, prevEnd, {}), cfg)
|
||||||
|
: [];
|
||||||
|
const prevMetrics = cfg.includeComparison ? calculateMetrics(prevRows, cfg.includeVAT) : null;
|
||||||
|
|
||||||
|
const currTrend = buildTrend(currRows, cfg.startDate, cfg);
|
||||||
|
const prevTrend = cfg.includeComparison ? buildTrend(prevRows, prevStart, cfg) : null;
|
||||||
|
const maxLen = Math.max(currTrend.labels.length, prevTrend?.values.length ?? 0);
|
||||||
|
const trendLabels = Array.from({ length: maxLen }, (_, i) => `W${i + 1}`);
|
||||||
|
const trendCurrent = Array.from({ length: maxLen }, (_, i) => currTrend.values[i] ?? 0);
|
||||||
|
const trendPrevious = prevTrend
|
||||||
|
? Array.from({ length: maxLen }, (_, i) => prevTrend.values[i] ?? 0)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const musG = groupByMuseum(currRows, cfg.includeVAT);
|
||||||
|
const museumBreakdown: BreakdownItem[] = Object.entries(musG)
|
||||||
|
.map(([name, g]) => ({ name, value: g.revenue }))
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const chanG = groupByChannel(currRows, cfg.includeVAT);
|
||||||
|
const channelBreakdown: BreakdownItem[] = Object.entries(chanG)
|
||||||
|
.map(([name, g]) => ({ name, value: g.visitors }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
const currPilgrims = estimatePilgrims(cfg.startDate, cfg.endDate);
|
||||||
|
const prevPilgrims = cfg.includeComparison ? estimatePilgrims(prevStart, prevEnd) : 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,
|
||||||
|
trendLabels,
|
||||||
|
trendCurrent,
|
||||||
|
trendPrevious,
|
||||||
|
museumBreakdown,
|
||||||
|
channelBreakdown,
|
||||||
|
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 } = data;
|
||||||
|
const lang = cfg.language;
|
||||||
|
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
|
||||||
|
const revenue = formatCurrency(metrics.revenue, cfg.includeVAT);
|
||||||
|
const topChannel = channelBreakdown[0]?.name ?? '';
|
||||||
|
const totalVisitors = channelBreakdown.reduce((s, i) => s + i.value, 0);
|
||||||
|
const topPct = totalVisitors > 0 && channelBreakdown[0]
|
||||||
|
? Math.round((channelBreakdown[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 the same period last year.`;
|
||||||
|
}
|
||||||
|
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)} في الإيرادات مقارنةً بالفترة ذاتها من العام الماضي.`;
|
||||||
|
}
|
||||||
|
if (topChannel) s += ` كانت ${topChannel} أعلى القنوات أداءً بنسبة ${topPct}% من إجمالي الزوار.`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── page count estimator ─────────────────────────────────────────
|
||||||
|
export function estimatePageCount(cfg: ReportConfig): number {
|
||||||
|
let pages = 2; // cover + first content page
|
||||||
|
if (cfg.showMuseumBreakdown) pages += 1;
|
||||||
|
if (cfg.showChannelBreakdown) pages += 1;
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user