Compare commits

...

22 Commits

Author SHA1 Message Date
fahed 4f51280d1c feat(report+charts): report builder improvements and TOTAL_COLOR consistency
Deploy HiHala Dashboard / deploy (push) Successful in 11s
- Add TOTAL_COLOR constant to chartConfig and use it in Dashboard and Comparison for consistent total-line styling
- Overhaul ReportDocument layout, ReportForm UX, and reportHelpers logic
- Add IBM Plex Sans Arabic and Noto Sans Arabic font assets for PDF rendering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 15:49:09 +03:00
fahed 89689c5979 feat(charts): right-side bold legend with circle indicators + tooltip polish
Deploy HiHala Dashboard / deploy (push) Successful in 10s
- Legend moved to right, bold text, color matches line, circle outline indicator
- Museums with no data in current period excluded from chart and legend
- Tooltip uses circle point style and boxPadding for readable spacing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 11:37:40 +03:00
fahed 49bda53598 fix(charts): collision-aware end-of-line labels when lines converge
Deploy HiHala Dashboard / deploy (push) Successful in 15s
Replace per-dataset label drawing with a post-pass in afterDatasetsDraw
that collects all museum line endpoints, sorts by Y, then pushes overlapping
labels apart with a connector line back to the actual data point.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 11:21:32 +03:00
fahed 2888936d54 feat(charts): hover dimming, end-of-line labels, and value-label toggle
Deploy HiHala Dashboard / deploy (push) Successful in 11s
- Hover: non-hovered lines fade to 15% opacity so active line pops out
- End labels: museum name rendered at the tip of each line (always visible,
  stays full-opacity even when dimmed) with 110px right-padding for space
- Labels toggle: button in chart controls shows/hides per-point value labels
- interaction mode set to nearest/no-intersect for responsive hover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 11:13:05 +03:00
fahed 131868a280 feat(report): per-museum trend lines in PDF report chart
Deploy HiHala Dashboard / deploy (push) Successful in 11s
When multiple museums are present, the report trend chart now renders one
colored line per museum plus a bold Total line, mirroring dashboard behavior.
Legend is updated to list each museum with its corresponding color.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:56:26 +03:00
fahed 7365bc808b feat(charts): always show per-museum trend lines, with or without filter
Deploy HiHala Dashboard / deploy (push) Successful in 11s
When no museum is selected, all museums get individual lines. When a subset
is selected, only those museums are shown. Both Dashboard and Comparison
trend charts now follow this pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:44:52 +03:00
fahed 26bb69c76c feat(charts): show per-museum trend lines when multiple museums selected
Deploy HiHala Dashboard / deploy (push) Successful in 10s
When 2+ museums are selected, the trend chart now renders one colored line
per museum plus a bold Total line, instead of a single aggregated line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:42:18 +03:00
fahed 1070490ad2 feat(charts): show actual dates in trend chart tooltips
Deploy HiHala Dashboard / deploy (push) Successful in 11s
Replace opaque W1/D1/month abbreviation tooltip titles with human-readable
period labels (e.g. "Week 1 · 1 Apr – 7 Apr", "1 April 2025", "April 2025")
in both Dashboard and Comparison trend charts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:37:05 +03:00
fahed c858075232 refactor(report): full UX audit + accessibility pass
Deploy HiHala Dashboard / deploy (push) Successful in 11s
UI/UX redesign:
- Module cards with master toggle + badge state for all report sections
- BreakdownModule with indeterminate checkbox and metric pill sub-toggles
- PillGroup replaces all text toggles and <select> (Language, VAT,
  Confidentiality, Trend metric, Orientation) for full visual consistency
- Visual orientation picker (portrait/landscape card buttons)
- Comparison period in accent-tinted block, revealed contextually
- Footer meta strip: section count, date range, orientation, comparison flag
- Removed generic subtitle copy

Accessibility (audit findings C1–C3, H2, H6, L1–L2):
- aria-pressed on all PillGroup and orientation buttons
- role="group" + aria-label on every pill group and orientation row
- aria-hidden on decorative module badges and footer separator dots
- :focus-visible on rf-metric-pill, rf-orient-btn, rf-upload-btn, rf-remove-btn
- aria-label on upload/remove logo buttons
- Semantic <h2> elements replace <div> group labels
- alert() replaced with inline role="alert" error messages in footer + logo field
- aria-live="polite" sr-only region for PDF generation status
- aria-busy on generate button during PDF creation

Dark mode & theming (H1):
- All rgba(37,99,235,...) hard-codes replaced with color-mix(in srgb,
  var(--accent) N%, transparent) so tints follow the accent token in dark mode
- rf-module-header:hover uses var(--hover) instead of rgba(0,0,0,0.02)

Performance (H8):
- getUniqueMuseums/getUniqueChannels wrapped in useMemo([data])

PDF fixes:
- ▲/▼ Unicode glyphs (outside Helvetica Latin-1 range) replaced with +/- prefix
- Chart width adapts to orientation via CHART_W constant
- Y-axis labels added to trend chart (padL 38pt)

Responsive (H4–H5):
- rf-metric-pill touch target increased to 8px/14px on mobile
- Mobile footer shows section count only; period/orientation details hide

Cleanup (M3):
- Removed dead CSS: rf-toggle, rf-toggle-opt, rf-section-title,
  rf-check-h-group, rf-inline-row (7 rules)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 09:41:38 +03:00
fahed 648365348f feat(report): visitors by museum, avg ticket price, chart label fix, VAT indicator
Deploy HiHala Dashboard / deploy (push) Successful in 10s
2026-04-28 14:59:24 +03:00
fahed 594321738a fix(report): SVG logo unsupported, date validation, blob URL cleanup, remove as-any cast
Deploy HiHala Dashboard / deploy (push) Successful in 11s
2026-04-28 14:47:39 +03:00
fahed b6bd3bcff5 feat(report): wire /report route and nav links (desktop + mobile)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 14:44:43 +03:00
fahed d59af22329 feat(report): CSS for report page, form, and preview panel 2026-04-28 14:43:37 +03:00
fahed 640538bcbd feat(report): page shell with two-column layout and PDF download action 2026-04-28 14:42:31 +03:00
fahed 553928a3a9 feat(report): form component with all config fields 2026-04-28 14:41:44 +03:00
fahed d925d41a79 feat(report): static preview panel 2026-04-28 14:40:44 +03:00
fahed d7d035adb0 feat(report): PDF document component (cover + content pages + charts)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 14:39:45 +03:00
fahed cf6a4c0b3d feat(report): PDF SVG chart components (trend line + horizontal bar) 2026-04-28 14:37:03 +03:00
fahed 2f90753f57 fix(report): allow zero-visitor pilgrim capture rate 2026-04-28 14:34:36 +03:00
fahed 65025d7f3c feat(report): types, data computation, formatters, executive summary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 14:33:05 +03:00
fahed ab94d33868 chore: add @react-pdf/renderer 2026-04-28 14:30:42 +03:00
fahed 64955f0f51 docs: report builder design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 14:20:07 +03:00
21 changed files with 9142 additions and 28 deletions
@@ -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 (34 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)
+589 -1
View File
@@ -8,6 +8,7 @@
"name": "hihala-dashboard", "name": "hihala-dashboard",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.5.1",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
@@ -827,6 +828,207 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@react-pdf/fns": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.3.tgz",
"integrity": "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==",
"license": "MIT"
},
"node_modules/@react-pdf/font": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.8.tgz",
"integrity": "sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==",
"license": "MIT",
"dependencies": {
"@react-pdf/pdfkit": "^5.1.1",
"@react-pdf/types": "^2.11.1",
"fontkit": "^2.0.2",
"is-url": "^1.2.4"
}
},
"node_modules/@react-pdf/image": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.1.0.tgz",
"integrity": "sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==",
"license": "MIT",
"dependencies": {
"@react-pdf/svg": "^1.1.0",
"jay-peg": "^1.1.1",
"png-js": "^2.0.0"
}
},
"node_modules/@react-pdf/layout": {
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.6.1.tgz",
"integrity": "sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.3",
"@react-pdf/image": "^3.1.0",
"@react-pdf/primitives": "^4.3.0",
"@react-pdf/stylesheet": "^6.2.1",
"@react-pdf/textkit": "^6.3.0",
"@react-pdf/types": "^2.11.1",
"emoji-regex-xs": "^1.0.0",
"queue": "^6.0.1",
"yoga-layout": "^3.2.1"
}
},
"node_modules/@react-pdf/pdfkit": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-5.1.1.tgz",
"integrity": "sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.6.0",
"browserify-zlib": "^0.2.0",
"fontkit": "^2.0.2",
"jay-peg": "^1.1.1",
"js-md5": "^0.8.3",
"linebreak": "^1.1.0",
"png-js": "^2.0.0",
"vite-compatible-readable-stream": "^3.6.1"
}
},
"node_modules/@react-pdf/primitives": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.3.0.tgz",
"integrity": "sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==",
"license": "MIT"
},
"node_modules/@react-pdf/reconciler": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz",
"integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4.1.1",
"scheduler": "0.25.0-rc-603e6108-20241029"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
"version": "0.25.0-rc-603e6108-20241029",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
"license": "MIT"
},
"node_modules/@react-pdf/render": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.5.1.tgz",
"integrity": "sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.3",
"@react-pdf/primitives": "^4.3.0",
"@react-pdf/textkit": "^6.3.0",
"@react-pdf/types": "^2.11.1",
"abs-svg-path": "^0.1.1",
"color-string": "^2.1.4",
"normalize-svg-path": "^1.1.0",
"parse-svg-path": "^0.1.2",
"svg-arc-to-cubic-bezier": "^3.2.0"
}
},
"node_modules/@react-pdf/renderer": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.5.1.tgz",
"integrity": "sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.3",
"@react-pdf/font": "^4.0.8",
"@react-pdf/layout": "^4.6.1",
"@react-pdf/pdfkit": "^5.1.1",
"@react-pdf/primitives": "^4.3.0",
"@react-pdf/reconciler": "^2.0.0",
"@react-pdf/render": "^4.5.1",
"@react-pdf/types": "^2.11.1",
"events": "^3.3.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"queue": "^6.0.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/stylesheet": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.2.1.tgz",
"integrity": "sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.3",
"@react-pdf/types": "^2.11.1",
"color-string": "^2.1.4",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/svg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/svg/-/svg-1.1.0.tgz",
"integrity": "sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==",
"license": "MIT",
"dependencies": {
"@react-pdf/primitives": "^4.3.0"
}
},
"node_modules/@react-pdf/textkit": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.3.0.tgz",
"integrity": "sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.3",
"bidi-js": "^1.0.2",
"hyphen": "^1.6.4",
"unicode-properties": "^1.4.1"
}
},
"node_modules/@react-pdf/types": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.11.1.tgz",
"integrity": "sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==",
"license": "MIT",
"dependencies": {
"@react-pdf/font": "^4.0.8",
"@react-pdf/primitives": "^4.3.0",
"@react-pdf/stylesheet": "^6.2.1"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3", "version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -1184,6 +1386,15 @@
"win32" "win32"
] ]
}, },
"node_modules/@swc/helpers": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz",
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -1419,6 +1630,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/abs-svg-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
"license": "MIT"
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -1462,6 +1679,26 @@
"node": ">= 0.6.0" "node": ">= 0.6.0"
} }
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@@ -1475,6 +1712,33 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.1", "version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -1605,6 +1869,15 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1625,6 +1898,27 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/color-string": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
"license": "MIT",
"dependencies": {
"color-name": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/color-string/node_modules/color-name": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
"license": "MIT",
"engines": {
"node": ">=12.20"
}
},
"node_modules/concurrently": { "node_modules/concurrently": {
"version": "9.2.1", "version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
@@ -1712,6 +2006,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/dom-accessibility-api": { "node_modules/dom-accessibility-api": {
"version": "0.5.16", "version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@@ -1732,6 +2032,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/emoji-regex-xs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.3", "version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -1784,6 +2090,21 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1802,6 +2123,29 @@
} }
} }
}, },
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1847,6 +2191,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/hsl-to-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
"license": "MIT",
"dependencies": {
"hsl-to-rgb-for-reals": "^1.1.0"
}
},
"node_modules/hsl-to-rgb-for-reals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
"license": "ISC"
},
"node_modules/html2canvas": { "node_modules/html2canvas": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
@@ -1860,6 +2219,12 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/hyphen": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz",
"integrity": "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==",
"license": "ISC"
},
"node_modules/immediate": { "node_modules/immediate": {
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -1891,6 +2256,27 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/jay-peg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
"license": "MIT",
"dependencies": {
"restructure": "^3.0.0"
}
},
"node_modules/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1980,6 +2366,37 @@
"immediate": "~3.0.5" "immediate": "~3.0.5"
} }
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -1999,6 +2416,12 @@
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
}, },
"node_modules/media-engine": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
"license": "MIT"
},
"node_modules/min-indent": { "node_modules/min-indent": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -2041,12 +2464,36 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
"license": "MIT",
"dependencies": {
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pako": { "node_modules/pako": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)" "license": "(MIT AND Zlib)"
}, },
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2066,6 +2513,14 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/png-js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
"dependencies": {
"fflate": "^0.8.2"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -2095,6 +2550,12 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/pretty-format": { "node_modules/pretty-format": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -2127,6 +2588,32 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -2248,6 +2735,21 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.59.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -2303,6 +2805,26 @@
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -2354,6 +2876,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -2410,6 +2941,12 @@
"url": "https://github.com/chalk/supports-color?sponsor=1" "url": "https://github.com/chalk/supports-color?sponsor=1"
} }
}, },
"node_modules/svg-arc-to-cubic-bezier": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
"license": "ISC"
},
"node_modules/text-segmentation": { "node_modules/text-segmentation": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
@@ -2419,6 +2956,12 @@
"utrie": "^1.0.2" "utrie": "^1.0.2"
} }
}, },
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2450,7 +2993,6 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/typescript": { "node_modules/typescript": {
@@ -2474,6 +3016,32 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -2595,6 +3163,20 @@
} }
} }
}, },
"node_modules/vite-compatible-readable-stream": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/web-vitals": { "node_modules/web-vitals": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
@@ -2664,6 +3246,12 @@
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
},
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
} }
} }
} }
+1
View File
@@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.5.1",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+478
View File
@@ -2854,6 +2854,484 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
.alt-chart-header { flex-direction:column; } .alt-chart-header { flex-direction:column; }
} }
/* ========================================
Report Builder page
======================================== */
.report-page {
max-width: 1100px;
margin: 0 auto;
padding: 32px 24px 100px;
}
.report-header {
margin-bottom: 28px;
}
.report-title {
font-family: var(--alt-display-font, 'DM Serif Display', serif);
font-size: 2rem;
font-weight: 400;
color: var(--text-primary);
margin: 0 0 6px;
letter-spacing: -0.03em;
}
.report-sub {
font-size: 0.9375rem;
color: var(--text-muted);
margin: 0;
}
.report-body {}
.report-form-col {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
/* Generate button bar */
.report-footer-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--surface);
border-top: 1px solid var(--border);
padding: 12px 24px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
display: flex;
align-items: center;
justify-content: space-between;
z-index: 100;
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
}
.report-generate-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
background: var(--accent);
color: var(--text-inverse);
border: none;
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.report-generate-btn:hover { opacity: 0.88; }
.report-generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.report-generate-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@keyframes report-spin { to { transform: rotate(360deg); } }
.report-spin { animation: report-spin 0.8s linear infinite; }
/* ── Report Form ── */
.report-form {
overflow: hidden;
}
.rf-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
}
.rf-col {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.rf-col + .rf-col {
border-left: 1px solid var(--border);
}
.rf-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.rf-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
}
.rf-input {
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 7px;
font-size: 0.875rem;
background: var(--surface);
color: var(--text-primary);
width: 100%;
box-sizing: border-box;
}
.rf-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 10%, transparent); }
.rf-date-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.rf-check-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
color: var(--text-primary);
cursor: pointer;
}
.rf-checkbox {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
flex-shrink: 0;
}
.rf-color-row {
display: flex;
align-items: center;
gap: 10px;
}
.rf-color-input {
width: 40px;
height: 36px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 7px;
cursor: pointer;
}
.rf-color-val {
font-size: 0.8125rem;
color: var(--text-muted);
font-family: var(--alt-mono-font, monospace);
}
.rf-orient-row {
display: flex;
gap: 8px;
}
.rf-orient-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 10px 16px;
border: 1.5px solid var(--border);
border-radius: 8px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 0.75rem;
font-weight: 500;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.rf-orient-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.rf-orient-btn--on {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
.rf-orient-page {
border: 1.5px solid currentColor;
border-radius: 2px;
opacity: 0.75;
}
.rf-orient-page--portrait {
width: 18px;
height: 26px;
}
.rf-orient-page--landscape {
width: 26px;
height: 18px;
}
.rf-logo-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.rf-upload-btn {
padding: 6px 12px;
border: 1px dashed var(--border);
border-radius: 7px;
background: var(--bg);
color: var(--text-secondary);
font-size: 0.8125rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.rf-upload-btn:hover { border-color: var(--accent); color: var(--accent); }
.rf-logo-preview {
height: 32px;
max-width: 80px;
object-fit: contain;
border: 1px solid var(--border);
border-radius: 5px;
padding: 2px;
}
.rf-remove-btn {
width: 24px;
height: 24px;
border: none;
background: var(--danger-light, #fee2e2);
color: var(--danger, #dc2626);
border-radius: 50%;
font-size: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
/* ── Group labels & dividers ── */
.rf-group-label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.rf-divider {
height: 1px;
background: var(--border);
margin: 2px 0;
}
/* ── Branding row (accent color + logo side by side) ── */
.rf-branding-row {
display: flex;
gap: 16px;
align-items: flex-start;
}
.rf-branding-row .rf-field {
flex: 1;
min-width: 0;
}
/* ── Comparison block ── */
.rf-comparison-block {
background: color-mix(in srgb, var(--accent) 4%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent);
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.rf-comparison-label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--accent);
}
/* ── Module cards ── */
.rf-module {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
transition: border-color 0.15s;
}
.rf-module--on {
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
}
.rf-module-header {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
cursor: pointer;
user-select: none;
background: transparent;
}
.rf-module-header:hover {
background: var(--hover);
}
.rf-module-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
flex: 1;
}
.rf-module-badge {
font-size: 0.6875rem;
font-weight: 600;
padding: 2px 7px;
border-radius: 10px;
background: var(--border);
color: var(--text-muted);
white-space: nowrap;
}
.rf-module-badge--on {
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent);
}
.rf-module-body {
padding: 10px 12px 12px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 10px;
}
.rf-module-note {
font-size: 0.8125rem;
color: var(--text-muted);
margin: 0;
}
/* ── Metric pill toggles ── */
.rf-metric-pills {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.rf-metric-pill {
padding: 4px 12px;
border: 1.5px solid var(--border);
border-radius: 20px;
background: transparent;
color: var(--text-muted);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.rf-metric-pill--on {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent);
font-weight: 600;
}
.rf-metric-pill:hover:not(.rf-metric-pill--on) {
border-color: var(--text-muted);
color: var(--text-primary);
}
/* ── Footer meta strip ── */
.report-footer-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.report-footer-chip {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.report-footer-chip--compare {
color: var(--accent);
font-weight: 600;
}
.report-footer-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--text-muted);
opacity: 0.4;
flex-shrink: 0;
}
/* H2: focus-visible on all custom interactive elements */
.rf-metric-pill:focus-visible,
.rf-orient-btn:focus-visible,
.rf-upload-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.rf-remove-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* H3: H2 elements used as group labels reset browser heading defaults */
h2.rf-group-label {
font-size: 0.6875rem;
font-weight: 700;
margin: 0;
line-height: inherit;
}
/* H3: H2 badge-on buttons inside module cards (badge is aria-hidden, no h2 styling needed there) */
/* H3: rf-remove-btn touch target — min 36×36 */
.rf-remove-btn {
width: 36px;
height: 36px;
}
/* C2: inline error messages */
.rf-field-error {
font-size: 0.75rem;
color: var(--danger);
margin-top: 2px;
}
.report-footer-error {
font-size: 0.8125rem;
color: var(--danger);
font-weight: 500;
margin-left: 4px;
}
@media (max-width: 800px) {
.rf-two-col { grid-template-columns: 1fr; }
.rf-col + .rf-col { border-left: none; border-top: 1px solid var(--border); }
.report-page { padding: 20px 16px 90px; }
/* H5: show only section count in footer on mobile, hide details */
.report-footer-chip:not(.report-footer-chip--count),
.report-footer-dot { display: none; }
/* H4: larger touch targets for metric pills on mobile */
.rf-metric-pill { padding: 8px 14px; }
}
/* ======================================== /* ========================================
Reduced Motion Reduced Motion
======================================== */ ======================================== */
+26
View File
@@ -4,6 +4,7 @@ import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react
const Settings = lazy(() => import('./components/Settings')); const Settings = lazy(() => import('./components/Settings'));
const Comparison = lazy(() => import('./components/Comparison')); const Comparison = lazy(() => import('./components/Comparison'));
const Dashboard = lazy(() => import('./components/Dashboard')); const Dashboard = lazy(() => import('./components/Dashboard'));
const Report = lazy(() => import('./components/Report'));
import Login from './components/Login'; import Login from './components/Login';
import LoadingSkeleton from './components/shared/LoadingSkeleton'; import LoadingSkeleton from './components/shared/LoadingSkeleton';
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService'; import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService';
@@ -245,6 +246,18 @@ function App() {
{t('nav.settings')} {t('nav.settings')}
</NavLink> </NavLink>
)} )}
{userRole === 'admin' && (
<NavLink to="/report">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
Report
</NavLink>
)}
<span className="nav-sep" aria-hidden="true" /> <span className="nav-sep" aria-hidden="true" />
{isOffline && ( {isOffline && (
<span className="offline-badge" title={cacheInfo ? `Cached: ${new Date(cacheInfo.timestamp || '').toLocaleString()}` : ''}> <span className="offline-badge" title={cacheInfo ? `Cached: ${new Date(cacheInfo.timestamp || '').toLocaleString()}` : ''}>
@@ -317,6 +330,7 @@ function App() {
<Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} /> <Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} /> <Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />} {userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
{userRole === 'admin' && <Route path="/report" element={<Report data={data} />} />}
</Routes> </Routes>
</Suspense> </Suspense>
</main> </main>
@@ -340,6 +354,18 @@ function App() {
</svg> </svg>
<span>{t('nav.compare')}</span> <span>{t('nav.compare')}</span>
</NavLink> </NavLink>
{userRole === 'admin' && (
<NavLink to="/report" className="mobile-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<span>Report</span>
</NavLink>
)}
{userRole === 'admin' && ( {userRole === 'admin' && (
<NavLink to="/settings" className="mobile-nav-item"> <NavLink to="/settings" className="mobile-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+88 -7
View File
@@ -6,7 +6,7 @@ import {
getUniqueChannels, getUniqueMuseums, getUniqueDistricts, getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
umrahData umrahData
} from '../services/dataService'; } from '../services/dataService';
import { chartColors, createBaseOptions } from '../config/chartConfig'; import { chartColors, chartPalette, createBaseOptions, TOTAL_COLOR } from '../config/chartConfig';
import type { MuseumRecord, Season } from '../types'; import type { MuseumRecord, Season } from '../types';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import type { LC } from '../lib/locale'; import type { LC } from '../lib/locale';
@@ -78,6 +78,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
const [selMuseums, setSelMuseums] = useState<string[]>([]); const [selMuseums, setSelMuseums] = useState<string[]>([]);
const [metric, setMetric] = useState('revenue'); const [metric, setMetric] = useState('revenue');
const [gran, setGran] = useState('week'); const [gran, setGran] = useState('week');
const [showLabels, setShowLabels] = useState(false);
const perm = useMemo(() => { const perm = useMemo(() => {
if (!allowedMuseums || !allowedChannels) return []; if (!allowedMuseums || !allowedChannels) return [];
@@ -128,7 +129,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
return sy===ey ? sy : `${L.monthShort[parseInt(s.slice(5,7))-1]} '${sy.slice(-2)}${L.monthShort[parseInt(e.slice(5,7))-1]} '${ey.slice(-2)}`; return sy===ey ? sy : `${L.monthShort[parseInt(s.slice(5,7))-1]} '${sy.slice(-2)}${L.monthShort[parseInt(e.slice(5,7))-1]} '${ey.slice(-2)}`;
}; };
const trendData = useMemo(() => { const trendResult = useMemo(() => {
const group = (rows: MuseumRecord[], ps: string) => { const group = (rows: MuseumRecord[], ps: string) => {
const s=new Date(ps); const acc: Record<number,MuseumRecord[]> = {}; const s=new Date(ps); const acc: Record<number,MuseumRecord[]> = {};
rows.forEach(r => { rows.forEach(r => {
@@ -142,17 +143,56 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
}; };
const pg = group(prevData, prevStart), cg = group(currData, currStart); const pg = group(prevData, prevStart), cg = group(currData, currStart);
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1); const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
const cs0 = new Date(currStart);
const fmt = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
const labels = Array.from({length:maxK}, (_,i) => const labels = Array.from({length:maxK}, (_,i) =>
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(new Date(currStart).getMonth()+i)%12] : `D${i+1}` gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(cs0.getMonth()+i)%12] : `D${i+1}`
); );
const tooltipLabels = Array.from({length:maxK}, (_,i) => {
if (gran==='week') {
const ws = new Date(cs0.getTime() + i * 7 * 86400000);
const we = new Date(cs0.getTime() + (i+1) * 7 * 86400000 - 86400000);
return `Week ${i+1} · ${fmt(ws)} ${fmt(we)}`;
}
if (gran==='month') {
const ms = new Date(cs0.getFullYear(), cs0.getMonth() + i, 1);
return ms.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
}
const ds = new Date(cs0.getTime() + i * 86400000);
return ds.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
});
const museumList = (selMuseums.length > 0 ? selMuseums : museums)
.filter(museum => currData.some(r => r.museum_name === museum));
const multiMuseum = museumList.length >= 2;
const museumDatasets = museumList.map((museum, idx) => {
const mg = group(currData.filter(r => r.museum_name === museum), currStart);
return { return {
label: museum,
data: labels.map((_,i) => mg[i+1]||0),
borderColor: chartPalette[idx % chartPalette.length],
backgroundColor: 'transparent',
borderWidth: 1.5,
tension: 0.4,
fill: false,
pointRadius: gran==='week' ? 3 : 1,
pointBackgroundColor: chartPalette[idx % chartPalette.length],
_isMuseumLine: true,
};
});
return {
tooltipLabels,
multiMuseum,
data: {
labels, labels,
datasets: [ datasets: [
{ label:periodLabel(prevStart,prevEnd), data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:2, tension:0.4, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.muted }, { label:periodLabel(prevStart,prevEnd), data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:2, tension:0.4, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.muted },
{ label:periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'15', borderWidth:2, tension:0.4, fill:true, pointRadius:gran==='week'?4:2, pointBackgroundColor:chartColors.primary }, ...museumDatasets,
{ label: multiMuseum ? `Total · ${periodLabel(currStart,currEnd)}` : periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:TOTAL_COLOR, backgroundColor: multiMuseum ? 'transparent' : TOTAL_COLOR+'15', borderWidth:2.5, tension:0.4, fill: !multiMuseum, pointRadius:gran==='week'?4:2, pointBackgroundColor:TOTAL_COLOR },
] ]
}
}; };
}, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L]); }, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L, selMuseums, museums]);
const trendData = trendResult.data;
const museumData = useMemo(() => { const museumData = useMemo(() => {
const all = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[]; const all = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[];
@@ -168,11 +208,50 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
}; };
}, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal]); }, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal]);
const baseOpts = useMemo(() => createBaseOptions(false), []); const baseOpts = useMemo(() => createBaseOptions(showLabels), [showLabels]);
const { chartOpts } = useMemo(() => { const { chartOpts } = useMemo(() => {
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } }; const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } };
return { chartOpts }; return { chartOpts };
}, [baseOpts]); }, [baseOpts]);
const trendOpts: any = useMemo(() => ({
...chartOpts,
interaction: { mode: 'nearest', intersect: false },
plugins: {
...chartOpts.plugins,
legend: {
display: true,
position: 'right' as const,
labels: {
padding: 14,
font: { size: 11, weight: 'bold' as const },
usePointStyle: true,
generateLabels: (chart: any) =>
chart.data.datasets.map((ds: any, i: number) => {
const color: string = ds.borderColor || '#64748b';
const pill = document.createElement('canvas');
pill.width = 10; pill.height = 10;
const pCtx = pill.getContext('2d');
if (pCtx) {
pCtx.strokeStyle = color;
pCtx.lineWidth = 1;
pCtx.beginPath();
pCtx.arc(5, 5, 4, 0, Math.PI * 2);
pCtx.stroke();
}
return { text: ds.label, fillStyle: color, strokeStyle: color,
fontColor: color, lineWidth: 0, pointStyle: pill,
hidden: !chart.isDatasetVisible(i), datasetIndex: i };
}),
},
},
tooltip: {
...chartOpts.plugins.tooltip,
callbacks: {
title: (items: any[]) => trendResult.tooltipLabels[items[0]?.dataIndex] ?? items[0]?.label,
}
}
}
}), [chartOpts, trendResult.tooltipLabels]);
const metricOpts = [ const metricOpts = [
{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors }, { value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors },
@@ -272,9 +351,11 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)} {granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" />
<button type="button" aria-pressed={showLabels} className={`alt-ctrl${showLabels?' alt-ctrl-on':''}`} onClick={() => setShowLabels(v => !v)}>{'Labels'}</button>
</div> </div>
</div> </div>
<div className="alt-chart-wrap"><Line data={trendData} options={chartOpts} /></div> <div className="alt-chart-wrap"><Line data={trendData} options={trendOpts} /></div>
</div> </div>
<div className="alt-chart-card"> <div className="alt-chart-card">
<div className="alt-chart-header"> <div className="alt-chart-header">
+89 -7
View File
@@ -6,7 +6,7 @@ import {
groupByMuseum, groupByChannel, groupByDistrict, groupByMuseum, groupByChannel, groupByDistrict,
umrahData, umrahData,
} from '../services/dataService'; } from '../services/dataService';
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig'; import { chartColors, chartPalette, createBaseOptions, TOTAL_COLOR } from '../config/chartConfig';
import type { MuseumRecord, Season } from '../types'; import type { MuseumRecord, Season } from '../types';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { EN, AR } from '../lib/locale'; import { EN, AR } from '../lib/locale';
@@ -37,6 +37,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
const [selMuseums, setSelMuseums] = useState<string[]>([]); const [selMuseums, setSelMuseums] = useState<string[]>([]);
const [metric, setMetric] = useState('revenue'); const [metric, setMetric] = useState('revenue');
const [gran, setGran] = useState('week'); const [gran, setGran] = useState('week');
const [showLabels, setShowLabels] = useState(false);
const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar'); const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar');
const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie'); const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie');
const [districtChartType, setDistrictChartType] = useState<'bar'|'pie'>('pie'); const [districtChartType, setDistrictChartType] = useState<'bar'|'pie'>('pie');
@@ -88,7 +89,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0); return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0);
}, [revenueField]); }, [revenueField]);
const trendData = useMemo(() => { const trendResult = useMemo(() => {
const group = (rows: MuseumRecord[], ps: string) => { const group = (rows: MuseumRecord[], ps: string) => {
const s = new Date(ps); const acc: Record<number, MuseumRecord[]> = {}; const s = new Date(ps); const acc: Record<number, MuseumRecord[]> = {};
rows.forEach(r => { rows.forEach(r => {
@@ -102,18 +103,57 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
}; };
const pg = group(prevData, prevStart), cg = group(filteredData, start); const pg = group(prevData, prevStart), cg = group(filteredData, start);
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1); const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
const s0 = new Date(start);
const fmt = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
const labels = Array.from({length:maxK}, (_,i) => const labels = Array.from({length:maxK}, (_,i) =>
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(new Date(start).getMonth()+i)%12] : `D${i+1}` gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(s0.getMonth()+i)%12] : `D${i+1}`
); );
const tooltipLabels = Array.from({length:maxK}, (_,i) => {
if (gran==='week') {
const ws = new Date(s0.getTime() + i * 7 * 86400000);
const we = new Date(s0.getTime() + (i+1) * 7 * 86400000 - 86400000);
return `Week ${i+1} · ${fmt(ws)} ${fmt(we)}`;
}
if (gran==='month') {
const ms = new Date(s0.getFullYear(), s0.getMonth() + i, 1);
return ms.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
}
const ds = new Date(s0.getTime() + i * 86400000);
return ds.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
});
const prevYear = parseInt(start.slice(0,4))-1; const prevYear = parseInt(start.slice(0,4))-1;
const museumList = (selMuseums.length > 0 ? selMuseums : allMuseums)
.filter(museum => filteredData.some(r => r.museum_name === museum));
const multiMuseum = museumList.length >= 2;
const museumDatasets = museumList.map((museum, idx) => {
const mg = group(filteredData.filter(r => r.museum_name === museum), start);
return { return {
label: museum,
data: labels.map((_,i) => mg[i+1]||0),
borderColor: chartPalette[idx % chartPalette.length],
backgroundColor: 'transparent',
borderWidth: 1.5,
tension: 0.4,
fill: false,
pointRadius: gran==='week' ? 3 : 1,
pointBackgroundColor: chartPalette[idx % chartPalette.length],
_isMuseumLine: true,
};
});
return {
tooltipLabels,
multiMuseum,
data: {
labels, labels,
datasets: [ datasets: [
{ label:`${prevYear}`, data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:1.5, tension:0.4, pointRadius:0, borderDash:[4,3] }, { label:`${prevYear}`, data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:1.5, tension:0.4, pointRadius:0, borderDash:[4,3] },
{ label:start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'18', borderWidth:2.5, tension:0.4, fill:true, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.primary }, ...museumDatasets,
{ label: multiMuseum ? `Total ${start.slice(0,4)}` : start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:TOTAL_COLOR, backgroundColor: multiMuseum ? 'transparent' : TOTAL_COLOR+'18', borderWidth:2.5, tension:0.4, fill: !multiMuseum, pointRadius:gran==='week'?3:1, pointBackgroundColor:TOTAL_COLOR },
] ]
}
}; };
}, [filteredData, prevData, prevStart, start, metric, gran, getVal, L]); }, [filteredData, prevData, prevStart, start, metric, gran, getVal, L, selMuseums, allMuseums]);
const trendData = trendResult.data;
const museumData = useMemo(() => { const museumData = useMemo(() => {
const g = groupByMuseum(filteredData, includeVAT); const g = groupByMuseum(filteredData, includeVAT);
@@ -165,13 +205,53 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null; const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null;
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null; const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
const baseOpts = useMemo(() => createBaseOptions(false), []); const baseOpts = useMemo(() => createBaseOptions(showLabels), [showLabels]);
const { chartOpts, barHorizOpts, barNoLegend } = useMemo(() => { 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 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 barHorizOpts: any = { ...chartOpts, indexAxis:'y', plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
const barNoLegend: any = { ...chartOpts, plugins:{ ...chartOpts.plugins, legend:{ display:false } } }; const barNoLegend: any = { ...chartOpts, plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
return { chartOpts, barHorizOpts, barNoLegend }; return { chartOpts, barHorizOpts, barNoLegend };
}, [baseOpts]); }, [baseOpts]);
const trendOpts: any = useMemo(() => ({
...chartOpts,
interaction: { mode: 'nearest', intersect: false },
plugins: {
...chartOpts.plugins,
legend: {
display: true,
position: 'right' as const,
labels: {
padding: 14,
font: { size: 11, weight: 'bold' as const },
usePointStyle: true,
generateLabels: (chart: any) =>
chart.data.datasets.map((ds: any, i: number) => {
const color: string = ds.borderColor || '#64748b';
const pill = document.createElement('canvas');
pill.width = 10; pill.height = 10;
const pCtx = pill.getContext('2d');
if (pCtx) {
pCtx.strokeStyle = color;
pCtx.lineWidth = 1;
pCtx.beginPath();
pCtx.arc(5, 5, 4, 0, Math.PI * 2);
pCtx.stroke();
}
return { text: ds.label, fillStyle: color, strokeStyle: color,
fontColor: color, lineWidth: 0, pointStyle: pill,
hidden: !chart.isDatasetVisible(i), datasetIndex: i };
}),
},
},
tooltip: {
...chartOpts.plugins.tooltip,
callbacks: {
title: (items: any[]) => trendResult.tooltipLabels[items[0]?.dataIndex] ?? items[0]?.label,
}
}
}
}), [chartOpts, trendResult.tooltipLabels]);
const pieOptions: any = useMemo(() => ({ const pieOptions: any = useMemo(() => ({
responsive: true, maintainAspectRatio: false, responsive: true, maintainAspectRatio: false,
plugins: { plugins: {
@@ -248,9 +328,11 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)} {granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" />
<button type="button" aria-pressed={showLabels} className={`alt-ctrl${showLabels?' alt-ctrl-on':''}`} onClick={() => setShowLabels(v => !v)}>{'Labels'}</button>
</div> </div>
</div> </div>
<div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={chartOpts} /></div> <div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={trendOpts} /></div>
</div> </div>
<div className="alt-chart-card"> <div className="alt-chart-card">
+633
View File
@@ -0,0 +1,633 @@
import React from 'react';
import {
Document, Page, View, Text, Image, StyleSheet, Font
} from '@react-pdf/renderer';
import { PdfTrendChart, PdfHBarChart, CHART_PALETTE } from './reportCharts';
import {
ReportData, MuseumDataRow, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary
} from './reportHelpers';
Font.register({
family: 'IBMPlexArabic',
fonts: [
{ src: '/fonts/IBMPlexSansArabic-Regular.woff2', fontWeight: 400 },
{ src: '/fonts/IBMPlexSansArabic-Bold.woff2', fontWeight: 700 },
],
});
const TOTAL_LINE_COLOR = '#1e293b';
// A4 content width minus chart-wrap padding (14×2)
// Portrait: 595 - 44 - 44 - 28 = 479
// Landscape: 842 - 44 - 44 - 28 = 726
const CHART_W = { portrait: 479, landscape: 726 } as const;
const S = StyleSheet.create({
page: { fontFamily: 'Helvetica', fontSize: 10, color: '#0f172a', backgroundColor: '#ffffff' },
// ── Cover ──────────────────────────────────────────────
coverPage: { flexDirection: 'column', padding: 0 },
coverHeader: { paddingTop: 56, paddingRight: 52, paddingBottom: 52, paddingLeft: 52 },
coverHeaderTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 48 },
coverBrand: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', letterSpacing: 0.8 },
coverLogoBox: { width: 90, height: 44, justifyContent: 'flex-end', alignItems: 'flex-end' },
coverClientLogo: { width: 90, height: 44, objectFit: 'contain' as const },
coverTitle: { fontSize: 36, fontFamily: 'Helvetica-Bold', color: '#ffffff', lineHeight: 1.2 },
coverBody: { flex: 1, paddingTop: 44, paddingRight: 52, paddingBottom: 44, paddingLeft: 52, flexDirection: 'column' },
coverClientName: { fontSize: 15, color: '#0f172a', fontFamily: 'Helvetica-Bold', marginBottom: 5 },
coverContactName: { fontSize: 11, color: '#64748b', marginBottom: 32 },
coverBodySpacer: { flex: 1 },
coverPeriodRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 5 },
coverPeriodDot: { width: 6, height: 6, borderRadius: 3, marginRight: 8 },
coverPeriod: { fontSize: 12, color: '#334155', fontFamily: 'Helvetica-Oblique' },
coverDate: { fontSize: 9, color: '#94a3b8', marginBottom: 20 },
coverConfidential: { fontSize: 7.5, color: '#94a3b8', letterSpacing: 2, paddingTop: 10, borderTopWidth: 1, borderTopColor: '#e2e8f0' },
// ── Content pages ──────────────────────────────────────
contentPage: { paddingTop: 34, paddingRight: 44, paddingBottom: 54, paddingLeft: 44 },
pageHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderBottomWidth: 1.5, borderBottomColor: '#e2e8f0', paddingBottom: 10, marginBottom: 26 },
pageHeaderLogo: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: '#2563eb' },
pageHeaderTitle: { fontSize: 9, color: '#94a3b8' },
pageHeaderNum: { fontSize: 9, color: '#94a3b8' },
pageFooter: { position: 'absolute', bottom: 22, left: 44, right: 44, flexDirection: 'row', justifyContent: 'space-between', borderTopWidth: 1, borderTopColor: '#f1f5f9', paddingTop: 6 },
pageFooterText: { fontSize: 7.5, color: '#b0bec5' },
// ── Section headings ───────────────────────────────────
sectionHeading: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', paddingTop: 8, paddingRight: 14, paddingBottom: 8, paddingLeft: 14, marginBottom: 16, borderRadius: 4 },
sectionGap: { marginBottom: 28 },
// ── Executive summary ──────────────────────────────────
summaryText: { fontSize: 10.5, color: '#334155', lineHeight: 1.7 },
// ── Key metrics table ──────────────────────────────────
metricsTable: { marginBottom: 8 },
metricsHeaderRow: { flexDirection: 'row', backgroundColor: '#f1f5f9', paddingTop: 5, paddingBottom: 5, marginBottom: 2, borderRadius: 3 },
metricsHeaderLabel: { flex: 1.8, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', paddingLeft: 8 },
metricsHeaderCell: { flex: 1, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right', paddingRight: 6 },
metricsRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 7 },
metricsRowAlt: { backgroundColor: '#fafbfd' },
metricsLabel: { flex: 1.8, fontSize: 10, color: '#334155', fontFamily: 'Helvetica-Bold', paddingLeft: 8 },
metricsValue: { flex: 1, fontSize: 10, color: '#0f172a', textAlign: 'right', paddingRight: 6 },
metricsChange: { flex: 0.8, fontSize: 9, textAlign: 'right', paddingRight: 6 },
metricsChangeUp: { color: '#059669' },
metricsChangeDown: { color: '#dc2626' },
// ── Trend chart ────────────────────────────────────────
chartWrap: { marginBottom: 8, backgroundColor: '#f8fafc', paddingTop: 14, paddingRight: 14, paddingBottom: 14, paddingLeft: 14, borderRadius: 6, borderWidth: 1, borderColor: '#f1f5f9' },
legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 10 },
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18, marginBottom: 4 },
legendDot: { width: 8, height: 8, borderRadius: 4 },
legendLabel: { fontSize: 8, color: '#64748b', marginLeft: 5 },
// ── Museum mini-reports ────────────────────────────────
museumBlock: { marginBottom: 20, borderLeftWidth: 3, paddingLeft: 12 },
museumBlockName: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#0f172a', marginBottom: 4 },
museumIntroText: { fontSize: 9.5, color: '#64748b', fontFamily: 'Helvetica-Oblique', marginBottom: 10 },
miniTable: { marginBottom: 4 },
miniHeaderRow: { flexDirection: 'row', backgroundColor: '#f1f5f9', paddingTop: 4, paddingBottom: 4, marginBottom: 1, borderRadius: 2 },
miniHeaderLabel: { flex: 2, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', paddingLeft: 6 },
miniHeaderCell: { flex: 2, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right', paddingRight: 6 },
miniHeaderChangeCell: { flex: 1, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right', paddingRight: 6 },
miniRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 5 },
miniRowAlt: { backgroundColor: '#fafafa' },
miniLabel: { flex: 2, fontSize: 9.5, color: '#334155', fontFamily: 'Helvetica-Bold', paddingLeft: 6 },
miniValue: { flex: 2, fontSize: 9.5, color: '#0f172a', textAlign: 'right', paddingRight: 6 },
miniChange: { flex: 1, fontSize: 9, textAlign: 'right', paddingRight: 6 },
miniChangeUp: { color: '#059669' },
miniChangeDown: { color: '#dc2626' },
// ── Global summary table ───────────────────────────────
summarySubLabel: { fontSize: 9, color: '#64748b', marginBottom: 14 },
summaryHeaderRow: { flexDirection: 'row', backgroundColor: '#0f172a', paddingTop: 8, paddingBottom: 8, borderRadius: 4 },
summaryHeaderMuseum: { flex: 3, fontSize: 8.5, fontFamily: 'Helvetica-Bold', color: '#ffffff', paddingLeft: 10 },
summaryHeaderMetric: { flex: 2, fontSize: 8.5, fontFamily: 'Helvetica-Bold', color: '#ffffff', textAlign: 'right', paddingRight: 6 },
summaryHeaderDelta: { flex: 1, fontSize: 8.5, fontFamily: 'Helvetica-Bold', color: '#ffffff', textAlign: 'right', paddingRight: 6 },
summaryRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 6 },
summaryRowAlt: { backgroundColor: '#f8fafc' },
summaryTotalRow: { flexDirection: 'row', borderTopWidth: 2, borderTopColor: '#0f172a', paddingTop: 8, paddingBottom: 5, marginTop: 2 },
summaryMuseum: { flex: 3, fontSize: 9.5, color: '#0f172a', paddingLeft: 10 },
summaryMuseumTotal: { flex: 3, fontSize: 9.5, fontFamily: 'Helvetica-Bold', color: '#0f172a', paddingLeft: 10 },
summaryMetric: { flex: 2, fontSize: 9.5, color: '#0f172a', textAlign: 'right', paddingRight: 6 },
summaryMetricTotal: { flex: 2, fontSize: 9.5, fontFamily: 'Helvetica-Bold', color: '#0f172a', textAlign: 'right', paddingRight: 6 },
summaryDelta: { flex: 1, fontSize: 9, textAlign: 'right', paddingRight: 6 },
summaryDeltaUp: { color: '#059669' },
summaryDeltaDown: { color: '#dc2626' },
summaryDeltaTotal: { flex: 1, fontSize: 9, fontFamily: 'Helvetica-Bold', textAlign: 'right', paddingRight: 6 },
});
function pctChange(curr: number, prev: number): number {
if (prev === 0) return 0;
return Math.round(((curr - prev) / prev) * 100);
}
function museumIntro(row: MuseumDataRow, lang: 'en' | 'ar', compLabel: string): string {
if (!row.prev) return '';
const revChg = pctChange(row.curr.revenue, row.prev.revenue);
const visChg = pctChange(row.curr.visitors, row.prev.visitors);
if (lang === 'en') {
return `Revenue ${revChg >= 0 ? 'up' : 'down'} ${Math.abs(revChg)}%, visitors ${visChg >= 0 ? 'up' : 'down'} ${Math.abs(visChg)}% vs ${compLabel}.`;
}
return `الإيرادات ${revChg >= 0 ? 'ارتفعت' : 'انخفضت'} ${Math.abs(revChg)}%، الزوار ${visChg >= 0 ? 'ارتفعوا' : 'انخفضوا'} ${Math.abs(visChg)}% مقارنةً بـ${compLabel}.`;
}
interface PageHeaderProps { title: string; page: number; isAr: boolean; arB: any; }
function PageHeader({ title, page, isAr, arB }: PageHeaderProps) {
return (
<View style={S.pageHeader}>
<Text style={[S.pageHeaderLogo, arB]}>HiHala Data</Text>
<Text style={[S.pageHeaderTitle, isAr ? { fontFamily: 'IBMPlexArabic' } : {}]}>{title}</Text>
<Text style={S.pageHeaderNum}>{page}</Text>
</View>
);
}
interface PageFooterProps { confidentiality: string; generatedAt: string; }
function PageFooter({ confidentiality, generatedAt }: PageFooterProps) {
return (
<View style={S.pageFooter}>
<Text style={S.pageFooterText}>{confidentiality}</Text>
<Text style={S.pageFooterText}>Generated {generatedAt}</Text>
</View>
);
}
interface SectionProps { title: string; color: string; arB: any; }
function SectionHeading({ title, color, arB }: SectionProps) {
return (
<View style={[S.sectionHeading, { backgroundColor: color }]}>
<Text style={arB}>{title}</Text>
</View>
);
}
interface Props { data: ReportData; }
export function ReportDocument({ data }: Props) {
const { config: cfg, metrics, prevMetrics, comparisonPeriodLabel,
trendCharts,
museumData, channelBreakdown, districtBreakdown,
pilgrimCapture, generatedAt } = data;
const lang = cfg.language;
const isAr = lang === 'ar';
const color = cfg.accentColor;
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
const isLandscape = cfg.orientation === 'landscape';
const orientation = isLandscape ? 'landscape' : 'portrait';
const T = lang === 'en' ? LABELS_EN : LABELS_AR;
// Arabic font overrides — Helvetica has no Arabic glyphs; cast as any so style arrays stay compatible
const arN: any = isAr ? { fontFamily: 'IBMPlexArabic', fontWeight: 400 } : {};
const arB: any = isAr ? { fontFamily: 'IBMPlexArabic', fontWeight: 700 } : {};
// direction: 'rtl' flips flex-row children right-to-left; fontFamily cascades to elements without an explicit one
const arPageExtra: any = isAr ? { direction: 'rtl', fontFamily: 'IBMPlexArabic' } : {};
const chartW = isLandscape ? CHART_W.landscape : CHART_W.portrait;
const avgTicketPrice = metrics.tickets > 0 ? metrics.revenue / metrics.tickets : 0;
const prevAvgTicketPrice = prevMetrics && prevMetrics.tickets > 0
? prevMetrics.revenue / prevMetrics.tickets : null;
const metricsRows = [
{ label: T.revenue, curr: formatCurrency(metrics.revenue, cfg.includeVAT),
prev: prevMetrics ? formatCurrency(prevMetrics.revenue, cfg.includeVAT) : null,
chg: prevMetrics ? pctChange(metrics.revenue, prevMetrics.revenue) : null },
{ label: T.visitors, curr: metrics.visitors.toLocaleString(),
prev: prevMetrics ? prevMetrics.visitors.toLocaleString() : null,
chg: prevMetrics ? pctChange(metrics.visitors, prevMetrics.visitors) : null },
{ label: T.tickets, curr: metrics.tickets.toLocaleString(),
prev: prevMetrics ? prevMetrics.tickets.toLocaleString() : null,
chg: prevMetrics ? pctChange(metrics.tickets, prevMetrics.tickets) : null },
{ label: T.avgRev, curr: formatCurrency(metrics.avgRevPerVisitor, false),
prev: prevMetrics ? formatCurrency(prevMetrics.avgRevPerVisitor, false) : null,
chg: prevMetrics ? pctChange(metrics.avgRevPerVisitor, prevMetrics.avgRevPerVisitor) : null },
{ label: T.avgTicketPrice, curr: formatCurrency(avgTicketPrice, false),
prev: prevAvgTicketPrice !== null ? formatCurrency(prevAvgTicketPrice, false) : null,
chg: prevAvgTicketPrice !== null ? pctChange(avgTicketPrice, prevAvgTicketPrice) : null },
...(cfg.showPilgrimCapture && pilgrimCapture ? [{
label: T.capture, curr: `${pilgrimCapture.current}%`,
prev: pilgrimCapture.previous !== null ? `${pilgrimCapture.previous}%` : null,
chg: pilgrimCapture.previous !== null ? pctChange(pilgrimCapture.current, pilgrimCapture.previous) : null,
}] : []),
];
const showMuseumPage = cfg.showMuseumRevenue || cfg.showMuseumVisitors || cfg.showMuseumTickets;
const showChannelPage = cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets;
const showDistrictPage = cfg.showDistrictRevenue || cfg.showDistrictVisitors || cfg.showDistrictTickets;
const showSummaryPage = cfg.showGlobalSummary && cfg.includeComparison;
let pg = 1;
const mainPg = ++pg;
const museumPg = showMuseumPage ? ++pg : 0;
const channelPg = showChannelPage ? ++pg : 0;
const districtPg = showDistrictPage ? ++pg : 0;
const summaryPg = showSummaryPage ? ++pg : 0;
const museumMetricRows = (row: MuseumDataRow) => {
const rows = [];
if (cfg.showMuseumRevenue) rows.push({
label: T.revenue,
curr: formatCurrency(row.curr.revenue, cfg.includeVAT),
prev: row.prev ? formatCurrency(row.prev.revenue, cfg.includeVAT) : null,
chg: row.prev ? pctChange(row.curr.revenue, row.prev.revenue) : null,
});
if (cfg.showMuseumVisitors) rows.push({
label: T.visitors,
curr: row.curr.visitors.toLocaleString(),
prev: row.prev ? row.prev.visitors.toLocaleString() : null,
chg: row.prev ? pctChange(row.curr.visitors, row.prev.visitors) : null,
});
if (cfg.showMuseumTickets) rows.push({
label: T.tickets,
curr: row.curr.tickets.toLocaleString(),
prev: row.prev ? row.prev.tickets.toLocaleString() : null,
chg: row.prev ? pctChange(row.curr.tickets, row.prev.tickets) : null,
});
return rows;
};
return (
<Document title={cfg.title || 'HiHala Report'} author="HiHala Data">
{/* ── Cover ─────────────────────────────────────────── */}
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage, arPageExtra]}>
<View style={[S.coverHeader, { backgroundColor: color }]}>
<View style={S.coverHeaderTop}>
<Text style={[S.coverBrand, arB]}>HiHala Data</Text>
{cfg.clientLogoBase64 && (
<View style={S.coverLogoBox}>
<Image src={cfg.clientLogoBase64} style={S.coverClientLogo} />
</View>
)}
</View>
<Text style={[S.coverTitle, arB]}>{cfg.title || T.defaultTitle}</Text>
</View>
<View style={S.coverBody}>
{cfg.clientName && (
<Text style={[S.coverClientName, arB]}>{T.preparedFor}: {cfg.clientName}</Text>
)}
{cfg.contactName && (
<Text style={[S.coverContactName, arN]}>{T.attention}: {cfg.contactName}</Text>
)}
<View style={S.coverBodySpacer} />
<View style={S.coverPeriodRow}>
<View style={[S.coverPeriodDot, { backgroundColor: color }]} />
<Text style={[S.coverPeriod, arN]}>{period}</Text>
</View>
<Text style={S.coverDate}>{T.generated}: {generatedAt}</Text>
{cfg.confidentiality !== 'Public' && (
<Text style={S.coverConfidential}>{cfg.confidentiality.toUpperCase()}</Text>
)}
</View>
</Page>
{/* ── Summary + Metrics + Trend ──────────────────────── */}
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
<PageHeader title={cfg.title || T.defaultTitle} page={mainPg} isAr={isAr} arB={arB} />
{cfg.showExecutiveSummary && (
<View style={S.sectionGap}>
<SectionHeading title={T.execSummary} color={color} arB={arB} />
<Text style={[S.summaryText, arN]}>{generateExecutiveSummary(data)}</Text>
</View>
)}
{cfg.showMetricsTable && (
<View style={S.sectionGap}>
<SectionHeading title={`${T.keyMetrics}${cfg.includeVAT ? T.inclVAT : T.exclVAT}`} color={color} arB={arB} />
<View style={S.metricsTable}>
<View style={S.metricsHeaderRow}>
<Text style={[S.metricsHeaderLabel, arB]}> </Text>
<Text style={[S.metricsHeaderCell, arB]}>{period}</Text>
{prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
{prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{T.change}</Text>}
</View>
{metricsRows.map((row, i) => (
<View key={row.label} style={[S.metricsRow, i % 2 === 1 ? S.metricsRowAlt : {}]}>
<Text style={[S.metricsLabel, arB]}>{row.label}</Text>
<Text style={[S.metricsValue, arN]}>{row.curr}</Text>
{prevMetrics && <Text style={[S.metricsValue, arN]}>{row.prev ?? '—'}</Text>}
{prevMetrics && row.chg !== null && (
<Text style={[S.metricsChange, row.chg >= 0 ? S.metricsChangeUp : S.metricsChangeDown]}>
{formatPct(row.chg)}
</Text>
)}
</View>
))}
</View>
</View>
)}
{cfg.showTrendChart && trendCharts.map((tc, tci) => {
const trendTitle = tc.metric === 'visitors' ? T.trendVisitors
: tc.metric === 'tickets' ? T.trendTickets
: T.trendRevenue;
return (
<View key={tci} style={S.sectionGap}>
<SectionHeading title={trendTitle} color={color} arB={arB} />
<View style={S.legendRow}>
{tc.museums.length >= 2 && tc.museums.map((m, i) => (
<View key={m.name} style={S.legendItem}>
<View style={[S.legendDot, { backgroundColor: CHART_PALETTE[i % CHART_PALETTE.length] }]} />
<Text style={[S.legendLabel, arN]}>{m.name}</Text>
</View>
))}
<View style={S.legendItem}>
<View style={[S.legendDot, { backgroundColor: TOTAL_LINE_COLOR }]} />
<Text style={[S.legendLabel, arN]}>{tc.museums.length >= 2 ? `Total · ${period}` : period}</Text>
</View>
{cfg.includeComparison && tc.previous && (
<View style={S.legendItem}>
<View style={[S.legendDot, { backgroundColor: '#94a3b8' }]} />
<Text style={[S.legendLabel, arN]}>{comparisonPeriodLabel}</Text>
</View>
)}
</View>
<View style={S.chartWrap}>
<PdfTrendChart
labels={tc.labels}
current={tc.current}
previous={tc.previous}
color={TOTAL_LINE_COLOR}
width={chartW}
height={155}
series={tc.museums.length >= 2 ? tc.museums.map((m, i) => ({
label: m.name,
color: CHART_PALETTE[i % CHART_PALETTE.length],
data: m.values,
})) : undefined}
/>
</View>
</View>
);
})}
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
</Page>
{/* ── Museum Mini-Reports ────────────────────────────── */}
{showMuseumPage && museumData.length > 0 && (
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
<PageHeader title={cfg.title || T.defaultTitle} page={museumPg} isAr={isAr} arB={arB} />
<SectionHeading title={T.museumBreakdowns} color={color} arB={arB} />
{museumData.map((row, mi) => {
const mRows = museumMetricRows(row);
const hasPrev = row.prev !== null;
return (
<View key={row.name} style={[S.museumBlock, { borderLeftColor: CHART_PALETTE[mi % CHART_PALETTE.length] }]}>
<Text style={[S.museumBlockName, arB]}>{row.name}</Text>
{hasPrev && (
<Text style={[S.museumIntroText, arN]}>
{museumIntro(row, lang, comparisonPeriodLabel)}
</Text>
)}
<View style={S.miniTable}>
<View style={S.miniHeaderRow}>
<Text style={[S.miniHeaderLabel, arB]}> </Text>
<Text style={[S.miniHeaderCell, arB]}>{period}</Text>
{hasPrev && <Text style={[S.miniHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
{hasPrev && <Text style={[S.miniHeaderChangeCell, arB]}>{T.change}</Text>}
</View>
{mRows.map((mr, ri) => (
<View key={mr.label} style={[S.miniRow, ri % 2 === 1 ? S.miniRowAlt : {}]}>
<Text style={[S.miniLabel, arB]}>{mr.label}</Text>
<Text style={[S.miniValue, arN]}>{mr.curr}</Text>
{hasPrev && <Text style={[S.miniValue, arN]}>{mr.prev ?? '—'}</Text>}
{hasPrev && mr.chg !== null && (
<Text style={[S.miniChange, mr.chg >= 0 ? S.miniChangeUp : S.miniChangeDown]}>
{formatPct(mr.chg)}
</Text>
)}
</View>
))}
</View>
</View>
);
})}
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
</Page>
)}
{/* ── Channel Breakdowns ─────────────────────────────── */}
{showChannelPage && (
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
<PageHeader title={cfg.title || T.defaultTitle} page={channelPg} isAr={isAr} arB={arB} />
{cfg.showChannelRevenue && channelBreakdown.revenue.length > 0 && (
<View style={S.sectionGap}>
<SectionHeading title={T.byChannelRevenue} color={color} arB={arB} />
<View style={S.chartWrap}>
<PdfHBarChart items={channelBreakdown.revenue} color={color} usepalette width={chartW} />
</View>
</View>
)}
{cfg.showChannelVisitors && channelBreakdown.visitors.length > 0 && (
<View style={S.sectionGap}>
<SectionHeading title={T.byChannelVisitors} color={color} arB={arB} />
<View style={S.chartWrap}>
<PdfHBarChart items={channelBreakdown.visitors} color={color} usepalette width={chartW} />
</View>
</View>
)}
{cfg.showChannelTickets && channelBreakdown.tickets.length > 0 && (
<View style={S.sectionGap}>
<SectionHeading title={T.byChannelTickets} color={color} arB={arB} />
<View style={S.chartWrap}>
<PdfHBarChart items={channelBreakdown.tickets} color={color} usepalette width={chartW} />
</View>
</View>
)}
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
</Page>
)}
{/* ── District Breakdowns ────────────────────────────── */}
{showDistrictPage && (
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
<PageHeader title={cfg.title || T.defaultTitle} page={districtPg} isAr={isAr} arB={arB} />
{cfg.showDistrictRevenue && districtBreakdown.revenue.length > 0 && (
<View style={S.sectionGap}>
<SectionHeading title={T.byDistrictRevenue} color={color} arB={arB} />
<View style={S.chartWrap}>
<PdfHBarChart items={districtBreakdown.revenue} color={color} usepalette width={chartW} />
</View>
</View>
)}
{cfg.showDistrictVisitors && districtBreakdown.visitors.length > 0 && (
<View style={S.sectionGap}>
<SectionHeading title={T.byDistrictVisitors} color={color} arB={arB} />
<View style={S.chartWrap}>
<PdfHBarChart items={districtBreakdown.visitors} color={color} usepalette width={chartW} />
</View>
</View>
)}
{cfg.showDistrictTickets && districtBreakdown.tickets.length > 0 && (
<View style={S.sectionGap}>
<SectionHeading title={T.byDistrictTickets} color={color} arB={arB} />
<View style={S.chartWrap}>
<PdfHBarChart items={districtBreakdown.tickets} color={color} usepalette width={chartW} />
</View>
</View>
)}
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
</Page>
)}
{/* ── Global Performance Summary ─────────────────────── */}
{showSummaryPage && museumData.length > 0 && (
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
<PageHeader title={cfg.title || T.defaultTitle} page={summaryPg} isAr={isAr} arB={arB} />
<SectionHeading title={T.globalSummary} color={color} arB={arB} />
<Text style={[S.summarySubLabel, arN]}>
{period} {T.comparedTo} {comparisonPeriodLabel}
</Text>
<View style={S.summaryHeaderRow}>
<Text style={[S.summaryHeaderMuseum, arB]}>{T.museum}</Text>
{cfg.showMuseumRevenue && <>
<Text style={[S.summaryHeaderMetric, arB]}>{T.revenue}</Text>
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
</>}
{cfg.showMuseumVisitors && <>
<Text style={[S.summaryHeaderMetric, arB]}>{T.visitors}</Text>
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
</>}
{cfg.showMuseumTickets && <>
<Text style={[S.summaryHeaderMetric, arB]}>{T.tickets}</Text>
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
</>}
</View>
{museumData.map((row, i) => {
const hasPrev = row.prev !== null;
return (
<View key={row.name} style={[S.summaryRow, i % 2 === 1 ? S.summaryRowAlt : {}]}>
<Text style={[S.summaryMuseum, arN]}>{row.name.length > 30 ? row.name.slice(0, 30) + '…' : row.name}</Text>
{cfg.showMuseumRevenue && <>
<Text style={[S.summaryMetric, arN]}>{formatCurrency(row.curr.revenue, cfg.includeVAT)}</Text>
{hasPrev && row.prev ? (() => {
const c = pctChange(row.curr.revenue, row.prev!.revenue);
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
})() : <Text style={S.summaryDelta}></Text>}
</>}
{cfg.showMuseumVisitors && <>
<Text style={[S.summaryMetric, arN]}>{row.curr.visitors.toLocaleString()}</Text>
{hasPrev && row.prev ? (() => {
const c = pctChange(row.curr.visitors, row.prev!.visitors);
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
})() : <Text style={S.summaryDelta}></Text>}
</>}
{cfg.showMuseumTickets && <>
<Text style={[S.summaryMetric, arN]}>{row.curr.tickets.toLocaleString()}</Text>
{hasPrev && row.prev ? (() => {
const c = pctChange(row.curr.tickets, row.prev!.tickets);
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
})() : <Text style={S.summaryDelta}></Text>}
</>}
</View>
);
})}
<View style={S.summaryTotalRow}>
<Text style={[S.summaryMuseumTotal, arB]}>{T.total}</Text>
{cfg.showMuseumRevenue && <>
<Text style={[S.summaryMetricTotal, arB]}>{formatCurrency(metrics.revenue, cfg.includeVAT)}</Text>
{prevMetrics ? (() => {
const c = pctChange(metrics.revenue, prevMetrics.revenue);
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
})() : <Text style={S.summaryDeltaTotal}></Text>}
</>}
{cfg.showMuseumVisitors && <>
<Text style={[S.summaryMetricTotal, arB]}>{metrics.visitors.toLocaleString()}</Text>
{prevMetrics ? (() => {
const c = pctChange(metrics.visitors, prevMetrics.visitors);
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
})() : <Text style={S.summaryDeltaTotal}></Text>}
</>}
{cfg.showMuseumTickets && <>
<Text style={[S.summaryMetricTotal, arB]}>{metrics.tickets.toLocaleString()}</Text>
{prevMetrics ? (() => {
const c = pctChange(metrics.tickets, prevMetrics.tickets);
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
})() : <Text style={S.summaryDeltaTotal}></Text>}
</>}
</View>
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
</Page>
)}
</Document>
);
}
const LABELS_EN = {
defaultTitle: 'Performance Report',
preparedFor: 'Prepared for',
attention: 'Attention',
generated: 'Generated',
execSummary: 'Executive Summary',
keyMetrics: 'Key Metrics',
inclVAT: 'Incl. VAT',
exclVAT: 'Excl. VAT',
change: 'Change',
comparedTo: 'vs.',
trendRevenue: 'Revenue Trend',
trendVisitors: 'Visitor Trend',
trendTickets: 'Ticket Trend',
museumBreakdowns: 'Museum Breakdown',
byChannelRevenue: 'Revenue by Channel',
byChannelVisitors: 'Visitors by Channel',
byChannelTickets: 'Tickets by Channel',
byDistrictRevenue: 'Revenue by District',
byDistrictVisitors: 'Visitors by District',
byDistrictTickets: 'Tickets by District',
globalSummary: 'Performance Summary',
museum: 'Museum',
total: 'TOTAL',
revenue: 'Revenue',
visitors: 'Visitors',
tickets: 'Tickets',
avgRev: 'Avg Rev / Visitor',
avgTicketPrice: 'Avg Ticket Price',
capture: 'Pilgrim Capture Rate',
};
const LABELS_AR = {
defaultTitle: 'تقرير الأداء',
preparedFor: 'مُعدّ لـ',
attention: 'عناية',
generated: 'تاريخ الإصدار',
execSummary: 'الملخص التنفيذي',
keyMetrics: 'المؤشرات الرئيسية',
inclVAT: 'شامل ضريبة القيمة المضافة',
exclVAT: 'غير شامل ضريبة القيمة المضافة',
change: 'التغيّر',
comparedTo: 'مقابل',
trendRevenue: 'اتجاه الإيرادات',
trendVisitors: 'اتجاه الزوار',
trendTickets: 'اتجاه التذاكر',
museumBreakdowns: 'تفاصيل المتاحف',
byChannelRevenue: 'الإيرادات حسب القناة',
byChannelVisitors: 'الزوار حسب القناة',
byChannelTickets: 'التذاكر حسب القناة',
byDistrictRevenue: 'الإيرادات حسب الحي',
byDistrictVisitors: 'الزوار حسب الحي',
byDistrictTickets: 'التذاكر حسب الحي',
globalSummary: 'ملخص الأداء',
museum: 'المتحف',
total: 'الإجمالي',
revenue: 'الإيرادات',
visitors: 'الزوار',
tickets: 'التذاكر',
avgRev: 'متوسط الإيراد / زائر',
avgTicketPrice: 'متوسط سعر التذكرة',
capture: 'معدل استيعاب الحجاج',
};
+406
View File
@@ -0,0 +1,406 @@
import React, { useRef, useEffect, useState } from 'react';
import AltMultiSelect from '../shared/AltMultiSelect';
import type { ReportConfig, TrendMetric } from './reportHelpers';
interface Props {
config: ReportConfig;
onChange: (patch: Partial<ReportConfig>) => void;
allMuseums: string[];
allChannels: string[];
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="rf-field">
<span className="rf-label">{label}</span>
{children}
</label>
);
}
// C1+C3: role="group" + aria-label + aria-pressed on every button
function PillGroup({ options, value, onChange, label }: {
options: Array<{ label: string; value: string }>;
value: string;
onChange: (v: string) => void;
label: string;
}) {
return (
<div className="rf-metric-pills" role="group" aria-label={label}>
{options.map(opt => (
<button key={opt.value} type="button"
className={`rf-metric-pill${value === opt.value ? ' rf-metric-pill--on' : ''}`}
aria-pressed={value === opt.value}
onClick={() => onChange(opt.value)}>
{opt.label}
</button>
))}
</div>
);
}
function IndeterminateCheckbox({ checked, indeterminate, onChange, className }: {
checked: boolean; indeterminate: boolean; onChange: (v: boolean) => void; className?: string;
}) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<input ref={ref} type="checkbox" checked={checked}
onChange={e => onChange(e.target.checked)} className={className} />
);
}
// C1: aria-hidden badge (visual only), role/aria on header label provides the accessible name
function ModuleCard({ title, badge, enabled, onToggle, children }: {
title: string;
badge?: string;
enabled: boolean;
onToggle: (v: boolean) => void;
children?: React.ReactNode;
}) {
return (
<div className={`rf-module${enabled ? ' rf-module--on' : ''}`}>
<label className="rf-module-header">
<input type="checkbox" checked={enabled}
onChange={e => onToggle(e.target.checked)} className="rf-checkbox" />
<span className="rf-module-title">{title}</span>
{/* aria-hidden: badge is visual state feedback, not part of checkbox label */}
<span className={`rf-module-badge${enabled ? ' rf-module-badge--on' : ''}`} aria-hidden="true">
{badge ?? (enabled ? 'Included' : 'Excluded')}
</span>
</label>
{enabled && children && (
<div className="rf-module-body">{children}</div>
)}
</div>
);
}
type MetricPatch = { revenue?: boolean; visitors?: boolean; tickets?: boolean };
function BreakdownModule({ title, revenue, visitors, tickets, onChange }: {
title: string;
revenue: boolean; visitors: boolean; tickets: boolean;
onChange: (patch: MetricPatch) => void;
}) {
const anyOn = revenue || visitors || tickets;
const allOn = revenue && visitors && tickets;
const badge = anyOn
? [revenue && 'Revenue', visitors && 'Visitors', tickets && 'Tickets'].filter(Boolean).join(' · ')
: 'Excluded';
return (
<div className={`rf-module${anyOn ? ' rf-module--on' : ''}`}>
<label className="rf-module-header">
<IndeterminateCheckbox
checked={anyOn}
indeterminate={anyOn && !allOn}
onChange={v => onChange({ revenue: v, visitors: v, tickets: v })}
className="rf-checkbox"
/>
<span className="rf-module-title">{title}</span>
{/* aria-hidden: badge is visual only */}
<span className={`rf-module-badge${anyOn ? ' rf-module-badge--on' : ''}`} aria-hidden="true">
{badge}
</span>
</label>
{anyOn && (
<div className="rf-module-body">
{/* C1+C3: role="group" + aria-label + aria-pressed */}
<div className="rf-metric-pills" role="group" aria-label={`${title} metrics to include`}>
{([
{ label: 'Revenue', on: revenue, key: 'revenue' as keyof MetricPatch },
{ label: 'Visitors', on: visitors, key: 'visitors' as keyof MetricPatch },
{ label: 'Tickets', on: tickets, key: 'tickets' as keyof MetricPatch },
]).map(({ label, on, key }) => (
<button key={label} type="button"
className={`rf-metric-pill${on ? ' rf-metric-pill--on' : ''}`}
aria-pressed={on}
onClick={() => onChange({ [key]: !on } as MetricPatch)}>
{label}
</button>
))}
</div>
</div>
)}
</div>
);
}
export default function ReportForm({ config: cfg, onChange, allMuseums, allChannels }: Props) {
const logoInputRef = useRef<HTMLInputElement>(null);
// C2: inline error instead of alert()
const [logoError, setLogoError] = useState<string | null>(null);
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
setLogoError('File must be under 2 MB.');
return;
}
setLogoError(null);
const reader = new FileReader();
reader.onload = () => onChange({ clientLogoBase64: reader.result as string });
reader.readAsDataURL(file);
};
return (
<div className="report-form">
<div className="rf-two-col">
{/* ── Left: setup ── */}
<div className="rf-col">
{/* M2: semantic h2 instead of div — visually identical via CSS */}
<h2 className="rf-group-label">Client</h2>
<Field label="Report title">
<input className="rf-input" type="text" value={cfg.title}
onChange={e => onChange({ title: e.target.value })}
placeholder="Q1 2025 Visitor Performance" />
</Field>
<Field label="Prepared for">
<input className="rf-input" type="text" value={cfg.clientName}
onChange={e => onChange({ clientName: e.target.value })}
placeholder="Acme Group" />
</Field>
<Field label="Contact (optional)">
<input className="rf-input" type="text" value={cfg.contactName}
onChange={e => onChange({ contactName: e.target.value })}
placeholder="Mohammed Al-..." />
</Field>
<div className="rf-branding-row">
<div className="rf-field">
<span className="rf-label">Accent color</span>
<div className="rf-color-row">
<input type="color" value={cfg.accentColor}
onChange={e => onChange({ accentColor: e.target.value })}
className="rf-color-input"
aria-label="Report accent color" />
<span className="rf-color-val">{cfg.accentColor}</span>
</div>
</div>
<div className="rf-field">
<span className="rf-label">Logo (PNG/JPG, max 2 MB)</span>
<div className="rf-logo-row">
{/* H6: descriptive aria-label on upload button */}
<button type="button" className="rf-upload-btn"
aria-label={cfg.clientLogoBase64 ? 'Change client logo' : 'Upload client logo'}
onClick={() => logoInputRef.current?.click()}>
{cfg.clientLogoBase64 ? 'Change' : 'Upload'}
</button>
{cfg.clientLogoBase64 && (
<>
{/* M1: meaningful alt text */}
<img src={cfg.clientLogoBase64} alt="Uploaded client logo" className="rf-logo-preview" />
{/* H6: descriptive aria-label on remove button */}
<button type="button" className="rf-remove-btn"
aria-label="Remove client logo"
onClick={() => onChange({ clientLogoBase64: null })}></button>
</>
)}
<input ref={logoInputRef} type="file" accept="image/png,image/jpeg"
style={{ display: 'none' }} onChange={handleLogoUpload} />
</div>
{/* C2: inline logo error */}
{logoError && <span className="rf-field-error" role="alert">{logoError}</span>}
</div>
</div>
<div className="rf-divider" />
<h2 className="rf-group-label">Data</h2>
<div className="rf-date-row">
<Field label="Period start">
<input className="rf-input" type="date" value={cfg.startDate}
onChange={e => onChange({ startDate: e.target.value })} />
</Field>
<Field label="Period end">
<input className="rf-input" type="date" value={cfg.endDate}
onChange={e => onChange({ endDate: e.target.value })} />
</Field>
</div>
<Field label="Museums">
<AltMultiSelect value={cfg.selectedMuseums} options={allMuseums}
onChange={v => onChange({ selectedMuseums: v })}
allLabel="All museums" countLabel={n => `${n} museums`} clearLabel="Clear" />
</Field>
<Field label="Channels">
<AltMultiSelect value={cfg.selectedChannels} options={allChannels}
onChange={v => onChange({ selectedChannels: v })}
allLabel="All channels" countLabel={n => `${n} channels`} clearLabel="Clear" />
</Field>
<div className="rf-field">
<span className="rf-label">VAT</span>
<PillGroup
label="VAT"
options={[{ label: 'Excl. VAT', value: 'excl' }, { label: 'Incl. VAT', value: 'incl' }]}
value={cfg.includeVAT ? 'incl' : 'excl'}
onChange={v => onChange({ includeVAT: v === 'incl' })}
/>
</div>
<label className="rf-check-row">
<input type="checkbox" checked={cfg.includeComparison}
onChange={e => onChange({ includeComparison: e.target.checked })} className="rf-checkbox" />
<span>Include comparison period</span>
</label>
{cfg.includeComparison && (
<div className="rf-comparison-block">
<div className="rf-comparison-label" aria-hidden="true">vs. period</div>
<div className="rf-date-row">
<Field label="From">
<input className="rf-input" type="date" value={cfg.comparisonStartDate}
onChange={e => onChange({ comparisonStartDate: e.target.value })} />
</Field>
<Field label="To">
<input className="rf-input" type="date" value={cfg.comparisonEndDate}
onChange={e => onChange({ comparisonEndDate: e.target.value })} />
</Field>
</div>
</div>
)}
<div className="rf-divider" />
<h2 className="rf-group-label">Format</h2>
<div className="rf-field">
<span className="rf-label">Language</span>
<PillGroup
label="Language"
options={[{ label: 'English', value: 'en' }, { label: 'العربية', value: 'ar' }]}
value={cfg.language}
onChange={v => onChange({ language: v as 'en' | 'ar' })}
/>
</div>
<div className="rf-field">
<span className="rf-label">Orientation</span>
<div className="rf-orient-row" role="group" aria-label="Page orientation">
<button type="button"
className={`rf-orient-btn${cfg.orientation === 'portrait' ? ' rf-orient-btn--on' : ''}`}
aria-pressed={cfg.orientation === 'portrait'}
onClick={() => onChange({ orientation: 'portrait' })}>
<div className="rf-orient-page rf-orient-page--portrait" aria-hidden="true" />
<span>Portrait</span>
</button>
<button type="button"
className={`rf-orient-btn${cfg.orientation === 'landscape' ? ' rf-orient-btn--on' : ''}`}
aria-pressed={cfg.orientation === 'landscape'}
onClick={() => onChange({ orientation: 'landscape' })}>
<div className="rf-orient-page rf-orient-page--landscape" aria-hidden="true" />
<span>Landscape</span>
</button>
</div>
</div>
<div className="rf-field">
<span className="rf-label">Confidentiality</span>
<PillGroup
label="Confidentiality"
options={[
{ label: 'Confidential', value: 'Confidential' },
{ label: 'Internal', value: 'Internal' },
{ label: 'Public', value: 'Public' },
]}
value={cfg.confidentiality}
onChange={v => onChange({ confidentiality: v as ReportConfig['confidentiality'] })}
/>
</div>
</div>
{/* ── Right: content selection ── */}
<div className="rf-col">
<h2 className="rf-group-label">Report Sections</h2>
<ModuleCard title="Executive Summary"
enabled={cfg.showExecutiveSummary} onToggle={v => onChange({ showExecutiveSummary: v })} />
<ModuleCard title="Key Metrics Table"
enabled={cfg.showMetricsTable} onToggle={v => onChange({ showMetricsTable: v })} />
<ModuleCard title="Pilgrim Capture Rate"
enabled={cfg.showPilgrimCapture} onToggle={v => onChange({ showPilgrimCapture: v })} />
<div className="rf-divider" />
<h2 className="rf-group-label">Trend</h2>
<ModuleCard
title="Trend Chart"
enabled={cfg.showTrendChart}
onToggle={v => onChange({ showTrendChart: v })}
badge={cfg.showTrendChart && cfg.trendMetrics.length
? cfg.trendMetrics.map(m => m.charAt(0).toUpperCase() + m.slice(1)).join(' · ')
: undefined}
>
<div className="rf-metric-pills" role="group" aria-label="Trend metrics to include">
{(['revenue', 'visitors', 'tickets'] as TrendMetric[]).map(m => {
const on = cfg.trendMetrics.includes(m);
return (
<button key={m} type="button"
className={`rf-metric-pill${on ? ' rf-metric-pill--on' : ''}`}
aria-pressed={on}
onClick={() => {
const next = on
? cfg.trendMetrics.filter(x => x !== m)
: [...cfg.trendMetrics, m];
onChange({ trendMetrics: next.length ? next : [m] });
}}>
{m.charAt(0).toUpperCase() + m.slice(1)}
</button>
);
})}
</div>
</ModuleCard>
<div className="rf-divider" />
<h2 className="rf-group-label">Breakdowns</h2>
<BreakdownModule title="Museums"
revenue={cfg.showMuseumRevenue} visitors={cfg.showMuseumVisitors} tickets={cfg.showMuseumTickets}
onChange={p => onChange({
showMuseumRevenue: p.revenue ?? cfg.showMuseumRevenue,
showMuseumVisitors: p.visitors ?? cfg.showMuseumVisitors,
showMuseumTickets: p.tickets ?? cfg.showMuseumTickets,
})} />
<BreakdownModule title="Channels"
revenue={cfg.showChannelRevenue} visitors={cfg.showChannelVisitors} tickets={cfg.showChannelTickets}
onChange={p => onChange({
showChannelRevenue: p.revenue ?? cfg.showChannelRevenue,
showChannelVisitors: p.visitors ?? cfg.showChannelVisitors,
showChannelTickets: p.tickets ?? cfg.showChannelTickets,
})} />
<BreakdownModule title="Districts"
revenue={cfg.showDistrictRevenue} visitors={cfg.showDistrictVisitors} tickets={cfg.showDistrictTickets}
onChange={p => onChange({
showDistrictRevenue: p.revenue ?? cfg.showDistrictRevenue,
showDistrictVisitors: p.visitors ?? cfg.showDistrictVisitors,
showDistrictTickets: p.tickets ?? cfg.showDistrictTickets,
})} />
<div className="rf-divider" />
<h2 className="rf-group-label">Summary</h2>
<ModuleCard title="Global Performance Table"
enabled={cfg.showGlobalSummary} onToggle={v => onChange({ showGlobalSummary: v })}>
{!cfg.includeComparison && (
<p className="rf-module-note">
Enable a comparison period to show progression data.
</p>
)}
</ModuleCard>
</div>
</div>
</div>
);
}
+82
View File
@@ -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>
);
}
+155
View File
@@ -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>
);
}
+140
View File
@@ -0,0 +1,140 @@
import React from 'react';
import { Svg, Line, Polyline, Rect, Text as SvgText, G } from '@react-pdf/renderer';
export const CHART_PALETTE = [
'#2563eb', '#0891b2', '#7c3aed', '#059669',
'#d97706', '#dc2626', '#db2777', '#f59e0b',
'#10b981', '#6366f1', '#0284c7', '#65a30d',
];
function fmtAxis(v: number): string {
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (v >= 10_000) return `${Math.round(v / 1_000)}K`;
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
return String(Math.round(v));
}
interface TrendChartProps {
labels: string[];
current: number[];
previous: number[] | null;
color: string;
series?: Array<{ label: string; color: string; data: number[] }>;
width?: number;
height?: number;
}
export function PdfTrendChart({ labels, current, previous, color, series, width = 470, height = 155 }: TrendChartProps) {
const seriesValues = (series ?? []).flatMap(s => s.data);
const allValues = [...current, ...(previous ?? []), ...seriesValues].filter(v => v > 0);
const max = allValues.length > 0 ? Math.max(...allValues) : 1;
// padL wide enough for y-axis labels like "1.2M"
const padL = 38, padR = 8, padT = 10, padB = 20;
const w = width - padL - padR;
const h = height - padT - padB;
const sx = (i: number) => padL + (labels.length > 1 ? (i / (labels.length - 1)) * w : w / 2);
const sy = (v: number) => padT + h - (v / max) * h;
const toPoints = (data: number[]) =>
data.map((v, i) => `${sx(i).toFixed(1)},${sy(v).toFixed(1)}`).join(' ');
const gridLines = [0.25, 0.5, 0.75, 1.0];
return (
<Svg width={width} height={height}>
{/* Baseline */}
<Line x1={padL} y1={(padT + h).toFixed(1)} x2={width - padR} y2={(padT + h).toFixed(1)}
stroke="#cbd5e1" strokeWidth={0.75} />
{/* Grid lines + Y-axis labels */}
{gridLines.map(f => {
const yPos = sy(max * f);
return (
<G key={f}>
<Line x1={padL} y1={yPos.toFixed(1)} x2={width - padR} y2={yPos.toFixed(1)}
stroke="#e2e8f0" strokeWidth={0.5} />
<SvgText x={(padL - 5).toFixed(1)} y={(yPos + 2.5).toFixed(1)}
fill="#94a3b8" style={{ fontSize: 6.5, textAnchor: 'end' }}>
{fmtAxis(max * f)}
</SvgText>
</G>
);
})}
{/* Comparison line (dashed) */}
{previous && previous.some(v => v > 0) && (
<Polyline points={toPoints(previous)}
stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 3" fill="none" />
)}
{/* Per-museum series */}
{(series ?? []).map(s => s.data.some(v => v > 0) && (
<Polyline key={s.label} points={toPoints(s.data)}
stroke={s.color} strokeWidth={1.5} fill="none" />
))}
{/* Current period total line */}
{current.some(v => v > 0) && (
<Polyline points={toPoints(current)}
stroke={color} strokeWidth={series && series.length >= 2 ? 2 : 2.5} fill="none" />
)}
{/* X-axis week labels */}
{labels
.filter((_, i) => labels.length <= 8 || i % Math.ceil(labels.length / 8) === 0)
.map((label) => {
const origIdx = labels.indexOf(label);
return (
<SvgText key={label}
x={sx(origIdx).toFixed(1)} y={height - 5}
style={{ fontSize: 7, fill: '#94a3b8', textAnchor: 'middle' }}>
{label}
</SvgText>
);
})}
</Svg>
);
}
interface HBarChartProps {
items: Array<{ name: string; value: number }>;
color: string;
usepalette?: boolean;
width?: number;
}
export function PdfHBarChart({ items, color, usepalette = false, width = 470 }: HBarChartProps) {
const barH = 17;
const gap = 10;
const labelW = 160;
const barAreaW = width - labelW - 20;
const max = Math.max(...items.map(i => i.value), 1);
const totalH = items.length * (barH + gap);
return (
<Svg width={width} height={totalH}>
{items.map((item, i) => {
const y = i * (barH + gap);
const bw = Math.max((item.value / max) * barAreaW, 2);
const shortName = item.name.length > 26 ? item.name.slice(0, 26) + '…' : item.name;
const valueStr = item.value.toLocaleString('en-SA', { maximumFractionDigits: 0 });
const barColor = usepalette ? CHART_PALETTE[i % CHART_PALETTE.length] : color;
const isShort = bw < 48;
return (
<G key={item.name + i}>
<SvgText x={0} y={y + barH - 4} fill="#334155" style={{ fontSize: 8.5 }}>{shortName}</SvgText>
<Rect x={labelW} y={y} width={bw} height={barH} fill={barColor} rx={3} />
{isShort ? (
<SvgText x={labelW + bw + 6} y={y + barH - 4} fill="#334155"
style={{ fontSize: 8.5 }}>{valueStr}</SvgText>
) : (
<SvgText x={labelW + bw - 6} y={y + barH - 4} fill="#ffffff"
style={{ fontSize: 8.5, textAnchor: 'end' }}>{valueStr}</SvgText>
)}
</G>
);
})}
</Svg>
);
}
+330
View File
@@ -0,0 +1,330 @@
import { filterDataByDateRange, calculateMetrics, groupByMuseum, groupByChannel, groupByDistrict, umrahData } from '../../services/dataService';
import { shiftYear } from '../../lib/dateHelpers';
import type { MuseumRecord, Metrics } from '../../types';
// ─── config ───────────────────────────────────────────────────────
export type TrendMetric = 'revenue' | 'visitors' | 'tickets';
export type TrendGranularity = 'day' | 'week' | 'month';
function inferGranularity(start: string, end: string): TrendGranularity {
const days = Math.round((new Date(end).getTime() - new Date(start).getTime()) / 86400000);
if (days > 180) return 'month';
if (days >= 14) return 'week';
return 'day';
}
const _start = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10);
const _end = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString().slice(0, 10);
export interface ReportConfig {
title: string;
clientName: string;
contactName: string;
clientLogoBase64: string | null;
accentColor: string;
startDate: string;
endDate: string;
selectedMuseums: string[];
selectedChannels: string[];
includeVAT: boolean;
includeComparison: boolean;
comparisonStartDate: string;
comparisonEndDate: string;
// Summary & metrics
showExecutiveSummary: boolean;
showMetricsTable: boolean;
showPilgrimCapture: boolean;
// Trend chart
showTrendChart: boolean;
trendMetrics: TrendMetric[];
// Museum mini-reports
showMuseumRevenue: boolean;
showMuseumVisitors: boolean;
showMuseumTickets: boolean;
// Channel breakdowns
showChannelRevenue: boolean;
showChannelVisitors: boolean;
showChannelTickets: boolean;
// District breakdowns
showDistrictRevenue: boolean;
showDistrictVisitors: boolean;
showDistrictTickets: boolean;
// Global summary table
showGlobalSummary: boolean;
// Presentation
language: 'en' | 'ar';
confidentiality: 'Confidential' | 'Internal' | 'Public';
orientation: 'portrait' | 'landscape';
}
export const DEFAULT_CONFIG: ReportConfig = {
title: '',
clientName: '',
contactName: '',
clientLogoBase64: null,
accentColor: '#2563eb',
startDate: _start,
endDate: _end,
selectedMuseums: [],
selectedChannels: [],
includeVAT: true,
includeComparison: true,
comparisonStartDate: shiftYear(_start),
comparisonEndDate: shiftYear(_end),
showExecutiveSummary: true,
showMetricsTable: true,
showPilgrimCapture: true,
showTrendChart: true,
trendMetrics: ['revenue'],
showMuseumRevenue: true,
showMuseumVisitors: true,
showMuseumTickets: false,
showChannelRevenue: false,
showChannelVisitors: true,
showChannelTickets: false,
showDistrictRevenue: false,
showDistrictVisitors: false,
showDistrictTickets: false,
showGlobalSummary: true,
language: 'en',
confidentiality: 'Confidential',
orientation: 'portrait',
};
// ─── computed report data ─────────────────────────────────────────
export interface BreakdownItem { name: string; value: number; }
export interface DimensionBreakdown {
revenue: BreakdownItem[];
visitors: BreakdownItem[];
tickets: BreakdownItem[];
}
export interface MuseumDataRow {
name: string;
curr: { revenue: number; visitors: number; tickets: number };
prev: { revenue: number; visitors: number; tickets: number } | null;
}
export interface TrendChart {
metric: TrendMetric;
labels: string[];
current: number[];
previous: number[] | null;
museums: Array<{ name: string; values: number[] }>;
}
export interface ReportData {
config: ReportConfig;
metrics: Metrics;
prevMetrics: Metrics | null;
comparisonPeriodLabel: string;
trendCharts: TrendChart[];
museumData: MuseumDataRow[];
museumBreakdown: DimensionBreakdown;
channelBreakdown: DimensionBreakdown;
districtBreakdown: DimensionBreakdown;
pilgrimCapture: { current: number; previous: number | null } | null;
generatedAt: string;
}
// ─── data computation ─────────────────────────────────────────────
function applyDimFilters(rows: MuseumRecord[], cfg: ReportConfig): MuseumRecord[] {
let d = rows;
if (cfg.selectedMuseums.length) d = d.filter(r => cfg.selectedMuseums.includes(r.museum_name));
if (cfg.selectedChannels.length) d = d.filter(r => cfg.selectedChannels.includes(r.channel));
return d;
}
function estimatePilgrims(start: string, end: string): number | null {
const sd = new Date(start), ed = new Date(end);
let total = 0, has = false;
for (let y = sd.getFullYear(); y <= ed.getFullYear(); y++) {
for (let q = 1; q <= 4; q++) {
const qs = new Date(y, (q - 1) * 3, 1), qe = new Date(y, q * 3, 0);
if (qe < sd || qs > ed) continue;
const p = (umrahData as any)[y]?.[q];
if (!p) continue;
const os = new Date(Math.max(qs.getTime(), sd.getTime()));
const oe = new Date(Math.min(qe.getTime(), ed.getTime()));
total += p * ((oe.getTime() - os.getTime()) / 86400000 + 1) /
((qe.getTime() - qs.getTime()) / 86400000 + 1);
has = true;
}
}
return has ? Math.round(total) : null;
}
function getMetricVal(r: MuseumRecord, metric: TrendMetric, includeVAT: boolean): number {
if (metric === 'visitors') return r.visits || 0;
if (metric === 'tickets') return r.tickets || 0;
return (includeVAT ? r.revenue_gross : r.revenue_net) || 0;
}
const MONTH_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function buildTrend(rows: MuseumRecord[], start: string, metric: TrendMetric, includeVAT: boolean, gran: TrendGranularity): { labels: string[]; values: number[] } {
const s = new Date(start);
const acc: Record<number, MuseumRecord[]> = {};
rows.forEach(r => {
if (!r.date) return;
const diff = Math.floor((new Date(r.date).getTime() - s.getTime()) / 86400000);
const key = gran === 'month' ? Math.floor(diff / 30) + 1 : gran === 'week' ? Math.floor(diff / 7) + 1 : diff + 1;
if (!acc[key]) acc[key] = [];
acc[key].push(r);
});
const maxK = Math.max(...Object.keys(acc).map(Number), 1);
const labels = Array.from({ length: maxK }, (_, i) => {
if (gran === 'month') return MONTH_SHORT[(s.getMonth() + i) % 12];
if (gran === 'week') return `W${i + 1}`;
return `${i + 1}`;
});
const values = labels.map((_, i) => {
const group = acc[i + 1] || [];
return group.reduce((sum, r) => sum + getMetricVal(r, metric, includeVAT), 0);
});
return { labels, values };
}
function makeDimensionBreakdown(g: Record<string, { revenue: number; visitors: number; tickets: number }>, limit = 10): DimensionBreakdown {
const entries = Object.entries(g);
const sort = (key: 'revenue' | 'visitors' | 'tickets') =>
entries.map(([name, v]) => ({ name, value: v[key] })).sort((a, b) => b.value - a.value).slice(0, limit);
return { revenue: sort('revenue'), visitors: sort('visitors'), tickets: sort('tickets') };
}
export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): ReportData {
const currRows = applyDimFilters(filterDataByDateRange(allData, cfg.startDate, cfg.endDate, {}), cfg);
const metrics = calculateMetrics(currRows, cfg.includeVAT);
const prevRows = cfg.includeComparison
? applyDimFilters(filterDataByDateRange(allData, cfg.comparisonStartDate, cfg.comparisonEndDate, {}), cfg)
: [];
const prevMetrics = cfg.includeComparison ? calculateMetrics(prevRows, cfg.includeVAT) : null;
const comparisonPeriodLabel = cfg.includeComparison
? formatPeriodLabel(cfg.comparisonStartDate, cfg.comparisonEndDate, cfg.language)
: '';
const gran = inferGranularity(cfg.startDate, cfg.endDate);
const museumNames = Object.keys(groupByMuseum(currRows, cfg.includeVAT))
.filter(name => currRows.some(r => r.museum_name === name));
const trendCharts: TrendChart[] = cfg.trendMetrics.map(metric => {
const currT = buildTrend(currRows, cfg.startDate, metric, cfg.includeVAT, gran);
const prevT = cfg.includeComparison
? buildTrend(prevRows, cfg.comparisonStartDate, metric, cfg.includeVAT, gran)
: null;
const maxLen = Math.max(currT.labels.length, prevT ? prevT.values.length : 0, 1);
const labels = Array.from({ length: maxLen }, (_, i) => currT.labels[i] ?? `${i + 1}`);
const current = Array.from({ length: maxLen }, (_, i) => currT.values[i] ?? 0);
const previous = prevT ? Array.from({ length: maxLen }, (_, i) => prevT.values[i] ?? 0) : null;
const museums = museumNames.map(name => {
const mt = buildTrend(currRows.filter(r => r.museum_name === name), cfg.startDate, metric, cfg.includeVAT, gran);
return { name, values: Array.from({ length: maxLen }, (_, i) => mt.values[i] ?? 0) };
}).filter(m => m.values.some(v => v > 0));
return { metric, labels, current, previous, museums };
});
const currMuseumGroups = groupByMuseum(currRows, cfg.includeVAT);
const prevMuseumGroups = cfg.includeComparison ? groupByMuseum(prevRows, cfg.includeVAT) : {};
const museumData: MuseumDataRow[] = Object.entries(currMuseumGroups)
.map(([name, curr]) => ({ name, curr, prev: prevMuseumGroups[name] ?? null }))
.sort((a, b) => b.curr.revenue - a.curr.revenue);
const museumBreakdown = makeDimensionBreakdown(currMuseumGroups);
const channelBreakdown = makeDimensionBreakdown(groupByChannel(currRows, cfg.includeVAT), 20);
const districtBreakdown = makeDimensionBreakdown(groupByDistrict(currRows, cfg.includeVAT));
const currPilgrims = estimatePilgrims(cfg.startDate, cfg.endDate);
const prevPilgrims = cfg.includeComparison
? estimatePilgrims(cfg.comparisonStartDate, cfg.comparisonEndDate)
: null;
const pilgrimCapture = currPilgrims !== null
? {
current: parseFloat(((metrics.visitors / currPilgrims) * 100).toFixed(2)),
previous: prevPilgrims && prevMetrics
? parseFloat(((prevMetrics.visitors / prevPilgrims) * 100).toFixed(2))
: null,
}
: null;
return {
config: cfg,
metrics,
prevMetrics,
comparisonPeriodLabel,
trendCharts,
museumData,
museumBreakdown,
channelBreakdown,
districtBreakdown,
pilgrimCapture,
generatedAt: new Date().toLocaleDateString('en-GB'),
};
}
// ─── formatters ───────────────────────────────────────────────────
export function formatCurrency(n: number, inclVAT: boolean): string {
return `SAR ${n.toLocaleString('en-SA', { maximumFractionDigits: 0 })}${inclVAT ? '' : ' (ex-VAT)'}`;
}
export function formatPct(change: number): string {
return `${change >= 0 ? '+' : ''}${change.toFixed(1)}%`;
}
export function formatPeriodLabel(start: string, end: string, lang: 'en' | 'ar'): string {
const months = lang === 'en'
? ['January','February','March','April','May','June','July','August','September','October','November','December']
: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'];
const s = new Date(start), e = new Date(end);
const sm = months[s.getMonth()], em = months[e.getMonth()];
const sy = s.getFullYear(), ey = e.getFullYear();
if (sy === ey && sm === em) return `${sm} ${sy}`;
if (sy === ey) return `${sm} ${em} ${sy}`;
return `${sm} ${sy} ${em} ${ey}`;
}
// ─── executive summary ────────────────────────────────────────────
export function generateExecutiveSummary(data: ReportData): string {
const { config: cfg, metrics, prevMetrics, channelBreakdown, comparisonPeriodLabel } = data;
const lang = cfg.language;
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
const revenue = formatCurrency(metrics.revenue, cfg.includeVAT);
const topChannel = channelBreakdown.visitors[0]?.name ?? '';
const totalVisitors = channelBreakdown.visitors.reduce((s, i) => s + i.value, 0);
const topPct = totalVisitors > 0 && channelBreakdown.visitors[0]
? Math.round((channelBreakdown.visitors[0].value / totalVisitors) * 100)
: 0;
const museumLabel = cfg.selectedMuseums.length > 0
? cfg.selectedMuseums.join(', ')
: (lang === 'en' ? 'all museums' : 'جميع المتاحف');
if (lang === 'en') {
let s = `During ${period}, ${museumLabel} recorded ${metrics.visitors.toLocaleString()} visitors and ${revenue} in revenue.`;
if (prevMetrics && prevMetrics.revenue > 0) {
const chg = Math.round(((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue) * 100);
s += ` This represents a ${formatPct(chg)} change in revenue versus ${comparisonPeriodLabel}.`;
}
if (topChannel) s += ` The top-performing channel was ${topChannel} with ${topPct}% of total visitors.`;
return s;
} else {
let s = `خلال ${period}، سجّلت ${museumLabel} ${metrics.visitors.toLocaleString()} زائراً وإيرادات بلغت ${revenue}.`;
if (prevMetrics && prevMetrics.revenue > 0) {
const chg = Math.round(((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue) * 100);
s += ` يمثّل ذلك تغيّراً بنسبة ${formatPct(chg)} في الإيرادات مقارنةً بـ${comparisonPeriodLabel}.`;
}
if (topChannel) s += ` كانت ${topChannel} أعلى القنوات أداءً بنسبة ${topPct}% من إجمالي الزوار.`;
return s;
}
}
// ─── page count estimator ─────────────────────────────────────────
export function estimatePageCount(cfg: ReportConfig): number {
let pages = 2; // cover + summary/metrics/trend
if (cfg.showMuseumRevenue || cfg.showMuseumVisitors || cfg.showMuseumTickets) pages += 1;
if (cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets) pages += 1;
if (cfg.showDistrictRevenue || cfg.showDistrictVisitors || cfg.showDistrictTickets) pages += 1;
if (cfg.showGlobalSummary && cfg.includeComparison) pages += 1;
return pages;
}
+33 -1
View File
@@ -30,6 +30,9 @@ ChartJS.register(
Annotation Annotation
); );
// Used for the "Total" line in multi-museum trend charts — always distinct from chartPalette.
export const TOTAL_COLOR = '#1e293b';
export const chartColors = { export const chartColors = {
primary: '#2563eb', primary: '#2563eb',
secondary: '#7c3aed', secondary: '#7c3aed',
@@ -113,7 +116,9 @@ export const createBaseOptions = (showDataLabels: boolean): any => {
titleFont: { size: 12 }, titleFont: { size: 12 },
bodyFont: { size: 11 }, bodyFont: { size: 11 },
rtl: false, rtl: false,
textDirection: 'ltr' textDirection: 'ltr',
usePointStyle: true,
boxPadding: 6,
}, },
datalabels: createDataLabelConfig(showDataLabels, { datalabels: createDataLabelConfig(showDataLabels, {
color: theme.textPrimary, color: theme.textPrimary,
@@ -134,6 +139,33 @@ export const createBaseOptions = (showDataLabels: boolean): any => {
}; };
}; };
// Hover-dim + end-of-line name labels for multi-museum trend charts.
// Only activates for charts that have datasets marked with _isMuseumLine.
const trendLinePlugin = {
id: 'trendLineOverlay',
// ── hover dim ──────────────────────────────────────────────────
beforeDatasetDraw(chart: any, args: any) {
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
const active = chart.getActiveElements();
if (active.length === 0) return;
if (active[0].datasetIndex !== args.index) {
chart.ctx.save();
chart.ctx.globalAlpha = 0.15;
}
},
afterDatasetDraw(chart: any, args: any) {
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
const active = chart.getActiveElements();
if (active.length > 0 && active[0].datasetIndex !== args.index) {
chart.ctx.restore();
}
},
};
ChartJS.register(trendLinePlugin);
export const lineDatasetDefaults = { export const lineDatasetDefaults = {
borderWidth: 2, borderWidth: 2,
tension: 0.4, tension: 0.4,