feat(report+charts): report builder improvements and TOTAL_COLOR consistency
Deploy HiHala Dashboard / deploy (push) Successful in 11s

- Add TOTAL_COLOR constant to chartConfig and use it in Dashboard and Comparison for consistent total-line styling
- Overhaul ReportDocument layout, ReportForm UX, and reportHelpers logic
- Add IBM Plex Sans Arabic and Noto Sans Arabic font assets for PDF rendering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-05-03 15:49:09 +03:00
parent 89689c5979
commit 4f51280d1c
13 changed files with 6141 additions and 159 deletions
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
+2 -2
View File
@@ -6,7 +6,7 @@ import {
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
umrahData
} 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 { useLanguage } from '../contexts/LanguageContext';
import type { LC } from '../lib/locale';
@@ -187,7 +187,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
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 },
...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 },
]
}
};
+2 -2
View File
@@ -6,7 +6,7 @@ import {
groupByMuseum, groupByChannel, groupByDistrict,
umrahData,
} 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 { useLanguage } from '../contexts/LanguageContext';
import { EN, AR } from '../lib/locale';
@@ -148,7 +148,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
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] },
...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 },
]
}
};
+118 -100
View File
@@ -1,12 +1,22 @@
import React from 'react';
import {
Document, Page, View, Text, Image, StyleSheet
Document, Page, View, Text, Image, StyleSheet, Font
} from '@react-pdf/renderer';
import { PdfTrendChart, PdfHBarChart, CHART_PALETTE } from './reportCharts';
import {
ReportData, MuseumDataRow, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary
} 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)
// Portrait: 595 - 44 - 44 - 28 = 479
// Landscape: 842 - 44 - 44 - 28 = 726
@@ -17,14 +27,12 @@ const S = StyleSheet.create({
// ── Cover ──────────────────────────────────────────────
coverPage: { flexDirection: 'column', padding: 0 },
// colored header band
coverHeader: { paddingTop: 56, paddingRight: 52, paddingBottom: 52, paddingLeft: 52 },
coverHeaderTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 48 },
coverBrand: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', letterSpacing: 0.8 },
coverLogoBox: { width: 90, height: 44, justifyContent: 'flex-end', alignItems: 'flex-end' },
coverClientLogo: { width: 90, height: 44, objectFit: 'contain' as const },
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' },
coverClientName: { fontSize: 15, color: '#0f172a', fontFamily: 'Helvetica-Bold', marginBottom: 5 },
coverContactName: { fontSize: 11, color: '#64748b', marginBottom: 32 },
@@ -66,8 +74,8 @@ const S = StyleSheet.create({
// ── Trend chart ────────────────────────────────────────
chartWrap: { marginBottom: 8, backgroundColor: '#f8fafc', paddingTop: 14, paddingRight: 14, paddingBottom: 14, paddingLeft: 14, borderRadius: 6, borderWidth: 1, borderColor: '#f1f5f9' },
legendRow: { flexDirection: 'row', marginBottom: 10 },
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18 },
legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 10 },
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18, marginBottom: 4 },
legendDot: { width: 8, height: 8, borderRadius: 4 },
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}.`;
}
interface PageHeaderProps { title: string; page: number; }
function PageHeader({ title, page }: PageHeaderProps) {
interface PageHeaderProps { title: string; page: number; isAr: boolean; arB: any; }
function PageHeader({ title, page, isAr, arB }: PageHeaderProps) {
return (
<View style={S.pageHeader}>
<Text style={S.pageHeaderLogo}>HiHala Data</Text>
<Text style={S.pageHeaderTitle}>{title}</Text>
<Text style={[S.pageHeaderLogo, arB]}>HiHala Data</Text>
<Text style={[S.pageHeaderTitle, isAr ? { fontFamily: 'IBMPlexArabic' } : {}]}>{title}</Text>
<Text style={S.pageHeaderNum}>{page}</Text>
</View>
);
@@ -143,11 +151,11 @@ function PageFooter({ confidentiality, generatedAt }: PageFooterProps) {
);
}
interface SectionProps { title: string; color: string; }
function SectionHeading({ title, color }: SectionProps) {
interface SectionProps { title: string; color: string; arB: any; }
function SectionHeading({ title, color, arB }: SectionProps) {
return (
<View style={[S.sectionHeading, { backgroundColor: color }]}>
<Text>{title}</Text>
<Text style={arB}>{title}</Text>
</View>
);
}
@@ -156,18 +164,24 @@ interface Props { data: ReportData; }
export function ReportDocument({ data }: Props) {
const { config: cfg, metrics, prevMetrics, comparisonPeriodLabel,
trendLabels, trendCurrent, trendPrevious, trendMuseums,
trendCharts,
museumData, channelBreakdown, districtBreakdown,
pilgrimCapture, generatedAt } = data;
const lang = cfg.language;
const isAr = lang === 'ar';
const color = cfg.accentColor;
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
const isLandscape = cfg.orientation === 'landscape';
const orientation = isLandscape ? 'landscape' : 'portrait';
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 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 showChannelPage = cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets;
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">
{/* ── Cover ─────────────────────────────────────────── */}
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage]}>
{/* Colored header band */}
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage, arPageExtra]}>
<View style={[S.coverHeader, { backgroundColor: color }]}>
<View style={S.coverHeaderTop}>
<Text style={S.coverBrand}>HiHala Data</Text>
<Text style={[S.coverBrand, arB]}>HiHala Data</Text>
{cfg.clientLogoBase64 && (
<View style={S.coverLogoBox}>
<Image src={cfg.clientLogoBase64} style={S.coverClientLogo} />
</View>
)}
</View>
<Text style={S.coverTitle}>{cfg.title || T.defaultTitle}</Text>
<Text style={[S.coverTitle, arB]}>{cfg.title || T.defaultTitle}</Text>
</View>
{/* White body */}
<View style={S.coverBody}>
{cfg.clientName && (
<Text style={S.coverClientName}>{T.preparedFor}: {cfg.clientName}</Text>
<Text style={[S.coverClientName, arB]}>{T.preparedFor}: {cfg.clientName}</Text>
)}
{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.coverPeriodRow}>
<View style={[S.coverPeriodDot, { backgroundColor: color }]} />
<Text style={S.coverPeriod}>{period}</Text>
<Text style={[S.coverPeriod, arN]}>{period}</Text>
</View>
<Text style={S.coverDate}>{T.generated}: {generatedAt}</Text>
{cfg.confidentiality !== 'Public' && (
@@ -275,34 +283,34 @@ export function ReportDocument({ data }: Props) {
</Page>
{/* ── Summary + Metrics + Trend ──────────────────────── */}
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
<PageHeader title={cfg.title || T.defaultTitle} page={mainPg} />
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
<PageHeader title={cfg.title || T.defaultTitle} page={mainPg} isAr={isAr} arB={arB} />
{cfg.showExecutiveSummary && (
<View style={S.sectionGap}>
<SectionHeading title={T.execSummary} color={color} />
<Text style={S.summaryText}>{generateExecutiveSummary(data)}</Text>
<SectionHeading title={T.execSummary} color={color} arB={arB} />
<Text style={[S.summaryText, arN]}>{generateExecutiveSummary(data)}</Text>
</View>
)}
{cfg.showMetricsTable && (
<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.metricsHeaderRow}>
<Text style={S.metricsHeaderLabel}> </Text>
<Text style={S.metricsHeaderCell}>{period}</Text>
{prevMetrics && <Text style={S.metricsHeaderCell}>{comparisonPeriodLabel}</Text>}
{prevMetrics && <Text style={S.metricsHeaderCell}>{T.change}</Text>}
<Text style={[S.metricsHeaderLabel, arB]}> </Text>
<Text style={[S.metricsHeaderCell, arB]}>{period}</Text>
{prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
{prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{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>}
<Text style={[S.metricsLabel, arB]}>{row.label}</Text>
<Text style={[S.metricsValue, arN]}>{row.curr}</Text>
{prevMetrics && <Text style={[S.metricsValue, arN]}>{row.prev ?? '—'}</Text>}
{prevMetrics && row.chg !== null && (
<Text style={[S.metricsChange, row.chg >= 0 ? S.metricsChangeUp : S.metricsChangeDown]}>
{row.chg >= 0 ? '+' : '-'}{formatPct(Math.abs(row.chg))}
{formatPct(row.chg)}
</Text>
)}
</View>
@@ -311,31 +319,40 @@ export function ReportDocument({ data }: Props) {
</View>
)}
{cfg.showTrendChart && (
<View style={S.sectionGap}>
<SectionHeading title={trendTitle} color={color} />
{cfg.showTrendChart && trendCharts.map((tc, tci) => {
const trendTitle = tc.metric === 'visitors' ? T.trendVisitors
: 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}>
{trendMuseums.length >= 2 && trendMuseums.map((m, i) => (
{tc.museums.length >= 2 && tc.museums.map((m, i) => (
<View key={m.name} style={S.legendItem}>
<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 style={S.legendItem}>
<View style={[S.legendDot, { backgroundColor: color }]} />
<Text style={S.legendLabel}>{trendMuseums.length >= 2 ? `Total · ${period}` : period}</Text>
<View style={[S.legendDot, { backgroundColor: TOTAL_LINE_COLOR }]} />
<Text style={[S.legendLabel, arN]}>{tc.museums.length >= 2 ? `Total · ${period}` : period}</Text>
</View>
{cfg.includeComparison && (
{cfg.includeComparison && tc.previous && (
<View style={S.legendItem}>
<View style={[S.legendDot, { backgroundColor: '#94a3b8' }]} />
<Text style={S.legendLabel}>{comparisonPeriodLabel}</Text>
<Text style={[S.legendLabel, arN]}>{comparisonPeriodLabel}</Text>
</View>
)}
</View>
<View style={S.chartWrap}>
<PdfTrendChart labels={trendLabels} current={trendCurrent}
previous={trendPrevious} color={color} width={chartW} height={155}
series={trendMuseums.length >= 2 ? trendMuseums.map((m, i) => ({
<PdfTrendChart
labels={tc.labels}
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,
color: CHART_PALETTE[i % CHART_PALETTE.length],
data: m.values,
@@ -343,43 +360,44 @@ export function ReportDocument({ data }: Props) {
/>
</View>
</View>
)}
);
})}
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
</Page>
{/* ── Museum Mini-Reports ────────────────────────────── */}
{showMuseumPage && museumData.length > 0 && (
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
<PageHeader title={cfg.title || T.defaultTitle} page={museumPg} />
<SectionHeading title={T.museumBreakdowns} color={color} />
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
<PageHeader title={cfg.title || T.defaultTitle} page={museumPg} isAr={isAr} arB={arB} />
<SectionHeading title={T.museumBreakdowns} color={color} arB={arB} />
{museumData.map((row, mi) => {
const mRows = museumMetricRows(row);
const hasPrev = row.prev !== null;
return (
<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 && (
<Text style={S.museumIntroText}>
<Text style={[S.museumIntroText, arN]}>
{museumIntro(row, lang, comparisonPeriodLabel)}
</Text>
)}
<View style={S.miniTable}>
<View style={S.miniHeaderRow}>
<Text style={S.miniHeaderLabel}> </Text>
<Text style={S.miniHeaderCell}>{period}</Text>
{hasPrev && <Text style={S.miniHeaderCell}>{comparisonPeriodLabel}</Text>}
{hasPrev && <Text style={S.miniHeaderChangeCell}>{T.change}</Text>}
<Text style={[S.miniHeaderLabel, arB]}> </Text>
<Text style={[S.miniHeaderCell, arB]}>{period}</Text>
{hasPrev && <Text style={[S.miniHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
{hasPrev && <Text style={[S.miniHeaderChangeCell, arB]}>{T.change}</Text>}
</View>
{mRows.map((mr, ri) => (
<View key={mr.label} style={[S.miniRow, ri % 2 === 1 ? S.miniRowAlt : {}]}>
<Text style={S.miniLabel}>{mr.label}</Text>
<Text style={S.miniValue}>{mr.curr}</Text>
{hasPrev && <Text style={S.miniValue}>{mr.prev ?? '—'}</Text>}
<Text style={[S.miniLabel, arB]}>{mr.label}</Text>
<Text style={[S.miniValue, arN]}>{mr.curr}</Text>
{hasPrev && <Text style={[S.miniValue, arN]}>{mr.prev ?? '—'}</Text>}
{hasPrev && mr.chg !== null && (
<Text style={[S.miniChange, mr.chg >= 0 ? S.miniChangeUp : S.miniChangeDown]}>
{mr.chg >= 0 ? '+' : '-'}{formatPct(Math.abs(mr.chg))}
{formatPct(mr.chg)}
</Text>
)}
</View>
@@ -395,12 +413,12 @@ export function ReportDocument({ data }: Props) {
{/* ── Channel Breakdowns ─────────────────────────────── */}
{showChannelPage && (
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
<PageHeader title={cfg.title || T.defaultTitle} page={channelPg} />
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
<PageHeader title={cfg.title || T.defaultTitle} page={channelPg} isAr={isAr} arB={arB} />
{cfg.showChannelRevenue && channelBreakdown.revenue.length > 0 && (
<View style={S.sectionGap}>
<SectionHeading title={T.byChannelRevenue} color={color} />
<SectionHeading title={T.byChannelRevenue} color={color} arB={arB} />
<View style={S.chartWrap}>
<PdfHBarChart items={channelBreakdown.revenue} color={color} usepalette width={chartW} />
</View>
@@ -408,7 +426,7 @@ export function ReportDocument({ data }: Props) {
)}
{cfg.showChannelVisitors && channelBreakdown.visitors.length > 0 && (
<View style={S.sectionGap}>
<SectionHeading title={T.byChannelVisitors} color={color} />
<SectionHeading title={T.byChannelVisitors} color={color} arB={arB} />
<View style={S.chartWrap}>
<PdfHBarChart items={channelBreakdown.visitors} color={color} usepalette width={chartW} />
</View>
@@ -416,7 +434,7 @@ export function ReportDocument({ data }: Props) {
)}
{cfg.showChannelTickets && channelBreakdown.tickets.length > 0 && (
<View style={S.sectionGap}>
<SectionHeading title={T.byChannelTickets} color={color} />
<SectionHeading title={T.byChannelTickets} color={color} arB={arB} />
<View style={S.chartWrap}>
<PdfHBarChart items={channelBreakdown.tickets} color={color} usepalette width={chartW} />
</View>
@@ -429,12 +447,12 @@ export function ReportDocument({ data }: Props) {
{/* ── District Breakdowns ────────────────────────────── */}
{showDistrictPage && (
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
<PageHeader title={cfg.title || T.defaultTitle} page={districtPg} />
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
<PageHeader title={cfg.title || T.defaultTitle} page={districtPg} isAr={isAr} arB={arB} />
{cfg.showDistrictRevenue && districtBreakdown.revenue.length > 0 && (
<View style={S.sectionGap}>
<SectionHeading title={T.byDistrictRevenue} color={color} />
<SectionHeading title={T.byDistrictRevenue} color={color} arB={arB} />
<View style={S.chartWrap}>
<PdfHBarChart items={districtBreakdown.revenue} color={color} usepalette width={chartW} />
</View>
@@ -442,7 +460,7 @@ export function ReportDocument({ data }: Props) {
)}
{cfg.showDistrictVisitors && districtBreakdown.visitors.length > 0 && (
<View style={S.sectionGap}>
<SectionHeading title={T.byDistrictVisitors} color={color} />
<SectionHeading title={T.byDistrictVisitors} color={color} arB={arB} />
<View style={S.chartWrap}>
<PdfHBarChart items={districtBreakdown.visitors} color={color} usepalette width={chartW} />
</View>
@@ -450,7 +468,7 @@ export function ReportDocument({ data }: Props) {
)}
{cfg.showDistrictTickets && districtBreakdown.tickets.length > 0 && (
<View style={S.sectionGap}>
<SectionHeading title={T.byDistrictTickets} color={color} />
<SectionHeading title={T.byDistrictTickets} color={color} arB={arB} />
<View style={S.chartWrap}>
<PdfHBarChart items={districtBreakdown.tickets} color={color} usepalette width={chartW} />
</View>
@@ -463,27 +481,27 @@ export function ReportDocument({ data }: Props) {
{/* ── Global Performance Summary ─────────────────────── */}
{showSummaryPage && museumData.length > 0 && (
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
<PageHeader title={cfg.title || T.defaultTitle} page={summaryPg} />
<SectionHeading title={T.globalSummary} color={color} />
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
<PageHeader title={cfg.title || T.defaultTitle} page={summaryPg} isAr={isAr} arB={arB} />
<SectionHeading title={T.globalSummary} color={color} arB={arB} />
<Text style={S.summarySubLabel}>
<Text style={[S.summarySubLabel, arN]}>
{period} {T.comparedTo} {comparisonPeriodLabel}
</Text>
<View style={S.summaryHeaderRow}>
<Text style={S.summaryHeaderMuseum}>{T.museum}</Text>
<Text style={[S.summaryHeaderMuseum, arB]}>{T.museum}</Text>
{cfg.showMuseumRevenue && <>
<Text style={S.summaryHeaderMetric}>{T.revenue}</Text>
<Text style={S.summaryHeaderDelta}>Δ</Text>
<Text style={[S.summaryHeaderMetric, arB]}>{T.revenue}</Text>
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
</>}
{cfg.showMuseumVisitors && <>
<Text style={S.summaryHeaderMetric}>{T.visitors}</Text>
<Text style={S.summaryHeaderDelta}>Δ</Text>
<Text style={[S.summaryHeaderMetric, arB]}>{T.visitors}</Text>
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
</>}
{cfg.showMuseumTickets && <>
<Text style={S.summaryHeaderMetric}>{T.tickets}</Text>
<Text style={S.summaryHeaderDelta}>Δ</Text>
<Text style={[S.summaryHeaderMetric, arB]}>{T.tickets}</Text>
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
</>}
</View>
@@ -491,26 +509,26 @@ export function ReportDocument({ data }: Props) {
const hasPrev = row.prev !== null;
return (
<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 && <>
<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 ? (() => {
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>}
</>}
{cfg.showMuseumVisitors && <>
<Text style={S.summaryMetric}>{row.curr.visitors.toLocaleString()}</Text>
<Text style={[S.summaryMetric, arN]}>{row.curr.visitors.toLocaleString()}</Text>
{hasPrev && row.prev ? (() => {
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>}
</>}
{cfg.showMuseumTickets && <>
<Text style={S.summaryMetric}>{row.curr.tickets.toLocaleString()}</Text>
<Text style={[S.summaryMetric, arN]}>{row.curr.tickets.toLocaleString()}</Text>
{hasPrev && row.prev ? (() => {
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>}
</>}
</View>
@@ -518,26 +536,26 @@ export function ReportDocument({ data }: Props) {
})}
<View style={S.summaryTotalRow}>
<Text style={S.summaryMuseumTotal}>{T.total}</Text>
<Text style={[S.summaryMuseumTotal, arB]}>{T.total}</Text>
{cfg.showMuseumRevenue && <>
<Text style={S.summaryMetricTotal}>{formatCurrency(metrics.revenue, cfg.includeVAT)}</Text>
<Text style={[S.summaryMetricTotal, arB]}>{formatCurrency(metrics.revenue, cfg.includeVAT)}</Text>
{prevMetrics ? (() => {
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>}
</>}
{cfg.showMuseumVisitors && <>
<Text style={S.summaryMetricTotal}>{metrics.visitors.toLocaleString()}</Text>
<Text style={[S.summaryMetricTotal, arB]}>{metrics.visitors.toLocaleString()}</Text>
{prevMetrics ? (() => {
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>}
</>}
{cfg.showMuseumTickets && <>
<Text style={S.summaryMetricTotal}>{metrics.tickets.toLocaleString()}</Text>
<Text style={[S.summaryMetricTotal, arB]}>{metrics.tickets.toLocaleString()}</Text>
{prevMetrics ? (() => {
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>}
</>}
</View>
+20 -13
View File
@@ -336,21 +336,28 @@ export default function ReportForm({ config: cfg, onChange, allMuseums, allChann
title="Trend Chart"
enabled={cfg.showTrendChart}
onToggle={v => onChange({ showTrendChart: v })}
badge={cfg.showTrendChart
? cfg.trendMetric.charAt(0).toUpperCase() + cfg.trendMetric.slice(1)
badge={cfg.showTrendChart && cfg.trendMetrics.length
? cfg.trendMetrics.map(m => m.charAt(0).toUpperCase() + m.slice(1)).join(' · ')
: undefined}
>
{/* H7: PillGroup instead of <select> for full consistency */}
<PillGroup
label="Trend metric"
options={[
{ label: 'Revenue', value: 'revenue' },
{ label: 'Visitors', value: 'visitors' },
{ label: 'Tickets', value: 'tickets' },
]}
value={cfg.trendMetric}
onChange={v => onChange({ trendMetric: v as TrendMetric })}
/>
<div className="rf-metric-pills" role="group" aria-label="Trend metrics to include">
{(['revenue', 'visitors', 'tickets'] as TrendMetric[]).map(m => {
const on = cfg.trendMetrics.includes(m);
return (
<button key={m} type="button"
className={`rf-metric-pill${on ? ' rf-metric-pill--on' : ''}`}
aria-pressed={on}
onClick={() => {
const next = on
? 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>
<div className="rf-divider" />
+46 -24
View File
@@ -4,6 +4,14 @@ import type { MuseumRecord, Metrics } from '../../types';
// ─── config ───────────────────────────────────────────────────────
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 _end = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString().slice(0, 10);
@@ -28,7 +36,7 @@ export interface ReportConfig {
showPilgrimCapture: boolean;
// Trend chart
showTrendChart: boolean;
trendMetric: TrendMetric;
trendMetrics: TrendMetric[];
// Museum mini-reports
showMuseumRevenue: boolean;
showMuseumVisitors: boolean;
@@ -67,7 +75,7 @@ export const DEFAULT_CONFIG: ReportConfig = {
showMetricsTable: true,
showPilgrimCapture: true,
showTrendChart: true,
trendMetric: 'revenue',
trendMetrics: ['revenue'],
showMuseumRevenue: true,
showMuseumVisitors: true,
showMuseumTickets: false,
@@ -98,15 +106,20 @@ export interface MuseumDataRow {
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 {
config: ReportConfig;
metrics: Metrics;
prevMetrics: Metrics | null;
comparisonPeriodLabel: string;
trendLabels: string[];
trendCurrent: number[];
trendPrevious: number[] | null;
trendMuseums: Array<{ name: string; values: number[] }>;
trendCharts: TrendChart[];
museumData: MuseumDataRow[];
museumBreakdown: DimensionBreakdown;
channelBreakdown: DimensionBreakdown;
@@ -148,21 +161,27 @@ function getMetricVal(r: MuseumRecord, metric: TrendMetric, includeVAT: boolean)
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 acc: Record<number, MuseumRecord[]> = {};
rows.forEach(r => {
if (!r.date) return;
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] = [];
acc[key].push(r);
});
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 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 };
}
@@ -187,18 +206,24 @@ export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): R
? formatPeriodLabel(cfg.comparisonStartDate, cfg.comparisonEndDate, cfg.language)
: '';
const currTrend = buildTrend(currRows, cfg.startDate, cfg);
const prevTrend = cfg.includeComparison ? buildTrend(prevRows, cfg.comparisonStartDate, cfg) : null;
const maxLen = Math.max(currTrend.labels.length, prevTrend?.values.length ?? 0);
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 gran = inferGranularity(cfg.startDate, cfg.endDate);
const museumNames = Object.keys(groupByMuseum(currRows, cfg.includeVAT))
.filter(name => currRows.some(r => r.museum_name === name));
const trendMuseums = Object.keys(groupByMuseum(currRows, cfg.includeVAT)).map(name => {
const mt = buildTrend(currRows.filter(r => r.museum_name === name), cfg.startDate, cfg);
const trendCharts: TrendChart[] = cfg.trendMetrics.map(metric => {
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) };
}).filter(m => m.values.some(v => v > 0));
return { metric, labels, current, previous, museums };
});
const currMuseumGroups = groupByMuseum(currRows, cfg.includeVAT);
@@ -229,10 +254,7 @@ export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): R
metrics,
prevMetrics,
comparisonPeriodLabel,
trendLabels,
trendCurrent,
trendPrevious,
trendMuseums,
trendCharts,
museumData,
museumBreakdown,
channelBreakdown,
+3
View File
@@ -30,6 +30,9 @@ ChartJS.register(
Annotation
);
// Used for the "Total" line in multi-museum trend charts — always distinct from chartPalette.
export const TOTAL_COLOR = '#1e293b';
export const chartColors = {
primary: '#2563eb',
secondary: '#7c3aed',