Compare commits

..

10 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
16 changed files with 7532 additions and 490 deletions
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
+262 -149
View File
@@ -2859,7 +2859,7 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
======================================== */
.report-page {
max-width: 1400px;
max-width: 1100px;
margin: 0 auto;
padding: 32px 24px 100px;
}
@@ -2883,12 +2883,7 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
margin: 0;
}
.report-body {
display: grid;
grid-template-columns: 420px 1fr;
gap: 32px;
align-items: start;
}
.report-body {}
.report-form-col {
background: var(--surface);
@@ -2897,13 +2892,6 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
overflow: hidden;
}
.report-preview-col {}
.report-preview-sticky {
position: sticky;
top: 80px;
}
/* Generate button bar */
.report-footer-bar {
position: fixed;
@@ -2915,7 +2903,8 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
padding: 12px 24px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
display: flex;
justify-content: flex-end;
align-items: center;
justify-content: space-between;
z-index: 100;
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
}
@@ -2944,27 +2933,23 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
/* ── Report Form ── */
.report-form {
padding: 20px;
overflow: hidden;
}
.rf-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
}
.rf-col {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.rf-section-title {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
padding-top: 8px;
border-top: 1px solid var(--border);
margin-top: 4px;
}
.rf-section-title:first-child {
border-top: none;
margin-top: 0;
padding-top: 0;
.rf-col + .rf-col {
border-left: 1px solid var(--border);
}
.rf-field {
@@ -2990,7 +2975,7 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
box-sizing: border-box;
}
.rf-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(37,99,235,.1); }
.rf-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 10%, transparent); }
.rf-date-row {
display: grid;
@@ -2998,29 +2983,6 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
gap: 10px;
}
.rf-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.rf-toggle-opt {
padding: 6px 12px;
font-size: 0.8125rem;
font-weight: 500;
background: var(--surface);
color: var(--text-muted);
border: none;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.rf-toggle-opt--on {
background: var(--accent);
color: var(--text-inverse);
}
.rf-check-row {
display: flex;
align-items: center;
@@ -3059,6 +3021,54 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
font-family: var(--alt-mono-font, monospace);
}
.rf-orient-row {
display: flex;
gap: 8px;
}
.rf-orient-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 10px 16px;
border: 1.5px solid var(--border);
border-radius: 8px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 0.75rem;
font-weight: 500;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.rf-orient-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.rf-orient-btn--on {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
.rf-orient-page {
border: 1.5px solid currentColor;
border-radius: 2px;
opacity: 0.75;
}
.rf-orient-page--portrait {
width: 18px;
height: 26px;
}
.rf-orient-page--landscape {
width: 26px;
height: 18px;
}
.rf-logo-row {
display: flex;
align-items: center;
@@ -3102,121 +3112,224 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
justify-content: center;
}
/* ── Report Preview ── */
.report-preview {
display: flex;
flex-direction: column;
gap: 12px;
}
.report-preview-label {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 600;
/* ── Group labels & dividers ── */
.rf-group-label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.rp-page {
background: white;
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: var(--shadow-sm, 0 1px 3px rgba(0,0,0,.08));
overflow: hidden;
aspect-ratio: 210 / 297;
.rf-divider {
height: 1px;
background: var(--border);
margin: 2px 0;
}
/* ── Branding row (accent color + logo side by side) ── */
.rf-branding-row {
display: flex;
gap: 16px;
align-items: flex-start;
}
.rf-branding-row .rf-field {
flex: 1;
min-width: 0;
}
/* ── Comparison block ── */
.rf-comparison-block {
background: color-mix(in srgb, var(--accent) 4%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent);
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
font-size: 7px;
padding: 16px;
box-sizing: border-box;
color: #0f172a;
gap: 10px;
}
.rp-cover-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: auto;
}
.rp-brand { font-weight: 700; color: #2563eb; font-size: 8px; }
.rp-brand-small { font-weight: 700; font-size: 6px; }
.rp-client-logo {
height: 20px;
max-width: 50px;
object-fit: contain;
}
.rp-cover-body {
padding: 24px 0 16px;
}
.rp-cover-title {
font-size: 13px;
.rf-comparison-label {
font-size: 0.6875rem;
font-weight: 700;
margin-bottom: 8px;
color: #0f172a;
line-height: 1.3;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--accent);
}
.rp-cover-for, .rp-cover-contact, .rp-cover-period {
font-size: 7px;
color: #64748b;
margin-bottom: 3px;
/* ── Module cards ── */
.rf-module {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
transition: border-color 0.15s;
}
.rp-cover-bar { height: 4px; margin-top: auto; width: calc(100% + 32px); margin-left: -16px; }
.rp-placeholder-text { color: #cbd5e1; font-style: italic; }
.rp-page--content { padding: 12px 16px; }
.rp-page-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 4px;
margin-bottom: 10px;
.rf-module--on {
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
}
.rp-page-title-small, .rp-page-num { font-size: 5px; color: #94a3b8; }
.rp-section { margin-bottom: 10px; }
.rp-section-heading {
color: white;
font-size: 6px;
font-weight: 700;
padding: 2px 6px;
border-radius: 2px;
margin-bottom: 6px;
}
.rp-placeholder-lines { display: flex; flex-direction: column; gap: 3px; }
.rp-ph-line { height: 4px; background: #e2e8f0; border-radius: 2px; }
.rp-ph-table { display: flex; flex-direction: column; gap: 2px; }
.rp-ph-row {
.rf-module-header {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 0;
border-bottom: 1px solid #f1f5f9;
gap: 10px;
padding: 9px 12px;
cursor: pointer;
user-select: none;
background: transparent;
}
.rp-ph-row-label { font-size: 5.5px; color: #334155; flex: 1.5; }
.rp-ph-row-val { flex: 1; height: 4px; background: #e2e8f0; border-radius: 2px; }
.rp-ph-row-val--sm { flex: 0.8; }
.rf-module-header:hover {
background: var(--hover);
}
.rp-ph-chart { height: 40px; background: #f8fafc; border-radius: 3px; border: 1px solid #e2e8f0; }
.rf-module-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
flex: 1;
}
@media (max-width: 900px) {
.report-body { grid-template-columns: 1fr; }
.report-preview-sticky { position: static; }
.rf-module-badge {
font-size: 0.6875rem;
font-weight: 600;
padding: 2px 7px;
border-radius: 10px;
background: var(--border);
color: var(--text-muted);
white-space: nowrap;
}
.rf-module-badge--on {
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent);
}
.rf-module-body {
padding: 10px 12px 12px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 10px;
}
.rf-module-note {
font-size: 0.8125rem;
color: var(--text-muted);
margin: 0;
}
/* ── Metric pill toggles ── */
.rf-metric-pills {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.rf-metric-pill {
padding: 4px 12px;
border: 1.5px solid var(--border);
border-radius: 20px;
background: transparent;
color: var(--text-muted);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.rf-metric-pill--on {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent);
font-weight: 600;
}
.rf-metric-pill:hover:not(.rf-metric-pill--on) {
border-color: var(--text-muted);
color: var(--text-primary);
}
/* ── Footer meta strip ── */
.report-footer-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.report-footer-chip {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.report-footer-chip--compare {
color: var(--accent);
font-weight: 600;
}
.report-footer-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--text-muted);
opacity: 0.4;
flex-shrink: 0;
}
/* H2: focus-visible on all custom interactive elements */
.rf-metric-pill:focus-visible,
.rf-orient-btn:focus-visible,
.rf-upload-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.rf-remove-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* H3: H2 elements used as group labels reset browser heading defaults */
h2.rf-group-label {
font-size: 0.6875rem;
font-weight: 700;
margin: 0;
line-height: inherit;
}
/* H3: H2 badge-on buttons inside module cards (badge is aria-hidden, no h2 styling needed there) */
/* H3: rf-remove-btn touch target — min 36×36 */
.rf-remove-btn {
width: 36px;
height: 36px;
}
/* C2: inline error messages */
.rf-field-error {
font-size: 0.75rem;
color: var(--danger);
margin-top: 2px;
}
.report-footer-error {
font-size: 0.8125rem;
color: var(--danger);
font-weight: 500;
margin-left: 4px;
}
@media (max-width: 800px) {
.rf-two-col { grid-template-columns: 1fr; }
.rf-col + .rf-col { border-left: none; border-top: 1px solid var(--border); }
.report-page { padding: 20px 16px 90px; }
/* H5: show only section count in footer on mobile, hide details */
.report-footer-chip:not(.report-footer-chip--count),
.report-footer-dot { display: none; }
/* H4: larger touch targets for metric pills on mobile */
.rf-metric-pill { padding: 8px 14px; }
}
/* ========================================
+88 -7
View File
@@ -6,7 +6,7 @@ import {
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
umrahData
} 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 { useLanguage } from '../contexts/LanguageContext';
import type { LC } from '../lib/locale';
@@ -78,6 +78,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
const [selMuseums, setSelMuseums] = useState<string[]>([]);
const [metric, setMetric] = useState('revenue');
const [gran, setGran] = useState('week');
const [showLabels, setShowLabels] = useState(false);
const perm = useMemo(() => {
if (!allowedMuseums || !allowedChannels) return [];
@@ -128,7 +129,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
return sy===ey ? sy : `${L.monthShort[parseInt(s.slice(5,7))-1]} '${sy.slice(-2)}${L.monthShort[parseInt(e.slice(5,7))-1]} '${ey.slice(-2)}`;
};
const trendData = useMemo(() => {
const trendResult = useMemo(() => {
const group = (rows: MuseumRecord[], ps: string) => {
const s=new Date(ps); const acc: Record<number,MuseumRecord[]> = {};
rows.forEach(r => {
@@ -142,17 +143,56 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
};
const pg = group(prevData, prevStart), cg = group(currData, currStart);
const 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) =>
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 {
tooltipLabels,
multiMuseum,
data: {
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 },
{ label:periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'15', borderWidth:2, tension:0.4, fill:true, pointRadius:gran==='week'?4:2, pointBackgroundColor:chartColors.primary },
...museumDatasets,
{ label: multiMuseum ? `Total · ${periodLabel(currStart,currEnd)}` : periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:TOTAL_COLOR, backgroundColor: multiMuseum ? 'transparent' : TOTAL_COLOR+'15', borderWidth:2.5, tension:0.4, fill: !multiMuseum, pointRadius:gran==='week'?4:2, pointBackgroundColor:TOTAL_COLOR },
]
}
};
}, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L]);
}, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L, selMuseums, museums]);
const trendData = trendResult.data;
const museumData = useMemo(() => {
const all = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[];
@@ -168,11 +208,50 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
};
}, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal]);
const baseOpts = useMemo(() => createBaseOptions(false), []);
const baseOpts = useMemo(() => createBaseOptions(showLabels), [showLabels]);
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 = [
{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors },
@@ -272,9 +351,11 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" />
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
<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 className="alt-chart-wrap"><Line data={trendData} options={chartOpts} /></div>
<div className="alt-chart-wrap"><Line data={trendData} options={trendOpts} /></div>
</div>
<div className="alt-chart-card">
<div className="alt-chart-header">
+89 -7
View File
@@ -6,7 +6,7 @@ import {
groupByMuseum, groupByChannel, groupByDistrict,
umrahData,
} 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 { useLanguage } from '../contexts/LanguageContext';
import { EN, AR } from '../lib/locale';
@@ -37,6 +37,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
const [selMuseums, setSelMuseums] = useState<string[]>([]);
const [metric, setMetric] = useState('revenue');
const [gran, setGran] = useState('week');
const [showLabels, setShowLabels] = useState(false);
const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar');
const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie');
const [districtChartType, setDistrictChartType] = useState<'bar'|'pie'>('pie');
@@ -88,7 +89,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0);
}, [revenueField]);
const trendData = useMemo(() => {
const trendResult = useMemo(() => {
const group = (rows: MuseumRecord[], ps: string) => {
const s = new Date(ps); const acc: Record<number, MuseumRecord[]> = {};
rows.forEach(r => {
@@ -102,18 +103,57 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
};
const pg = group(prevData, prevStart), cg = group(filteredData, start);
const 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) =>
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 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 {
tooltipLabels,
multiMuseum,
data: {
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] },
{ label:start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'18', borderWidth:2.5, tension:0.4, fill:true, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.primary },
...museumDatasets,
{ label: multiMuseum ? `Total ${start.slice(0,4)}` : start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:TOTAL_COLOR, backgroundColor: multiMuseum ? 'transparent' : TOTAL_COLOR+'18', borderWidth:2.5, tension:0.4, fill: !multiMuseum, pointRadius:gran==='week'?3:1, pointBackgroundColor:TOTAL_COLOR },
]
}
};
}, [filteredData, prevData, prevStart, start, metric, gran, getVal, L]);
}, [filteredData, prevData, prevStart, start, metric, gran, getVal, L, selMuseums, allMuseums]);
const trendData = trendResult.data;
const museumData = useMemo(() => {
const g = groupByMuseum(filteredData, includeVAT);
@@ -165,13 +205,53 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null;
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
const baseOpts = useMemo(() => createBaseOptions(false), []);
const baseOpts = useMemo(() => createBaseOptions(showLabels), [showLabels]);
const { chartOpts, barHorizOpts, barNoLegend } = useMemo(() => {
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:10, padding:10, font:{ size:11 } } } } };
const barHorizOpts: any = { ...chartOpts, indexAxis:'y', plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
const barNoLegend: any = { ...chartOpts, plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
return { chartOpts, barHorizOpts, barNoLegend };
}, [baseOpts]);
const 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(() => ({
responsive: true, maintainAspectRatio: false,
plugins: {
@@ -248,9 +328,11 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" />
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
<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 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 className="alt-chart-card">
+466 -101
View File
@@ -1,52 +1,118 @@
import React from 'react';
import {
Document, Page, View, Text, Image, StyleSheet
Document, Page, View, Text, Image, StyleSheet, Font
} from '@react-pdf/renderer';
import { PdfTrendChart, PdfHBarChart } from './reportCharts';
import { PdfTrendChart, PdfHBarChart, CHART_PALETTE } from './reportCharts';
import {
ReportData, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary
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: 9, color: '#0f172a', backgroundColor: '#ffffff' },
page: { fontFamily: 'Helvetica', fontSize: 10, color: '#0f172a', backgroundColor: '#ffffff' },
// ── Cover ──────────────────────────────────────────────
coverPage: { flexDirection: 'column', padding: 0 },
coverTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', paddingTop: 40, paddingRight: 50, paddingBottom: 0, paddingLeft: 50 },
coverLogoBox: { width: 80, height: 40, justifyContent: 'center' },
coverClientLogo: { width: 80, height: 40, objectFit: 'contain' as const },
coverHiHala: { fontSize: 13, fontFamily: 'Helvetica-Bold', color: '#2563eb', letterSpacing: 0.5 },
coverMiddle: { flex: 1, justifyContent: 'center', paddingHorizontal: 50, paddingTop: 80 },
coverTitle: { fontSize: 28, fontFamily: 'Helvetica-Bold', marginBottom: 16, lineHeight: 1.2 },
coverFor: { fontSize: 11, color: '#334155', marginBottom: 4 },
coverContact: { fontSize: 10, color: '#64748b', marginBottom: 32 },
coverPeriod: { fontSize: 10, color: '#64748b', fontFamily: 'Helvetica-Oblique', marginBottom: 6 },
coverDate: { fontSize: 9, color: '#94a3b8' },
coverBar: { height: 6, flex: 1 },
contentPage: { paddingTop: 32, paddingRight: 44, paddingBottom: 48, paddingLeft: 44 },
pageHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderBottomWidth: 1, borderBottomColor: '#e2e8f0', paddingBottom: 8, marginBottom: 24 },
pageHeaderTitle: { fontSize: 8, color: '#94a3b8' },
pageHeaderLogo: { fontSize: 9, fontFamily: 'Helvetica-Bold', color: '#2563eb' },
pageHeaderNum: { fontSize: 8, color: '#94a3b8' },
pageFooter: { position: 'absolute', bottom: 20, left: 44, right: 44, flexDirection: 'row', justifyContent: 'space-between' },
pageFooterText: { fontSize: 7, color: '#94a3b8' },
sectionHeading: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: '#ffffff', paddingTop: 5, paddingRight: 10, paddingBottom: 5, paddingLeft: 10, marginBottom: 14, borderRadius: 3 },
summaryText: { fontSize: 9.5, color: '#334155', lineHeight: 1.6 },
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 },
metricsRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 6 },
metricsRowAlt: { backgroundColor: '#f8fafc' },
metricsLabel: { flex: 1.5, fontSize: 9, color: '#334155', fontFamily: 'Helvetica-Bold' },
metricsValue: { flex: 1, fontSize: 9, color: '#0f172a', textAlign: 'right' },
metricsChange: { flex: 0.8, fontSize: 8, textAlign: 'right' },
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' },
metricsHeaderRow: { flexDirection: 'row', backgroundColor: '#f1f5f9', paddingTop: 4, paddingBottom: 4, marginBottom: 2 },
metricsHeaderCell: { flex: 1, fontSize: 7.5, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right' },
metricsHeaderLabel: { flex: 1.5, fontSize: 7.5, fontFamily: 'Helvetica-Bold', color: '#64748b' },
chartWrap: { marginBottom: 8, backgroundColor: '#f8fafc', padding: 12, borderRadius: 4 },
sectionGap: { marginBottom: 24 },
legendRow: { flexDirection: 'row', marginBottom: 8 },
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 16 },
// ── 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: 7.5, color: '#64748b', marginLeft: 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 {
@@ -54,12 +120,22 @@ function pctChange(curr: number, prev: number): number {
return Math.round(((curr - prev) / prev) * 100);
}
interface PageHeaderProps { title: string; page: number; }
function PageHeader({ title, page }: PageHeaderProps) {
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}>HiHala Data</Text>
<Text style={S.pageHeaderTitle}>{title}</Text>
<Text style={[S.pageHeaderLogo, arB]}>HiHala Data</Text>
<Text style={[S.pageHeaderTitle, isAr ? { fontFamily: 'IBMPlexArabic' } : {}]}>{title}</Text>
<Text style={S.pageHeaderNum}>{page}</Text>
</View>
);
@@ -75,11 +151,11 @@ function PageFooter({ confidentiality, generatedAt }: PageFooterProps) {
);
}
interface SectionProps { title: string; color: string; }
function SectionHeading({ title, color }: SectionProps) {
interface SectionProps { title: string; color: string; arB: any; }
function SectionHeading({ title, color, arB }: SectionProps) {
return (
<View style={[S.sectionHeading, { backgroundColor: color }]}>
<Text>{title}</Text>
<Text style={arB}>{title}</Text>
</View>
);
}
@@ -87,15 +163,31 @@ function SectionHeading({ title, color }: SectionProps) {
interface Props { data: ReportData; }
export function ReportDocument({ data }: Props) {
const { config: cfg, metrics, prevMetrics, trendLabels, trendCurrent, trendPrevious,
museumBreakdown, channelBreakdown, pilgrimCapture, generatedAt } = data;
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 orientation = cfg.orientation === 'landscape' ? 'landscape' : 'portrait';
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,
@@ -109,6 +201,9 @@ export function ReportDocument({ data }: Props) {
{ 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,
@@ -116,55 +211,103 @@ export function ReportDocument({ data }: Props) {
}] : []),
];
const prevYear = parseInt(cfg.startDate.slice(0, 4)) - 1;
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">
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage]}>
<View style={S.coverTop}>
<Text style={S.coverHiHala}>HiHala Data</Text>
{/* ── 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>
<View style={S.coverMiddle}>
<Text style={S.coverTitle}>{cfg.title || T.defaultTitle}</Text>
{cfg.clientName && <Text style={S.coverFor}>{T.preparedFor}: {cfg.clientName}</Text>}
{cfg.contactName && <Text style={S.coverContact}>{T.attention}: {cfg.contactName}</Text>}
<Text style={S.coverPeriod}>{period}</Text>
<Text style={S.coverDate}>{T.generated}: {generatedAt}</Text>
<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>
<View style={[S.coverBar, { backgroundColor: color }]} />
</Page>
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
<PageHeader title={cfg.title || T.defaultTitle} page={2} />
{/* ── 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} />
<Text style={S.summaryText}>{generateExecutiveSummary(data)}</Text>
<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} color={color} />
<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}> </Text>
<Text style={S.metricsHeaderCell}>{period}</Text>
{prevMetrics && <Text style={S.metricsHeaderCell}>{prevYear}</Text>}
{prevMetrics && <Text style={S.metricsHeaderCell}>{T.change}</Text>}
<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}>{row.label}</Text>
<Text style={S.metricsValue}>{row.curr}</Text>
{prevMetrics && <Text style={S.metricsValue}>{row.prev ?? '—'}</Text>}
<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)}
@@ -176,53 +319,247 @@ export function ReportDocument({ data }: Props) {
</View>
)}
{cfg.showTrendChart && (
<View style={S.sectionGap}>
<SectionHeading title={T.trend} color={color} />
{cfg.includeComparison && (
{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}>
<View style={S.legendItem}>
<View style={[S.legendDot, { backgroundColor: color }]} />
<Text style={S.legendLabel}>{period}</Text>
{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}>{prevYear}</Text>
<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}>
<PdfTrendChart labels={trendLabels} current={trendCurrent}
previous={trendPrevious} color={color} width={460} height={130} />
<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>
)}
{(cfg.showMuseumBreakdown || cfg.showChannelBreakdown) && (
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
<PageHeader title={cfg.title || T.defaultTitle} page={3} />
{/* ── 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.showMuseumBreakdown && museumBreakdown.length > 0 && (
{cfg.showDistrictRevenue && districtBreakdown.revenue.length > 0 && (
<View style={S.sectionGap}>
<SectionHeading title={T.byMuseum} color={color} />
<SectionHeading title={T.byDistrictRevenue} color={color} arB={arB} />
<View style={S.chartWrap}>
<PdfHBarChart items={museumBreakdown} color={color} width={460} />
<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>
)}
{cfg.showChannelBreakdown && channelBreakdown.length > 0 && (
<View style={S.sectionGap}>
<SectionHeading title={T.byChannel} color={color} />
<View style={S.chartWrap}>
<PdfHBarChart items={channelBreakdown} color={color} width={460} />
</View>
</View>
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
</Page>
)}
{/* ── 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>
)}
@@ -238,14 +575,28 @@ const LABELS_EN = {
generated: 'Generated',
execSummary: 'Executive Summary',
keyMetrics: 'Key Metrics',
change: 'vs Prior Year',
trend: 'Revenue Trend',
byMuseum: 'Revenue by Museum',
byChannel: 'Visitors by Channel',
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',
};
@@ -256,13 +607,27 @@ const LABELS_AR = {
generated: 'تاريخ الإصدار',
execSummary: 'الملخص التنفيذي',
keyMetrics: 'المؤشرات الرئيسية',
change: 'مقابل العام السابق',
trend: 'اتجاه الإيرادات',
byMuseum: 'الإيرادات حسب المتحف',
byChannel: 'الزوار حسب القناة',
inclVAT: 'شامل ضريبة القيمة المضافة',
exclVAT: 'غير شامل ضريبة القيمة المضافة',
change: 'التغيّر',
comparedTo: 'مقابل',
trendRevenue: 'اتجاه الإيرادات',
trendVisitors: 'اتجاه الزوار',
trendTickets: 'اتجاه التذاكر',
museumBreakdowns: 'تفاصيل المتاحف',
byChannelRevenue: 'الإيرادات حسب القناة',
byChannelVisitors: 'الزوار حسب القناة',
byChannelTickets: 'التذاكر حسب القناة',
byDistrictRevenue: 'الإيرادات حسب الحي',
byDistrictVisitors: 'الزوار حسب الحي',
byDistrictTickets: 'التذاكر حسب الحي',
globalSummary: 'ملخص الأداء',
museum: 'المتحف',
total: 'الإجمالي',
revenue: 'الإيرادات',
visitors: 'الزوار',
tickets: 'التذاكر',
avgRev: 'متوسط الإيراد / زائر',
avgTicketPrice: 'متوسط سعر التذكرة',
capture: 'معدل استيعاب الحجاج',
};
+305 -69
View File
@@ -1,6 +1,6 @@
import React, { useRef } from 'react';
import React, { useRef, useEffect, useState } from 'react';
import AltMultiSelect from '../shared/AltMultiSelect';
import type { ReportConfig } from './reportHelpers';
import type { ReportConfig, TrendMetric } from './reportHelpers';
interface Props {
config: ReportConfig;
@@ -9,10 +9,6 @@ interface Props {
allChannels: string[];
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return <div className="rf-section-title">{children}</div>;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="rf-field">
@@ -22,33 +18,130 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
);
}
function Toggle({ left, right, value, onChange }: {
left: string; right: string; value: boolean; onChange: (v: boolean) => void;
// 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-toggle">
<button type="button" className={`rf-toggle-opt${!value ? ' rf-toggle-opt--on' : ''}`} onClick={() => onChange(false)}>{left}</button>
<button type="button" className={`rf-toggle-opt${value ? ' rf-toggle-opt--on' : ''}`} onClick={() => onChange(true)}>{right}</button>
<div 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 CheckRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
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 (
<label className="rf-check-row">
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} className="rf-checkbox" />
<span>{label}</span>
<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) { alert('Logo must be under 2 MB'); 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);
@@ -56,8 +149,12 @@ export default function ReportForm({ config: cfg, onChange, allMuseums, allChann
return (
<div className="report-form">
<div className="rf-two-col">
<SectionTitle>Client Info</SectionTitle>
{/* ── 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}
@@ -65,51 +162,65 @@ export default function ReportForm({ config: cfg, onChange, allMuseums, allChann
placeholder="Q1 2025 Visitor Performance" />
</Field>
<Field label="Prepared for (company)">
<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 name (optional)">
<Field label="Contact (optional)">
<input className="rf-input" type="text" value={cfg.contactName}
onChange={e => onChange({ contactName: e.target.value })}
placeholder="Mohammed Al-..." />
</Field>
<Field label="Client logo (PNG/JPG, max 2 MB)">
<div className="rf-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">
<button type="button" className="rf-upload-btn" onClick={() => logoInputRef.current?.click()}>
{cfg.clientLogoBase64 ? 'Change logo' : 'Upload logo'}
{/* 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 && (
<>
<img src={cfg.clientLogoBase64} alt="preview" className="rf-logo-preview" />
<button type="button" className="rf-remove-btn" onClick={() => onChange({ clientLogoBase64: null })}></button>
{/* 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>
</Field>
<Field label="Accent color">
<div className="rf-color-row">
<input type="color" value={cfg.accentColor}
onChange={e => onChange({ accentColor: e.target.value })}
className="rf-color-input" />
<span className="rf-color-val">{cfg.accentColor}</span>
{/* C2: inline logo error */}
{logoError && <span className="rf-field-error" role="alert">{logoError}</span>}
</div>
</div>
</Field>
<SectionTitle>Data Selection</SectionTitle>
<div className="rf-divider" />
<h2 className="rf-group-label">Data</h2>
<div className="rf-date-row">
<Field label="Start date">
<Field label="Period start">
<input className="rf-input" type="date" value={cfg.startDate}
onChange={e => onChange({ startDate: e.target.value })} />
</Field>
<Field label="End date">
<Field label="Period end">
<input className="rf-input" type="date" value={cfg.endDate}
onChange={e => onChange({ endDate: e.target.value })} />
</Field>
@@ -127,44 +238,169 @@ export default function ReportForm({ config: cfg, onChange, allMuseums, allChann
allLabel="All channels" countLabel={n => `${n} channels`} clearLabel="Clear" />
</Field>
<Field label="VAT">
<Toggle left="Excl. VAT" right="Incl. VAT" value={cfg.includeVAT}
onChange={v => onChange({ includeVAT: v })} />
<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>
<CheckRow label="Include previous year comparison"
checked={cfg.includeComparison} onChange={v => onChange({ includeComparison: v })} />
<SectionTitle>Content Sections</SectionTitle>
<CheckRow label="Executive summary" checked={cfg.showExecutiveSummary} onChange={v => onChange({ showExecutiveSummary: v })} />
<CheckRow label="Key metrics table" checked={cfg.showMetricsTable} onChange={v => onChange({ showMetricsTable: v })} />
<CheckRow label="Revenue trend chart" checked={cfg.showTrendChart} onChange={v => onChange({ showTrendChart: v })} />
<CheckRow label="Breakdown by museum" checked={cfg.showMuseumBreakdown} onChange={v => onChange({ showMuseumBreakdown: v })} />
<CheckRow label="Breakdown by channel" checked={cfg.showChannelBreakdown} onChange={v => onChange({ showChannelBreakdown: v })} />
<CheckRow label="Pilgrim capture rate" checked={cfg.showPilgrimCapture} onChange={v => onChange({ showPilgrimCapture: v })} />
<SectionTitle>Presentation</SectionTitle>
<Field label="Language">
<Toggle left="English" right="العربية" value={cfg.language === 'ar'}
onChange={v => onChange({ language: v ? 'ar' : 'en' })} />
<Field label="To">
<input className="rf-input" type="date" value={cfg.comparisonEndDate}
onChange={e => onChange({ comparisonEndDate: e.target.value })} />
</Field>
</div>
</div>
)}
<Field label="Orientation">
<Toggle left="Portrait" right="Landscape" value={cfg.orientation === 'landscape'}
onChange={v => onChange({ orientation: v ? 'landscape' : 'portrait' })} />
</Field>
<div className="rf-divider" />
<h2 className="rf-group-label">Format</h2>
<Field label="Confidentiality">
<select className="rf-input" value={cfg.confidentiality}
onChange={e => onChange({ confidentiality: e.target.value as ReportConfig['confidentiality'] })}>
<option value="Confidential">Confidential</option>
<option value="Internal">Internal</option>
<option value="Public">Public</option>
</select>
</Field>
<div 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>
);
}
+68 -12
View File
@@ -1,11 +1,10 @@
import React, { useState, useCallback } from 'react';
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 ReportPreview from './ReportPreview';
import { getUniqueMuseums, getUniqueChannels } from '../../services/dataService';
interface Props {
@@ -15,18 +14,44 @@ interface Props {
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);
const allMuseums = getUniqueMuseums(data);
const allChannels = getUniqueChannels(data);
// 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) {
alert('End date must be after start date.');
// 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();
@@ -44,7 +69,8 @@ export default function ReportPage({ data }: Props) {
}
} catch (err) {
console.error('PDF generation failed:', err);
alert('Failed to generate PDF. Please try again.');
// C2: inline error instead of alert()
setErrorMsg('Failed to generate PDF. Please try again.');
} finally {
setGenerating(false);
}
@@ -52,28 +78,58 @@ export default function ReportPage({ data }: Props) {
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>
<p className="report-sub">Configure and download a client-ready PDF report.</p>
{/* 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 className="report-preview-col">
<div className="report-preview-sticky">
<ReportPreview config={config} />
</div>
</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 ? (
<>
+69 -23
View File
@@ -1,19 +1,35 @@
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, width = 460, height = 140 }: TrendChartProps) {
const allValues = [...current, ...(previous ?? [])].filter(v => v > 0);
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;
const padL = 8, padR = 8, padT = 8, padB = 8;
// 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;
@@ -27,30 +43,52 @@ export function PdfTrendChart({ labels, current, previous, color, width = 460, h
return (
<Svg width={width} height={height}>
{gridLines.map(f => (
<Line key={f}
x1={padL} y1={sy(max * f).toFixed(1)}
x2={width - padR} y2={sy(max * f).toFixed(1)}
{/* 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={2.5} fill="none" />
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 - 1}
fill="#94a3b8"
textAnchor="middle"
style={{ fontSize: 7 }}>
x={sx(origIdx).toFixed(1)} y={height - 5}
style={{ fontSize: 7, fill: '#94a3b8', textAnchor: 'middle' }}>
{label}
</SvgText>
);
@@ -62,15 +100,15 @@ export function PdfTrendChart({ labels, current, previous, color, width = 460, h
interface HBarChartProps {
items: Array<{ name: string; value: number }>;
color: string;
usepalette?: boolean;
width?: number;
}
export function PdfHBarChart({ items, color, width = 460 }: HBarChartProps) {
const barH = 16;
export function PdfHBarChart({ items, color, usepalette = false, width = 470 }: HBarChartProps) {
const barH = 17;
const gap = 10;
const labelW = 150;
const valueW = 70;
const barAreaW = width - labelW - valueW - 8;
const labelW = 160;
const barAreaW = width - labelW - 20;
const max = Math.max(...items.map(i => i.value), 1);
const totalH = items.length * (barH + gap);
@@ -79,13 +117,21 @@ export function PdfHBarChart({ items, color, width = 460 }: HBarChartProps) {
{items.map((item, i) => {
const y = i * (barH + gap);
const bw = Math.max((item.value / max) * barAreaW, 2);
const shortName = item.name.length > 22 ? item.name.slice(0, 22) + '…' : item.name;
const 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}>
<SvgText x={0} y={y + barH - 4} fill="#334155" style={{ fontSize: 8 }}>{shortName}</SvgText>
<Rect x={labelW} y={y} width={bw} height={barH} fill={color} rx={3} />
<SvgText x={labelW + bw + 4} y={y + barH - 4} fill="#64748b" style={{ fontSize: 8 }}>{valueStr}</SvgText>
<G 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>
);
})}
+150 -51
View File
@@ -1,8 +1,21 @@
import { filterDataByDateRange, calculateMetrics, groupByMuseum, groupByChannel, umrahData } from '../../services/dataService';
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;
@@ -15,12 +28,30 @@ export interface ReportConfig {
selectedChannels: string[];
includeVAT: boolean;
includeComparison: boolean;
comparisonStartDate: string;
comparisonEndDate: string;
// Summary & metrics
showExecutiveSummary: boolean;
showMetricsTable: boolean;
showTrendChart: boolean;
showMuseumBreakdown: boolean;
showChannelBreakdown: 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';
@@ -32,18 +63,29 @@ export const DEFAULT_CONFIG: ReportConfig = {
contactName: '',
clientLogoBase64: null,
accentColor: '#2563eb',
startDate: new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10),
endDate: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString().slice(0, 10),
startDate: _start,
endDate: _end,
selectedMuseums: [],
selectedChannels: [],
includeVAT: true,
includeComparison: true,
comparisonStartDate: shiftYear(_start),
comparisonEndDate: shiftYear(_end),
showExecutiveSummary: true,
showMetricsTable: true,
showTrendChart: true,
showMuseumBreakdown: true,
showChannelBreakdown: 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',
@@ -52,15 +94,36 @@ export const DEFAULT_CONFIG: ReportConfig = {
// ─── 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;
trendLabels: string[];
trendCurrent: number[];
trendPrevious: number[] | null;
museumBreakdown: BreakdownItem[];
channelBreakdown: BreakdownItem[];
comparisonPeriodLabel: string;
trendCharts: TrendChart[];
museumData: MuseumDataRow[];
museumBreakdown: DimensionBreakdown;
channelBreakdown: DimensionBreakdown;
districtBreakdown: DimensionBreakdown;
pilgrimCapture: { current: number; previous: number | null } | null;
generatedAt: string;
}
@@ -92,58 +155,91 @@ function estimatePilgrims(start: string, end: string): number | null {
return has ? Math.round(total) : null;
}
function buildTrend(rows: MuseumRecord[], start: string, cfg: ReportConfig): { labels: string[]; values: number[] } {
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 = Math.floor(diff / 7) + 1;
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) => `W${i + 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((s, r) => s + (cfg.includeVAT ? r.revenue_gross : r.revenue_net) || 0, 0);
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 prevStart = shiftYear(cfg.startDate);
const prevEnd = shiftYear(cfg.endDate);
const prevRows = cfg.includeComparison
? applyDimFilters(filterDataByDateRange(allData, prevStart, prevEnd, {}), cfg)
? applyDimFilters(filterDataByDateRange(allData, cfg.comparisonStartDate, cfg.comparisonEndDate, {}), cfg)
: [];
const prevMetrics = cfg.includeComparison ? calculateMetrics(prevRows, cfg.includeVAT) : null;
const currTrend = buildTrend(currRows, cfg.startDate, cfg);
const prevTrend = cfg.includeComparison ? buildTrend(prevRows, prevStart, cfg) : null;
const maxLen = Math.max(currTrend.labels.length, prevTrend?.values.length ?? 0);
const trendLabels = Array.from({ length: maxLen }, (_, i) => `W${i + 1}`);
const trendCurrent = Array.from({ length: maxLen }, (_, i) => currTrend.values[i] ?? 0);
const trendPrevious = prevTrend
? Array.from({ length: maxLen }, (_, i) => prevTrend.values[i] ?? 0)
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 musG = groupByMuseum(currRows, cfg.includeVAT);
const museumBreakdown: BreakdownItem[] = Object.entries(musG)
.map(([name, g]) => ({ name, value: g.revenue }))
.sort((a, b) => b.value - a.value)
.slice(0, 10);
const 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 chanG = groupByChannel(currRows, cfg.includeVAT);
const channelBreakdown: BreakdownItem[] = Object.entries(chanG)
.map(([name, g]) => ({ name, value: g.visitors }))
.sort((a, b) => b.value - a.value);
const 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(prevStart, prevEnd) : null;
const prevPilgrims = cfg.includeComparison
? estimatePilgrims(cfg.comparisonStartDate, cfg.comparisonEndDate)
: null;
const pilgrimCapture = currPilgrims !== null
? {
current: parseFloat(((metrics.visitors / currPilgrims) * 100).toFixed(2)),
@@ -157,11 +253,12 @@ export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): R
config: cfg,
metrics,
prevMetrics,
trendLabels,
trendCurrent,
trendPrevious,
comparisonPeriodLabel,
trendCharts,
museumData,
museumBreakdown,
channelBreakdown,
districtBreakdown,
pilgrimCapture,
generatedAt: new Date().toLocaleDateString('en-GB'),
};
@@ -190,14 +287,14 @@ export function formatPeriodLabel(start: string, end: string, lang: 'en' | 'ar')
// ─── executive summary ────────────────────────────────────────────
export function generateExecutiveSummary(data: ReportData): string {
const { config: cfg, metrics, prevMetrics, channelBreakdown } = data;
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[0]?.name ?? '';
const totalVisitors = channelBreakdown.reduce((s, i) => s + i.value, 0);
const topPct = totalVisitors > 0 && channelBreakdown[0]
? Math.round((channelBreakdown[0].value / totalVisitors) * 100)
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(', ')
@@ -207,7 +304,7 @@ export function generateExecutiveSummary(data: ReportData): string {
let s = `During ${period}, ${museumLabel} recorded ${metrics.visitors.toLocaleString()} visitors and ${revenue} in revenue.`;
if (prevMetrics && prevMetrics.revenue > 0) {
const chg = Math.round(((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue) * 100);
s += ` This represents a ${formatPct(chg)} change in revenue versus the same period last year.`;
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;
@@ -215,7 +312,7 @@ export function generateExecutiveSummary(data: ReportData): string {
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)} في الإيرادات مقارنةً بالفترة ذاتها من العام الماضي.`;
s += ` يمثّل ذلك تغيّراً بنسبة ${formatPct(chg)} في الإيرادات مقارنةً بـ${comparisonPeriodLabel}.`;
}
if (topChannel) s += ` كانت ${topChannel} أعلى القنوات أداءً بنسبة ${topPct}% من إجمالي الزوار.`;
return s;
@@ -224,8 +321,10 @@ export function generateExecutiveSummary(data: ReportData): string {
// ─── page count estimator ─────────────────────────────────────────
export function estimatePageCount(cfg: ReportConfig): number {
let pages = 2; // cover + first content page
if (cfg.showMuseumBreakdown) pages += 1;
if (cfg.showChannelBreakdown) pages += 1;
let pages = 2; // cover + summary/metrics/trend
if (cfg.showMuseumRevenue || cfg.showMuseumVisitors || cfg.showMuseumTickets) pages += 1;
if (cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets) pages += 1;
if (cfg.showDistrictRevenue || cfg.showDistrictVisitors || cfg.showDistrictTickets) pages += 1;
if (cfg.showGlobalSummary && cfg.includeComparison) pages += 1;
return pages;
}
+33 -1
View File
@@ -30,6 +30,9 @@ ChartJS.register(
Annotation
);
// Used for the "Total" line in multi-museum trend charts — always distinct from chartPalette.
export const TOTAL_COLOR = '#1e293b';
export const chartColors = {
primary: '#2563eb',
secondary: '#7c3aed',
@@ -113,7 +116,9 @@ export const createBaseOptions = (showDataLabels: boolean): any => {
titleFont: { size: 12 },
bodyFont: { size: 11 },
rtl: false,
textDirection: 'ltr'
textDirection: 'ltr',
usePointStyle: true,
boxPadding: 6,
},
datalabels: createDataLabelConfig(showDataLabels, {
color: theme.textPrimary,
@@ -134,6 +139,33 @@ export const createBaseOptions = (showDataLabels: boolean): any => {
};
};
// Hover-dim + end-of-line name labels for multi-museum trend charts.
// Only activates for charts that have datasets marked with _isMuseumLine.
const trendLinePlugin = {
id: 'trendLineOverlay',
// ── hover dim ──────────────────────────────────────────────────
beforeDatasetDraw(chart: any, args: any) {
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
const active = chart.getActiveElements();
if (active.length === 0) return;
if (active[0].datasetIndex !== args.index) {
chart.ctx.save();
chart.ctx.globalAlpha = 0.15;
}
},
afterDatasetDraw(chart: any, args: any) {
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
const active = chart.getActiveElements();
if (active.length > 0 && active[0].datasetIndex !== args.index) {
chart.ctx.restore();
}
},
};
ChartJS.register(trendLinePlugin);
export const lineDatasetDefaults = {
borderWidth: 2,
tension: 0.4,