d7d035adb0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
269 lines
12 KiB
TypeScript
269 lines
12 KiB
TypeScript
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: 'معدل استيعاب الحجاج',
|
|
};
|