Compare commits
1 Commits
89689c5979
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f51280d1c |
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@ import {
|
|||||||
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
|
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
|
||||||
umrahData
|
umrahData
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, chartPalette, createBaseOptions, TOTAL_COLOR } from '../config/chartConfig';
|
||||||
import type { MuseumRecord, Season } from '../types';
|
import type { MuseumRecord, Season } from '../types';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import type { LC } from '../lib/locale';
|
import type { LC } from '../lib/locale';
|
||||||
@@ -187,7 +187,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
datasets: [
|
datasets: [
|
||||||
{ label:periodLabel(prevStart,prevEnd), data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:2, tension:0.4, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.muted },
|
{ label:periodLabel(prevStart,prevEnd), data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:2, tension:0.4, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.muted },
|
||||||
...museumDatasets,
|
...museumDatasets,
|
||||||
{ label: multiMuseum ? `Total · ${periodLabel(currStart,currEnd)}` : periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor: multiMuseum ? 'transparent' : chartColors.primary+'15', borderWidth:2.5, tension:0.4, fill: !multiMuseum, pointRadius:gran==='week'?4:2, pointBackgroundColor:chartColors.primary },
|
{ label: multiMuseum ? `Total · ${periodLabel(currStart,currEnd)}` : periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:TOTAL_COLOR, backgroundColor: multiMuseum ? 'transparent' : TOTAL_COLOR+'15', borderWidth:2.5, tension:0.4, fill: !multiMuseum, pointRadius:gran==='week'?4:2, pointBackgroundColor:TOTAL_COLOR },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
groupByMuseum, groupByChannel, groupByDistrict,
|
groupByMuseum, groupByChannel, groupByDistrict,
|
||||||
umrahData,
|
umrahData,
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, chartPalette, createBaseOptions, TOTAL_COLOR } from '../config/chartConfig';
|
||||||
import type { MuseumRecord, Season } from '../types';
|
import type { MuseumRecord, Season } from '../types';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { EN, AR } from '../lib/locale';
|
import { EN, AR } from '../lib/locale';
|
||||||
@@ -148,7 +148,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
datasets: [
|
datasets: [
|
||||||
{ label:`${prevYear}`, data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:1.5, tension:0.4, pointRadius:0, borderDash:[4,3] },
|
{ label:`${prevYear}`, data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:1.5, tension:0.4, pointRadius:0, borderDash:[4,3] },
|
||||||
...museumDatasets,
|
...museumDatasets,
|
||||||
{ label: multiMuseum ? `Total ${start.slice(0,4)}` : start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor: multiMuseum ? 'transparent' : chartColors.primary+'18', borderWidth:2.5, tension:0.4, fill: !multiMuseum, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.primary },
|
{ label: multiMuseum ? `Total ${start.slice(0,4)}` : start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:TOTAL_COLOR, backgroundColor: multiMuseum ? 'transparent' : TOTAL_COLOR+'18', borderWidth:2.5, tension:0.4, fill: !multiMuseum, pointRadius:gran==='week'?3:1, pointBackgroundColor:TOTAL_COLOR },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Document, Page, View, Text, Image, StyleSheet
|
Document, Page, View, Text, Image, StyleSheet, Font
|
||||||
} from '@react-pdf/renderer';
|
} from '@react-pdf/renderer';
|
||||||
import { PdfTrendChart, PdfHBarChart, CHART_PALETTE } from './reportCharts';
|
import { PdfTrendChart, PdfHBarChart, CHART_PALETTE } from './reportCharts';
|
||||||
import {
|
import {
|
||||||
ReportData, MuseumDataRow, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary
|
ReportData, MuseumDataRow, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary
|
||||||
} from './reportHelpers';
|
} from './reportHelpers';
|
||||||
|
|
||||||
|
Font.register({
|
||||||
|
family: 'IBMPlexArabic',
|
||||||
|
fonts: [
|
||||||
|
{ src: '/fonts/IBMPlexSansArabic-Regular.woff2', fontWeight: 400 },
|
||||||
|
{ src: '/fonts/IBMPlexSansArabic-Bold.woff2', fontWeight: 700 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const TOTAL_LINE_COLOR = '#1e293b';
|
||||||
|
|
||||||
// A4 content width minus chart-wrap padding (14×2)
|
// A4 content width minus chart-wrap padding (14×2)
|
||||||
// Portrait: 595 - 44 - 44 - 28 = 479
|
// Portrait: 595 - 44 - 44 - 28 = 479
|
||||||
// Landscape: 842 - 44 - 44 - 28 = 726
|
// Landscape: 842 - 44 - 44 - 28 = 726
|
||||||
@@ -17,14 +27,12 @@ const S = StyleSheet.create({
|
|||||||
|
|
||||||
// ── Cover ──────────────────────────────────────────────
|
// ── Cover ──────────────────────────────────────────────
|
||||||
coverPage: { flexDirection: 'column', padding: 0 },
|
coverPage: { flexDirection: 'column', padding: 0 },
|
||||||
// colored header band
|
|
||||||
coverHeader: { paddingTop: 56, paddingRight: 52, paddingBottom: 52, paddingLeft: 52 },
|
coverHeader: { paddingTop: 56, paddingRight: 52, paddingBottom: 52, paddingLeft: 52 },
|
||||||
coverHeaderTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 48 },
|
coverHeaderTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 48 },
|
||||||
coverBrand: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', letterSpacing: 0.8 },
|
coverBrand: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', letterSpacing: 0.8 },
|
||||||
coverLogoBox: { width: 90, height: 44, justifyContent: 'flex-end', alignItems: 'flex-end' },
|
coverLogoBox: { width: 90, height: 44, justifyContent: 'flex-end', alignItems: 'flex-end' },
|
||||||
coverClientLogo: { width: 90, height: 44, objectFit: 'contain' as const },
|
coverClientLogo: { width: 90, height: 44, objectFit: 'contain' as const },
|
||||||
coverTitle: { fontSize: 36, fontFamily: 'Helvetica-Bold', color: '#ffffff', lineHeight: 1.2 },
|
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' },
|
coverBody: { flex: 1, paddingTop: 44, paddingRight: 52, paddingBottom: 44, paddingLeft: 52, flexDirection: 'column' },
|
||||||
coverClientName: { fontSize: 15, color: '#0f172a', fontFamily: 'Helvetica-Bold', marginBottom: 5 },
|
coverClientName: { fontSize: 15, color: '#0f172a', fontFamily: 'Helvetica-Bold', marginBottom: 5 },
|
||||||
coverContactName: { fontSize: 11, color: '#64748b', marginBottom: 32 },
|
coverContactName: { fontSize: 11, color: '#64748b', marginBottom: 32 },
|
||||||
@@ -66,8 +74,8 @@ const S = StyleSheet.create({
|
|||||||
|
|
||||||
// ── Trend chart ────────────────────────────────────────
|
// ── Trend chart ────────────────────────────────────────
|
||||||
chartWrap: { marginBottom: 8, backgroundColor: '#f8fafc', paddingTop: 14, paddingRight: 14, paddingBottom: 14, paddingLeft: 14, borderRadius: 6, borderWidth: 1, borderColor: '#f1f5f9' },
|
chartWrap: { marginBottom: 8, backgroundColor: '#f8fafc', paddingTop: 14, paddingRight: 14, paddingBottom: 14, paddingLeft: 14, borderRadius: 6, borderWidth: 1, borderColor: '#f1f5f9' },
|
||||||
legendRow: { flexDirection: 'row', marginBottom: 10 },
|
legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 10 },
|
||||||
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18 },
|
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18, marginBottom: 4 },
|
||||||
legendDot: { width: 8, height: 8, borderRadius: 4 },
|
legendDot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
legendLabel: { fontSize: 8, color: '#64748b', marginLeft: 5 },
|
legendLabel: { fontSize: 8, color: '#64748b', marginLeft: 5 },
|
||||||
|
|
||||||
@@ -122,12 +130,12 @@ function museumIntro(row: MuseumDataRow, lang: 'en' | 'ar', compLabel: string):
|
|||||||
return `الإيرادات ${revChg >= 0 ? 'ارتفعت' : 'انخفضت'} ${Math.abs(revChg)}%، الزوار ${visChg >= 0 ? 'ارتفعوا' : 'انخفضوا'} ${Math.abs(visChg)}% مقارنةً بـ${compLabel}.`;
|
return `الإيرادات ${revChg >= 0 ? 'ارتفعت' : 'انخفضت'} ${Math.abs(revChg)}%، الزوار ${visChg >= 0 ? 'ارتفعوا' : 'انخفضوا'} ${Math.abs(visChg)}% مقارنةً بـ${compLabel}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageHeaderProps { title: string; page: number; }
|
interface PageHeaderProps { title: string; page: number; isAr: boolean; arB: any; }
|
||||||
function PageHeader({ title, page }: PageHeaderProps) {
|
function PageHeader({ title, page, isAr, arB }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<View style={S.pageHeader}>
|
<View style={S.pageHeader}>
|
||||||
<Text style={S.pageHeaderLogo}>HiHala Data</Text>
|
<Text style={[S.pageHeaderLogo, arB]}>HiHala Data</Text>
|
||||||
<Text style={S.pageHeaderTitle}>{title}</Text>
|
<Text style={[S.pageHeaderTitle, isAr ? { fontFamily: 'IBMPlexArabic' } : {}]}>{title}</Text>
|
||||||
<Text style={S.pageHeaderNum}>{page}</Text>
|
<Text style={S.pageHeaderNum}>{page}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -143,11 +151,11 @@ function PageFooter({ confidentiality, generatedAt }: PageFooterProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SectionProps { title: string; color: string; }
|
interface SectionProps { title: string; color: string; arB: any; }
|
||||||
function SectionHeading({ title, color }: SectionProps) {
|
function SectionHeading({ title, color, arB }: SectionProps) {
|
||||||
return (
|
return (
|
||||||
<View style={[S.sectionHeading, { backgroundColor: color }]}>
|
<View style={[S.sectionHeading, { backgroundColor: color }]}>
|
||||||
<Text>{title}</Text>
|
<Text style={arB}>{title}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -156,18 +164,24 @@ interface Props { data: ReportData; }
|
|||||||
|
|
||||||
export function ReportDocument({ data }: Props) {
|
export function ReportDocument({ data }: Props) {
|
||||||
const { config: cfg, metrics, prevMetrics, comparisonPeriodLabel,
|
const { config: cfg, metrics, prevMetrics, comparisonPeriodLabel,
|
||||||
trendLabels, trendCurrent, trendPrevious, trendMuseums,
|
trendCharts,
|
||||||
museumData, channelBreakdown, districtBreakdown,
|
museumData, channelBreakdown, districtBreakdown,
|
||||||
pilgrimCapture, generatedAt } = data;
|
pilgrimCapture, generatedAt } = data;
|
||||||
|
|
||||||
const lang = cfg.language;
|
const lang = cfg.language;
|
||||||
|
const isAr = lang === 'ar';
|
||||||
const color = cfg.accentColor;
|
const color = cfg.accentColor;
|
||||||
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
|
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
|
||||||
const isLandscape = cfg.orientation === 'landscape';
|
const isLandscape = cfg.orientation === 'landscape';
|
||||||
const orientation = isLandscape ? 'landscape' : 'portrait';
|
const orientation = isLandscape ? 'landscape' : 'portrait';
|
||||||
const T = lang === 'en' ? LABELS_EN : LABELS_AR;
|
const T = lang === 'en' ? LABELS_EN : LABELS_AR;
|
||||||
|
|
||||||
// Chart width adapts to orientation
|
// Arabic font overrides — Helvetica has no Arabic glyphs; cast as any so style arrays stay compatible
|
||||||
|
const arN: any = isAr ? { fontFamily: 'IBMPlexArabic', fontWeight: 400 } : {};
|
||||||
|
const arB: any = isAr ? { fontFamily: 'IBMPlexArabic', fontWeight: 700 } : {};
|
||||||
|
// direction: 'rtl' flips flex-row children right-to-left; fontFamily cascades to elements without an explicit one
|
||||||
|
const arPageExtra: any = isAr ? { direction: 'rtl', fontFamily: 'IBMPlexArabic' } : {};
|
||||||
|
|
||||||
const chartW = isLandscape ? CHART_W.landscape : CHART_W.portrait;
|
const chartW = isLandscape ? CHART_W.landscape : CHART_W.portrait;
|
||||||
|
|
||||||
const avgTicketPrice = metrics.tickets > 0 ? metrics.revenue / metrics.tickets : 0;
|
const avgTicketPrice = metrics.tickets > 0 ? metrics.revenue / metrics.tickets : 0;
|
||||||
@@ -197,10 +211,6 @@ export function ReportDocument({ data }: Props) {
|
|||||||
}] : []),
|
}] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const trendTitle = cfg.trendMetric === 'visitors' ? T.trendVisitors
|
|
||||||
: cfg.trendMetric === 'tickets' ? T.trendTickets
|
|
||||||
: T.trendRevenue;
|
|
||||||
|
|
||||||
const showMuseumPage = cfg.showMuseumRevenue || cfg.showMuseumVisitors || cfg.showMuseumTickets;
|
const showMuseumPage = cfg.showMuseumRevenue || cfg.showMuseumVisitors || cfg.showMuseumTickets;
|
||||||
const showChannelPage = cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets;
|
const showChannelPage = cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets;
|
||||||
const showDistrictPage = cfg.showDistrictRevenue || cfg.showDistrictVisitors || cfg.showDistrictTickets;
|
const showDistrictPage = cfg.showDistrictRevenue || cfg.showDistrictVisitors || cfg.showDistrictTickets;
|
||||||
@@ -240,32 +250,30 @@ export function ReportDocument({ data }: Props) {
|
|||||||
<Document title={cfg.title || 'HiHala Report'} author="HiHala Data">
|
<Document title={cfg.title || 'HiHala Report'} author="HiHala Data">
|
||||||
|
|
||||||
{/* ── Cover ─────────────────────────────────────────── */}
|
{/* ── Cover ─────────────────────────────────────────── */}
|
||||||
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage]}>
|
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage, arPageExtra]}>
|
||||||
{/* Colored header band */}
|
|
||||||
<View style={[S.coverHeader, { backgroundColor: color }]}>
|
<View style={[S.coverHeader, { backgroundColor: color }]}>
|
||||||
<View style={S.coverHeaderTop}>
|
<View style={S.coverHeaderTop}>
|
||||||
<Text style={S.coverBrand}>HiHala Data</Text>
|
<Text style={[S.coverBrand, arB]}>HiHala Data</Text>
|
||||||
{cfg.clientLogoBase64 && (
|
{cfg.clientLogoBase64 && (
|
||||||
<View style={S.coverLogoBox}>
|
<View style={S.coverLogoBox}>
|
||||||
<Image src={cfg.clientLogoBase64} style={S.coverClientLogo} />
|
<Image src={cfg.clientLogoBase64} style={S.coverClientLogo} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text style={S.coverTitle}>{cfg.title || T.defaultTitle}</Text>
|
<Text style={[S.coverTitle, arB]}>{cfg.title || T.defaultTitle}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* White body */}
|
|
||||||
<View style={S.coverBody}>
|
<View style={S.coverBody}>
|
||||||
{cfg.clientName && (
|
{cfg.clientName && (
|
||||||
<Text style={S.coverClientName}>{T.preparedFor}: {cfg.clientName}</Text>
|
<Text style={[S.coverClientName, arB]}>{T.preparedFor}: {cfg.clientName}</Text>
|
||||||
)}
|
)}
|
||||||
{cfg.contactName && (
|
{cfg.contactName && (
|
||||||
<Text style={S.coverContactName}>{T.attention}: {cfg.contactName}</Text>
|
<Text style={[S.coverContactName, arN]}>{T.attention}: {cfg.contactName}</Text>
|
||||||
)}
|
)}
|
||||||
<View style={S.coverBodySpacer} />
|
<View style={S.coverBodySpacer} />
|
||||||
<View style={S.coverPeriodRow}>
|
<View style={S.coverPeriodRow}>
|
||||||
<View style={[S.coverPeriodDot, { backgroundColor: color }]} />
|
<View style={[S.coverPeriodDot, { backgroundColor: color }]} />
|
||||||
<Text style={S.coverPeriod}>{period}</Text>
|
<Text style={[S.coverPeriod, arN]}>{period}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={S.coverDate}>{T.generated}: {generatedAt}</Text>
|
<Text style={S.coverDate}>{T.generated}: {generatedAt}</Text>
|
||||||
{cfg.confidentiality !== 'Public' && (
|
{cfg.confidentiality !== 'Public' && (
|
||||||
@@ -275,34 +283,34 @@ export function ReportDocument({ data }: Props) {
|
|||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
{/* ── Summary + Metrics + Trend ──────────────────────── */}
|
{/* ── Summary + Metrics + Trend ──────────────────────── */}
|
||||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
<PageHeader title={cfg.title || T.defaultTitle} page={mainPg} />
|
<PageHeader title={cfg.title || T.defaultTitle} page={mainPg} isAr={isAr} arB={arB} />
|
||||||
|
|
||||||
{cfg.showExecutiveSummary && (
|
{cfg.showExecutiveSummary && (
|
||||||
<View style={S.sectionGap}>
|
<View style={S.sectionGap}>
|
||||||
<SectionHeading title={T.execSummary} color={color} />
|
<SectionHeading title={T.execSummary} color={color} arB={arB} />
|
||||||
<Text style={S.summaryText}>{generateExecutiveSummary(data)}</Text>
|
<Text style={[S.summaryText, arN]}>{generateExecutiveSummary(data)}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cfg.showMetricsTable && (
|
{cfg.showMetricsTable && (
|
||||||
<View style={S.sectionGap}>
|
<View style={S.sectionGap}>
|
||||||
<SectionHeading title={`${T.keyMetrics} — ${cfg.includeVAT ? T.inclVAT : T.exclVAT}`} color={color} />
|
<SectionHeading title={`${T.keyMetrics} — ${cfg.includeVAT ? T.inclVAT : T.exclVAT}`} color={color} arB={arB} />
|
||||||
<View style={S.metricsTable}>
|
<View style={S.metricsTable}>
|
||||||
<View style={S.metricsHeaderRow}>
|
<View style={S.metricsHeaderRow}>
|
||||||
<Text style={S.metricsHeaderLabel}> </Text>
|
<Text style={[S.metricsHeaderLabel, arB]}> </Text>
|
||||||
<Text style={S.metricsHeaderCell}>{period}</Text>
|
<Text style={[S.metricsHeaderCell, arB]}>{period}</Text>
|
||||||
{prevMetrics && <Text style={S.metricsHeaderCell}>{comparisonPeriodLabel}</Text>}
|
{prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
|
||||||
{prevMetrics && <Text style={S.metricsHeaderCell}>{T.change}</Text>}
|
{prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{T.change}</Text>}
|
||||||
</View>
|
</View>
|
||||||
{metricsRows.map((row, i) => (
|
{metricsRows.map((row, i) => (
|
||||||
<View key={row.label} style={[S.metricsRow, i % 2 === 1 ? S.metricsRowAlt : {}]}>
|
<View key={row.label} style={[S.metricsRow, i % 2 === 1 ? S.metricsRowAlt : {}]}>
|
||||||
<Text style={S.metricsLabel}>{row.label}</Text>
|
<Text style={[S.metricsLabel, arB]}>{row.label}</Text>
|
||||||
<Text style={S.metricsValue}>{row.curr}</Text>
|
<Text style={[S.metricsValue, arN]}>{row.curr}</Text>
|
||||||
{prevMetrics && <Text style={S.metricsValue}>{row.prev ?? '—'}</Text>}
|
{prevMetrics && <Text style={[S.metricsValue, arN]}>{row.prev ?? '—'}</Text>}
|
||||||
{prevMetrics && row.chg !== null && (
|
{prevMetrics && row.chg !== null && (
|
||||||
<Text style={[S.metricsChange, row.chg >= 0 ? S.metricsChangeUp : S.metricsChangeDown]}>
|
<Text style={[S.metricsChange, row.chg >= 0 ? S.metricsChangeUp : S.metricsChangeDown]}>
|
||||||
{row.chg >= 0 ? '+' : '-'}{formatPct(Math.abs(row.chg))}
|
{formatPct(row.chg)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -311,31 +319,40 @@ export function ReportDocument({ data }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cfg.showTrendChart && (
|
{cfg.showTrendChart && trendCharts.map((tc, tci) => {
|
||||||
<View style={S.sectionGap}>
|
const trendTitle = tc.metric === 'visitors' ? T.trendVisitors
|
||||||
<SectionHeading title={trendTitle} color={color} />
|
: tc.metric === 'tickets' ? T.trendTickets
|
||||||
|
: T.trendRevenue;
|
||||||
|
return (
|
||||||
|
<View key={tci} style={S.sectionGap}>
|
||||||
|
<SectionHeading title={trendTitle} color={color} arB={arB} />
|
||||||
<View style={S.legendRow}>
|
<View style={S.legendRow}>
|
||||||
{trendMuseums.length >= 2 && trendMuseums.map((m, i) => (
|
{tc.museums.length >= 2 && tc.museums.map((m, i) => (
|
||||||
<View key={m.name} style={S.legendItem}>
|
<View key={m.name} style={S.legendItem}>
|
||||||
<View style={[S.legendDot, { backgroundColor: CHART_PALETTE[i % CHART_PALETTE.length] }]} />
|
<View style={[S.legendDot, { backgroundColor: CHART_PALETTE[i % CHART_PALETTE.length] }]} />
|
||||||
<Text style={S.legendLabel}>{m.name}</Text>
|
<Text style={[S.legendLabel, arN]}>{m.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
<View style={S.legendItem}>
|
<View style={S.legendItem}>
|
||||||
<View style={[S.legendDot, { backgroundColor: color }]} />
|
<View style={[S.legendDot, { backgroundColor: TOTAL_LINE_COLOR }]} />
|
||||||
<Text style={S.legendLabel}>{trendMuseums.length >= 2 ? `Total · ${period}` : period}</Text>
|
<Text style={[S.legendLabel, arN]}>{tc.museums.length >= 2 ? `Total · ${period}` : period}</Text>
|
||||||
</View>
|
</View>
|
||||||
{cfg.includeComparison && (
|
{cfg.includeComparison && tc.previous && (
|
||||||
<View style={S.legendItem}>
|
<View style={S.legendItem}>
|
||||||
<View style={[S.legendDot, { backgroundColor: '#94a3b8' }]} />
|
<View style={[S.legendDot, { backgroundColor: '#94a3b8' }]} />
|
||||||
<Text style={S.legendLabel}>{comparisonPeriodLabel}</Text>
|
<Text style={[S.legendLabel, arN]}>{comparisonPeriodLabel}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={S.chartWrap}>
|
<View style={S.chartWrap}>
|
||||||
<PdfTrendChart labels={trendLabels} current={trendCurrent}
|
<PdfTrendChart
|
||||||
previous={trendPrevious} color={color} width={chartW} height={155}
|
labels={tc.labels}
|
||||||
series={trendMuseums.length >= 2 ? trendMuseums.map((m, i) => ({
|
current={tc.current}
|
||||||
|
previous={tc.previous}
|
||||||
|
color={TOTAL_LINE_COLOR}
|
||||||
|
width={chartW}
|
||||||
|
height={155}
|
||||||
|
series={tc.museums.length >= 2 ? tc.museums.map((m, i) => ({
|
||||||
label: m.name,
|
label: m.name,
|
||||||
color: CHART_PALETTE[i % CHART_PALETTE.length],
|
color: CHART_PALETTE[i % CHART_PALETTE.length],
|
||||||
data: m.values,
|
data: m.values,
|
||||||
@@ -343,43 +360,44 @@ export function ReportDocument({ data }: Props) {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
{/* ── Museum Mini-Reports ────────────────────────────── */}
|
{/* ── Museum Mini-Reports ────────────────────────────── */}
|
||||||
{showMuseumPage && museumData.length > 0 && (
|
{showMuseumPage && museumData.length > 0 && (
|
||||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
<PageHeader title={cfg.title || T.defaultTitle} page={museumPg} />
|
<PageHeader title={cfg.title || T.defaultTitle} page={museumPg} isAr={isAr} arB={arB} />
|
||||||
<SectionHeading title={T.museumBreakdowns} color={color} />
|
<SectionHeading title={T.museumBreakdowns} color={color} arB={arB} />
|
||||||
|
|
||||||
{museumData.map((row, mi) => {
|
{museumData.map((row, mi) => {
|
||||||
const mRows = museumMetricRows(row);
|
const mRows = museumMetricRows(row);
|
||||||
const hasPrev = row.prev !== null;
|
const hasPrev = row.prev !== null;
|
||||||
return (
|
return (
|
||||||
<View key={row.name} style={[S.museumBlock, { borderLeftColor: CHART_PALETTE[mi % CHART_PALETTE.length] }]}>
|
<View key={row.name} style={[S.museumBlock, { borderLeftColor: CHART_PALETTE[mi % CHART_PALETTE.length] }]}>
|
||||||
<Text style={S.museumBlockName}>{row.name}</Text>
|
<Text style={[S.museumBlockName, arB]}>{row.name}</Text>
|
||||||
{hasPrev && (
|
{hasPrev && (
|
||||||
<Text style={S.museumIntroText}>
|
<Text style={[S.museumIntroText, arN]}>
|
||||||
{museumIntro(row, lang, comparisonPeriodLabel)}
|
{museumIntro(row, lang, comparisonPeriodLabel)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<View style={S.miniTable}>
|
<View style={S.miniTable}>
|
||||||
<View style={S.miniHeaderRow}>
|
<View style={S.miniHeaderRow}>
|
||||||
<Text style={S.miniHeaderLabel}> </Text>
|
<Text style={[S.miniHeaderLabel, arB]}> </Text>
|
||||||
<Text style={S.miniHeaderCell}>{period}</Text>
|
<Text style={[S.miniHeaderCell, arB]}>{period}</Text>
|
||||||
{hasPrev && <Text style={S.miniHeaderCell}>{comparisonPeriodLabel}</Text>}
|
{hasPrev && <Text style={[S.miniHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
|
||||||
{hasPrev && <Text style={S.miniHeaderChangeCell}>{T.change}</Text>}
|
{hasPrev && <Text style={[S.miniHeaderChangeCell, arB]}>{T.change}</Text>}
|
||||||
</View>
|
</View>
|
||||||
{mRows.map((mr, ri) => (
|
{mRows.map((mr, ri) => (
|
||||||
<View key={mr.label} style={[S.miniRow, ri % 2 === 1 ? S.miniRowAlt : {}]}>
|
<View key={mr.label} style={[S.miniRow, ri % 2 === 1 ? S.miniRowAlt : {}]}>
|
||||||
<Text style={S.miniLabel}>{mr.label}</Text>
|
<Text style={[S.miniLabel, arB]}>{mr.label}</Text>
|
||||||
<Text style={S.miniValue}>{mr.curr}</Text>
|
<Text style={[S.miniValue, arN]}>{mr.curr}</Text>
|
||||||
{hasPrev && <Text style={S.miniValue}>{mr.prev ?? '—'}</Text>}
|
{hasPrev && <Text style={[S.miniValue, arN]}>{mr.prev ?? '—'}</Text>}
|
||||||
{hasPrev && mr.chg !== null && (
|
{hasPrev && mr.chg !== null && (
|
||||||
<Text style={[S.miniChange, mr.chg >= 0 ? S.miniChangeUp : S.miniChangeDown]}>
|
<Text style={[S.miniChange, mr.chg >= 0 ? S.miniChangeUp : S.miniChangeDown]}>
|
||||||
{mr.chg >= 0 ? '+' : '-'}{formatPct(Math.abs(mr.chg))}
|
{formatPct(mr.chg)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -395,12 +413,12 @@ export function ReportDocument({ data }: Props) {
|
|||||||
|
|
||||||
{/* ── Channel Breakdowns ─────────────────────────────── */}
|
{/* ── Channel Breakdowns ─────────────────────────────── */}
|
||||||
{showChannelPage && (
|
{showChannelPage && (
|
||||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
<PageHeader title={cfg.title || T.defaultTitle} page={channelPg} />
|
<PageHeader title={cfg.title || T.defaultTitle} page={channelPg} isAr={isAr} arB={arB} />
|
||||||
|
|
||||||
{cfg.showChannelRevenue && channelBreakdown.revenue.length > 0 && (
|
{cfg.showChannelRevenue && channelBreakdown.revenue.length > 0 && (
|
||||||
<View style={S.sectionGap}>
|
<View style={S.sectionGap}>
|
||||||
<SectionHeading title={T.byChannelRevenue} color={color} />
|
<SectionHeading title={T.byChannelRevenue} color={color} arB={arB} />
|
||||||
<View style={S.chartWrap}>
|
<View style={S.chartWrap}>
|
||||||
<PdfHBarChart items={channelBreakdown.revenue} color={color} usepalette width={chartW} />
|
<PdfHBarChart items={channelBreakdown.revenue} color={color} usepalette width={chartW} />
|
||||||
</View>
|
</View>
|
||||||
@@ -408,7 +426,7 @@ export function ReportDocument({ data }: Props) {
|
|||||||
)}
|
)}
|
||||||
{cfg.showChannelVisitors && channelBreakdown.visitors.length > 0 && (
|
{cfg.showChannelVisitors && channelBreakdown.visitors.length > 0 && (
|
||||||
<View style={S.sectionGap}>
|
<View style={S.sectionGap}>
|
||||||
<SectionHeading title={T.byChannelVisitors} color={color} />
|
<SectionHeading title={T.byChannelVisitors} color={color} arB={arB} />
|
||||||
<View style={S.chartWrap}>
|
<View style={S.chartWrap}>
|
||||||
<PdfHBarChart items={channelBreakdown.visitors} color={color} usepalette width={chartW} />
|
<PdfHBarChart items={channelBreakdown.visitors} color={color} usepalette width={chartW} />
|
||||||
</View>
|
</View>
|
||||||
@@ -416,7 +434,7 @@ export function ReportDocument({ data }: Props) {
|
|||||||
)}
|
)}
|
||||||
{cfg.showChannelTickets && channelBreakdown.tickets.length > 0 && (
|
{cfg.showChannelTickets && channelBreakdown.tickets.length > 0 && (
|
||||||
<View style={S.sectionGap}>
|
<View style={S.sectionGap}>
|
||||||
<SectionHeading title={T.byChannelTickets} color={color} />
|
<SectionHeading title={T.byChannelTickets} color={color} arB={arB} />
|
||||||
<View style={S.chartWrap}>
|
<View style={S.chartWrap}>
|
||||||
<PdfHBarChart items={channelBreakdown.tickets} color={color} usepalette width={chartW} />
|
<PdfHBarChart items={channelBreakdown.tickets} color={color} usepalette width={chartW} />
|
||||||
</View>
|
</View>
|
||||||
@@ -429,12 +447,12 @@ export function ReportDocument({ data }: Props) {
|
|||||||
|
|
||||||
{/* ── District Breakdowns ────────────────────────────── */}
|
{/* ── District Breakdowns ────────────────────────────── */}
|
||||||
{showDistrictPage && (
|
{showDistrictPage && (
|
||||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
<PageHeader title={cfg.title || T.defaultTitle} page={districtPg} />
|
<PageHeader title={cfg.title || T.defaultTitle} page={districtPg} isAr={isAr} arB={arB} />
|
||||||
|
|
||||||
{cfg.showDistrictRevenue && districtBreakdown.revenue.length > 0 && (
|
{cfg.showDistrictRevenue && districtBreakdown.revenue.length > 0 && (
|
||||||
<View style={S.sectionGap}>
|
<View style={S.sectionGap}>
|
||||||
<SectionHeading title={T.byDistrictRevenue} color={color} />
|
<SectionHeading title={T.byDistrictRevenue} color={color} arB={arB} />
|
||||||
<View style={S.chartWrap}>
|
<View style={S.chartWrap}>
|
||||||
<PdfHBarChart items={districtBreakdown.revenue} color={color} usepalette width={chartW} />
|
<PdfHBarChart items={districtBreakdown.revenue} color={color} usepalette width={chartW} />
|
||||||
</View>
|
</View>
|
||||||
@@ -442,7 +460,7 @@ export function ReportDocument({ data }: Props) {
|
|||||||
)}
|
)}
|
||||||
{cfg.showDistrictVisitors && districtBreakdown.visitors.length > 0 && (
|
{cfg.showDistrictVisitors && districtBreakdown.visitors.length > 0 && (
|
||||||
<View style={S.sectionGap}>
|
<View style={S.sectionGap}>
|
||||||
<SectionHeading title={T.byDistrictVisitors} color={color} />
|
<SectionHeading title={T.byDistrictVisitors} color={color} arB={arB} />
|
||||||
<View style={S.chartWrap}>
|
<View style={S.chartWrap}>
|
||||||
<PdfHBarChart items={districtBreakdown.visitors} color={color} usepalette width={chartW} />
|
<PdfHBarChart items={districtBreakdown.visitors} color={color} usepalette width={chartW} />
|
||||||
</View>
|
</View>
|
||||||
@@ -450,7 +468,7 @@ export function ReportDocument({ data }: Props) {
|
|||||||
)}
|
)}
|
||||||
{cfg.showDistrictTickets && districtBreakdown.tickets.length > 0 && (
|
{cfg.showDistrictTickets && districtBreakdown.tickets.length > 0 && (
|
||||||
<View style={S.sectionGap}>
|
<View style={S.sectionGap}>
|
||||||
<SectionHeading title={T.byDistrictTickets} color={color} />
|
<SectionHeading title={T.byDistrictTickets} color={color} arB={arB} />
|
||||||
<View style={S.chartWrap}>
|
<View style={S.chartWrap}>
|
||||||
<PdfHBarChart items={districtBreakdown.tickets} color={color} usepalette width={chartW} />
|
<PdfHBarChart items={districtBreakdown.tickets} color={color} usepalette width={chartW} />
|
||||||
</View>
|
</View>
|
||||||
@@ -463,27 +481,27 @@ export function ReportDocument({ data }: Props) {
|
|||||||
|
|
||||||
{/* ── Global Performance Summary ─────────────────────── */}
|
{/* ── Global Performance Summary ─────────────────────── */}
|
||||||
{showSummaryPage && museumData.length > 0 && (
|
{showSummaryPage && museumData.length > 0 && (
|
||||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
<PageHeader title={cfg.title || T.defaultTitle} page={summaryPg} />
|
<PageHeader title={cfg.title || T.defaultTitle} page={summaryPg} isAr={isAr} arB={arB} />
|
||||||
<SectionHeading title={T.globalSummary} color={color} />
|
<SectionHeading title={T.globalSummary} color={color} arB={arB} />
|
||||||
|
|
||||||
<Text style={S.summarySubLabel}>
|
<Text style={[S.summarySubLabel, arN]}>
|
||||||
{period} — {T.comparedTo} {comparisonPeriodLabel}
|
{period} — {T.comparedTo} {comparisonPeriodLabel}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={S.summaryHeaderRow}>
|
<View style={S.summaryHeaderRow}>
|
||||||
<Text style={S.summaryHeaderMuseum}>{T.museum}</Text>
|
<Text style={[S.summaryHeaderMuseum, arB]}>{T.museum}</Text>
|
||||||
{cfg.showMuseumRevenue && <>
|
{cfg.showMuseumRevenue && <>
|
||||||
<Text style={S.summaryHeaderMetric}>{T.revenue}</Text>
|
<Text style={[S.summaryHeaderMetric, arB]}>{T.revenue}</Text>
|
||||||
<Text style={S.summaryHeaderDelta}>Δ</Text>
|
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
|
||||||
</>}
|
</>}
|
||||||
{cfg.showMuseumVisitors && <>
|
{cfg.showMuseumVisitors && <>
|
||||||
<Text style={S.summaryHeaderMetric}>{T.visitors}</Text>
|
<Text style={[S.summaryHeaderMetric, arB]}>{T.visitors}</Text>
|
||||||
<Text style={S.summaryHeaderDelta}>Δ</Text>
|
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
|
||||||
</>}
|
</>}
|
||||||
{cfg.showMuseumTickets && <>
|
{cfg.showMuseumTickets && <>
|
||||||
<Text style={S.summaryHeaderMetric}>{T.tickets}</Text>
|
<Text style={[S.summaryHeaderMetric, arB]}>{T.tickets}</Text>
|
||||||
<Text style={S.summaryHeaderDelta}>Δ</Text>
|
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
|
||||||
</>}
|
</>}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -491,26 +509,26 @@ export function ReportDocument({ data }: Props) {
|
|||||||
const hasPrev = row.prev !== null;
|
const hasPrev = row.prev !== null;
|
||||||
return (
|
return (
|
||||||
<View key={row.name} style={[S.summaryRow, i % 2 === 1 ? S.summaryRowAlt : {}]}>
|
<View key={row.name} style={[S.summaryRow, i % 2 === 1 ? S.summaryRowAlt : {}]}>
|
||||||
<Text style={S.summaryMuseum}>{row.name.length > 30 ? row.name.slice(0, 30) + '…' : row.name}</Text>
|
<Text style={[S.summaryMuseum, arN]}>{row.name.length > 30 ? row.name.slice(0, 30) + '…' : row.name}</Text>
|
||||||
{cfg.showMuseumRevenue && <>
|
{cfg.showMuseumRevenue && <>
|
||||||
<Text style={S.summaryMetric}>{formatCurrency(row.curr.revenue, cfg.includeVAT)}</Text>
|
<Text style={[S.summaryMetric, arN]}>{formatCurrency(row.curr.revenue, cfg.includeVAT)}</Text>
|
||||||
{hasPrev && row.prev ? (() => {
|
{hasPrev && row.prev ? (() => {
|
||||||
const c = pctChange(row.curr.revenue, row.prev!.revenue);
|
const c = pctChange(row.curr.revenue, row.prev!.revenue);
|
||||||
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
})() : <Text style={S.summaryDelta}>—</Text>}
|
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||||
</>}
|
</>}
|
||||||
{cfg.showMuseumVisitors && <>
|
{cfg.showMuseumVisitors && <>
|
||||||
<Text style={S.summaryMetric}>{row.curr.visitors.toLocaleString()}</Text>
|
<Text style={[S.summaryMetric, arN]}>{row.curr.visitors.toLocaleString()}</Text>
|
||||||
{hasPrev && row.prev ? (() => {
|
{hasPrev && row.prev ? (() => {
|
||||||
const c = pctChange(row.curr.visitors, row.prev!.visitors);
|
const c = pctChange(row.curr.visitors, row.prev!.visitors);
|
||||||
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
})() : <Text style={S.summaryDelta}>—</Text>}
|
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||||
</>}
|
</>}
|
||||||
{cfg.showMuseumTickets && <>
|
{cfg.showMuseumTickets && <>
|
||||||
<Text style={S.summaryMetric}>{row.curr.tickets.toLocaleString()}</Text>
|
<Text style={[S.summaryMetric, arN]}>{row.curr.tickets.toLocaleString()}</Text>
|
||||||
{hasPrev && row.prev ? (() => {
|
{hasPrev && row.prev ? (() => {
|
||||||
const c = pctChange(row.curr.tickets, row.prev!.tickets);
|
const c = pctChange(row.curr.tickets, row.prev!.tickets);
|
||||||
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
})() : <Text style={S.summaryDelta}>—</Text>}
|
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||||
</>}
|
</>}
|
||||||
</View>
|
</View>
|
||||||
@@ -518,26 +536,26 @@ export function ReportDocument({ data }: Props) {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
<View style={S.summaryTotalRow}>
|
<View style={S.summaryTotalRow}>
|
||||||
<Text style={S.summaryMuseumTotal}>{T.total}</Text>
|
<Text style={[S.summaryMuseumTotal, arB]}>{T.total}</Text>
|
||||||
{cfg.showMuseumRevenue && <>
|
{cfg.showMuseumRevenue && <>
|
||||||
<Text style={S.summaryMetricTotal}>{formatCurrency(metrics.revenue, cfg.includeVAT)}</Text>
|
<Text style={[S.summaryMetricTotal, arB]}>{formatCurrency(metrics.revenue, cfg.includeVAT)}</Text>
|
||||||
{prevMetrics ? (() => {
|
{prevMetrics ? (() => {
|
||||||
const c = pctChange(metrics.revenue, prevMetrics.revenue);
|
const c = pctChange(metrics.revenue, prevMetrics.revenue);
|
||||||
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||||
</>}
|
</>}
|
||||||
{cfg.showMuseumVisitors && <>
|
{cfg.showMuseumVisitors && <>
|
||||||
<Text style={S.summaryMetricTotal}>{metrics.visitors.toLocaleString()}</Text>
|
<Text style={[S.summaryMetricTotal, arB]}>{metrics.visitors.toLocaleString()}</Text>
|
||||||
{prevMetrics ? (() => {
|
{prevMetrics ? (() => {
|
||||||
const c = pctChange(metrics.visitors, prevMetrics.visitors);
|
const c = pctChange(metrics.visitors, prevMetrics.visitors);
|
||||||
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||||
</>}
|
</>}
|
||||||
{cfg.showMuseumTickets && <>
|
{cfg.showMuseumTickets && <>
|
||||||
<Text style={S.summaryMetricTotal}>{metrics.tickets.toLocaleString()}</Text>
|
<Text style={[S.summaryMetricTotal, arB]}>{metrics.tickets.toLocaleString()}</Text>
|
||||||
{prevMetrics ? (() => {
|
{prevMetrics ? (() => {
|
||||||
const c = pctChange(metrics.tickets, prevMetrics.tickets);
|
const c = pctChange(metrics.tickets, prevMetrics.tickets);
|
||||||
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||||
</>}
|
</>}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -336,21 +336,28 @@ export default function ReportForm({ config: cfg, onChange, allMuseums, allChann
|
|||||||
title="Trend Chart"
|
title="Trend Chart"
|
||||||
enabled={cfg.showTrendChart}
|
enabled={cfg.showTrendChart}
|
||||||
onToggle={v => onChange({ showTrendChart: v })}
|
onToggle={v => onChange({ showTrendChart: v })}
|
||||||
badge={cfg.showTrendChart
|
badge={cfg.showTrendChart && cfg.trendMetrics.length
|
||||||
? cfg.trendMetric.charAt(0).toUpperCase() + cfg.trendMetric.slice(1)
|
? cfg.trendMetrics.map(m => m.charAt(0).toUpperCase() + m.slice(1)).join(' · ')
|
||||||
: undefined}
|
: undefined}
|
||||||
>
|
>
|
||||||
{/* H7: PillGroup instead of <select> for full consistency */}
|
<div className="rf-metric-pills" role="group" aria-label="Trend metrics to include">
|
||||||
<PillGroup
|
{(['revenue', 'visitors', 'tickets'] as TrendMetric[]).map(m => {
|
||||||
label="Trend metric"
|
const on = cfg.trendMetrics.includes(m);
|
||||||
options={[
|
return (
|
||||||
{ label: 'Revenue', value: 'revenue' },
|
<button key={m} type="button"
|
||||||
{ label: 'Visitors', value: 'visitors' },
|
className={`rf-metric-pill${on ? ' rf-metric-pill--on' : ''}`}
|
||||||
{ label: 'Tickets', value: 'tickets' },
|
aria-pressed={on}
|
||||||
]}
|
onClick={() => {
|
||||||
value={cfg.trendMetric}
|
const next = on
|
||||||
onChange={v => onChange({ trendMetric: v as TrendMetric })}
|
? cfg.trendMetrics.filter(x => x !== m)
|
||||||
/>
|
: [...cfg.trendMetrics, m];
|
||||||
|
onChange({ trendMetrics: next.length ? next : [m] });
|
||||||
|
}}>
|
||||||
|
{m.charAt(0).toUpperCase() + m.slice(1)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</ModuleCard>
|
</ModuleCard>
|
||||||
|
|
||||||
<div className="rf-divider" />
|
<div className="rf-divider" />
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import type { MuseumRecord, Metrics } from '../../types';
|
|||||||
|
|
||||||
// ─── config ───────────────────────────────────────────────────────
|
// ─── config ───────────────────────────────────────────────────────
|
||||||
export type TrendMetric = 'revenue' | 'visitors' | 'tickets';
|
export type TrendMetric = 'revenue' | 'visitors' | 'tickets';
|
||||||
|
export type TrendGranularity = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
function inferGranularity(start: string, end: string): TrendGranularity {
|
||||||
|
const days = Math.round((new Date(end).getTime() - new Date(start).getTime()) / 86400000);
|
||||||
|
if (days > 180) return 'month';
|
||||||
|
if (days >= 14) return 'week';
|
||||||
|
return 'day';
|
||||||
|
}
|
||||||
|
|
||||||
const _start = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10);
|
const _start = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10);
|
||||||
const _end = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString().slice(0, 10);
|
const _end = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString().slice(0, 10);
|
||||||
@@ -28,7 +36,7 @@ export interface ReportConfig {
|
|||||||
showPilgrimCapture: boolean;
|
showPilgrimCapture: boolean;
|
||||||
// Trend chart
|
// Trend chart
|
||||||
showTrendChart: boolean;
|
showTrendChart: boolean;
|
||||||
trendMetric: TrendMetric;
|
trendMetrics: TrendMetric[];
|
||||||
// Museum mini-reports
|
// Museum mini-reports
|
||||||
showMuseumRevenue: boolean;
|
showMuseumRevenue: boolean;
|
||||||
showMuseumVisitors: boolean;
|
showMuseumVisitors: boolean;
|
||||||
@@ -67,7 +75,7 @@ export const DEFAULT_CONFIG: ReportConfig = {
|
|||||||
showMetricsTable: true,
|
showMetricsTable: true,
|
||||||
showPilgrimCapture: true,
|
showPilgrimCapture: true,
|
||||||
showTrendChart: true,
|
showTrendChart: true,
|
||||||
trendMetric: 'revenue',
|
trendMetrics: ['revenue'],
|
||||||
showMuseumRevenue: true,
|
showMuseumRevenue: true,
|
||||||
showMuseumVisitors: true,
|
showMuseumVisitors: true,
|
||||||
showMuseumTickets: false,
|
showMuseumTickets: false,
|
||||||
@@ -98,15 +106,20 @@ export interface MuseumDataRow {
|
|||||||
prev: { revenue: number; visitors: number; tickets: number } | null;
|
prev: { revenue: number; visitors: number; tickets: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrendChart {
|
||||||
|
metric: TrendMetric;
|
||||||
|
labels: string[];
|
||||||
|
current: number[];
|
||||||
|
previous: number[] | null;
|
||||||
|
museums: Array<{ name: string; values: number[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReportData {
|
export interface ReportData {
|
||||||
config: ReportConfig;
|
config: ReportConfig;
|
||||||
metrics: Metrics;
|
metrics: Metrics;
|
||||||
prevMetrics: Metrics | null;
|
prevMetrics: Metrics | null;
|
||||||
comparisonPeriodLabel: string;
|
comparisonPeriodLabel: string;
|
||||||
trendLabels: string[];
|
trendCharts: TrendChart[];
|
||||||
trendCurrent: number[];
|
|
||||||
trendPrevious: number[] | null;
|
|
||||||
trendMuseums: Array<{ name: string; values: number[] }>;
|
|
||||||
museumData: MuseumDataRow[];
|
museumData: MuseumDataRow[];
|
||||||
museumBreakdown: DimensionBreakdown;
|
museumBreakdown: DimensionBreakdown;
|
||||||
channelBreakdown: DimensionBreakdown;
|
channelBreakdown: DimensionBreakdown;
|
||||||
@@ -148,21 +161,27 @@ function getMetricVal(r: MuseumRecord, metric: TrendMetric, includeVAT: boolean)
|
|||||||
return (includeVAT ? r.revenue_gross : r.revenue_net) || 0;
|
return (includeVAT ? r.revenue_gross : r.revenue_net) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTrend(rows: MuseumRecord[], start: string, cfg: ReportConfig): { labels: string[]; values: number[] } {
|
const MONTH_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
|
||||||
|
function buildTrend(rows: MuseumRecord[], start: string, metric: TrendMetric, includeVAT: boolean, gran: TrendGranularity): { labels: string[]; values: number[] } {
|
||||||
const s = new Date(start);
|
const s = new Date(start);
|
||||||
const acc: Record<number, MuseumRecord[]> = {};
|
const acc: Record<number, MuseumRecord[]> = {};
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
if (!r.date) return;
|
if (!r.date) return;
|
||||||
const diff = Math.floor((new Date(r.date).getTime() - s.getTime()) / 86400000);
|
const diff = Math.floor((new Date(r.date).getTime() - s.getTime()) / 86400000);
|
||||||
const key = Math.floor(diff / 7) + 1;
|
const key = gran === 'month' ? Math.floor(diff / 30) + 1 : gran === 'week' ? Math.floor(diff / 7) + 1 : diff + 1;
|
||||||
if (!acc[key]) acc[key] = [];
|
if (!acc[key]) acc[key] = [];
|
||||||
acc[key].push(r);
|
acc[key].push(r);
|
||||||
});
|
});
|
||||||
const maxK = Math.max(...Object.keys(acc).map(Number), 1);
|
const maxK = Math.max(...Object.keys(acc).map(Number), 1);
|
||||||
const labels = Array.from({ length: maxK }, (_, i) => `W${i + 1}`);
|
const labels = Array.from({ length: maxK }, (_, i) => {
|
||||||
|
if (gran === 'month') return MONTH_SHORT[(s.getMonth() + i) % 12];
|
||||||
|
if (gran === 'week') return `W${i + 1}`;
|
||||||
|
return `${i + 1}`;
|
||||||
|
});
|
||||||
const values = labels.map((_, i) => {
|
const values = labels.map((_, i) => {
|
||||||
const group = acc[i + 1] || [];
|
const group = acc[i + 1] || [];
|
||||||
return group.reduce((s, r) => s + getMetricVal(r, cfg.trendMetric, cfg.includeVAT), 0);
|
return group.reduce((sum, r) => sum + getMetricVal(r, metric, includeVAT), 0);
|
||||||
});
|
});
|
||||||
return { labels, values };
|
return { labels, values };
|
||||||
}
|
}
|
||||||
@@ -187,18 +206,24 @@ export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): R
|
|||||||
? formatPeriodLabel(cfg.comparisonStartDate, cfg.comparisonEndDate, cfg.language)
|
? formatPeriodLabel(cfg.comparisonStartDate, cfg.comparisonEndDate, cfg.language)
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const currTrend = buildTrend(currRows, cfg.startDate, cfg);
|
const gran = inferGranularity(cfg.startDate, cfg.endDate);
|
||||||
const prevTrend = cfg.includeComparison ? buildTrend(prevRows, cfg.comparisonStartDate, cfg) : null;
|
const museumNames = Object.keys(groupByMuseum(currRows, cfg.includeVAT))
|
||||||
const maxLen = Math.max(currTrend.labels.length, prevTrend?.values.length ?? 0);
|
.filter(name => currRows.some(r => r.museum_name === name));
|
||||||
const trendLabels = Array.from({ length: maxLen }, (_, i) => `W${i + 1}`);
|
|
||||||
const trendCurrent = Array.from({ length: maxLen }, (_, i) => currTrend.values[i] ?? 0);
|
|
||||||
const trendPrevious = prevTrend
|
|
||||||
? Array.from({ length: maxLen }, (_, i) => prevTrend.values[i] ?? 0)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const trendMuseums = Object.keys(groupByMuseum(currRows, cfg.includeVAT)).map(name => {
|
const trendCharts: TrendChart[] = cfg.trendMetrics.map(metric => {
|
||||||
const mt = buildTrend(currRows.filter(r => r.museum_name === name), cfg.startDate, cfg);
|
const currT = buildTrend(currRows, cfg.startDate, metric, cfg.includeVAT, gran);
|
||||||
|
const prevT = cfg.includeComparison
|
||||||
|
? buildTrend(prevRows, cfg.comparisonStartDate, metric, cfg.includeVAT, gran)
|
||||||
|
: null;
|
||||||
|
const maxLen = Math.max(currT.labels.length, prevT ? prevT.values.length : 0, 1);
|
||||||
|
const labels = Array.from({ length: maxLen }, (_, i) => currT.labels[i] ?? `${i + 1}`);
|
||||||
|
const current = Array.from({ length: maxLen }, (_, i) => currT.values[i] ?? 0);
|
||||||
|
const previous = prevT ? Array.from({ length: maxLen }, (_, i) => prevT.values[i] ?? 0) : null;
|
||||||
|
const museums = museumNames.map(name => {
|
||||||
|
const mt = buildTrend(currRows.filter(r => r.museum_name === name), cfg.startDate, metric, cfg.includeVAT, gran);
|
||||||
return { name, values: Array.from({ length: maxLen }, (_, i) => mt.values[i] ?? 0) };
|
return { name, values: Array.from({ length: maxLen }, (_, i) => mt.values[i] ?? 0) };
|
||||||
|
}).filter(m => m.values.some(v => v > 0));
|
||||||
|
return { metric, labels, current, previous, museums };
|
||||||
});
|
});
|
||||||
|
|
||||||
const currMuseumGroups = groupByMuseum(currRows, cfg.includeVAT);
|
const currMuseumGroups = groupByMuseum(currRows, cfg.includeVAT);
|
||||||
@@ -229,10 +254,7 @@ export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): R
|
|||||||
metrics,
|
metrics,
|
||||||
prevMetrics,
|
prevMetrics,
|
||||||
comparisonPeriodLabel,
|
comparisonPeriodLabel,
|
||||||
trendLabels,
|
trendCharts,
|
||||||
trendCurrent,
|
|
||||||
trendPrevious,
|
|
||||||
trendMuseums,
|
|
||||||
museumData,
|
museumData,
|
||||||
museumBreakdown,
|
museumBreakdown,
|
||||||
channelBreakdown,
|
channelBreakdown,
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ ChartJS.register(
|
|||||||
Annotation
|
Annotation
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Used for the "Total" line in multi-museum trend charts — always distinct from chartPalette.
|
||||||
|
export const TOTAL_COLOR = '#1e293b';
|
||||||
|
|
||||||
export const chartColors = {
|
export const chartColors = {
|
||||||
primary: '#2563eb',
|
primary: '#2563eb',
|
||||||
secondary: '#7c3aed',
|
secondary: '#7c3aed',
|
||||||
|
|||||||
Reference in New Issue
Block a user