diff --git a/src/components/Report/reportHelpers.ts b/src/components/Report/reportHelpers.ts new file mode 100644 index 0000000..31e64dc --- /dev/null +++ b/src/components/Report/reportHelpers.ts @@ -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 = {}; + 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; +}