Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c858075232 | |||
| 648365348f | |||
| 594321738a | |||
| b6bd3bcff5 | |||
| d59af22329 | |||
| 640538bcbd | |||
| 553928a3a9 | |||
| d925d41a79 | |||
| d7d035adb0 | |||
| cf6a4c0b3d | |||
| 2f90753f57 | |||
| 65025d7f3c | |||
| ab94d33868 | |||
| 64955f0f51 | |||
| c9cfb58896 | |||
| 30cdb5064a | |||
| 25cb91e31b | |||
| ef9a960e5d | |||
| 9138ac1098 | |||
| d3f9a6cd43 |
@@ -0,0 +1,148 @@
|
||||
# Report Builder — Design Spec
|
||||
**Date:** 2026-04-28
|
||||
**Status:** Approved
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A dedicated `/report` page (admin-only) where users configure a client-facing PDF report from scratch. The report is a professional business document — no app interface visible — downloadable as a `.pdf` file via `@react-pdf/renderer`.
|
||||
|
||||
---
|
||||
|
||||
## Page Structure
|
||||
|
||||
Two-column layout on desktop, stacked on mobile:
|
||||
- **Left panel (form):** all configuration fields, grouped into sections
|
||||
- **Right panel:** a static document preview mockup (not a live render — too expensive). Shows real text fields (title, client name, period) updating in real time, but charts and metrics are represented as grey placeholder shapes. Gives the user a sense of page structure and section order.
|
||||
- **Footer bar:** "Generate PDF" button (triggers download), estimated page count
|
||||
|
||||
The route is `/report`, protected the same way `/settings` is (admin only, via `userRole === 'admin'` check in `App.tsx`).
|
||||
|
||||
---
|
||||
|
||||
## Form Sections
|
||||
|
||||
### 1. Client Info
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| Report title | Text input | e.g. "Q1 2025 Visitor Performance" |
|
||||
| Prepared for (company) | Text input | Shown in report header |
|
||||
| Contact name | Text input | Optional — "Attention: …" line |
|
||||
| Client logo | File upload (PNG/JPG/SVG, max 2MB) | Base64-encoded, embedded in PDF header |
|
||||
| Accent color | Color picker | Defaults to HiHala blue `#2563eb`; used for section headers, borders |
|
||||
|
||||
### 2. Data Selection
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| Period start / end | Date inputs | Defaults to current month |
|
||||
| Museums | Multi-select (same component as dashboard) | Empty = all |
|
||||
| Channels | Multi-select | Empty = all |
|
||||
| VAT | Toggle: Excl / Incl | Defaults to Incl |
|
||||
| Include comparison | Checkbox | Adds previous-year column to metrics table |
|
||||
|
||||
### 3. Content Sections (toggleable)
|
||||
Each is a checkbox, all on by default:
|
||||
- Executive summary (3–4 sentences auto-generated from numbers)
|
||||
- Key metrics table (Revenue, Visitors, Tickets, Avg Rev/Visitor, optionally Pilgrim Capture Rate)
|
||||
- Revenue & visitor trend chart
|
||||
- Breakdown by museum
|
||||
- Breakdown by channel
|
||||
- Pilgrim capture rate section (only shown if toggle is on AND data exists)
|
||||
|
||||
### 4. Presentation
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| Language | Toggle: EN / AR | Controls all PDF text and direction |
|
||||
| Confidentiality footer | Select: Confidential / Internal / Public | Shown in page footer |
|
||||
| Page orientation | Toggle: Portrait / Landscape | Portrait default |
|
||||
|
||||
---
|
||||
|
||||
## PDF Document Design
|
||||
|
||||
Built with `@react-pdf/renderer`. All layout is code — no DOM capture, no html2canvas.
|
||||
|
||||
**Page 1 — Cover**
|
||||
- HiHala logo (top-left) + client logo (top-right)
|
||||
- Large report title
|
||||
- "Prepared for: [Company]" / "Attention: [Contact]"
|
||||
- Period label (e.g. "January – March 2025")
|
||||
- Generation date
|
||||
- Accent color bar at bottom
|
||||
|
||||
**Page 2+ — Content pages**
|
||||
- Shared header: small HiHala wordmark + report title + page number
|
||||
- Sections in the order the user toggled them on
|
||||
- Each section starts with a colored heading bar (accent color)
|
||||
- Confidentiality level in page footer
|
||||
|
||||
**Chart rendering:**
|
||||
Charts do not use the live Chart.js instances. Instead, `@react-pdf/renderer` draws simplified chart equivalents natively using SVG primitives (`<Svg>`, `<Rect>`, `<Path>`, `<Line>`) — no canvas capture needed. This produces crisp vector output at any print resolution.
|
||||
|
||||
Simplified charts to implement:
|
||||
- **Trend line chart:** SVG polyline over a grid
|
||||
- **Bar chart (museum/channel breakdown):** horizontal SVG bars with labels
|
||||
|
||||
**Executive summary generation:**
|
||||
Computed from the metrics — a template string filled with actual numbers. Example (EN):
|
||||
> "During [period], [selected museums] recorded [X] visitors and [Y SAR] in revenue, representing a [Z%] change versus the same period last year. The top-performing channel was [channel] with [N%] of total tickets."
|
||||
|
||||
The same template exists in Arabic (stored in the locale file alongside EN/AR strings already in the codebase). Falls back gracefully if comparison data is absent (omits the change sentence).
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
/report page
|
||||
→ user fills form
|
||||
→ clicks "Generate PDF"
|
||||
→ reads filtered data from already-loaded app state (passed as prop from App.tsx)
|
||||
OR re-fetches if needed (dataService.fetchData())
|
||||
→ applies period + museum + channel filters client-side
|
||||
→ computes metrics (reuses existing calculateMetrics())
|
||||
→ passes computed values to <ReportDocument /> (react-pdf component)
|
||||
→ pdf.download('hihala-report.pdf')
|
||||
```
|
||||
|
||||
No new API endpoints required. All computation is client-side using existing `dataService` functions.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
components/
|
||||
Report/
|
||||
index.tsx — the /report page (form + preview layout)
|
||||
ReportForm.tsx — the left-panel form
|
||||
ReportPreview.tsx — the right-panel static mockup
|
||||
ReportDocument.tsx — the @react-pdf/renderer document
|
||||
reportCharts.tsx — SVG chart primitives for PDF
|
||||
reportHelpers.ts — executive summary generator, data filters
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
- Desktop nav: gear icon already links to `/settings`; add a "Report" link (document icon) next to it, admin-only
|
||||
- Mobile bottom nav: add Report icon between Comparison and Settings
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@react-pdf/renderer` — PDF generation
|
||||
- No other new dependencies (reuses existing AltMultiSelect, form inputs, data service)
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Scheduled / emailed reports
|
||||
- Saving report configurations
|
||||
- Non-admin users generating reports
|
||||
- Live chart preview in the right panel (static mockup only)
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
<meta name="description" content="HiHala Data Dashboard — Event analytics, visitor tracking, and revenue insights" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+589
-1
@@ -8,6 +8,7 @@
|
||||
"name": "hihala-dashboard",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -827,6 +828,207 @@
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/fns": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.3.tgz",
|
||||
"integrity": "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-pdf/font": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.8.tgz",
|
||||
"integrity": "sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/pdfkit": "^5.1.1",
|
||||
"@react-pdf/types": "^2.11.1",
|
||||
"fontkit": "^2.0.2",
|
||||
"is-url": "^1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/image": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.1.0.tgz",
|
||||
"integrity": "sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/svg": "^1.1.0",
|
||||
"jay-peg": "^1.1.1",
|
||||
"png-js": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/layout": {
|
||||
"version": "4.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.6.1.tgz",
|
||||
"integrity": "sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.3",
|
||||
"@react-pdf/image": "^3.1.0",
|
||||
"@react-pdf/primitives": "^4.3.0",
|
||||
"@react-pdf/stylesheet": "^6.2.1",
|
||||
"@react-pdf/textkit": "^6.3.0",
|
||||
"@react-pdf/types": "^2.11.1",
|
||||
"emoji-regex-xs": "^1.0.0",
|
||||
"queue": "^6.0.1",
|
||||
"yoga-layout": "^3.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/pdfkit": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-5.1.1.tgz",
|
||||
"integrity": "sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@noble/ciphers": "^1.0.0",
|
||||
"@noble/hashes": "^1.6.0",
|
||||
"browserify-zlib": "^0.2.0",
|
||||
"fontkit": "^2.0.2",
|
||||
"jay-peg": "^1.1.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^2.0.0",
|
||||
"vite-compatible-readable-stream": "^3.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/primitives": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.3.0.tgz",
|
||||
"integrity": "sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-pdf/reconciler": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz",
|
||||
"integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4.1.1",
|
||||
"scheduler": "0.25.0-rc-603e6108-20241029"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
|
||||
"version": "0.25.0-rc-603e6108-20241029",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
|
||||
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-pdf/render": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.5.1.tgz",
|
||||
"integrity": "sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "3.1.3",
|
||||
"@react-pdf/primitives": "^4.3.0",
|
||||
"@react-pdf/textkit": "^6.3.0",
|
||||
"@react-pdf/types": "^2.11.1",
|
||||
"abs-svg-path": "^0.1.1",
|
||||
"color-string": "^2.1.4",
|
||||
"normalize-svg-path": "^1.1.0",
|
||||
"parse-svg-path": "^0.1.2",
|
||||
"svg-arc-to-cubic-bezier": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/renderer": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.5.1.tgz",
|
||||
"integrity": "sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "3.1.3",
|
||||
"@react-pdf/font": "^4.0.8",
|
||||
"@react-pdf/layout": "^4.6.1",
|
||||
"@react-pdf/pdfkit": "^5.1.1",
|
||||
"@react-pdf/primitives": "^4.3.0",
|
||||
"@react-pdf/reconciler": "^2.0.0",
|
||||
"@react-pdf/render": "^4.5.1",
|
||||
"@react-pdf/types": "^2.11.1",
|
||||
"events": "^3.3.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"prop-types": "^15.6.2",
|
||||
"queue": "^6.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/stylesheet": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.2.1.tgz",
|
||||
"integrity": "sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.3",
|
||||
"@react-pdf/types": "^2.11.1",
|
||||
"color-string": "^2.1.4",
|
||||
"hsl-to-hex": "^1.0.0",
|
||||
"media-engine": "^1.0.3",
|
||||
"postcss-value-parser": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/svg": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/svg/-/svg-1.1.0.tgz",
|
||||
"integrity": "sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/primitives": "^4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/textkit": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.3.0.tgz",
|
||||
"integrity": "sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.3",
|
||||
"bidi-js": "^1.0.2",
|
||||
"hyphen": "^1.6.4",
|
||||
"unicode-properties": "^1.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/types": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.11.1.tgz",
|
||||
"integrity": "sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/font": "^4.0.8",
|
||||
"@react-pdf/primitives": "^4.3.0",
|
||||
"@react-pdf/stylesheet": "^6.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||
@@ -1184,6 +1386,15 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz",
|
||||
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
@@ -1419,6 +1630,12 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abs-svg-path": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
|
||||
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -1462,6 +1679,26 @@
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
@@ -1475,6 +1712,33 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserify-zlib": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "~1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
@@ -1605,6 +1869,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1625,6 +1898,27 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
|
||||
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/color-string/node_modules/color-name": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
|
||||
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
@@ -1712,6 +2006,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
@@ -1732,6 +2032,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex-xs": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
|
||||
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
@@ -1784,6 +2090,21 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1802,6 +2123,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1847,6 +2191,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/hsl-to-hex": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
|
||||
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hsl-to-rgb-for-reals": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hsl-to-rgb-for-reals": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
|
||||
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
@@ -1860,6 +2219,12 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hyphen": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz",
|
||||
"integrity": "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
@@ -1891,6 +2256,27 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-url": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jay-peg": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
|
||||
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"restructure": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-md5": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -1980,6 +2366,37 @@
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -1999,6 +2416,12 @@
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/media-engine": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
|
||||
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
@@ -2041,12 +2464,36 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-svg-path": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
||||
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"svg-arc-to-cubic-bezier": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-svg-path": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
|
||||
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2066,6 +2513,14 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
|
||||
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
@@ -2095,6 +2550,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
@@ -2127,6 +2588,32 @@
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
@@ -2248,6 +2735,21 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
@@ -2303,6 +2805,26 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -2354,6 +2876,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
@@ -2410,6 +2941,12 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-arc-to-cubic-bezier": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
|
||||
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
@@ -2419,6 +2956,12 @@
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -2450,7 +2993,6 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
@@ -2474,6 +3016,32 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie/node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -2595,6 +3163,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-compatible-readable-stream": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
|
||||
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
|
||||
@@ -2664,6 +3246,12 @@
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yoga-layout": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
||||
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
||||
+961
-462
File diff suppressed because it is too large
Load Diff
+39
@@ -4,6 +4,7 @@ import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react
|
||||
const Settings = lazy(() => import('./components/Settings'));
|
||||
const Comparison = lazy(() => import('./components/Comparison'));
|
||||
const Dashboard = lazy(() => import('./components/Dashboard'));
|
||||
const Report = lazy(() => import('./components/Report'));
|
||||
import Login from './components/Login';
|
||||
import LoadingSkeleton from './components/shared/LoadingSkeleton';
|
||||
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService';
|
||||
@@ -237,6 +238,26 @@ function App() {
|
||||
</svg>
|
||||
{t('nav.comparison')}
|
||||
</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 && (
|
||||
<span className="offline-badge" title={cacheInfo ? `Cached: ${new Date(cacheInfo.timestamp || '').toLocaleString()}` : ''}>
|
||||
@@ -250,6 +271,11 @@ function App() {
|
||||
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
||||
</svg>
|
||||
{t('app.offline') || 'Offline'}
|
||||
{cacheInfo && (
|
||||
<span className="sr-only">
|
||||
{` (cached ${new Date(cacheInfo.timestamp || '').toLocaleString()})`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
@@ -304,6 +330,7 @@ function App() {
|
||||
<Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
||||
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
|
||||
{userRole === 'admin' && <Route path="/report" element={<Report data={data} />} />}
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</main>
|
||||
@@ -327,6 +354,18 @@ function App() {
|
||||
</svg>
|
||||
<span>{t('nav.compare')}</span>
|
||||
</NavLink>
|
||||
{userRole === 'admin' && (
|
||||
<NavLink to="/report" className="mobile-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
<span>Report</span>
|
||||
</NavLink>
|
||||
)}
|
||||
{userRole === 'admin' && (
|
||||
<NavLink to="/settings" className="mobile-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
|
||||
+35
-429
@@ -2,13 +2,19 @@ import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Line, Bar } from 'react-chartjs-2';
|
||||
import {
|
||||
filterDataByDateRange, calculateMetrics, formatCurrency, formatNumber,
|
||||
filterDataByDateRange, calculateMetrics,
|
||||
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
|
||||
umrahData
|
||||
} from '../services/dataService';
|
||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||
import type { MuseumRecord, Season } from '../types';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import type { LC } from '../lib/locale';
|
||||
import { EN, AR } from '../lib/locale';
|
||||
import { currentMonth, shiftYear, periodNameL, dateRangeTextL } from '../lib/dateHelpers';
|
||||
import { InlinePicker } from './shared/PeriodPicker';
|
||||
import AltMultiSelect from './shared/AltMultiSelect';
|
||||
import MetricCard from './shared/MetricCard';
|
||||
|
||||
interface Props {
|
||||
data: MuseumRecord[];
|
||||
@@ -19,222 +25,6 @@ interface Props {
|
||||
lang?: 'en' | 'ar';
|
||||
}
|
||||
|
||||
// ─── language config ──────────────────────────────────────────────
|
||||
interface LC {
|
||||
dir: 'ltr' | 'rtl';
|
||||
fontImport: string;
|
||||
bodyFont: string;
|
||||
displayFont: string;
|
||||
monoFont: string;
|
||||
monthFull: string[];
|
||||
monthShort: string[];
|
||||
periods: Record<string, string>;
|
||||
fullYearLabel: (y: number) => string;
|
||||
dateRangeSep: string;
|
||||
backLink: string;
|
||||
backTo: string;
|
||||
pageTitle: string;
|
||||
pageSub: string;
|
||||
currentRole: string; previousRole: string;
|
||||
currentHint: string; previousHint: string;
|
||||
changePeriod: string; close: string; apply: string;
|
||||
vs: string;
|
||||
filter: string;
|
||||
allDistricts: string; allChannels: string; allMuseums: string;
|
||||
countDistricts: (n: number) => string;
|
||||
countChannels: (n: number) => string;
|
||||
countMuseums: (n: number) => string;
|
||||
reset: string;
|
||||
keyMetrics: string;
|
||||
revenue: string; visitors: string; tickets: string; avgRev: string;
|
||||
pilgrims: string; captureRate: string;
|
||||
trendTitle: string; museumTitle: string;
|
||||
daily: string; weekly: string; monthly: string;
|
||||
newLabel: string; clearSel: string;
|
||||
monthSection: string; periodSection: string;
|
||||
from: string; to: string;
|
||||
}
|
||||
|
||||
const EN: LC = {
|
||||
dir: 'ltr',
|
||||
fontImport: `@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&display=swap');`,
|
||||
bodyFont: "'Outfit', sans-serif",
|
||||
displayFont: "'DM Serif Display', serif",
|
||||
monoFont: "ui-monospace, 'Cascadia Code', monospace",
|
||||
monthFull: ['January','February','March','April','May','June','July','August','September','October','November','December'],
|
||||
monthShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
|
||||
periods: { q1:'Q1', q2:'Q2', q3:'Q3', q4:'Q4', h1:'H1', h2:'H2', full:'Full Year' },
|
||||
fullYearLabel: (y) => String(y),
|
||||
dateRangeSep: '→',
|
||||
backLink: '← Overview', backTo: '/',
|
||||
pageTitle: 'Period Comparison', pageSub: 'Compare any two periods side by side.',
|
||||
currentRole: 'This period', previousRole: 'Compared to',
|
||||
currentHint: 'primary', previousHint: 'auto year −1',
|
||||
changePeriod: 'Change period', close: 'Cancel', apply: 'Apply',
|
||||
vs: 'vs',
|
||||
filter: 'Filter',
|
||||
allDistricts: 'All districts', allChannels: 'All channels', allMuseums: 'All museums',
|
||||
countDistricts: (n) => `${n} districts`,
|
||||
countChannels: (n) => `${n} channels`,
|
||||
countMuseums: (n) => `${n} museums`,
|
||||
reset: 'Reset',
|
||||
keyMetrics: 'Key Metrics',
|
||||
revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets',
|
||||
avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %',
|
||||
trendTitle: 'Trend over time', museumTitle: 'By museum',
|
||||
daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
|
||||
newLabel: 'New', clearSel: 'Clear selection',
|
||||
monthSection: 'Month', periodSection: 'Quarter · Half · Year',
|
||||
from: 'From', to: 'To',
|
||||
};
|
||||
|
||||
const AR: LC = {
|
||||
dir: 'rtl',
|
||||
fontImport: `@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');`,
|
||||
bodyFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||
displayFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||
monoFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||
monthFull: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'],
|
||||
monthShort: ['ينا','فبر','مار','أبر','ماي','يون','يول','أغس','سبت','أكت','نوف','ديس'],
|
||||
periods: { q1:'ر١', q2:'ر٢', q3:'ر٣', q4:'ر٤', h1:'ن١', h2:'ن٢', full:'السنة' },
|
||||
fullYearLabel: (y) => `${y} كاملاً`,
|
||||
dateRangeSep: '–',
|
||||
backLink: '← نظرة عامة', backTo: '/ar',
|
||||
pageTitle: 'مقارنة الفترات', pageSub: 'قارن بين فترتين زمنيتين.',
|
||||
currentRole: 'الفترة الحالية', previousRole: 'مقارنةً بـ',
|
||||
currentHint: 'رئيسية', previousHint: 'تلقائياً −١ سنة',
|
||||
changePeriod: 'تغيير الفترة', close: 'إلغاء', apply: 'تطبيق',
|
||||
vs: 'مقابل',
|
||||
filter: 'تصفية',
|
||||
allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف',
|
||||
countDistricts: (n) => `${n} مناطق`,
|
||||
countChannels: (n) => `${n} قنوات`,
|
||||
countMuseums: (n) => `${n} متاحف`,
|
||||
reset: 'إعادة ضبط',
|
||||
keyMetrics: 'المؤشرات الرئيسية',
|
||||
revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر',
|
||||
avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %',
|
||||
trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف',
|
||||
daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري',
|
||||
newLabel: 'جديد', clearSel: 'مسح التحديد',
|
||||
monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة',
|
||||
from: 'من', to: 'إلى',
|
||||
};
|
||||
|
||||
// ─── date helpers ─────────────────────────────────────────────────
|
||||
const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
|
||||
|
||||
function isLeap(y: number) { return (y%4===0 && y%100!==0) || y%400===0; }
|
||||
|
||||
function makePresets(y: number): Record<string, { start: string; end: string }> {
|
||||
const feb = isLeap(y) ? 29 : 28;
|
||||
return {
|
||||
jan:{start:`${y}-01-01`,end:`${y}-01-31`}, feb:{start:`${y}-02-01`,end:`${y}-02-${String(feb).padStart(2,'0')}`},
|
||||
mar:{start:`${y}-03-01`,end:`${y}-03-31`}, apr:{start:`${y}-04-01`,end:`${y}-04-30`},
|
||||
may:{start:`${y}-05-01`,end:`${y}-05-31`}, jun:{start:`${y}-06-01`,end:`${y}-06-30`},
|
||||
jul:{start:`${y}-07-01`,end:`${y}-07-31`}, aug:{start:`${y}-08-01`,end:`${y}-08-31`},
|
||||
sep:{start:`${y}-09-01`,end:`${y}-09-30`}, oct:{start:`${y}-10-01`,end:`${y}-10-31`},
|
||||
nov:{start:`${y}-11-01`,end:`${y}-11-30`}, dec:{start:`${y}-12-01`,end:`${y}-12-31`},
|
||||
q1:{start:`${y}-01-01`,end:`${y}-03-31`}, q2:{start:`${y}-04-01`,end:`${y}-06-30`},
|
||||
q3:{start:`${y}-07-01`,end:`${y}-09-30`}, q4:{start:`${y}-10-01`,end:`${y}-12-31`},
|
||||
h1:{start:`${y}-01-01`,end:`${y}-06-30`}, h2:{start:`${y}-07-01`,end:`${y}-12-31`},
|
||||
full:{start:`${y}-01-01`,end:`${y}-12-31`},
|
||||
};
|
||||
}
|
||||
|
||||
function guessPreset(start: string, end: string) {
|
||||
const year = parseInt(start.slice(0,4));
|
||||
const presets = makePresets(year);
|
||||
for (const [key, r] of Object.entries(presets)) {
|
||||
if (r.start===start && r.end===end) return { key, year };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function periodNameL(start: string, end: string, L: LC): string {
|
||||
const year = parseInt(start.slice(0,4));
|
||||
const g = guessPreset(start, end);
|
||||
if (!g) {
|
||||
const fmt = (d: string) => { const [,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]}`; };
|
||||
const ey = parseInt(end.slice(0,4));
|
||||
return year===ey ? `${fmt(start)} – ${fmt(end)} ${year}` : `${fmt(start)} ${year} – ${fmt(end)} ${ey}`;
|
||||
}
|
||||
const mi = MONTH_KEYS.indexOf(g.key);
|
||||
if (mi >= 0) return `${L.monthFull[mi]} ${g.year}`;
|
||||
if (g.key==='full') return L.fullYearLabel(g.year);
|
||||
return `${L.periods[g.key]??g.key.toUpperCase()} ${g.year}`;
|
||||
}
|
||||
|
||||
function dateRangeTextL(start: string, end: string, L: LC): string {
|
||||
const fmt = (d: string) => { const [y,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]} ${y}`; };
|
||||
return `${fmt(start)} ${L.dateRangeSep} ${fmt(end)}`;
|
||||
}
|
||||
|
||||
function currentMonth() {
|
||||
const now = new Date(); const y=now.getFullYear(), m=now.getMonth()+1;
|
||||
const p = (n: number) => String(n).padStart(2,'0');
|
||||
return { start:`${y}-${p(m)}-01`, end:`${y}-${p(m)}-${p(new Date(y,m,0).getDate())}` };
|
||||
}
|
||||
function shiftYear(s: string) { return s.replace(/^(\d{4})/, (_,y) => String(parseInt(y)-1)); }
|
||||
|
||||
// ─── inline picker ────────────────────────────────────────────────
|
||||
function InlinePicker({ start, end, onChange, onClose, availableYears, L }: {
|
||||
start: string; end: string; onChange: (s: string, e: string) => void;
|
||||
onClose: () => void;
|
||||
availableYears: number[]; L: LC;
|
||||
}) {
|
||||
const g = guessPreset(start, end);
|
||||
const [year, setYear] = useState(g?.year ?? parseInt(start.slice(0,4)));
|
||||
const [active, setActive] = useState<string|null>(g?.key ?? null);
|
||||
const [draftStart, setDraftStart] = useState(start);
|
||||
const [draftEnd, setDraftEnd] = useState(end);
|
||||
const minY = Math.min(...availableYears), maxY = Math.max(...availableYears);
|
||||
|
||||
const pick = (key: string) => { const r=makePresets(year)[key]; if(!r) return; setActive(key); setDraftStart(r.start); setDraftEnd(r.end); };
|
||||
const shift = (d: number) => {
|
||||
const ny=year+d; if(ny<minY||ny>maxY) return; setYear(ny);
|
||||
if(active && makePresets(ny)[active]) { setDraftStart(makePresets(ny)[active].start); setDraftEnd(makePresets(ny)[active].end); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="alt-picker">
|
||||
<div className="alt-picker-year">
|
||||
<button type="button" onClick={() => shift(L.dir==='rtl' ? 1 : -1)} disabled={L.dir==='rtl' ? year>=maxY : year<=minY} className="alt-yr-btn">
|
||||
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M5.5 9.5L1.5 5.5L5.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
<span className="alt-yr-val">{year}</span>
|
||||
<button type="button" onClick={() => shift(L.dir==='rtl' ? -1 : 1)} disabled={L.dir==='rtl' ? year<=minY : year>=maxY} className="alt-yr-btn">
|
||||
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M1.5 1.5L5.5 5.5L1.5 9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="alt-picker-section">{L.monthSection}</p>
|
||||
<div className="alt-chips">
|
||||
{MONTH_KEYS.map((k,i) => (
|
||||
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.monthShort[i]}</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="alt-picker-section">{L.periodSection}</p>
|
||||
<div className="alt-chips">
|
||||
{['q1','q2','q3','q4','h1','h2'].map(k => (
|
||||
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.periods[k]}</button>
|
||||
))}
|
||||
<button type="button" className={`alt-chip alt-chip-wide${active==='full'?' alt-chip-on':''}`} onClick={() => pick('full')}>{L.periods.full}</button>
|
||||
</div>
|
||||
<div className="alt-picker-div" />
|
||||
<div className="alt-custom">
|
||||
<div className="alt-custom-f"><label>{L.from}</label><input type="date" value={draftStart} onChange={e => { setActive(null); setDraftStart(e.target.value); }} /></div>
|
||||
<span className="alt-custom-arrow">{L.dateRangeSep}</span>
|
||||
<div className="alt-custom-f"><label>{L.to}</label><input type="date" value={draftEnd} onChange={e => { setActive(null); setDraftEnd(e.target.value); }} /></div>
|
||||
</div>
|
||||
<div className="alt-picker-div" />
|
||||
<div className="alt-footer">
|
||||
<button type="button" className="alt-cancel" onClick={onClose}>{L.close}</button>
|
||||
<button type="button" className="alt-apply" onClick={() => { onChange(draftStart, draftEnd); onClose(); }}>{L.apply}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── period card ──────────────────────────────────────────────────
|
||||
function PeriodCard({ role, hint, start, end, variant, onChange, availableYears, L }: {
|
||||
role: string; hint: string; start: string; end: string;
|
||||
@@ -262,7 +52,7 @@ function PeriodCard({ role, hint, start, end, variant, onChange, availableYears,
|
||||
</div>
|
||||
<div className="alt-period-name">{periodNameL(start, end, L)}</div>
|
||||
<div className="alt-date-range">{dateRangeTextL(start, end, L)}</div>
|
||||
<button type="button" className="alt-change-btn" onClick={() => setOpen(v => !v)} aria-expanded={open}>
|
||||
<button type="button" className="alt-change-btn" onClick={() => setOpen(v => !v)} aria-expanded={open} aria-controls="period-picker-panel">
|
||||
{open ? L.close : L.changePeriod}
|
||||
<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"/>
|
||||
@@ -274,70 +64,6 @@ function PeriodCard({ role, hint, start, end, variant, onChange, availableYears,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── multi-select ─────────────────────────────────────────────────
|
||||
function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
|
||||
value: string[]; options: string[];
|
||||
onChange: (vals: string[]) => void;
|
||||
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e: MouseEvent) => { if(ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
|
||||
}, [open]);
|
||||
|
||||
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v!==opt) : [...value, opt]);
|
||||
const label = value.length===0 ? allLabel : value.length===1 ? value[0] : countLabel(value.length);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="altms">
|
||||
<button type="button" className={`altms-trigger${value.length>0?' altms-trigger--active':''}`} onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox">
|
||||
<span className="altms-label">{label}</span>
|
||||
<svg className={`altms-chevron${open?' altms-chevron--open':''}`} width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="altms-dropdown" role="listbox" aria-multiselectable="true">
|
||||
<div className="altms-list">
|
||||
{options.map(opt => (
|
||||
<label key={opt} className={`altms-option${value.includes(opt)?' altms-option--checked':''}`}>
|
||||
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
|
||||
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
|
||||
<span className="altms-opt-label">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{value.length>0 && <button type="button" className="altms-clear" onClick={() => { onChange([]); setOpen(false); }}>{clearLabel}</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── metric card ──────────────────────────────────────────────────
|
||||
function MetricCard({ title, curr, prev, isCurrency, newLabel }: {
|
||||
title: string; curr: number; prev: number; isCurrency?: boolean; newLabel?: string;
|
||||
}) {
|
||||
const fmt = (n: number) => isCurrency ? formatCurrency(n) : formatNumber(n);
|
||||
const change = prev===0 ? (curr>0 ? Infinity : 0) : ((curr-prev)/prev*100);
|
||||
const isPos = change>0, isNeg = change<0;
|
||||
return (
|
||||
<div className="alt-metric">
|
||||
<p className="alt-metric-title">{title}</p>
|
||||
<div className="alt-metric-value">{fmt(curr)}</div>
|
||||
<div className="alt-metric-footer">
|
||||
{isFinite(change)
|
||||
? <span className={`alt-change ${isPos?'alt-change--up':isNeg?'alt-change--down':'alt-change--flat'}`}>{isPos?'▲':isNeg?'▼':'—'} {Math.abs(change).toFixed(1)}%</span>
|
||||
: <span className="alt-change alt-change--up">{newLabel??'New'}</span>}
|
||||
<span className="alt-metric-prev">{fmt(prev)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── main page ────────────────────────────────────────────────────
|
||||
export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels }: Props) {
|
||||
const { lang: activeLang, setLanguage } = useLanguage();
|
||||
@@ -443,7 +169,10 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
||||
}, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal]);
|
||||
|
||||
const baseOpts = useMemo(() => createBaseOptions(false), []);
|
||||
const { chartOpts } = useMemo(() => {
|
||||
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } };
|
||||
return { chartOpts };
|
||||
}, [baseOpts]);
|
||||
|
||||
const metricOpts = [
|
||||
{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors },
|
||||
@@ -471,148 +200,19 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
||||
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
|
||||
|
||||
const hasFilters = selDistricts.length>0 || selChannels.length>0 || selMuseums.length>0;
|
||||
const activeFilterCount = selDistricts.length + selChannels.length + selMuseums.length;
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="alt-page" dir={L.dir}>
|
||||
<style>{`
|
||||
${L.fontImport}
|
||||
|
||||
.alt-page { max-width:1100px; margin:0 auto; padding:48px 24px 80px; font-family:${L.bodyFont}; width:100%; box-sizing:border-box; }
|
||||
|
||||
/* ── header ── */
|
||||
.alt-back { display:inline-flex; align-items:center; gap:6px; font-size:.8125rem; color:var(--text-muted); text-decoration:none; margin-bottom:28px; transition:color .15s; }
|
||||
.alt-back:hover { color:var(--accent); }
|
||||
.alt-page-title { font-family:${L.displayFont}; font-size:2.25rem; font-weight:400; color:var(--text-primary); margin:0 0 6px; letter-spacing:-.03em; line-height:1.15; }
|
||||
.alt-page-sub { font-size:.9375rem; color:var(--text-muted); margin:0 0 40px; font-weight:300; }
|
||||
|
||||
/* ── period row ── */
|
||||
.alt-period-row { display:grid; grid-template-columns:1fr auto 1fr; align-items:stretch; margin-bottom:32px; }
|
||||
.alt-vs { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:0 20px; position:relative; }
|
||||
.alt-vs-line { position:absolute; top:0; bottom:0; left:50%; width:1px; background:var(--border); }
|
||||
.alt-vs-badge { font-family:${L.displayFont}; font-size:.9rem; font-style:italic; color:var(--text-muted); background:var(--bg); padding:6px 10px; border:1px solid var(--border); border-radius:20px; position:relative; z-index:1; }
|
||||
|
||||
/* ── period card ── */
|
||||
.alt-card { border:1px solid var(--border); border-radius:var(--radius); background:var(--surface); overflow:hidden; transition:border-color .2s,box-shadow .2s; display:flex; flex-direction:column; }
|
||||
.alt-card--current { border-radius:var(--radius) 0 0 var(--radius); }
|
||||
.alt-card--previous { border-radius:0 var(--radius) var(--radius) 0; }
|
||||
[dir="rtl"] .alt-card--current { border-radius:0 var(--radius) var(--radius) 0; }
|
||||
[dir="rtl"] .alt-card--previous { border-radius:var(--radius) 0 0 var(--radius); }
|
||||
.alt-card:hover { box-shadow:var(--shadow); }
|
||||
.alt-card--current:hover,.alt-card--current.alt-card--open { border-color:var(--accent); }
|
||||
.alt-card--previous:hover,.alt-card--previous.alt-card--open { border-color:#94a3b8; }
|
||||
.alt-card-bar { height:3px; width:100%; }
|
||||
.alt-card--current .alt-card-bar { background:var(--accent); }
|
||||
.alt-card--previous .alt-card-bar { background:#94a3b8; }
|
||||
.alt-card-body { padding:24px 28px 20px; flex:1; }
|
||||
.alt-role-row { display:flex; align-items:baseline; gap:8px; margin-bottom:12px; }
|
||||
.alt-role { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.1em; }
|
||||
.alt-card--current .alt-role { color:var(--accent); }
|
||||
.alt-card--previous .alt-role { color:#64748b; }
|
||||
.alt-role-hint { font-size:.75rem; color:var(--text-muted); font-weight:300; }
|
||||
.alt-period-name { font-family:${L.displayFont}; font-size:2.25rem; font-weight:400; color:var(--text-primary); line-height:1.1; letter-spacing:-.02em; margin-bottom:8px; }
|
||||
.alt-date-range { font-family:${L.monoFont}; font-size:.8125rem; color:var(--text-muted); letter-spacing:.01em; margin-bottom:20px; }
|
||||
.alt-change-btn { display:inline-flex; align-items:center; gap:5px; font-family:${L.bodyFont}; font-size:.8125rem; font-weight:500; color:var(--text-muted); background:none; border:none; padding:0; cursor:pointer; transition:color .15s; }
|
||||
.alt-card--current .alt-change-btn:hover { color:var(--accent); }
|
||||
.alt-card--previous .alt-change-btn:hover { color:var(--text-primary); }
|
||||
|
||||
/* ── picker ── */
|
||||
.alt-picker { border-top:1px solid var(--border); padding:16px 24px 20px; background:var(--bg); animation:altPickIn 180ms cubic-bezier(.16,1,.3,1); }
|
||||
@keyframes altPickIn { from{opacity:0;transform:translateY(-8px)} to{opacity:1;transform:translateY(0)} }
|
||||
.alt-picker-year { display:flex; align-items:center; gap:16px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--border); }
|
||||
.alt-yr-val { font-family:${L.displayFont}; font-size:1.25rem; color:var(--text-primary); min-width:50px; text-align:center; }
|
||||
.alt-yr-btn { width:28px; height:28px; display:flex; align-items:center; justify-content:center; border:1px solid var(--border); border-radius:7px; background:var(--surface); color:var(--text-secondary); cursor:pointer; transition:background .12s,border-color .12s,color .12s; }
|
||||
.alt-yr-btn:hover:not(:disabled) { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
|
||||
.alt-yr-btn:disabled { opacity:.3; cursor:not-allowed; }
|
||||
.alt-picker-section { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:10px 0 6px; }
|
||||
.alt-chips { display:flex; flex-wrap:wrap; gap:5px; margin-bottom:4px; }
|
||||
.alt-chip { font-family:${L.bodyFont}; padding:4px 9px; border:1px solid var(--border); border-radius:6px; background:var(--surface); color:var(--text-secondary); font-size:.8rem; font-weight:500; cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
|
||||
.alt-chip:hover { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
|
||||
.alt-chip-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; font-weight:600!important; }
|
||||
.alt-chip-wide { padding-left:14px; padding-right:14px; }
|
||||
.alt-picker-div { height:1px; background:var(--border); margin:12px 0 10px; }
|
||||
.alt-custom { display:flex; align-items:flex-end; gap:8px; }
|
||||
.alt-custom-f { flex:1; display:flex; flex-direction:column; gap:4px; }
|
||||
.alt-custom-f label { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--text-muted); }
|
||||
.alt-custom-f input[type="date"] { padding:7px 9px; border:1px solid var(--border); border-radius:7px; font-size:.825rem; background:var(--surface); color:var(--text-primary); width:100%; }
|
||||
.alt-custom-f input[type="date"]:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 2px rgba(37,99,235,.12); }
|
||||
.alt-custom-arrow { font-size:.75rem; color:var(--text-muted); padding-bottom:9px; flex-shrink:0; }
|
||||
.alt-footer { display:flex; justify-content:flex-end; gap:8px; }
|
||||
.alt-cancel,.alt-apply { padding:7px 16px; border-radius:7px; font-size:.825rem; font-weight:600; cursor:pointer; font-family:${L.bodyFont}; transition:background .12s,color .12s; }
|
||||
.alt-cancel { background:transparent; border:1px solid var(--border); color:var(--text-secondary); }
|
||||
.alt-cancel:hover { background:var(--bg-secondary); }
|
||||
.alt-apply { background:var(--accent); border:1px solid transparent; color:#fff; }
|
||||
.alt-apply:hover { opacity:.88; }
|
||||
|
||||
/* ── multi-select ── */
|
||||
.altms { position:relative; }
|
||||
.altms-trigger { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border:1px solid var(--border); border-radius:8px; background:var(--surface); color:var(--text-secondary); font-family:${L.bodyFont}; font-size:.875rem; cursor:pointer; transition:border-color .15s,color .15s,background .15s; white-space:nowrap; }
|
||||
.altms-trigger:hover { border-color:var(--accent); color:var(--accent); }
|
||||
.altms-trigger--active { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
|
||||
.altms-label { max-width:140px; overflow:hidden; text-overflow:ellipsis; }
|
||||
.altms-chevron { transition:transform .18s; flex-shrink:0; }
|
||||
.altms-chevron--open { transform:rotate(180deg); }
|
||||
.altms-dropdown { position:absolute; top:calc(100% + 6px); left:0; z-index:200; min-width:200px; background:var(--surface); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.12); overflow:hidden; animation:altPickIn 140ms cubic-bezier(.16,1,.3,1); }
|
||||
[dir="rtl"] .altms-dropdown { left:auto; right:0; }
|
||||
.altms-list { max-height:220px; overflow-y:auto; padding:6px; display:flex; flex-direction:column; gap:2px; }
|
||||
.altms-option { display:flex; align-items:center; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; transition:background .1s; }
|
||||
.altms-option:hover { background:var(--bg); }
|
||||
.altms-option--checked { background:var(--accent-light); }
|
||||
.altms-check { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
|
||||
.altms-check-box { width:16px; height:16px; border:1.5px solid var(--border); border-radius:4px; display:flex; align-items:center; justify-content:center; flex-shrink:0; transition:background .1s,border-color .1s; }
|
||||
.altms-option--checked .altms-check-box { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
|
||||
.altms-opt-label { font-family:${L.bodyFont}; font-size:.875rem; color:var(--text-primary); }
|
||||
.altms-clear { width:100%; padding:8px 14px; border-top:1px solid var(--border); background:none; border-left:none; border-right:none; border-bottom:none; font-family:${L.bodyFont}; font-size:.8125rem; color:var(--danger); cursor:pointer; text-align:start; transition:background .1s; }
|
||||
.altms-clear:hover { background:var(--danger-light); }
|
||||
|
||||
/* ── filter bar ── */
|
||||
.alt-filter-bar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:36px; padding:14px 20px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); }
|
||||
.alt-filter-label { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); white-space:nowrap; }
|
||||
.alt-filter-sep { width:1px; height:20px; background:var(--border); flex-shrink:0; }
|
||||
.alt-filter-reset { margin-inline-start:auto; font-size:.8125rem; color:var(--text-muted); background:none; border:none; cursor:pointer; padding:4px 6px; transition:color .15s; font-family:${L.bodyFont}; }
|
||||
.alt-filter-reset:hover { color:var(--danger); }
|
||||
|
||||
/* ── metrics ── */
|
||||
.alt-metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:1px; background:var(--border); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; margin-bottom:40px; }
|
||||
.alt-metric { background:var(--surface); padding:24px 22px; }
|
||||
.alt-metric-title { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:0 0 12px; }
|
||||
.alt-metric-value { font-family:${L.displayFont}; font-size:1.875rem; font-weight:400; color:var(--text-primary); line-height:1; margin-bottom:10px; letter-spacing:-.02em; }
|
||||
.alt-metric-footer { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.alt-change { font-size:.75rem; font-weight:600; padding:2px 8px; border-radius:20px; white-space:nowrap; font-family:${L.bodyFont}; }
|
||||
.alt-change--up { background:var(--success-light); color:var(--success); }
|
||||
.alt-change--down { background:var(--danger-light); color:var(--danger); }
|
||||
.alt-change--flat { background:var(--muted-light); color:var(--text-muted); }
|
||||
.alt-metric-prev { font-size:.75rem; color:var(--text-muted); font-family:${L.monoFont}; }
|
||||
|
||||
/* ── charts ── */
|
||||
.alt-charts { display:grid; grid-template-columns:1fr; gap:24px; }
|
||||
.alt-chart-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:28px 28px 24px; min-width:0; overflow:hidden; }
|
||||
.alt-chart-header { display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:24px; gap:16px; flex-wrap:wrap; }
|
||||
.alt-chart-title { font-family:${L.displayFont}; font-size:1.375rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; font-style:italic; }
|
||||
.alt-chart-controls { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.alt-ctrl { font-family:${L.bodyFont}; font-size:.75rem; font-weight:500; padding:4px 10px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text-secondary); cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
|
||||
.alt-ctrl:hover { border-color:var(--accent); color:var(--accent); }
|
||||
.alt-ctrl-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; }
|
||||
.alt-ctrl-sep { width:1px; height:20px; background:var(--border); align-self:center; }
|
||||
.alt-chart-wrap { position:relative; height:280px; overflow:hidden; direction:ltr; width:100%; }
|
||||
|
||||
/* ── section heading ── */
|
||||
.alt-section-heading { display:flex; align-items:center; gap:12px; margin:0 0 20px; }
|
||||
.alt-section-heading h2 { font-family:${L.displayFont}; font-size:1.375rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; }
|
||||
.alt-section-heading::after { content:''; flex:1; height:1px; background:var(--border); }
|
||||
|
||||
/* ── responsive ── */
|
||||
@media (max-width:680px) {
|
||||
.alt-period-row { grid-template-columns:1fr; }
|
||||
.alt-card--current,.alt-card--previous { border-radius:var(--radius); }
|
||||
.alt-vs { flex-direction:row; padding:10px 0; }
|
||||
.alt-vs-line { position:static; width:100%; height:1px; }
|
||||
.alt-period-name { font-size:1.75rem; }
|
||||
.alt-metrics { grid-template-columns:1fr 1fr; }
|
||||
.alt-page-title { font-size:1.75rem; }
|
||||
.alt-chart-header { flex-direction:column; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
className="alt-page"
|
||||
dir={L.dir}
|
||||
style={{
|
||||
'--alt-body-font': L.bodyFont,
|
||||
'--alt-display-font': L.displayFont,
|
||||
'--alt-mono-font': L.monoFont,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Link to={L.backTo} className="alt-back">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{ transform: L.dir==='rtl' ? 'scaleX(-1)' : undefined }}>
|
||||
<path d="M9 2L4 7L9 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
@@ -633,16 +233,22 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
||||
onChange={(s,e) => { setPrevStart(s); setPrevEnd(e); }} availableYears={availableYears} L={L} />
|
||||
</div>
|
||||
|
||||
<div className="alt-filter-bar">
|
||||
<div className={`alt-filter-bar${filtersOpen ? ' alt-filter-bar--open' : ''}`}>
|
||||
<div className="alt-filter-head">
|
||||
<span className="alt-filter-label">{L.filter}</span>
|
||||
{activeFilterCount > 0 && <span className="alt-filter-badge">{activeFilterCount}</span>}
|
||||
<button type="button" className="alt-filter-toggle" onClick={() => setFiltersOpen(v => !v)} aria-expanded={filtersOpen} aria-label="Toggle filters">
|
||||
<svg className={`alt-filter-chevron${filtersOpen ? ' alt-filter-chevron--open' : ''}`} width="14" height="14" 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>
|
||||
</div>
|
||||
<div className="alt-filter-body">
|
||||
<div className="alt-filter-sep" />
|
||||
<AltMultiSelect value={selDistricts} options={districts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
|
||||
<AltMultiSelect value={selChannels} options={channels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
|
||||
<AltMultiSelect value={selMuseums} options={museums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} />
|
||||
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
|
||||
<div className="alt-vat-toggle" style={{ marginInlineStart: 'auto' }}>
|
||||
<button type="button" className={`alt-vat-opt${activeLang==='en'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('en')}>EN</button>
|
||||
<button type="button" className={`alt-vat-opt${activeLang==='ar'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('ar')}>AR</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -663,9 +269,9 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
||||
<div className="alt-chart-header">
|
||||
<h3 className="alt-chart-title">{L.trendTitle}</h3>
|
||||
<div className="alt-chart-controls">
|
||||
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||
<div className="alt-ctrl-sep" />
|
||||
{granOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
||||
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="alt-chart-wrap"><Line data={trendData} options={chartOpts} /></div>
|
||||
@@ -674,7 +280,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
||||
<div className="alt-chart-header">
|
||||
<h3 className="alt-chart-title">{L.museumTitle}</h3>
|
||||
<div className="alt-chart-controls">
|
||||
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="alt-chart-wrap"><Bar data={museumData} options={chartOpts} /></div>
|
||||
|
||||
+49
-464
@@ -1,8 +1,7 @@
|
||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Line, Bar, Pie } from 'react-chartjs-2';
|
||||
import {
|
||||
filterDataByDateRange, calculateMetrics, formatCurrency, formatNumber,
|
||||
filterDataByDateRange, calculateMetrics,
|
||||
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
|
||||
groupByMuseum, groupByChannel, groupByDistrict,
|
||||
umrahData,
|
||||
@@ -10,6 +9,11 @@ import {
|
||||
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
|
||||
import type { MuseumRecord, Season } from '../types';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { EN, AR } from '../lib/locale';
|
||||
import { currentMonth, shiftYear } from '../lib/dateHelpers';
|
||||
import PeriodHero from './shared/PeriodPicker';
|
||||
import AltMultiSelect from './shared/AltMultiSelect';
|
||||
import MetricCard from './shared/MetricCard';
|
||||
|
||||
interface Props {
|
||||
data: MuseumRecord[];
|
||||
@@ -21,325 +25,6 @@ interface Props {
|
||||
lang?: 'en' | 'ar';
|
||||
}
|
||||
|
||||
// ─── language config ──────────────────────────────────────────────
|
||||
interface LC {
|
||||
dir: 'ltr' | 'rtl';
|
||||
fontImport: string;
|
||||
bodyFont: string;
|
||||
displayFont: string;
|
||||
monoFont: string;
|
||||
monthFull: string[];
|
||||
monthShort: string[];
|
||||
periods: Record<string, string>;
|
||||
fullYearLabel: (y: number) => string;
|
||||
dateRangeSep: string;
|
||||
backLink: string;
|
||||
backTo: string;
|
||||
pageTitle: string;
|
||||
pageSub: string;
|
||||
changePeriod: string;
|
||||
close: string;
|
||||
apply: string;
|
||||
filter: string;
|
||||
allDistricts: string; allChannels: string; allMuseums: string;
|
||||
countDistricts: (n: number) => string;
|
||||
countChannels: (n: number) => string;
|
||||
countMuseums: (n: number) => string;
|
||||
reset: string;
|
||||
exclVAT: string; inclVAT: string;
|
||||
keyMetrics: string;
|
||||
revenue: string; visitors: string; tickets: string; avgRev: string;
|
||||
pilgrims: string; captureRate: string;
|
||||
charts: string;
|
||||
trendTitle: string; museumTitle: string; channelTitle: string; districtTitle: string;
|
||||
daily: string; weekly: string; monthly: string;
|
||||
newLabel: string;
|
||||
clearSel: string;
|
||||
monthSection: string; periodSection: string;
|
||||
from: string; to: string;
|
||||
vsLabel: string;
|
||||
barLabel: string; pieLabel: string;
|
||||
absLabel: string; pctLabel: string;
|
||||
}
|
||||
|
||||
const EN: LC = {
|
||||
dir: 'ltr',
|
||||
fontImport: `@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&display=swap');`,
|
||||
bodyFont: "'Outfit', sans-serif",
|
||||
displayFont: "'DM Serif Display', serif",
|
||||
monoFont: "ui-monospace, 'Cascadia Code', monospace",
|
||||
monthFull: ['January','February','March','April','May','June','July','August','September','October','November','December'],
|
||||
monthShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
|
||||
periods: { q1:'Q1', q2:'Q2', q3:'Q3', q4:'Q4', h1:'H1', h2:'H2', full:'Full Year' },
|
||||
fullYearLabel: (y) => String(y),
|
||||
dateRangeSep: '→',
|
||||
backLink: 'Back to Dashboard', backTo: '/',
|
||||
pageTitle: 'Overview', pageSub: 'Museum performance at a glance.',
|
||||
changePeriod: 'Change period', close: 'Cancel', apply: 'Apply',
|
||||
filter: 'Filter',
|
||||
allDistricts: 'All districts', allChannels: 'All channels', allMuseums: 'All museums',
|
||||
countDistricts: (n) => `${n} districts`,
|
||||
countChannels: (n) => `${n} channels`,
|
||||
countMuseums: (n) => `${n} museums`,
|
||||
reset: 'Reset', exclVAT: 'Excl. VAT', inclVAT: 'Incl. VAT',
|
||||
keyMetrics: 'Key Metrics',
|
||||
revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets',
|
||||
avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %',
|
||||
charts: 'Charts',
|
||||
trendTitle: 'Trend over time', museumTitle: 'By museum',
|
||||
channelTitle: 'By channel', districtTitle: 'By district',
|
||||
daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
|
||||
newLabel: 'New', clearSel: 'Clear selection',
|
||||
monthSection: 'Month', periodSection: 'Quarter · Half · Year',
|
||||
from: 'From', to: 'To', vsLabel: 'vs',
|
||||
barLabel: 'Bar', pieLabel: 'Pie', absLabel: '#', pctLabel: '%',
|
||||
};
|
||||
|
||||
const AR: LC = {
|
||||
dir: 'rtl',
|
||||
fontImport: `@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');`,
|
||||
bodyFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||
displayFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||
monoFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||
monthFull: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'],
|
||||
monthShort: ['ينا','فبر','مار','أبر','ماي','يون','يول','أغس','سبت','أكت','نوف','ديس'],
|
||||
periods: { q1:'ر١', q2:'ر٢', q3:'ر٣', q4:'ر٤', h1:'ن١', h2:'ن٢', full:'السنة' },
|
||||
fullYearLabel: (y) => `${y} كاملاً`,
|
||||
dateRangeSep: '–',
|
||||
backLink: 'العودة إلى لوحة التحكم', backTo: '/ar',
|
||||
pageTitle: 'نظرة عامة', pageSub: 'أداء المتاحف في لمحة.',
|
||||
changePeriod: 'تغيير الفترة', close: 'إلغاء', apply: 'تطبيق',
|
||||
filter: 'تصفية',
|
||||
allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف',
|
||||
countDistricts: (n) => `${n} مناطق`,
|
||||
countChannels: (n) => `${n} قنوات`,
|
||||
countMuseums: (n) => `${n} متاحف`,
|
||||
reset: 'إعادة ضبط', exclVAT: 'بدون ضريبة', inclVAT: 'مع ضريبة',
|
||||
keyMetrics: 'المؤشرات الرئيسية',
|
||||
revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر',
|
||||
avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %',
|
||||
charts: 'المخططات',
|
||||
trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف',
|
||||
channelTitle: 'حسب القناة', districtTitle: 'حسب المنطقة',
|
||||
daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري',
|
||||
newLabel: 'جديد', clearSel: 'مسح التحديد',
|
||||
monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة',
|
||||
from: 'من', to: 'إلى', vsLabel: 'مقابل',
|
||||
barLabel: 'أعمدة', pieLabel: 'دائرة', absLabel: '#', pctLabel: '%',
|
||||
};
|
||||
|
||||
// ─── date helpers ─────────────────────────────────────────────────
|
||||
const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
|
||||
|
||||
function isLeap(y: number) { return (y%4===0 && y%100!==0) || y%400===0; }
|
||||
|
||||
function makePresets(y: number): Record<string, { start: string; end: string }> {
|
||||
const feb = isLeap(y) ? 29 : 28;
|
||||
return {
|
||||
jan:{start:`${y}-01-01`,end:`${y}-01-31`}, feb:{start:`${y}-02-01`,end:`${y}-02-${String(feb).padStart(2,'0')}`},
|
||||
mar:{start:`${y}-03-01`,end:`${y}-03-31`}, apr:{start:`${y}-04-01`,end:`${y}-04-30`},
|
||||
may:{start:`${y}-05-01`,end:`${y}-05-31`}, jun:{start:`${y}-06-01`,end:`${y}-06-30`},
|
||||
jul:{start:`${y}-07-01`,end:`${y}-07-31`}, aug:{start:`${y}-08-01`,end:`${y}-08-31`},
|
||||
sep:{start:`${y}-09-01`,end:`${y}-09-30`}, oct:{start:`${y}-10-01`,end:`${y}-10-31`},
|
||||
nov:{start:`${y}-11-01`,end:`${y}-11-30`}, dec:{start:`${y}-12-01`,end:`${y}-12-31`},
|
||||
q1:{start:`${y}-01-01`,end:`${y}-03-31`}, q2:{start:`${y}-04-01`,end:`${y}-06-30`},
|
||||
q3:{start:`${y}-07-01`,end:`${y}-09-30`}, q4:{start:`${y}-10-01`,end:`${y}-12-31`},
|
||||
h1:{start:`${y}-01-01`,end:`${y}-06-30`}, h2:{start:`${y}-07-01`,end:`${y}-12-31`},
|
||||
full:{start:`${y}-01-01`,end:`${y}-12-31`},
|
||||
};
|
||||
}
|
||||
|
||||
function guessPreset(start: string, end: string) {
|
||||
const year = parseInt(start.slice(0,4));
|
||||
const presets = makePresets(year);
|
||||
for (const [key, r] of Object.entries(presets)) {
|
||||
if (r.start === start && r.end === end) return { key, year };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function periodNameL(start: string, end: string, L: LC): string {
|
||||
const year = parseInt(start.slice(0,4));
|
||||
const g = guessPreset(start, end);
|
||||
if (!g) {
|
||||
const fmt = (d: string) => { const [,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]}`; };
|
||||
const ey = parseInt(end.slice(0,4));
|
||||
return year === ey ? `${fmt(start)} – ${fmt(end)} ${year}` : `${fmt(start)} ${year} – ${fmt(end)} ${ey}`;
|
||||
}
|
||||
const mi = MONTH_KEYS.indexOf(g.key);
|
||||
if (mi >= 0) return `${L.monthFull[mi]} ${g.year}`;
|
||||
if (g.key === 'full') return L.fullYearLabel(g.year);
|
||||
return `${L.periods[g.key] ?? g.key.toUpperCase()} ${g.year}`;
|
||||
}
|
||||
|
||||
function dateRangeTextL(start: string, end: string, L: LC): string {
|
||||
const fmt = (d: string) => { const [y,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]} ${y}`; };
|
||||
return `${fmt(start)} ${L.dateRangeSep} ${fmt(end)}`;
|
||||
}
|
||||
|
||||
function currentMonth() {
|
||||
const now = new Date(); const y = now.getFullYear(), m = now.getMonth()+1;
|
||||
const p = (n: number) => String(n).padStart(2,'0');
|
||||
return { start: `${y}-${p(m)}-01`, end: `${y}-${p(m)}-${p(new Date(y, m, 0).getDate())}` };
|
||||
}
|
||||
function shiftYear(s: string) { return s.replace(/^(\d{4})/, (_,y) => String(parseInt(y)-1)); }
|
||||
|
||||
// ─── inline picker ────────────────────────────────────────────────
|
||||
function InlinePicker({ start, end, onChange, onClose, availableYears, L }: {
|
||||
start: string; end: string; onChange: (s: string, e: string) => void;
|
||||
onClose: () => void;
|
||||
availableYears: number[]; L: LC;
|
||||
}) {
|
||||
const g = guessPreset(start, end);
|
||||
const [year, setYear] = useState(g?.year ?? parseInt(start.slice(0,4)));
|
||||
const [active, setActive] = useState<string|null>(g?.key ?? null);
|
||||
const [draftStart, setDraftStart] = useState(start);
|
||||
const [draftEnd, setDraftEnd] = useState(end);
|
||||
const minY = Math.min(...availableYears), maxY = Math.max(...availableYears);
|
||||
|
||||
const pick = (key: string) => { const r = makePresets(year)[key]; if (!r) return; setActive(key); setDraftStart(r.start); setDraftEnd(r.end); };
|
||||
const shift = (d: number) => {
|
||||
const ny = year+d; if (ny < minY || ny > maxY) return; setYear(ny);
|
||||
if (active && makePresets(ny)[active]) { setDraftStart(makePresets(ny)[active].start); setDraftEnd(makePresets(ny)[active].end); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="alt-picker">
|
||||
<div className="alt-picker-year">
|
||||
<button type="button" onClick={() => shift(L.dir==='rtl' ? 1 : -1)} disabled={L.dir==='rtl' ? year>=maxY : year<=minY} className="alt-yr-btn">
|
||||
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M5.5 9.5L1.5 5.5L5.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
<span className="alt-yr-val">{year}</span>
|
||||
<button type="button" onClick={() => shift(L.dir==='rtl' ? -1 : 1)} disabled={L.dir==='rtl' ? year<=minY : year>=maxY} className="alt-yr-btn">
|
||||
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M1.5 1.5L5.5 5.5L1.5 9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="alt-picker-section">{L.monthSection}</p>
|
||||
<div className="alt-chips">
|
||||
{MONTH_KEYS.map((k,i) => (
|
||||
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.monthShort[i]}</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="alt-picker-section">{L.periodSection}</p>
|
||||
<div className="alt-chips">
|
||||
{['q1','q2','q3','q4','h1','h2'].map(k => (
|
||||
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.periods[k]}</button>
|
||||
))}
|
||||
<button type="button" className={`alt-chip alt-chip-wide${active==='full'?' alt-chip-on':''}`} onClick={() => pick('full')}>{L.periods.full}</button>
|
||||
</div>
|
||||
<div className="alt-picker-div" />
|
||||
<div className="alt-custom">
|
||||
<div className="alt-custom-f"><label>{L.from}</label><input type="date" value={draftStart} onChange={e => { setActive(null); setDraftStart(e.target.value); }} /></div>
|
||||
<span className="alt-custom-arrow">{L.dateRangeSep}</span>
|
||||
<div className="alt-custom-f"><label>{L.to}</label><input type="date" value={draftEnd} onChange={e => { setActive(null); setDraftEnd(e.target.value); }} /></div>
|
||||
</div>
|
||||
<div className="alt-picker-div" />
|
||||
<div className="alt-footer">
|
||||
<button type="button" className="alt-cancel" onClick={onClose}>{L.close}</button>
|
||||
<button type="button" className="alt-apply" onClick={() => { onChange(draftStart, draftEnd); onClose(); }}>{L.apply}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── period hero ──────────────────────────────────────────────────
|
||||
function PeriodHero({ start, end, onChange, availableYears, L }: {
|
||||
start: string; end: string; onChange: (s: string, e: string) => void;
|
||||
availableYears: number[]; L: LC;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onM = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
const onK = (e: KeyboardEvent) => { if (e.key==='Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onM); document.addEventListener('keydown', onK);
|
||||
return () => { document.removeEventListener('mousedown', onM); document.removeEventListener('keydown', onK); };
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="dalt-hero">
|
||||
<div className="dalt-hero-inner">
|
||||
<div>
|
||||
<div className="dalt-hero-name">{periodNameL(start, end, L)}</div>
|
||||
<div className="dalt-hero-range">{dateRangeTextL(start, end, L)}</div>
|
||||
</div>
|
||||
<button type="button" className="dalt-hero-btn" onClick={() => setOpen(v => !v)} aria-expanded={open}>
|
||||
{open ? L.close : L.changePeriod}
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ transform: open?'rotate(180deg)':'none', transition:'transform 0.2s' }}>
|
||||
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{open && <InlinePicker start={start} end={end} onChange={onChange} onClose={() => setOpen(false)} availableYears={availableYears} L={L} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── multi-select ─────────────────────────────────────────────────
|
||||
function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
|
||||
value: string[]; options: string[];
|
||||
onChange: (vals: string[]) => void;
|
||||
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
|
||||
}, [open]);
|
||||
|
||||
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v!==opt) : [...value, opt]);
|
||||
const label = value.length===0 ? allLabel : value.length===1 ? value[0] : countLabel(value.length);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="altms">
|
||||
<button type="button" className={`altms-trigger${value.length>0?' altms-trigger--active':''}`} onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox">
|
||||
<span className="altms-label">{label}</span>
|
||||
<svg className={`altms-chevron${open?' altms-chevron--open':''}`} width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="altms-dropdown" role="listbox" aria-multiselectable="true">
|
||||
<div className="altms-list">
|
||||
{options.map(opt => (
|
||||
<label key={opt} className={`altms-option${value.includes(opt)?' altms-option--checked':''}`}>
|
||||
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
|
||||
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
|
||||
<span className="altms-opt-label">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{value.length>0 && <button type="button" className="altms-clear" onClick={() => { onChange([]); setOpen(false); }}>{clearLabel}</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── metric card ──────────────────────────────────────────────────
|
||||
function MetricCard({ title, curr, prev, isCurrency, newLabel }: {
|
||||
title: string; curr: number; prev: number; isCurrency?: boolean; newLabel?: string;
|
||||
}) {
|
||||
const fmt = (n: number) => isCurrency ? formatCurrency(n) : formatNumber(n);
|
||||
const change = prev===0 ? (curr>0 ? Infinity : 0) : ((curr-prev)/prev*100);
|
||||
const isPos = change>0, isNeg = change<0;
|
||||
return (
|
||||
<div className="alt-metric">
|
||||
<p className="alt-metric-title">{title}</p>
|
||||
<div className="alt-metric-value">{fmt(curr)}</div>
|
||||
<div className="alt-metric-footer">
|
||||
{isFinite(change)
|
||||
? <span className={`alt-change ${isPos?'alt-change--up':isNeg?'alt-change--down':'alt-change--flat'}`}>{isPos?'▲':isNeg?'▼':'—'} {Math.abs(change).toFixed(1)}%</span>
|
||||
: <span className="alt-change alt-change--up">{newLabel??'New'}</span>}
|
||||
<span className="alt-metric-prev">{fmt(prev)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── main page ────────────────────────────────────────────────────
|
||||
export default function DashboardDemo({ data, seasons: _seasons, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: Props) {
|
||||
const { lang: activeLang, setLanguage } = useLanguage();
|
||||
@@ -481,9 +166,12 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
|
||||
|
||||
const baseOpts = useMemo(() => createBaseOptions(false), []);
|
||||
const { chartOpts, barHorizOpts, barNoLegend } = useMemo(() => {
|
||||
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:10, padding:10, font:{ size:11 } } } } };
|
||||
const barHorizOpts: any = { ...chartOpts, indexAxis:'y', plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
|
||||
const barNoLegend: any = { ...chartOpts, plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
|
||||
return { chartOpts, barHorizOpts, barNoLegend };
|
||||
}, [baseOpts]);
|
||||
const pieOptions: any = useMemo(() => ({
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
@@ -496,148 +184,45 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
const metricOpts = [{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors }, { value:'tickets', label:L.tickets }];
|
||||
const granOpts = [{ value:'day', label:L.daily }, { value:'week', label:L.weekly }, { value:'month', label:L.monthly }];
|
||||
const hasFilters = selDistricts.length>0 || selChannels.length>0 || selMuseums.length>0;
|
||||
const activeFilterCount = selDistricts.length + selChannels.length + selMuseums.length;
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="alt-page" dir={L.dir}>
|
||||
<style>{`
|
||||
${L.fontImport}
|
||||
|
||||
.alt-page { max-width:1100px; margin:0 auto; padding:48px 24px 80px; font-family:${L.bodyFont}; width:100%; box-sizing:border-box; }
|
||||
|
||||
/* ── header ── */
|
||||
.alt-back { display:inline-flex; align-items:center; gap:6px; font-size:.8125rem; color:var(--text-muted); text-decoration:none; margin-bottom:28px; transition:color .15s; }
|
||||
.alt-back:hover { color:var(--accent); }
|
||||
.alt-page-title { font-family:${L.displayFont}; font-size:2.25rem; font-weight:400; color:var(--text-primary); margin:0 0 6px; letter-spacing:-.03em; line-height:1.15; }
|
||||
.alt-page-sub { font-size:.9375rem; color:var(--text-muted); margin:0 0 40px; font-weight:300; }
|
||||
|
||||
/* ── hero ── */
|
||||
.dalt-hero { border:1px solid var(--border); border-radius:var(--radius); background:var(--surface); overflow:hidden; margin-bottom:24px; }
|
||||
.dalt-hero-inner { display:flex; align-items:center; justify-content:space-between; padding:24px 28px; gap:16px; flex-wrap:wrap; }
|
||||
.dalt-hero-name { font-family:${L.displayFont}; font-size:2.5rem; font-weight:400; color:var(--text-primary); line-height:1; letter-spacing:-.025em; margin-bottom:6px; }
|
||||
.dalt-hero-range { font-family:${L.monoFont}; font-size:.875rem; color:var(--text-muted); letter-spacing:.01em; }
|
||||
.dalt-hero-btn { display:inline-flex; align-items:center; gap:5px; font-family:${L.bodyFont}; font-size:.8125rem; font-weight:500; color:var(--text-muted); background:none; border:1px solid var(--border); border-radius:8px; padding:7px 12px; cursor:pointer; transition:color .15s,border-color .15s; white-space:nowrap; }
|
||||
.dalt-hero-btn:hover { color:var(--accent); border-color:var(--accent); }
|
||||
|
||||
/* ── picker ── */
|
||||
.alt-picker { border-top:1px solid var(--border); padding:16px 24px 20px; background:var(--bg); animation:altPickIn 180ms cubic-bezier(.16,1,.3,1); }
|
||||
@keyframes altPickIn { from{opacity:0;transform:translateY(-8px)} to{opacity:1;transform:translateY(0)} }
|
||||
.alt-picker-year { display:flex; align-items:center; gap:16px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--border); }
|
||||
.alt-yr-val { font-family:${L.displayFont}; font-size:1.25rem; color:var(--text-primary); min-width:50px; text-align:center; }
|
||||
.alt-yr-btn { width:28px; height:28px; display:flex; align-items:center; justify-content:center; border:1px solid var(--border); border-radius:7px; background:var(--surface); color:var(--text-secondary); cursor:pointer; transition:background .12s,border-color .12s,color .12s; }
|
||||
.alt-yr-btn:hover:not(:disabled) { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
|
||||
.alt-yr-btn:disabled { opacity:.3; cursor:not-allowed; }
|
||||
.alt-picker-section { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:10px 0 6px; }
|
||||
.alt-chips { display:flex; flex-wrap:wrap; gap:5px; margin-bottom:4px; }
|
||||
.alt-chip { font-family:${L.bodyFont}; padding:4px 9px; border:1px solid var(--border); border-radius:6px; background:var(--surface); color:var(--text-secondary); font-size:.8rem; font-weight:500; cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
|
||||
.alt-chip:hover { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
|
||||
.alt-chip-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; font-weight:600!important; }
|
||||
.alt-chip-wide { padding-left:14px; padding-right:14px; }
|
||||
.alt-picker-div { height:1px; background:var(--border); margin:12px 0 10px; }
|
||||
.alt-custom { display:flex; align-items:flex-end; gap:8px; }
|
||||
.alt-custom-f { flex:1; display:flex; flex-direction:column; gap:4px; }
|
||||
.alt-custom-f label { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--text-muted); }
|
||||
.alt-custom-f input[type="date"] { padding:7px 9px; border:1px solid var(--border); border-radius:7px; font-size:.825rem; background:var(--surface); color:var(--text-primary); width:100%; }
|
||||
.alt-custom-f input[type="date"]:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 2px rgba(37,99,235,.12); }
|
||||
.alt-custom-arrow { font-size:.75rem; color:var(--text-muted); padding-bottom:9px; flex-shrink:0; }
|
||||
.alt-footer { display:flex; justify-content:flex-end; gap:8px; }
|
||||
.alt-cancel,.alt-apply { padding:7px 16px; border-radius:7px; font-size:.825rem; font-weight:600; cursor:pointer; font-family:${L.bodyFont}; transition:background .12s,color .12s; }
|
||||
.alt-cancel { background:transparent; border:1px solid var(--border); color:var(--text-secondary); }
|
||||
.alt-cancel:hover { background:var(--bg-secondary); }
|
||||
.alt-apply { background:var(--accent); border:1px solid transparent; color:#fff; }
|
||||
.alt-apply:hover { opacity:.88; }
|
||||
|
||||
/* ── multi-select ── */
|
||||
.altms { position:relative; }
|
||||
.altms-trigger { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border:1px solid var(--border); border-radius:8px; background:var(--surface); color:var(--text-secondary); font-family:${L.bodyFont}; font-size:.875rem; cursor:pointer; transition:border-color .15s,color .15s,background .15s; white-space:nowrap; }
|
||||
.altms-trigger:hover { border-color:var(--accent); color:var(--accent); }
|
||||
.altms-trigger--active { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
|
||||
.altms-label { max-width:140px; overflow:hidden; text-overflow:ellipsis; }
|
||||
.altms-chevron { transition:transform .18s; flex-shrink:0; color:currentColor; }
|
||||
.altms-chevron--open { transform:rotate(180deg); }
|
||||
.altms-dropdown { position:absolute; top:calc(100% + 6px); left:0; z-index:200; min-width:200px; background:var(--surface); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.12); overflow:hidden; animation:altPickIn 140ms cubic-bezier(.16,1,.3,1); }
|
||||
[dir="rtl"] .altms-dropdown { left:auto; right:0; }
|
||||
.altms-list { max-height:220px; overflow-y:auto; padding:6px; display:flex; flex-direction:column; gap:2px; }
|
||||
.altms-option { display:flex; align-items:center; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; transition:background .1s; }
|
||||
.altms-option:hover { background:var(--bg); }
|
||||
.altms-option--checked { background:var(--accent-light); }
|
||||
.altms-check { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
|
||||
.altms-check-box { width:16px; height:16px; border:1.5px solid var(--border); border-radius:4px; display:flex; align-items:center; justify-content:center; flex-shrink:0; transition:background .1s,border-color .1s; }
|
||||
.altms-option--checked .altms-check-box { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
|
||||
.altms-opt-label { font-family:${L.bodyFont}; font-size:.875rem; color:var(--text-primary); }
|
||||
.altms-clear { width:100%; padding:8px 14px; border-top:1px solid var(--border); background:none; border-left:none; border-right:none; border-bottom:none; font-family:${L.bodyFont}; font-size:.8125rem; color:var(--danger); cursor:pointer; text-align:start; transition:background .1s; }
|
||||
.altms-clear:hover { background:var(--danger-light); }
|
||||
|
||||
/* ── filter bar ── */
|
||||
.alt-filter-bar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:32px; padding:14px 20px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); }
|
||||
.alt-filter-label { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); white-space:nowrap; }
|
||||
.alt-filter-sep { width:1px; height:20px; background:var(--border); flex-shrink:0; }
|
||||
.alt-vat-toggle { margin-inline-start:auto; display:flex; align-items:center; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
|
||||
.alt-vat-opt { font-family:${L.bodyFont}; font-size:.75rem; font-weight:500; padding:5px 10px; background:var(--surface); color:var(--text-muted); cursor:pointer; border:none; transition:background .1s,color .1s; }
|
||||
.alt-vat-opt--on { background:var(--accent); color:var(--text-inverse); }
|
||||
.alt-filter-reset { font-size:.8125rem; color:var(--text-muted); background:none; border:none; cursor:pointer; padding:4px 6px; transition:color .15s; font-family:${L.bodyFont}; }
|
||||
.alt-filter-reset:hover { color:var(--danger); }
|
||||
|
||||
/* ── metrics ── */
|
||||
.alt-metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:1px; background:var(--border); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; margin-bottom:40px; }
|
||||
.alt-metric { background:var(--surface); padding:24px 22px; }
|
||||
.alt-metric-title { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:0 0 12px; }
|
||||
.alt-metric-value { font-family:${L.displayFont}; font-size:1.875rem; font-weight:400; color:var(--text-primary); line-height:1; margin-bottom:10px; letter-spacing:-.02em; }
|
||||
.alt-metric-footer { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.alt-change { font-size:.75rem; font-weight:600; padding:2px 8px; border-radius:20px; white-space:nowrap; font-family:${L.bodyFont}; }
|
||||
.alt-change--up { background:var(--success-light); color:var(--success); }
|
||||
.alt-change--down { background:var(--danger-light); color:var(--danger); }
|
||||
.alt-change--flat { background:var(--muted-light); color:var(--text-muted); }
|
||||
.alt-metric-prev { font-size:.75rem; color:var(--text-muted); font-family:${L.monoFont}; }
|
||||
|
||||
/* ── section heading ── */
|
||||
.alt-section-heading { display:flex; align-items:center; gap:12px; margin:0 0 20px; }
|
||||
.alt-section-heading h2 { font-family:${L.displayFont}; font-size:1.375rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; }
|
||||
.alt-section-heading::after { content:''; flex:1; height:1px; background:var(--border); }
|
||||
|
||||
/* ── charts ── */
|
||||
.dalt-charts-grid { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
|
||||
.dalt-chart-full { grid-column:1/-1; }
|
||||
.alt-chart-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:24px 24px 20px; min-width:0; overflow:hidden; }
|
||||
.alt-chart-header { display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:20px; gap:12px; flex-wrap:wrap; }
|
||||
.alt-chart-title { font-family:${L.displayFont}; font-size:1.25rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; font-style:italic; }
|
||||
.alt-chart-controls { display:flex; gap:5px; flex-wrap:wrap; }
|
||||
.alt-ctrl { font-family:${L.bodyFont}; font-size:.75rem; font-weight:500; padding:4px 10px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text-secondary); cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
|
||||
.alt-ctrl:hover { border-color:var(--accent); color:var(--accent); }
|
||||
.alt-ctrl-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; }
|
||||
.alt-ctrl-sep { width:1px; height:20px; background:var(--border); align-self:center; }
|
||||
.alt-chart-wrap { position:relative; height:260px; overflow:hidden; direction:ltr; width:100%; }
|
||||
.alt-chart-wrap--tall { height:320px; }
|
||||
|
||||
/* ── responsive ── */
|
||||
@media (max-width:700px) {
|
||||
.dalt-hero-name { font-size:1.875rem; }
|
||||
.dalt-charts-grid { grid-template-columns:1fr; }
|
||||
.dalt-chart-full { grid-column:auto; }
|
||||
.alt-metrics { grid-template-columns:1fr 1fr; }
|
||||
.alt-page-title { font-size:1.75rem; }
|
||||
.altms-label { max-width:100px; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
className="alt-page"
|
||||
dir={L.dir}
|
||||
style={{
|
||||
'--alt-body-font': L.bodyFont,
|
||||
'--alt-display-font': L.displayFont,
|
||||
'--alt-mono-font': L.monoFont,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<h1 className="alt-page-title">{L.pageTitle}</h1>
|
||||
<p className="alt-page-sub">{L.pageSub}</p>
|
||||
|
||||
<PeriodHero start={start} end={end} onChange={(s,e) => { setStart(s); setEnd(e); }} availableYears={availableYears} L={L} />
|
||||
|
||||
<div className="alt-filter-bar">
|
||||
<div className={`alt-filter-bar${filtersOpen ? ' alt-filter-bar--open' : ''}`}>
|
||||
<div className="alt-filter-head">
|
||||
<span className="alt-filter-label">{L.filter}</span>
|
||||
{activeFilterCount > 0 && <span className="alt-filter-badge">{activeFilterCount}</span>}
|
||||
<button type="button" className="alt-filter-toggle" onClick={() => setFiltersOpen(v => !v)} aria-expanded={filtersOpen} aria-label="Toggle filters">
|
||||
<svg className={`alt-filter-chevron${filtersOpen ? ' alt-filter-chevron--open' : ''}`} width="14" height="14" 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>
|
||||
</div>
|
||||
<div className="alt-filter-body">
|
||||
<div className="alt-filter-sep" />
|
||||
<AltMultiSelect value={selDistricts} options={allDistricts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
|
||||
<AltMultiSelect value={selChannels} options={allChannels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
|
||||
<AltMultiSelect value={selMuseums} options={allMuseums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} />
|
||||
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
|
||||
<div className="alt-filter-spacer" />
|
||||
<div className="alt-vat-toggle">
|
||||
<button type="button" className={`alt-vat-opt${!includeVAT?' alt-vat-opt--on':''}`} onClick={() => setIncludeVAT(false)}>{L.exclVAT}</button>
|
||||
<button type="button" className={`alt-vat-opt${includeVAT ?' alt-vat-opt--on':''}`} onClick={() => setIncludeVAT(true)}>{L.inclVAT}</button>
|
||||
</div>
|
||||
<div className="alt-vat-toggle">
|
||||
<button type="button" className={`alt-vat-opt${activeLang==='en'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('en')}>EN</button>
|
||||
<button type="button" className={`alt-vat-opt${activeLang==='ar'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('ar')}>AR</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -660,9 +245,9 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
<div className="alt-chart-header">
|
||||
<h3 className="alt-chart-title">{L.trendTitle}</h3>
|
||||
<div className="alt-chart-controls">
|
||||
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||
<div className="alt-ctrl-sep" />
|
||||
{granOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
||||
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={chartOpts} /></div>
|
||||
@@ -672,13 +257,13 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
<div className="alt-chart-header">
|
||||
<h3 className="alt-chart-title">{L.museumTitle}</h3>
|
||||
<div className="alt-chart-controls">
|
||||
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||
<div className="alt-ctrl-sep" />
|
||||
<button type="button" className={`alt-ctrl${museumChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('bar')}>{L.barLabel}</button>
|
||||
<button type="button" className={`alt-ctrl${museumChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('pie')}>{L.pieLabel}</button>
|
||||
<button type="button" aria-pressed={museumChartType==='bar'} className={`alt-ctrl${museumChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('bar')}>{L.barLabel}</button>
|
||||
<button type="button" aria-pressed={museumChartType==='pie'} className={`alt-ctrl${museumChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('pie')}>{L.pieLabel}</button>
|
||||
<div className="alt-ctrl-sep" />
|
||||
<button type="button" className={`alt-ctrl${museumDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('absolute')}>{L.absLabel}</button>
|
||||
<button type="button" className={`alt-ctrl${museumDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('percent')}>{L.pctLabel}</button>
|
||||
<button type="button" aria-pressed={museumDisplayMode==='absolute'} className={`alt-ctrl${museumDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('absolute')}>{L.absLabel}</button>
|
||||
<button type="button" aria-pressed={museumDisplayMode==='percent'} className={`alt-ctrl${museumDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('percent')}>{L.pctLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="alt-chart-wrap alt-chart-wrap--tall">
|
||||
@@ -690,13 +275,13 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
<div className="alt-chart-header">
|
||||
<h3 className="alt-chart-title">{L.channelTitle}</h3>
|
||||
<div className="alt-chart-controls">
|
||||
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||
<div className="alt-ctrl-sep" />
|
||||
<button type="button" className={`alt-ctrl${channelChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('bar')}>{L.barLabel}</button>
|
||||
<button type="button" className={`alt-ctrl${channelChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('pie')}>{L.pieLabel}</button>
|
||||
<button type="button" aria-pressed={channelChartType==='bar'} className={`alt-ctrl${channelChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('bar')}>{L.barLabel}</button>
|
||||
<button type="button" aria-pressed={channelChartType==='pie'} className={`alt-ctrl${channelChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('pie')}>{L.pieLabel}</button>
|
||||
<div className="alt-ctrl-sep" />
|
||||
<button type="button" className={`alt-ctrl${channelDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('absolute')}>{L.absLabel}</button>
|
||||
<button type="button" className={`alt-ctrl${channelDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('percent')}>{L.pctLabel}</button>
|
||||
<button type="button" aria-pressed={channelDisplayMode==='absolute'} className={`alt-ctrl${channelDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('absolute')}>{L.absLabel}</button>
|
||||
<button type="button" aria-pressed={channelDisplayMode==='percent'} className={`alt-ctrl${channelDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('percent')}>{L.pctLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="alt-chart-wrap">
|
||||
@@ -708,13 +293,13 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
<div className="alt-chart-header">
|
||||
<h3 className="alt-chart-title">{L.districtTitle}</h3>
|
||||
<div className="alt-chart-controls">
|
||||
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||
<div className="alt-ctrl-sep" />
|
||||
<button type="button" className={`alt-ctrl${districtChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('bar')}>{L.barLabel}</button>
|
||||
<button type="button" className={`alt-ctrl${districtChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('pie')}>{L.pieLabel}</button>
|
||||
<button type="button" aria-pressed={districtChartType==='bar'} className={`alt-ctrl${districtChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('bar')}>{L.barLabel}</button>
|
||||
<button type="button" aria-pressed={districtChartType==='pie'} className={`alt-ctrl${districtChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('pie')}>{L.pieLabel}</button>
|
||||
<div className="alt-ctrl-sep" />
|
||||
<button type="button" className={`alt-ctrl${districtDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('absolute')}>{L.absLabel}</button>
|
||||
<button type="button" className={`alt-ctrl${districtDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('percent')}>{L.pctLabel}</button>
|
||||
<button type="button" aria-pressed={districtDisplayMode==='absolute'} className={`alt-ctrl${districtDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('absolute')}>{L.absLabel}</button>
|
||||
<button type="button" aria-pressed={districtDisplayMode==='percent'} className={`alt-ctrl${districtDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('percent')}>{L.pctLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="alt-chart-wrap">
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Document, Page, View, Text, Image, StyleSheet
|
||||
} from '@react-pdf/renderer';
|
||||
import { PdfTrendChart, PdfHBarChart, CHART_PALETTE } from './reportCharts';
|
||||
import {
|
||||
ReportData, MuseumDataRow, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary
|
||||
} from './reportHelpers';
|
||||
|
||||
// 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 },
|
||||
// colored header band
|
||||
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 },
|
||||
// white body
|
||||
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', marginBottom: 10 },
|
||||
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18 },
|
||||
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; }
|
||||
function PageHeader({ title, page }: PageHeaderProps) {
|
||||
return (
|
||||
<View style={S.pageHeader}>
|
||||
<Text style={S.pageHeaderLogo}>HiHala Data</Text>
|
||||
<Text style={S.pageHeaderTitle}>{title}</Text>
|
||||
<Text style={S.pageHeaderNum}>{page}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageFooterProps { confidentiality: string; generatedAt: string; }
|
||||
function PageFooter({ confidentiality, generatedAt }: PageFooterProps) {
|
||||
return (
|
||||
<View style={S.pageFooter}>
|
||||
<Text style={S.pageFooterText}>{confidentiality}</Text>
|
||||
<Text style={S.pageFooterText}>Generated {generatedAt}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionProps { title: string; color: string; }
|
||||
function SectionHeading({ title, color }: SectionProps) {
|
||||
return (
|
||||
<View style={[S.sectionHeading, { backgroundColor: color }]}>
|
||||
<Text>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props { data: ReportData; }
|
||||
|
||||
export function ReportDocument({ data }: Props) {
|
||||
const { config: cfg, metrics, prevMetrics, comparisonPeriodLabel,
|
||||
trendLabels, trendCurrent, trendPrevious,
|
||||
museumData, channelBreakdown, districtBreakdown,
|
||||
pilgrimCapture, generatedAt } = data;
|
||||
|
||||
const lang = cfg.language;
|
||||
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;
|
||||
|
||||
// Chart width adapts to orientation
|
||||
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 trendTitle = cfg.trendMetric === 'visitors' ? T.trendVisitors
|
||||
: cfg.trendMetric === 'tickets' ? T.trendTickets
|
||||
: T.trendRevenue;
|
||||
|
||||
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]}>
|
||||
{/* Colored header band */}
|
||||
<View style={[S.coverHeader, { backgroundColor: color }]}>
|
||||
<View style={S.coverHeaderTop}>
|
||||
<Text style={S.coverBrand}>HiHala Data</Text>
|
||||
{cfg.clientLogoBase64 && (
|
||||
<View style={S.coverLogoBox}>
|
||||
<Image src={cfg.clientLogoBase64} style={S.coverClientLogo} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={S.coverTitle}>{cfg.title || T.defaultTitle}</Text>
|
||||
</View>
|
||||
|
||||
{/* White body */}
|
||||
<View style={S.coverBody}>
|
||||
{cfg.clientName && (
|
||||
<Text style={S.coverClientName}>{T.preparedFor}: {cfg.clientName}</Text>
|
||||
)}
|
||||
{cfg.contactName && (
|
||||
<Text style={S.coverContactName}>{T.attention}: {cfg.contactName}</Text>
|
||||
)}
|
||||
<View style={S.coverBodySpacer} />
|
||||
<View style={S.coverPeriodRow}>
|
||||
<View style={[S.coverPeriodDot, { backgroundColor: color }]} />
|
||||
<Text style={S.coverPeriod}>{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]}>
|
||||
<PageHeader title={cfg.title || T.defaultTitle} page={mainPg} />
|
||||
|
||||
{cfg.showExecutiveSummary && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.execSummary} color={color} />
|
||||
<Text style={S.summaryText}>{generateExecutiveSummary(data)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{cfg.showMetricsTable && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={`${T.keyMetrics} — ${cfg.includeVAT ? T.inclVAT : T.exclVAT}`} color={color} />
|
||||
<View style={S.metricsTable}>
|
||||
<View style={S.metricsHeaderRow}>
|
||||
<Text style={S.metricsHeaderLabel}> </Text>
|
||||
<Text style={S.metricsHeaderCell}>{period}</Text>
|
||||
{prevMetrics && <Text style={S.metricsHeaderCell}>{comparisonPeriodLabel}</Text>}
|
||||
{prevMetrics && <Text style={S.metricsHeaderCell}>{T.change}</Text>}
|
||||
</View>
|
||||
{metricsRows.map((row, i) => (
|
||||
<View key={row.label} style={[S.metricsRow, i % 2 === 1 ? S.metricsRowAlt : {}]}>
|
||||
<Text style={S.metricsLabel}>{row.label}</Text>
|
||||
<Text style={S.metricsValue}>{row.curr}</Text>
|
||||
{prevMetrics && <Text style={S.metricsValue}>{row.prev ?? '—'}</Text>}
|
||||
{prevMetrics && row.chg !== null && (
|
||||
<Text style={[S.metricsChange, row.chg >= 0 ? S.metricsChangeUp : S.metricsChangeDown]}>
|
||||
{row.chg >= 0 ? '+' : '-'}{formatPct(Math.abs(row.chg))}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{cfg.showTrendChart && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={trendTitle} color={color} />
|
||||
{cfg.includeComparison && (
|
||||
<View style={S.legendRow}>
|
||||
<View style={S.legendItem}>
|
||||
<View style={[S.legendDot, { backgroundColor: color }]} />
|
||||
<Text style={S.legendLabel}>{period}</Text>
|
||||
</View>
|
||||
<View style={S.legendItem}>
|
||||
<View style={[S.legendDot, { backgroundColor: '#94a3b8' }]} />
|
||||
<Text style={S.legendLabel}>{comparisonPeriodLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<View style={S.chartWrap}>
|
||||
<PdfTrendChart labels={trendLabels} current={trendCurrent}
|
||||
previous={trendPrevious} color={color} width={chartW} height={155} />
|
||||
</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]}>
|
||||
<PageHeader title={cfg.title || T.defaultTitle} page={museumPg} />
|
||||
<SectionHeading title={T.museumBreakdowns} color={color} />
|
||||
|
||||
{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}>{row.name}</Text>
|
||||
{hasPrev && (
|
||||
<Text style={S.museumIntroText}>
|
||||
{museumIntro(row, lang, comparisonPeriodLabel)}
|
||||
</Text>
|
||||
)}
|
||||
<View style={S.miniTable}>
|
||||
<View style={S.miniHeaderRow}>
|
||||
<Text style={S.miniHeaderLabel}> </Text>
|
||||
<Text style={S.miniHeaderCell}>{period}</Text>
|
||||
{hasPrev && <Text style={S.miniHeaderCell}>{comparisonPeriodLabel}</Text>}
|
||||
{hasPrev && <Text style={S.miniHeaderChangeCell}>{T.change}</Text>}
|
||||
</View>
|
||||
{mRows.map((mr, ri) => (
|
||||
<View key={mr.label} style={[S.miniRow, ri % 2 === 1 ? S.miniRowAlt : {}]}>
|
||||
<Text style={S.miniLabel}>{mr.label}</Text>
|
||||
<Text style={S.miniValue}>{mr.curr}</Text>
|
||||
{hasPrev && <Text style={S.miniValue}>{mr.prev ?? '—'}</Text>}
|
||||
{hasPrev && mr.chg !== null && (
|
||||
<Text style={[S.miniChange, mr.chg >= 0 ? S.miniChangeUp : S.miniChangeDown]}>
|
||||
{mr.chg >= 0 ? '+' : '-'}{formatPct(Math.abs(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]}>
|
||||
<PageHeader title={cfg.title || T.defaultTitle} page={channelPg} />
|
||||
|
||||
{cfg.showChannelRevenue && channelBreakdown.revenue.length > 0 && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.byChannelRevenue} color={color} />
|
||||
<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} />
|
||||
<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} />
|
||||
<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]}>
|
||||
<PageHeader title={cfg.title || T.defaultTitle} page={districtPg} />
|
||||
|
||||
{cfg.showDistrictRevenue && districtBreakdown.revenue.length > 0 && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.byDistrictRevenue} color={color} />
|
||||
<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} />
|
||||
<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} />
|
||||
<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]}>
|
||||
<PageHeader title={cfg.title || T.defaultTitle} page={summaryPg} />
|
||||
<SectionHeading title={T.globalSummary} color={color} />
|
||||
|
||||
<Text style={S.summarySubLabel}>
|
||||
{period} — {T.comparedTo} {comparisonPeriodLabel}
|
||||
</Text>
|
||||
|
||||
<View style={S.summaryHeaderRow}>
|
||||
<Text style={S.summaryHeaderMuseum}>{T.museum}</Text>
|
||||
{cfg.showMuseumRevenue && <>
|
||||
<Text style={S.summaryHeaderMetric}>{T.revenue}</Text>
|
||||
<Text style={S.summaryHeaderDelta}>Δ</Text>
|
||||
</>}
|
||||
{cfg.showMuseumVisitors && <>
|
||||
<Text style={S.summaryHeaderMetric}>{T.visitors}</Text>
|
||||
<Text style={S.summaryHeaderDelta}>Δ</Text>
|
||||
</>}
|
||||
{cfg.showMuseumTickets && <>
|
||||
<Text style={S.summaryHeaderMetric}>{T.tickets}</Text>
|
||||
<Text style={S.summaryHeaderDelta}>Δ</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}>{row.name.length > 30 ? row.name.slice(0, 30) + '…' : row.name}</Text>
|
||||
{cfg.showMuseumRevenue && <>
|
||||
<Text style={S.summaryMetric}>{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]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
||||
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||
</>}
|
||||
{cfg.showMuseumVisitors && <>
|
||||
<Text style={S.summaryMetric}>{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]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
||||
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||
</>}
|
||||
{cfg.showMuseumTickets && <>
|
||||
<Text style={S.summaryMetric}>{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]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
||||
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||
</>}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
<View style={S.summaryTotalRow}>
|
||||
<Text style={S.summaryMuseumTotal}>{T.total}</Text>
|
||||
{cfg.showMuseumRevenue && <>
|
||||
<Text style={S.summaryMetricTotal}>{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]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
||||
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||
</>}
|
||||
{cfg.showMuseumVisitors && <>
|
||||
<Text style={S.summaryMetricTotal}>{metrics.visitors.toLocaleString()}</Text>
|
||||
{prevMetrics ? (() => {
|
||||
const c = pctChange(metrics.visitors, prevMetrics.visitors);
|
||||
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
||||
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||
</>}
|
||||
{cfg.showMuseumTickets && <>
|
||||
<Text style={S.summaryMetricTotal}>{metrics.tickets.toLocaleString()}</Text>
|
||||
{prevMetrics ? (() => {
|
||||
const c = pctChange(metrics.tickets, prevMetrics.tickets);
|
||||
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(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,399 @@
|
||||
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.trendMetric.charAt(0).toUpperCase() + cfg.trendMetric.slice(1)
|
||||
: undefined}
|
||||
>
|
||||
{/* H7: PillGroup instead of <select> for full consistency */}
|
||||
<PillGroup
|
||||
label="Trend metric"
|
||||
options={[
|
||||
{ label: 'Revenue', value: 'revenue' },
|
||||
{ label: 'Visitors', value: 'visitors' },
|
||||
{ label: 'Tickets', value: 'tickets' },
|
||||
]}
|
||||
value={cfg.trendMetric}
|
||||
onChange={v => onChange({ trendMetric: v as TrendMetric })}
|
||||
/>
|
||||
</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,132 @@
|
||||
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;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function PdfTrendChart({ labels, current, previous, color, width = 470, height = 155 }: TrendChartProps) {
|
||||
const allValues = [...current, ...(previous ?? [])].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" />
|
||||
)}
|
||||
|
||||
{/* Current period line */}
|
||||
{current.some(v => v > 0) && (
|
||||
<Polyline points={toPoints(current)}
|
||||
stroke={color} strokeWidth={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,301 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
trendMetric: 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,
|
||||
trendMetric: '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 ReportData {
|
||||
config: ReportConfig;
|
||||
metrics: Metrics;
|
||||
prevMetrics: Metrics | null;
|
||||
comparisonPeriodLabel: string;
|
||||
trendLabels: string[];
|
||||
trendCurrent: number[];
|
||||
trendPrevious: number[] | null;
|
||||
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;
|
||||
}
|
||||
|
||||
function buildTrend(rows: MuseumRecord[], start: string, cfg: ReportConfig): { labels: string[]; values: number[] } {
|
||||
const s = new Date(start);
|
||||
const acc: Record<number, MuseumRecord[]> = {};
|
||||
rows.forEach(r => {
|
||||
if (!r.date) return;
|
||||
const diff = Math.floor((new Date(r.date).getTime() - s.getTime()) / 86400000);
|
||||
const key = Math.floor(diff / 7) + 1;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(r);
|
||||
});
|
||||
const maxK = Math.max(...Object.keys(acc).map(Number), 1);
|
||||
const labels = Array.from({ length: maxK }, (_, i) => `W${i + 1}`);
|
||||
const values = labels.map((_, i) => {
|
||||
const group = acc[i + 1] || [];
|
||||
return group.reduce((s, r) => s + getMetricVal(r, cfg.trendMetric, cfg.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 currTrend = buildTrend(currRows, cfg.startDate, cfg);
|
||||
const prevTrend = cfg.includeComparison ? buildTrend(prevRows, cfg.comparisonStartDate, cfg) : null;
|
||||
const maxLen = Math.max(currTrend.labels.length, prevTrend?.values.length ?? 0);
|
||||
const trendLabels = Array.from({ length: maxLen }, (_, i) => `W${i + 1}`);
|
||||
const trendCurrent = Array.from({ length: maxLen }, (_, i) => currTrend.values[i] ?? 0);
|
||||
const trendPrevious = prevTrend
|
||||
? Array.from({ length: maxLen }, (_, i) => prevTrend.values[i] ?? 0)
|
||||
: null;
|
||||
|
||||
const 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,
|
||||
trendLabels,
|
||||
trendCurrent,
|
||||
trendPrevious,
|
||||
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;
|
||||
}
|
||||
+88
-121
@@ -6,13 +6,13 @@ import type { Season } from '../types';
|
||||
|
||||
const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899'];
|
||||
|
||||
interface SeasonRowProps {
|
||||
interface SeasonItemProps {
|
||||
season: Season;
|
||||
onSave: (id: number, data: Partial<Season>) => 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 [form, setForm] = useState(season);
|
||||
|
||||
@@ -21,48 +21,46 @@ function SeasonRow({ season, onSave, onDelete }: SeasonRowProps) {
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<div className={`settings-item${editing ? ' settings-item--editing' : ''}`}>
|
||||
<div className="settings-item-row">
|
||||
<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>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="editing">
|
||||
<td>
|
||||
<div className="season-edit-name">
|
||||
<input type="text" value={form.Name} onChange={e => setForm({ ...form, Name: e.target.value })} placeholder="Name" />
|
||||
<input type="number" value={form.HijriYear} onChange={e => setForm({ ...form, HijriYear: parseInt(e.target.value) || 0 })} placeholder="Year" style={{ width: 80 }} />
|
||||
<input type="color" value={form.Color} onChange={e => setForm({ ...form, Color: e.target.value })} />
|
||||
</div>
|
||||
</td>
|
||||
<td><input type="date" value={form.StartDate} onChange={e => setForm({ ...form, StartDate: e.target.value })} /></td>
|
||||
<td><input type="date" value={form.EndDate} onChange={e => setForm({ ...form, EndDate: e.target.value })} /></td>
|
||||
<td>
|
||||
<div className="season-actions">
|
||||
{editing && (
|
||||
<div className="settings-item-form">
|
||||
<div className="form-row">
|
||||
<input className="form-input" type="text" value={form.Name} onChange={e => setForm({ ...form, Name: e.target.value })} placeholder="Name" />
|
||||
<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" />
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UserRowProps {
|
||||
interface UserItemProps {
|
||||
user: User;
|
||||
allMuseums: string[];
|
||||
allChannels: string[];
|
||||
@@ -70,7 +68,7 @@ interface UserRowProps {
|
||||
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 [allowedMuseums, setAllowedMuseums] = useState<string[]>(() => {
|
||||
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 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; } })();
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<tr key={user.Id}>
|
||||
<td>{user.Name}</td>
|
||||
<td><code>{user.PIN}</code></td>
|
||||
<td>{user.Role}</td>
|
||||
<td>
|
||||
<div className={`settings-item${editing ? ' settings-item--editing' : ''}`}>
|
||||
<div className="settings-item-row">
|
||||
<div className="settings-user-info">
|
||||
<span className="settings-user-name">{user.Name}</span>
|
||||
<code className="settings-user-pin">{user.PIN}</code>
|
||||
<span className="settings-user-role">{user.Role}</span>
|
||||
</div>
|
||||
<div className="settings-user-access">
|
||||
{isAdmin ? (
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="season-actions">
|
||||
</div>
|
||||
<div className="settings-item-actions">
|
||||
{!isAdmin && <button className="btn-small" onClick={() => setEditing(true)}>Edit access</button>}
|
||||
<button className="btn-small btn-danger" onClick={() => onDelete(user.Id!)}>Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
{editing && (
|
||||
<div className="settings-item-form">
|
||||
<div className="access-columns">
|
||||
<div className="access-col">
|
||||
<div className="access-col-title">
|
||||
Events {allowedMuseums.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
||||
</div>
|
||||
{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)} />
|
||||
{m}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||
Allowed Channels {allowedChannels.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
||||
<div className="access-col">
|
||||
<div className="access-col-title">
|
||||
Channels {allowedChannels.length === 0 && <span className="access-badge access-badge--full">All</span>}
|
||||
</div>
|
||||
{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)} />
|
||||
{c}
|
||||
</label>
|
||||
))}
|
||||
</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" onClick={() => setEditing(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,7 +164,7 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
||||
|
||||
const [newSeason, setNewSeason] = useState<Omit<Season, 'Id'>>({
|
||||
Name: '',
|
||||
HijriYear: new Date().getFullYear() - 579, // rough Gregorian → Hijri
|
||||
HijriYear: new Date().getFullYear() - 579,
|
||||
StartDate: '',
|
||||
EndDate: '',
|
||||
Color: DEFAULT_COLORS[0],
|
||||
@@ -238,42 +229,36 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
||||
<h2>{t('settings.seasons')}</h2>
|
||||
<p className="settings-hint">{t('settings.seasonsHint')}</p>
|
||||
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('settings.seasonName')}</th>
|
||||
<th>{t('settings.startDate')}</th>
|
||||
<th>{t('settings.endDate')}</th>
|
||||
<th>{t('settings.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div className="settings-list">
|
||||
{loading ? (
|
||||
<tr><td colSpan={4} style={{ textAlign: 'center', padding: 24 }}>Loading...</td></tr>
|
||||
<div className="settings-loading">Loading...</div>
|
||||
) : (
|
||||
seasons.map(s => (
|
||||
<SeasonRow key={s.Id} season={s} onSave={handleSave} onDelete={handleDelete} />
|
||||
<SeasonItem key={s.Id} season={s} onSave={handleSave} onDelete={handleDelete} />
|
||||
))
|
||||
)}
|
||||
<tr className="add-row">
|
||||
<td>
|
||||
<div className="season-edit-name">
|
||||
<input type="text" value={newSeason.Name} onChange={e => setNewSeason({ ...newSeason, Name: e.target.value })} placeholder={t('settings.namePlaceholder')} />
|
||||
<input type="number" value={newSeason.HijriYear} onChange={e => setNewSeason({ ...newSeason, HijriYear: parseInt(e.target.value) || 0 })} style={{ width: 80 }} />
|
||||
<input type="color" value={newSeason.Color} onChange={e => setNewSeason({ ...newSeason, Color: e.target.value })} />
|
||||
</div>
|
||||
</td>
|
||||
<td><input type="date" value={newSeason.StartDate} onChange={e => setNewSeason({ ...newSeason, StartDate: e.target.value })} /></td>
|
||||
<td><input type="date" value={newSeason.EndDate} onChange={e => setNewSeason({ ...newSeason, EndDate: e.target.value })} /></td>
|
||||
<td>
|
||||
|
||||
<div className="settings-add-form">
|
||||
<div className="settings-add-title">{t('settings.add')} Season</div>
|
||||
<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')} />
|
||||
<input className="form-input form-input--sm" type="number" value={newSeason.HijriYear} onChange={e => setNewSeason({ ...newSeason, HijriYear: parseInt(e.target.value) || 0 })} />
|
||||
<input type="color" className="form-color" value={newSeason.Color} onChange={e => setNewSeason({ ...newSeason, Color: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
<span className="form-label">{t('settings.startDate')}</span>
|
||||
<input className="form-input" type="date" value={newSeason.StartDate} onChange={e => setNewSeason({ ...newSeason, StartDate: e.target.value })} />
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<span className="form-label">{t('settings.endDate')}</span>
|
||||
<input className="form-input" type="date" value={newSeason.EndDate} onChange={e => setNewSeason({ ...newSeason, EndDate: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<button className="btn-small btn-primary" onClick={handleCreate} disabled={!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate}>
|
||||
{t('settings.add')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -281,20 +266,9 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
||||
<h2>{t('settings.users')}</h2>
|
||||
<p className="settings-hint">{t('settings.usersHint')}</p>
|
||||
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('settings.userName')}</th>
|
||||
<th>{t('settings.userPin')}</th>
|
||||
<th>{t('settings.userRole')}</th>
|
||||
<th>Access</th>
|
||||
<th>{t('settings.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div className="settings-list">
|
||||
{users.map(u => (
|
||||
<UserRow
|
||||
<UserItem
|
||||
key={u.Id}
|
||||
user={u}
|
||||
allMuseums={allMuseums}
|
||||
@@ -303,21 +277,18 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
||||
onDelete={async (id) => { await deleteUser(id); await loadUsers(); }}
|
||||
/>
|
||||
))}
|
||||
<tr className="add-row">
|
||||
<td>
|
||||
<input type="text" value={newUser.Name} onChange={e => setNewUser({ ...newUser, Name: e.target.value })} placeholder={t('settings.userNamePlaceholder')} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" value={newUser.PIN} onChange={e => setNewUser({ ...newUser, PIN: e.target.value })} placeholder="PIN" />
|
||||
</td>
|
||||
<td>
|
||||
<select value={newUser.Role} onChange={e => setNewUser({ ...newUser, Role: e.target.value })}>
|
||||
</div>
|
||||
|
||||
<div className="settings-add-form">
|
||||
<div className="settings-add-title">{t('settings.add')} User</div>
|
||||
<div className="form-row">
|
||||
<input className="form-input" type="text" value={newUser.Name} onChange={e => setNewUser({ ...newUser, Name: e.target.value })} placeholder={t('settings.userNamePlaceholder')} />
|
||||
<input className="form-input form-input--sm" type="text" value={newUser.PIN} onChange={e => setNewUser({ ...newUser, PIN: e.target.value })} placeholder="PIN" />
|
||||
<select className="form-input form-input--sm" value={newUser.Role} onChange={e => setNewUser({ ...newUser, Role: e.target.value })}>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
</div>
|
||||
<button className="btn-small btn-primary" onClick={async () => {
|
||||
if (!newUser.Name || !newUser.PIN) return;
|
||||
await createUser(newUser);
|
||||
@@ -326,10 +297,6 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
|
||||
}} disabled={!newUser.Name || !newUser.PIN}>
|
||||
{t('settings.add')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
// ─── multi-select ─────────────────────────────────────────────────
|
||||
export default function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
|
||||
value: string[]; options: string[];
|
||||
onChange: (vals: string[]) => void;
|
||||
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
|
||||
}, [open]);
|
||||
|
||||
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v !== opt) : [...value, opt]);
|
||||
const label = value.length === 0 ? allLabel : value.length === 1 ? value[0] : countLabel(value.length);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="altms">
|
||||
<button type="button" className={`altms-trigger${value.length > 0 ? ' altms-trigger--active' : ''}`} onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox">
|
||||
<span className="altms-label">{label}</span>
|
||||
<svg className={`altms-chevron${open ? ' altms-chevron--open' : ''}`} width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="altms-dropdown" role="listbox" aria-multiselectable="true">
|
||||
<div className="altms-list">
|
||||
{options.map(opt => (
|
||||
<label key={opt} role="option" aria-selected={value.includes(opt)} className={`altms-option${value.includes(opt) ? ' altms-option--checked' : ''}`}>
|
||||
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
|
||||
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
|
||||
<span className="altms-opt-label">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{value.length > 0 && <button type="button" className="altms-clear" onClick={() => { onChange([]); setOpen(false); }}>{clearLabel}</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { formatCurrency, formatNumber } from '../../services/dataService';
|
||||
|
||||
// ─── metric card ──────────────────────────────────────────────────
|
||||
export default function MetricCard({ title, curr, prev, isCurrency, newLabel }: {
|
||||
title: string; curr: number; prev: number; isCurrency?: boolean; newLabel?: string;
|
||||
}) {
|
||||
const fmt = (n: number) => isCurrency ? formatCurrency(n) : formatNumber(n);
|
||||
const change = prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
||||
const isPos = change > 0, isNeg = change < 0;
|
||||
return (
|
||||
<div className="alt-metric">
|
||||
<p className="alt-metric-title">{title}</p>
|
||||
<div className="alt-metric-value">{fmt(curr)}</div>
|
||||
<div className="alt-metric-footer">
|
||||
{isFinite(change)
|
||||
? <span className={`alt-change ${isPos ? 'alt-change--up' : isNeg ? 'alt-change--down' : 'alt-change--flat'}`}>{isPos ? '▲' : isNeg ? '▼' : '—'} {Math.abs(change).toFixed(1)}%</span>
|
||||
: <span className="alt-change alt-change--up">{newLabel ?? 'New'}</span>}
|
||||
<span className="alt-metric-prev">{fmt(prev)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import type { LC } from '../../lib/locale';
|
||||
import { MONTH_KEYS, makePresets, guessPreset, periodNameL, dateRangeTextL } from '../../lib/dateHelpers';
|
||||
|
||||
// ─── inline picker ────────────────────────────────────────────────
|
||||
export function InlinePicker({ start, end, onChange, onClose, availableYears, L }: {
|
||||
start: string; end: string; onChange: (s: string, e: string) => void;
|
||||
onClose: () => void;
|
||||
availableYears: number[]; L: LC;
|
||||
}) {
|
||||
const g = guessPreset(start, end);
|
||||
const [year, setYear] = useState(g?.year ?? parseInt(start.slice(0, 4)));
|
||||
const [active, setActive] = useState<string | null>(g?.key ?? null);
|
||||
const [draftStart, setDraftStart] = useState(start);
|
||||
const [draftEnd, setDraftEnd] = useState(end);
|
||||
const minY = Math.min(...availableYears), maxY = Math.max(...availableYears);
|
||||
|
||||
const pick = (key: string) => { const r = makePresets(year)[key]; if (!r) return; setActive(key); setDraftStart(r.start); setDraftEnd(r.end); };
|
||||
const shift = (d: number) => {
|
||||
const ny = year + d; if (ny < minY || ny > maxY) return; setYear(ny);
|
||||
if (active && makePresets(ny)[active]) { setDraftStart(makePresets(ny)[active].start); setDraftEnd(makePresets(ny)[active].end); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="alt-picker" id="period-picker-panel">
|
||||
<div className="alt-picker-year">
|
||||
<button type="button" onClick={() => shift(L.dir === 'rtl' ? 1 : -1)} disabled={L.dir === 'rtl' ? year >= maxY : year <= minY} className="alt-yr-btn">
|
||||
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M5.5 9.5L1.5 5.5L5.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
<span className="alt-yr-val">{year}</span>
|
||||
<button type="button" onClick={() => shift(L.dir === 'rtl' ? -1 : 1)} disabled={L.dir === 'rtl' ? year <= minY : year >= maxY} className="alt-yr-btn">
|
||||
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M1.5 1.5L5.5 5.5L1.5 9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="alt-picker-section">{L.monthSection}</p>
|
||||
<div className="alt-chips">
|
||||
{MONTH_KEYS.map((k, i) => (
|
||||
<button key={k} type="button" className={`alt-chip${active === k ? ' alt-chip-on' : ''}`} onClick={() => pick(k)}>{L.monthShort[i]}</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="alt-picker-section">{L.periodSection}</p>
|
||||
<div className="alt-chips">
|
||||
{['q1','q2','q3','q4','h1','h2'].map(k => (
|
||||
<button key={k} type="button" className={`alt-chip${active === k ? ' alt-chip-on' : ''}`} onClick={() => pick(k)}>{L.periods[k]}</button>
|
||||
))}
|
||||
<button type="button" className={`alt-chip alt-chip-wide${active === 'full' ? ' alt-chip-on' : ''}`} onClick={() => pick('full')}>{L.periods.full}</button>
|
||||
</div>
|
||||
<div className="alt-picker-div" />
|
||||
<div className="alt-custom">
|
||||
<div className="alt-custom-f"><label>{L.from}</label><input type="date" value={draftStart} onChange={e => { setActive(null); setDraftStart(e.target.value); }} /></div>
|
||||
<span className="alt-custom-arrow">{L.dateRangeSep}</span>
|
||||
<div className="alt-custom-f"><label>{L.to}</label><input type="date" value={draftEnd} onChange={e => { setActive(null); setDraftEnd(e.target.value); }} /></div>
|
||||
</div>
|
||||
<div className="alt-picker-div" />
|
||||
<div className="alt-footer">
|
||||
<button type="button" className="alt-cancel" onClick={onClose}>{L.close}</button>
|
||||
<button type="button" className="alt-apply" onClick={() => { onChange(draftStart, draftEnd); onClose(); }}>{L.apply}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── period hero ──────────────────────────────────────────────────
|
||||
export default function PeriodHero({ start, end, onChange, availableYears, L }: {
|
||||
start: string; end: string; onChange: (s: string, e: string) => void;
|
||||
availableYears: number[]; L: LC;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onM = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
const onK = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onM); document.addEventListener('keydown', onK);
|
||||
return () => { document.removeEventListener('mousedown', onM); document.removeEventListener('keydown', onK); };
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="dalt-hero">
|
||||
<div className="dalt-hero-inner">
|
||||
<div>
|
||||
<div className="dalt-hero-name">{periodNameL(start, end, L)}</div>
|
||||
<div className="dalt-hero-range">{dateRangeTextL(start, end, L)}</div>
|
||||
</div>
|
||||
<button type="button" className="dalt-hero-btn" onClick={() => setOpen(v => !v)} aria-expanded={open} aria-controls="period-picker-panel">
|
||||
{open ? L.close : L.changePeriod}
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
|
||||
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{open && <InlinePicker start={start} end={end} onChange={onChange} onClose={() => setOpen(false)} availableYears={availableYears} L={L} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+33
-11
@@ -37,9 +37,21 @@ export const chartColors = {
|
||||
success: '#059669',
|
||||
danger: '#dc2626',
|
||||
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)
|
||||
export const chartPalette = [
|
||||
'#2563eb', // blue
|
||||
@@ -54,15 +66,15 @@ export const chartPalette = [
|
||||
'#ea580c', // orange
|
||||
];
|
||||
|
||||
export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
||||
export const createDataLabelConfig = (showDataLabels: boolean, overrides?: { color?: string; backgroundColor?: string }): any => ({
|
||||
display: showDataLabels,
|
||||
color: '#1e293b',
|
||||
color: overrides?.color ?? '#1e293b',
|
||||
font: { size: 10, weight: 600 },
|
||||
anchor: 'end',
|
||||
align: 'end',
|
||||
offset: 4,
|
||||
padding: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
||||
backgroundColor: overrides?.backgroundColor ?? 'rgba(255, 255, 255, 0.85)',
|
||||
borderRadius: 3,
|
||||
textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment
|
||||
formatter: (value: number | null) => {
|
||||
@@ -74,7 +86,9 @@ export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
||||
}
|
||||
});
|
||||
|
||||
export const createBaseOptions = (showDataLabels: boolean): any => ({
|
||||
export const createBaseOptions = (showDataLabels: boolean): any => {
|
||||
const theme = getChartTheme();
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
locale: 'en-US', // Force LTR number formatting
|
||||
@@ -89,7 +103,11 @@ export const createBaseOptions = (showDataLabels: boolean): any => ({
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: '#1e293b',
|
||||
backgroundColor: theme.surface,
|
||||
titleColor: theme.textPrimary,
|
||||
bodyColor: theme.textMuted,
|
||||
borderColor: theme.border,
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
cornerRadius: 8,
|
||||
titleFont: { size: 12 },
|
||||
@@ -97,20 +115,24 @@ export const createBaseOptions = (showDataLabels: boolean): any => ({
|
||||
rtl: false,
|
||||
textDirection: 'ltr'
|
||||
},
|
||||
datalabels: createDataLabelConfig(showDataLabels)
|
||||
datalabels: createDataLabelConfig(showDataLabels, {
|
||||
color: theme.textPrimary,
|
||||
backgroundColor: theme.surface + 'dd',
|
||||
})
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { font: { size: 10 }, color: '#94a3b8' }
|
||||
ticks: { font: { size: 10 }, color: theme.textMuted }
|
||||
},
|
||||
y: {
|
||||
grid: { color: chartColors.grid },
|
||||
ticks: { font: { size: 10 }, color: '#94a3b8' },
|
||||
grid: { color: theme.border },
|
||||
ticks: { font: { size: 10 }, color: theme.textMuted },
|
||||
border: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const lineDatasetDefaults = {
|
||||
borderWidth: 2,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { LC } from './locale';
|
||||
|
||||
// ─── date helpers ─────────────────────────────────────────────────
|
||||
|
||||
export const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
|
||||
|
||||
export function isLeap(y: number): boolean {
|
||||
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
|
||||
}
|
||||
|
||||
export function makePresets(y: number): Record<string, { start: string; end: string }> {
|
||||
const feb = isLeap(y) ? 29 : 28;
|
||||
return {
|
||||
jan:{start:`${y}-01-01`,end:`${y}-01-31`}, feb:{start:`${y}-02-01`,end:`${y}-02-${String(feb).padStart(2,'0')}`},
|
||||
mar:{start:`${y}-03-01`,end:`${y}-03-31`}, apr:{start:`${y}-04-01`,end:`${y}-04-30`},
|
||||
may:{start:`${y}-05-01`,end:`${y}-05-31`}, jun:{start:`${y}-06-01`,end:`${y}-06-30`},
|
||||
jul:{start:`${y}-07-01`,end:`${y}-07-31`}, aug:{start:`${y}-08-01`,end:`${y}-08-31`},
|
||||
sep:{start:`${y}-09-01`,end:`${y}-09-30`}, oct:{start:`${y}-10-01`,end:`${y}-10-31`},
|
||||
nov:{start:`${y}-11-01`,end:`${y}-11-30`}, dec:{start:`${y}-12-01`,end:`${y}-12-31`},
|
||||
q1:{start:`${y}-01-01`,end:`${y}-03-31`}, q2:{start:`${y}-04-01`,end:`${y}-06-30`},
|
||||
q3:{start:`${y}-07-01`,end:`${y}-09-30`}, q4:{start:`${y}-10-01`,end:`${y}-12-31`},
|
||||
h1:{start:`${y}-01-01`,end:`${y}-06-30`}, h2:{start:`${y}-07-01`,end:`${y}-12-31`},
|
||||
full:{start:`${y}-01-01`,end:`${y}-12-31`},
|
||||
};
|
||||
}
|
||||
|
||||
export function guessPreset(start: string, end: string): { key: string; year: number } | null {
|
||||
const year = parseInt(start.slice(0, 4));
|
||||
const presets = makePresets(year);
|
||||
for (const [key, r] of Object.entries(presets)) {
|
||||
if (r.start === start && r.end === end) return { key, year };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function periodNameL(start: string, end: string, L: LC): string {
|
||||
const year = parseInt(start.slice(0, 4));
|
||||
const g = guessPreset(start, end);
|
||||
if (!g) {
|
||||
const fmt = (d: string) => { const [,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]}`; };
|
||||
const ey = parseInt(end.slice(0, 4));
|
||||
return year === ey ? `${fmt(start)} – ${fmt(end)} ${year}` : `${fmt(start)} ${year} – ${fmt(end)} ${ey}`;
|
||||
}
|
||||
const mi = MONTH_KEYS.indexOf(g.key);
|
||||
if (mi >= 0) return `${L.monthFull[mi]} ${g.year}`;
|
||||
if (g.key === 'full') return L.fullYearLabel(g.year);
|
||||
return `${L.periods[g.key] ?? g.key.toUpperCase()} ${g.year}`;
|
||||
}
|
||||
|
||||
export function dateRangeTextL(start: string, end: string, L: LC): string {
|
||||
const fmt = (d: string) => { const [y,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]} ${y}`; };
|
||||
return `${fmt(start)} ${L.dateRangeSep} ${fmt(end)}`;
|
||||
}
|
||||
|
||||
export function currentMonth(): { start: string; end: string } {
|
||||
const now = new Date(); const y = now.getFullYear(), m = now.getMonth() + 1;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return { start: `${y}-${p(m)}-01`, end: `${y}-${p(m)}-${p(new Date(y, m, 0).getDate())}` };
|
||||
}
|
||||
|
||||
export function shiftYear(s: string): string {
|
||||
return s.replace(/^(\d{4})/, (_, y) => String(parseInt(y) - 1));
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// ─── language config ──────────────────────────────────────────────
|
||||
// Shared LC interface used by Dashboard and Comparison.
|
||||
// Fields marked with a comment are only consumed by one page but kept
|
||||
// here so both components share a single type.
|
||||
export interface LC {
|
||||
dir: 'ltr' | 'rtl';
|
||||
/** @deprecated Fonts are now loaded from index.html; kept for compatibility */
|
||||
fontImport: string;
|
||||
bodyFont: string;
|
||||
displayFont: string;
|
||||
monoFont: string;
|
||||
monthFull: string[];
|
||||
monthShort: string[];
|
||||
periods: Record<string, string>;
|
||||
fullYearLabel: (y: number) => string;
|
||||
dateRangeSep: string;
|
||||
backLink: string;
|
||||
backTo: string;
|
||||
pageTitle: string;
|
||||
pageSub: string;
|
||||
// Dashboard
|
||||
changePeriod: string;
|
||||
close: string;
|
||||
apply: string;
|
||||
filter: string;
|
||||
allDistricts: string;
|
||||
allChannels: string;
|
||||
allMuseums: string;
|
||||
countDistricts: (n: number) => string;
|
||||
countChannels: (n: number) => string;
|
||||
countMuseums: (n: number) => string;
|
||||
reset: string;
|
||||
exclVAT: string;
|
||||
inclVAT: string;
|
||||
keyMetrics: string;
|
||||
revenue: string;
|
||||
visitors: string;
|
||||
tickets: string;
|
||||
avgRev: string;
|
||||
pilgrims: string;
|
||||
captureRate: string;
|
||||
charts: string;
|
||||
trendTitle: string;
|
||||
museumTitle: string;
|
||||
channelTitle: string;
|
||||
districtTitle: string;
|
||||
daily: string;
|
||||
weekly: string;
|
||||
monthly: string;
|
||||
newLabel: string;
|
||||
clearSel: string;
|
||||
monthSection: string;
|
||||
periodSection: string;
|
||||
from: string;
|
||||
to: string;
|
||||
vsLabel: string;
|
||||
barLabel: string;
|
||||
pieLabel: string;
|
||||
absLabel: string;
|
||||
pctLabel: string;
|
||||
// Comparison-specific
|
||||
currentRole: string;
|
||||
previousRole: string;
|
||||
currentHint: string;
|
||||
previousHint: string;
|
||||
vs: string;
|
||||
}
|
||||
|
||||
export const EN: LC = {
|
||||
dir: 'ltr',
|
||||
fontImport: `@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&display=swap');`,
|
||||
bodyFont: "'Outfit', sans-serif",
|
||||
displayFont: "'DM Serif Display', serif",
|
||||
monoFont: "ui-monospace, 'Cascadia Code', monospace",
|
||||
monthFull: ['January','February','March','April','May','June','July','August','September','October','November','December'],
|
||||
monthShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
|
||||
periods: { q1:'Q1', q2:'Q2', q3:'Q3', q4:'Q4', h1:'H1', h2:'H2', full:'Full Year' },
|
||||
fullYearLabel: (y) => String(y),
|
||||
dateRangeSep: '→',
|
||||
backLink: 'Back to Dashboard', backTo: '/',
|
||||
pageTitle: 'Overview', pageSub: 'Museum performance at a glance.',
|
||||
changePeriod: 'Change period', close: 'Cancel', apply: 'Apply',
|
||||
filter: 'Filter',
|
||||
allDistricts: 'All districts', allChannels: 'All channels', allMuseums: 'All museums',
|
||||
countDistricts: (n) => `${n} districts`,
|
||||
countChannels: (n) => `${n} channels`,
|
||||
countMuseums: (n) => `${n} museums`,
|
||||
reset: 'Reset', exclVAT: 'Excl. VAT', inclVAT: 'Incl. VAT',
|
||||
keyMetrics: 'Key Metrics',
|
||||
revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets',
|
||||
avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %',
|
||||
charts: 'Charts',
|
||||
trendTitle: 'Trend over time', museumTitle: 'By museum',
|
||||
channelTitle: 'By channel', districtTitle: 'By district',
|
||||
daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
|
||||
newLabel: 'New', clearSel: 'Clear selection',
|
||||
monthSection: 'Month', periodSection: 'Quarter · Half · Year',
|
||||
from: 'From', to: 'To', vsLabel: 'vs',
|
||||
barLabel: 'Bar', pieLabel: 'Pie', absLabel: '#', pctLabel: '%',
|
||||
currentRole: 'This period', previousRole: 'Compared to',
|
||||
currentHint: 'primary', previousHint: 'auto year −1',
|
||||
vs: 'vs',
|
||||
};
|
||||
|
||||
export const AR: LC = {
|
||||
dir: 'rtl',
|
||||
fontImport: `@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');`,
|
||||
bodyFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||
displayFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||
monoFont: "'IBM Plex Sans Arabic', sans-serif",
|
||||
monthFull: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'],
|
||||
monthShort: ['ينا','فبر','مار','أبر','ماي','يون','يول','أغس','سبت','أكت','نوف','ديس'],
|
||||
periods: { q1:'ر١', q2:'ر٢', q3:'ر٣', q4:'ر٤', h1:'ن١', h2:'ن٢', full:'السنة' },
|
||||
fullYearLabel: (y) => `${y} كاملاً`,
|
||||
dateRangeSep: '–',
|
||||
backLink: 'العودة إلى لوحة التحكم', backTo: '/ar',
|
||||
pageTitle: 'نظرة عامة', pageSub: 'أداء المتاحف في لمحة.',
|
||||
changePeriod: 'تغيير الفترة', close: 'إلغاء', apply: 'تطبيق',
|
||||
filter: 'تصفية',
|
||||
allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف',
|
||||
countDistricts: (n) => `${n} مناطق`,
|
||||
countChannels: (n) => `${n} قنوات`,
|
||||
countMuseums: (n) => `${n} متاحف`,
|
||||
reset: 'إعادة ضبط', exclVAT: 'بدون ضريبة', inclVAT: 'مع ضريبة',
|
||||
keyMetrics: 'المؤشرات الرئيسية',
|
||||
revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر',
|
||||
avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %',
|
||||
charts: 'المخططات',
|
||||
trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف',
|
||||
channelTitle: 'حسب القناة', districtTitle: 'حسب المنطقة',
|
||||
daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري',
|
||||
newLabel: 'جديد', clearSel: 'مسح التحديد',
|
||||
monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة',
|
||||
from: 'من', to: 'إلى', vsLabel: 'مقابل',
|
||||
barLabel: 'أعمدة', pieLabel: 'دائرة', absLabel: '#', pctLabel: '%',
|
||||
currentRole: 'الفترة الحالية', previousRole: 'مقارنةً بـ',
|
||||
currentHint: 'رئيسية', previousHint: 'تلقائياً −١ سنة',
|
||||
vs: 'مقابل',
|
||||
};
|
||||
Reference in New Issue
Block a user