Compare commits

...

28 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
fahed c9cfb58896 fix: mobile UX overhaul — collapsible filters, settings nav, responsive layout
Deploy HiHala Dashboard / deploy (push) Successful in 8s
- Add Settings link to desktop nav bar for admin users
- Rewrite Settings page from table layout to responsive card list (fixes unusable mobile state)
- Filter bar (Dashboard + Comparison): collapsible panel on mobile via display:contents trick; stacked full-width dropdowns replace horizontal scroll
- Active filter count badge shown in collapsed filter header
- AltMultiSelect dropdowns go full-width on mobile to prevent viewport overflow
- Chart control separators hidden on mobile to avoid crowding
- Metric grid: 2-col at ≤700px, 1-col at ≤480px
- Comparison period cards: smaller font and tighter padding at ≤680px
- Page shell padding reduced on mobile (48px→20px top, 24px→16px sides)
- Settings page gets correct 80px bottom padding for mobile nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:22:07 +03:00
fahed 30cdb5064a refactor: extract shared locale, date helpers, and components (H6)
~300 lines of code that were independently duplicated in Dashboard.tsx
and Comparison.tsx are now in shared modules:

- src/lib/locale.ts — LC interface, EN and AR language configs (merged
  fields from both pages into one unified interface)
- src/lib/dateHelpers.ts — MONTH_KEYS, isLeap, makePresets, guessPreset,
  periodNameL, dateRangeTextL, currentMonth, shiftYear
- src/components/shared/PeriodPicker.tsx — InlinePicker + PeriodHero
- src/components/shared/AltMultiSelect.tsx — AltMultiSelect
- src/components/shared/MetricCard.tsx — MetricCard

Dashboard.tsx and Comparison.tsx now import from these shared modules.
Zero behavioral changes — all props, ARIA, and render output unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:53:35 +03:00
fahed 25cb91e31b refactor: extract inline style blocks to App.css (H1)
The 130-line (Dashboard) and 155-line (Comparison) inline <style> JSX
blocks are removed and replaced with static CSS in App.css.

Font-family values that changed per language are now set as CSS custom
properties (--alt-body-font, --alt-display-font, --alt-mono-font) via
the root element's style prop — 3 vars instead of re-injecting 130+
lines of CSS on every language switch.

The redundant @import font URLs are dropped (fonts already preloaded
in index.html). Default values for the three font vars are defined in
:root so the page renders correctly before JS executes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:46:41 +03:00
fahed ef9a960e5d fix: responsive, ARIA, performance and CSS cleanup improvements
Addresses remaining medium and low severity audit findings:

- H2: Dark mode @media selector narrowed to :root:not([data-theme]) so
  OS-preference and manual-override blocks are now mutually exclusive
- L2: Remove ~410 lines of dead Slides Builder CSS (no component exists)
- M2: VAT toggle uses flex spacer instead of margin-inline-start:auto,
  preventing layout break when filter bar wraps at medium-small widths
- M3: Page content max-width aligned to 1400px (matches nav bar)
- M5: Period picker toggle now has aria-controls="period-picker-panel"
  pointing to the InlinePicker root in both Dashboard and Comparison
- M6: Offline badge cache timestamp exposed via sr-only span for
  screen reader accessibility (was title-only before)
- M7: chartOpts/barHorizOpts/barNoLegend wrapped in useMemo([baseOpts])
  to prevent unnecessary Chart.js re-renders
- L4: Filter bar scrolls horizontally on mobile instead of wrapping
- L5: Metrics grid uses auto-fit/minmax to eliminate orphaned cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:41:44 +03:00
fahed 9138ac1098 fix: accessibility, theming, and focus-visibility improvements
Addresses critical and high-severity findings from UI audit:

- C1: Define missing CSS tokens (--hover, --bg-primary/secondary/tertiary)
  fixing broken hover states and Slides Builder backgrounds
- C2: Chart colors now read CSS custom properties at render-time via
  getChartTheme(), adapting tooltip, ticks, and grid to dark mode
- C3: Multi-select ARIA fixed — label elements now carry role="option"
  and aria-selected for valid listbox semantics
- H1/M1: Remove unused --gold and duplicate --primary tokens;
  replace all var(--primary) with var(--accent) throughout App.css
- H3/H4: Focus-visible outlines added to all custom interactive elements
  (chips, controls, year buttons, hero button, multi-select trigger)
- H5: access-badge--full hardcoded colors replaced with design tokens
- H7: aria-pressed added to all chart toggle buttons
- L1: Hardcoded #fff/white replaced with var(--text-inverse)
- M4: index.html now preloads DM Serif Display, Outfit, and IBM Plex
  Sans Arabic — all fonts actually used in the app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:46:54 +03:00
fahed d3f9a6cd43 fix: remove duplicate EN/AR language toggle from filter bars
The header already has a language switcher; the one in the filter
bar was redundant on both Dashboard and Comparison pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:42:09 +03:00
28 changed files with 10293 additions and 1601 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)
+1 -1
View File
@@ -8,7 +8,7 @@
<meta name="description" content="HiHala Data Dashboard — Event analytics, visitor tracking, and revenue insights" /> <meta name="description" content="HiHala Data Dashboard — Event analytics, visitor tracking, and revenue insights" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap">
<title>HiHala Data</title> <title>HiHala Data</title>
</head> </head>
<body> <body>
+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
+961 -462
View File
File diff suppressed because it is too large Load Diff
+39
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';
@@ -237,6 +238,26 @@ function App() {
</svg> </svg>
{t('nav.comparison')} {t('nav.comparison')}
</NavLink> </NavLink>
{userRole === 'admin' && (
<NavLink to="/settings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
{t('nav.settings')}
</NavLink>
)}
{userRole === 'admin' && (
<NavLink to="/report">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
Report
</NavLink>
)}
<span className="nav-sep" aria-hidden="true" /> <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()}` : ''}>
@@ -250,6 +271,11 @@ function App() {
<line x1="12" y1="20" x2="12.01" y2="20"/> <line x1="12" y1="20" x2="12.01" y2="20"/>
</svg> </svg>
{t('app.offline') || 'Offline'} {t('app.offline') || 'Offline'}
{cacheInfo && (
<span className="sr-only">
{` (cached ${new Date(cacheInfo.timestamp || '').toLocaleString()})`}
</span>
)}
</span> </span>
)} )}
<button <button
@@ -304,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>
@@ -327,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">
+136 -449
View File
@@ -2,13 +2,19 @@ import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Line, Bar } from 'react-chartjs-2'; import { Line, Bar } from 'react-chartjs-2';
import { import {
filterDataByDateRange, calculateMetrics, formatCurrency, formatNumber, filterDataByDateRange, calculateMetrics,
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 { EN, AR } from '../lib/locale';
import { currentMonth, shiftYear, periodNameL, dateRangeTextL } from '../lib/dateHelpers';
import { InlinePicker } from './shared/PeriodPicker';
import AltMultiSelect from './shared/AltMultiSelect';
import MetricCard from './shared/MetricCard';
interface Props { interface Props {
data: MuseumRecord[]; data: MuseumRecord[];
@@ -19,222 +25,6 @@ interface Props {
lang?: 'en' | 'ar'; lang?: 'en' | 'ar';
} }
// ─── language config ──────────────────────────────────────────────
interface LC {
dir: 'ltr' | 'rtl';
fontImport: string;
bodyFont: string;
displayFont: string;
monoFont: string;
monthFull: string[];
monthShort: string[];
periods: Record<string, string>;
fullYearLabel: (y: number) => string;
dateRangeSep: string;
backLink: string;
backTo: string;
pageTitle: string;
pageSub: string;
currentRole: string; previousRole: string;
currentHint: string; previousHint: string;
changePeriod: string; close: string; apply: string;
vs: string;
filter: string;
allDistricts: string; allChannels: string; allMuseums: string;
countDistricts: (n: number) => string;
countChannels: (n: number) => string;
countMuseums: (n: number) => string;
reset: string;
keyMetrics: string;
revenue: string; visitors: string; tickets: string; avgRev: string;
pilgrims: string; captureRate: string;
trendTitle: string; museumTitle: string;
daily: string; weekly: string; monthly: string;
newLabel: string; clearSel: string;
monthSection: string; periodSection: string;
from: string; to: string;
}
const EN: LC = {
dir: 'ltr',
fontImport: `@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&display=swap');`,
bodyFont: "'Outfit', sans-serif",
displayFont: "'DM Serif Display', serif",
monoFont: "ui-monospace, 'Cascadia Code', monospace",
monthFull: ['January','February','March','April','May','June','July','August','September','October','November','December'],
monthShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
periods: { q1:'Q1', q2:'Q2', q3:'Q3', q4:'Q4', h1:'H1', h2:'H2', full:'Full Year' },
fullYearLabel: (y) => String(y),
dateRangeSep: '→',
backLink: '← Overview', backTo: '/',
pageTitle: 'Period Comparison', pageSub: 'Compare any two periods side by side.',
currentRole: 'This period', previousRole: 'Compared to',
currentHint: 'primary', previousHint: 'auto year 1',
changePeriod: 'Change period', close: 'Cancel', apply: 'Apply',
vs: 'vs',
filter: 'Filter',
allDistricts: 'All districts', allChannels: 'All channels', allMuseums: 'All museums',
countDistricts: (n) => `${n} districts`,
countChannels: (n) => `${n} channels`,
countMuseums: (n) => `${n} museums`,
reset: 'Reset',
keyMetrics: 'Key Metrics',
revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets',
avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %',
trendTitle: 'Trend over time', museumTitle: 'By museum',
daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
newLabel: 'New', clearSel: 'Clear selection',
monthSection: 'Month', periodSection: 'Quarter · Half · Year',
from: 'From', to: 'To',
};
const AR: LC = {
dir: 'rtl',
fontImport: `@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');`,
bodyFont: "'IBM Plex Sans Arabic', sans-serif",
displayFont: "'IBM Plex Sans Arabic', sans-serif",
monoFont: "'IBM Plex Sans Arabic', sans-serif",
monthFull: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'],
monthShort: ['ينا','فبر','مار','أبر','ماي','يون','يول','أغس','سبت','أكت','نوف','ديس'],
periods: { q1:'ر١', q2:'ر٢', q3:'ر٣', q4:'ر٤', h1:'ن١', h2:'ن٢', full:'السنة' },
fullYearLabel: (y) => `${y} كاملاً`,
dateRangeSep: '',
backLink: '← نظرة عامة', backTo: '/ar',
pageTitle: 'مقارنة الفترات', pageSub: 'قارن بين فترتين زمنيتين.',
currentRole: 'الفترة الحالية', previousRole: 'مقارنةً بـ',
currentHint: 'رئيسية', previousHint: 'تلقائياً −١ سنة',
changePeriod: 'تغيير الفترة', close: 'إلغاء', apply: 'تطبيق',
vs: 'مقابل',
filter: 'تصفية',
allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف',
countDistricts: (n) => `${n} مناطق`,
countChannels: (n) => `${n} قنوات`,
countMuseums: (n) => `${n} متاحف`,
reset: 'إعادة ضبط',
keyMetrics: 'المؤشرات الرئيسية',
revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر',
avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %',
trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف',
daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري',
newLabel: 'جديد', clearSel: 'مسح التحديد',
monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة',
from: 'من', to: 'إلى',
};
// ─── date helpers ─────────────────────────────────────────────────
const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
function isLeap(y: number) { return (y%4===0 && y%100!==0) || y%400===0; }
function makePresets(y: number): Record<string, { start: string; end: string }> {
const feb = isLeap(y) ? 29 : 28;
return {
jan:{start:`${y}-01-01`,end:`${y}-01-31`}, feb:{start:`${y}-02-01`,end:`${y}-02-${String(feb).padStart(2,'0')}`},
mar:{start:`${y}-03-01`,end:`${y}-03-31`}, apr:{start:`${y}-04-01`,end:`${y}-04-30`},
may:{start:`${y}-05-01`,end:`${y}-05-31`}, jun:{start:`${y}-06-01`,end:`${y}-06-30`},
jul:{start:`${y}-07-01`,end:`${y}-07-31`}, aug:{start:`${y}-08-01`,end:`${y}-08-31`},
sep:{start:`${y}-09-01`,end:`${y}-09-30`}, oct:{start:`${y}-10-01`,end:`${y}-10-31`},
nov:{start:`${y}-11-01`,end:`${y}-11-30`}, dec:{start:`${y}-12-01`,end:`${y}-12-31`},
q1:{start:`${y}-01-01`,end:`${y}-03-31`}, q2:{start:`${y}-04-01`,end:`${y}-06-30`},
q3:{start:`${y}-07-01`,end:`${y}-09-30`}, q4:{start:`${y}-10-01`,end:`${y}-12-31`},
h1:{start:`${y}-01-01`,end:`${y}-06-30`}, h2:{start:`${y}-07-01`,end:`${y}-12-31`},
full:{start:`${y}-01-01`,end:`${y}-12-31`},
};
}
function guessPreset(start: string, end: string) {
const year = parseInt(start.slice(0,4));
const presets = makePresets(year);
for (const [key, r] of Object.entries(presets)) {
if (r.start===start && r.end===end) return { key, year };
}
return null;
}
function periodNameL(start: string, end: string, L: LC): string {
const year = parseInt(start.slice(0,4));
const g = guessPreset(start, end);
if (!g) {
const fmt = (d: string) => { const [,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]}`; };
const ey = parseInt(end.slice(0,4));
return year===ey ? `${fmt(start)} ${fmt(end)} ${year}` : `${fmt(start)} ${year} ${fmt(end)} ${ey}`;
}
const mi = MONTH_KEYS.indexOf(g.key);
if (mi >= 0) return `${L.monthFull[mi]} ${g.year}`;
if (g.key==='full') return L.fullYearLabel(g.year);
return `${L.periods[g.key]??g.key.toUpperCase()} ${g.year}`;
}
function dateRangeTextL(start: string, end: string, L: LC): string {
const fmt = (d: string) => { const [y,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]} ${y}`; };
return `${fmt(start)} ${L.dateRangeSep} ${fmt(end)}`;
}
function currentMonth() {
const now = new Date(); const y=now.getFullYear(), m=now.getMonth()+1;
const p = (n: number) => String(n).padStart(2,'0');
return { start:`${y}-${p(m)}-01`, end:`${y}-${p(m)}-${p(new Date(y,m,0).getDate())}` };
}
function shiftYear(s: string) { return s.replace(/^(\d{4})/, (_,y) => String(parseInt(y)-1)); }
// ─── inline picker ────────────────────────────────────────────────
function InlinePicker({ start, end, onChange, onClose, availableYears, L }: {
start: string; end: string; onChange: (s: string, e: string) => void;
onClose: () => void;
availableYears: number[]; L: LC;
}) {
const g = guessPreset(start, end);
const [year, setYear] = useState(g?.year ?? parseInt(start.slice(0,4)));
const [active, setActive] = useState<string|null>(g?.key ?? null);
const [draftStart, setDraftStart] = useState(start);
const [draftEnd, setDraftEnd] = useState(end);
const minY = Math.min(...availableYears), maxY = Math.max(...availableYears);
const pick = (key: string) => { const r=makePresets(year)[key]; if(!r) return; setActive(key); setDraftStart(r.start); setDraftEnd(r.end); };
const shift = (d: number) => {
const ny=year+d; if(ny<minY||ny>maxY) return; setYear(ny);
if(active && makePresets(ny)[active]) { setDraftStart(makePresets(ny)[active].start); setDraftEnd(makePresets(ny)[active].end); }
};
return (
<div className="alt-picker">
<div className="alt-picker-year">
<button type="button" onClick={() => shift(L.dir==='rtl' ? 1 : -1)} disabled={L.dir==='rtl' ? year>=maxY : year<=minY} className="alt-yr-btn">
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M5.5 9.5L1.5 5.5L5.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
<span className="alt-yr-val">{year}</span>
<button type="button" onClick={() => shift(L.dir==='rtl' ? -1 : 1)} disabled={L.dir==='rtl' ? year<=minY : year>=maxY} className="alt-yr-btn">
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M1.5 1.5L5.5 5.5L1.5 9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
</div>
<p className="alt-picker-section">{L.monthSection}</p>
<div className="alt-chips">
{MONTH_KEYS.map((k,i) => (
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.monthShort[i]}</button>
))}
</div>
<p className="alt-picker-section">{L.periodSection}</p>
<div className="alt-chips">
{['q1','q2','q3','q4','h1','h2'].map(k => (
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.periods[k]}</button>
))}
<button type="button" className={`alt-chip alt-chip-wide${active==='full'?' alt-chip-on':''}`} onClick={() => pick('full')}>{L.periods.full}</button>
</div>
<div className="alt-picker-div" />
<div className="alt-custom">
<div className="alt-custom-f"><label>{L.from}</label><input type="date" value={draftStart} onChange={e => { setActive(null); setDraftStart(e.target.value); }} /></div>
<span className="alt-custom-arrow">{L.dateRangeSep}</span>
<div className="alt-custom-f"><label>{L.to}</label><input type="date" value={draftEnd} onChange={e => { setActive(null); setDraftEnd(e.target.value); }} /></div>
</div>
<div className="alt-picker-div" />
<div className="alt-footer">
<button type="button" className="alt-cancel" onClick={onClose}>{L.close}</button>
<button type="button" className="alt-apply" onClick={() => { onChange(draftStart, draftEnd); onClose(); }}>{L.apply}</button>
</div>
</div>
);
}
// ─── period card ────────────────────────────────────────────────── // ─── period card ──────────────────────────────────────────────────
function PeriodCard({ role, hint, start, end, variant, onChange, availableYears, L }: { function PeriodCard({ role, hint, start, end, variant, onChange, availableYears, L }: {
role: string; hint: string; start: string; end: string; role: string; hint: string; start: string; end: string;
@@ -262,7 +52,7 @@ function PeriodCard({ role, hint, start, end, variant, onChange, availableYears,
</div> </div>
<div className="alt-period-name">{periodNameL(start, end, L)}</div> <div className="alt-period-name">{periodNameL(start, end, L)}</div>
<div className="alt-date-range">{dateRangeTextL(start, end, L)}</div> <div className="alt-date-range">{dateRangeTextL(start, end, L)}</div>
<button type="button" className="alt-change-btn" onClick={() => setOpen(v => !v)} aria-expanded={open}> <button type="button" className="alt-change-btn" onClick={() => setOpen(v => !v)} aria-expanded={open} aria-controls="period-picker-panel">
{open ? L.close : L.changePeriod} {open ? L.close : L.changePeriod}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ transform:open?'rotate(180deg)':'none', transition:'transform 0.2s' }}> <svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ transform:open?'rotate(180deg)':'none', transition:'transform 0.2s' }}>
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/> <path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
@@ -274,70 +64,6 @@ function PeriodCard({ role, hint, start, end, variant, onChange, availableYears,
); );
} }
// ─── multi-select ─────────────────────────────────────────────────
function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
value: string[]; options: string[];
onChange: (vals: string[]) => void;
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const h = (e: MouseEvent) => { if(ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
}, [open]);
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v!==opt) : [...value, opt]);
const label = value.length===0 ? allLabel : value.length===1 ? value[0] : countLabel(value.length);
return (
<div ref={ref} className="altms">
<button type="button" className={`altms-trigger${value.length>0?' altms-trigger--active':''}`} onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox">
<span className="altms-label">{label}</span>
<svg className={`altms-chevron${open?' altms-chevron--open':''}`} width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{open && (
<div className="altms-dropdown" role="listbox" aria-multiselectable="true">
<div className="altms-list">
{options.map(opt => (
<label key={opt} className={`altms-option${value.includes(opt)?' altms-option--checked':''}`}>
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
<span className="altms-opt-label">{opt}</span>
</label>
))}
</div>
{value.length>0 && <button type="button" className="altms-clear" onClick={() => { onChange([]); setOpen(false); }}>{clearLabel}</button>}
</div>
)}
</div>
);
}
// ─── metric card ──────────────────────────────────────────────────
function MetricCard({ title, curr, prev, isCurrency, newLabel }: {
title: string; curr: number; prev: number; isCurrency?: boolean; newLabel?: string;
}) {
const fmt = (n: number) => isCurrency ? formatCurrency(n) : formatNumber(n);
const change = prev===0 ? (curr>0 ? Infinity : 0) : ((curr-prev)/prev*100);
const isPos = change>0, isNeg = change<0;
return (
<div className="alt-metric">
<p className="alt-metric-title">{title}</p>
<div className="alt-metric-value">{fmt(curr)}</div>
<div className="alt-metric-footer">
{isFinite(change)
? <span className={`alt-change ${isPos?'alt-change--up':isNeg?'alt-change--down':'alt-change--flat'}`}>{isPos?'▲':isNeg?'▼':'—'} {Math.abs(change).toFixed(1)}%</span>
: <span className="alt-change alt-change--up">{newLabel??'New'}</span>}
<span className="alt-metric-prev">{fmt(prev)}</span>
</div>
</div>
);
}
// ─── main page ──────────────────────────────────────────────────── // ─── main page ────────────────────────────────────────────────────
export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels }: Props) { export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels }: Props) {
const { lang: activeLang, setLanguage } = useLanguage(); const { lang: activeLang, setLanguage } = useLanguage();
@@ -350,8 +76,9 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
const [selDistricts, setSelDistricts] = useState<string[]>([]); const [selDistricts, setSelDistricts] = useState<string[]>([]);
const [selChannels, setSelChannels] = useState<string[]>([]); const [selChannels, setSelChannels] = useState<string[]>([]);
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 [];
@@ -402,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 => {
@@ -416,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 {
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 { return {
labels, tooltipLabels,
datasets: [ multiMuseum,
{ 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 }, data: {
{ 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 }, labels,
] datasets: [
{ label:periodLabel(prevStart,prevEnd), data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:2, tension:0.4, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.muted },
...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[];
@@ -442,8 +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: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } }; const { chartOpts } = useMemo(() => {
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } };
return { chartOpts };
}, [baseOpts]);
const trendOpts: any = useMemo(() => ({
...chartOpts,
interaction: { mode: 'nearest', intersect: false },
plugins: {
...chartOpts.plugins,
legend: {
display: true,
position: 'right' as const,
labels: {
padding: 14,
font: { size: 11, weight: 'bold' as const },
usePointStyle: true,
generateLabels: (chart: any) =>
chart.data.datasets.map((ds: any, i: number) => {
const color: string = ds.borderColor || '#64748b';
const pill = document.createElement('canvas');
pill.width = 10; pill.height = 10;
const pCtx = pill.getContext('2d');
if (pCtx) {
pCtx.strokeStyle = color;
pCtx.lineWidth = 1;
pCtx.beginPath();
pCtx.arc(5, 5, 4, 0, Math.PI * 2);
pCtx.stroke();
}
return { text: ds.label, fillStyle: color, strokeStyle: color,
fontColor: color, lineWidth: 0, pointStyle: pill,
hidden: !chart.isDatasetVisible(i), datasetIndex: i };
}),
},
},
tooltip: {
...chartOpts.plugins.tooltip,
callbacks: {
title: (items: any[]) => trendResult.tooltipLabels[items[0]?.dataIndex] ?? items[0]?.label,
}
}
}
}), [chartOpts, trendResult.tooltipLabels]);
const metricOpts = [ const metricOpts = [
{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors }, { value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors },
@@ -471,148 +279,19 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null; const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
const hasFilters = selDistricts.length>0 || selChannels.length>0 || selMuseums.length>0; const hasFilters = selDistricts.length>0 || selChannels.length>0 || selMuseums.length>0;
const activeFilterCount = selDistricts.length + selChannels.length + selMuseums.length;
const [filtersOpen, setFiltersOpen] = useState(false);
return ( return (
<div className="alt-page" dir={L.dir}> <div
<style>{` className="alt-page"
${L.fontImport} dir={L.dir}
style={{
.alt-page { max-width:1100px; margin:0 auto; padding:48px 24px 80px; font-family:${L.bodyFont}; width:100%; box-sizing:border-box; } '--alt-body-font': L.bodyFont,
'--alt-display-font': L.displayFont,
/* ── header ── */ '--alt-mono-font': L.monoFont,
.alt-back { display:inline-flex; align-items:center; gap:6px; font-size:.8125rem; color:var(--text-muted); text-decoration:none; margin-bottom:28px; transition:color .15s; } } as React.CSSProperties}
.alt-back:hover { color:var(--accent); } >
.alt-page-title { font-family:${L.displayFont}; font-size:2.25rem; font-weight:400; color:var(--text-primary); margin:0 0 6px; letter-spacing:-.03em; line-height:1.15; }
.alt-page-sub { font-size:.9375rem; color:var(--text-muted); margin:0 0 40px; font-weight:300; }
/* ── period row ── */
.alt-period-row { display:grid; grid-template-columns:1fr auto 1fr; align-items:stretch; margin-bottom:32px; }
.alt-vs { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:0 20px; position:relative; }
.alt-vs-line { position:absolute; top:0; bottom:0; left:50%; width:1px; background:var(--border); }
.alt-vs-badge { font-family:${L.displayFont}; font-size:.9rem; font-style:italic; color:var(--text-muted); background:var(--bg); padding:6px 10px; border:1px solid var(--border); border-radius:20px; position:relative; z-index:1; }
/* ── period card ── */
.alt-card { border:1px solid var(--border); border-radius:var(--radius); background:var(--surface); overflow:hidden; transition:border-color .2s,box-shadow .2s; display:flex; flex-direction:column; }
.alt-card--current { border-radius:var(--radius) 0 0 var(--radius); }
.alt-card--previous { border-radius:0 var(--radius) var(--radius) 0; }
[dir="rtl"] .alt-card--current { border-radius:0 var(--radius) var(--radius) 0; }
[dir="rtl"] .alt-card--previous { border-radius:var(--radius) 0 0 var(--radius); }
.alt-card:hover { box-shadow:var(--shadow); }
.alt-card--current:hover,.alt-card--current.alt-card--open { border-color:var(--accent); }
.alt-card--previous:hover,.alt-card--previous.alt-card--open { border-color:#94a3b8; }
.alt-card-bar { height:3px; width:100%; }
.alt-card--current .alt-card-bar { background:var(--accent); }
.alt-card--previous .alt-card-bar { background:#94a3b8; }
.alt-card-body { padding:24px 28px 20px; flex:1; }
.alt-role-row { display:flex; align-items:baseline; gap:8px; margin-bottom:12px; }
.alt-role { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.1em; }
.alt-card--current .alt-role { color:var(--accent); }
.alt-card--previous .alt-role { color:#64748b; }
.alt-role-hint { font-size:.75rem; color:var(--text-muted); font-weight:300; }
.alt-period-name { font-family:${L.displayFont}; font-size:2.25rem; font-weight:400; color:var(--text-primary); line-height:1.1; letter-spacing:-.02em; margin-bottom:8px; }
.alt-date-range { font-family:${L.monoFont}; font-size:.8125rem; color:var(--text-muted); letter-spacing:.01em; margin-bottom:20px; }
.alt-change-btn { display:inline-flex; align-items:center; gap:5px; font-family:${L.bodyFont}; font-size:.8125rem; font-weight:500; color:var(--text-muted); background:none; border:none; padding:0; cursor:pointer; transition:color .15s; }
.alt-card--current .alt-change-btn:hover { color:var(--accent); }
.alt-card--previous .alt-change-btn:hover { color:var(--text-primary); }
/* ── picker ── */
.alt-picker { border-top:1px solid var(--border); padding:16px 24px 20px; background:var(--bg); animation:altPickIn 180ms cubic-bezier(.16,1,.3,1); }
@keyframes altPickIn { from{opacity:0;transform:translateY(-8px)} to{opacity:1;transform:translateY(0)} }
.alt-picker-year { display:flex; align-items:center; gap:16px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--border); }
.alt-yr-val { font-family:${L.displayFont}; font-size:1.25rem; color:var(--text-primary); min-width:50px; text-align:center; }
.alt-yr-btn { width:28px; height:28px; display:flex; align-items:center; justify-content:center; border:1px solid var(--border); border-radius:7px; background:var(--surface); color:var(--text-secondary); cursor:pointer; transition:background .12s,border-color .12s,color .12s; }
.alt-yr-btn:hover:not(:disabled) { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
.alt-yr-btn:disabled { opacity:.3; cursor:not-allowed; }
.alt-picker-section { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:10px 0 6px; }
.alt-chips { display:flex; flex-wrap:wrap; gap:5px; margin-bottom:4px; }
.alt-chip { font-family:${L.bodyFont}; padding:4px 9px; border:1px solid var(--border); border-radius:6px; background:var(--surface); color:var(--text-secondary); font-size:.8rem; font-weight:500; cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
.alt-chip:hover { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
.alt-chip-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; font-weight:600!important; }
.alt-chip-wide { padding-left:14px; padding-right:14px; }
.alt-picker-div { height:1px; background:var(--border); margin:12px 0 10px; }
.alt-custom { display:flex; align-items:flex-end; gap:8px; }
.alt-custom-f { flex:1; display:flex; flex-direction:column; gap:4px; }
.alt-custom-f label { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--text-muted); }
.alt-custom-f input[type="date"] { padding:7px 9px; border:1px solid var(--border); border-radius:7px; font-size:.825rem; background:var(--surface); color:var(--text-primary); width:100%; }
.alt-custom-f input[type="date"]:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 2px rgba(37,99,235,.12); }
.alt-custom-arrow { font-size:.75rem; color:var(--text-muted); padding-bottom:9px; flex-shrink:0; }
.alt-footer { display:flex; justify-content:flex-end; gap:8px; }
.alt-cancel,.alt-apply { padding:7px 16px; border-radius:7px; font-size:.825rem; font-weight:600; cursor:pointer; font-family:${L.bodyFont}; transition:background .12s,color .12s; }
.alt-cancel { background:transparent; border:1px solid var(--border); color:var(--text-secondary); }
.alt-cancel:hover { background:var(--bg-secondary); }
.alt-apply { background:var(--accent); border:1px solid transparent; color:#fff; }
.alt-apply:hover { opacity:.88; }
/* ── multi-select ── */
.altms { position:relative; }
.altms-trigger { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border:1px solid var(--border); border-radius:8px; background:var(--surface); color:var(--text-secondary); font-family:${L.bodyFont}; font-size:.875rem; cursor:pointer; transition:border-color .15s,color .15s,background .15s; white-space:nowrap; }
.altms-trigger:hover { border-color:var(--accent); color:var(--accent); }
.altms-trigger--active { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
.altms-label { max-width:140px; overflow:hidden; text-overflow:ellipsis; }
.altms-chevron { transition:transform .18s; flex-shrink:0; }
.altms-chevron--open { transform:rotate(180deg); }
.altms-dropdown { position:absolute; top:calc(100% + 6px); left:0; z-index:200; min-width:200px; background:var(--surface); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.12); overflow:hidden; animation:altPickIn 140ms cubic-bezier(.16,1,.3,1); }
[dir="rtl"] .altms-dropdown { left:auto; right:0; }
.altms-list { max-height:220px; overflow-y:auto; padding:6px; display:flex; flex-direction:column; gap:2px; }
.altms-option { display:flex; align-items:center; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; transition:background .1s; }
.altms-option:hover { background:var(--bg); }
.altms-option--checked { background:var(--accent-light); }
.altms-check { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
.altms-check-box { width:16px; height:16px; border:1.5px solid var(--border); border-radius:4px; display:flex; align-items:center; justify-content:center; flex-shrink:0; transition:background .1s,border-color .1s; }
.altms-option--checked .altms-check-box { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
.altms-opt-label { font-family:${L.bodyFont}; font-size:.875rem; color:var(--text-primary); }
.altms-clear { width:100%; padding:8px 14px; border-top:1px solid var(--border); background:none; border-left:none; border-right:none; border-bottom:none; font-family:${L.bodyFont}; font-size:.8125rem; color:var(--danger); cursor:pointer; text-align:start; transition:background .1s; }
.altms-clear:hover { background:var(--danger-light); }
/* ── filter bar ── */
.alt-filter-bar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:36px; padding:14px 20px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); }
.alt-filter-label { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); white-space:nowrap; }
.alt-filter-sep { width:1px; height:20px; background:var(--border); flex-shrink:0; }
.alt-filter-reset { margin-inline-start:auto; font-size:.8125rem; color:var(--text-muted); background:none; border:none; cursor:pointer; padding:4px 6px; transition:color .15s; font-family:${L.bodyFont}; }
.alt-filter-reset:hover { color:var(--danger); }
/* ── metrics ── */
.alt-metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:1px; background:var(--border); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; margin-bottom:40px; }
.alt-metric { background:var(--surface); padding:24px 22px; }
.alt-metric-title { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:0 0 12px; }
.alt-metric-value { font-family:${L.displayFont}; font-size:1.875rem; font-weight:400; color:var(--text-primary); line-height:1; margin-bottom:10px; letter-spacing:-.02em; }
.alt-metric-footer { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.alt-change { font-size:.75rem; font-weight:600; padding:2px 8px; border-radius:20px; white-space:nowrap; font-family:${L.bodyFont}; }
.alt-change--up { background:var(--success-light); color:var(--success); }
.alt-change--down { background:var(--danger-light); color:var(--danger); }
.alt-change--flat { background:var(--muted-light); color:var(--text-muted); }
.alt-metric-prev { font-size:.75rem; color:var(--text-muted); font-family:${L.monoFont}; }
/* ── charts ── */
.alt-charts { display:grid; grid-template-columns:1fr; gap:24px; }
.alt-chart-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:28px 28px 24px; min-width:0; overflow:hidden; }
.alt-chart-header { display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:24px; gap:16px; flex-wrap:wrap; }
.alt-chart-title { font-family:${L.displayFont}; font-size:1.375rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; font-style:italic; }
.alt-chart-controls { display:flex; gap:6px; flex-wrap:wrap; }
.alt-ctrl { font-family:${L.bodyFont}; font-size:.75rem; font-weight:500; padding:4px 10px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text-secondary); cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
.alt-ctrl:hover { border-color:var(--accent); color:var(--accent); }
.alt-ctrl-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; }
.alt-ctrl-sep { width:1px; height:20px; background:var(--border); align-self:center; }
.alt-chart-wrap { position:relative; height:280px; overflow:hidden; direction:ltr; width:100%; }
/* ── section heading ── */
.alt-section-heading { display:flex; align-items:center; gap:12px; margin:0 0 20px; }
.alt-section-heading h2 { font-family:${L.displayFont}; font-size:1.375rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; }
.alt-section-heading::after { content:''; flex:1; height:1px; background:var(--border); }
/* ── responsive ── */
@media (max-width:680px) {
.alt-period-row { grid-template-columns:1fr; }
.alt-card--current,.alt-card--previous { border-radius:var(--radius); }
.alt-vs { flex-direction:row; padding:10px 0; }
.alt-vs-line { position:static; width:100%; height:1px; }
.alt-period-name { font-size:1.75rem; }
.alt-metrics { grid-template-columns:1fr 1fr; }
.alt-page-title { font-size:1.75rem; }
.alt-chart-header { flex-direction:column; }
}
`}</style>
<Link to={L.backTo} className="alt-back"> <Link to={L.backTo} className="alt-back">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{ transform: L.dir==='rtl' ? 'scaleX(-1)' : undefined }}> <svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{ transform: L.dir==='rtl' ? 'scaleX(-1)' : undefined }}>
<path d="M9 2L4 7L9 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/> <path d="M9 2L4 7L9 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
@@ -633,16 +312,22 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
onChange={(s,e) => { setPrevStart(s); setPrevEnd(e); }} availableYears={availableYears} L={L} /> onChange={(s,e) => { setPrevStart(s); setPrevEnd(e); }} availableYears={availableYears} L={L} />
</div> </div>
<div className="alt-filter-bar"> <div className={`alt-filter-bar${filtersOpen ? ' alt-filter-bar--open' : ''}`}>
<span className="alt-filter-label">{L.filter}</span> <div className="alt-filter-head">
<div className="alt-filter-sep" /> <span className="alt-filter-label">{L.filter}</span>
<AltMultiSelect value={selDistricts} options={districts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} /> {activeFilterCount > 0 && <span className="alt-filter-badge">{activeFilterCount}</span>}
<AltMultiSelect value={selChannels} options={channels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} /> <button type="button" className="alt-filter-toggle" onClick={() => setFiltersOpen(v => !v)} aria-expanded={filtersOpen} aria-label="Toggle filters">
<AltMultiSelect value={selMuseums} options={museums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} /> <svg className={`alt-filter-chevron${filtersOpen ? ' alt-filter-chevron--open' : ''}`} width="14" height="14" viewBox="0 0 10 10" fill="none">
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>} <path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<div className="alt-vat-toggle" style={{ marginInlineStart: 'auto' }}> </svg>
<button type="button" className={`alt-vat-opt${activeLang==='en'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('en')}>EN</button> </button>
<button type="button" className={`alt-vat-opt${activeLang==='ar'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('ar')}>AR</button> </div>
<div className="alt-filter-body">
<div className="alt-filter-sep" />
<AltMultiSelect value={selDistricts} options={districts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
<AltMultiSelect value={selChannels} options={channels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
<AltMultiSelect value={selMuseums} options={museums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} />
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
</div> </div>
</div> </div>
@@ -663,18 +348,20 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
<div className="alt-chart-header"> <div className="alt-chart-header">
<h3 className="alt-chart-title">{L.trendTitle}</h3> <h3 className="alt-chart-title">{L.trendTitle}</h3>
<div className="alt-chart-controls"> <div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
{granOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)} {granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
<div 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">
<h3 className="alt-chart-title">{L.museumTitle}</h3> <h3 className="alt-chart-title">{L.museumTitle}</h3>
<div className="alt-chart-controls"> <div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
</div> </div>
</div> </div>
<div className="alt-chart-wrap"><Bar data={museumData} options={chartOpts} /></div> <div className="alt-chart-wrap"><Bar data={museumData} options={chartOpts} /></div>
+156 -489
View File
@@ -1,15 +1,19 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Line, Bar, Pie } from 'react-chartjs-2'; import { Line, Bar, Pie } from 'react-chartjs-2';
import { import {
filterDataByDateRange, calculateMetrics, formatCurrency, formatNumber, filterDataByDateRange, calculateMetrics,
getUniqueChannels, getUniqueMuseums, getUniqueDistricts, getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
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 { currentMonth, shiftYear } from '../lib/dateHelpers';
import PeriodHero from './shared/PeriodPicker';
import AltMultiSelect from './shared/AltMultiSelect';
import MetricCard from './shared/MetricCard';
interface Props { interface Props {
data: MuseumRecord[]; data: MuseumRecord[];
@@ -21,325 +25,6 @@ interface Props {
lang?: 'en' | 'ar'; lang?: 'en' | 'ar';
} }
// ─── language config ──────────────────────────────────────────────
interface LC {
dir: 'ltr' | 'rtl';
fontImport: string;
bodyFont: string;
displayFont: string;
monoFont: string;
monthFull: string[];
monthShort: string[];
periods: Record<string, string>;
fullYearLabel: (y: number) => string;
dateRangeSep: string;
backLink: string;
backTo: string;
pageTitle: string;
pageSub: string;
changePeriod: string;
close: string;
apply: string;
filter: string;
allDistricts: string; allChannels: string; allMuseums: string;
countDistricts: (n: number) => string;
countChannels: (n: number) => string;
countMuseums: (n: number) => string;
reset: string;
exclVAT: string; inclVAT: string;
keyMetrics: string;
revenue: string; visitors: string; tickets: string; avgRev: string;
pilgrims: string; captureRate: string;
charts: string;
trendTitle: string; museumTitle: string; channelTitle: string; districtTitle: string;
daily: string; weekly: string; monthly: string;
newLabel: string;
clearSel: string;
monthSection: string; periodSection: string;
from: string; to: string;
vsLabel: string;
barLabel: string; pieLabel: string;
absLabel: string; pctLabel: string;
}
const EN: LC = {
dir: 'ltr',
fontImport: `@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&display=swap');`,
bodyFont: "'Outfit', sans-serif",
displayFont: "'DM Serif Display', serif",
monoFont: "ui-monospace, 'Cascadia Code', monospace",
monthFull: ['January','February','March','April','May','June','July','August','September','October','November','December'],
monthShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
periods: { q1:'Q1', q2:'Q2', q3:'Q3', q4:'Q4', h1:'H1', h2:'H2', full:'Full Year' },
fullYearLabel: (y) => String(y),
dateRangeSep: '→',
backLink: 'Back to Dashboard', backTo: '/',
pageTitle: 'Overview', pageSub: 'Museum performance at a glance.',
changePeriod: 'Change period', close: 'Cancel', apply: 'Apply',
filter: 'Filter',
allDistricts: 'All districts', allChannels: 'All channels', allMuseums: 'All museums',
countDistricts: (n) => `${n} districts`,
countChannels: (n) => `${n} channels`,
countMuseums: (n) => `${n} museums`,
reset: 'Reset', exclVAT: 'Excl. VAT', inclVAT: 'Incl. VAT',
keyMetrics: 'Key Metrics',
revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets',
avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %',
charts: 'Charts',
trendTitle: 'Trend over time', museumTitle: 'By museum',
channelTitle: 'By channel', districtTitle: 'By district',
daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
newLabel: 'New', clearSel: 'Clear selection',
monthSection: 'Month', periodSection: 'Quarter · Half · Year',
from: 'From', to: 'To', vsLabel: 'vs',
barLabel: 'Bar', pieLabel: 'Pie', absLabel: '#', pctLabel: '%',
};
const AR: LC = {
dir: 'rtl',
fontImport: `@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');`,
bodyFont: "'IBM Plex Sans Arabic', sans-serif",
displayFont: "'IBM Plex Sans Arabic', sans-serif",
monoFont: "'IBM Plex Sans Arabic', sans-serif",
monthFull: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'],
monthShort: ['ينا','فبر','مار','أبر','ماي','يون','يول','أغس','سبت','أكت','نوف','ديس'],
periods: { q1:'ر١', q2:'ر٢', q3:'ر٣', q4:'ر٤', h1:'ن١', h2:'ن٢', full:'السنة' },
fullYearLabel: (y) => `${y} كاملاً`,
dateRangeSep: '',
backLink: 'العودة إلى لوحة التحكم', backTo: '/ar',
pageTitle: 'نظرة عامة', pageSub: 'أداء المتاحف في لمحة.',
changePeriod: 'تغيير الفترة', close: 'إلغاء', apply: 'تطبيق',
filter: 'تصفية',
allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف',
countDistricts: (n) => `${n} مناطق`,
countChannels: (n) => `${n} قنوات`,
countMuseums: (n) => `${n} متاحف`,
reset: 'إعادة ضبط', exclVAT: 'بدون ضريبة', inclVAT: 'مع ضريبة',
keyMetrics: 'المؤشرات الرئيسية',
revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر',
avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %',
charts: 'المخططات',
trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف',
channelTitle: 'حسب القناة', districtTitle: 'حسب المنطقة',
daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري',
newLabel: 'جديد', clearSel: 'مسح التحديد',
monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة',
from: 'من', to: 'إلى', vsLabel: 'مقابل',
barLabel: 'أعمدة', pieLabel: 'دائرة', absLabel: '#', pctLabel: '%',
};
// ─── date helpers ─────────────────────────────────────────────────
const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
function isLeap(y: number) { return (y%4===0 && y%100!==0) || y%400===0; }
function makePresets(y: number): Record<string, { start: string; end: string }> {
const feb = isLeap(y) ? 29 : 28;
return {
jan:{start:`${y}-01-01`,end:`${y}-01-31`}, feb:{start:`${y}-02-01`,end:`${y}-02-${String(feb).padStart(2,'0')}`},
mar:{start:`${y}-03-01`,end:`${y}-03-31`}, apr:{start:`${y}-04-01`,end:`${y}-04-30`},
may:{start:`${y}-05-01`,end:`${y}-05-31`}, jun:{start:`${y}-06-01`,end:`${y}-06-30`},
jul:{start:`${y}-07-01`,end:`${y}-07-31`}, aug:{start:`${y}-08-01`,end:`${y}-08-31`},
sep:{start:`${y}-09-01`,end:`${y}-09-30`}, oct:{start:`${y}-10-01`,end:`${y}-10-31`},
nov:{start:`${y}-11-01`,end:`${y}-11-30`}, dec:{start:`${y}-12-01`,end:`${y}-12-31`},
q1:{start:`${y}-01-01`,end:`${y}-03-31`}, q2:{start:`${y}-04-01`,end:`${y}-06-30`},
q3:{start:`${y}-07-01`,end:`${y}-09-30`}, q4:{start:`${y}-10-01`,end:`${y}-12-31`},
h1:{start:`${y}-01-01`,end:`${y}-06-30`}, h2:{start:`${y}-07-01`,end:`${y}-12-31`},
full:{start:`${y}-01-01`,end:`${y}-12-31`},
};
}
function guessPreset(start: string, end: string) {
const year = parseInt(start.slice(0,4));
const presets = makePresets(year);
for (const [key, r] of Object.entries(presets)) {
if (r.start === start && r.end === end) return { key, year };
}
return null;
}
function periodNameL(start: string, end: string, L: LC): string {
const year = parseInt(start.slice(0,4));
const g = guessPreset(start, end);
if (!g) {
const fmt = (d: string) => { const [,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]}`; };
const ey = parseInt(end.slice(0,4));
return year === ey ? `${fmt(start)} ${fmt(end)} ${year}` : `${fmt(start)} ${year} ${fmt(end)} ${ey}`;
}
const mi = MONTH_KEYS.indexOf(g.key);
if (mi >= 0) return `${L.monthFull[mi]} ${g.year}`;
if (g.key === 'full') return L.fullYearLabel(g.year);
return `${L.periods[g.key] ?? g.key.toUpperCase()} ${g.year}`;
}
function dateRangeTextL(start: string, end: string, L: LC): string {
const fmt = (d: string) => { const [y,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]} ${y}`; };
return `${fmt(start)} ${L.dateRangeSep} ${fmt(end)}`;
}
function currentMonth() {
const now = new Date(); const y = now.getFullYear(), m = now.getMonth()+1;
const p = (n: number) => String(n).padStart(2,'0');
return { start: `${y}-${p(m)}-01`, end: `${y}-${p(m)}-${p(new Date(y, m, 0).getDate())}` };
}
function shiftYear(s: string) { return s.replace(/^(\d{4})/, (_,y) => String(parseInt(y)-1)); }
// ─── inline picker ────────────────────────────────────────────────
function InlinePicker({ start, end, onChange, onClose, availableYears, L }: {
start: string; end: string; onChange: (s: string, e: string) => void;
onClose: () => void;
availableYears: number[]; L: LC;
}) {
const g = guessPreset(start, end);
const [year, setYear] = useState(g?.year ?? parseInt(start.slice(0,4)));
const [active, setActive] = useState<string|null>(g?.key ?? null);
const [draftStart, setDraftStart] = useState(start);
const [draftEnd, setDraftEnd] = useState(end);
const minY = Math.min(...availableYears), maxY = Math.max(...availableYears);
const pick = (key: string) => { const r = makePresets(year)[key]; if (!r) return; setActive(key); setDraftStart(r.start); setDraftEnd(r.end); };
const shift = (d: number) => {
const ny = year+d; if (ny < minY || ny > maxY) return; setYear(ny);
if (active && makePresets(ny)[active]) { setDraftStart(makePresets(ny)[active].start); setDraftEnd(makePresets(ny)[active].end); }
};
return (
<div className="alt-picker">
<div className="alt-picker-year">
<button type="button" onClick={() => shift(L.dir==='rtl' ? 1 : -1)} disabled={L.dir==='rtl' ? year>=maxY : year<=minY} className="alt-yr-btn">
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M5.5 9.5L1.5 5.5L5.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
<span className="alt-yr-val">{year}</span>
<button type="button" onClick={() => shift(L.dir==='rtl' ? -1 : 1)} disabled={L.dir==='rtl' ? year<=minY : year>=maxY} className="alt-yr-btn">
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M1.5 1.5L5.5 5.5L1.5 9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
</div>
<p className="alt-picker-section">{L.monthSection}</p>
<div className="alt-chips">
{MONTH_KEYS.map((k,i) => (
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.monthShort[i]}</button>
))}
</div>
<p className="alt-picker-section">{L.periodSection}</p>
<div className="alt-chips">
{['q1','q2','q3','q4','h1','h2'].map(k => (
<button key={k} type="button" className={`alt-chip${active===k?' alt-chip-on':''}`} onClick={() => pick(k)}>{L.periods[k]}</button>
))}
<button type="button" className={`alt-chip alt-chip-wide${active==='full'?' alt-chip-on':''}`} onClick={() => pick('full')}>{L.periods.full}</button>
</div>
<div className="alt-picker-div" />
<div className="alt-custom">
<div className="alt-custom-f"><label>{L.from}</label><input type="date" value={draftStart} onChange={e => { setActive(null); setDraftStart(e.target.value); }} /></div>
<span className="alt-custom-arrow">{L.dateRangeSep}</span>
<div className="alt-custom-f"><label>{L.to}</label><input type="date" value={draftEnd} onChange={e => { setActive(null); setDraftEnd(e.target.value); }} /></div>
</div>
<div className="alt-picker-div" />
<div className="alt-footer">
<button type="button" className="alt-cancel" onClick={onClose}>{L.close}</button>
<button type="button" className="alt-apply" onClick={() => { onChange(draftStart, draftEnd); onClose(); }}>{L.apply}</button>
</div>
</div>
);
}
// ─── period hero ──────────────────────────────────────────────────
function PeriodHero({ start, end, onChange, availableYears, L }: {
start: string; end: string; onChange: (s: string, e: string) => void;
availableYears: number[]; L: LC;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onM = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
const onK = (e: KeyboardEvent) => { if (e.key==='Escape') setOpen(false); };
document.addEventListener('mousedown', onM); document.addEventListener('keydown', onK);
return () => { document.removeEventListener('mousedown', onM); document.removeEventListener('keydown', onK); };
}, [open]);
return (
<div ref={ref} className="dalt-hero">
<div className="dalt-hero-inner">
<div>
<div className="dalt-hero-name">{periodNameL(start, end, L)}</div>
<div className="dalt-hero-range">{dateRangeTextL(start, end, L)}</div>
</div>
<button type="button" className="dalt-hero-btn" onClick={() => setOpen(v => !v)} aria-expanded={open}>
{open ? L.close : L.changePeriod}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ transform: open?'rotate(180deg)':'none', transition:'transform 0.2s' }}>
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
{open && <InlinePicker start={start} end={end} onChange={onChange} onClose={() => setOpen(false)} availableYears={availableYears} L={L} />}
</div>
);
}
// ─── multi-select ─────────────────────────────────────────────────
function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
value: string[]; options: string[];
onChange: (vals: string[]) => void;
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
}, [open]);
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v!==opt) : [...value, opt]);
const label = value.length===0 ? allLabel : value.length===1 ? value[0] : countLabel(value.length);
return (
<div ref={ref} className="altms">
<button type="button" className={`altms-trigger${value.length>0?' altms-trigger--active':''}`} onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox">
<span className="altms-label">{label}</span>
<svg className={`altms-chevron${open?' altms-chevron--open':''}`} width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{open && (
<div className="altms-dropdown" role="listbox" aria-multiselectable="true">
<div className="altms-list">
{options.map(opt => (
<label key={opt} className={`altms-option${value.includes(opt)?' altms-option--checked':''}`}>
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
<span className="altms-opt-label">{opt}</span>
</label>
))}
</div>
{value.length>0 && <button type="button" className="altms-clear" onClick={() => { onChange([]); setOpen(false); }}>{clearLabel}</button>}
</div>
)}
</div>
);
}
// ─── metric card ──────────────────────────────────────────────────
function MetricCard({ title, curr, prev, isCurrency, newLabel }: {
title: string; curr: number; prev: number; isCurrency?: boolean; newLabel?: string;
}) {
const fmt = (n: number) => isCurrency ? formatCurrency(n) : formatNumber(n);
const change = prev===0 ? (curr>0 ? Infinity : 0) : ((curr-prev)/prev*100);
const isPos = change>0, isNeg = change<0;
return (
<div className="alt-metric">
<p className="alt-metric-title">{title}</p>
<div className="alt-metric-value">{fmt(curr)}</div>
<div className="alt-metric-footer">
{isFinite(change)
? <span className={`alt-change ${isPos?'alt-change--up':isNeg?'alt-change--down':'alt-change--flat'}`}>{isPos?'▲':isNeg?'▼':'—'} {Math.abs(change).toFixed(1)}%</span>
: <span className="alt-change alt-change--up">{newLabel??'New'}</span>}
<span className="alt-metric-prev">{fmt(prev)}</span>
</div>
</div>
);
}
// ─── main page ──────────────────────────────────────────────────── // ─── main page ────────────────────────────────────────────────────
export default function DashboardDemo({ data, seasons: _seasons, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: Props) { export default function DashboardDemo({ data, seasons: _seasons, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: Props) {
const { lang: activeLang, setLanguage } = useLanguage(); const { lang: activeLang, setLanguage } = useLanguage();
@@ -350,8 +35,9 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
const [selDistricts, setSelDistricts] = useState<string[]>([]); const [selDistricts, setSelDistricts] = useState<string[]>([]);
const [selChannels, setSelChannels] = useState<string[]>([]); const [selChannels, setSelChannels] = useState<string[]>([]);
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');
@@ -403,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 => {
@@ -417,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 {
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 { return {
labels, tooltipLabels,
datasets: [ multiMuseum,
{ 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] }, data: {
{ 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 }, labels,
] datasets: [
{ label:`${prevYear}`, data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:1.5, tension:0.4, pointRadius:0, borderDash:[4,3] },
...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);
@@ -480,10 +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: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:10, padding:10, font:{ size:11 } } } } }; const { chartOpts, barHorizOpts, barNoLegend } = useMemo(() => {
const barHorizOpts: any = { ...chartOpts, indexAxis:'y', plugins:{ ...chartOpts.plugins, legend:{ display:false } } }; const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:10, padding:10, font:{ size:11 } } } } };
const barNoLegend: any = { ...chartOpts, 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 } } };
return { chartOpts, barHorizOpts, barNoLegend };
}, [baseOpts]);
const trendOpts: any = useMemo(() => ({
...chartOpts,
interaction: { mode: 'nearest', intersect: false },
plugins: {
...chartOpts.plugins,
legend: {
display: true,
position: 'right' as const,
labels: {
padding: 14,
font: { size: 11, weight: 'bold' as const },
usePointStyle: true,
generateLabels: (chart: any) =>
chart.data.datasets.map((ds: any, i: number) => {
const color: string = ds.borderColor || '#64748b';
const pill = document.createElement('canvas');
pill.width = 10; pill.height = 10;
const pCtx = pill.getContext('2d');
if (pCtx) {
pCtx.strokeStyle = color;
pCtx.lineWidth = 1;
pCtx.beginPath();
pCtx.arc(5, 5, 4, 0, Math.PI * 2);
pCtx.stroke();
}
return { text: ds.label, fillStyle: color, strokeStyle: color,
fontColor: color, lineWidth: 0, pointStyle: pill,
hidden: !chart.isDatasetVisible(i), datasetIndex: i };
}),
},
},
tooltip: {
...chartOpts.plugins.tooltip,
callbacks: {
title: (items: any[]) => trendResult.tooltipLabels[items[0]?.dataIndex] ?? items[0]?.label,
}
}
}
}), [chartOpts, trendResult.tooltipLabels]);
const pieOptions: any = useMemo(() => ({ const pieOptions: any = useMemo(() => ({
responsive: true, maintainAspectRatio: false, responsive: true, maintainAspectRatio: false,
plugins: { plugins: {
@@ -496,148 +264,45 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
const metricOpts = [{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors }, { value:'tickets', label:L.tickets }]; const metricOpts = [{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors }, { value:'tickets', label:L.tickets }];
const granOpts = [{ value:'day', label:L.daily }, { value:'week', label:L.weekly }, { value:'month', label:L.monthly }]; const granOpts = [{ value:'day', label:L.daily }, { value:'week', label:L.weekly }, { value:'month', label:L.monthly }];
const hasFilters = selDistricts.length>0 || selChannels.length>0 || selMuseums.length>0; const hasFilters = selDistricts.length>0 || selChannels.length>0 || selMuseums.length>0;
const activeFilterCount = selDistricts.length + selChannels.length + selMuseums.length;
const [filtersOpen, setFiltersOpen] = useState(false);
return ( return (
<div className="alt-page" dir={L.dir}> <div
<style>{` className="alt-page"
${L.fontImport} dir={L.dir}
style={{
.alt-page { max-width:1100px; margin:0 auto; padding:48px 24px 80px; font-family:${L.bodyFont}; width:100%; box-sizing:border-box; } '--alt-body-font': L.bodyFont,
'--alt-display-font': L.displayFont,
/* ── header ── */ '--alt-mono-font': L.monoFont,
.alt-back { display:inline-flex; align-items:center; gap:6px; font-size:.8125rem; color:var(--text-muted); text-decoration:none; margin-bottom:28px; transition:color .15s; } } as React.CSSProperties}
.alt-back:hover { color:var(--accent); } >
.alt-page-title { font-family:${L.displayFont}; font-size:2.25rem; font-weight:400; color:var(--text-primary); margin:0 0 6px; letter-spacing:-.03em; line-height:1.15; }
.alt-page-sub { font-size:.9375rem; color:var(--text-muted); margin:0 0 40px; font-weight:300; }
/* ── hero ── */
.dalt-hero { border:1px solid var(--border); border-radius:var(--radius); background:var(--surface); overflow:hidden; margin-bottom:24px; }
.dalt-hero-inner { display:flex; align-items:center; justify-content:space-between; padding:24px 28px; gap:16px; flex-wrap:wrap; }
.dalt-hero-name { font-family:${L.displayFont}; font-size:2.5rem; font-weight:400; color:var(--text-primary); line-height:1; letter-spacing:-.025em; margin-bottom:6px; }
.dalt-hero-range { font-family:${L.monoFont}; font-size:.875rem; color:var(--text-muted); letter-spacing:.01em; }
.dalt-hero-btn { display:inline-flex; align-items:center; gap:5px; font-family:${L.bodyFont}; font-size:.8125rem; font-weight:500; color:var(--text-muted); background:none; border:1px solid var(--border); border-radius:8px; padding:7px 12px; cursor:pointer; transition:color .15s,border-color .15s; white-space:nowrap; }
.dalt-hero-btn:hover { color:var(--accent); border-color:var(--accent); }
/* ── picker ── */
.alt-picker { border-top:1px solid var(--border); padding:16px 24px 20px; background:var(--bg); animation:altPickIn 180ms cubic-bezier(.16,1,.3,1); }
@keyframes altPickIn { from{opacity:0;transform:translateY(-8px)} to{opacity:1;transform:translateY(0)} }
.alt-picker-year { display:flex; align-items:center; gap:16px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--border); }
.alt-yr-val { font-family:${L.displayFont}; font-size:1.25rem; color:var(--text-primary); min-width:50px; text-align:center; }
.alt-yr-btn { width:28px; height:28px; display:flex; align-items:center; justify-content:center; border:1px solid var(--border); border-radius:7px; background:var(--surface); color:var(--text-secondary); cursor:pointer; transition:background .12s,border-color .12s,color .12s; }
.alt-yr-btn:hover:not(:disabled) { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
.alt-yr-btn:disabled { opacity:.3; cursor:not-allowed; }
.alt-picker-section { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:10px 0 6px; }
.alt-chips { display:flex; flex-wrap:wrap; gap:5px; margin-bottom:4px; }
.alt-chip { font-family:${L.bodyFont}; padding:4px 9px; border:1px solid var(--border); border-radius:6px; background:var(--surface); color:var(--text-secondary); font-size:.8rem; font-weight:500; cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
.alt-chip:hover { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
.alt-chip-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; font-weight:600!important; }
.alt-chip-wide { padding-left:14px; padding-right:14px; }
.alt-picker-div { height:1px; background:var(--border); margin:12px 0 10px; }
.alt-custom { display:flex; align-items:flex-end; gap:8px; }
.alt-custom-f { flex:1; display:flex; flex-direction:column; gap:4px; }
.alt-custom-f label { font-size:.625rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--text-muted); }
.alt-custom-f input[type="date"] { padding:7px 9px; border:1px solid var(--border); border-radius:7px; font-size:.825rem; background:var(--surface); color:var(--text-primary); width:100%; }
.alt-custom-f input[type="date"]:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 2px rgba(37,99,235,.12); }
.alt-custom-arrow { font-size:.75rem; color:var(--text-muted); padding-bottom:9px; flex-shrink:0; }
.alt-footer { display:flex; justify-content:flex-end; gap:8px; }
.alt-cancel,.alt-apply { padding:7px 16px; border-radius:7px; font-size:.825rem; font-weight:600; cursor:pointer; font-family:${L.bodyFont}; transition:background .12s,color .12s; }
.alt-cancel { background:transparent; border:1px solid var(--border); color:var(--text-secondary); }
.alt-cancel:hover { background:var(--bg-secondary); }
.alt-apply { background:var(--accent); border:1px solid transparent; color:#fff; }
.alt-apply:hover { opacity:.88; }
/* ── multi-select ── */
.altms { position:relative; }
.altms-trigger { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border:1px solid var(--border); border-radius:8px; background:var(--surface); color:var(--text-secondary); font-family:${L.bodyFont}; font-size:.875rem; cursor:pointer; transition:border-color .15s,color .15s,background .15s; white-space:nowrap; }
.altms-trigger:hover { border-color:var(--accent); color:var(--accent); }
.altms-trigger--active { border-color:var(--accent); color:var(--accent); background:var(--accent-light); }
.altms-label { max-width:140px; overflow:hidden; text-overflow:ellipsis; }
.altms-chevron { transition:transform .18s; flex-shrink:0; color:currentColor; }
.altms-chevron--open { transform:rotate(180deg); }
.altms-dropdown { position:absolute; top:calc(100% + 6px); left:0; z-index:200; min-width:200px; background:var(--surface); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.12); overflow:hidden; animation:altPickIn 140ms cubic-bezier(.16,1,.3,1); }
[dir="rtl"] .altms-dropdown { left:auto; right:0; }
.altms-list { max-height:220px; overflow-y:auto; padding:6px; display:flex; flex-direction:column; gap:2px; }
.altms-option { display:flex; align-items:center; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; transition:background .1s; }
.altms-option:hover { background:var(--bg); }
.altms-option--checked { background:var(--accent-light); }
.altms-check { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
.altms-check-box { width:16px; height:16px; border:1.5px solid var(--border); border-radius:4px; display:flex; align-items:center; justify-content:center; flex-shrink:0; transition:background .1s,border-color .1s; }
.altms-option--checked .altms-check-box { background:var(--accent); border-color:var(--accent); color:var(--text-inverse); }
.altms-opt-label { font-family:${L.bodyFont}; font-size:.875rem; color:var(--text-primary); }
.altms-clear { width:100%; padding:8px 14px; border-top:1px solid var(--border); background:none; border-left:none; border-right:none; border-bottom:none; font-family:${L.bodyFont}; font-size:.8125rem; color:var(--danger); cursor:pointer; text-align:start; transition:background .1s; }
.altms-clear:hover { background:var(--danger-light); }
/* ── filter bar ── */
.alt-filter-bar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:32px; padding:14px 20px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); }
.alt-filter-label { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); white-space:nowrap; }
.alt-filter-sep { width:1px; height:20px; background:var(--border); flex-shrink:0; }
.alt-vat-toggle { margin-inline-start:auto; display:flex; align-items:center; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
.alt-vat-opt { font-family:${L.bodyFont}; font-size:.75rem; font-weight:500; padding:5px 10px; background:var(--surface); color:var(--text-muted); cursor:pointer; border:none; transition:background .1s,color .1s; }
.alt-vat-opt--on { background:var(--accent); color:var(--text-inverse); }
.alt-filter-reset { font-size:.8125rem; color:var(--text-muted); background:none; border:none; cursor:pointer; padding:4px 6px; transition:color .15s; font-family:${L.bodyFont}; }
.alt-filter-reset:hover { color:var(--danger); }
/* ── metrics ── */
.alt-metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:1px; background:var(--border); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; margin-bottom:40px; }
.alt-metric { background:var(--surface); padding:24px 22px; }
.alt-metric-title { font-size:.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--text-muted); margin:0 0 12px; }
.alt-metric-value { font-family:${L.displayFont}; font-size:1.875rem; font-weight:400; color:var(--text-primary); line-height:1; margin-bottom:10px; letter-spacing:-.02em; }
.alt-metric-footer { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.alt-change { font-size:.75rem; font-weight:600; padding:2px 8px; border-radius:20px; white-space:nowrap; font-family:${L.bodyFont}; }
.alt-change--up { background:var(--success-light); color:var(--success); }
.alt-change--down { background:var(--danger-light); color:var(--danger); }
.alt-change--flat { background:var(--muted-light); color:var(--text-muted); }
.alt-metric-prev { font-size:.75rem; color:var(--text-muted); font-family:${L.monoFont}; }
/* ── section heading ── */
.alt-section-heading { display:flex; align-items:center; gap:12px; margin:0 0 20px; }
.alt-section-heading h2 { font-family:${L.displayFont}; font-size:1.375rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; }
.alt-section-heading::after { content:''; flex:1; height:1px; background:var(--border); }
/* ── charts ── */
.dalt-charts-grid { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
.dalt-chart-full { grid-column:1/-1; }
.alt-chart-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:24px 24px 20px; min-width:0; overflow:hidden; }
.alt-chart-header { display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:20px; gap:12px; flex-wrap:wrap; }
.alt-chart-title { font-family:${L.displayFont}; font-size:1.25rem; font-weight:400; color:var(--text-primary); margin:0; letter-spacing:-.02em; font-style:italic; }
.alt-chart-controls { display:flex; gap:5px; flex-wrap:wrap; }
.alt-ctrl { font-family:${L.bodyFont}; font-size:.75rem; font-weight:500; padding:4px 10px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text-secondary); cursor:pointer; transition:background .1s,border-color .1s,color .1s; }
.alt-ctrl:hover { border-color:var(--accent); color:var(--accent); }
.alt-ctrl-on { background:var(--accent)!important; border-color:var(--accent)!important; color:var(--text-inverse)!important; }
.alt-ctrl-sep { width:1px; height:20px; background:var(--border); align-self:center; }
.alt-chart-wrap { position:relative; height:260px; overflow:hidden; direction:ltr; width:100%; }
.alt-chart-wrap--tall { height:320px; }
/* ── responsive ── */
@media (max-width:700px) {
.dalt-hero-name { font-size:1.875rem; }
.dalt-charts-grid { grid-template-columns:1fr; }
.dalt-chart-full { grid-column:auto; }
.alt-metrics { grid-template-columns:1fr 1fr; }
.alt-page-title { font-size:1.75rem; }
.altms-label { max-width:100px; }
}
`}</style>
<h1 className="alt-page-title">{L.pageTitle}</h1> <h1 className="alt-page-title">{L.pageTitle}</h1>
<p className="alt-page-sub">{L.pageSub}</p> <p className="alt-page-sub">{L.pageSub}</p>
<PeriodHero start={start} end={end} onChange={(s,e) => { setStart(s); setEnd(e); }} availableYears={availableYears} L={L} /> <PeriodHero start={start} end={end} onChange={(s,e) => { setStart(s); setEnd(e); }} availableYears={availableYears} L={L} />
<div className="alt-filter-bar"> <div className={`alt-filter-bar${filtersOpen ? ' alt-filter-bar--open' : ''}`}>
<span className="alt-filter-label">{L.filter}</span> <div className="alt-filter-head">
<div className="alt-filter-sep" /> <span className="alt-filter-label">{L.filter}</span>
<AltMultiSelect value={selDistricts} options={allDistricts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} /> {activeFilterCount > 0 && <span className="alt-filter-badge">{activeFilterCount}</span>}
<AltMultiSelect value={selChannels} options={allChannels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} /> <button type="button" className="alt-filter-toggle" onClick={() => setFiltersOpen(v => !v)} aria-expanded={filtersOpen} aria-label="Toggle filters">
<AltMultiSelect value={selMuseums} options={allMuseums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} /> <svg className={`alt-filter-chevron${filtersOpen ? ' alt-filter-chevron--open' : ''}`} width="14" height="14" viewBox="0 0 10 10" fill="none">
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>} <path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<div className="alt-vat-toggle"> </svg>
<button type="button" className={`alt-vat-opt${!includeVAT?' alt-vat-opt--on':''}`} onClick={() => setIncludeVAT(false)}>{L.exclVAT}</button> </button>
<button type="button" className={`alt-vat-opt${includeVAT ?' alt-vat-opt--on':''}`} onClick={() => setIncludeVAT(true)}>{L.inclVAT}</button>
</div> </div>
<div className="alt-vat-toggle"> <div className="alt-filter-body">
<button type="button" className={`alt-vat-opt${activeLang==='en'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('en')}>EN</button> <div className="alt-filter-sep" />
<button type="button" className={`alt-vat-opt${activeLang==='ar'?' alt-vat-opt--on':''}`} onClick={() => setLanguage('ar')}>AR</button> <AltMultiSelect value={selDistricts} options={allDistricts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
<AltMultiSelect value={selChannels} options={allChannels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
<AltMultiSelect value={selMuseums} options={allMuseums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} />
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
<div className="alt-filter-spacer" />
<div className="alt-vat-toggle">
<button type="button" className={`alt-vat-opt${!includeVAT?' alt-vat-opt--on':''}`} onClick={() => setIncludeVAT(false)}>{L.exclVAT}</button>
<button type="button" className={`alt-vat-opt${includeVAT ?' alt-vat-opt--on':''}`} onClick={() => setIncludeVAT(true)}>{L.inclVAT}</button>
</div>
</div> </div>
</div> </div>
@@ -660,25 +325,27 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
<div className="alt-chart-header"> <div className="alt-chart-header">
<h3 className="alt-chart-title">{L.trendTitle}</h3> <h3 className="alt-chart-title">{L.trendTitle}</h3>
<div className="alt-chart-controls"> <div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
{granOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)} {granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
<div 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">
<div className="alt-chart-header"> <div className="alt-chart-header">
<h3 className="alt-chart-title">{L.museumTitle}</h3> <h3 className="alt-chart-title">{L.museumTitle}</h3>
<div className="alt-chart-controls"> <div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${museumChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('bar')}>{L.barLabel}</button> <button type="button" aria-pressed={museumChartType==='bar'} className={`alt-ctrl${museumChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('bar')}>{L.barLabel}</button>
<button type="button" className={`alt-ctrl${museumChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('pie')}>{L.pieLabel}</button> <button type="button" aria-pressed={museumChartType==='pie'} className={`alt-ctrl${museumChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('pie')}>{L.pieLabel}</button>
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${museumDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('absolute')}>{L.absLabel}</button> <button type="button" aria-pressed={museumDisplayMode==='absolute'} className={`alt-ctrl${museumDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('absolute')}>{L.absLabel}</button>
<button type="button" className={`alt-ctrl${museumDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('percent')}>{L.pctLabel}</button> <button type="button" aria-pressed={museumDisplayMode==='percent'} className={`alt-ctrl${museumDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('percent')}>{L.pctLabel}</button>
</div> </div>
</div> </div>
<div className="alt-chart-wrap alt-chart-wrap--tall"> <div className="alt-chart-wrap alt-chart-wrap--tall">
@@ -690,13 +357,13 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
<div className="alt-chart-header"> <div className="alt-chart-header">
<h3 className="alt-chart-title">{L.channelTitle}</h3> <h3 className="alt-chart-title">{L.channelTitle}</h3>
<div className="alt-chart-controls"> <div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${channelChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('bar')}>{L.barLabel}</button> <button type="button" aria-pressed={channelChartType==='bar'} className={`alt-ctrl${channelChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('bar')}>{L.barLabel}</button>
<button type="button" className={`alt-ctrl${channelChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('pie')}>{L.pieLabel}</button> <button type="button" aria-pressed={channelChartType==='pie'} className={`alt-ctrl${channelChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('pie')}>{L.pieLabel}</button>
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${channelDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('absolute')}>{L.absLabel}</button> <button type="button" aria-pressed={channelDisplayMode==='absolute'} className={`alt-ctrl${channelDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('absolute')}>{L.absLabel}</button>
<button type="button" className={`alt-ctrl${channelDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('percent')}>{L.pctLabel}</button> <button type="button" aria-pressed={channelDisplayMode==='percent'} className={`alt-ctrl${channelDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('percent')}>{L.pctLabel}</button>
</div> </div>
</div> </div>
<div className="alt-chart-wrap"> <div className="alt-chart-wrap">
@@ -708,13 +375,13 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
<div className="alt-chart-header"> <div className="alt-chart-header">
<h3 className="alt-chart-title">{L.districtTitle}</h3> <h3 className="alt-chart-title">{L.districtTitle}</h3>
<div className="alt-chart-controls"> <div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${districtChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('bar')}>{L.barLabel}</button> <button type="button" aria-pressed={districtChartType==='bar'} className={`alt-ctrl${districtChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('bar')}>{L.barLabel}</button>
<button type="button" className={`alt-ctrl${districtChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('pie')}>{L.pieLabel}</button> <button type="button" aria-pressed={districtChartType==='pie'} className={`alt-ctrl${districtChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('pie')}>{L.pieLabel}</button>
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${districtDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('absolute')}>{L.absLabel}</button> <button type="button" aria-pressed={districtDisplayMode==='absolute'} className={`alt-ctrl${districtDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('absolute')}>{L.absLabel}</button>
<button type="button" className={`alt-ctrl${districtDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('percent')}>{L.pctLabel}</button> <button type="button" aria-pressed={districtDisplayMode==='percent'} className={`alt-ctrl${districtDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('percent')}>{L.pctLabel}</button>
</div> </div>
</div> </div>
<div className="alt-chart-wrap"> <div className="alt-chart-wrap">
+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;
}
+128 -161
View File
@@ -6,13 +6,13 @@ import type { Season } from '../types';
const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899']; const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899'];
interface SeasonRowProps { interface SeasonItemProps {
season: Season; season: Season;
onSave: (id: number, data: Partial<Season>) => Promise<void>; onSave: (id: number, data: Partial<Season>) => Promise<void>;
onDelete: (id: number) => Promise<void>; onDelete: (id: number) => Promise<void>;
} }
function SeasonRow({ season, onSave, onDelete }: SeasonRowProps) { function SeasonItem({ season, onSave, onDelete }: SeasonItemProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [form, setForm] = useState(season); const [form, setForm] = useState(season);
@@ -21,48 +21,46 @@ function SeasonRow({ season, onSave, onDelete }: SeasonRowProps) {
setEditing(false); setEditing(false);
}; };
if (!editing) {
return (
<tr>
<td>
<span className="season-chip" style={{ backgroundColor: season.Color + '20', color: season.Color, borderColor: season.Color }}>
{season.Name} {season.HijriYear}
</span>
</td>
<td>{season.StartDate}</td>
<td>{season.EndDate}</td>
<td>
<div className="season-actions">
<button className="btn-small" onClick={() => setEditing(true)}>Edit</button>
<button className="btn-small btn-danger" onClick={() => onDelete(season.Id!)}>Delete</button>
</div>
</td>
</tr>
);
}
return ( return (
<tr className="editing"> <div className={`settings-item${editing ? ' settings-item--editing' : ''}`}>
<td> <div className="settings-item-row">
<div className="season-edit-name"> <span className="season-chip" style={{ backgroundColor: season.Color + '20', color: season.Color, borderColor: season.Color }}>
<input type="text" value={form.Name} onChange={e => setForm({ ...form, Name: e.target.value })} placeholder="Name" /> {season.Name} {season.HijriYear}
<input type="number" value={form.HijriYear} onChange={e => setForm({ ...form, HijriYear: parseInt(e.target.value) || 0 })} placeholder="Year" style={{ width: 80 }} /> </span>
<input type="color" value={form.Color} onChange={e => setForm({ ...form, Color: e.target.value })} /> <span className="settings-dates">{season.StartDate} {season.EndDate}</span>
<div className="settings-item-actions">
<button className="btn-small" onClick={() => { setForm(season); setEditing(true); }}>Edit</button>
<button className="btn-small btn-danger" onClick={() => onDelete(season.Id!)}>Delete</button>
</div> </div>
</td> </div>
<td><input type="date" value={form.StartDate} onChange={e => setForm({ ...form, StartDate: e.target.value })} /></td> {editing && (
<td><input type="date" value={form.EndDate} onChange={e => setForm({ ...form, EndDate: e.target.value })} /></td> <div className="settings-item-form">
<td> <div className="form-row">
<div className="season-actions"> <input className="form-input" type="text" value={form.Name} onChange={e => setForm({ ...form, Name: e.target.value })} placeholder="Name" />
<button className="btn-small btn-primary" onClick={handleSave}>Save</button> <input className="form-input form-input--sm" type="number" value={form.HijriYear} onChange={e => setForm({ ...form, HijriYear: parseInt(e.target.value) || 0 })} placeholder="Hijri Year" />
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button> <input type="color" className="form-color" value={form.Color} onChange={e => setForm({ ...form, Color: e.target.value })} />
</div>
<div className="form-row">
<label className="form-field">
<span className="form-label">Start</span>
<input className="form-input" type="date" value={form.StartDate} onChange={e => setForm({ ...form, StartDate: e.target.value })} />
</label>
<label className="form-field">
<span className="form-label">End</span>
<input className="form-input" type="date" value={form.EndDate} onChange={e => setForm({ ...form, EndDate: e.target.value })} />
</label>
</div>
<div className="form-actions">
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
</div>
</div> </div>
</td> )}
</tr> </div>
); );
} }
interface UserRowProps { interface UserItemProps {
user: User; user: User;
allMuseums: string[]; allMuseums: string[];
allChannels: string[]; allChannels: string[];
@@ -70,7 +68,7 @@ interface UserRowProps {
onDelete: (id: number) => Promise<void>; onDelete: (id: number) => Promise<void>;
} }
function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowProps) { function UserItem({ user, allMuseums, allChannels, onUpdate, onDelete }: UserItemProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [allowedMuseums, setAllowedMuseums] = useState<string[]>(() => { const [allowedMuseums, setAllowedMuseums] = useState<string[]>(() => {
try { return JSON.parse(user.AllowedMuseums || '[]'); } catch { return []; } try { return JSON.parse(user.AllowedMuseums || '[]'); } catch { return []; }
@@ -91,17 +89,18 @@ function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowP
}; };
const isAdmin = user.Role === 'admin'; const isAdmin = user.Role === 'admin';
const museumCount = (() => { try { const a = JSON.parse(user.AllowedMuseums || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })(); const museumCount = (() => { try { const a = JSON.parse(user.AllowedMuseums || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
const channelCount = (() => { try { const a = JSON.parse(user.AllowedChannels || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })(); const channelCount = (() => { try { const a = JSON.parse(user.AllowedChannels || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })();
if (!editing) { return (
return ( <div className={`settings-item${editing ? ' settings-item--editing' : ''}`}>
<tr key={user.Id}> <div className="settings-item-row">
<td>{user.Name}</td> <div className="settings-user-info">
<td><code>{user.PIN}</code></td> <span className="settings-user-name">{user.Name}</span>
<td>{user.Role}</td> <code className="settings-user-pin">{user.PIN}</code>
<td> <span className="settings-user-role">{user.Role}</span>
</div>
<div className="settings-user-access">
{isAdmin ? ( {isAdmin ? (
<span className="access-badge access-badge--full">Full access</span> <span className="access-badge access-badge--full">Full access</span>
) : ( ) : (
@@ -110,53 +109,45 @@ function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowP
<span className="access-badge">{channelCount === 0 ? 'All channels' : `${channelCount} channels`}</span> <span className="access-badge">{channelCount === 0 ? 'All channels' : `${channelCount} channels`}</span>
</> </>
)} )}
</td> </div>
<td> <div className="settings-item-actions">
<div className="season-actions"> {!isAdmin && <button className="btn-small" onClick={() => setEditing(true)}>Edit access</button>}
{!isAdmin && <button className="btn-small" onClick={() => setEditing(true)}>Edit access</button>} <button className="btn-small btn-danger" onClick={() => onDelete(user.Id!)}>Delete</button>
<button className="btn-small btn-danger" onClick={() => onDelete(user.Id!)}>Delete</button> </div>
</div> </div>
</td> {editing && (
</tr> <div className="settings-item-form">
); <div className="access-columns">
} <div className="access-col">
<div className="access-col-title">
return ( Events {allowedMuseums.length === 0 && <span className="access-badge access-badge--full">All</span>}
<tr className="editing">
<td colSpan={5}>
<div style={{ padding: '12px 4px' }}>
<strong>{user.Name}</strong>
<div style={{ display: 'flex', gap: 32, marginTop: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontWeight: 600, marginBottom: 8 }}>
Allowed Events {allowedMuseums.length === 0 && <span className="access-badge access-badge--full">All</span>}
</div> </div>
{allMuseums.map(m => ( {allMuseums.map(m => (
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, cursor: 'pointer' }}> <label key={m} className="access-check">
<input type="checkbox" checked={allowedMuseums.includes(m)} onChange={() => toggleItem(allowedMuseums, setAllowedMuseums, m)} /> <input type="checkbox" checked={allowedMuseums.includes(m)} onChange={() => toggleItem(allowedMuseums, setAllowedMuseums, m)} />
{m} {m}
</label> </label>
))} ))}
</div> </div>
<div> <div className="access-col">
<div style={{ fontWeight: 600, marginBottom: 8 }}> <div className="access-col-title">
Allowed Channels {allowedChannels.length === 0 && <span className="access-badge access-badge--full">All</span>} Channels {allowedChannels.length === 0 && <span className="access-badge access-badge--full">All</span>}
</div> </div>
{allChannels.map(c => ( {allChannels.map(c => (
<label key={c} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, cursor: 'pointer' }}> <label key={c} className="access-check">
<input type="checkbox" checked={allowedChannels.includes(c)} onChange={() => toggleItem(allowedChannels, setAllowedChannels, c)} /> <input type="checkbox" checked={allowedChannels.includes(c)} onChange={() => toggleItem(allowedChannels, setAllowedChannels, c)} />
{c} {c}
</label> </label>
))} ))}
</div> </div>
</div> </div>
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}> <div className="form-actions">
<button className="btn-small btn-primary" onClick={handleSave}>Save</button> <button className="btn-small btn-primary" onClick={handleSave}>Save</button>
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button> <button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
</div> </div>
</div> </div>
</td> )}
</tr> </div>
); );
} }
@@ -173,7 +164,7 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
const [newSeason, setNewSeason] = useState<Omit<Season, 'Id'>>({ const [newSeason, setNewSeason] = useState<Omit<Season, 'Id'>>({
Name: '', Name: '',
HijriYear: new Date().getFullYear() - 579, // rough Gregorian → Hijri HijriYear: new Date().getFullYear() - 579,
StartDate: '', StartDate: '',
EndDate: '', EndDate: '',
Color: DEFAULT_COLORS[0], Color: DEFAULT_COLORS[0],
@@ -238,42 +229,36 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
<h2>{t('settings.seasons')}</h2> <h2>{t('settings.seasons')}</h2>
<p className="settings-hint">{t('settings.seasonsHint')}</p> <p className="settings-hint">{t('settings.seasonsHint')}</p>
<div className="table-container"> <div className="settings-list">
<table> {loading ? (
<thead> <div className="settings-loading">Loading...</div>
<tr> ) : (
<th>{t('settings.seasonName')}</th> seasons.map(s => (
<th>{t('settings.startDate')}</th> <SeasonItem key={s.Id} season={s} onSave={handleSave} onDelete={handleDelete} />
<th>{t('settings.endDate')}</th> ))
<th>{t('settings.actions')}</th> )}
</tr> </div>
</thead>
<tbody> <div className="settings-add-form">
{loading ? ( <div className="settings-add-title">{t('settings.add')} Season</div>
<tr><td colSpan={4} style={{ textAlign: 'center', padding: 24 }}>Loading...</td></tr> <div className="form-row">
) : ( <input className="form-input" type="text" value={newSeason.Name} onChange={e => setNewSeason({ ...newSeason, Name: e.target.value })} placeholder={t('settings.namePlaceholder')} />
seasons.map(s => ( <input className="form-input form-input--sm" type="number" value={newSeason.HijriYear} onChange={e => setNewSeason({ ...newSeason, HijriYear: parseInt(e.target.value) || 0 })} />
<SeasonRow key={s.Id} season={s} onSave={handleSave} onDelete={handleDelete} /> <input type="color" className="form-color" value={newSeason.Color} onChange={e => setNewSeason({ ...newSeason, Color: e.target.value })} />
)) </div>
)} <div className="form-row">
<tr className="add-row"> <label className="form-field">
<td> <span className="form-label">{t('settings.startDate')}</span>
<div className="season-edit-name"> <input className="form-input" type="date" value={newSeason.StartDate} onChange={e => setNewSeason({ ...newSeason, StartDate: e.target.value })} />
<input type="text" value={newSeason.Name} onChange={e => setNewSeason({ ...newSeason, Name: e.target.value })} placeholder={t('settings.namePlaceholder')} /> </label>
<input type="number" value={newSeason.HijriYear} onChange={e => setNewSeason({ ...newSeason, HijriYear: parseInt(e.target.value) || 0 })} style={{ width: 80 }} /> <label className="form-field">
<input type="color" value={newSeason.Color} onChange={e => setNewSeason({ ...newSeason, Color: e.target.value })} /> <span className="form-label">{t('settings.endDate')}</span>
</div> <input className="form-input" type="date" value={newSeason.EndDate} onChange={e => setNewSeason({ ...newSeason, EndDate: e.target.value })} />
</td> </label>
<td><input type="date" value={newSeason.StartDate} onChange={e => setNewSeason({ ...newSeason, StartDate: e.target.value })} /></td> </div>
<td><input type="date" value={newSeason.EndDate} onChange={e => setNewSeason({ ...newSeason, EndDate: e.target.value })} /></td> <button className="btn-small btn-primary" onClick={handleCreate} disabled={!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate}>
<td> {t('settings.add')}
<button className="btn-small btn-primary" onClick={handleCreate} disabled={!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate}> </button>
{t('settings.add')}
</button>
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
@@ -281,55 +266,37 @@ function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
<h2>{t('settings.users')}</h2> <h2>{t('settings.users')}</h2>
<p className="settings-hint">{t('settings.usersHint')}</p> <p className="settings-hint">{t('settings.usersHint')}</p>
<div className="table-container"> <div className="settings-list">
<table> {users.map(u => (
<thead> <UserItem
<tr> key={u.Id}
<th>{t('settings.userName')}</th> user={u}
<th>{t('settings.userPin')}</th> allMuseums={allMuseums}
<th>{t('settings.userRole')}</th> allChannels={allChannels}
<th>Access</th> onUpdate={handleUpdateUser}
<th>{t('settings.actions')}</th> onDelete={async (id) => { await deleteUser(id); await loadUsers(); }}
</tr> />
</thead> ))}
<tbody> </div>
{users.map(u => (
<UserRow <div className="settings-add-form">
key={u.Id} <div className="settings-add-title">{t('settings.add')} User</div>
user={u} <div className="form-row">
allMuseums={allMuseums} <input className="form-input" type="text" value={newUser.Name} onChange={e => setNewUser({ ...newUser, Name: e.target.value })} placeholder={t('settings.userNamePlaceholder')} />
allChannels={allChannels} <input className="form-input form-input--sm" type="text" value={newUser.PIN} onChange={e => setNewUser({ ...newUser, PIN: e.target.value })} placeholder="PIN" />
onUpdate={handleUpdateUser} <select className="form-input form-input--sm" value={newUser.Role} onChange={e => setNewUser({ ...newUser, Role: e.target.value })}>
onDelete={async (id) => { await deleteUser(id); await loadUsers(); }} <option value="viewer">Viewer</option>
/> <option value="admin">Admin</option>
))} </select>
<tr className="add-row"> </div>
<td> <button className="btn-small btn-primary" onClick={async () => {
<input type="text" value={newUser.Name} onChange={e => setNewUser({ ...newUser, Name: e.target.value })} placeholder={t('settings.userNamePlaceholder')} /> if (!newUser.Name || !newUser.PIN) return;
</td> await createUser(newUser);
<td> setNewUser({ Name: '', PIN: '', Role: 'viewer', AllowedMuseums: '[]', AllowedChannels: '[]' });
<input type="text" value={newUser.PIN} onChange={e => setNewUser({ ...newUser, PIN: e.target.value })} placeholder="PIN" /> await loadUsers();
</td> }} disabled={!newUser.Name || !newUser.PIN}>
<td> {t('settings.add')}
<select value={newUser.Role} onChange={e => setNewUser({ ...newUser, Role: e.target.value })}> </button>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
</select>
</td>
<td></td>
<td>
<button className="btn-small btn-primary" onClick={async () => {
if (!newUser.Name || !newUser.PIN) return;
await createUser(newUser);
setNewUser({ Name: '', PIN: '', Role: 'viewer', AllowedMuseums: '[]', AllowedChannels: '[]' });
await loadUsers();
}} disabled={!newUser.Name || !newUser.PIN}>
{t('settings.add')}
</button>
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
+44
View File
@@ -0,0 +1,44 @@
import React, { useState, useRef, useEffect } from 'react';
// ─── multi-select ─────────────────────────────────────────────────
export default function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
value: string[]; options: string[];
onChange: (vals: string[]) => void;
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
}, [open]);
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v !== opt) : [...value, opt]);
const label = value.length === 0 ? allLabel : value.length === 1 ? value[0] : countLabel(value.length);
return (
<div ref={ref} className="altms">
<button type="button" className={`altms-trigger${value.length > 0 ? ' altms-trigger--active' : ''}`} onClick={() => setOpen(v => !v)} aria-expanded={open} aria-haspopup="listbox">
<span className="altms-label">{label}</span>
<svg className={`altms-chevron${open ? ' altms-chevron--open' : ''}`} width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{open && (
<div className="altms-dropdown" role="listbox" aria-multiselectable="true">
<div className="altms-list">
{options.map(opt => (
<label key={opt} role="option" aria-selected={value.includes(opt)} className={`altms-option${value.includes(opt) ? ' altms-option--checked' : ''}`}>
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
<span className="altms-opt-label">{opt}</span>
</label>
))}
</div>
{value.length > 0 && <button type="button" className="altms-clear" onClick={() => { onChange([]); setOpen(false); }}>{clearLabel}</button>}
</div>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { formatCurrency, formatNumber } from '../../services/dataService';
// ─── metric card ──────────────────────────────────────────────────
export default function MetricCard({ title, curr, prev, isCurrency, newLabel }: {
title: string; curr: number; prev: number; isCurrency?: boolean; newLabel?: string;
}) {
const fmt = (n: number) => isCurrency ? formatCurrency(n) : formatNumber(n);
const change = prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
const isPos = change > 0, isNeg = change < 0;
return (
<div className="alt-metric">
<p className="alt-metric-title">{title}</p>
<div className="alt-metric-value">{fmt(curr)}</div>
<div className="alt-metric-footer">
{isFinite(change)
? <span className={`alt-change ${isPos ? 'alt-change--up' : isNeg ? 'alt-change--down' : 'alt-change--flat'}`}>{isPos ? '▲' : isNeg ? '▼' : '—'} {Math.abs(change).toFixed(1)}%</span>
: <span className="alt-change alt-change--up">{newLabel ?? 'New'}</span>}
<span className="alt-metric-prev">{fmt(prev)}</span>
</div>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
import React, { useState, useRef, useEffect } from 'react';
import type { LC } from '../../lib/locale';
import { MONTH_KEYS, makePresets, guessPreset, periodNameL, dateRangeTextL } from '../../lib/dateHelpers';
// ─── inline picker ────────────────────────────────────────────────
export function InlinePicker({ start, end, onChange, onClose, availableYears, L }: {
start: string; end: string; onChange: (s: string, e: string) => void;
onClose: () => void;
availableYears: number[]; L: LC;
}) {
const g = guessPreset(start, end);
const [year, setYear] = useState(g?.year ?? parseInt(start.slice(0, 4)));
const [active, setActive] = useState<string | null>(g?.key ?? null);
const [draftStart, setDraftStart] = useState(start);
const [draftEnd, setDraftEnd] = useState(end);
const minY = Math.min(...availableYears), maxY = Math.max(...availableYears);
const pick = (key: string) => { const r = makePresets(year)[key]; if (!r) return; setActive(key); setDraftStart(r.start); setDraftEnd(r.end); };
const shift = (d: number) => {
const ny = year + d; if (ny < minY || ny > maxY) return; setYear(ny);
if (active && makePresets(ny)[active]) { setDraftStart(makePresets(ny)[active].start); setDraftEnd(makePresets(ny)[active].end); }
};
return (
<div className="alt-picker" id="period-picker-panel">
<div className="alt-picker-year">
<button type="button" onClick={() => shift(L.dir === 'rtl' ? 1 : -1)} disabled={L.dir === 'rtl' ? year >= maxY : year <= minY} className="alt-yr-btn">
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M5.5 9.5L1.5 5.5L5.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
<span className="alt-yr-val">{year}</span>
<button type="button" onClick={() => shift(L.dir === 'rtl' ? -1 : 1)} disabled={L.dir === 'rtl' ? year <= minY : year >= maxY} className="alt-yr-btn">
<svg width="7" height="11" viewBox="0 0 7 11" fill="none"><path d="M1.5 1.5L5.5 5.5L1.5 9.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
</div>
<p className="alt-picker-section">{L.monthSection}</p>
<div className="alt-chips">
{MONTH_KEYS.map((k, i) => (
<button key={k} type="button" className={`alt-chip${active === k ? ' alt-chip-on' : ''}`} onClick={() => pick(k)}>{L.monthShort[i]}</button>
))}
</div>
<p className="alt-picker-section">{L.periodSection}</p>
<div className="alt-chips">
{['q1','q2','q3','q4','h1','h2'].map(k => (
<button key={k} type="button" className={`alt-chip${active === k ? ' alt-chip-on' : ''}`} onClick={() => pick(k)}>{L.periods[k]}</button>
))}
<button type="button" className={`alt-chip alt-chip-wide${active === 'full' ? ' alt-chip-on' : ''}`} onClick={() => pick('full')}>{L.periods.full}</button>
</div>
<div className="alt-picker-div" />
<div className="alt-custom">
<div className="alt-custom-f"><label>{L.from}</label><input type="date" value={draftStart} onChange={e => { setActive(null); setDraftStart(e.target.value); }} /></div>
<span className="alt-custom-arrow">{L.dateRangeSep}</span>
<div className="alt-custom-f"><label>{L.to}</label><input type="date" value={draftEnd} onChange={e => { setActive(null); setDraftEnd(e.target.value); }} /></div>
</div>
<div className="alt-picker-div" />
<div className="alt-footer">
<button type="button" className="alt-cancel" onClick={onClose}>{L.close}</button>
<button type="button" className="alt-apply" onClick={() => { onChange(draftStart, draftEnd); onClose(); }}>{L.apply}</button>
</div>
</div>
);
}
// ─── period hero ──────────────────────────────────────────────────
export default function PeriodHero({ start, end, onChange, availableYears, L }: {
start: string; end: string; onChange: (s: string, e: string) => void;
availableYears: number[]; L: LC;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onM = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
const onK = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onM); document.addEventListener('keydown', onK);
return () => { document.removeEventListener('mousedown', onM); document.removeEventListener('keydown', onK); };
}, [open]);
return (
<div ref={ref} className="dalt-hero">
<div className="dalt-hero-inner">
<div>
<div className="dalt-hero-name">{periodNameL(start, end, L)}</div>
<div className="dalt-hero-range">{dateRangeTextL(start, end, L)}</div>
</div>
<button type="button" className="dalt-hero-btn" onClick={() => setOpen(v => !v)} aria-expanded={open} aria-controls="period-picker-panel">
{open ? L.close : L.changePeriod}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
{open && <InlinePicker start={start} end={end} onChange={onChange} onClose={() => setOpen(false)} availableYears={availableYears} L={L} />}
</div>
);
}
+92 -38
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',
@@ -37,9 +40,21 @@ export const chartColors = {
success: '#059669', success: '#059669',
danger: '#dc2626', danger: '#dc2626',
muted: '#94a3b8', muted: '#94a3b8',
grid: '#f1f5f9' grid: '#e2e8f0' // fallback only; use getChartTheme().border at runtime
}; };
export function getChartTheme() {
const style = getComputedStyle(document.documentElement);
const get = (v: string) => style.getPropertyValue(v).trim();
return {
surface: get('--surface') || '#ffffff',
textPrimary: get('--text-primary') || '#0f172a',
textMuted: get('--text-muted') || '#64748b',
border: get('--border') || '#e2e8f0',
textInverse: get('--text-inverse') || '#ffffff',
};
}
// Extended palette for charts with many categories (events, channels) // Extended palette for charts with many categories (events, channels)
export const chartPalette = [ export const chartPalette = [
'#2563eb', // blue '#2563eb', // blue
@@ -54,15 +69,15 @@ export const chartPalette = [
'#ea580c', // orange '#ea580c', // orange
]; ];
export const createDataLabelConfig = (showDataLabels: boolean): any => ({ export const createDataLabelConfig = (showDataLabels: boolean, overrides?: { color?: string; backgroundColor?: string }): any => ({
display: showDataLabels, display: showDataLabels,
color: '#1e293b', color: overrides?.color ?? '#1e293b',
font: { size: 10, weight: 600 }, font: { size: 10, weight: 600 },
anchor: 'end', anchor: 'end',
align: 'end', align: 'end',
offset: 4, offset: 4,
padding: 4, padding: 4,
backgroundColor: 'rgba(255, 255, 255, 0.85)', backgroundColor: overrides?.backgroundColor ?? 'rgba(255, 255, 255, 0.85)',
borderRadius: 3, borderRadius: 3,
textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment
formatter: (value: number | null) => { formatter: (value: number | null) => {
@@ -74,43 +89,82 @@ export const createDataLabelConfig = (showDataLabels: boolean): any => ({
} }
}); });
export const createBaseOptions = (showDataLabels: boolean): any => ({ export const createBaseOptions = (showDataLabels: boolean): any => {
responsive: true, const theme = getChartTheme();
maintainAspectRatio: false, return {
locale: 'en-US', // Force LTR number formatting responsive: true,
layout: { maintainAspectRatio: false,
padding: { locale: 'en-US', // Force LTR number formatting
top: showDataLabels ? 25 : 5, layout: {
right: 5, padding: {
bottom: 5, top: showDataLabels ? 25 : 5,
left: 5 right: 5,
bottom: 5,
left: 5
}
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: theme.surface,
titleColor: theme.textPrimary,
bodyColor: theme.textMuted,
borderColor: theme.border,
borderWidth: 1,
padding: 12,
cornerRadius: 8,
titleFont: { size: 12 },
bodyFont: { size: 11 },
rtl: false,
textDirection: 'ltr',
usePointStyle: true,
boxPadding: 6,
},
datalabels: createDataLabelConfig(showDataLabels, {
color: theme.textPrimary,
backgroundColor: theme.surface + 'dd',
})
},
scales: {
x: {
grid: { display: false },
ticks: { font: { size: 10 }, color: theme.textMuted }
},
y: {
grid: { color: theme.border },
ticks: { font: { size: 10 }, color: theme.textMuted },
border: { display: false }
}
}
};
};
// Hover-dim + end-of-line name labels for multi-museum trend charts.
// Only activates for charts that have datasets marked with _isMuseumLine.
const trendLinePlugin = {
id: 'trendLineOverlay',
// ── hover dim ──────────────────────────────────────────────────
beforeDatasetDraw(chart: any, args: any) {
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
const active = chart.getActiveElements();
if (active.length === 0) return;
if (active[0].datasetIndex !== args.index) {
chart.ctx.save();
chart.ctx.globalAlpha = 0.15;
} }
}, },
plugins: { afterDatasetDraw(chart: any, args: any) {
legend: { display: false }, if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
tooltip: { const active = chart.getActiveElements();
backgroundColor: '#1e293b', if (active.length > 0 && active[0].datasetIndex !== args.index) {
padding: 12, chart.ctx.restore();
cornerRadius: 8,
titleFont: { size: 12 },
bodyFont: { size: 11 },
rtl: false,
textDirection: 'ltr'
},
datalabels: createDataLabelConfig(showDataLabels)
},
scales: {
x: {
grid: { display: false },
ticks: { font: { size: 10 }, color: '#94a3b8' }
},
y: {
grid: { color: chartColors.grid },
ticks: { font: { size: 10 }, color: '#94a3b8' },
border: { display: false }
} }
} },
});
};
ChartJS.register(trendLinePlugin);
export const lineDatasetDefaults = { export const lineDatasetDefaults = {
borderWidth: 2, borderWidth: 2,
+63
View File
@@ -0,0 +1,63 @@
import type { LC } from './locale';
// ─── date helpers ─────────────────────────────────────────────────
export const MONTH_KEYS = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
export function isLeap(y: number): boolean {
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
}
export function makePresets(y: number): Record<string, { start: string; end: string }> {
const feb = isLeap(y) ? 29 : 28;
return {
jan:{start:`${y}-01-01`,end:`${y}-01-31`}, feb:{start:`${y}-02-01`,end:`${y}-02-${String(feb).padStart(2,'0')}`},
mar:{start:`${y}-03-01`,end:`${y}-03-31`}, apr:{start:`${y}-04-01`,end:`${y}-04-30`},
may:{start:`${y}-05-01`,end:`${y}-05-31`}, jun:{start:`${y}-06-01`,end:`${y}-06-30`},
jul:{start:`${y}-07-01`,end:`${y}-07-31`}, aug:{start:`${y}-08-01`,end:`${y}-08-31`},
sep:{start:`${y}-09-01`,end:`${y}-09-30`}, oct:{start:`${y}-10-01`,end:`${y}-10-31`},
nov:{start:`${y}-11-01`,end:`${y}-11-30`}, dec:{start:`${y}-12-01`,end:`${y}-12-31`},
q1:{start:`${y}-01-01`,end:`${y}-03-31`}, q2:{start:`${y}-04-01`,end:`${y}-06-30`},
q3:{start:`${y}-07-01`,end:`${y}-09-30`}, q4:{start:`${y}-10-01`,end:`${y}-12-31`},
h1:{start:`${y}-01-01`,end:`${y}-06-30`}, h2:{start:`${y}-07-01`,end:`${y}-12-31`},
full:{start:`${y}-01-01`,end:`${y}-12-31`},
};
}
export function guessPreset(start: string, end: string): { key: string; year: number } | null {
const year = parseInt(start.slice(0, 4));
const presets = makePresets(year);
for (const [key, r] of Object.entries(presets)) {
if (r.start === start && r.end === end) return { key, year };
}
return null;
}
export function periodNameL(start: string, end: string, L: LC): string {
const year = parseInt(start.slice(0, 4));
const g = guessPreset(start, end);
if (!g) {
const fmt = (d: string) => { const [,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]}`; };
const ey = parseInt(end.slice(0, 4));
return year === ey ? `${fmt(start)} ${fmt(end)} ${year}` : `${fmt(start)} ${year} ${fmt(end)} ${ey}`;
}
const mi = MONTH_KEYS.indexOf(g.key);
if (mi >= 0) return `${L.monthFull[mi]} ${g.year}`;
if (g.key === 'full') return L.fullYearLabel(g.year);
return `${L.periods[g.key] ?? g.key.toUpperCase()} ${g.year}`;
}
export function dateRangeTextL(start: string, end: string, L: LC): string {
const fmt = (d: string) => { const [y,m,day] = d.split('-'); return `${parseInt(day)} ${L.monthShort[parseInt(m)-1]} ${y}`; };
return `${fmt(start)} ${L.dateRangeSep} ${fmt(end)}`;
}
export function currentMonth(): { start: string; end: string } {
const now = new Date(); const y = now.getFullYear(), m = now.getMonth() + 1;
const p = (n: number) => String(n).padStart(2, '0');
return { start: `${y}-${p(m)}-01`, end: `${y}-${p(m)}-${p(new Date(y, m, 0).getDate())}` };
}
export function shiftYear(s: string): string {
return s.replace(/^(\d{4})/, (_, y) => String(parseInt(y) - 1));
}
+139
View File
@@ -0,0 +1,139 @@
// ─── language config ──────────────────────────────────────────────
// Shared LC interface used by Dashboard and Comparison.
// Fields marked with a comment are only consumed by one page but kept
// here so both components share a single type.
export interface LC {
dir: 'ltr' | 'rtl';
/** @deprecated Fonts are now loaded from index.html; kept for compatibility */
fontImport: string;
bodyFont: string;
displayFont: string;
monoFont: string;
monthFull: string[];
monthShort: string[];
periods: Record<string, string>;
fullYearLabel: (y: number) => string;
dateRangeSep: string;
backLink: string;
backTo: string;
pageTitle: string;
pageSub: string;
// Dashboard
changePeriod: string;
close: string;
apply: string;
filter: string;
allDistricts: string;
allChannels: string;
allMuseums: string;
countDistricts: (n: number) => string;
countChannels: (n: number) => string;
countMuseums: (n: number) => string;
reset: string;
exclVAT: string;
inclVAT: string;
keyMetrics: string;
revenue: string;
visitors: string;
tickets: string;
avgRev: string;
pilgrims: string;
captureRate: string;
charts: string;
trendTitle: string;
museumTitle: string;
channelTitle: string;
districtTitle: string;
daily: string;
weekly: string;
monthly: string;
newLabel: string;
clearSel: string;
monthSection: string;
periodSection: string;
from: string;
to: string;
vsLabel: string;
barLabel: string;
pieLabel: string;
absLabel: string;
pctLabel: string;
// Comparison-specific
currentRole: string;
previousRole: string;
currentHint: string;
previousHint: string;
vs: string;
}
export const EN: LC = {
dir: 'ltr',
fontImport: `@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&display=swap');`,
bodyFont: "'Outfit', sans-serif",
displayFont: "'DM Serif Display', serif",
monoFont: "ui-monospace, 'Cascadia Code', monospace",
monthFull: ['January','February','March','April','May','June','July','August','September','October','November','December'],
monthShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
periods: { q1:'Q1', q2:'Q2', q3:'Q3', q4:'Q4', h1:'H1', h2:'H2', full:'Full Year' },
fullYearLabel: (y) => String(y),
dateRangeSep: '→',
backLink: 'Back to Dashboard', backTo: '/',
pageTitle: 'Overview', pageSub: 'Museum performance at a glance.',
changePeriod: 'Change period', close: 'Cancel', apply: 'Apply',
filter: 'Filter',
allDistricts: 'All districts', allChannels: 'All channels', allMuseums: 'All museums',
countDistricts: (n) => `${n} districts`,
countChannels: (n) => `${n} channels`,
countMuseums: (n) => `${n} museums`,
reset: 'Reset', exclVAT: 'Excl. VAT', inclVAT: 'Incl. VAT',
keyMetrics: 'Key Metrics',
revenue: 'Revenue', visitors: 'Visitors', tickets: 'Tickets',
avgRev: 'Avg Rev / Visitor', pilgrims: 'Pilgrims', captureRate: 'Capture Rate %',
charts: 'Charts',
trendTitle: 'Trend over time', museumTitle: 'By museum',
channelTitle: 'By channel', districtTitle: 'By district',
daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
newLabel: 'New', clearSel: 'Clear selection',
monthSection: 'Month', periodSection: 'Quarter · Half · Year',
from: 'From', to: 'To', vsLabel: 'vs',
barLabel: 'Bar', pieLabel: 'Pie', absLabel: '#', pctLabel: '%',
currentRole: 'This period', previousRole: 'Compared to',
currentHint: 'primary', previousHint: 'auto year 1',
vs: 'vs',
};
export const AR: LC = {
dir: 'rtl',
fontImport: `@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');`,
bodyFont: "'IBM Plex Sans Arabic', sans-serif",
displayFont: "'IBM Plex Sans Arabic', sans-serif",
monoFont: "'IBM Plex Sans Arabic', sans-serif",
monthFull: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'],
monthShort: ['ينا','فبر','مار','أبر','ماي','يون','يول','أغس','سبت','أكت','نوف','ديس'],
periods: { q1:'ر١', q2:'ر٢', q3:'ر٣', q4:'ر٤', h1:'ن١', h2:'ن٢', full:'السنة' },
fullYearLabel: (y) => `${y} كاملاً`,
dateRangeSep: '',
backLink: 'العودة إلى لوحة التحكم', backTo: '/ar',
pageTitle: 'نظرة عامة', pageSub: 'أداء المتاحف في لمحة.',
changePeriod: 'تغيير الفترة', close: 'إلغاء', apply: 'تطبيق',
filter: 'تصفية',
allDistricts: 'كل المناطق', allChannels: 'كل القنوات', allMuseums: 'كل المتاحف',
countDistricts: (n) => `${n} مناطق`,
countChannels: (n) => `${n} قنوات`,
countMuseums: (n) => `${n} متاحف`,
reset: 'إعادة ضبط', exclVAT: 'بدون ضريبة', inclVAT: 'مع ضريبة',
keyMetrics: 'المؤشرات الرئيسية',
revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر',
avgRev: 'متوسط الإيراد / زائر', pilgrims: 'الحجاج والمعتمرون', captureRate: 'معدل الاستيعاب %',
charts: 'المخططات',
trendTitle: 'الاتجاه عبر الزمن', museumTitle: 'حسب المتحف',
channelTitle: 'حسب القناة', districtTitle: 'حسب المنطقة',
daily: 'يومي', weekly: 'أسبوعي', monthly: 'شهري',
newLabel: 'جديد', clearSel: 'مسح التحديد',
monthSection: 'الشهر', periodSection: 'ربع · نصف · سنة',
from: 'من', to: 'إلى', vsLabel: 'مقابل',
barLabel: 'أعمدة', pieLabel: 'دائرة', absLabel: '#', pctLabel: '%',
currentRole: 'الفترة الحالية', previousRole: 'مقارنةً بـ',
currentHint: 'رئيسية', previousHint: 'تلقائياً −١ سنة',
vs: 'مقابل',
};