diff --git a/src/App.css b/src/App.css index eb743c6..e312391 100644 --- a/src/App.css +++ b/src/App.css @@ -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; } } /* ======================================== diff --git a/src/components/Report/ReportDocument.tsx b/src/components/Report/ReportDocument.tsx index 554ccab..c50c83e 100644 --- a/src/components/Report/ReportDocument.tsx +++ b/src/components/Report/ReportDocument.tsx @@ -2,51 +2,109 @@ import React from 'react'; import { Document, Page, View, Text, Image, StyleSheet } 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'; +// 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 }, + // colored header band + coverHeader: { paddingTop: 56, paddingRight: 52, paddingBottom: 52, paddingLeft: 52 }, + coverHeaderTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 48 }, + coverBrand: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', letterSpacing: 0.8 }, + coverLogoBox: { width: 90, height: 44, justifyContent: 'flex-end', alignItems: 'flex-end' }, + coverClientLogo: { width: 90, height: 44, objectFit: 'contain' as const }, + coverTitle: { fontSize: 36, fontFamily: 'Helvetica-Bold', color: '#ffffff', lineHeight: 1.2 }, + // white body + coverBody: { flex: 1, paddingTop: 44, paddingRight: 52, paddingBottom: 44, paddingLeft: 52, flexDirection: 'column' }, + coverClientName: { fontSize: 15, color: '#0f172a', fontFamily: 'Helvetica-Bold', marginBottom: 5 }, + coverContactName: { fontSize: 11, color: '#64748b', marginBottom: 32 }, + coverBodySpacer: { flex: 1 }, + coverPeriodRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 5 }, + coverPeriodDot: { width: 6, height: 6, borderRadius: 3, marginRight: 8 }, + coverPeriod: { fontSize: 12, color: '#334155', fontFamily: 'Helvetica-Oblique' }, + coverDate: { fontSize: 9, color: '#94a3b8', marginBottom: 20 }, + coverConfidential: { fontSize: 7.5, color: '#94a3b8', letterSpacing: 2, paddingTop: 10, borderTopWidth: 1, borderTopColor: '#e2e8f0' }, + + // ── Content pages ────────────────────────────────────── + contentPage: { paddingTop: 34, paddingRight: 44, paddingBottom: 54, paddingLeft: 44 }, + pageHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderBottomWidth: 1.5, borderBottomColor: '#e2e8f0', paddingBottom: 10, marginBottom: 26 }, + pageHeaderLogo: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: '#2563eb' }, + pageHeaderTitle: { fontSize: 9, color: '#94a3b8' }, + pageHeaderNum: { fontSize: 9, color: '#94a3b8' }, + pageFooter: { position: 'absolute', bottom: 22, left: 44, right: 44, flexDirection: 'row', justifyContent: 'space-between', borderTopWidth: 1, borderTopColor: '#f1f5f9', paddingTop: 6 }, + pageFooterText: { fontSize: 7.5, color: '#b0bec5' }, + + // ── Section headings ─────────────────────────────────── + sectionHeading: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', paddingTop: 8, paddingRight: 14, paddingBottom: 8, paddingLeft: 14, marginBottom: 16, borderRadius: 4 }, + sectionGap: { marginBottom: 28 }, + + // ── Executive summary ────────────────────────────────── + summaryText: { fontSize: 10.5, color: '#334155', lineHeight: 1.7 }, + + // ── Key metrics table ────────────────────────────────── metricsTable: { marginBottom: 8 }, - 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', marginBottom: 10 }, + legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18 }, 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,6 +112,16 @@ function pctChange(curr: number, prev: number): number { return Math.round(((curr - prev) / prev) * 100); } +function museumIntro(row: MuseumDataRow, lang: 'en' | 'ar', compLabel: string): string { + if (!row.prev) return ''; + const revChg = pctChange(row.curr.revenue, row.prev.revenue); + const visChg = pctChange(row.curr.visitors, row.prev.visitors); + if (lang === 'en') { + return `Revenue ${revChg >= 0 ? 'up' : 'down'} ${Math.abs(revChg)}%, visitors ${visChg >= 0 ? 'up' : 'down'} ${Math.abs(visChg)}% vs ${compLabel}.`; + } + return `الإيرادات ${revChg >= 0 ? 'ارتفعت' : 'انخفضت'} ${Math.abs(revChg)}%، الزوار ${visChg >= 0 ? 'ارتفعوا' : 'انخفضوا'} ${Math.abs(visChg)}% مقارنةً بـ${compLabel}.`; +} + interface PageHeaderProps { title: string; page: number; } function PageHeader({ title, page }: PageHeaderProps) { return ( @@ -87,29 +155,36 @@ function SectionHeading({ title, color }: SectionProps) { interface Props { data: ReportData; } export function ReportDocument({ data }: Props) { - const { config: cfg, metrics, prevMetrics, trendLabels, trendCurrent, trendPrevious, - museumBreakdown, museumVisitorBreakdown, channelBreakdown, pilgrimCapture, generatedAt } = data; + const { config: cfg, metrics, prevMetrics, comparisonPeriodLabel, + trendLabels, trendCurrent, trendPrevious, + museumData, channelBreakdown, districtBreakdown, + pilgrimCapture, generatedAt } = data; const lang = cfg.language; const color = cfg.accentColor; const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang); - const orientation = cfg.orientation === 'landscape' ? 'landscape' : 'portrait'; + const isLandscape = cfg.orientation === 'landscape'; + const orientation = isLandscape ? 'landscape' : 'portrait'; const T = lang === 'en' ? LABELS_EN : LABELS_AR; + // Chart width adapts to orientation + const chartW = isLandscape ? CHART_W.landscape : CHART_W.portrait; + const avgTicketPrice = metrics.tickets > 0 ? metrics.revenue / metrics.tickets : 0; - const prevAvgTicketPrice = prevMetrics && prevMetrics.tickets > 0 ? prevMetrics.revenue / prevMetrics.tickets : null; + const prevAvgTicketPrice = prevMetrics && prevMetrics.tickets > 0 + ? prevMetrics.revenue / prevMetrics.tickets : null; const metricsRows = [ - { label: T.revenue, curr: formatCurrency(metrics.revenue, cfg.includeVAT), + { label: T.revenue, curr: formatCurrency(metrics.revenue, cfg.includeVAT), prev: prevMetrics ? formatCurrency(prevMetrics.revenue, cfg.includeVAT) : null, chg: prevMetrics ? pctChange(metrics.revenue, prevMetrics.revenue) : null }, { label: T.visitors, curr: metrics.visitors.toLocaleString(), prev: prevMetrics ? prevMetrics.visitors.toLocaleString() : null, chg: prevMetrics ? pctChange(metrics.visitors, prevMetrics.visitors) : null }, - { label: T.tickets, curr: metrics.tickets.toLocaleString(), + { label: T.tickets, curr: metrics.tickets.toLocaleString(), prev: prevMetrics ? prevMetrics.tickets.toLocaleString() : null, chg: prevMetrics ? pctChange(metrics.tickets, prevMetrics.tickets) : null }, - { label: T.avgRev, curr: formatCurrency(metrics.avgRevPerVisitor, false), + { 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), @@ -122,32 +197,86 @@ export function ReportDocument({ data }: Props) { }] : []), ]; - const prevYear = parseInt(cfg.startDate.slice(0, 4)) - 1; + const trendTitle = cfg.trendMetric === 'visitors' ? T.trendVisitors + : cfg.trendMetric === 'tickets' ? T.trendTickets + : T.trendRevenue; + + const showMuseumPage = cfg.showMuseumRevenue || cfg.showMuseumVisitors || cfg.showMuseumTickets; + const showChannelPage = cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets; + const showDistrictPage = cfg.showDistrictRevenue || cfg.showDistrictVisitors || cfg.showDistrictTickets; + const showSummaryPage = cfg.showGlobalSummary && cfg.includeComparison; + + let pg = 1; + const mainPg = ++pg; + const museumPg = showMuseumPage ? ++pg : 0; + const channelPg = showChannelPage ? ++pg : 0; + const districtPg = showDistrictPage ? ++pg : 0; + const summaryPg = showSummaryPage ? ++pg : 0; + + const museumMetricRows = (row: MuseumDataRow) => { + const rows = []; + if (cfg.showMuseumRevenue) rows.push({ + label: T.revenue, + curr: formatCurrency(row.curr.revenue, cfg.includeVAT), + prev: row.prev ? formatCurrency(row.prev.revenue, cfg.includeVAT) : null, + chg: row.prev ? pctChange(row.curr.revenue, row.prev.revenue) : null, + }); + if (cfg.showMuseumVisitors) rows.push({ + label: T.visitors, + curr: row.curr.visitors.toLocaleString(), + prev: row.prev ? row.prev.visitors.toLocaleString() : null, + chg: row.prev ? pctChange(row.curr.visitors, row.prev.visitors) : null, + }); + if (cfg.showMuseumTickets) rows.push({ + label: T.tickets, + curr: row.curr.tickets.toLocaleString(), + prev: row.prev ? row.prev.tickets.toLocaleString() : null, + chg: row.prev ? pctChange(row.curr.tickets, row.prev.tickets) : null, + }); + return rows; + }; return ( + {/* ── Cover ─────────────────────────────────────────── */} - - HiHala Data - {cfg.clientLogoBase64 && ( - - - + {/* Colored header band */} + + + HiHala Data + {cfg.clientLogoBase64 && ( + + + + )} + + {cfg.title || T.defaultTitle} + + + {/* White body */} + + {cfg.clientName && ( + {T.preparedFor}: {cfg.clientName} + )} + {cfg.contactName && ( + {T.attention}: {cfg.contactName} + )} + + + + {period} + + {T.generated}: {generatedAt} + {cfg.confidentiality !== 'Public' && ( + {cfg.confidentiality.toUpperCase()} )} - - {cfg.title || T.defaultTitle} - {cfg.clientName && {T.preparedFor}: {cfg.clientName}} - {cfg.contactName && {T.attention}: {cfg.contactName}} - {period} - {T.generated}: {generatedAt} - - + {/* ── Summary + Metrics + Trend ──────────────────────── */} - + {cfg.showExecutiveSummary && ( @@ -163,7 +292,7 @@ export function ReportDocument({ data }: Props) { {period} - {prevMetrics && {prevYear}} + {prevMetrics && {comparisonPeriodLabel}} {prevMetrics && {T.change}} {metricsRows.map((row, i) => ( @@ -173,7 +302,7 @@ export function ReportDocument({ data }: Props) { {prevMetrics && {row.prev ?? '—'}} {prevMetrics && row.chg !== null && ( = 0 ? S.metricsChangeUp : S.metricsChangeDown]}> - {formatPct(row.chg)} + {row.chg >= 0 ? '+' : '-'}{formatPct(Math.abs(row.chg))} )} @@ -184,7 +313,7 @@ export function ReportDocument({ data }: Props) { {cfg.showTrendChart && ( - + {cfg.includeComparison && ( @@ -193,13 +322,13 @@ export function ReportDocument({ data }: Props) { - {prevYear} + {comparisonPeriodLabel} )} + previous={trendPrevious} color={color} width={chartW} height={155} /> )} @@ -207,36 +336,199 @@ export function ReportDocument({ data }: Props) { - {(cfg.showMuseumBreakdown || cfg.showChannelBreakdown) && ( + {/* ── Museum Mini-Reports ────────────────────────────── */} + {showMuseumPage && museumData.length > 0 && ( - + + - {cfg.showMuseumBreakdown && museumBreakdown.length > 0 && ( + {museumData.map((row, mi) => { + const mRows = museumMetricRows(row); + const hasPrev = row.prev !== null; + return ( + + {row.name} + {hasPrev && ( + + {museumIntro(row, lang, comparisonPeriodLabel)} + + )} + + + + {period} + {hasPrev && {comparisonPeriodLabel}} + {hasPrev && {T.change}} + + {mRows.map((mr, ri) => ( + + {mr.label} + {mr.curr} + {hasPrev && {mr.prev ?? '—'}} + {hasPrev && mr.chg !== null && ( + = 0 ? S.miniChangeUp : S.miniChangeDown]}> + {mr.chg >= 0 ? '+' : '-'}{formatPct(Math.abs(mr.chg))} + + )} + + ))} + + + ); + })} + + + + )} + + {/* ── Channel Breakdowns ─────────────────────────────── */} + {showChannelPage && ( + + + + {cfg.showChannelRevenue && channelBreakdown.revenue.length > 0 && ( - + - + + + + )} + {cfg.showChannelVisitors && channelBreakdown.visitors.length > 0 && ( + + + + + + + )} + {cfg.showChannelTickets && channelBreakdown.tickets.length > 0 && ( + + + + )} - {cfg.showMuseumBreakdown && museumVisitorBreakdown.length > 0 && ( + + + )} + + {/* ── District Breakdowns ────────────────────────────── */} + {showDistrictPage && ( + + + + {cfg.showDistrictRevenue && districtBreakdown.revenue.length > 0 && ( - + - + + + + )} + {cfg.showDistrictVisitors && districtBreakdown.visitors.length > 0 && ( + + + + + + + )} + {cfg.showDistrictTickets && districtBreakdown.tickets.length > 0 && ( + + + + )} - {cfg.showChannelBreakdown && channelBreakdown.length > 0 && ( - - - - + + + )} + + {/* ── Global Performance Summary ─────────────────────── */} + {showSummaryPage && museumData.length > 0 && ( + + + + + + {period} — {T.comparedTo} {comparisonPeriodLabel} + + + + {T.museum} + {cfg.showMuseumRevenue && <> + {T.revenue} + Δ + } + {cfg.showMuseumVisitors && <> + {T.visitors} + Δ + } + {cfg.showMuseumTickets && <> + {T.tickets} + Δ + } + + + {museumData.map((row, i) => { + const hasPrev = row.prev !== null; + return ( + + {row.name.length > 30 ? row.name.slice(0, 30) + '…' : row.name} + {cfg.showMuseumRevenue && <> + {formatCurrency(row.curr.revenue, cfg.includeVAT)} + {hasPrev && row.prev ? (() => { + const c = pctChange(row.curr.revenue, row.prev!.revenue); + return = 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}; + })() : } + } + {cfg.showMuseumVisitors && <> + {row.curr.visitors.toLocaleString()} + {hasPrev && row.prev ? (() => { + const c = pctChange(row.curr.visitors, row.prev!.visitors); + return = 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}; + })() : } + } + {cfg.showMuseumTickets && <> + {row.curr.tickets.toLocaleString()} + {hasPrev && row.prev ? (() => { + const c = pctChange(row.curr.tickets, row.prev!.tickets); + return = 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}; + })() : } + } - - )} + ); + })} + + + {T.total} + {cfg.showMuseumRevenue && <> + {formatCurrency(metrics.revenue, cfg.includeVAT)} + {prevMetrics ? (() => { + const c = pctChange(metrics.revenue, prevMetrics.revenue); + return = 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}; + })() : } + } + {cfg.showMuseumVisitors && <> + {metrics.visitors.toLocaleString()} + {prevMetrics ? (() => { + const c = pctChange(metrics.visitors, prevMetrics.visitors); + return = 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}; + })() : } + } + {cfg.showMuseumTickets && <> + {metrics.tickets.toLocaleString()} + {prevMetrics ? (() => { + const c = pctChange(metrics.tickets, prevMetrics.tickets); + return = 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}; + })() : } + } + @@ -255,11 +547,21 @@ const LABELS_EN = { keyMetrics: 'Key Metrics', inclVAT: 'Incl. VAT', exclVAT: 'Excl. VAT', - change: 'vs Prior Year', - trend: 'Revenue Trend', - byMuseumRevenue: 'Revenue by Museum', - byMuseumVisitors: 'Visitors by Museum', - byChannel: 'Visitors by Channel', + 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', @@ -277,11 +579,21 @@ const LABELS_AR = { keyMetrics: 'المؤشرات الرئيسية', inclVAT: 'شامل ضريبة القيمة المضافة', exclVAT: 'غير شامل ضريبة القيمة المضافة', - change: 'مقابل العام السابق', - trend: 'اتجاه الإيرادات', - byMuseumRevenue: 'الإيرادات حسب المتحف', - byMuseumVisitors: 'الزوار حسب المتحف', - byChannel: 'الزوار حسب القناة', + change: 'التغيّر', + comparedTo: 'مقابل', + trendRevenue: 'اتجاه الإيرادات', + trendVisitors: 'اتجاه الزوار', + trendTickets: 'اتجاه التذاكر', + museumBreakdowns: 'تفاصيل المتاحف', + byChannelRevenue: 'الإيرادات حسب القناة', + byChannelVisitors: 'الزوار حسب القناة', + byChannelTickets: 'التذاكر حسب القناة', + byDistrictRevenue: 'الإيرادات حسب الحي', + byDistrictVisitors: 'الزوار حسب الحي', + byDistrictTickets: 'التذاكر حسب الحي', + globalSummary: 'ملخص الأداء', + museum: 'المتحف', + total: 'الإجمالي', revenue: 'الإيرادات', visitors: 'الزوار', tickets: 'التذاكر', diff --git a/src/components/Report/ReportForm.tsx b/src/components/Report/ReportForm.tsx index b87b42e..c8d4857 100644 --- a/src/components/Report/ReportForm.tsx +++ b/src/components/Report/ReportForm.tsx @@ -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
{children}
; -} - function Field({ label, children }: { label: string; children: React.ReactNode }) { return (