feat(report): types, data computation, formatters, executive summary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-04-28 14:33:05 +03:00
parent ab94d33868
commit 65025d7f3c
+232
View File
@@ -0,0 +1,232 @@
import { filterDataByDateRange, calculateMetrics, groupByMuseum, groupByChannel, umrahData } from '../../services/dataService';
import { shiftYear } from '../../lib/dateHelpers';
import type { MuseumRecord, Metrics } from '../../types';
// ─── config ───────────────────────────────────────────────────────
export interface ReportConfig {
title: string;
clientName: string;
contactName: string;
clientLogoBase64: string | null;
accentColor: string;
startDate: string;
endDate: string;
selectedMuseums: string[];
selectedChannels: string[];
includeVAT: boolean;
includeComparison: boolean;
showExecutiveSummary: boolean;
showMetricsTable: boolean;
showTrendChart: boolean;
showMuseumBreakdown: boolean;
showChannelBreakdown: boolean;
showPilgrimCapture: boolean;
language: 'en' | 'ar';
confidentiality: 'Confidential' | 'Internal' | 'Public';
orientation: 'portrait' | 'landscape';
}
export const DEFAULT_CONFIG: ReportConfig = {
title: '',
clientName: '',
contactName: '',
clientLogoBase64: null,
accentColor: '#2563eb',
startDate: new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10),
endDate: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString().slice(0, 10),
selectedMuseums: [],
selectedChannels: [],
includeVAT: true,
includeComparison: true,
showExecutiveSummary: true,
showMetricsTable: true,
showTrendChart: true,
showMuseumBreakdown: true,
showChannelBreakdown: true,
showPilgrimCapture: true,
language: 'en',
confidentiality: 'Confidential',
orientation: 'portrait',
};
// ─── computed report data ─────────────────────────────────────────
export interface BreakdownItem { name: string; value: number; }
export interface ReportData {
config: ReportConfig;
metrics: Metrics;
prevMetrics: Metrics | null;
trendLabels: string[];
trendCurrent: number[];
trendPrevious: number[] | null;
museumBreakdown: BreakdownItem[];
channelBreakdown: BreakdownItem[];
pilgrimCapture: { current: number; previous: number | null } | null;
generatedAt: string;
}
// ─── data computation ─────────────────────────────────────────────
function applyDimFilters(rows: MuseumRecord[], cfg: ReportConfig): MuseumRecord[] {
let d = rows;
if (cfg.selectedMuseums.length) d = d.filter(r => cfg.selectedMuseums.includes(r.museum_name));
if (cfg.selectedChannels.length) d = d.filter(r => cfg.selectedChannels.includes(r.channel));
return d;
}
function estimatePilgrims(start: string, end: string): number | null {
const sd = new Date(start), ed = new Date(end);
let total = 0, has = false;
for (let y = sd.getFullYear(); y <= ed.getFullYear(); y++) {
for (let q = 1; q <= 4; q++) {
const qs = new Date(y, (q - 1) * 3, 1), qe = new Date(y, q * 3, 0);
if (qe < sd || qs > ed) continue;
const p = (umrahData as any)[y]?.[q];
if (!p) continue;
const os = new Date(Math.max(qs.getTime(), sd.getTime()));
const oe = new Date(Math.min(qe.getTime(), ed.getTime()));
total += p * ((oe.getTime() - os.getTime()) / 86400000 + 1) /
((qe.getTime() - qs.getTime()) / 86400000 + 1);
has = true;
}
}
return has ? Math.round(total) : null;
}
function buildTrend(rows: MuseumRecord[], start: string, cfg: ReportConfig): { 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;
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 values = labels.map((_, i) => {
const group = acc[i + 1] || [];
const revenueField = cfg.includeVAT ? 'revenue_gross' : 'revenue_net';
return group.reduce((s, r) => s + parseFloat(String((r as any)[revenueField] || 0)), 0);
});
return { labels, values };
}
export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): ReportData {
const currRows = applyDimFilters(filterDataByDateRange(allData, cfg.startDate, cfg.endDate, {}), cfg);
const metrics = calculateMetrics(currRows, cfg.includeVAT);
const prevStart = shiftYear(cfg.startDate);
const prevEnd = shiftYear(cfg.endDate);
const prevRows = cfg.includeComparison
? applyDimFilters(filterDataByDateRange(allData, prevStart, prevEnd, {}), cfg)
: [];
const prevMetrics = cfg.includeComparison ? calculateMetrics(prevRows, cfg.includeVAT) : null;
const currTrend = buildTrend(currRows, cfg.startDate, cfg);
const prevTrend = cfg.includeComparison ? buildTrend(prevRows, prevStart, 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 musG = groupByMuseum(currRows, cfg.includeVAT);
const museumBreakdown: BreakdownItem[] = Object.entries(musG)
.map(([name, g]) => ({ name, value: g.revenue }))
.sort((a, b) => b.value - a.value)
.slice(0, 10);
const chanG = groupByChannel(currRows, cfg.includeVAT);
const channelBreakdown: BreakdownItem[] = Object.entries(chanG)
.map(([name, g]) => ({ name, value: g.visitors }))
.sort((a, b) => b.value - a.value);
const currPilgrims = estimatePilgrims(cfg.startDate, cfg.endDate);
const prevPilgrims = cfg.includeComparison ? estimatePilgrims(prevStart, prevEnd) : null;
const pilgrimCapture = currPilgrims && metrics.visitors
? {
current: parseFloat(((metrics.visitors / currPilgrims) * 100).toFixed(2)),
previous: prevPilgrims && prevMetrics
? parseFloat(((prevMetrics.visitors / prevPilgrims) * 100).toFixed(2))
: null,
}
: null;
return {
config: cfg,
metrics,
prevMetrics,
trendLabels,
trendCurrent,
trendPrevious,
museumBreakdown,
channelBreakdown,
pilgrimCapture,
generatedAt: new Date().toLocaleDateString('en-GB'),
};
}
// ─── formatters ───────────────────────────────────────────────────
export function formatCurrency(n: number, inclVAT: boolean): string {
return `SAR ${n.toLocaleString('en-SA', { maximumFractionDigits: 0 })}${inclVAT ? '' : ' (ex-VAT)'}`;
}
export function formatPct(change: number): string {
return `${change >= 0 ? '+' : ''}${change.toFixed(1)}%`;
}
export function formatPeriodLabel(start: string, end: string, lang: 'en' | 'ar'): string {
const months = lang === 'en'
? ['January','February','March','April','May','June','July','August','September','October','November','December']
: ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'];
const s = new Date(start), e = new Date(end);
const sm = months[s.getMonth()], em = months[e.getMonth()];
const sy = s.getFullYear(), ey = e.getFullYear();
if (sy === ey && sm === em) return `${sm} ${sy}`;
if (sy === ey) return `${sm} ${em} ${sy}`;
return `${sm} ${sy} ${em} ${ey}`;
}
// ─── executive summary ────────────────────────────────────────────
export function generateExecutiveSummary(data: ReportData): string {
const { config: cfg, metrics, prevMetrics, channelBreakdown } = data;
const lang = cfg.language;
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
const revenue = formatCurrency(metrics.revenue, cfg.includeVAT);
const topChannel = channelBreakdown[0]?.name ?? '';
const totalVisitors = channelBreakdown.reduce((s, i) => s + i.value, 0);
const topPct = totalVisitors > 0 && channelBreakdown[0]
? Math.round((channelBreakdown[0].value / totalVisitors) * 100)
: 0;
const museumLabel = cfg.selectedMuseums.length > 0
? cfg.selectedMuseums.join(', ')
: (lang === 'en' ? 'all museums' : 'جميع المتاحف');
if (lang === 'en') {
let s = `During ${period}, ${museumLabel} recorded ${metrics.visitors.toLocaleString()} visitors and ${revenue} in revenue.`;
if (prevMetrics && prevMetrics.revenue > 0) {
const chg = Math.round(((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue) * 100);
s += ` This represents a ${formatPct(chg)} change in revenue versus the same period last year.`;
}
if (topChannel) s += ` The top-performing channel was ${topChannel} with ${topPct}% of total visitors.`;
return s;
} else {
let s = `خلال ${period}، سجّلت ${museumLabel} ${metrics.visitors.toLocaleString()} زائراً وإيرادات بلغت ${revenue}.`;
if (prevMetrics && prevMetrics.revenue > 0) {
const chg = Math.round(((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue) * 100);
s += ` يمثّل ذلك تغيّراً بنسبة ${formatPct(chg)} في الإيرادات مقارنةً بالفترة ذاتها من العام الماضي.`;
}
if (topChannel) s += ` كانت ${topChannel} أعلى القنوات أداءً بنسبة ${topPct}% من إجمالي الزوار.`;
return s;
}
}
// ─── page count estimator ─────────────────────────────────────────
export function estimatePageCount(cfg: ReportConfig): number {
let pages = 2; // cover + first content page
if (cfg.showMuseumBreakdown) pages += 1;
if (cfg.showChannelBreakdown) pages += 1;
return pages;
}