feat(report): types, data computation, formatters, executive summary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user