feat(report): PDF document component (cover + content pages + charts)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<View style={S.pageHeader}>
|
||||
<Text style={S.pageHeaderLogo}>HiHala Data</Text>
|
||||
<Text style={S.pageHeaderTitle}>{title}</Text>
|
||||
<Text style={S.pageHeaderNum}>{page}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageFooterProps { confidentiality: string; generatedAt: string; }
|
||||
function PageFooter({ confidentiality, generatedAt }: PageFooterProps) {
|
||||
return (
|
||||
<View style={S.pageFooter}>
|
||||
<Text style={S.pageFooterText}>{confidentiality}</Text>
|
||||
<Text style={S.pageFooterText}>Generated {generatedAt}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionProps { title: string; color: string; }
|
||||
function SectionHeading({ title, color }: SectionProps) {
|
||||
return (
|
||||
<View style={[S.sectionHeading, { backgroundColor: color }]}>
|
||||
<Text>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
{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>
|
||||
</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} />
|
||||
|
||||
{cfg.showExecutiveSummary && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.execSummary} color={color} />
|
||||
<Text style={S.summaryText}>{generateExecutiveSummary(data)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{cfg.showMetricsTable && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.keyMetrics} color={color} />
|
||||
<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>}
|
||||
</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>}
|
||||
{prevMetrics && row.chg !== null && (
|
||||
<Text style={[S.metricsChange, row.chg >= 0 ? S.metricsChangeUp : S.metricsChangeDown]}>
|
||||
{formatPct(row.chg)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{cfg.showTrendChart && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.trend} color={color} />
|
||||
{cfg.includeComparison && (
|
||||
<View style={S.legendRow}>
|
||||
<View style={S.legendItem}>
|
||||
<View style={[S.legendDot, { backgroundColor: color }]} />
|
||||
<Text style={S.legendLabel}>{period}</Text>
|
||||
</View>
|
||||
<View style={S.legendItem}>
|
||||
<View style={[S.legendDot, { backgroundColor: '#94a3b8' }]} />
|
||||
<Text style={S.legendLabel}>{prevYear}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<View style={S.chartWrap}>
|
||||
<PdfTrendChart labels={trendLabels} current={trendCurrent}
|
||||
previous={trendPrevious} color={color} width={460} height={130} />
|
||||
</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} />
|
||||
|
||||
{cfg.showMuseumBreakdown && museumBreakdown.length > 0 && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.byMuseum} color={color} />
|
||||
<View style={S.chartWrap}>
|
||||
<PdfHBarChart items={museumBreakdown} color={color} width={460} />
|
||||
</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>
|
||||
)}
|
||||
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
|
||||
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: 'معدل استيعاب الحجاج',
|
||||
};
|
||||
Reference in New Issue
Block a user