Files
hihala-dashboard/src/components/Report/ReportDocument.tsx
T
fahed 648365348f
Deploy HiHala Dashboard / deploy (push) Successful in 10s
feat(report): visitors by museum, avg ticket price, chart label fix, VAT indicator
2026-04-28 14:59:24 +03:00

292 lines
13 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, museumVisitorBreakdown, 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 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,
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 },
{ 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,
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}${cfg.includeVAT ? T.inclVAT : T.exclVAT}`} 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.byMuseumRevenue} color={color} />
<View style={S.chartWrap}>
<PdfHBarChart items={museumBreakdown} color={color} width={460} />
</View>
</View>
)}
{cfg.showMuseumBreakdown && museumVisitorBreakdown.length > 0 && (
<View style={S.sectionGap}>
<SectionHeading title={T.byMuseumVisitors} color={color} />
<View style={S.chartWrap}>
<PdfHBarChart items={museumVisitorBreakdown} 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',
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',
revenue: 'Revenue',
visitors: 'Visitors',
tickets: 'Tickets',
avgRev: 'Avg Rev / Visitor',
avgTicketPrice: 'Avg Ticket Price',
capture: 'Pilgrim Capture Rate',
};
const LABELS_AR = {
defaultTitle: 'تقرير الأداء',
preparedFor: 'مُعدّ لـ',
attention: 'عناية',
generated: 'تاريخ الإصدار',
execSummary: 'الملخص التنفيذي',
keyMetrics: 'المؤشرات الرئيسية',
inclVAT: 'شامل ضريبة القيمة المضافة',
exclVAT: 'غير شامل ضريبة القيمة المضافة',
change: 'مقابل العام السابق',
trend: 'اتجاه الإيرادات',
byMuseumRevenue: 'الإيرادات حسب المتحف',
byMuseumVisitors: 'الزوار حسب المتحف',
byChannel: 'الزوار حسب القناة',
revenue: 'الإيرادات',
visitors: 'الزوار',
tickets: 'التذاكر',
avgRev: 'متوسط الإيراد / زائر',
avgTicketPrice: 'متوسط سعر التذكرة',
capture: 'معدل استيعاب الحجاج',
};