diff --git a/src/components/Report/ReportDocument.tsx b/src/components/Report/ReportDocument.tsx new file mode 100644 index 0000000..e7a50f0 --- /dev/null +++ b/src/components/Report/ReportDocument.tsx @@ -0,0 +1,268 @@ +import React from 'react'; +import { + Document, Page, View, Text, Image, StyleSheet +} from '@react-pdf/renderer'; +import { PdfTrendChart, PdfHBarChart } from './reportCharts'; +import { + ReportData, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary +} from './reportHelpers'; + +const S = StyleSheet.create({ + page: { fontFamily: 'Helvetica', fontSize: 9, color: '#0f172a', backgroundColor: '#ffffff' }, + 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 }, + 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' }, + 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 }, + legendDot: { width: 8, height: 8, borderRadius: 4 }, + legendLabel: { fontSize: 7.5, color: '#64748b', marginLeft: 4 }, +}); + +function pctChange(curr: number, prev: number): number { + if (prev === 0) return 0; + return Math.round(((curr - prev) / prev) * 100); +} + +interface PageHeaderProps { title: string; page: number; } +function PageHeader({ title, page }: PageHeaderProps) { + return ( + + HiHala Data + {title} + {page} + + ); +} + +interface PageFooterProps { confidentiality: string; generatedAt: string; } +function PageFooter({ confidentiality, generatedAt }: PageFooterProps) { + return ( + + {confidentiality} + Generated {generatedAt} + + ); +} + +interface SectionProps { title: string; color: string; } +function SectionHeading({ title, color }: SectionProps) { + return ( + + {title} + + ); +} + +interface Props { data: ReportData; } + +export function ReportDocument({ data }: Props) { + const { config: cfg, metrics, prevMetrics, trendLabels, trendCurrent, trendPrevious, + museumBreakdown, channelBreakdown, 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 T = lang === 'en' ? LABELS_EN : LABELS_AR; + + const metricsRows = [ + { label: T.revenue, curr: formatCurrency(metrics.revenue, cfg.includeVAT), + prev: prevMetrics ? formatCurrency(prevMetrics.revenue, cfg.includeVAT) : null, + chg: prevMetrics ? pctChange(metrics.revenue, prevMetrics.revenue) : null }, + { label: T.visitors, curr: metrics.visitors.toLocaleString(), + prev: prevMetrics ? prevMetrics.visitors.toLocaleString() : null, + chg: prevMetrics ? pctChange(metrics.visitors, prevMetrics.visitors) : null }, + { label: T.tickets, curr: metrics.tickets.toLocaleString(), + prev: prevMetrics ? prevMetrics.tickets.toLocaleString() : null, + chg: prevMetrics ? pctChange(metrics.tickets, prevMetrics.tickets) : null }, + { label: T.avgRev, curr: formatCurrency(metrics.avgRevPerVisitor, false), + prev: prevMetrics ? formatCurrency(prevMetrics.avgRevPerVisitor, false) : null, + chg: prevMetrics ? pctChange(metrics.avgRevPerVisitor, prevMetrics.avgRevPerVisitor) : null }, + ...(cfg.showPilgrimCapture && pilgrimCapture ? [{ + label: T.capture, curr: `${pilgrimCapture.current}%`, + prev: pilgrimCapture.previous !== null ? `${pilgrimCapture.previous}%` : null, + chg: pilgrimCapture.previous !== null ? pctChange(pilgrimCapture.current, pilgrimCapture.previous) : null, + }] : []), + ]; + + const prevYear = parseInt(cfg.startDate.slice(0, 4)) - 1; + + return ( + + + + + HiHala Data + {cfg.clientLogoBase64 && ( + + + + )} + + + {cfg.title || T.defaultTitle} + {cfg.clientName && {T.preparedFor}: {cfg.clientName}} + {cfg.contactName && {T.attention}: {cfg.contactName}} + {period} + {T.generated}: {generatedAt} + + + + + + + + {cfg.showExecutiveSummary && ( + + + {generateExecutiveSummary(data)} + + )} + + {cfg.showMetricsTable && ( + + + + + + {period} + {prevMetrics && {prevYear}} + {prevMetrics && {T.change}} + + {metricsRows.map((row, i) => ( + + {row.label} + {row.curr} + {prevMetrics && {row.prev ?? '—'}} + {prevMetrics && row.chg !== null && ( + = 0 ? S.metricsChangeUp : S.metricsChangeDown]}> + {formatPct(row.chg)} + + )} + + ))} + + + )} + + {cfg.showTrendChart && ( + + + {cfg.includeComparison && ( + + + + {period} + + + + {prevYear} + + + )} + + + + + )} + + + + + {(cfg.showMuseumBreakdown || cfg.showChannelBreakdown) && ( + + + + {cfg.showMuseumBreakdown && museumBreakdown.length > 0 && ( + + + + + + + )} + + {cfg.showChannelBreakdown && channelBreakdown.length > 0 && ( + + + + + + + )} + + + + )} + + + ); +} + +const LABELS_EN = { + defaultTitle: 'Performance Report', + preparedFor: 'Prepared for', + attention: 'Attention', + generated: 'Generated', + execSummary: 'Executive Summary', + keyMetrics: 'Key Metrics', + change: 'vs Prior Year', + trend: 'Revenue Trend', + byMuseum: 'Revenue by Museum', + byChannel: 'Visitors by Channel', + revenue: 'Revenue', + visitors: 'Visitors', + tickets: 'Tickets', + avgRev: 'Avg Rev / Visitor', + capture: 'Pilgrim Capture Rate', +}; + +const LABELS_AR = { + defaultTitle: 'تقرير الأداء', + preparedFor: 'مُعدّ لـ', + attention: 'عناية', + generated: 'تاريخ الإصدار', + execSummary: 'الملخص التنفيذي', + keyMetrics: 'المؤشرات الرئيسية', + change: 'مقابل العام السابق', + trend: 'اتجاه الإيرادات', + byMuseum: 'الإيرادات حسب المتحف', + byChannel: 'الزوار حسب القناة', + revenue: 'الإيرادات', + visitors: 'الزوار', + tickets: 'التذاكر', + avgRev: 'متوسط الإيراد / زائر', + capture: 'معدل استيعاب الحجاج', +};