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