Compare commits
32 Commits
ac32a541a1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f51280d1c | |||
| 89689c5979 | |||
| 49bda53598 | |||
| 2888936d54 | |||
| 131868a280 | |||
| 7365bc808b | |||
| 26bb69c76c | |||
| 1070490ad2 | |||
| c858075232 | |||
| 648365348f | |||
| 594321738a | |||
| b6bd3bcff5 | |||
| d59af22329 | |||
| 640538bcbd | |||
| 553928a3a9 | |||
| d925d41a79 | |||
| d7d035adb0 | |||
| cf6a4c0b3d | |||
| 2f90753f57 | |||
| 65025d7f3c | |||
| ab94d33868 | |||
| 64955f0f51 | |||
| c9cfb58896 | |||
| 30cdb5064a | |||
| 25cb91e31b | |||
| ef9a960e5d | |||
| 9138ac1098 | |||
| d3f9a6cd43 | |||
| 36df0065ed | |||
| c8c3465233 | |||
| 0f6881309c | |||
| 9064df82be |
@@ -0,0 +1,148 @@
|
|||||||
|
# Report Builder — Design Spec
|
||||||
|
**Date:** 2026-04-28
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A dedicated `/report` page (admin-only) where users configure a client-facing PDF report from scratch. The report is a professional business document — no app interface visible — downloadable as a `.pdf` file via `@react-pdf/renderer`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page Structure
|
||||||
|
|
||||||
|
Two-column layout on desktop, stacked on mobile:
|
||||||
|
- **Left panel (form):** all configuration fields, grouped into sections
|
||||||
|
- **Right panel:** a static document preview mockup (not a live render — too expensive). Shows real text fields (title, client name, period) updating in real time, but charts and metrics are represented as grey placeholder shapes. Gives the user a sense of page structure and section order.
|
||||||
|
- **Footer bar:** "Generate PDF" button (triggers download), estimated page count
|
||||||
|
|
||||||
|
The route is `/report`, protected the same way `/settings` is (admin only, via `userRole === 'admin'` check in `App.tsx`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Form Sections
|
||||||
|
|
||||||
|
### 1. Client Info
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Report title | Text input | e.g. "Q1 2025 Visitor Performance" |
|
||||||
|
| Prepared for (company) | Text input | Shown in report header |
|
||||||
|
| Contact name | Text input | Optional — "Attention: …" line |
|
||||||
|
| Client logo | File upload (PNG/JPG/SVG, max 2MB) | Base64-encoded, embedded in PDF header |
|
||||||
|
| Accent color | Color picker | Defaults to HiHala blue `#2563eb`; used for section headers, borders |
|
||||||
|
|
||||||
|
### 2. Data Selection
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Period start / end | Date inputs | Defaults to current month |
|
||||||
|
| Museums | Multi-select (same component as dashboard) | Empty = all |
|
||||||
|
| Channels | Multi-select | Empty = all |
|
||||||
|
| VAT | Toggle: Excl / Incl | Defaults to Incl |
|
||||||
|
| Include comparison | Checkbox | Adds previous-year column to metrics table |
|
||||||
|
|
||||||
|
### 3. Content Sections (toggleable)
|
||||||
|
Each is a checkbox, all on by default:
|
||||||
|
- Executive summary (3–4 sentences auto-generated from numbers)
|
||||||
|
- Key metrics table (Revenue, Visitors, Tickets, Avg Rev/Visitor, optionally Pilgrim Capture Rate)
|
||||||
|
- Revenue & visitor trend chart
|
||||||
|
- Breakdown by museum
|
||||||
|
- Breakdown by channel
|
||||||
|
- Pilgrim capture rate section (only shown if toggle is on AND data exists)
|
||||||
|
|
||||||
|
### 4. Presentation
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Language | Toggle: EN / AR | Controls all PDF text and direction |
|
||||||
|
| Confidentiality footer | Select: Confidential / Internal / Public | Shown in page footer |
|
||||||
|
| Page orientation | Toggle: Portrait / Landscape | Portrait default |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PDF Document Design
|
||||||
|
|
||||||
|
Built with `@react-pdf/renderer`. All layout is code — no DOM capture, no html2canvas.
|
||||||
|
|
||||||
|
**Page 1 — Cover**
|
||||||
|
- HiHala logo (top-left) + client logo (top-right)
|
||||||
|
- Large report title
|
||||||
|
- "Prepared for: [Company]" / "Attention: [Contact]"
|
||||||
|
- Period label (e.g. "January – March 2025")
|
||||||
|
- Generation date
|
||||||
|
- Accent color bar at bottom
|
||||||
|
|
||||||
|
**Page 2+ — Content pages**
|
||||||
|
- Shared header: small HiHala wordmark + report title + page number
|
||||||
|
- Sections in the order the user toggled them on
|
||||||
|
- Each section starts with a colored heading bar (accent color)
|
||||||
|
- Confidentiality level in page footer
|
||||||
|
|
||||||
|
**Chart rendering:**
|
||||||
|
Charts do not use the live Chart.js instances. Instead, `@react-pdf/renderer` draws simplified chart equivalents natively using SVG primitives (`<Svg>`, `<Rect>`, `<Path>`, `<Line>`) — no canvas capture needed. This produces crisp vector output at any print resolution.
|
||||||
|
|
||||||
|
Simplified charts to implement:
|
||||||
|
- **Trend line chart:** SVG polyline over a grid
|
||||||
|
- **Bar chart (museum/channel breakdown):** horizontal SVG bars with labels
|
||||||
|
|
||||||
|
**Executive summary generation:**
|
||||||
|
Computed from the metrics — a template string filled with actual numbers. Example (EN):
|
||||||
|
> "During [period], [selected museums] recorded [X] visitors and [Y SAR] in revenue, representing a [Z%] change versus the same period last year. The top-performing channel was [channel] with [N%] of total tickets."
|
||||||
|
|
||||||
|
The same template exists in Arabic (stored in the locale file alongside EN/AR strings already in the codebase). Falls back gracefully if comparison data is absent (omits the change sentence).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
/report page
|
||||||
|
→ user fills form
|
||||||
|
→ clicks "Generate PDF"
|
||||||
|
→ reads filtered data from already-loaded app state (passed as prop from App.tsx)
|
||||||
|
OR re-fetches if needed (dataService.fetchData())
|
||||||
|
→ applies period + museum + channel filters client-side
|
||||||
|
→ computes metrics (reuses existing calculateMetrics())
|
||||||
|
→ passes computed values to <ReportDocument /> (react-pdf component)
|
||||||
|
→ pdf.download('hihala-report.pdf')
|
||||||
|
```
|
||||||
|
|
||||||
|
No new API endpoints required. All computation is client-side using existing `dataService` functions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
components/
|
||||||
|
Report/
|
||||||
|
index.tsx — the /report page (form + preview layout)
|
||||||
|
ReportForm.tsx — the left-panel form
|
||||||
|
ReportPreview.tsx — the right-panel static mockup
|
||||||
|
ReportDocument.tsx — the @react-pdf/renderer document
|
||||||
|
reportCharts.tsx — SVG chart primitives for PDF
|
||||||
|
reportHelpers.ts — executive summary generator, data filters
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
- Desktop nav: gear icon already links to `/settings`; add a "Report" link (document icon) next to it, admin-only
|
||||||
|
- Mobile bottom nav: add Report icon between Comparison and Settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `@react-pdf/renderer` — PDF generation
|
||||||
|
- No other new dependencies (reuses existing AltMultiSelect, form inputs, data service)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Scheduled / emailed reports
|
||||||
|
- Saving report configurations
|
||||||
|
- Non-admin users generating reports
|
||||||
|
- Live chart preview in the right panel (static mockup only)
|
||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
<meta name="description" content="HiHala Data Dashboard — Event analytics, visitor tracking, and revenue insights" />
|
<meta name="description" content="HiHala Data Dashboard — Event analytics, visitor tracking, and revenue insights" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap">
|
||||||
<title>HiHala Data</title>
|
<title>HiHala Data</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+589
-1
@@ -8,6 +8,7 @@
|
|||||||
"name": "hihala-dashboard",
|
"name": "hihala-dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -827,6 +828,207 @@
|
|||||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/ciphers": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/fns": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/font": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/pdfkit": "^5.1.1",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"fontkit": "^2.0.2",
|
||||||
|
"is-url": "^1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/image": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/svg": "^1.1.0",
|
||||||
|
"jay-peg": "^1.1.1",
|
||||||
|
"png-js": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/layout": {
|
||||||
|
"version": "4.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.6.1.tgz",
|
||||||
|
"integrity": "sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/image": "^3.1.0",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/stylesheet": "^6.2.1",
|
||||||
|
"@react-pdf/textkit": "^6.3.0",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"emoji-regex-xs": "^1.0.0",
|
||||||
|
"queue": "^6.0.1",
|
||||||
|
"yoga-layout": "^3.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/pdfkit": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@noble/ciphers": "^1.0.0",
|
||||||
|
"@noble/hashes": "^1.6.0",
|
||||||
|
"browserify-zlib": "^0.2.0",
|
||||||
|
"fontkit": "^2.0.2",
|
||||||
|
"jay-peg": "^1.1.1",
|
||||||
|
"js-md5": "^0.8.3",
|
||||||
|
"linebreak": "^1.1.0",
|
||||||
|
"png-js": "^2.0.0",
|
||||||
|
"vite-compatible-readable-stream": "^3.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/primitives": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/reconciler": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"scheduler": "0.25.0-rc-603e6108-20241029"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
|
||||||
|
"version": "0.25.0-rc-603e6108-20241029",
|
||||||
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
|
||||||
|
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/render": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/textkit": "^6.3.0",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"abs-svg-path": "^0.1.1",
|
||||||
|
"color-string": "^2.1.4",
|
||||||
|
"normalize-svg-path": "^1.1.0",
|
||||||
|
"parse-svg-path": "^0.1.2",
|
||||||
|
"svg-arc-to-cubic-bezier": "^3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/renderer": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/font": "^4.0.8",
|
||||||
|
"@react-pdf/layout": "^4.6.1",
|
||||||
|
"@react-pdf/pdfkit": "^5.1.1",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/reconciler": "^2.0.0",
|
||||||
|
"@react-pdf/render": "^4.5.1",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"prop-types": "^15.6.2",
|
||||||
|
"queue": "^6.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/stylesheet": {
|
||||||
|
"version": "6.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.2.1.tgz",
|
||||||
|
"integrity": "sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"color-string": "^2.1.4",
|
||||||
|
"hsl-to-hex": "^1.0.0",
|
||||||
|
"media-engine": "^1.0.3",
|
||||||
|
"postcss-value-parser": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/svg": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/svg/-/svg-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/primitives": "^4.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/textkit": {
|
||||||
|
"version": "6.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.3.0.tgz",
|
||||||
|
"integrity": "sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"bidi-js": "^1.0.2",
|
||||||
|
"hyphen": "^1.6.4",
|
||||||
|
"unicode-properties": "^1.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/types": {
|
||||||
|
"version": "2.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.11.1.tgz",
|
||||||
|
"integrity": "sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/font": "^4.0.8",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/stylesheet": "^6.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.3",
|
"version": "1.0.0-rc.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||||
@@ -1184,6 +1386,15 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz",
|
||||||
|
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
@@ -1419,6 +1630,12 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/abs-svg-path": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
@@ -1462,6 +1679,26 @@
|
|||||||
"node": ">= 0.6.0"
|
"node": ">= 0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||||
@@ -1475,6 +1712,33 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/brotli": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/browserify-zlib": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "~1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.1",
|
"version": "4.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
@@ -1605,6 +1869,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -1625,6 +1898,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/color-string": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-string/node_modules/color-name": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concurrently": {
|
"node_modules/concurrently": {
|
||||||
"version": "9.2.1",
|
"version": "9.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||||
@@ -1712,6 +2006,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dfa": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dom-accessibility-api": {
|
"node_modules/dom-accessibility-api": {
|
||||||
"version": "0.5.16",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
@@ -1732,6 +2032,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex-xs": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
@@ -1784,6 +2090,21 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/events": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1802,6 +2123,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fontkit": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.12",
|
||||||
|
"brotli": "^1.3.2",
|
||||||
|
"clone": "^2.1.2",
|
||||||
|
"dfa": "^1.2.0",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"restructure": "^3.0.0",
|
||||||
|
"tiny-inflate": "^1.0.3",
|
||||||
|
"unicode-properties": "^1.4.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1847,6 +2191,21 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hsl-to-hex": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hsl-to-rgb-for-reals": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hsl-to-rgb-for-reals": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/html2canvas": {
|
"node_modules/html2canvas": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
@@ -1860,6 +2219,12 @@
|
|||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hyphen": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/immediate": {
|
"node_modules/immediate": {
|
||||||
"version": "3.0.6",
|
"version": "3.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
@@ -1891,6 +2256,27 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-url": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jay-peg": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"restructure": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/js-md5": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -1980,6 +2366,37 @@
|
|||||||
"immediate": "~3.0.5"
|
"immediate": "~3.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linebreak": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "0.0.8",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linebreak/node_modules/base64-js": {
|
||||||
|
"version": "0.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||||
|
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/loose-envify": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"loose-envify": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -1999,6 +2416,12 @@
|
|||||||
"lz-string": "bin/bin.js"
|
"lz-string": "bin/bin.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/media-engine": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/min-indent": {
|
"node_modules/min-indent": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||||
@@ -2041,12 +2464,36 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/normalize-svg-path": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"svg-arc-to-cubic-bezier": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pako": {
|
"node_modules/pako": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
"license": "(MIT AND Zlib)"
|
"license": "(MIT AND Zlib)"
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-svg-path": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2066,6 +2513,14 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/png-js": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
|
||||||
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
@@ -2095,6 +2550,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss-value-parser": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pretty-format": {
|
"node_modules/pretty-format": {
|
||||||
"version": "27.5.1",
|
"version": "27.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||||
@@ -2127,6 +2588,32 @@
|
|||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/prop-types": {
|
||||||
|
"version": "15.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"react-is": "^16.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prop-types/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/queue": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "~2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
@@ -2248,6 +2735,21 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/restructure": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
@@ -2303,6 +2805,26 @@
|
|||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -2354,6 +2876,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
@@ -2410,6 +2941,12 @@
|
|||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svg-arc-to-cubic-bezier": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/text-segmentation": {
|
"node_modules/text-segmentation": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
@@ -2419,6 +2956,12 @@
|
|||||||
"utrie": "^1.0.2"
|
"utrie": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-inflate": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -2450,7 +2993,6 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
@@ -2474,6 +3016,32 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/unicode-properties": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^0.2.5",
|
||||||
|
"tiny-inflate": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie/node_modules/pako": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -2595,6 +3163,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-compatible-readable-stream": {
|
||||||
|
"version": "3.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
|
||||||
|
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/web-vitals": {
|
"node_modules/web-vitals": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
|
||||||
@@ -2664,6 +3246,12 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yoga-layout": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { fetchSales, isConfigured } from '../services/erpClient';
|
import { fetchSales, isConfigured } from '../services/erpClient';
|
||||||
|
import { etl } from '../config';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|||||||
+41
-17
@@ -1,34 +1,58 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { etl } from '../config';
|
import { etl } from '../config';
|
||||||
import { runSync } from '../services/etlSync';
|
import { runSync, SyncResult } from '../services/etlSync';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// POST /api/etl/sync?mode=full|incremental
|
type SyncState =
|
||||||
router.post('/sync', async (req: Request, res: Response) => {
|
| { status: 'idle' }
|
||||||
// Auth check
|
| { status: 'running'; mode: string; startedAt: string; currentMonth?: string }
|
||||||
const auth = req.headers.authorization;
|
| { status: 'done'; result: SyncResult; finishedAt: string }
|
||||||
if (etl.secret && auth !== `Bearer ${etl.secret}`) {
|
| { status: 'error'; error: string; finishedAt: string };
|
||||||
|
|
||||||
|
let syncState: SyncState = { status: 'idle' };
|
||||||
|
|
||||||
|
function auth(req: Request, res: Response): boolean {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
if (etl.secret && header !== `Bearer ${etl.secret}`) {
|
||||||
res.status(401).json({ error: 'Unauthorized' });
|
res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/etl/sync?mode=full|incremental — fires and returns immediately
|
||||||
|
router.post('/sync', (req: Request, res: Response) => {
|
||||||
|
if (!auth(req, res)) return;
|
||||||
|
|
||||||
|
if (syncState.status === 'running') {
|
||||||
|
res.status(409).json({ error: 'Sync already running', state: syncState });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = (req.query.mode as string) === 'full' ? 'full' : 'incremental';
|
const mode = (req.query.mode as string) === 'full' ? 'full' : 'incremental';
|
||||||
|
syncState = { status: 'running', mode, startedAt: new Date().toISOString() };
|
||||||
|
|
||||||
try {
|
res.json({ accepted: true, mode, message: 'Sync started — poll GET /api/etl/status for progress' });
|
||||||
console.log(`\nETL sync started (${mode})...`);
|
|
||||||
const result = await runSync(mode);
|
console.log(`\nETL sync started (${mode})...`);
|
||||||
console.log(`ETL sync complete: ${result.transactionsFetched} transactions → ${result.recordsWritten} records in ${result.duration}`);
|
runSync(mode, (month) => {
|
||||||
res.json(result);
|
if (syncState.status === 'running') syncState = { ...syncState, currentMonth: month };
|
||||||
} catch (err) {
|
})
|
||||||
console.error('ETL sync failed:', (err as Error).message);
|
.then(result => {
|
||||||
res.status(500).json({ error: 'ETL sync failed', details: (err as Error).message });
|
console.log(`ETL sync complete: ${result.transactionsFetched} transactions → ${result.recordsWritten} records in ${result.duration}`);
|
||||||
}
|
syncState = { status: 'done', result, finishedAt: new Date().toISOString() };
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('ETL sync failed:', (err as Error).message);
|
||||||
|
syncState = { status: 'error', error: (err as Error).message, finishedAt: new Date().toISOString() };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/etl/status
|
// GET /api/etl/status
|
||||||
router.get('/status', (_req: Request, res: Response) => {
|
router.get('/status', (req: Request, res: Response) => {
|
||||||
res.json({ configured: !!etl.secret });
|
if (!auth(req, res)) return;
|
||||||
|
res.json(syncState);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export interface SyncResult {
|
|||||||
duration: string;
|
duration: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runSync(mode: 'full' | 'incremental' = 'incremental'): Promise<SyncResult> {
|
export async function runSync(mode: 'full' | 'incremental' = 'incremental', onMonth?: (month: string) => void): Promise<SyncResult> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
const tables = await discoverTableIds();
|
const tables = await discoverTableIds();
|
||||||
@@ -113,7 +113,9 @@ export async function runSync(mode: 'full' | 'incremental' = 'incremental'): Pro
|
|||||||
// Fetch from ERP sequentially (API can't handle concurrent requests)
|
// Fetch from ERP sequentially (API can't handle concurrent requests)
|
||||||
const allSales: ERPSaleRecord[] = [];
|
const allSales: ERPSaleRecord[] = [];
|
||||||
for (const [startDate, endDate] of months) {
|
for (const [startDate, endDate] of months) {
|
||||||
console.log(` Fetching ${startDate.slice(0, 7)}...`);
|
const monthLabel = startDate.slice(0, 7);
|
||||||
|
console.log(` Fetching ${monthLabel}...`);
|
||||||
|
onMonth?.(monthLabel);
|
||||||
const chunk = await fetchSales(startDate, endDate) as ERPSaleRecord[];
|
const chunk = await fetchSales(startDate, endDate) as ERPSaleRecord[];
|
||||||
allSales.push(...chunk);
|
allSales.push(...chunk);
|
||||||
}
|
}
|
||||||
|
|||||||
+1462
-526
File diff suppressed because it is too large
Load Diff
+54
-10
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo, ReactNode, lazy, Suspense } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, ReactNode, lazy, Suspense } from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
const Dashboard = lazy(() => import('./components/Dashboard'));
|
|
||||||
const Comparison = lazy(() => import('./components/Comparison'));
|
|
||||||
const Settings = lazy(() => import('./components/Settings'));
|
const Settings = lazy(() => import('./components/Settings'));
|
||||||
|
const Comparison = lazy(() => import('./components/Comparison'));
|
||||||
|
const Dashboard = lazy(() => import('./components/Dashboard'));
|
||||||
|
const Report = lazy(() => import('./components/Report'));
|
||||||
import Login from './components/Login';
|
import 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';
|
||||||
@@ -30,6 +31,10 @@ function NavLink({ to, children, className }: NavLinkProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppNav({ children }: { children: ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
interface DataSource {
|
interface DataSource {
|
||||||
id: string;
|
id: string;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
@@ -51,7 +56,6 @@ function App() {
|
|||||||
const [error, setError] = useState<{ message: string; type: DataErrorType } | null>(null);
|
const [error, setError] = useState<{ message: string; type: DataErrorType } | null>(null);
|
||||||
const [isOffline, setIsOffline] = useState<boolean>(false);
|
const [isOffline, setIsOffline] = useState<boolean>(false);
|
||||||
const [cacheInfo, setCacheInfo] = useState<CacheStatus | null>(null);
|
const [cacheInfo, setCacheInfo] = useState<CacheStatus | null>(null);
|
||||||
const [showDataLabels, setShowDataLabels] = useState<boolean>(false);
|
|
||||||
const [includeVAT, setIncludeVAT] = useState<boolean>(true);
|
const [includeVAT, setIncludeVAT] = useState<boolean>(true);
|
||||||
const [dataSource, setDataSource] = useState<string>('museums');
|
const [dataSource, setDataSource] = useState<string>('museums');
|
||||||
const [seasons, setSeasons] = useState<Season[]>([]);
|
const [seasons, setSeasons] = useState<Season[]>([]);
|
||||||
@@ -188,7 +192,7 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<div className="app" dir={dir}>
|
<div className="app" dir={dir}>
|
||||||
<nav className="nav-bar" aria-label={t('nav.dashboard')}>
|
<AppNav><nav className="nav-bar" aria-label={t('nav.dashboard')}>
|
||||||
<div className="nav-content">
|
<div className="nav-content">
|
||||||
<div className="nav-brand">
|
<div className="nav-brand">
|
||||||
<svg className="nav-brand-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<svg className="nav-brand-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
@@ -198,7 +202,8 @@ function App() {
|
|||||||
<rect x="14" y="11" width="7" height="10" rx="1"/>
|
<rect x="14" y="11" width="7" height="10" rx="1"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="nav-brand-text">
|
<span className="nav-brand-text">
|
||||||
HiHala Data
|
<span className="nav-brand-name">HiHala</span>
|
||||||
|
<span className="nav-brand-tag">Data</span>
|
||||||
<select
|
<select
|
||||||
className="data-source-select"
|
className="data-source-select"
|
||||||
value={dataSource}
|
value={dataSource}
|
||||||
@@ -233,6 +238,27 @@ function App() {
|
|||||||
</svg>
|
</svg>
|
||||||
{t('nav.comparison')}
|
{t('nav.comparison')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
{userRole === 'admin' && (
|
||||||
|
<NavLink to="/settings">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
{t('nav.settings')}
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
{userRole === 'admin' && (
|
||||||
|
<NavLink to="/report">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
|
</svg>
|
||||||
|
Report
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
<span className="nav-sep" aria-hidden="true" />
|
||||||
{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()}` : ''}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@@ -245,6 +271,11 @@ function App() {
|
|||||||
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('app.offline') || 'Offline'}
|
{t('app.offline') || 'Offline'}
|
||||||
|
{cacheInfo && (
|
||||||
|
<span className="sr-only">
|
||||||
|
{` (cached ${new Date(cacheInfo.timestamp || '').toLocaleString()})`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -291,20 +322,21 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav></AppNav>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<Suspense fallback={<LoadingSkeleton />}>
|
<Suspense fallback={<LoadingSkeleton />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard data={data} seasons={seasons} userRole={userRole} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} 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} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||||
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
|
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
|
||||||
|
{userRole === 'admin' && <Route path="/report" element={<Report data={data} />} />}
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Mobile Bottom Navigation */}
|
{/* Mobile Bottom Navigation */}
|
||||||
<nav className="mobile-nav" aria-label="Mobile navigation">
|
<AppNav><nav className="mobile-nav" aria-label="Mobile navigation">
|
||||||
<NavLink to="/" className="mobile-nav-item">
|
<NavLink to="/" 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">
|
||||||
<rect x="3" y="3" width="7" height="9" rx="1"/>
|
<rect x="3" y="3" width="7" height="9" rx="1"/>
|
||||||
@@ -322,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">
|
||||||
@@ -342,7 +386,7 @@ function App() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{t('language.switch')}</span>
|
<span>{t('language.switch')}</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav></AppNav>
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
import React, { useRef, useState, ReactNode } from 'react';
|
|
||||||
import JSZip from 'jszip';
|
|
||||||
|
|
||||||
interface ExportableChartProps {
|
|
||||||
children: ReactNode;
|
|
||||||
filename?: string;
|
|
||||||
title?: string;
|
|
||||||
className?: string;
|
|
||||||
controls?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrapper component that adds PNG export to any chart
|
|
||||||
export function ExportableChart({
|
|
||||||
children,
|
|
||||||
filename = 'chart',
|
|
||||||
title = '',
|
|
||||||
className = '',
|
|
||||||
controls = null
|
|
||||||
}: ExportableChartProps) {
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const exportAsPNG = () => {
|
|
||||||
const chartContainer = chartRef.current;
|
|
||||||
if (!chartContainer) return;
|
|
||||||
|
|
||||||
const canvas = chartContainer.querySelector('canvas');
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
// Create a new canvas with white background and title
|
|
||||||
const exportCanvas = document.createElement('canvas');
|
|
||||||
const ctx = exportCanvas.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
// Set dimensions with padding and title space
|
|
||||||
const padding = 24;
|
|
||||||
const titleHeight = title ? 48 : 0;
|
|
||||||
exportCanvas.width = canvas.width + (padding * 2);
|
|
||||||
exportCanvas.height = canvas.height + (padding * 2) + titleHeight;
|
|
||||||
|
|
||||||
// Fill white background
|
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
|
||||||
|
|
||||||
// Draw title if provided (left-aligned, matching on-screen style)
|
|
||||||
if (title) {
|
|
||||||
ctx.fillStyle = '#1e293b';
|
|
||||||
ctx.font = '600 20px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
||||||
ctx.textAlign = 'left';
|
|
||||||
ctx.fillText(title, padding, padding + 24);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw the chart
|
|
||||||
ctx.drawImage(canvas, padding, padding + titleHeight);
|
|
||||||
|
|
||||||
// Export
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.png`;
|
|
||||||
link.href = exportCanvas.toDataURL('image/png', 1.0);
|
|
||||||
link.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="exportable-chart-wrapper">
|
|
||||||
{/* Download button - positioned absolutely in corner */}
|
|
||||||
<button
|
|
||||||
className="chart-export-btn visible"
|
|
||||||
onClick={exportAsPNG}
|
|
||||||
title="Download as PNG"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<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>
|
|
||||||
</button>
|
|
||||||
{title && (
|
|
||||||
<div className="chart-header-with-export">
|
|
||||||
<h2>{title}</h2>
|
|
||||||
{controls && <div className="chart-header-actions">{controls}</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!title && controls && <div className="chart-controls">{controls}</div>}
|
|
||||||
<div className={`exportable-chart ${className}`}>
|
|
||||||
<div ref={chartRef} className="chart-canvas-wrapper">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility function to export all charts from a container as a ZIP
|
|
||||||
export async function exportAllCharts(containerSelector: string, zipFilename: string = 'charts'): Promise<void> {
|
|
||||||
const container = document.querySelector(containerSelector);
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const zip = new JSZip();
|
|
||||||
const chartWrappers = container.querySelectorAll('.exportable-chart-wrapper');
|
|
||||||
|
|
||||||
for (let i = 0; i < chartWrappers.length; i++) {
|
|
||||||
const wrapper = chartWrappers[i];
|
|
||||||
const canvas = wrapper.querySelector('canvas');
|
|
||||||
const titleEl = wrapper.querySelector('.chart-header-with-export h2');
|
|
||||||
const title = titleEl?.textContent || `chart-${i + 1}`;
|
|
||||||
|
|
||||||
if (!canvas) continue;
|
|
||||||
|
|
||||||
// Create export canvas with white background and title
|
|
||||||
const exportCanvas = document.createElement('canvas');
|
|
||||||
const ctx = exportCanvas.getContext('2d');
|
|
||||||
if (!ctx) continue;
|
|
||||||
|
|
||||||
const padding = 32;
|
|
||||||
const titleHeight = 56;
|
|
||||||
exportCanvas.width = canvas.width + (padding * 2);
|
|
||||||
exportCanvas.height = canvas.height + (padding * 2) + titleHeight;
|
|
||||||
|
|
||||||
// White background
|
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
|
||||||
|
|
||||||
// Draw title
|
|
||||||
ctx.fillStyle = '#1e293b';
|
|
||||||
ctx.font = '600 24px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
||||||
ctx.textAlign = 'left';
|
|
||||||
ctx.fillText(title, padding, padding + 28);
|
|
||||||
|
|
||||||
// Draw chart
|
|
||||||
ctx.drawImage(canvas, padding, padding + titleHeight);
|
|
||||||
|
|
||||||
// Convert to blob and add to zip
|
|
||||||
const dataUrl = exportCanvas.toDataURL('image/png', 1.0);
|
|
||||||
const base64Data = dataUrl.split(',')[1];
|
|
||||||
const safeFilename = title.replace(/[^a-zA-Z0-9\u0600-\u06FF\s-]/g, '').replace(/\s+/g, '-');
|
|
||||||
zip.file(`${String(i + 1).padStart(2, '0')}-${safeFilename}.png`, base64Data, { base64: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate and download ZIP
|
|
||||||
const blob = await zip.generateAsync({ type: 'blob' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `${zipFilename}-${new Date().toISOString().split('T')[0]}.zip`;
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExportAllButtonProps {
|
|
||||||
containerSelector: string;
|
|
||||||
zipFilename?: string;
|
|
||||||
label: string;
|
|
||||||
loadingLabel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Button component for exporting all charts
|
|
||||||
export function ExportAllButton({ containerSelector, zipFilename = 'charts', label, loadingLabel }: ExportAllButtonProps) {
|
|
||||||
const [exporting, setExporting] = useState(false);
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
|
||||||
setExporting(true);
|
|
||||||
try {
|
|
||||||
await exportAllCharts(containerSelector, zipFilename);
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="btn-export-all"
|
|
||||||
onClick={handleExport}
|
|
||||||
disabled={exporting}
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<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>
|
|
||||||
{exporting ? loadingLabel : label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ExportableChart;
|
|
||||||
+326
-930
File diff suppressed because it is too large
Load Diff
+353
-979
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,9 @@ function Login({ onLogin }: LoginProps) {
|
|||||||
<p className="login-subtitle">{t('login.subtitle')}</p>
|
<p className="login-subtitle">{t('login.subtitle')}</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
|
<label htmlFor="pin-input" className="sr-only">{t('login.placeholder')}</label>
|
||||||
<input
|
<input
|
||||||
|
id="pin-input"
|
||||||
type="password"
|
type="password"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={pin}
|
value={pin}
|
||||||
|
|||||||
@@ -0,0 +1,633 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Document, Page, View, Text, Image, StyleSheet, Font
|
||||||
|
} from '@react-pdf/renderer';
|
||||||
|
import { PdfTrendChart, PdfHBarChart, CHART_PALETTE } from './reportCharts';
|
||||||
|
import {
|
||||||
|
ReportData, MuseumDataRow, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary
|
||||||
|
} from './reportHelpers';
|
||||||
|
|
||||||
|
Font.register({
|
||||||
|
family: 'IBMPlexArabic',
|
||||||
|
fonts: [
|
||||||
|
{ src: '/fonts/IBMPlexSansArabic-Regular.woff2', fontWeight: 400 },
|
||||||
|
{ src: '/fonts/IBMPlexSansArabic-Bold.woff2', fontWeight: 700 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const TOTAL_LINE_COLOR = '#1e293b';
|
||||||
|
|
||||||
|
// A4 content width minus chart-wrap padding (14×2)
|
||||||
|
// Portrait: 595 - 44 - 44 - 28 = 479
|
||||||
|
// Landscape: 842 - 44 - 44 - 28 = 726
|
||||||
|
const CHART_W = { portrait: 479, landscape: 726 } as const;
|
||||||
|
|
||||||
|
const S = StyleSheet.create({
|
||||||
|
page: { fontFamily: 'Helvetica', fontSize: 10, color: '#0f172a', backgroundColor: '#ffffff' },
|
||||||
|
|
||||||
|
// ── Cover ──────────────────────────────────────────────
|
||||||
|
coverPage: { flexDirection: 'column', padding: 0 },
|
||||||
|
coverHeader: { paddingTop: 56, paddingRight: 52, paddingBottom: 52, paddingLeft: 52 },
|
||||||
|
coverHeaderTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 48 },
|
||||||
|
coverBrand: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', letterSpacing: 0.8 },
|
||||||
|
coverLogoBox: { width: 90, height: 44, justifyContent: 'flex-end', alignItems: 'flex-end' },
|
||||||
|
coverClientLogo: { width: 90, height: 44, objectFit: 'contain' as const },
|
||||||
|
coverTitle: { fontSize: 36, fontFamily: 'Helvetica-Bold', color: '#ffffff', lineHeight: 1.2 },
|
||||||
|
coverBody: { flex: 1, paddingTop: 44, paddingRight: 52, paddingBottom: 44, paddingLeft: 52, flexDirection: 'column' },
|
||||||
|
coverClientName: { fontSize: 15, color: '#0f172a', fontFamily: 'Helvetica-Bold', marginBottom: 5 },
|
||||||
|
coverContactName: { fontSize: 11, color: '#64748b', marginBottom: 32 },
|
||||||
|
coverBodySpacer: { flex: 1 },
|
||||||
|
coverPeriodRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 5 },
|
||||||
|
coverPeriodDot: { width: 6, height: 6, borderRadius: 3, marginRight: 8 },
|
||||||
|
coverPeriod: { fontSize: 12, color: '#334155', fontFamily: 'Helvetica-Oblique' },
|
||||||
|
coverDate: { fontSize: 9, color: '#94a3b8', marginBottom: 20 },
|
||||||
|
coverConfidential: { fontSize: 7.5, color: '#94a3b8', letterSpacing: 2, paddingTop: 10, borderTopWidth: 1, borderTopColor: '#e2e8f0' },
|
||||||
|
|
||||||
|
// ── Content pages ──────────────────────────────────────
|
||||||
|
contentPage: { paddingTop: 34, paddingRight: 44, paddingBottom: 54, paddingLeft: 44 },
|
||||||
|
pageHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderBottomWidth: 1.5, borderBottomColor: '#e2e8f0', paddingBottom: 10, marginBottom: 26 },
|
||||||
|
pageHeaderLogo: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: '#2563eb' },
|
||||||
|
pageHeaderTitle: { fontSize: 9, color: '#94a3b8' },
|
||||||
|
pageHeaderNum: { fontSize: 9, color: '#94a3b8' },
|
||||||
|
pageFooter: { position: 'absolute', bottom: 22, left: 44, right: 44, flexDirection: 'row', justifyContent: 'space-between', borderTopWidth: 1, borderTopColor: '#f1f5f9', paddingTop: 6 },
|
||||||
|
pageFooterText: { fontSize: 7.5, color: '#b0bec5' },
|
||||||
|
|
||||||
|
// ── Section headings ───────────────────────────────────
|
||||||
|
sectionHeading: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', paddingTop: 8, paddingRight: 14, paddingBottom: 8, paddingLeft: 14, marginBottom: 16, borderRadius: 4 },
|
||||||
|
sectionGap: { marginBottom: 28 },
|
||||||
|
|
||||||
|
// ── Executive summary ──────────────────────────────────
|
||||||
|
summaryText: { fontSize: 10.5, color: '#334155', lineHeight: 1.7 },
|
||||||
|
|
||||||
|
// ── Key metrics table ──────────────────────────────────
|
||||||
|
metricsTable: { marginBottom: 8 },
|
||||||
|
metricsHeaderRow: { flexDirection: 'row', backgroundColor: '#f1f5f9', paddingTop: 5, paddingBottom: 5, marginBottom: 2, borderRadius: 3 },
|
||||||
|
metricsHeaderLabel: { flex: 1.8, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', paddingLeft: 8 },
|
||||||
|
metricsHeaderCell: { flex: 1, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right', paddingRight: 6 },
|
||||||
|
metricsRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 7 },
|
||||||
|
metricsRowAlt: { backgroundColor: '#fafbfd' },
|
||||||
|
metricsLabel: { flex: 1.8, fontSize: 10, color: '#334155', fontFamily: 'Helvetica-Bold', paddingLeft: 8 },
|
||||||
|
metricsValue: { flex: 1, fontSize: 10, color: '#0f172a', textAlign: 'right', paddingRight: 6 },
|
||||||
|
metricsChange: { flex: 0.8, fontSize: 9, textAlign: 'right', paddingRight: 6 },
|
||||||
|
metricsChangeUp: { color: '#059669' },
|
||||||
|
metricsChangeDown: { color: '#dc2626' },
|
||||||
|
|
||||||
|
// ── Trend chart ────────────────────────────────────────
|
||||||
|
chartWrap: { marginBottom: 8, backgroundColor: '#f8fafc', paddingTop: 14, paddingRight: 14, paddingBottom: 14, paddingLeft: 14, borderRadius: 6, borderWidth: 1, borderColor: '#f1f5f9' },
|
||||||
|
legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 10 },
|
||||||
|
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18, marginBottom: 4 },
|
||||||
|
legendDot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
|
legendLabel: { fontSize: 8, color: '#64748b', marginLeft: 5 },
|
||||||
|
|
||||||
|
// ── Museum mini-reports ────────────────────────────────
|
||||||
|
museumBlock: { marginBottom: 20, borderLeftWidth: 3, paddingLeft: 12 },
|
||||||
|
museumBlockName: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#0f172a', marginBottom: 4 },
|
||||||
|
museumIntroText: { fontSize: 9.5, color: '#64748b', fontFamily: 'Helvetica-Oblique', marginBottom: 10 },
|
||||||
|
miniTable: { marginBottom: 4 },
|
||||||
|
miniHeaderRow: { flexDirection: 'row', backgroundColor: '#f1f5f9', paddingTop: 4, paddingBottom: 4, marginBottom: 1, borderRadius: 2 },
|
||||||
|
miniHeaderLabel: { flex: 2, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', paddingLeft: 6 },
|
||||||
|
miniHeaderCell: { flex: 2, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right', paddingRight: 6 },
|
||||||
|
miniHeaderChangeCell: { flex: 1, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right', paddingRight: 6 },
|
||||||
|
miniRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 5 },
|
||||||
|
miniRowAlt: { backgroundColor: '#fafafa' },
|
||||||
|
miniLabel: { flex: 2, fontSize: 9.5, color: '#334155', fontFamily: 'Helvetica-Bold', paddingLeft: 6 },
|
||||||
|
miniValue: { flex: 2, fontSize: 9.5, color: '#0f172a', textAlign: 'right', paddingRight: 6 },
|
||||||
|
miniChange: { flex: 1, fontSize: 9, textAlign: 'right', paddingRight: 6 },
|
||||||
|
miniChangeUp: { color: '#059669' },
|
||||||
|
miniChangeDown: { color: '#dc2626' },
|
||||||
|
|
||||||
|
// ── Global summary table ───────────────────────────────
|
||||||
|
summarySubLabel: { fontSize: 9, color: '#64748b', marginBottom: 14 },
|
||||||
|
summaryHeaderRow: { flexDirection: 'row', backgroundColor: '#0f172a', paddingTop: 8, paddingBottom: 8, borderRadius: 4 },
|
||||||
|
summaryHeaderMuseum: { flex: 3, fontSize: 8.5, fontFamily: 'Helvetica-Bold', color: '#ffffff', paddingLeft: 10 },
|
||||||
|
summaryHeaderMetric: { flex: 2, fontSize: 8.5, fontFamily: 'Helvetica-Bold', color: '#ffffff', textAlign: 'right', paddingRight: 6 },
|
||||||
|
summaryHeaderDelta: { flex: 1, fontSize: 8.5, fontFamily: 'Helvetica-Bold', color: '#ffffff', textAlign: 'right', paddingRight: 6 },
|
||||||
|
summaryRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 6 },
|
||||||
|
summaryRowAlt: { backgroundColor: '#f8fafc' },
|
||||||
|
summaryTotalRow: { flexDirection: 'row', borderTopWidth: 2, borderTopColor: '#0f172a', paddingTop: 8, paddingBottom: 5, marginTop: 2 },
|
||||||
|
summaryMuseum: { flex: 3, fontSize: 9.5, color: '#0f172a', paddingLeft: 10 },
|
||||||
|
summaryMuseumTotal: { flex: 3, fontSize: 9.5, fontFamily: 'Helvetica-Bold', color: '#0f172a', paddingLeft: 10 },
|
||||||
|
summaryMetric: { flex: 2, fontSize: 9.5, color: '#0f172a', textAlign: 'right', paddingRight: 6 },
|
||||||
|
summaryMetricTotal: { flex: 2, fontSize: 9.5, fontFamily: 'Helvetica-Bold', color: '#0f172a', textAlign: 'right', paddingRight: 6 },
|
||||||
|
summaryDelta: { flex: 1, fontSize: 9, textAlign: 'right', paddingRight: 6 },
|
||||||
|
summaryDeltaUp: { color: '#059669' },
|
||||||
|
summaryDeltaDown: { color: '#dc2626' },
|
||||||
|
summaryDeltaTotal: { flex: 1, fontSize: 9, fontFamily: 'Helvetica-Bold', textAlign: 'right', paddingRight: 6 },
|
||||||
|
});
|
||||||
|
|
||||||
|
function pctChange(curr: number, prev: number): number {
|
||||||
|
if (prev === 0) return 0;
|
||||||
|
return Math.round(((curr - prev) / prev) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function museumIntro(row: MuseumDataRow, lang: 'en' | 'ar', compLabel: string): string {
|
||||||
|
if (!row.prev) return '';
|
||||||
|
const revChg = pctChange(row.curr.revenue, row.prev.revenue);
|
||||||
|
const visChg = pctChange(row.curr.visitors, row.prev.visitors);
|
||||||
|
if (lang === 'en') {
|
||||||
|
return `Revenue ${revChg >= 0 ? 'up' : 'down'} ${Math.abs(revChg)}%, visitors ${visChg >= 0 ? 'up' : 'down'} ${Math.abs(visChg)}% vs ${compLabel}.`;
|
||||||
|
}
|
||||||
|
return `الإيرادات ${revChg >= 0 ? 'ارتفعت' : 'انخفضت'} ${Math.abs(revChg)}%، الزوار ${visChg >= 0 ? 'ارتفعوا' : 'انخفضوا'} ${Math.abs(visChg)}% مقارنةً بـ${compLabel}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageHeaderProps { title: string; page: number; isAr: boolean; arB: any; }
|
||||||
|
function PageHeader({ title, page, isAr, arB }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<View style={S.pageHeader}>
|
||||||
|
<Text style={[S.pageHeaderLogo, arB]}>HiHala Data</Text>
|
||||||
|
<Text style={[S.pageHeaderTitle, isAr ? { fontFamily: 'IBMPlexArabic' } : {}]}>{title}</Text>
|
||||||
|
<Text style={S.pageHeaderNum}>{page}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageFooterProps { confidentiality: string; generatedAt: string; }
|
||||||
|
function PageFooter({ confidentiality, generatedAt }: PageFooterProps) {
|
||||||
|
return (
|
||||||
|
<View style={S.pageFooter}>
|
||||||
|
<Text style={S.pageFooterText}>{confidentiality}</Text>
|
||||||
|
<Text style={S.pageFooterText}>Generated {generatedAt}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionProps { title: string; color: string; arB: any; }
|
||||||
|
function SectionHeading({ title, color, arB }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<View style={[S.sectionHeading, { backgroundColor: color }]}>
|
||||||
|
<Text style={arB}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props { data: ReportData; }
|
||||||
|
|
||||||
|
export function ReportDocument({ data }: Props) {
|
||||||
|
const { config: cfg, metrics, prevMetrics, comparisonPeriodLabel,
|
||||||
|
trendCharts,
|
||||||
|
museumData, channelBreakdown, districtBreakdown,
|
||||||
|
pilgrimCapture, generatedAt } = data;
|
||||||
|
|
||||||
|
const lang = cfg.language;
|
||||||
|
const isAr = lang === 'ar';
|
||||||
|
const color = cfg.accentColor;
|
||||||
|
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
|
||||||
|
const isLandscape = cfg.orientation === 'landscape';
|
||||||
|
const orientation = isLandscape ? 'landscape' : 'portrait';
|
||||||
|
const T = lang === 'en' ? LABELS_EN : LABELS_AR;
|
||||||
|
|
||||||
|
// Arabic font overrides — Helvetica has no Arabic glyphs; cast as any so style arrays stay compatible
|
||||||
|
const arN: any = isAr ? { fontFamily: 'IBMPlexArabic', fontWeight: 400 } : {};
|
||||||
|
const arB: any = isAr ? { fontFamily: 'IBMPlexArabic', fontWeight: 700 } : {};
|
||||||
|
// direction: 'rtl' flips flex-row children right-to-left; fontFamily cascades to elements without an explicit one
|
||||||
|
const arPageExtra: any = isAr ? { direction: 'rtl', fontFamily: 'IBMPlexArabic' } : {};
|
||||||
|
|
||||||
|
const chartW = isLandscape ? CHART_W.landscape : CHART_W.portrait;
|
||||||
|
|
||||||
|
const avgTicketPrice = metrics.tickets > 0 ? metrics.revenue / metrics.tickets : 0;
|
||||||
|
const prevAvgTicketPrice = prevMetrics && prevMetrics.tickets > 0
|
||||||
|
? prevMetrics.revenue / prevMetrics.tickets : null;
|
||||||
|
|
||||||
|
const metricsRows = [
|
||||||
|
{ label: T.revenue, curr: formatCurrency(metrics.revenue, cfg.includeVAT),
|
||||||
|
prev: prevMetrics ? formatCurrency(prevMetrics.revenue, cfg.includeVAT) : null,
|
||||||
|
chg: prevMetrics ? pctChange(metrics.revenue, prevMetrics.revenue) : null },
|
||||||
|
{ label: T.visitors, curr: metrics.visitors.toLocaleString(),
|
||||||
|
prev: prevMetrics ? prevMetrics.visitors.toLocaleString() : null,
|
||||||
|
chg: prevMetrics ? pctChange(metrics.visitors, prevMetrics.visitors) : null },
|
||||||
|
{ label: T.tickets, curr: metrics.tickets.toLocaleString(),
|
||||||
|
prev: prevMetrics ? prevMetrics.tickets.toLocaleString() : null,
|
||||||
|
chg: prevMetrics ? pctChange(metrics.tickets, prevMetrics.tickets) : null },
|
||||||
|
{ label: T.avgRev, curr: formatCurrency(metrics.avgRevPerVisitor, false),
|
||||||
|
prev: prevMetrics ? formatCurrency(prevMetrics.avgRevPerVisitor, false) : null,
|
||||||
|
chg: prevMetrics ? pctChange(metrics.avgRevPerVisitor, prevMetrics.avgRevPerVisitor) : null },
|
||||||
|
{ label: T.avgTicketPrice, curr: formatCurrency(avgTicketPrice, false),
|
||||||
|
prev: prevAvgTicketPrice !== null ? formatCurrency(prevAvgTicketPrice, false) : null,
|
||||||
|
chg: prevAvgTicketPrice !== null ? pctChange(avgTicketPrice, prevAvgTicketPrice) : null },
|
||||||
|
...(cfg.showPilgrimCapture && pilgrimCapture ? [{
|
||||||
|
label: T.capture, curr: `${pilgrimCapture.current}%`,
|
||||||
|
prev: pilgrimCapture.previous !== null ? `${pilgrimCapture.previous}%` : null,
|
||||||
|
chg: pilgrimCapture.previous !== null ? pctChange(pilgrimCapture.current, pilgrimCapture.previous) : null,
|
||||||
|
}] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const showMuseumPage = cfg.showMuseumRevenue || cfg.showMuseumVisitors || cfg.showMuseumTickets;
|
||||||
|
const showChannelPage = cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets;
|
||||||
|
const showDistrictPage = cfg.showDistrictRevenue || cfg.showDistrictVisitors || cfg.showDistrictTickets;
|
||||||
|
const showSummaryPage = cfg.showGlobalSummary && cfg.includeComparison;
|
||||||
|
|
||||||
|
let pg = 1;
|
||||||
|
const mainPg = ++pg;
|
||||||
|
const museumPg = showMuseumPage ? ++pg : 0;
|
||||||
|
const channelPg = showChannelPage ? ++pg : 0;
|
||||||
|
const districtPg = showDistrictPage ? ++pg : 0;
|
||||||
|
const summaryPg = showSummaryPage ? ++pg : 0;
|
||||||
|
|
||||||
|
const museumMetricRows = (row: MuseumDataRow) => {
|
||||||
|
const rows = [];
|
||||||
|
if (cfg.showMuseumRevenue) rows.push({
|
||||||
|
label: T.revenue,
|
||||||
|
curr: formatCurrency(row.curr.revenue, cfg.includeVAT),
|
||||||
|
prev: row.prev ? formatCurrency(row.prev.revenue, cfg.includeVAT) : null,
|
||||||
|
chg: row.prev ? pctChange(row.curr.revenue, row.prev.revenue) : null,
|
||||||
|
});
|
||||||
|
if (cfg.showMuseumVisitors) rows.push({
|
||||||
|
label: T.visitors,
|
||||||
|
curr: row.curr.visitors.toLocaleString(),
|
||||||
|
prev: row.prev ? row.prev.visitors.toLocaleString() : null,
|
||||||
|
chg: row.prev ? pctChange(row.curr.visitors, row.prev.visitors) : null,
|
||||||
|
});
|
||||||
|
if (cfg.showMuseumTickets) rows.push({
|
||||||
|
label: T.tickets,
|
||||||
|
curr: row.curr.tickets.toLocaleString(),
|
||||||
|
prev: row.prev ? row.prev.tickets.toLocaleString() : null,
|
||||||
|
chg: row.prev ? pctChange(row.curr.tickets, row.prev.tickets) : null,
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Document title={cfg.title || 'HiHala Report'} author="HiHala Data">
|
||||||
|
|
||||||
|
{/* ── Cover ─────────────────────────────────────────── */}
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage, arPageExtra]}>
|
||||||
|
<View style={[S.coverHeader, { backgroundColor: color }]}>
|
||||||
|
<View style={S.coverHeaderTop}>
|
||||||
|
<Text style={[S.coverBrand, arB]}>HiHala Data</Text>
|
||||||
|
{cfg.clientLogoBase64 && (
|
||||||
|
<View style={S.coverLogoBox}>
|
||||||
|
<Image src={cfg.clientLogoBase64} style={S.coverClientLogo} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={[S.coverTitle, arB]}>{cfg.title || T.defaultTitle}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={S.coverBody}>
|
||||||
|
{cfg.clientName && (
|
||||||
|
<Text style={[S.coverClientName, arB]}>{T.preparedFor}: {cfg.clientName}</Text>
|
||||||
|
)}
|
||||||
|
{cfg.contactName && (
|
||||||
|
<Text style={[S.coverContactName, arN]}>{T.attention}: {cfg.contactName}</Text>
|
||||||
|
)}
|
||||||
|
<View style={S.coverBodySpacer} />
|
||||||
|
<View style={S.coverPeriodRow}>
|
||||||
|
<View style={[S.coverPeriodDot, { backgroundColor: color }]} />
|
||||||
|
<Text style={[S.coverPeriod, arN]}>{period}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={S.coverDate}>{T.generated}: {generatedAt}</Text>
|
||||||
|
{cfg.confidentiality !== 'Public' && (
|
||||||
|
<Text style={S.coverConfidential}>{cfg.confidentiality.toUpperCase()}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
|
||||||
|
{/* ── Summary + Metrics + Trend ──────────────────────── */}
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={mainPg} isAr={isAr} arB={arB} />
|
||||||
|
|
||||||
|
{cfg.showExecutiveSummary && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.execSummary} color={color} arB={arB} />
|
||||||
|
<Text style={[S.summaryText, arN]}>{generateExecutiveSummary(data)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cfg.showMetricsTable && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={`${T.keyMetrics} — ${cfg.includeVAT ? T.inclVAT : T.exclVAT}`} color={color} arB={arB} />
|
||||||
|
<View style={S.metricsTable}>
|
||||||
|
<View style={S.metricsHeaderRow}>
|
||||||
|
<Text style={[S.metricsHeaderLabel, arB]}> </Text>
|
||||||
|
<Text style={[S.metricsHeaderCell, arB]}>{period}</Text>
|
||||||
|
{prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
|
||||||
|
{prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{T.change}</Text>}
|
||||||
|
</View>
|
||||||
|
{metricsRows.map((row, i) => (
|
||||||
|
<View key={row.label} style={[S.metricsRow, i % 2 === 1 ? S.metricsRowAlt : {}]}>
|
||||||
|
<Text style={[S.metricsLabel, arB]}>{row.label}</Text>
|
||||||
|
<Text style={[S.metricsValue, arN]}>{row.curr}</Text>
|
||||||
|
{prevMetrics && <Text style={[S.metricsValue, arN]}>{row.prev ?? '—'}</Text>}
|
||||||
|
{prevMetrics && row.chg !== null && (
|
||||||
|
<Text style={[S.metricsChange, row.chg >= 0 ? S.metricsChangeUp : S.metricsChangeDown]}>
|
||||||
|
{formatPct(row.chg)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cfg.showTrendChart && trendCharts.map((tc, tci) => {
|
||||||
|
const trendTitle = tc.metric === 'visitors' ? T.trendVisitors
|
||||||
|
: tc.metric === 'tickets' ? T.trendTickets
|
||||||
|
: T.trendRevenue;
|
||||||
|
return (
|
||||||
|
<View key={tci} style={S.sectionGap}>
|
||||||
|
<SectionHeading title={trendTitle} color={color} arB={arB} />
|
||||||
|
<View style={S.legendRow}>
|
||||||
|
{tc.museums.length >= 2 && tc.museums.map((m, i) => (
|
||||||
|
<View key={m.name} style={S.legendItem}>
|
||||||
|
<View style={[S.legendDot, { backgroundColor: CHART_PALETTE[i % CHART_PALETTE.length] }]} />
|
||||||
|
<Text style={[S.legendLabel, arN]}>{m.name}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<View style={S.legendItem}>
|
||||||
|
<View style={[S.legendDot, { backgroundColor: TOTAL_LINE_COLOR }]} />
|
||||||
|
<Text style={[S.legendLabel, arN]}>{tc.museums.length >= 2 ? `Total · ${period}` : period}</Text>
|
||||||
|
</View>
|
||||||
|
{cfg.includeComparison && tc.previous && (
|
||||||
|
<View style={S.legendItem}>
|
||||||
|
<View style={[S.legendDot, { backgroundColor: '#94a3b8' }]} />
|
||||||
|
<Text style={[S.legendLabel, arN]}>{comparisonPeriodLabel}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfTrendChart
|
||||||
|
labels={tc.labels}
|
||||||
|
current={tc.current}
|
||||||
|
previous={tc.previous}
|
||||||
|
color={TOTAL_LINE_COLOR}
|
||||||
|
width={chartW}
|
||||||
|
height={155}
|
||||||
|
series={tc.museums.length >= 2 ? tc.museums.map((m, i) => ({
|
||||||
|
label: m.name,
|
||||||
|
color: CHART_PALETTE[i % CHART_PALETTE.length],
|
||||||
|
data: m.values,
|
||||||
|
})) : undefined}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
|
</Page>
|
||||||
|
|
||||||
|
{/* ── Museum Mini-Reports ────────────────────────────── */}
|
||||||
|
{showMuseumPage && museumData.length > 0 && (
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={museumPg} isAr={isAr} arB={arB} />
|
||||||
|
<SectionHeading title={T.museumBreakdowns} color={color} arB={arB} />
|
||||||
|
|
||||||
|
{museumData.map((row, mi) => {
|
||||||
|
const mRows = museumMetricRows(row);
|
||||||
|
const hasPrev = row.prev !== null;
|
||||||
|
return (
|
||||||
|
<View key={row.name} style={[S.museumBlock, { borderLeftColor: CHART_PALETTE[mi % CHART_PALETTE.length] }]}>
|
||||||
|
<Text style={[S.museumBlockName, arB]}>{row.name}</Text>
|
||||||
|
{hasPrev && (
|
||||||
|
<Text style={[S.museumIntroText, arN]}>
|
||||||
|
{museumIntro(row, lang, comparisonPeriodLabel)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View style={S.miniTable}>
|
||||||
|
<View style={S.miniHeaderRow}>
|
||||||
|
<Text style={[S.miniHeaderLabel, arB]}> </Text>
|
||||||
|
<Text style={[S.miniHeaderCell, arB]}>{period}</Text>
|
||||||
|
{hasPrev && <Text style={[S.miniHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
|
||||||
|
{hasPrev && <Text style={[S.miniHeaderChangeCell, arB]}>{T.change}</Text>}
|
||||||
|
</View>
|
||||||
|
{mRows.map((mr, ri) => (
|
||||||
|
<View key={mr.label} style={[S.miniRow, ri % 2 === 1 ? S.miniRowAlt : {}]}>
|
||||||
|
<Text style={[S.miniLabel, arB]}>{mr.label}</Text>
|
||||||
|
<Text style={[S.miniValue, arN]}>{mr.curr}</Text>
|
||||||
|
{hasPrev && <Text style={[S.miniValue, arN]}>{mr.prev ?? '—'}</Text>}
|
||||||
|
{hasPrev && mr.chg !== null && (
|
||||||
|
<Text style={[S.miniChange, mr.chg >= 0 ? S.miniChangeUp : S.miniChangeDown]}>
|
||||||
|
{formatPct(mr.chg)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
|
</Page>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Channel Breakdowns ─────────────────────────────── */}
|
||||||
|
{showChannelPage && (
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={channelPg} isAr={isAr} arB={arB} />
|
||||||
|
|
||||||
|
{cfg.showChannelRevenue && channelBreakdown.revenue.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byChannelRevenue} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={channelBreakdown.revenue} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{cfg.showChannelVisitors && channelBreakdown.visitors.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byChannelVisitors} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={channelBreakdown.visitors} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{cfg.showChannelTickets && channelBreakdown.tickets.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byChannelTickets} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={channelBreakdown.tickets} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
|
</Page>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── District Breakdowns ────────────────────────────── */}
|
||||||
|
{showDistrictPage && (
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={districtPg} isAr={isAr} arB={arB} />
|
||||||
|
|
||||||
|
{cfg.showDistrictRevenue && districtBreakdown.revenue.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byDistrictRevenue} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={districtBreakdown.revenue} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{cfg.showDistrictVisitors && districtBreakdown.visitors.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byDistrictVisitors} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={districtBreakdown.visitors} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{cfg.showDistrictTickets && districtBreakdown.tickets.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byDistrictTickets} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={districtBreakdown.tickets} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
|
</Page>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Global Performance Summary ─────────────────────── */}
|
||||||
|
{showSummaryPage && museumData.length > 0 && (
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={summaryPg} isAr={isAr} arB={arB} />
|
||||||
|
<SectionHeading title={T.globalSummary} color={color} arB={arB} />
|
||||||
|
|
||||||
|
<Text style={[S.summarySubLabel, arN]}>
|
||||||
|
{period} — {T.comparedTo} {comparisonPeriodLabel}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={S.summaryHeaderRow}>
|
||||||
|
<Text style={[S.summaryHeaderMuseum, arB]}>{T.museum}</Text>
|
||||||
|
{cfg.showMuseumRevenue && <>
|
||||||
|
<Text style={[S.summaryHeaderMetric, arB]}>{T.revenue}</Text>
|
||||||
|
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumVisitors && <>
|
||||||
|
<Text style={[S.summaryHeaderMetric, arB]}>{T.visitors}</Text>
|
||||||
|
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumTickets && <>
|
||||||
|
<Text style={[S.summaryHeaderMetric, arB]}>{T.tickets}</Text>
|
||||||
|
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
|
||||||
|
</>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{museumData.map((row, i) => {
|
||||||
|
const hasPrev = row.prev !== null;
|
||||||
|
return (
|
||||||
|
<View key={row.name} style={[S.summaryRow, i % 2 === 1 ? S.summaryRowAlt : {}]}>
|
||||||
|
<Text style={[S.summaryMuseum, arN]}>{row.name.length > 30 ? row.name.slice(0, 30) + '…' : row.name}</Text>
|
||||||
|
{cfg.showMuseumRevenue && <>
|
||||||
|
<Text style={[S.summaryMetric, arN]}>{formatCurrency(row.curr.revenue, cfg.includeVAT)}</Text>
|
||||||
|
{hasPrev && row.prev ? (() => {
|
||||||
|
const c = pctChange(row.curr.revenue, row.prev!.revenue);
|
||||||
|
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumVisitors && <>
|
||||||
|
<Text style={[S.summaryMetric, arN]}>{row.curr.visitors.toLocaleString()}</Text>
|
||||||
|
{hasPrev && row.prev ? (() => {
|
||||||
|
const c = pctChange(row.curr.visitors, row.prev!.visitors);
|
||||||
|
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumTickets && <>
|
||||||
|
<Text style={[S.summaryMetric, arN]}>{row.curr.tickets.toLocaleString()}</Text>
|
||||||
|
{hasPrev && row.prev ? (() => {
|
||||||
|
const c = pctChange(row.curr.tickets, row.prev!.tickets);
|
||||||
|
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||||
|
</>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<View style={S.summaryTotalRow}>
|
||||||
|
<Text style={[S.summaryMuseumTotal, arB]}>{T.total}</Text>
|
||||||
|
{cfg.showMuseumRevenue && <>
|
||||||
|
<Text style={[S.summaryMetricTotal, arB]}>{formatCurrency(metrics.revenue, cfg.includeVAT)}</Text>
|
||||||
|
{prevMetrics ? (() => {
|
||||||
|
const c = pctChange(metrics.revenue, prevMetrics.revenue);
|
||||||
|
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumVisitors && <>
|
||||||
|
<Text style={[S.summaryMetricTotal, arB]}>{metrics.visitors.toLocaleString()}</Text>
|
||||||
|
{prevMetrics ? (() => {
|
||||||
|
const c = pctChange(metrics.visitors, prevMetrics.visitors);
|
||||||
|
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumTickets && <>
|
||||||
|
<Text style={[S.summaryMetricTotal, arB]}>{metrics.tickets.toLocaleString()}</Text>
|
||||||
|
{prevMetrics ? (() => {
|
||||||
|
const c = pctChange(metrics.tickets, prevMetrics.tickets);
|
||||||
|
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||||
|
</>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
|
</Page>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS_EN = {
|
||||||
|
defaultTitle: 'Performance Report',
|
||||||
|
preparedFor: 'Prepared for',
|
||||||
|
attention: 'Attention',
|
||||||
|
generated: 'Generated',
|
||||||
|
execSummary: 'Executive Summary',
|
||||||
|
keyMetrics: 'Key Metrics',
|
||||||
|
inclVAT: 'Incl. VAT',
|
||||||
|
exclVAT: 'Excl. VAT',
|
||||||
|
change: 'Change',
|
||||||
|
comparedTo: 'vs.',
|
||||||
|
trendRevenue: 'Revenue Trend',
|
||||||
|
trendVisitors: 'Visitor Trend',
|
||||||
|
trendTickets: 'Ticket Trend',
|
||||||
|
museumBreakdowns: 'Museum Breakdown',
|
||||||
|
byChannelRevenue: 'Revenue by Channel',
|
||||||
|
byChannelVisitors: 'Visitors by Channel',
|
||||||
|
byChannelTickets: 'Tickets by Channel',
|
||||||
|
byDistrictRevenue: 'Revenue by District',
|
||||||
|
byDistrictVisitors: 'Visitors by District',
|
||||||
|
byDistrictTickets: 'Tickets by District',
|
||||||
|
globalSummary: 'Performance Summary',
|
||||||
|
museum: 'Museum',
|
||||||
|
total: 'TOTAL',
|
||||||
|
revenue: 'Revenue',
|
||||||
|
visitors: 'Visitors',
|
||||||
|
tickets: 'Tickets',
|
||||||
|
avgRev: 'Avg Rev / Visitor',
|
||||||
|
avgTicketPrice: 'Avg Ticket Price',
|
||||||
|
capture: 'Pilgrim Capture Rate',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LABELS_AR = {
|
||||||
|
defaultTitle: 'تقرير الأداء',
|
||||||
|
preparedFor: 'مُعدّ لـ',
|
||||||
|
attention: 'عناية',
|
||||||
|
generated: 'تاريخ الإصدار',
|
||||||
|
execSummary: 'الملخص التنفيذي',
|
||||||
|
keyMetrics: 'المؤشرات الرئيسية',
|
||||||
|
inclVAT: 'شامل ضريبة القيمة المضافة',
|
||||||
|
exclVAT: 'غير شامل ضريبة القيمة المضافة',
|
||||||
|
change: 'التغيّر',
|
||||||
|
comparedTo: 'مقابل',
|
||||||
|
trendRevenue: 'اتجاه الإيرادات',
|
||||||
|
trendVisitors: 'اتجاه الزوار',
|
||||||
|
trendTickets: 'اتجاه التذاكر',
|
||||||
|
museumBreakdowns: 'تفاصيل المتاحف',
|
||||||
|
byChannelRevenue: 'الإيرادات حسب القناة',
|
||||||
|
byChannelVisitors: 'الزوار حسب القناة',
|
||||||
|
byChannelTickets: 'التذاكر حسب القناة',
|
||||||
|
byDistrictRevenue: 'الإيرادات حسب الحي',
|
||||||
|
byDistrictVisitors: 'الزوار حسب الحي',
|
||||||
|
byDistrictTickets: 'التذاكر حسب الحي',
|
||||||
|
globalSummary: 'ملخص الأداء',
|
||||||
|
museum: 'المتحف',
|
||||||
|
total: 'الإجمالي',
|
||||||
|
revenue: 'الإيرادات',
|
||||||
|
visitors: 'الزوار',
|
||||||
|
tickets: 'التذاكر',
|
||||||
|
avgRev: 'متوسط الإيراد / زائر',
|
||||||
|
avgTicketPrice: 'متوسط سعر التذكرة',
|
||||||
|
capture: 'معدل استيعاب الحجاج',
|
||||||
|
};
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
|
import AltMultiSelect from '../shared/AltMultiSelect';
|
||||||
|
import type { ReportConfig, TrendMetric } from './reportHelpers';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
config: ReportConfig;
|
||||||
|
onChange: (patch: Partial<ReportConfig>) => void;
|
||||||
|
allMuseums: string[];
|
||||||
|
allChannels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="rf-field">
|
||||||
|
<span className="rf-label">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C1+C3: role="group" + aria-label + aria-pressed on every button
|
||||||
|
function PillGroup({ options, value, onChange, label }: {
|
||||||
|
options: Array<{ label: string; value: string }>;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rf-metric-pills" role="group" aria-label={label}>
|
||||||
|
{options.map(opt => (
|
||||||
|
<button key={opt.value} type="button"
|
||||||
|
className={`rf-metric-pill${value === opt.value ? ' rf-metric-pill--on' : ''}`}
|
||||||
|
aria-pressed={value === opt.value}
|
||||||
|
onClick={() => onChange(opt.value)}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndeterminateCheckbox({ checked, indeterminate, onChange, className }: {
|
||||||
|
checked: boolean; indeterminate: boolean; onChange: (v: boolean) => void; className?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) ref.current.indeterminate = indeterminate;
|
||||||
|
}, [indeterminate]);
|
||||||
|
return (
|
||||||
|
<input ref={ref} type="checkbox" checked={checked}
|
||||||
|
onChange={e => onChange(e.target.checked)} className={className} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C1: aria-hidden badge (visual only), role/aria on header label provides the accessible name
|
||||||
|
function ModuleCard({ title, badge, enabled, onToggle, children }: {
|
||||||
|
title: string;
|
||||||
|
badge?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: (v: boolean) => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`rf-module${enabled ? ' rf-module--on' : ''}`}>
|
||||||
|
<label className="rf-module-header">
|
||||||
|
<input type="checkbox" checked={enabled}
|
||||||
|
onChange={e => onToggle(e.target.checked)} className="rf-checkbox" />
|
||||||
|
<span className="rf-module-title">{title}</span>
|
||||||
|
{/* aria-hidden: badge is visual state feedback, not part of checkbox label */}
|
||||||
|
<span className={`rf-module-badge${enabled ? ' rf-module-badge--on' : ''}`} aria-hidden="true">
|
||||||
|
{badge ?? (enabled ? 'Included' : 'Excluded')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{enabled && children && (
|
||||||
|
<div className="rf-module-body">{children}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricPatch = { revenue?: boolean; visitors?: boolean; tickets?: boolean };
|
||||||
|
|
||||||
|
function BreakdownModule({ title, revenue, visitors, tickets, onChange }: {
|
||||||
|
title: string;
|
||||||
|
revenue: boolean; visitors: boolean; tickets: boolean;
|
||||||
|
onChange: (patch: MetricPatch) => void;
|
||||||
|
}) {
|
||||||
|
const anyOn = revenue || visitors || tickets;
|
||||||
|
const allOn = revenue && visitors && tickets;
|
||||||
|
const badge = anyOn
|
||||||
|
? [revenue && 'Revenue', visitors && 'Visitors', tickets && 'Tickets'].filter(Boolean).join(' · ')
|
||||||
|
: 'Excluded';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rf-module${anyOn ? ' rf-module--on' : ''}`}>
|
||||||
|
<label className="rf-module-header">
|
||||||
|
<IndeterminateCheckbox
|
||||||
|
checked={anyOn}
|
||||||
|
indeterminate={anyOn && !allOn}
|
||||||
|
onChange={v => onChange({ revenue: v, visitors: v, tickets: v })}
|
||||||
|
className="rf-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="rf-module-title">{title}</span>
|
||||||
|
{/* aria-hidden: badge is visual only */}
|
||||||
|
<span className={`rf-module-badge${anyOn ? ' rf-module-badge--on' : ''}`} aria-hidden="true">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{anyOn && (
|
||||||
|
<div className="rf-module-body">
|
||||||
|
{/* C1+C3: role="group" + aria-label + aria-pressed */}
|
||||||
|
<div className="rf-metric-pills" role="group" aria-label={`${title} metrics to include`}>
|
||||||
|
{([
|
||||||
|
{ label: 'Revenue', on: revenue, key: 'revenue' as keyof MetricPatch },
|
||||||
|
{ label: 'Visitors', on: visitors, key: 'visitors' as keyof MetricPatch },
|
||||||
|
{ label: 'Tickets', on: tickets, key: 'tickets' as keyof MetricPatch },
|
||||||
|
]).map(({ label, on, key }) => (
|
||||||
|
<button key={label} type="button"
|
||||||
|
className={`rf-metric-pill${on ? ' rf-metric-pill--on' : ''}`}
|
||||||
|
aria-pressed={on}
|
||||||
|
onClick={() => onChange({ [key]: !on } as MetricPatch)}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportForm({ config: cfg, onChange, allMuseums, allChannels }: Props) {
|
||||||
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// C2: inline error instead of alert()
|
||||||
|
const [logoError, setLogoError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
setLogoError('File must be under 2 MB.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLogoError(null);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => onChange({ clientLogoBase64: reader.result as string });
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="report-form">
|
||||||
|
<div className="rf-two-col">
|
||||||
|
|
||||||
|
{/* ── Left: setup ── */}
|
||||||
|
<div className="rf-col">
|
||||||
|
{/* M2: semantic h2 instead of div — visually identical via CSS */}
|
||||||
|
<h2 className="rf-group-label">Client</h2>
|
||||||
|
|
||||||
|
<Field label="Report title">
|
||||||
|
<input className="rf-input" type="text" value={cfg.title}
|
||||||
|
onChange={e => onChange({ title: e.target.value })}
|
||||||
|
placeholder="Q1 2025 Visitor Performance" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Prepared for">
|
||||||
|
<input className="rf-input" type="text" value={cfg.clientName}
|
||||||
|
onChange={e => onChange({ clientName: e.target.value })}
|
||||||
|
placeholder="Acme Group" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Contact (optional)">
|
||||||
|
<input className="rf-input" type="text" value={cfg.contactName}
|
||||||
|
onChange={e => onChange({ contactName: e.target.value })}
|
||||||
|
placeholder="Mohammed Al-..." />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="rf-branding-row">
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">Accent color</span>
|
||||||
|
<div className="rf-color-row">
|
||||||
|
<input type="color" value={cfg.accentColor}
|
||||||
|
onChange={e => onChange({ accentColor: e.target.value })}
|
||||||
|
className="rf-color-input"
|
||||||
|
aria-label="Report accent color" />
|
||||||
|
<span className="rf-color-val">{cfg.accentColor}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">Logo (PNG/JPG, max 2 MB)</span>
|
||||||
|
<div className="rf-logo-row">
|
||||||
|
{/* H6: descriptive aria-label on upload button */}
|
||||||
|
<button type="button" className="rf-upload-btn"
|
||||||
|
aria-label={cfg.clientLogoBase64 ? 'Change client logo' : 'Upload client logo'}
|
||||||
|
onClick={() => logoInputRef.current?.click()}>
|
||||||
|
{cfg.clientLogoBase64 ? 'Change' : 'Upload'}
|
||||||
|
</button>
|
||||||
|
{cfg.clientLogoBase64 && (
|
||||||
|
<>
|
||||||
|
{/* M1: meaningful alt text */}
|
||||||
|
<img src={cfg.clientLogoBase64} alt="Uploaded client logo" className="rf-logo-preview" />
|
||||||
|
{/* H6: descriptive aria-label on remove button */}
|
||||||
|
<button type="button" className="rf-remove-btn"
|
||||||
|
aria-label="Remove client logo"
|
||||||
|
onClick={() => onChange({ clientLogoBase64: null })}>✕</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<input ref={logoInputRef} type="file" accept="image/png,image/jpeg"
|
||||||
|
style={{ display: 'none' }} onChange={handleLogoUpload} />
|
||||||
|
</div>
|
||||||
|
{/* C2: inline logo error */}
|
||||||
|
{logoError && <span className="rf-field-error" role="alert">{logoError}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Data</h2>
|
||||||
|
|
||||||
|
<div className="rf-date-row">
|
||||||
|
<Field label="Period start">
|
||||||
|
<input className="rf-input" type="date" value={cfg.startDate}
|
||||||
|
onChange={e => onChange({ startDate: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Period end">
|
||||||
|
<input className="rf-input" type="date" value={cfg.endDate}
|
||||||
|
onChange={e => onChange({ endDate: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Museums">
|
||||||
|
<AltMultiSelect value={cfg.selectedMuseums} options={allMuseums}
|
||||||
|
onChange={v => onChange({ selectedMuseums: v })}
|
||||||
|
allLabel="All museums" countLabel={n => `${n} museums`} clearLabel="Clear" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Channels">
|
||||||
|
<AltMultiSelect value={cfg.selectedChannels} options={allChannels}
|
||||||
|
onChange={v => onChange({ selectedChannels: v })}
|
||||||
|
allLabel="All channels" countLabel={n => `${n} channels`} clearLabel="Clear" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">VAT</span>
|
||||||
|
<PillGroup
|
||||||
|
label="VAT"
|
||||||
|
options={[{ label: 'Excl. VAT', value: 'excl' }, { label: 'Incl. VAT', value: 'incl' }]}
|
||||||
|
value={cfg.includeVAT ? 'incl' : 'excl'}
|
||||||
|
onChange={v => onChange({ includeVAT: v === 'incl' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="rf-check-row">
|
||||||
|
<input type="checkbox" checked={cfg.includeComparison}
|
||||||
|
onChange={e => onChange({ includeComparison: e.target.checked })} className="rf-checkbox" />
|
||||||
|
<span>Include comparison period</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{cfg.includeComparison && (
|
||||||
|
<div className="rf-comparison-block">
|
||||||
|
<div className="rf-comparison-label" aria-hidden="true">vs. period</div>
|
||||||
|
<div className="rf-date-row">
|
||||||
|
<Field label="From">
|
||||||
|
<input className="rf-input" type="date" value={cfg.comparisonStartDate}
|
||||||
|
onChange={e => onChange({ comparisonStartDate: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="To">
|
||||||
|
<input className="rf-input" type="date" value={cfg.comparisonEndDate}
|
||||||
|
onChange={e => onChange({ comparisonEndDate: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Format</h2>
|
||||||
|
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">Language</span>
|
||||||
|
<PillGroup
|
||||||
|
label="Language"
|
||||||
|
options={[{ label: 'English', value: 'en' }, { label: 'العربية', value: 'ar' }]}
|
||||||
|
value={cfg.language}
|
||||||
|
onChange={v => onChange({ language: v as 'en' | 'ar' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">Orientation</span>
|
||||||
|
<div className="rf-orient-row" role="group" aria-label="Page orientation">
|
||||||
|
<button type="button"
|
||||||
|
className={`rf-orient-btn${cfg.orientation === 'portrait' ? ' rf-orient-btn--on' : ''}`}
|
||||||
|
aria-pressed={cfg.orientation === 'portrait'}
|
||||||
|
onClick={() => onChange({ orientation: 'portrait' })}>
|
||||||
|
<div className="rf-orient-page rf-orient-page--portrait" aria-hidden="true" />
|
||||||
|
<span>Portrait</span>
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
className={`rf-orient-btn${cfg.orientation === 'landscape' ? ' rf-orient-btn--on' : ''}`}
|
||||||
|
aria-pressed={cfg.orientation === 'landscape'}
|
||||||
|
onClick={() => onChange({ orientation: 'landscape' })}>
|
||||||
|
<div className="rf-orient-page rf-orient-page--landscape" aria-hidden="true" />
|
||||||
|
<span>Landscape</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">Confidentiality</span>
|
||||||
|
<PillGroup
|
||||||
|
label="Confidentiality"
|
||||||
|
options={[
|
||||||
|
{ label: 'Confidential', value: 'Confidential' },
|
||||||
|
{ label: 'Internal', value: 'Internal' },
|
||||||
|
{ label: 'Public', value: 'Public' },
|
||||||
|
]}
|
||||||
|
value={cfg.confidentiality}
|
||||||
|
onChange={v => onChange({ confidentiality: v as ReportConfig['confidentiality'] })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right: content selection ── */}
|
||||||
|
<div className="rf-col">
|
||||||
|
<h2 className="rf-group-label">Report Sections</h2>
|
||||||
|
|
||||||
|
<ModuleCard title="Executive Summary"
|
||||||
|
enabled={cfg.showExecutiveSummary} onToggle={v => onChange({ showExecutiveSummary: v })} />
|
||||||
|
<ModuleCard title="Key Metrics Table"
|
||||||
|
enabled={cfg.showMetricsTable} onToggle={v => onChange({ showMetricsTable: v })} />
|
||||||
|
<ModuleCard title="Pilgrim Capture Rate"
|
||||||
|
enabled={cfg.showPilgrimCapture} onToggle={v => onChange({ showPilgrimCapture: v })} />
|
||||||
|
|
||||||
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Trend</h2>
|
||||||
|
|
||||||
|
<ModuleCard
|
||||||
|
title="Trend Chart"
|
||||||
|
enabled={cfg.showTrendChart}
|
||||||
|
onToggle={v => onChange({ showTrendChart: v })}
|
||||||
|
badge={cfg.showTrendChart && cfg.trendMetrics.length
|
||||||
|
? cfg.trendMetrics.map(m => m.charAt(0).toUpperCase() + m.slice(1)).join(' · ')
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
<div className="rf-metric-pills" role="group" aria-label="Trend metrics to include">
|
||||||
|
{(['revenue', 'visitors', 'tickets'] as TrendMetric[]).map(m => {
|
||||||
|
const on = cfg.trendMetrics.includes(m);
|
||||||
|
return (
|
||||||
|
<button key={m} type="button"
|
||||||
|
className={`rf-metric-pill${on ? ' rf-metric-pill--on' : ''}`}
|
||||||
|
aria-pressed={on}
|
||||||
|
onClick={() => {
|
||||||
|
const next = on
|
||||||
|
? cfg.trendMetrics.filter(x => x !== m)
|
||||||
|
: [...cfg.trendMetrics, m];
|
||||||
|
onChange({ trendMetrics: next.length ? next : [m] });
|
||||||
|
}}>
|
||||||
|
{m.charAt(0).toUpperCase() + m.slice(1)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ModuleCard>
|
||||||
|
|
||||||
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Breakdowns</h2>
|
||||||
|
|
||||||
|
<BreakdownModule title="Museums"
|
||||||
|
revenue={cfg.showMuseumRevenue} visitors={cfg.showMuseumVisitors} tickets={cfg.showMuseumTickets}
|
||||||
|
onChange={p => onChange({
|
||||||
|
showMuseumRevenue: p.revenue ?? cfg.showMuseumRevenue,
|
||||||
|
showMuseumVisitors: p.visitors ?? cfg.showMuseumVisitors,
|
||||||
|
showMuseumTickets: p.tickets ?? cfg.showMuseumTickets,
|
||||||
|
})} />
|
||||||
|
|
||||||
|
<BreakdownModule title="Channels"
|
||||||
|
revenue={cfg.showChannelRevenue} visitors={cfg.showChannelVisitors} tickets={cfg.showChannelTickets}
|
||||||
|
onChange={p => onChange({
|
||||||
|
showChannelRevenue: p.revenue ?? cfg.showChannelRevenue,
|
||||||
|
showChannelVisitors: p.visitors ?? cfg.showChannelVisitors,
|
||||||
|
showChannelTickets: p.tickets ?? cfg.showChannelTickets,
|
||||||
|
})} />
|
||||||
|
|
||||||
|
<BreakdownModule title="Districts"
|
||||||
|
revenue={cfg.showDistrictRevenue} visitors={cfg.showDistrictVisitors} tickets={cfg.showDistrictTickets}
|
||||||
|
onChange={p => onChange({
|
||||||
|
showDistrictRevenue: p.revenue ?? cfg.showDistrictRevenue,
|
||||||
|
showDistrictVisitors: p.visitors ?? cfg.showDistrictVisitors,
|
||||||
|
showDistrictTickets: p.tickets ?? cfg.showDistrictTickets,
|
||||||
|
})} />
|
||||||
|
|
||||||
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Summary</h2>
|
||||||
|
|
||||||
|
<ModuleCard title="Global Performance Table"
|
||||||
|
enabled={cfg.showGlobalSummary} onToggle={v => onChange({ showGlobalSummary: v })}>
|
||||||
|
{!cfg.includeComparison && (
|
||||||
|
<p className="rf-module-note">
|
||||||
|
Enable a comparison period to show progression data.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ModuleCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { ReportConfig } from './reportHelpers';
|
||||||
|
import { formatPeriodLabel, estimatePageCount } from './reportHelpers';
|
||||||
|
|
||||||
|
interface Props { config: ReportConfig; }
|
||||||
|
|
||||||
|
export default function ReportPreview({ config: cfg }: Props) {
|
||||||
|
const color = cfg.accentColor;
|
||||||
|
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, cfg.language);
|
||||||
|
const pages = estimatePageCount(cfg);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="report-preview">
|
||||||
|
<div className="report-preview-label">Preview · ~{pages} pages</div>
|
||||||
|
|
||||||
|
{/* Cover page mockup */}
|
||||||
|
<div className="rp-page">
|
||||||
|
<div className="rp-cover-top">
|
||||||
|
<span className="rp-brand">HiHala Data</span>
|
||||||
|
{cfg.clientLogoBase64 && (
|
||||||
|
<img src={cfg.clientLogoBase64} alt="Client logo" className="rp-client-logo" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rp-cover-body">
|
||||||
|
<div className="rp-cover-title">
|
||||||
|
{cfg.title || <span className="rp-placeholder-text">Report Title</span>}
|
||||||
|
</div>
|
||||||
|
{cfg.clientName && (
|
||||||
|
<div className="rp-cover-for">Prepared for: <strong>{cfg.clientName}</strong></div>
|
||||||
|
)}
|
||||||
|
{cfg.contactName && (
|
||||||
|
<div className="rp-cover-contact">Attention: {cfg.contactName}</div>
|
||||||
|
)}
|
||||||
|
<div className="rp-cover-period">{period}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rp-cover-bar" style={{ backgroundColor: color }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content page mockup */}
|
||||||
|
<div className="rp-page rp-page--content">
|
||||||
|
<div className="rp-page-header">
|
||||||
|
<span className="rp-brand-small" style={{ color }}>HiHala Data</span>
|
||||||
|
<span className="rp-page-title-small">{cfg.title || 'Report'}</span>
|
||||||
|
<span className="rp-page-num">2</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cfg.showExecutiveSummary && (
|
||||||
|
<div className="rp-section">
|
||||||
|
<div className="rp-section-heading" style={{ backgroundColor: color }}>Executive Summary</div>
|
||||||
|
<div className="rp-placeholder-lines">
|
||||||
|
<div className="rp-ph-line" style={{ width: '100%' }} />
|
||||||
|
<div className="rp-ph-line" style={{ width: '90%' }} />
|
||||||
|
<div className="rp-ph-line" style={{ width: '80%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cfg.showMetricsTable && (
|
||||||
|
<div className="rp-section">
|
||||||
|
<div className="rp-section-heading" style={{ backgroundColor: color }}>Key Metrics</div>
|
||||||
|
<div className="rp-ph-table">
|
||||||
|
{['Revenue', 'Visitors', 'Tickets', 'Avg Rev'].map(r => (
|
||||||
|
<div key={r} className="rp-ph-row">
|
||||||
|
<span className="rp-ph-row-label">{r}</span>
|
||||||
|
<div className="rp-ph-row-val" />
|
||||||
|
{cfg.includeComparison && <div className="rp-ph-row-val rp-ph-row-val--sm" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cfg.showTrendChart && (
|
||||||
|
<div className="rp-section">
|
||||||
|
<div className="rp-section-heading" style={{ backgroundColor: color }}>Trend</div>
|
||||||
|
<div className="rp-ph-chart" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
|
import { pdf } from '@react-pdf/renderer';
|
||||||
|
import type { MuseumRecord } from '../../types';
|
||||||
|
import { DEFAULT_CONFIG, computeReportData } from './reportHelpers';
|
||||||
|
import type { ReportConfig } from './reportHelpers';
|
||||||
|
import { ReportDocument } from './ReportDocument';
|
||||||
|
import ReportForm from './ReportForm';
|
||||||
|
import { getUniqueMuseums, getUniqueChannels } from '../../services/dataService';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: MuseumRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportPage({ data }: Props) {
|
||||||
|
const [config, setConfig] = useState<ReportConfig>(DEFAULT_CONFIG);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// H8: memoize — these scan the full records array; re-running on every patch is wasteful
|
||||||
|
const allMuseums = useMemo(() => getUniqueMuseums(data), [data]);
|
||||||
|
const allChannels = useMemo(() => getUniqueChannels(data), [data]);
|
||||||
|
|
||||||
|
const patch = useCallback((p: Partial<ReportConfig>) => setConfig(c => ({ ...c, ...p })), []);
|
||||||
|
|
||||||
|
// C2: auto-clear inline error after 6 s
|
||||||
|
useEffect(() => {
|
||||||
|
if (!errorMsg) return;
|
||||||
|
const t = setTimeout(() => setErrorMsg(null), 6000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [errorMsg]);
|
||||||
|
|
||||||
|
const sectionCount = useMemo(() => [
|
||||||
|
config.showExecutiveSummary,
|
||||||
|
config.showMetricsTable,
|
||||||
|
config.showPilgrimCapture,
|
||||||
|
config.showTrendChart,
|
||||||
|
config.showMuseumRevenue || config.showMuseumVisitors || config.showMuseumTickets,
|
||||||
|
config.showChannelRevenue || config.showChannelVisitors || config.showChannelTickets,
|
||||||
|
config.showDistrictRevenue || config.showDistrictVisitors || config.showDistrictTickets,
|
||||||
|
config.showGlobalSummary && config.includeComparison,
|
||||||
|
].filter(Boolean).length, [config]);
|
||||||
|
|
||||||
|
const periodLabel = config.startDate && config.endDate
|
||||||
|
? `${config.startDate.slice(0, 7)} to ${config.endDate.slice(0, 7)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (config.startDate > config.endDate) {
|
||||||
|
// C2: inline error instead of alert()
|
||||||
|
setErrorMsg('End date must be after start date.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGenerating(true);
|
||||||
|
setErrorMsg(null);
|
||||||
|
try {
|
||||||
|
const reportData = computeReportData(data, config);
|
||||||
|
const blob = await pdf(<ReportDocument data={reportData} />).toBlob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
const slug = (config.clientName || 'report').toLowerCase().replace(/\s+/g, '-');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `hihala-${slug}-${config.startDate.slice(0, 7)}.pdf`;
|
||||||
|
try {
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PDF generation failed:', err);
|
||||||
|
// C2: inline error instead of alert()
|
||||||
|
setErrorMsg('Failed to generate PDF. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="report-page">
|
||||||
|
{/* L2: aria-live region for screen reader status announcements */}
|
||||||
|
<div role="status" aria-live="polite" className="sr-only">
|
||||||
|
{generating ? 'Generating PDF, please wait.' : ''}
|
||||||
|
{errorMsg ? `Error: ${errorMsg}` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="report-header">
|
||||||
|
<h1 className="report-title">Report Builder</h1>
|
||||||
|
{/* M5: removed generic filler subtitle */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="report-body">
|
||||||
|
<div className="report-form-col">
|
||||||
|
<ReportForm config={config} onChange={patch} allMuseums={allMuseums} allChannels={allChannels} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="report-footer-bar">
|
||||||
|
<div className="report-footer-meta">
|
||||||
|
{/* H5: report-footer-chip--count stays visible on mobile; others hide */}
|
||||||
|
<span className="report-footer-chip report-footer-chip--count">
|
||||||
|
{sectionCount} section{sectionCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{periodLabel && (
|
||||||
|
<>
|
||||||
|
{/* L1: aria-hidden on decorative separators */}
|
||||||
|
<span className="report-footer-dot" aria-hidden="true" />
|
||||||
|
<span className="report-footer-chip">{periodLabel}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="report-footer-dot" aria-hidden="true" />
|
||||||
|
<span className="report-footer-chip">
|
||||||
|
{config.orientation === 'landscape' ? 'Landscape' : 'Portrait'}
|
||||||
|
</span>
|
||||||
|
{config.includeComparison && (
|
||||||
|
<>
|
||||||
|
<span className="report-footer-dot" aria-hidden="true" />
|
||||||
|
<span className="report-footer-chip report-footer-chip--compare">With comparison</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* C2: inline error message */}
|
||||||
|
{errorMsg && (
|
||||||
|
<span className="report-footer-error" role="alert">{errorMsg}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="report-generate-btn"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={generating}
|
||||||
|
aria-busy={generating}
|
||||||
|
>
|
||||||
|
{generating ? (
|
||||||
|
<>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="report-spin" aria-hidden="true">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||||
|
</svg>
|
||||||
|
Generating…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
Download PDF
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Svg, Line, Polyline, Rect, Text as SvgText, G } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
export const CHART_PALETTE = [
|
||||||
|
'#2563eb', '#0891b2', '#7c3aed', '#059669',
|
||||||
|
'#d97706', '#dc2626', '#db2777', '#f59e0b',
|
||||||
|
'#10b981', '#6366f1', '#0284c7', '#65a30d',
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtAxis(v: number): string {
|
||||||
|
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (v >= 10_000) return `${Math.round(v / 1_000)}K`;
|
||||||
|
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
|
||||||
|
return String(Math.round(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrendChartProps {
|
||||||
|
labels: string[];
|
||||||
|
current: number[];
|
||||||
|
previous: number[] | null;
|
||||||
|
color: string;
|
||||||
|
series?: Array<{ label: string; color: string; data: number[] }>;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfTrendChart({ labels, current, previous, color, series, width = 470, height = 155 }: TrendChartProps) {
|
||||||
|
const seriesValues = (series ?? []).flatMap(s => s.data);
|
||||||
|
const allValues = [...current, ...(previous ?? []), ...seriesValues].filter(v => v > 0);
|
||||||
|
const max = allValues.length > 0 ? Math.max(...allValues) : 1;
|
||||||
|
// padL wide enough for y-axis labels like "1.2M"
|
||||||
|
const padL = 38, padR = 8, padT = 10, padB = 20;
|
||||||
|
const w = width - padL - padR;
|
||||||
|
const h = height - padT - padB;
|
||||||
|
|
||||||
|
const sx = (i: number) => padL + (labels.length > 1 ? (i / (labels.length - 1)) * w : w / 2);
|
||||||
|
const sy = (v: number) => padT + h - (v / max) * h;
|
||||||
|
|
||||||
|
const toPoints = (data: number[]) =>
|
||||||
|
data.map((v, i) => `${sx(i).toFixed(1)},${sy(v).toFixed(1)}`).join(' ');
|
||||||
|
|
||||||
|
const gridLines = [0.25, 0.5, 0.75, 1.0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={height}>
|
||||||
|
{/* Baseline */}
|
||||||
|
<Line x1={padL} y1={(padT + h).toFixed(1)} x2={width - padR} y2={(padT + h).toFixed(1)}
|
||||||
|
stroke="#cbd5e1" strokeWidth={0.75} />
|
||||||
|
|
||||||
|
{/* Grid lines + Y-axis labels */}
|
||||||
|
{gridLines.map(f => {
|
||||||
|
const yPos = sy(max * f);
|
||||||
|
return (
|
||||||
|
<G key={f}>
|
||||||
|
<Line x1={padL} y1={yPos.toFixed(1)} x2={width - padR} y2={yPos.toFixed(1)}
|
||||||
|
stroke="#e2e8f0" strokeWidth={0.5} />
|
||||||
|
<SvgText x={(padL - 5).toFixed(1)} y={(yPos + 2.5).toFixed(1)}
|
||||||
|
fill="#94a3b8" style={{ fontSize: 6.5, textAnchor: 'end' }}>
|
||||||
|
{fmtAxis(max * f)}
|
||||||
|
</SvgText>
|
||||||
|
</G>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Comparison line (dashed) */}
|
||||||
|
{previous && previous.some(v => v > 0) && (
|
||||||
|
<Polyline points={toPoints(previous)}
|
||||||
|
stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 3" fill="none" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Per-museum series */}
|
||||||
|
{(series ?? []).map(s => s.data.some(v => v > 0) && (
|
||||||
|
<Polyline key={s.label} points={toPoints(s.data)}
|
||||||
|
stroke={s.color} strokeWidth={1.5} fill="none" />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Current period total line */}
|
||||||
|
{current.some(v => v > 0) && (
|
||||||
|
<Polyline points={toPoints(current)}
|
||||||
|
stroke={color} strokeWidth={series && series.length >= 2 ? 2 : 2.5} fill="none" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* X-axis week labels */}
|
||||||
|
{labels
|
||||||
|
.filter((_, i) => labels.length <= 8 || i % Math.ceil(labels.length / 8) === 0)
|
||||||
|
.map((label) => {
|
||||||
|
const origIdx = labels.indexOf(label);
|
||||||
|
return (
|
||||||
|
<SvgText key={label}
|
||||||
|
x={sx(origIdx).toFixed(1)} y={height - 5}
|
||||||
|
style={{ fontSize: 7, fill: '#94a3b8', textAnchor: 'middle' }}>
|
||||||
|
{label}
|
||||||
|
</SvgText>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HBarChartProps {
|
||||||
|
items: Array<{ name: string; value: number }>;
|
||||||
|
color: string;
|
||||||
|
usepalette?: boolean;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfHBarChart({ items, color, usepalette = false, width = 470 }: HBarChartProps) {
|
||||||
|
const barH = 17;
|
||||||
|
const gap = 10;
|
||||||
|
const labelW = 160;
|
||||||
|
const barAreaW = width - labelW - 20;
|
||||||
|
const max = Math.max(...items.map(i => i.value), 1);
|
||||||
|
const totalH = items.length * (barH + gap);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={totalH}>
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const y = i * (barH + gap);
|
||||||
|
const bw = Math.max((item.value / max) * barAreaW, 2);
|
||||||
|
const shortName = item.name.length > 26 ? item.name.slice(0, 26) + '…' : item.name;
|
||||||
|
const valueStr = item.value.toLocaleString('en-SA', { maximumFractionDigits: 0 });
|
||||||
|
const barColor = usepalette ? CHART_PALETTE[i % CHART_PALETTE.length] : color;
|
||||||
|
const isShort = bw < 48;
|
||||||
|
return (
|
||||||
|
<G key={item.name + i}>
|
||||||
|
<SvgText x={0} y={y + barH - 4} fill="#334155" style={{ fontSize: 8.5 }}>{shortName}</SvgText>
|
||||||
|
<Rect x={labelW} y={y} width={bw} height={barH} fill={barColor} rx={3} />
|
||||||
|
{isShort ? (
|
||||||
|
<SvgText x={labelW + bw + 6} y={y + barH - 4} fill="#334155"
|
||||||
|
style={{ fontSize: 8.5 }}>{valueStr}</SvgText>
|
||||||
|
) : (
|
||||||
|
<SvgText x={labelW + bw - 6} y={y + barH - 4} fill="#ffffff"
|
||||||
|
style={{ fontSize: 8.5, textAnchor: 'end' }}>{valueStr}</SvgText>
|
||||||
|
)}
|
||||||
|
</G>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { filterDataByDateRange, calculateMetrics, groupByMuseum, groupByChannel, groupByDistrict, umrahData } from '../../services/dataService';
|
||||||
|
import { shiftYear } from '../../lib/dateHelpers';
|
||||||
|
import type { MuseumRecord, Metrics } from '../../types';
|
||||||
|
|
||||||
|
// ─── config ───────────────────────────────────────────────────────
|
||||||
|
export type TrendMetric = 'revenue' | 'visitors' | 'tickets';
|
||||||
|
export type TrendGranularity = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
function inferGranularity(start: string, end: string): TrendGranularity {
|
||||||
|
const days = Math.round((new Date(end).getTime() - new Date(start).getTime()) / 86400000);
|
||||||
|
if (days > 180) return 'month';
|
||||||
|
if (days >= 14) return 'week';
|
||||||
|
return 'day';
|
||||||
|
}
|
||||||
|
|
||||||
|
const _start = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10);
|
||||||
|
const _end = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
export interface ReportConfig {
|
||||||
|
title: string;
|
||||||
|
clientName: string;
|
||||||
|
contactName: string;
|
||||||
|
clientLogoBase64: string | null;
|
||||||
|
accentColor: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
selectedMuseums: string[];
|
||||||
|
selectedChannels: string[];
|
||||||
|
includeVAT: boolean;
|
||||||
|
includeComparison: boolean;
|
||||||
|
comparisonStartDate: string;
|
||||||
|
comparisonEndDate: string;
|
||||||
|
// Summary & metrics
|
||||||
|
showExecutiveSummary: boolean;
|
||||||
|
showMetricsTable: boolean;
|
||||||
|
showPilgrimCapture: boolean;
|
||||||
|
// Trend chart
|
||||||
|
showTrendChart: boolean;
|
||||||
|
trendMetrics: TrendMetric[];
|
||||||
|
// Museum mini-reports
|
||||||
|
showMuseumRevenue: boolean;
|
||||||
|
showMuseumVisitors: boolean;
|
||||||
|
showMuseumTickets: boolean;
|
||||||
|
// Channel breakdowns
|
||||||
|
showChannelRevenue: boolean;
|
||||||
|
showChannelVisitors: boolean;
|
||||||
|
showChannelTickets: boolean;
|
||||||
|
// District breakdowns
|
||||||
|
showDistrictRevenue: boolean;
|
||||||
|
showDistrictVisitors: boolean;
|
||||||
|
showDistrictTickets: boolean;
|
||||||
|
// Global summary table
|
||||||
|
showGlobalSummary: boolean;
|
||||||
|
// Presentation
|
||||||
|
language: 'en' | 'ar';
|
||||||
|
confidentiality: 'Confidential' | 'Internal' | 'Public';
|
||||||
|
orientation: 'portrait' | 'landscape';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIG: ReportConfig = {
|
||||||
|
title: '',
|
||||||
|
clientName: '',
|
||||||
|
contactName: '',
|
||||||
|
clientLogoBase64: null,
|
||||||
|
accentColor: '#2563eb',
|
||||||
|
startDate: _start,
|
||||||
|
endDate: _end,
|
||||||
|
selectedMuseums: [],
|
||||||
|
selectedChannels: [],
|
||||||
|
includeVAT: true,
|
||||||
|
includeComparison: true,
|
||||||
|
comparisonStartDate: shiftYear(_start),
|
||||||
|
comparisonEndDate: shiftYear(_end),
|
||||||
|
showExecutiveSummary: true,
|
||||||
|
showMetricsTable: true,
|
||||||
|
showPilgrimCapture: true,
|
||||||
|
showTrendChart: true,
|
||||||
|
trendMetrics: ['revenue'],
|
||||||
|
showMuseumRevenue: true,
|
||||||
|
showMuseumVisitors: true,
|
||||||
|
showMuseumTickets: false,
|
||||||
|
showChannelRevenue: false,
|
||||||
|
showChannelVisitors: true,
|
||||||
|
showChannelTickets: false,
|
||||||
|
showDistrictRevenue: false,
|
||||||
|
showDistrictVisitors: false,
|
||||||
|
showDistrictTickets: false,
|
||||||
|
showGlobalSummary: true,
|
||||||
|
language: 'en',
|
||||||
|
confidentiality: 'Confidential',
|
||||||
|
orientation: 'portrait',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── computed report data ─────────────────────────────────────────
|
||||||
|
export interface BreakdownItem { name: string; value: number; }
|
||||||
|
|
||||||
|
export interface DimensionBreakdown {
|
||||||
|
revenue: BreakdownItem[];
|
||||||
|
visitors: BreakdownItem[];
|
||||||
|
tickets: BreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MuseumDataRow {
|
||||||
|
name: string;
|
||||||
|
curr: { revenue: number; visitors: number; tickets: number };
|
||||||
|
prev: { revenue: number; visitors: number; tickets: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendChart {
|
||||||
|
metric: TrendMetric;
|
||||||
|
labels: string[];
|
||||||
|
current: number[];
|
||||||
|
previous: number[] | null;
|
||||||
|
museums: Array<{ name: string; values: number[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportData {
|
||||||
|
config: ReportConfig;
|
||||||
|
metrics: Metrics;
|
||||||
|
prevMetrics: Metrics | null;
|
||||||
|
comparisonPeriodLabel: string;
|
||||||
|
trendCharts: TrendChart[];
|
||||||
|
museumData: MuseumDataRow[];
|
||||||
|
museumBreakdown: DimensionBreakdown;
|
||||||
|
channelBreakdown: DimensionBreakdown;
|
||||||
|
districtBreakdown: DimensionBreakdown;
|
||||||
|
pilgrimCapture: { current: number; previous: number | null } | null;
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── data computation ─────────────────────────────────────────────
|
||||||
|
function applyDimFilters(rows: MuseumRecord[], cfg: ReportConfig): MuseumRecord[] {
|
||||||
|
let d = rows;
|
||||||
|
if (cfg.selectedMuseums.length) d = d.filter(r => cfg.selectedMuseums.includes(r.museum_name));
|
||||||
|
if (cfg.selectedChannels.length) d = d.filter(r => cfg.selectedChannels.includes(r.channel));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimatePilgrims(start: string, end: string): number | null {
|
||||||
|
const sd = new Date(start), ed = new Date(end);
|
||||||
|
let total = 0, has = false;
|
||||||
|
for (let y = sd.getFullYear(); y <= ed.getFullYear(); y++) {
|
||||||
|
for (let q = 1; q <= 4; q++) {
|
||||||
|
const qs = new Date(y, (q - 1) * 3, 1), qe = new Date(y, q * 3, 0);
|
||||||
|
if (qe < sd || qs > ed) continue;
|
||||||
|
const p = (umrahData as any)[y]?.[q];
|
||||||
|
if (!p) continue;
|
||||||
|
const os = new Date(Math.max(qs.getTime(), sd.getTime()));
|
||||||
|
const oe = new Date(Math.min(qe.getTime(), ed.getTime()));
|
||||||
|
total += p * ((oe.getTime() - os.getTime()) / 86400000 + 1) /
|
||||||
|
((qe.getTime() - qs.getTime()) / 86400000 + 1);
|
||||||
|
has = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return has ? Math.round(total) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetricVal(r: MuseumRecord, metric: TrendMetric, includeVAT: boolean): number {
|
||||||
|
if (metric === 'visitors') return r.visits || 0;
|
||||||
|
if (metric === 'tickets') return r.tickets || 0;
|
||||||
|
return (includeVAT ? r.revenue_gross : r.revenue_net) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTH_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
|
||||||
|
function buildTrend(rows: MuseumRecord[], start: string, metric: TrendMetric, includeVAT: boolean, gran: TrendGranularity): { labels: string[]; values: number[] } {
|
||||||
|
const s = new Date(start);
|
||||||
|
const acc: Record<number, MuseumRecord[]> = {};
|
||||||
|
rows.forEach(r => {
|
||||||
|
if (!r.date) return;
|
||||||
|
const diff = Math.floor((new Date(r.date).getTime() - s.getTime()) / 86400000);
|
||||||
|
const key = gran === 'month' ? Math.floor(diff / 30) + 1 : gran === 'week' ? Math.floor(diff / 7) + 1 : diff + 1;
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(r);
|
||||||
|
});
|
||||||
|
const maxK = Math.max(...Object.keys(acc).map(Number), 1);
|
||||||
|
const labels = Array.from({ length: maxK }, (_, i) => {
|
||||||
|
if (gran === 'month') return MONTH_SHORT[(s.getMonth() + i) % 12];
|
||||||
|
if (gran === 'week') return `W${i + 1}`;
|
||||||
|
return `${i + 1}`;
|
||||||
|
});
|
||||||
|
const values = labels.map((_, i) => {
|
||||||
|
const group = acc[i + 1] || [];
|
||||||
|
return group.reduce((sum, r) => sum + getMetricVal(r, metric, includeVAT), 0);
|
||||||
|
});
|
||||||
|
return { labels, values };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDimensionBreakdown(g: Record<string, { revenue: number; visitors: number; tickets: number }>, limit = 10): DimensionBreakdown {
|
||||||
|
const entries = Object.entries(g);
|
||||||
|
const sort = (key: 'revenue' | 'visitors' | 'tickets') =>
|
||||||
|
entries.map(([name, v]) => ({ name, value: v[key] })).sort((a, b) => b.value - a.value).slice(0, limit);
|
||||||
|
return { revenue: sort('revenue'), visitors: sort('visitors'), tickets: sort('tickets') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): ReportData {
|
||||||
|
const currRows = applyDimFilters(filterDataByDateRange(allData, cfg.startDate, cfg.endDate, {}), cfg);
|
||||||
|
const metrics = calculateMetrics(currRows, cfg.includeVAT);
|
||||||
|
|
||||||
|
const prevRows = cfg.includeComparison
|
||||||
|
? applyDimFilters(filterDataByDateRange(allData, cfg.comparisonStartDate, cfg.comparisonEndDate, {}), cfg)
|
||||||
|
: [];
|
||||||
|
const prevMetrics = cfg.includeComparison ? calculateMetrics(prevRows, cfg.includeVAT) : null;
|
||||||
|
|
||||||
|
const comparisonPeriodLabel = cfg.includeComparison
|
||||||
|
? formatPeriodLabel(cfg.comparisonStartDate, cfg.comparisonEndDate, cfg.language)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const gran = inferGranularity(cfg.startDate, cfg.endDate);
|
||||||
|
const museumNames = Object.keys(groupByMuseum(currRows, cfg.includeVAT))
|
||||||
|
.filter(name => currRows.some(r => r.museum_name === name));
|
||||||
|
|
||||||
|
const trendCharts: TrendChart[] = cfg.trendMetrics.map(metric => {
|
||||||
|
const currT = buildTrend(currRows, cfg.startDate, metric, cfg.includeVAT, gran);
|
||||||
|
const prevT = cfg.includeComparison
|
||||||
|
? buildTrend(prevRows, cfg.comparisonStartDate, metric, cfg.includeVAT, gran)
|
||||||
|
: null;
|
||||||
|
const maxLen = Math.max(currT.labels.length, prevT ? prevT.values.length : 0, 1);
|
||||||
|
const labels = Array.from({ length: maxLen }, (_, i) => currT.labels[i] ?? `${i + 1}`);
|
||||||
|
const current = Array.from({ length: maxLen }, (_, i) => currT.values[i] ?? 0);
|
||||||
|
const previous = prevT ? Array.from({ length: maxLen }, (_, i) => prevT.values[i] ?? 0) : null;
|
||||||
|
const museums = museumNames.map(name => {
|
||||||
|
const mt = buildTrend(currRows.filter(r => r.museum_name === name), cfg.startDate, metric, cfg.includeVAT, gran);
|
||||||
|
return { name, values: Array.from({ length: maxLen }, (_, i) => mt.values[i] ?? 0) };
|
||||||
|
}).filter(m => m.values.some(v => v > 0));
|
||||||
|
return { metric, labels, current, previous, museums };
|
||||||
|
});
|
||||||
|
|
||||||
|
const currMuseumGroups = groupByMuseum(currRows, cfg.includeVAT);
|
||||||
|
const prevMuseumGroups = cfg.includeComparison ? groupByMuseum(prevRows, cfg.includeVAT) : {};
|
||||||
|
const museumData: MuseumDataRow[] = Object.entries(currMuseumGroups)
|
||||||
|
.map(([name, curr]) => ({ name, curr, prev: prevMuseumGroups[name] ?? null }))
|
||||||
|
.sort((a, b) => b.curr.revenue - a.curr.revenue);
|
||||||
|
|
||||||
|
const museumBreakdown = makeDimensionBreakdown(currMuseumGroups);
|
||||||
|
const channelBreakdown = makeDimensionBreakdown(groupByChannel(currRows, cfg.includeVAT), 20);
|
||||||
|
const districtBreakdown = makeDimensionBreakdown(groupByDistrict(currRows, cfg.includeVAT));
|
||||||
|
|
||||||
|
const currPilgrims = estimatePilgrims(cfg.startDate, cfg.endDate);
|
||||||
|
const prevPilgrims = cfg.includeComparison
|
||||||
|
? estimatePilgrims(cfg.comparisonStartDate, cfg.comparisonEndDate)
|
||||||
|
: null;
|
||||||
|
const pilgrimCapture = currPilgrims !== null
|
||||||
|
? {
|
||||||
|
current: parseFloat(((metrics.visitors / currPilgrims) * 100).toFixed(2)),
|
||||||
|
previous: prevPilgrims && prevMetrics
|
||||||
|
? parseFloat(((prevMetrics.visitors / prevPilgrims) * 100).toFixed(2))
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: cfg,
|
||||||
|
metrics,
|
||||||
|
prevMetrics,
|
||||||
|
comparisonPeriodLabel,
|
||||||
|
trendCharts,
|
||||||
|
museumData,
|
||||||
|
museumBreakdown,
|
||||||
|
channelBreakdown,
|
||||||
|
districtBreakdown,
|
||||||
|
pilgrimCapture,
|
||||||
|
generatedAt: new Date().toLocaleDateString('en-GB'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── formatters ───────────────────────────────────────────────────
|
||||||
|
export function formatCurrency(n: number, inclVAT: boolean): string {
|
||||||
|
return `SAR ${n.toLocaleString('en-SA', { maximumFractionDigits: 0 })}${inclVAT ? '' : ' (ex-VAT)'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPct(change: number): string {
|
||||||
|
return `${change >= 0 ? '+' : ''}${change.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPeriodLabel(start: string, end: string, lang: 'en' | 'ar'): string {
|
||||||
|
const months = lang === 'en'
|
||||||
|
? ['January','February','March','April','May','June','July','August','September','October','November','December']
|
||||||
|
: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'];
|
||||||
|
const s = new Date(start), e = new Date(end);
|
||||||
|
const sm = months[s.getMonth()], em = months[e.getMonth()];
|
||||||
|
const sy = s.getFullYear(), ey = e.getFullYear();
|
||||||
|
if (sy === ey && sm === em) return `${sm} ${sy}`;
|
||||||
|
if (sy === ey) return `${sm} – ${em} ${sy}`;
|
||||||
|
return `${sm} ${sy} – ${em} ${ey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── executive summary ────────────────────────────────────────────
|
||||||
|
export function generateExecutiveSummary(data: ReportData): string {
|
||||||
|
const { config: cfg, metrics, prevMetrics, channelBreakdown, comparisonPeriodLabel } = data;
|
||||||
|
const lang = cfg.language;
|
||||||
|
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
|
||||||
|
const revenue = formatCurrency(metrics.revenue, cfg.includeVAT);
|
||||||
|
const topChannel = channelBreakdown.visitors[0]?.name ?? '';
|
||||||
|
const totalVisitors = channelBreakdown.visitors.reduce((s, i) => s + i.value, 0);
|
||||||
|
const topPct = totalVisitors > 0 && channelBreakdown.visitors[0]
|
||||||
|
? Math.round((channelBreakdown.visitors[0].value / totalVisitors) * 100)
|
||||||
|
: 0;
|
||||||
|
const museumLabel = cfg.selectedMuseums.length > 0
|
||||||
|
? cfg.selectedMuseums.join(', ')
|
||||||
|
: (lang === 'en' ? 'all museums' : 'جميع المتاحف');
|
||||||
|
|
||||||
|
if (lang === 'en') {
|
||||||
|
let s = `During ${period}, ${museumLabel} recorded ${metrics.visitors.toLocaleString()} visitors and ${revenue} in revenue.`;
|
||||||
|
if (prevMetrics && prevMetrics.revenue > 0) {
|
||||||
|
const chg = Math.round(((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue) * 100);
|
||||||
|
s += ` This represents a ${formatPct(chg)} change in revenue versus ${comparisonPeriodLabel}.`;
|
||||||
|
}
|
||||||
|
if (topChannel) s += ` The top-performing channel was ${topChannel} with ${topPct}% of total visitors.`;
|
||||||
|
return s;
|
||||||
|
} else {
|
||||||
|
let s = `خلال ${period}، سجّلت ${museumLabel} ${metrics.visitors.toLocaleString()} زائراً وإيرادات بلغت ${revenue}.`;
|
||||||
|
if (prevMetrics && prevMetrics.revenue > 0) {
|
||||||
|
const chg = Math.round(((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue) * 100);
|
||||||
|
s += ` يمثّل ذلك تغيّراً بنسبة ${formatPct(chg)} في الإيرادات مقارنةً بـ${comparisonPeriodLabel}.`;
|
||||||
|
}
|
||||||
|
if (topChannel) s += ` كانت ${topChannel} أعلى القنوات أداءً بنسبة ${topPct}% من إجمالي الزوار.`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── page count estimator ─────────────────────────────────────────
|
||||||
|
export function estimatePageCount(cfg: ReportConfig): number {
|
||||||
|
let pages = 2; // cover + summary/metrics/trend
|
||||||
|
if (cfg.showMuseumRevenue || cfg.showMuseumVisitors || cfg.showMuseumTickets) pages += 1;
|
||||||
|
if (cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets) pages += 1;
|
||||||
|
if (cfg.showDistrictRevenue || cfg.showDistrictVisitors || cfg.showDistrictTickets) pages += 1;
|
||||||
|
if (cfg.showGlobalSummary && cfg.includeComparison) pages += 1;
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
+128
-161
@@ -6,13 +6,13 @@ import type { Season } from '../types';
|
|||||||
|
|
||||||
const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899'];
|
const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899'];
|
||||||
|
|
||||||
interface SeasonRowProps {
|
interface SeasonItemProps {
|
||||||
season: Season;
|
season: Season;
|
||||||
onSave: (id: number, data: Partial<Season>) => Promise<void>;
|
onSave: (id: number, data: Partial<Season>) => Promise<void>;
|
||||||
onDelete: (id: number) => Promise<void>;
|
onDelete: (id: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SeasonRow({ season, onSave, onDelete }: SeasonRowProps) {
|
function SeasonItem({ season, onSave, onDelete }: SeasonItemProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [form, setForm] = useState(season);
|
const [form, setForm] = useState(season);
|
||||||
|
|
||||||
@@ -21,48 +21,46 @@ function SeasonRow({ season, onSave, onDelete }: SeasonRowProps) {
|
|||||||
setEditing(false);
|
setEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!editing) {
|
|
||||||
return (
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span className="season-chip" style={{ backgroundColor: season.Color + '20', color: season.Color, borderColor: season.Color }}>
|
|
||||||
{season.Name} {season.HijriYear}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{season.StartDate}</td>
|
|
||||||
<td>{season.EndDate}</td>
|
|
||||||
<td>
|
|
||||||
<div className="season-actions">
|
|
||||||
<button className="btn-small" onClick={() => setEditing(true)}>Edit</button>
|
|
||||||
<button className="btn-small btn-danger" onClick={() => onDelete(season.Id!)}>Delete</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="editing">
|
<div className={`settings-item${editing ? ' settings-item--editing' : ''}`}>
|
||||||
<td>
|
<div className="settings-item-row">
|
||||||
<div className="season-edit-name">
|
<span className="season-chip" style={{ backgroundColor: season.Color + '20', color: season.Color, borderColor: season.Color }}>
|
||||||
<input type="text" value={form.Name} onChange={e => setForm({ ...form, Name: e.target.value })} placeholder="Name" />
|
{season.Name} {season.HijriYear}
|
||||||
<input type="number" value={form.HijriYear} onChange={e => setForm({ ...form, HijriYear: parseInt(e.target.value) || 0 })} placeholder="Year" style={{ width: 80 }} />
|
</span>
|
||||||
<input type="color" value={form.Color} onChange={e => setForm({ ...form, Color: e.target.value })} />
|
<span className="settings-dates">{season.StartDate} → {season.EndDate}</span>
|
||||||
|
<div className="settings-item-actions">
|
||||||
|
<button className="btn-small" onClick={() => { setForm(season); setEditing(true); }}>Edit</button>
|
||||||
|
<button className="btn-small btn-danger" onClick={() => onDelete(season.Id!)}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td><input type="date" value={form.StartDate} onChange={e => setForm({ ...form, StartDate: e.target.value })} /></td>
|
{editing && (
|
||||||
<td><input type="date" value={form.EndDate} onChange={e => setForm({ ...form, EndDate: e.target.value })} /></td>
|
<div className="settings-item-form">
|
||||||
<td>
|
<div className="form-row">
|
||||||
<div className="season-actions">
|
<input className="form-input" type="text" value={form.Name} onChange={e => setForm({ ...form, Name: e.target.value })} placeholder="Name" />
|
||||||
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
|
<input className="form-input form-input--sm" type="number" value={form.HijriYear} onChange={e => setForm({ ...form, HijriYear: parseInt(e.target.value) || 0 })} placeholder="Hijri Year" />
|
||||||
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
|
<input type="color" className="form-color" value={form.Color} onChange={e => setForm({ ...form, Color: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-field">
|
||||||
|
<span className="form-label">Start</span>
|
||||||
|
<input className="form-input" type="date" value={form.StartDate} onChange={e => setForm({ ...form, StartDate: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
<span className="form-label">End</span>
|
||||||
|
<input className="form-input" type="date" value={form.EndDate} onChange={e => setForm({ ...form, EndDate: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
|
||||||
|
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
)}
|
||||||
</tr>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserRowProps {
|
interface UserItemProps {
|
||||||
user: User;
|
user: User;
|
||||||
allMuseums: string[];
|
allMuseums: string[];
|
||||||
allChannels: string[];
|
allChannels: string[];
|
||||||
@@ -70,7 +68,7 @@ interface UserRowProps {
|
|||||||
onDelete: (id: number) => Promise<void>;
|
onDelete: (id: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowProps) {
|
function UserItem({ user, allMuseums, allChannels, onUpdate, onDelete }: UserItemProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [allowedMuseums, setAllowedMuseums] = useState<string[]>(() => {
|
const [allowedMuseums, setAllowedMuseums] = useState<string[]>(() => {
|
||||||
try { return JSON.parse(user.AllowedMuseums || '[]'); } catch { return []; }
|
try { return JSON.parse(user.AllowedMuseums || '[]'); } catch { return []; }
|
||||||
@@ -91,17 +89,18 @@ function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowP
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isAdmin = user.Role === 'admin';
|
const isAdmin = user.Role === 'admin';
|
||||||
|
|
||||||
const museumCount = (() => { try { const a = JSON.parse(user.AllowedMuseums || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
|
const museumCount = (() => { try { const a = JSON.parse(user.AllowedMuseums || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
|
||||||
const channelCount = (() => { try { const a = JSON.parse(user.AllowedChannels || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
|
const channelCount = (() => { try { const a = JSON.parse(user.AllowedChannels || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
|
||||||
|
|
||||||
if (!editing) {
|
return (
|
||||||
return (
|
<div className={`settings-item${editing ? ' settings-item--editing' : ''}`}>
|
||||||
<tr key={user.Id}>
|
<div className="settings-item-row">
|
||||||
<td>{user.Name}</td>
|
<div className="settings-user-info">
|
||||||
<td><code>{user.PIN}</code></td>
|
<span className="settings-user-name">{user.Name}</span>
|
||||||
<td>{user.Role}</td>
|
<code className="settings-user-pin">{user.PIN}</code>
|
||||||
<td>
|
<span className="settings-user-role">{user.Role}</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-user-access">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<span className="access-badge access-badge--full">Full access</span>
|
<span className="access-badge access-badge--full">Full access</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -110,53 +109,45 @@ function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowP
|
|||||||
<span className="access-badge">{channelCount === 0 ? 'All channels' : `${channelCount} channels`}</span>
|
<span className="access-badge">{channelCount === 0 ? 'All channels' : `${channelCount} channels`}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
<td>
|
<div className="settings-item-actions">
|
||||||
<div className="season-actions">
|
{!isAdmin && <button className="btn-small" onClick={() => setEditing(true)}>Edit access</button>}
|
||||||
{!isAdmin && <button className="btn-small" onClick={() => setEditing(true)}>Edit access</button>}
|
<button className="btn-small btn-danger" onClick={() => onDelete(user.Id!)}>Delete</button>
|
||||||
<button className="btn-small btn-danger" onClick={() => onDelete(user.Id!)}>Delete</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
{editing && (
|
||||||
</tr>
|
<div className="settings-item-form">
|
||||||
);
|
<div className="access-columns">
|
||||||
}
|
<div className="access-col">
|
||||||
|
<div className="access-col-title">
|
||||||
return (
|
Events {allowedMuseums.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
||||||
<tr className="editing">
|
|
||||||
<td colSpan={5}>
|
|
||||||
<div style={{ padding: '12px 4px' }}>
|
|
||||||
<strong>{user.Name}</strong>
|
|
||||||
<div style={{ display: 'flex', gap: 32, marginTop: 12, flexWrap: 'wrap' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
|
||||||
Allowed Events {allowedMuseums.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
|
||||||
</div>
|
</div>
|
||||||
{allMuseums.map(m => (
|
{allMuseums.map(m => (
|
||||||
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, cursor: 'pointer' }}>
|
<label key={m} className="access-check">
|
||||||
<input type="checkbox" checked={allowedMuseums.includes(m)} onChange={() => toggleItem(allowedMuseums, setAllowedMuseums, m)} />
|
<input type="checkbox" checked={allowedMuseums.includes(m)} onChange={() => toggleItem(allowedMuseums, setAllowedMuseums, m)} />
|
||||||
{m}
|
{m}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="access-col">
|
||||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
<div className="access-col-title">
|
||||||
Allowed Channels {allowedChannels.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
Channels {allowedChannels.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
||||||
</div>
|
</div>
|
||||||
{allChannels.map(c => (
|
{allChannels.map(c => (
|
||||||
<label key={c} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, cursor: 'pointer' }}>
|
<label key={c} className="access-check">
|
||||||
<input type="checkbox" checked={allowedChannels.includes(c)} onChange={() => toggleItem(allowedChannels, setAllowedChannels, c)} />
|
<input type="checkbox" checked={allowedChannels.includes(c)} onChange={() => toggleItem(allowedChannels, setAllowedChannels, c)} />
|
||||||
{c}
|
{c}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
<div className="form-actions">
|
||||||
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
|
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
|
||||||
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
|
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
)}
|
||||||
</tr>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +164,7 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
|||||||
|
|
||||||
const [newSeason, setNewSeason] = useState<Omit<Season, 'Id'>>({
|
const [newSeason, setNewSeason] = useState<Omit<Season, 'Id'>>({
|
||||||
Name: '',
|
Name: '',
|
||||||
HijriYear: new Date().getFullYear() - 579, // rough Gregorian → Hijri
|
HijriYear: new Date().getFullYear() - 579,
|
||||||
StartDate: '',
|
StartDate: '',
|
||||||
EndDate: '',
|
EndDate: '',
|
||||||
Color: DEFAULT_COLORS[0],
|
Color: DEFAULT_COLORS[0],
|
||||||
@@ -238,42 +229,36 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
|||||||
<h2>{t('settings.seasons')}</h2>
|
<h2>{t('settings.seasons')}</h2>
|
||||||
<p className="settings-hint">{t('settings.seasonsHint')}</p>
|
<p className="settings-hint">{t('settings.seasonsHint')}</p>
|
||||||
|
|
||||||
<div className="table-container">
|
<div className="settings-list">
|
||||||
<table>
|
{loading ? (
|
||||||
<thead>
|
<div className="settings-loading">Loading...</div>
|
||||||
<tr>
|
) : (
|
||||||
<th>{t('settings.seasonName')}</th>
|
seasons.map(s => (
|
||||||
<th>{t('settings.startDate')}</th>
|
<SeasonItem key={s.Id} season={s} onSave={handleSave} onDelete={handleDelete} />
|
||||||
<th>{t('settings.endDate')}</th>
|
))
|
||||||
<th>{t('settings.actions')}</th>
|
)}
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
|
||||||
<tbody>
|
<div className="settings-add-form">
|
||||||
{loading ? (
|
<div className="settings-add-title">{t('settings.add')} Season</div>
|
||||||
<tr><td colSpan={4} style={{ textAlign: 'center', padding: 24 }}>Loading...</td></tr>
|
<div className="form-row">
|
||||||
) : (
|
<input className="form-input" type="text" value={newSeason.Name} onChange={e => setNewSeason({ ...newSeason, Name: e.target.value })} placeholder={t('settings.namePlaceholder')} />
|
||||||
seasons.map(s => (
|
<input className="form-input form-input--sm" type="number" value={newSeason.HijriYear} onChange={e => setNewSeason({ ...newSeason, HijriYear: parseInt(e.target.value) || 0 })} />
|
||||||
<SeasonRow key={s.Id} season={s} onSave={handleSave} onDelete={handleDelete} />
|
<input type="color" className="form-color" value={newSeason.Color} onChange={e => setNewSeason({ ...newSeason, Color: e.target.value })} />
|
||||||
))
|
</div>
|
||||||
)}
|
<div className="form-row">
|
||||||
<tr className="add-row">
|
<label className="form-field">
|
||||||
<td>
|
<span className="form-label">{t('settings.startDate')}</span>
|
||||||
<div className="season-edit-name">
|
<input className="form-input" type="date" value={newSeason.StartDate} onChange={e => setNewSeason({ ...newSeason, StartDate: e.target.value })} />
|
||||||
<input type="text" value={newSeason.Name} onChange={e => setNewSeason({ ...newSeason, Name: e.target.value })} placeholder={t('settings.namePlaceholder')} />
|
</label>
|
||||||
<input type="number" value={newSeason.HijriYear} onChange={e => setNewSeason({ ...newSeason, HijriYear: parseInt(e.target.value) || 0 })} style={{ width: 80 }} />
|
<label className="form-field">
|
||||||
<input type="color" value={newSeason.Color} onChange={e => setNewSeason({ ...newSeason, Color: e.target.value })} />
|
<span className="form-label">{t('settings.endDate')}</span>
|
||||||
</div>
|
<input className="form-input" type="date" value={newSeason.EndDate} onChange={e => setNewSeason({ ...newSeason, EndDate: e.target.value })} />
|
||||||
</td>
|
</label>
|
||||||
<td><input type="date" value={newSeason.StartDate} onChange={e => setNewSeason({ ...newSeason, StartDate: e.target.value })} /></td>
|
</div>
|
||||||
<td><input type="date" value={newSeason.EndDate} onChange={e => setNewSeason({ ...newSeason, EndDate: e.target.value })} /></td>
|
<button className="btn-small btn-primary" onClick={handleCreate} disabled={!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate}>
|
||||||
<td>
|
{t('settings.add')}
|
||||||
<button className="btn-small btn-primary" onClick={handleCreate} disabled={!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate}>
|
</button>
|
||||||
{t('settings.add')}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -281,55 +266,37 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
|||||||
<h2>{t('settings.users')}</h2>
|
<h2>{t('settings.users')}</h2>
|
||||||
<p className="settings-hint">{t('settings.usersHint')}</p>
|
<p className="settings-hint">{t('settings.usersHint')}</p>
|
||||||
|
|
||||||
<div className="table-container">
|
<div className="settings-list">
|
||||||
<table>
|
{users.map(u => (
|
||||||
<thead>
|
<UserItem
|
||||||
<tr>
|
key={u.Id}
|
||||||
<th>{t('settings.userName')}</th>
|
user={u}
|
||||||
<th>{t('settings.userPin')}</th>
|
allMuseums={allMuseums}
|
||||||
<th>{t('settings.userRole')}</th>
|
allChannels={allChannels}
|
||||||
<th>Access</th>
|
onUpdate={handleUpdateUser}
|
||||||
<th>{t('settings.actions')}</th>
|
onDelete={async (id) => { await deleteUser(id); await loadUsers(); }}
|
||||||
</tr>
|
/>
|
||||||
</thead>
|
))}
|
||||||
<tbody>
|
</div>
|
||||||
{users.map(u => (
|
|
||||||
<UserRow
|
<div className="settings-add-form">
|
||||||
key={u.Id}
|
<div className="settings-add-title">{t('settings.add')} User</div>
|
||||||
user={u}
|
<div className="form-row">
|
||||||
allMuseums={allMuseums}
|
<input className="form-input" type="text" value={newUser.Name} onChange={e => setNewUser({ ...newUser, Name: e.target.value })} placeholder={t('settings.userNamePlaceholder')} />
|
||||||
allChannels={allChannels}
|
<input className="form-input form-input--sm" type="text" value={newUser.PIN} onChange={e => setNewUser({ ...newUser, PIN: e.target.value })} placeholder="PIN" />
|
||||||
onUpdate={handleUpdateUser}
|
<select className="form-input form-input--sm" value={newUser.Role} onChange={e => setNewUser({ ...newUser, Role: e.target.value })}>
|
||||||
onDelete={async (id) => { await deleteUser(id); await loadUsers(); }}
|
<option value="viewer">Viewer</option>
|
||||||
/>
|
<option value="admin">Admin</option>
|
||||||
))}
|
</select>
|
||||||
<tr className="add-row">
|
</div>
|
||||||
<td>
|
<button className="btn-small btn-primary" onClick={async () => {
|
||||||
<input type="text" value={newUser.Name} onChange={e => setNewUser({ ...newUser, Name: e.target.value })} placeholder={t('settings.userNamePlaceholder')} />
|
if (!newUser.Name || !newUser.PIN) return;
|
||||||
</td>
|
await createUser(newUser);
|
||||||
<td>
|
setNewUser({ Name: '', PIN: '', Role: 'viewer', AllowedMuseums: '[]', AllowedChannels: '[]' });
|
||||||
<input type="text" value={newUser.PIN} onChange={e => setNewUser({ ...newUser, PIN: e.target.value })} placeholder="PIN" />
|
await loadUsers();
|
||||||
</td>
|
}} disabled={!newUser.Name || !newUser.PIN}>
|
||||||
<td>
|
{t('settings.add')}
|
||||||
<select value={newUser.Role} onChange={e => setNewUser({ ...newUser, Role: e.target.value })}>
|
</button>
|
||||||
<option value="viewer">Viewer</option>
|
|
||||||
<option value="admin">Admin</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
<td>
|
|
||||||
<button className="btn-small btn-primary" onClick={async () => {
|
|
||||||
if (!newUser.Name || !newUser.PIN) return;
|
|
||||||
await createUser(newUser);
|
|
||||||
setNewUser({ Name: '', PIN: '', Role: 'viewer', AllowedMuseums: '[]', AllowedChannels: '[]' });
|
|
||||||
await loadUsers();
|
|
||||||
}} disabled={!newUser.Name || !newUser.PIN}>
|
|
||||||
{t('settings.add')}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,643 +0,0 @@
|
|||||||
import React, { useState, useMemo, useCallback } from 'react';
|
|
||||||
import { Line, Bar } from 'react-chartjs-2';
|
|
||||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
|
||||||
import {
|
|
||||||
filterDataByDateRange,
|
|
||||||
calculateMetrics,
|
|
||||||
formatCompact,
|
|
||||||
formatCompactCurrency,
|
|
||||||
getUniqueChannels,
|
|
||||||
getUniqueMuseums
|
|
||||||
} from '../services/dataService';
|
|
||||||
import JSZip from 'jszip';
|
|
||||||
import type {
|
|
||||||
MuseumRecord,
|
|
||||||
SlideConfig,
|
|
||||||
ChartTypeOption,
|
|
||||||
MetricOption,
|
|
||||||
MetricFieldInfo,
|
|
||||||
SlidesProps
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
interface SlideEditorProps {
|
|
||||||
slide: SlideConfig;
|
|
||||||
onUpdate: (updates: Partial<SlideConfig>) => void;
|
|
||||||
channels: string[];
|
|
||||||
museums: string[];
|
|
||||||
data: MuseumRecord[];
|
|
||||||
chartTypes: ChartTypeOption[];
|
|
||||||
metrics: MetricOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SlidePreviewProps {
|
|
||||||
slide: SlideConfig;
|
|
||||||
data: MuseumRecord[];
|
|
||||||
channels: string[];
|
|
||||||
museums: string[];
|
|
||||||
metrics: MetricOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PreviewModeProps {
|
|
||||||
slides: SlideConfig[];
|
|
||||||
data: MuseumRecord[];
|
|
||||||
channels: string[];
|
|
||||||
museums: string[];
|
|
||||||
currentSlide: number;
|
|
||||||
setCurrentSlide: React.Dispatch<React.SetStateAction<number>>;
|
|
||||||
onExit: () => void;
|
|
||||||
metrics: MetricOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function Slides({ data }: SlidesProps) {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
const CHART_TYPES: ChartTypeOption[] = useMemo(() => [
|
|
||||||
{ id: 'trend', label: t('slides.revenueTrend'), icon: '📈' },
|
|
||||||
{ id: 'museum-bar', label: t('slides.byMuseum'), icon: '📊' },
|
|
||||||
{ id: 'kpi-cards', label: t('slides.kpiSummary'), icon: '🎯' },
|
|
||||||
{ id: 'comparison', label: t('slides.yoyComparison'), icon: '⚖️' }
|
|
||||||
], [t]);
|
|
||||||
|
|
||||||
const METRICS: MetricOption[] = useMemo(() => [
|
|
||||||
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_gross' },
|
|
||||||
{ id: 'visitors', label: t('metrics.visitors'), field: 'visits' },
|
|
||||||
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
|
|
||||||
], [t]);
|
|
||||||
const [slides, setSlides] = useState<SlideConfig[]>([]);
|
|
||||||
const [editingSlide, setEditingSlide] = useState<number | null>(null);
|
|
||||||
const [previewMode, setPreviewMode] = useState(false);
|
|
||||||
const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0);
|
|
||||||
|
|
||||||
const channels = useMemo(() => getUniqueChannels(data), [data]);
|
|
||||||
const museums = useMemo(() => getUniqueMuseums(data), [data]);
|
|
||||||
|
|
||||||
const defaultSlideConfig: Omit<SlideConfig, 'id'> = {
|
|
||||||
title: 'Slide Title',
|
|
||||||
chartType: 'trend',
|
|
||||||
metric: 'revenue',
|
|
||||||
startDate: '2026-01-01',
|
|
||||||
endDate: '2026-01-31',
|
|
||||||
channel: 'all',
|
|
||||||
museum: 'all',
|
|
||||||
showComparison: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const addSlide = () => {
|
|
||||||
const newSlide: SlideConfig = {
|
|
||||||
id: Date.now(),
|
|
||||||
...defaultSlideConfig,
|
|
||||||
title: `Slide ${slides.length + 1}`
|
|
||||||
};
|
|
||||||
setSlides([...slides, newSlide]);
|
|
||||||
setEditingSlide(newSlide.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSlide = (id: number, updates: Partial<SlideConfig>) => {
|
|
||||||
setSlides(slides.map(s => s.id === id ? { ...s, ...updates } : s));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeSlide = (id: number) => {
|
|
||||||
setSlides(slides.filter(s => s.id !== id));
|
|
||||||
if (editingSlide === id) setEditingSlide(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveSlide = (id: number, direction: number) => {
|
|
||||||
const index = slides.findIndex(s => s.id === id);
|
|
||||||
if ((direction === -1 && index === 0) || (direction === 1 && index === slides.length - 1)) return;
|
|
||||||
const newSlides = [...slides];
|
|
||||||
[newSlides[index], newSlides[index + direction]] = [newSlides[index + direction], newSlides[index]];
|
|
||||||
setSlides(newSlides);
|
|
||||||
};
|
|
||||||
|
|
||||||
const duplicateSlide = (id: number) => {
|
|
||||||
const slide = slides.find(s => s.id === id);
|
|
||||||
if (slide) {
|
|
||||||
const newSlide: SlideConfig = { ...slide, id: Date.now(), title: `${slide.title} (copy)` };
|
|
||||||
const index = slides.findIndex(s => s.id === id);
|
|
||||||
const newSlides = [...slides];
|
|
||||||
newSlides.splice(index + 1, 0, newSlide);
|
|
||||||
setSlides(newSlides);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportAsHTML = async () => {
|
|
||||||
const zip = new JSZip();
|
|
||||||
|
|
||||||
// Generate HTML for each slide
|
|
||||||
const slidesHTML = slides.map((slide, index) => {
|
|
||||||
return generateSlideHTML(slide, index, data);
|
|
||||||
}).join('\n');
|
|
||||||
|
|
||||||
const fullHTML = `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>HiHala Data Presentation</title>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f172a; }
|
|
||||||
.slide {
|
|
||||||
width: 100vw; height: 100vh;
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
justify-content: center; align-items: center;
|
|
||||||
padding: 60px; background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
|
||||||
page-break-after: always;
|
|
||||||
}
|
|
||||||
.slide-title {
|
|
||||||
color: #f8fafc; font-size: 2.5rem; font-weight: 600;
|
|
||||||
margin-bottom: 40px; text-align: center;
|
|
||||||
}
|
|
||||||
.slide-subtitle {
|
|
||||||
color: #94a3b8; font-size: 1.1rem; margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
.chart-container {
|
|
||||||
width: 100%; max-width: 900px; height: 400px;
|
|
||||||
background: rgba(255,255,255,0.03); border-radius: 16px;
|
|
||||||
padding: 30px;
|
|
||||||
}
|
|
||||||
.kpi-grid {
|
|
||||||
display: grid; grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 30px; width: 100%; max-width: 900px;
|
|
||||||
}
|
|
||||||
.kpi-card {
|
|
||||||
background: rgba(255,255,255,0.05); border-radius: 16px;
|
|
||||||
padding: 30px; text-align: center;
|
|
||||||
}
|
|
||||||
.kpi-value { color: #3b82f6; font-size: 2.5rem; font-weight: 700; }
|
|
||||||
.kpi-label { color: #94a3b8; font-size: 1rem; margin-top: 8px; }
|
|
||||||
.logo { position: absolute; bottom: 30px; right: 40px; opacity: 0.6; }
|
|
||||||
.logo svg { height: 30px; }
|
|
||||||
.slide-number {
|
|
||||||
position: absolute; bottom: 30px; left: 40px;
|
|
||||||
color: #475569; font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
@media print {
|
|
||||||
.slide { page-break-after: always; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
${slidesHTML}
|
|
||||||
<script>
|
|
||||||
// Chart.js initialization scripts will be here
|
|
||||||
${generateChartScripts(slides, data)}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
zip.file('presentation.html', fullHTML);
|
|
||||||
|
|
||||||
const blob = await zip.generateAsync({ type: 'blob' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = 'hihala-presentation.zip';
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (previewMode) {
|
|
||||||
return (
|
|
||||||
<PreviewMode
|
|
||||||
slides={slides}
|
|
||||||
data={data}
|
|
||||||
channels={channels}
|
|
||||||
museums={museums}
|
|
||||||
currentSlide={currentPreviewSlide}
|
|
||||||
setCurrentSlide={setCurrentPreviewSlide}
|
|
||||||
onExit={() => setPreviewMode(false)}
|
|
||||||
metrics={METRICS}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="slides-builder">
|
|
||||||
<div className="page-title">
|
|
||||||
<h1>{t('slides.title')}</h1>
|
|
||||||
<p>{t('slides.subtitle')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="slides-toolbar">
|
|
||||||
<button className="btn-primary" onClick={addSlide}>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
{t('slides.addSlide')}
|
|
||||||
</button>
|
|
||||||
{slides.length > 0 && (
|
|
||||||
<>
|
|
||||||
<button className="btn-secondary" onClick={() => setPreviewMode(true)}>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
|
|
||||||
</svg>
|
|
||||||
{t('slides.preview')}
|
|
||||||
</button>
|
|
||||||
<button className="btn-secondary" onClick={exportAsHTML}>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<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>
|
|
||||||
{t('slides.exportHtml')}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="slides-workspace">
|
|
||||||
<div className="slides-list">
|
|
||||||
<h3>{t('slides.slidesCount')} ({slides.length})</h3>
|
|
||||||
{slides.length === 0 ? (
|
|
||||||
<div className="empty-slides">
|
|
||||||
<p>{t('slides.noSlides')}</p>
|
|
||||||
<button onClick={addSlide}>{t('slides.addFirst')}</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="slides-thumbnails">
|
|
||||||
{slides.map((slide, index) => (
|
|
||||||
<div
|
|
||||||
key={slide.id}
|
|
||||||
className={`slide-thumbnail ${editingSlide === slide.id ? 'active' : ''}`}
|
|
||||||
onClick={() => setEditingSlide(slide.id)}
|
|
||||||
>
|
|
||||||
<div className="slide-number">{index + 1}</div>
|
|
||||||
<div className="slide-icon">{CHART_TYPES.find(c => c.id === slide.chartType)?.icon}</div>
|
|
||||||
<div className="slide-title-preview">{slide.title}</div>
|
|
||||||
<div className="slide-actions">
|
|
||||||
<button onClick={(e) => { e.stopPropagation(); moveSlide(slide.id, -1); }} disabled={index === 0}>↑</button>
|
|
||||||
<button onClick={(e) => { e.stopPropagation(); moveSlide(slide.id, 1); }} disabled={index === slides.length - 1}>↓</button>
|
|
||||||
<button onClick={(e) => { e.stopPropagation(); duplicateSlide(slide.id); }}>⎘</button>
|
|
||||||
<button onClick={(e) => { e.stopPropagation(); removeSlide(slide.id); }} className="delete">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editingSlide && (
|
|
||||||
<SlideEditor
|
|
||||||
slide={slides.find(s => s.id === editingSlide)!}
|
|
||||||
onUpdate={(updates) => updateSlide(editingSlide, updates)}
|
|
||||||
channels={channels}
|
|
||||||
museums={museums}
|
|
||||||
data={data}
|
|
||||||
chartTypes={CHART_TYPES}
|
|
||||||
metrics={METRICS}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SlideEditor({ slide, onUpdate, channels, museums, data, chartTypes, metrics }: SlideEditorProps) {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="slide-editor">
|
|
||||||
<div className="editor-section">
|
|
||||||
<label>{t('slides.slideTitle')}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={slide.title}
|
|
||||||
onChange={e => onUpdate({ title: e.target.value })}
|
|
||||||
placeholder={t('slides.slideTitle')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="editor-section">
|
|
||||||
<label>{t('slides.chartType')}</label>
|
|
||||||
<div className="chart-type-grid">
|
|
||||||
{chartTypes.map((type: ChartTypeOption) => (
|
|
||||||
<button
|
|
||||||
key={type.id}
|
|
||||||
className={`chart-type-btn ${slide.chartType === type.id ? 'active' : ''}`}
|
|
||||||
onClick={() => onUpdate({ chartType: type.id })}
|
|
||||||
>
|
|
||||||
<span className="chart-icon">{type.icon}</span>
|
|
||||||
<span>{type.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="editor-section">
|
|
||||||
<label>{t('slides.metric')}</label>
|
|
||||||
<select value={slide.metric} onChange={e => onUpdate({ metric: e.target.value })}>
|
|
||||||
{metrics.map((m: MetricOption) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="editor-row">
|
|
||||||
<div className="editor-section">
|
|
||||||
<label>{t('slides.startDate')}</label>
|
|
||||||
<input type="date" value={slide.startDate} onChange={e => onUpdate({ startDate: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="editor-section">
|
|
||||||
<label>{t('slides.endDate')}</label>
|
|
||||||
<input type="date" value={slide.endDate} onChange={e => onUpdate({ endDate: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="editor-row">
|
|
||||||
<div className="editor-section">
|
|
||||||
<label>{t('filters.channel')}</label>
|
|
||||||
<select value={slide.channel} onChange={e => onUpdate({ channel: e.target.value, museum: 'all' })}>
|
|
||||||
<option value="all">{t('filters.allChannels')}</option>
|
|
||||||
{channels.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="editor-section">
|
|
||||||
<label>{t('filters.museum')}</label>
|
|
||||||
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
|
|
||||||
<option value="all">{t('filters.allMuseums')}</option>
|
|
||||||
{museums.map((m: string) => <option key={m} value={m}>{m}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{slide.chartType === 'comparison' && (
|
|
||||||
<div className="editor-section">
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={slide.showComparison}
|
|
||||||
onChange={e => onUpdate({ showComparison: e.target.checked })}
|
|
||||||
/>
|
|
||||||
{t('slides.showYoY')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="slide-preview-box">
|
|
||||||
<h4>{t('slides.preview')}</h4>
|
|
||||||
<SlidePreview slide={slide} data={data} channels={channels} museums={museums} metrics={metrics} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Static field mapping for charts (Chart.js labels don't need i18n)
|
|
||||||
const METRIC_FIELDS: Record<string, MetricFieldInfo> = {
|
|
||||||
revenue: { field: 'revenue_gross', label: 'Revenue' },
|
|
||||||
visitors: { field: 'visits', label: 'Visitors' },
|
|
||||||
tickets: { field: 'tickets', label: 'Tickets' }
|
|
||||||
};
|
|
||||||
|
|
||||||
function SlidePreview({ slide, data, channels, museums, metrics }: SlidePreviewProps) {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const filteredData = useMemo(() =>
|
|
||||||
filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
|
||||||
channel: slide.channel,
|
|
||||||
museum: slide.museum
|
|
||||||
}),
|
|
||||||
[data, slide.startDate, slide.endDate, slide.channel, slide.museum]
|
|
||||||
);
|
|
||||||
|
|
||||||
const metricsData = useMemo(() => calculateMetrics(filteredData), [filteredData]);
|
|
||||||
const baseOptions = useMemo(() => createBaseOptions(false), []);
|
|
||||||
|
|
||||||
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
|
|
||||||
const fieldMap: Record<string, string> = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' };
|
|
||||||
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as unknown as Record<string, unknown>)[fieldMap[metric]] || 0)), 0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const trendData = useMemo(() => {
|
|
||||||
const grouped: Record<string, MuseumRecord[]> = {};
|
|
||||||
filteredData.forEach(row => {
|
|
||||||
if (!row.date) return;
|
|
||||||
const weekStart = row.date.substring(0, 10);
|
|
||||||
if (!grouped[weekStart]) grouped[weekStart] = [];
|
|
||||||
grouped[weekStart].push(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedDates = Object.keys(grouped).sort();
|
|
||||||
const metricLabel = metrics?.find((m: MetricOption) => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
|
|
||||||
return {
|
|
||||||
labels: sortedDates.map(d => d.substring(5)),
|
|
||||||
datasets: [{
|
|
||||||
label: metricLabel,
|
|
||||||
data: sortedDates.map(d => getMetricValue(grouped[d], slide.metric)),
|
|
||||||
borderColor: chartColors.primary,
|
|
||||||
backgroundColor: chartColors.primary + '20',
|
|
||||||
fill: true,
|
|
||||||
tension: 0.4
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
}, [filteredData, slide.metric, getMetricValue, metrics]);
|
|
||||||
|
|
||||||
const museumData = useMemo(() => {
|
|
||||||
const byMuseum: Record<string, MuseumRecord[]> = {};
|
|
||||||
filteredData.forEach(row => {
|
|
||||||
if (!row.museum_name) return;
|
|
||||||
if (!byMuseum[row.museum_name]) byMuseum[row.museum_name] = [];
|
|
||||||
byMuseum[row.museum_name].push(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
const museums = Object.keys(byMuseum).sort();
|
|
||||||
const metricLabel = metrics?.find((m: MetricOption) => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
|
|
||||||
return {
|
|
||||||
labels: museums,
|
|
||||||
datasets: [{
|
|
||||||
label: metricLabel,
|
|
||||||
data: museums.map(m => getMetricValue(byMuseum[m], slide.metric)),
|
|
||||||
backgroundColor: chartColors.primary,
|
|
||||||
borderRadius: 6
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
}, [filteredData, slide.metric, getMetricValue, metrics]);
|
|
||||||
|
|
||||||
if (slide.chartType === 'kpi-cards') {
|
|
||||||
return (
|
|
||||||
<div className="preview-kpis">
|
|
||||||
<div className="preview-kpi">
|
|
||||||
<div className="kpi-value">{formatCompactCurrency(metricsData.revenue)}</div>
|
|
||||||
<div className="kpi-label">{t('metrics.revenue')}</div>
|
|
||||||
</div>
|
|
||||||
<div className="preview-kpi">
|
|
||||||
<div className="kpi-value">{formatCompact(metricsData.visitors)}</div>
|
|
||||||
<div className="kpi-label">{t('metrics.visitors')}</div>
|
|
||||||
</div>
|
|
||||||
<div className="preview-kpi">
|
|
||||||
<div className="kpi-value">{formatCompact(metricsData.tickets)}</div>
|
|
||||||
<div className="kpi-label">{t('metrics.tickets')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slide.chartType === 'museum-bar') {
|
|
||||||
return (
|
|
||||||
<div className="preview-chart">
|
|
||||||
<Bar data={museumData} options={{ ...baseOptions, indexAxis: 'y' }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="preview-chart">
|
|
||||||
<Line data={trendData} options={baseOptions} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PreviewMode({ slides, data, channels, museums, currentSlide, setCurrentSlide, onExit, metrics }: PreviewModeProps) {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'ArrowRight' || e.key === ' ') {
|
|
||||||
setCurrentSlide((prev: number) => Math.min(prev + 1, slides.length - 1));
|
|
||||||
} else if (e.key === 'ArrowLeft') {
|
|
||||||
setCurrentSlide((prev: number) => Math.max(prev - 1, 0));
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
onExit();
|
|
||||||
}
|
|
||||||
}, [slides.length, setCurrentSlide, onExit]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [handleKeyDown]);
|
|
||||||
|
|
||||||
const slide = slides[currentSlide];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="preview-fullscreen">
|
|
||||||
<div className="preview-slide">
|
|
||||||
<h1 className="preview-title">{slide?.title}</h1>
|
|
||||||
<div className="preview-content">
|
|
||||||
{slide && <SlidePreview slide={slide} data={data} channels={channels} museums={museums} metrics={metrics} />}
|
|
||||||
</div>
|
|
||||||
<div className="preview-footer">
|
|
||||||
<span>{currentSlide + 1} / {slides.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="preview-controls">
|
|
||||||
<button onClick={() => setCurrentSlide((prev: number) => Math.max(prev - 1, 0))} disabled={currentSlide === 0}>←</button>
|
|
||||||
<button onClick={() => setCurrentSlide((prev: number) => Math.min(prev + 1, slides.length - 1))} disabled={currentSlide === slides.length - 1}>→</button>
|
|
||||||
<button onClick={onExit}>{t('slides.exit')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions for HTML export
|
|
||||||
function generateSlideHTML(slide: SlideConfig, index: number, data: MuseumRecord[]): string {
|
|
||||||
const chartType = slide.chartType;
|
|
||||||
const canvasId = `chart-${index}`;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="slide" id="slide-${index}">
|
|
||||||
<h1 class="slide-title">${slide.title}</h1>
|
|
||||||
<p class="slide-subtitle">${formatDateRange(slide.startDate, slide.endDate)}</p>
|
|
||||||
${chartType === 'kpi-cards' ? generateKPIHTML(slide, data) : `<div class="chart-container"><canvas id="${canvasId}"></canvas></div>`}
|
|
||||||
<div class="slide-number">Slide ${index + 1}</div>
|
|
||||||
<div class="logo">
|
|
||||||
<svg width="120" height="24" viewBox="0 0 120 24">
|
|
||||||
<text x="0" y="18" fill="#64748b" font-family="system-ui" font-size="14" font-weight="600">HiHala Data</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string {
|
|
||||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
|
||||||
channel: slide.channel,
|
|
||||||
museum: slide.museum
|
|
||||||
});
|
|
||||||
const metrics = calculateMetrics(filtered);
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="kpi-grid">
|
|
||||||
<div class="kpi-card">
|
|
||||||
<div class="kpi-value">${formatCompactCurrency(metrics.revenue)}</div>
|
|
||||||
<div class="kpi-label">Revenue</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi-card">
|
|
||||||
<div class="kpi-value">${formatCompact(metrics.visitors)}</div>
|
|
||||||
<div class="kpi-label">Visitors</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi-card">
|
|
||||||
<div class="kpi-value">${formatCompact(metrics.tickets)}</div>
|
|
||||||
<div class="kpi-label">Tickets</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateChartScripts(slides: SlideConfig[], data: MuseumRecord[]): string {
|
|
||||||
return slides.map((slide: SlideConfig, index: number) => {
|
|
||||||
if (slide.chartType === 'kpi-cards') return '';
|
|
||||||
|
|
||||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
|
||||||
channel: slide.channel,
|
|
||||||
museum: slide.museum
|
|
||||||
});
|
|
||||||
|
|
||||||
const chartConfig = generateChartConfig(slide, filtered);
|
|
||||||
|
|
||||||
return `
|
|
||||||
new Chart(document.getElementById('chart-${index}'), ${JSON.stringify(chartConfig)});
|
|
||||||
`;
|
|
||||||
}).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateChartConfig(slide: SlideConfig, data: MuseumRecord[]): object {
|
|
||||||
const fieldMap: Record<string, keyof MuseumRecord> = { revenue: 'revenue_gross', visitors: 'visits', tickets: 'tickets' };
|
|
||||||
const field = fieldMap[slide.metric];
|
|
||||||
|
|
||||||
if (slide.chartType === 'museum-bar') {
|
|
||||||
const byMuseum: Record<string, number> = {};
|
|
||||||
data.forEach((row: MuseumRecord) => {
|
|
||||||
if (!row.museum_name) return;
|
|
||||||
byMuseum[row.museum_name] = (byMuseum[row.museum_name] || 0) + parseFloat(String(row[field] || 0));
|
|
||||||
});
|
|
||||||
const museums = Object.keys(byMuseum).sort();
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: museums,
|
|
||||||
datasets: [{
|
|
||||||
data: museums.map(m => byMuseum[m]),
|
|
||||||
backgroundColor: '#3b82f6',
|
|
||||||
borderRadius: 6
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: { indexAxis: 'y', plugins: { legend: { display: false } } }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: trend line
|
|
||||||
const grouped: Record<string, number> = {};
|
|
||||||
data.forEach((row: MuseumRecord) => {
|
|
||||||
if (!row.date) return;
|
|
||||||
grouped[row.date] = (grouped[row.date] || 0) + parseFloat(String(row[field] || 0));
|
|
||||||
});
|
|
||||||
const dates = Object.keys(grouped).sort();
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: dates.map(d => d.substring(5)),
|
|
||||||
datasets: [{
|
|
||||||
data: dates.map(d => grouped[d]),
|
|
||||||
borderColor: '#3b82f6',
|
|
||||||
backgroundColor: 'rgba(59,130,246,0.1)',
|
|
||||||
fill: true,
|
|
||||||
tension: 0.4
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: { plugins: { legend: { display: false } } }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateRange(start: string, end: string): string {
|
|
||||||
const s = new Date(start);
|
|
||||||
const e = new Date(end);
|
|
||||||
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' };
|
|
||||||
return `${s.toLocaleDateString('en-US', opts)} – ${e.toLocaleDateString('en-US', opts)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Slides;
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
// ─── multi-select ─────────────────────────────────────────────────
|
||||||
|
export default function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
|
||||||
|
value: string[]; options: string[];
|
||||||
|
onChange: (vals: string[]) => void;
|
||||||
|
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v !== opt) : [...value, opt]);
|
||||||
|
const label = value.length === 0 ? allLabel : value.length === 1 ? value[0] : countLabel(value.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="altms">
|
||||||
|
<button type="button" className={`altms-trigger${value.length > 0 ? ' altms-trigger--active' : ''}`} onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox">
|
||||||
|
<span className="altms-label">{label}</span>
|
||||||
|
<svg className={`altms-chevron${open ? ' altms-chevron--open' : ''}`} width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||||
|
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="altms-dropdown" role="listbox" aria-multiselectable="true">
|
||||||
|
<div className="altms-list">
|
||||||
|
{options.map(opt => (
|
||||||
|
<label key={opt} role="option" aria-selected={value.includes(opt)} className={`altms-option${value.includes(opt) ? ' altms-option--checked' : ''}`}>
|
||||||
|
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
|
||||||
|
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
|
||||||
|
<span className="altms-opt-label">{opt}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{value.length > 0 && <button type="button" className="altms-clear" onClick={() => { onChange([]); setOpen(false); }}>{clearLabel}</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import React, { useRef, useCallback, useState, ReactNode, KeyboardEvent, TouchEvent } from 'react';
|
|
||||||
|
|
||||||
interface CarouselProps {
|
|
||||||
children: ReactNode;
|
|
||||||
activeIndex: number;
|
|
||||||
setActiveIndex: (index: number) => void;
|
|
||||||
labels?: string[];
|
|
||||||
showLabels?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Carousel({
|
|
||||||
children,
|
|
||||||
activeIndex,
|
|
||||||
setActiveIndex,
|
|
||||||
labels = [],
|
|
||||||
showLabels = true,
|
|
||||||
className = ''
|
|
||||||
}: CarouselProps) {
|
|
||||||
const touchStartX = useRef<number | null>(null);
|
|
||||||
const touchStartY = useRef<number | null>(null);
|
|
||||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [dragOffset, setDragOffset] = useState(0);
|
|
||||||
const itemCount = React.Children.count(children);
|
|
||||||
|
|
||||||
// Threshold for swipe detection
|
|
||||||
const SWIPE_THRESHOLD = 50;
|
|
||||||
const VELOCITY_THRESHOLD = 0.3;
|
|
||||||
|
|
||||||
const handleTouchStart = useCallback((e: TouchEvent<HTMLDivElement>) => {
|
|
||||||
touchStartX.current = e.touches[0].clientX;
|
|
||||||
touchStartY.current = e.touches[0].clientY;
|
|
||||||
setIsDragging(true);
|
|
||||||
setDragOffset(0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTouchMove = useCallback((e: TouchEvent<HTMLDivElement>) => {
|
|
||||||
if (!touchStartX.current || !isDragging) return;
|
|
||||||
|
|
||||||
const currentX = e.touches[0].clientX;
|
|
||||||
const currentY = e.touches[0].clientY;
|
|
||||||
const diffX = currentX - touchStartX.current;
|
|
||||||
const diffY = currentY - (touchStartY.current || 0);
|
|
||||||
|
|
||||||
// Only handle horizontal swipes
|
|
||||||
if (Math.abs(diffX) > Math.abs(diffY)) {
|
|
||||||
e.preventDefault();
|
|
||||||
// Add resistance at edges
|
|
||||||
let offset = diffX;
|
|
||||||
if ((activeIndex === 0 && diffX > 0) || (activeIndex === itemCount - 1 && diffX < 0)) {
|
|
||||||
offset = diffX * 0.3; // Rubber band effect
|
|
||||||
}
|
|
||||||
setDragOffset(offset);
|
|
||||||
}
|
|
||||||
}, [isDragging, activeIndex, itemCount]);
|
|
||||||
|
|
||||||
const handleTouchEnd = useCallback((e: TouchEvent<HTMLDivElement>) => {
|
|
||||||
if (!touchStartX.current || !isDragging) return;
|
|
||||||
|
|
||||||
const endX = e.changedTouches[0].clientX;
|
|
||||||
const diff = touchStartX.current - endX;
|
|
||||||
const velocity = Math.abs(diff) / 200; // Rough velocity calc
|
|
||||||
|
|
||||||
// Determine if we should change slide
|
|
||||||
if (Math.abs(diff) > SWIPE_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
|
|
||||||
if (diff > 0 && activeIndex < itemCount - 1) {
|
|
||||||
setActiveIndex(activeIndex + 1);
|
|
||||||
} else if (diff < 0 && activeIndex > 0) {
|
|
||||||
setActiveIndex(activeIndex - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset
|
|
||||||
touchStartX.current = null;
|
|
||||||
touchStartY.current = null;
|
|
||||||
setIsDragging(false);
|
|
||||||
setDragOffset(0);
|
|
||||||
}, [isDragging, activeIndex, setActiveIndex, itemCount]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
if (e.key === 'ArrowLeft' && activeIndex > 0) {
|
|
||||||
setActiveIndex(activeIndex - 1);
|
|
||||||
} else if (e.key === 'ArrowRight' && activeIndex < itemCount - 1) {
|
|
||||||
setActiveIndex(activeIndex + 1);
|
|
||||||
}
|
|
||||||
}, [activeIndex, setActiveIndex, itemCount]);
|
|
||||||
|
|
||||||
// Calculate transform
|
|
||||||
const baseTransform = -(activeIndex * 100);
|
|
||||||
const dragPercentage = trackRef.current ? (dragOffset / trackRef.current.offsetWidth) * 100 : 0;
|
|
||||||
const transform = baseTransform + dragPercentage;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`carousel ${className}`}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
tabIndex={0}
|
|
||||||
role="region"
|
|
||||||
aria-label="Carousel"
|
|
||||||
>
|
|
||||||
<div className="carousel-container">
|
|
||||||
<div className="carousel-viewport">
|
|
||||||
<div
|
|
||||||
ref={trackRef}
|
|
||||||
className="carousel-track"
|
|
||||||
style={{
|
|
||||||
transform: `translateX(${transform}%)`,
|
|
||||||
transition: isDragging ? 'none' : 'transform 400ms cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
|
||||||
}}
|
|
||||||
onTouchStart={handleTouchStart}
|
|
||||||
onTouchMove={handleTouchMove}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
>
|
|
||||||
{React.Children.map(children, (child, i) => (
|
|
||||||
<div
|
|
||||||
className="carousel-slide"
|
|
||||||
key={i}
|
|
||||||
role="tabpanel"
|
|
||||||
aria-hidden={activeIndex !== i}
|
|
||||||
aria-label={labels[i] || `Slide ${i + 1}`}
|
|
||||||
>
|
|
||||||
{child}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`carousel-dots ${showLabels ? 'labeled' : ''}`} role="tablist">
|
|
||||||
{Array.from({ length: itemCount }).map((_, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
className={`carousel-dot ${activeIndex === i ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveIndex(i)}
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeIndex === i}
|
|
||||||
aria-label={labels[i] || `Slide ${i + 1}`}
|
|
||||||
aria-controls={`slide-${i}`}
|
|
||||||
>
|
|
||||||
{showLabels && labels[i] && (
|
|
||||||
<span className="dot-label">{labels[i]}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Carousel;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import React, { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface ChartCardProps {
|
|
||||||
title?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
headerRight?: ReactNode;
|
|
||||||
fullWidth?: boolean;
|
|
||||||
halfWidth?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChartCard({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
className = '',
|
|
||||||
headerRight = null,
|
|
||||||
fullWidth = false,
|
|
||||||
halfWidth = false
|
|
||||||
}: ChartCardProps) {
|
|
||||||
const sizeClass = fullWidth ? 'full-width' : halfWidth ? 'half-width' : '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`chart-card ${sizeClass} ${className}`}>
|
|
||||||
{(title || headerRight) && (
|
|
||||||
<div className="chart-card-header">
|
|
||||||
{title && <h2>{title}</h2>}
|
|
||||||
{headerRight && <div className="chart-card-actions">{headerRight}</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="chart-container">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ChartCard;
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface EmptyStateProps {
|
|
||||||
icon?: string;
|
|
||||||
title?: string;
|
|
||||||
message?: string;
|
|
||||||
action?: (() => void) | null;
|
|
||||||
actionLabel?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyState({
|
|
||||||
icon = '📊',
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
action = null,
|
|
||||||
actionLabel = 'Try Again',
|
|
||||||
className = ''
|
|
||||||
}: EmptyStateProps) {
|
|
||||||
return (
|
|
||||||
<div className={`empty-state ${className}`}>
|
|
||||||
<div className="empty-state-icon" role="img" aria-hidden="true">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
{title && (
|
|
||||||
<h3 className="empty-state-title">{title}</h3>
|
|
||||||
)}
|
|
||||||
{message && (
|
|
||||||
<p className="empty-state-message">{message}</p>
|
|
||||||
)}
|
|
||||||
{action && (
|
|
||||||
<button
|
|
||||||
className="empty-state-action"
|
|
||||||
onClick={action}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{actionLabel}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EmptyState;
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import React, { useState, useEffect, ReactNode, KeyboardEvent } from 'react';
|
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
|
||||||
|
|
||||||
interface FilterControlsProps {
|
|
||||||
children: ReactNode;
|
|
||||||
title?: string;
|
|
||||||
defaultExpanded?: boolean;
|
|
||||||
onReset?: (() => void) | null;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterGroupProps {
|
|
||||||
label?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterRowProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterControlsComponent extends React.FC<FilterControlsProps> {
|
|
||||||
Group: React.FC<FilterGroupProps>;
|
|
||||||
Row: React.FC<FilterRowProps>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FilterControls: FilterControlsComponent = ({
|
|
||||||
children,
|
|
||||||
title,
|
|
||||||
defaultExpanded = true,
|
|
||||||
onReset = null,
|
|
||||||
className = ''
|
|
||||||
}) => {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const displayTitle = title || t('filters.title');
|
|
||||||
|
|
||||||
// Start collapsed on mobile
|
|
||||||
const [expanded, setExpanded] = useState(() => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
return window.innerWidth > 768 ? defaultExpanded : false;
|
|
||||||
}
|
|
||||||
return defaultExpanded;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle resize
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
// Auto-expand on desktop, keep user preference on mobile
|
|
||||||
if (window.innerWidth > 768) {
|
|
||||||
setExpanded(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleExpanded = () => {
|
|
||||||
setExpanded(!expanded);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleExpanded();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`controls ${expanded ? 'expanded' : 'collapsed'} ${className}`}>
|
|
||||||
<div
|
|
||||||
className="controls-header"
|
|
||||||
onClick={toggleExpanded}
|
|
||||||
role="button"
|
|
||||||
aria-expanded={expanded}
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
<h3>{displayTitle}</h3>
|
|
||||||
<div className="controls-header-actions">
|
|
||||||
{onReset && expanded && (
|
|
||||||
<button
|
|
||||||
className="controls-reset"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onReset();
|
|
||||||
}}
|
|
||||||
aria-label={t('filters.reset') || 'Reset filters'}
|
|
||||||
>
|
|
||||||
{t('filters.reset') || 'Reset'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="controls-toggle"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{expanded ? '▲' : '▼'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="controls-body"
|
|
||||||
style={{
|
|
||||||
display: expanded ? 'block' : 'none',
|
|
||||||
animation: expanded ? 'fadeIn 200ms ease' : 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const FilterGroup: React.FC<FilterGroupProps> = ({ label, children }) => {
|
|
||||||
return (
|
|
||||||
<div className="control-group">
|
|
||||||
{label && <label>{label}</label>}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const FilterRow: React.FC<FilterRowProps> = ({ children }) => {
|
|
||||||
return <div className="control-row">{children}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
FilterControls.Group = FilterGroup;
|
|
||||||
FilterControls.Row = FilterRow;
|
|
||||||
|
|
||||||
export default FilterControls;
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { formatCurrency, formatNumber } from '../../services/dataService';
|
||||||
|
|
||||||
|
// ─── metric card ──────────────────────────────────────────────────
|
||||||
|
export default function MetricCard({ title, curr, prev, isCurrency, newLabel }: {
|
||||||
|
title: string; curr: number; prev: number; isCurrency?: boolean; newLabel?: string;
|
||||||
|
}) {
|
||||||
|
const fmt = (n: number) => isCurrency ? formatCurrency(n) : formatNumber(n);
|
||||||
|
const change = prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
||||||
|
const isPos = change > 0, isNeg = change < 0;
|
||||||
|
return (
|
||||||
|
<div className="alt-metric">
|
||||||
|
<p className="alt-metric-title">{title}</p>
|
||||||
|
<div className="alt-metric-value">{fmt(curr)}</div>
|
||||||
|
<div className="alt-metric-footer">
|
||||||
|
{isFinite(change)
|
||||||
|
? <span className={`alt-change ${isPos ? 'alt-change--up' : isNeg ? 'alt-change--down' : 'alt-change--flat'}`}>{isPos ? '▲' : isNeg ? '▼' : '—'} {Math.abs(change).toFixed(1)}%</span>
|
||||||
|
: <span className="alt-change alt-change--up">{newLabel ?? 'New'}</span>}
|
||||||
|
<span className="alt-metric-prev">{fmt(prev)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
|
||||||
|
|
||||||
interface MultiSelectProps {
|
|
||||||
options: string[];
|
|
||||||
selected: string[];
|
|
||||||
onChange: (selected: string[]) => void;
|
|
||||||
allLabel: string;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MultiSelect({ options, selected, onChange, allLabel, placeholder }: MultiSelectProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Close on outside click
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClick = (e: MouseEvent) => {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClick);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClick);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isAll = selected.length === 0;
|
|
||||||
|
|
||||||
const toggle = (value: string) => {
|
|
||||||
if (selected.includes(value)) {
|
|
||||||
onChange(selected.filter(v => v !== value));
|
|
||||||
} else {
|
|
||||||
onChange([...selected, value]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectAll = () => onChange([]);
|
|
||||||
|
|
||||||
const displayText = isAll
|
|
||||||
? allLabel
|
|
||||||
: selected.length === 1
|
|
||||||
? selected[0]
|
|
||||||
: `${selected.length} selected`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="multi-select" ref={ref}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="multi-select-trigger"
|
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
aria-expanded={open}
|
|
||||||
>
|
|
||||||
<span className="multi-select-text">{displayText}</span>
|
|
||||||
<span className="multi-select-arrow">▼</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="multi-select-dropdown">
|
|
||||||
<label className="multi-select-option">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isAll}
|
|
||||||
onChange={selectAll}
|
|
||||||
/>
|
|
||||||
<span>{allLabel}</span>
|
|
||||||
</label>
|
|
||||||
{options.map(opt => (
|
|
||||||
<label key={opt} className="multi-select-option">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selected.includes(opt)}
|
|
||||||
onChange={() => toggle(opt)}
|
|
||||||
/>
|
|
||||||
<span>{opt}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MultiSelect;
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import type { LC } from '../../lib/locale';
|
||||||
|
import { MONTH_KEYS, makePresets, guessPreset, periodNameL, dateRangeTextL } from '../../lib/dateHelpers';
|
||||||
|
|
||||||
|
// ─── inline picker ────────────────────────────────────────────────
|
||||||
|
export function InlinePicker({ start, end, onChange, onClose, availableYears, L }: {
|
||||||
|
start: string; end: string; onChange: (s: string, e: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
availableYears: number[]; L: LC;
|
||||||
|
}) {
|
||||||
|
const g = guessPreset(start, end);
|
||||||
|
const [year, setYear] = useState(g?.year ?? parseInt(start.slice(0, 4)));
|
||||||
|
const [active, setActive] = useState<string | null>(g?.key ?? null);
|
||||||
|
const [draftStart, setDraftStart] = useState(start);
|
||||||
|
const [draftEnd, setDraftEnd] = useState(end);
|
||||||
|
const minY = Math.min(...availableYears), maxY = Math.max(...availableYears);
|
||||||
|
|
||||||
|
const pick = (key: string) => { const r = makePresets(year)[key]; if (!r) return; setActive(key); setDraftStart(r.start); setDraftEnd(r.end); };
|
||||||
|
const shift = (d: number) => {
|
||||||
|
const ny = year + d; if (ny < minY || ny > maxY) return; setYear(ny);
|
||||||
|
if (active && makePresets(ny)[active]) { setDraftStart(makePresets(ny)[active].start); setDraftEnd(makePresets(ny)[active].end); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="alt-picker" id="period-picker-panel">
|
||||||
|
<div className="alt-picker-year">
|
||||||
|
<button type="button" onClick={() => shift(L.dir === 'rtl' ? 1 : -1)} disabled={L.dir === 'rtl' ? year >= maxY : year <= minY} className="alt-yr-btn">
|
||||||
|
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M5.5 9.5L1.5 5.5L5.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<span className="alt-yr-val">{year}</span>
|
||||||
|
<button type="button" onClick={() => shift(L.dir === 'rtl' ? -1 : 1)} disabled={L.dir === 'rtl' ? year <= minY : year >= maxY} className="alt-yr-btn">
|
||||||
|
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M1.5 1.5L5.5 5.5L1.5 9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="alt-picker-section">{L.monthSection}</p>
|
||||||
|
<div className="alt-chips">
|
||||||
|
{MONTH_KEYS.map((k, i) => (
|
||||||
|
<button key={k} type="button" className={`alt-chip${active === k ? ' alt-chip-on' : ''}`} onClick={() => pick(k)}>{L.monthShort[i]}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="alt-picker-section">{L.periodSection}</p>
|
||||||
|
<div className="alt-chips">
|
||||||
|
{['q1','q2','q3','q4','h1','h2'].map(k => (
|
||||||
|
<button key={k} type="button" className={`alt-chip${active === k ? ' alt-chip-on' : ''}`} onClick={() => pick(k)}>{L.periods[k]}</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className={`alt-chip alt-chip-wide${active === 'full' ? ' alt-chip-on' : ''}`} onClick={() => pick('full')}>{L.periods.full}</button>
|
||||||
|
</div>
|
||||||
|
<div className="alt-picker-div" />
|
||||||
|
<div className="alt-custom">
|
||||||
|
<div className="alt-custom-f"><label>{L.from}</label><input type="date" value={draftStart} onChange={e => { setActive(null); setDraftStart(e.target.value); }} /></div>
|
||||||
|
<span className="alt-custom-arrow">{L.dateRangeSep}</span>
|
||||||
|
<div className="alt-custom-f"><label>{L.to}</label><input type="date" value={draftEnd} onChange={e => { setActive(null); setDraftEnd(e.target.value); }} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="alt-picker-div" />
|
||||||
|
<div className="alt-footer">
|
||||||
|
<button type="button" className="alt-cancel" onClick={onClose}>{L.close}</button>
|
||||||
|
<button type="button" className="alt-apply" onClick={() => { onChange(draftStart, draftEnd); onClose(); }}>{L.apply}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── period hero ──────────────────────────────────────────────────
|
||||||
|
export default function PeriodHero({ start, end, onChange, availableYears, L }: {
|
||||||
|
start: string; end: string; onChange: (s: string, e: string) => void;
|
||||||
|
availableYears: number[]; L: LC;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onM = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||||
|
const onK = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', onM); document.addEventListener('keydown', onK);
|
||||||
|
return () => { document.removeEventListener('mousedown', onM); document.removeEventListener('keydown', onK); };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="dalt-hero">
|
||||||
|
<div className="dalt-hero-inner">
|
||||||
|
<div>
|
||||||
|
<div className="dalt-hero-name">{periodNameL(start, end, L)}</div>
|
||||||
|
<div className="dalt-hero-range">{dateRangeTextL(start, end, L)}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="dalt-hero-btn" onClick={() => setOpen(v => !v)} aria-expanded={open} aria-controls="period-picker-panel">
|
||||||
|
{open ? L.close : L.changePeriod}
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
|
||||||
|
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open && <InlinePicker start={start} end={end} onChange={onChange} onClose={() => setOpen(false)} availableYears={availableYears} L={L} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface StatCardProps {
|
|
||||||
title: string;
|
|
||||||
value: string | number;
|
|
||||||
change?: number | null;
|
|
||||||
changeLabel?: string;
|
|
||||||
subtitle?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({ title, value, change = null, changeLabel = 'YoY', subtitle = null }: StatCardProps) {
|
|
||||||
const isPositive = change !== null && change >= 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="stat-card">
|
|
||||||
<h3>{title}</h3>
|
|
||||||
<div className="stat-value">{value}</div>
|
|
||||||
{subtitle && (
|
|
||||||
<div className="stat-subtitle">{subtitle}</div>
|
|
||||||
)}
|
|
||||||
{change !== null && (
|
|
||||||
<div className={`stat-change ${isPositive ? 'positive' : 'negative'}`}>
|
|
||||||
<span className="stat-change-arrow">{isPositive ? '↑' : '↓'}</span>
|
|
||||||
<span className="stat-change-value">{Math.abs(change).toFixed(1)}%</span>
|
|
||||||
<span className="stat-change-label">{changeLabel}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StatCard;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface ToggleOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToggleSwitchProps {
|
|
||||||
options: ToggleOption[];
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToggleSwitch({ options, value, onChange, className = '' }: ToggleSwitchProps) {
|
|
||||||
return (
|
|
||||||
<div className={`toggle-switch ${className}`} role="radiogroup">
|
|
||||||
{options.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
className={value === option.value ? 'active' : ''}
|
|
||||||
onClick={() => onChange(option.value)}
|
|
||||||
role="radio"
|
|
||||||
aria-checked={value === option.value}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ToggleSwitch;
|
|
||||||
@@ -1,7 +1 @@
|
|||||||
export { default as Carousel } from './Carousel';
|
export { default as LoadingSkeleton } from './LoadingSkeleton';
|
||||||
export { default as ChartCard } from './ChartCard';
|
|
||||||
export { default as EmptyState } from './EmptyState';
|
|
||||||
export { default as FilterControls } from './FilterControls';
|
|
||||||
export { default as MultiSelect } from './MultiSelect';
|
|
||||||
export { default as StatCard } from './StatCard';
|
|
||||||
export { default as ToggleSwitch } from './ToggleSwitch';
|
|
||||||
|
|||||||
+92
-38
@@ -30,6 +30,9 @@ ChartJS.register(
|
|||||||
Annotation
|
Annotation
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Used for the "Total" line in multi-museum trend charts — always distinct from chartPalette.
|
||||||
|
export const TOTAL_COLOR = '#1e293b';
|
||||||
|
|
||||||
export const chartColors = {
|
export const chartColors = {
|
||||||
primary: '#2563eb',
|
primary: '#2563eb',
|
||||||
secondary: '#7c3aed',
|
secondary: '#7c3aed',
|
||||||
@@ -37,9 +40,21 @@ export const chartColors = {
|
|||||||
success: '#059669',
|
success: '#059669',
|
||||||
danger: '#dc2626',
|
danger: '#dc2626',
|
||||||
muted: '#94a3b8',
|
muted: '#94a3b8',
|
||||||
grid: '#f1f5f9'
|
grid: '#e2e8f0' // fallback only; use getChartTheme().border at runtime
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getChartTheme() {
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
const get = (v: string) => style.getPropertyValue(v).trim();
|
||||||
|
return {
|
||||||
|
surface: get('--surface') || '#ffffff',
|
||||||
|
textPrimary: get('--text-primary') || '#0f172a',
|
||||||
|
textMuted: get('--text-muted') || '#64748b',
|
||||||
|
border: get('--border') || '#e2e8f0',
|
||||||
|
textInverse: get('--text-inverse') || '#ffffff',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Extended palette for charts with many categories (events, channels)
|
// Extended palette for charts with many categories (events, channels)
|
||||||
export const chartPalette = [
|
export const chartPalette = [
|
||||||
'#2563eb', // blue
|
'#2563eb', // blue
|
||||||
@@ -54,15 +69,15 @@ export const chartPalette = [
|
|||||||
'#ea580c', // orange
|
'#ea580c', // orange
|
||||||
];
|
];
|
||||||
|
|
||||||
export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
export const createDataLabelConfig = (showDataLabels: boolean, overrides?: { color?: string; backgroundColor?: string }): any => ({
|
||||||
display: showDataLabels,
|
display: showDataLabels,
|
||||||
color: '#1e293b',
|
color: overrides?.color ?? '#1e293b',
|
||||||
font: { size: 10, weight: 600 },
|
font: { size: 10, weight: 600 },
|
||||||
anchor: 'end',
|
anchor: 'end',
|
||||||
align: 'end',
|
align: 'end',
|
||||||
offset: 4,
|
offset: 4,
|
||||||
padding: 4,
|
padding: 4,
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
backgroundColor: overrides?.backgroundColor ?? 'rgba(255, 255, 255, 0.85)',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment
|
textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment
|
||||||
formatter: (value: number | null) => {
|
formatter: (value: number | null) => {
|
||||||
@@ -74,43 +89,82 @@ export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createBaseOptions = (showDataLabels: boolean): any => ({
|
export const createBaseOptions = (showDataLabels: boolean): any => {
|
||||||
responsive: true,
|
const theme = getChartTheme();
|
||||||
maintainAspectRatio: false,
|
return {
|
||||||
locale: 'en-US', // Force LTR number formatting
|
responsive: true,
|
||||||
layout: {
|
maintainAspectRatio: false,
|
||||||
padding: {
|
locale: 'en-US', // Force LTR number formatting
|
||||||
top: showDataLabels ? 25 : 5,
|
layout: {
|
||||||
right: 5,
|
padding: {
|
||||||
bottom: 5,
|
top: showDataLabels ? 25 : 5,
|
||||||
left: 5
|
right: 5,
|
||||||
|
bottom: 5,
|
||||||
|
left: 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: theme.surface,
|
||||||
|
titleColor: theme.textPrimary,
|
||||||
|
bodyColor: theme.textMuted,
|
||||||
|
borderColor: theme.border,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 8,
|
||||||
|
titleFont: { size: 12 },
|
||||||
|
bodyFont: { size: 11 },
|
||||||
|
rtl: false,
|
||||||
|
textDirection: 'ltr',
|
||||||
|
usePointStyle: true,
|
||||||
|
boxPadding: 6,
|
||||||
|
},
|
||||||
|
datalabels: createDataLabelConfig(showDataLabels, {
|
||||||
|
color: theme.textPrimary,
|
||||||
|
backgroundColor: theme.surface + 'dd',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { font: { size: 10 }, color: theme.textMuted }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: theme.border },
|
||||||
|
ticks: { font: { size: 10 }, color: theme.textMuted },
|
||||||
|
border: { display: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hover-dim + end-of-line name labels for multi-museum trend charts.
|
||||||
|
// Only activates for charts that have datasets marked with _isMuseumLine.
|
||||||
|
const trendLinePlugin = {
|
||||||
|
id: 'trendLineOverlay',
|
||||||
|
|
||||||
|
// ── hover dim ──────────────────────────────────────────────────
|
||||||
|
beforeDatasetDraw(chart: any, args: any) {
|
||||||
|
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
|
||||||
|
const active = chart.getActiveElements();
|
||||||
|
if (active.length === 0) return;
|
||||||
|
if (active[0].datasetIndex !== args.index) {
|
||||||
|
chart.ctx.save();
|
||||||
|
chart.ctx.globalAlpha = 0.15;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: {
|
afterDatasetDraw(chart: any, args: any) {
|
||||||
legend: { display: false },
|
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
|
||||||
tooltip: {
|
const active = chart.getActiveElements();
|
||||||
backgroundColor: '#1e293b',
|
if (active.length > 0 && active[0].datasetIndex !== args.index) {
|
||||||
padding: 12,
|
chart.ctx.restore();
|
||||||
cornerRadius: 8,
|
|
||||||
titleFont: { size: 12 },
|
|
||||||
bodyFont: { size: 11 },
|
|
||||||
rtl: false,
|
|
||||||
textDirection: 'ltr'
|
|
||||||
},
|
|
||||||
datalabels: createDataLabelConfig(showDataLabels)
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
grid: { display: false },
|
|
||||||
ticks: { font: { size: 10 }, color: '#94a3b8' }
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
grid: { color: chartColors.grid },
|
|
||||||
ticks: { font: { size: 10 }, color: '#94a3b8' },
|
|
||||||
border: { display: false }
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
});
|
|
||||||
|
};
|
||||||
|
|
||||||
|
ChartJS.register(trendLinePlugin);
|
||||||
|
|
||||||
export const lineDatasetDefaults = {
|
export const lineDatasetDefaults = {
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { LC } from './locale';
|
||||||
|
|
||||||
|
// ─── date helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
|
||||||
|
|
||||||
|
export function isLeap(y: number): boolean {
|
||||||
|
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makePresets(y: number): Record<string, { start: string; end: string }> {
|
||||||
|
const feb = isLeap(y) ? 29 : 28;
|
||||||
|
return {
|
||||||
|
jan:{start:`${y}-01-01`,end:`${y}-01-31`}, feb:{start:`${y}-02-01`,end:`${y}-02-${String(feb).padStart(2,'0')}`},
|
||||||
|
mar:{start:`${y}-03-01`,end:`${y}-03-31`}, apr:{start:`${y}-04-01`,end:`${y}-04-30`},
|
||||||
|
may:{start:`${y}-05-01`,end:`${y}-05-31`}, jun:{start:`${y}-06-01`,end:`${y}-06-30`},
|
||||||
|
jul:{start:`${y}-07-01`,end:`${y}-07-31`}, aug:{start:`${y}-08-01`,end:`${y}-08-31`},
|
||||||
|
sep:{start:`${y}-09-01`,end:`${y}-09-30`}, oct:{start:`${y}-10-01`,end:`${y}-10-31`},
|
||||||
|
nov:{start:`${y}-11-01`,end:`${y}-11-30`}, dec:{start:`${y}-12-01`,end:`${y}-12-31`},
|
||||||
|
q1:{start:`${y}-01-01`,end:`${y}-03-31`}, q2:{start:`${y}-04-01`,end:`${y}-06-30`},
|
||||||
|
q3:{start:`${y}-07-01`,end:`${y}-09-30`}, q4:{start:`${y}-10-01`,end:`${y}-12-31`},
|
||||||
|
h1:{start:`${y}-01-01`,end:`${y}-06-30`}, h2:{start:`${y}-07-01`,end:`${y}-12-31`},
|
||||||
|
full:{start:`${y}-01-01`,end:`${y}-12-31`},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function guessPreset(start: string, end: string): { key: string; year: number } | null {
|
||||||
|
const year = parseInt(start.slice(0, 4));
|
||||||
|
const presets = makePresets(year);
|
||||||
|
for (const [key, r] of Object.entries(presets)) {
|
||||||
|
if (r.start === start && r.end === end) return { key, year };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function periodNameL(start: string, end: string, L: LC): string {
|
||||||
|
const year = parseInt(start.slice(0, 4));
|
||||||
|
const g = guessPreset(start, end);
|
||||||
|
if (!g) {
|
||||||
|
const fmt = (d: string) => { const [,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]}`; };
|
||||||
|
const ey = parseInt(end.slice(0, 4));
|
||||||
|
return year === ey ? `${fmt(start)} – ${fmt(end)} ${year}` : `${fmt(start)} ${year} – ${fmt(end)} ${ey}`;
|
||||||
|
}
|
||||||
|
const mi = MONTH_KEYS.indexOf(g.key);
|
||||||
|
if (mi >= 0) return `${L.monthFull[mi]} ${g.year}`;
|
||||||
|
if (g.key === 'full') return L.fullYearLabel(g.year);
|
||||||
|
return `${L.periods[g.key] ?? g.key.toUpperCase()} ${g.year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateRangeTextL(start: string, end: string, L: LC): string {
|
||||||
|
const fmt = (d: string) => { const [y,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]} ${y}`; };
|
||||||
|
return `${fmt(start)} ${L.dateRangeSep} ${fmt(end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentMonth(): { start: string; end: string } {
|
||||||
|
const now = new Date(); const y = now.getFullYear(), m = now.getMonth() + 1;
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return { start: `${y}-${p(m)}-01`, end: `${y}-${p(m)}-${p(new Date(y, m, 0).getDate())}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shiftYear(s: string): string {
|
||||||
|
return s.replace(/^(\d{4})/, (_, y) => String(parseInt(y) - 1));
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// ─── language config ──────────────────────────────────────────────
|
||||||
|
// Shared LC interface used by Dashboard and Comparison.
|
||||||
|
// Fields marked with a comment are only consumed by one page but kept
|
||||||
|
// here so both components share a single type.
|
||||||
|
export interface LC {
|
||||||
|
dir: 'ltr' | 'rtl';
|
||||||
|
/** @deprecated Fonts are now loaded from index.html; kept for compatibility */
|
||||||
|
fontImport: string;
|
||||||
|
bodyFont: string;
|
||||||
|
displayFont: string;
|
||||||
|
monoFont: string;
|
||||||
|
monthFull: string[];
|
||||||
|
monthShort: string[];
|
||||||
|
periods: Record<string, string>;
|
||||||
|
fullYearLabel: (y: number) => string;
|
||||||
|
dateRangeSep: string;
|
||||||
|
backLink: string;
|
||||||
|
backTo: string;
|
||||||
|
pageTitle: string;
|
||||||
|
pageSub: string;
|
||||||
|
// Dashboard
|
||||||
|
changePeriod: string;
|
||||||
|
close: string;
|
||||||
|
apply: string;
|
||||||
|
filter: string;
|
||||||
|
allDistricts: string;
|
||||||
|
allChannels: string;
|
||||||
|
allMuseums: string;
|
||||||
|
countDistricts: (n: number) => string;
|
||||||
|
countChannels: (n: number) => string;
|
||||||
|
countMuseums: (n: number) => string;
|
||||||
|
reset: string;
|
||||||
|
exclVAT: string;
|
||||||
|
inclVAT: string;
|
||||||
|
keyMetrics: string;
|
||||||
|
revenue: string;
|
||||||
|
visitors: string;
|
||||||
|
tickets: string;
|
||||||
|
avgRev: string;
|
||||||
|
pilgrims: string;
|
||||||
|
captureRate: string;
|
||||||
|
charts: string;
|
||||||
|
trendTitle: string;
|
||||||
|
museumTitle: string;
|
||||||
|
channelTitle: string;
|
||||||
|
districtTitle: string;
|
||||||
|
daily: string;
|
||||||
|
weekly: string;
|
||||||
|
monthly: string;
|
||||||
|
newLabel: string;
|
||||||
|
clearSel: string;
|
||||||
|
monthSection: string;
|
||||||
|
periodSection: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
vsLabel: string;
|
||||||
|
barLabel: string;
|
||||||
|
pieLabel: string;
|
||||||
|
absLabel: string;
|
||||||
|
pctLabel: string;
|
||||||
|
// Comparison-specific
|
||||||
|
currentRole: string;
|
||||||
|
previousRole: string;
|
||||||
|
currentHint: string;
|
||||||
|
previousHint: string;
|
||||||
|
vs: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EN: LC = {
|
||||||
|
dir: 'ltr',
|
||||||
|
fontImport: `@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&display=swap');`,
|
||||||
|
bodyFont: "'Outfit', sans-serif",
|
||||||
|
displayFont: "'DM Serif Display', serif",
|
||||||
|
monoFont: "ui-monospace, 'Cascadia Code', monospace",
|
||||||
|
monthFull: ['January','February','March','April','May','June','July','August','September','October','November','December'],
|
||||||
|
monthShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
|
||||||
|
periods: { q1:'Q1', q2:'Q2', q3:'Q3', q4:'Q4', h1:'H1', h2:'H2', full:'Full Year' },
|
||||||
|
fullYearLabel: (y) => String(y),
|
||||||
|
dateRangeSep: '→',
|
||||||
|
backLink: 'Back to Dashboard', backTo: '/',
|
||||||
|
pageTitle: 'Overview', pageSub: 'Museum performance at a glance.',
|
||||||
|
changePeriod: 'Change period', close: 'Cancel', apply: 'Apply',
|
||||||
|
filter: 'Filter',
|
||||||
|
allDistricts: 'All districts', allChannels: 'All channels', allMuseums: 'All museums',
|
||||||
|
countDistricts: (n) => `${n} districts`,
|
||||||
|
countChannels: (n) => `${n} channels`,
|
||||||
|
countMuseums: (n) => `${n} museums`,
|
||||||
|
reset: 'Reset', exclVAT: 'Excl. VAT', inclVAT: 'Incl. VAT',
|
||||||
|
keyMetrics: 'Key Metrics',
|
||||||
|
revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets',
|
||||||
|
avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %',
|
||||||
|
charts: 'Charts',
|
||||||
|
trendTitle: 'Trend over time', museumTitle: 'By museum',
|
||||||
|
channelTitle: 'By channel', districtTitle: 'By district',
|
||||||
|
daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
|
||||||
|
newLabel: 'New', clearSel: 'Clear selection',
|
||||||
|
monthSection: 'Month', periodSection: 'Quarter · Half · Year',
|
||||||
|
from: 'From', to: 'To', vsLabel: 'vs',
|
||||||
|
barLabel: 'Bar', pieLabel: 'Pie', absLabel: '#', pctLabel: '%',
|
||||||
|
currentRole: 'This period', previousRole: 'Compared to',
|
||||||
|
currentHint: 'primary', previousHint: 'auto year −1',
|
||||||
|
vs: 'vs',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AR: LC = {
|
||||||
|
dir: 'rtl',
|
||||||
|
fontImport: `@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');`,
|
||||||
|
bodyFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||||
|
displayFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||||
|
monoFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||||
|
monthFull: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'],
|
||||||
|
monthShort: ['ينا','فبر','مار','أبر','ماي','يون','يول','أغس','سبت','أكت','نوف','ديس'],
|
||||||
|
periods: { q1:'ر١', q2:'ر٢', q3:'ر٣', q4:'ر٤', h1:'ن١', h2:'ن٢', full:'السنة' },
|
||||||
|
fullYearLabel: (y) => `${y} كاملاً`,
|
||||||
|
dateRangeSep: '–',
|
||||||
|
backLink: 'العودة إلى لوحة التحكم', backTo: '/ar',
|
||||||
|
pageTitle: 'نظرة عامة', pageSub: 'أداء المتاحف في لمحة.',
|
||||||
|
changePeriod: 'تغيير الفترة', close: 'إلغاء', apply: 'تطبيق',
|
||||||
|
filter: 'تصفية',
|
||||||
|
allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف',
|
||||||
|
countDistricts: (n) => `${n} مناطق`,
|
||||||
|
countChannels: (n) => `${n} قنوات`,
|
||||||
|
countMuseums: (n) => `${n} متاحف`,
|
||||||
|
reset: 'إعادة ضبط', exclVAT: 'بدون ضريبة', inclVAT: 'مع ضريبة',
|
||||||
|
keyMetrics: 'المؤشرات الرئيسية',
|
||||||
|
revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر',
|
||||||
|
avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %',
|
||||||
|
charts: 'المخططات',
|
||||||
|
trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف',
|
||||||
|
channelTitle: 'حسب القناة', districtTitle: 'حسب المنطقة',
|
||||||
|
daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري',
|
||||||
|
newLabel: 'جديد', clearSel: 'مسح التحديد',
|
||||||
|
monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة',
|
||||||
|
from: 'من', to: 'إلى', vsLabel: 'مقابل',
|
||||||
|
barLabel: 'أعمدة', pieLabel: 'دائرة', absLabel: '#', pctLabel: '%',
|
||||||
|
currentRole: 'الفترة الحالية', previousRole: 'مقارنةً بـ',
|
||||||
|
currentHint: 'رئيسية', previousHint: 'تلقائياً −١ سنة',
|
||||||
|
vs: 'مقابل',
|
||||||
|
};
|
||||||
@@ -273,11 +273,9 @@ export async function refreshData(): Promise<FetchResult> {
|
|||||||
|
|
||||||
export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord[] {
|
export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord[] {
|
||||||
return data.filter(row => {
|
return data.filter(row => {
|
||||||
if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false;
|
|
||||||
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
||||||
if (filters.channel.length > 0 && !filters.channel.includes(row.channel)) return false;
|
if (filters.channel.length > 0 && !filters.channel.includes(row.channel)) return false;
|
||||||
if (filters.museum.length > 0 && !filters.museum.includes(row.museum_name)) return false;
|
if (filters.museum.length > 0 && !filters.museum.includes(row.museum_name)) return false;
|
||||||
if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false;
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -21,11 +21,11 @@ export interface Metrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Filters {
|
export interface Filters {
|
||||||
year: string;
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
district: string;
|
district: string;
|
||||||
channel: string[];
|
channel: string[];
|
||||||
museum: string[];
|
museum: string[];
|
||||||
quarter: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateRangeFilters {
|
export interface DateRangeFilters {
|
||||||
|
|||||||
Reference in New Issue
Block a user