export type TopProductSnapshot = { productId: string; name: string; quantity: number; revenue: number; }; export type DailyReportSnapshot = { id: string; cafeId: string; branchId: string; date: string; totalRevenue: number; cashRevenue: number; cardRevenue: number; creditRevenue: number; totalOrders: number; avgOrderValue: number; totalVoids: number; voidAmount: number; totalExpenses: number; netIncome: number; topProducts: TopProductSnapshot[]; generatedAt: string; }; export type DateRangePreset = "7d" | "30d" | "90d" | "custom"; export type ReportRange = { from: string; to: string; preset: DateRangePreset; }; export function isoTodayTehran(): string { return new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Tehran" }); } export function addDaysIso(iso: string, days: number): string { const d = new Date(`${iso}T12:00:00`); d.setDate(d.getDate() + days); return d.toLocaleDateString("en-CA", { timeZone: "Asia/Tehran" }); } export function daysBetweenInclusive(from: string, to: string): number { const start = new Date(`${from}T12:00:00`).getTime(); const end = new Date(`${to}T12:00:00`).getTime(); return Math.max(1, Math.round((end - start) / 86_400_000) + 1); } export function buildRangeFromPreset(preset: DateRangePreset): ReportRange { const to = isoTodayTehran(); if (preset === "7d") return { from: addDaysIso(to, -6), to, preset }; if (preset === "30d") return { from: addDaysIso(to, -29), to, preset }; if (preset === "90d") return { from: addDaysIso(to, -89), to, preset }; return { from: addDaysIso(to, -6), to, preset: "7d" }; } export function previousPeriod(from: string, to: string): { from: string; to: string } { const len = daysBetweenInclusive(from, to); return { from: addDaysIso(from, -len), to: addDaysIso(from, -1), }; } export function formatJalaliLabel(isoDate: string, locale: string): string { try { return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : locale === "ar" ? "ar-SA" : "en-GB", { calendar: "persian", month: "short", day: "numeric", timeZone: "Asia/Tehran", }).format(new Date(`${isoDate}T12:00:00`)); } catch { return isoDate; } } export function percentChange(current: number, previous: number): number | null { if (previous === 0) return current === 0 ? 0 : 100; return ((current - previous) / previous) * 100; } export type RangeTotals = { totalRevenue: number; totalOrders: number; avgOrderValue: number; netIncome: number; totalExpenses: number; cashRevenue: number; cardRevenue: number; creditRevenue: number; }; export function sumSnapshots(rows: DailyReportSnapshot[]): RangeTotals { const totalOrders = rows.reduce((s, r) => s + r.totalOrders, 0); const totalRevenue = rows.reduce((s, r) => s + r.totalRevenue, 0); return { totalRevenue, totalOrders, avgOrderValue: totalOrders > 0 ? totalRevenue / totalOrders : 0, netIncome: rows.reduce((s, r) => s + r.netIncome, 0), totalExpenses: rows.reduce((s, r) => s + r.totalExpenses, 0), cashRevenue: rows.reduce((s, r) => s + r.cashRevenue, 0), cardRevenue: rows.reduce((s, r) => s + r.cardRevenue, 0), creditRevenue: rows.reduce((s, r) => s + r.creditRevenue, 0), }; } export function aggregateByDate(rows: DailyReportSnapshot[]): DailyReportSnapshot[] { const map = new Map(); for (const r of rows) { const existing = map.get(r.date); if (!existing) { map.set(r.date, { ...r, branchId: "", topProducts: [...r.topProducts] }); continue; } existing.totalRevenue += r.totalRevenue; existing.cashRevenue += r.cashRevenue; existing.cardRevenue += r.cardRevenue; existing.creditRevenue += r.creditRevenue; existing.totalOrders += r.totalOrders; existing.totalVoids += r.totalVoids; existing.voidAmount += r.voidAmount; existing.totalExpenses += r.totalExpenses; existing.netIncome += r.netIncome; existing.totalExpenses += r.totalExpenses; existing.topProducts = mergeTopProducts(existing.topProducts, r.topProducts); } const merged = Array.from(map.values()); for (const m of merged) { m.avgOrderValue = m.totalOrders > 0 ? m.totalRevenue / m.totalOrders : 0; } return merged.sort((a, b) => a.date.localeCompare(b.date)); } export function mergeTopProducts( a: TopProductSnapshot[], b: TopProductSnapshot[] ): TopProductSnapshot[] { const map = new Map(); for (const p of [...a, ...b]) { const cur = map.get(p.productId); if (!cur) { map.set(p.productId, { ...p }); continue; } cur.quantity += p.quantity; cur.revenue += p.revenue; } return Array.from(map.values()).sort((x, y) => y.revenue - x.revenue); } export function topProductsFromRange(rows: DailyReportSnapshot[], take = 10): TopProductSnapshot[] { return mergeTopProducts([], rows.flatMap((r) => r.topProducts)).slice(0, take); } export function revenueChartPoints( rows: DailyReportSnapshot[], locale: string, rtl: boolean ) { const sorted = [...rows].sort((a, b) => a.date.localeCompare(b.date)); const points = sorted.map((r) => ({ date: r.date, label: formatJalaliLabel(r.date, locale), revenue: r.totalRevenue, })); return rtl ? [...points].reverse() : points; } export function branchComparisonPoints( rows: DailyReportSnapshot[], branches: { id: string; name: string }[], locale: string, rtl: boolean ) { const dates = Array.from(new Set(rows.map((r) => r.date))).sort(); const points = dates.map((date) => { const entry: Record = { date, label: formatJalaliLabel(date, locale), }; for (const b of branches) { const row = rows.find((r) => r.date === date && r.branchId === b.id); entry[b.id] = row?.totalRevenue ?? 0; } return entry; }); return rtl ? [...points].reverse() : points; } const CHART_COLORS = ["#0F6E56", "#0C447C", "#BA7517", "#6366f1", "#ec4899", "#14b8a6"]; export function chartColor(index: number): string { return CHART_COLORS[index % CHART_COLORS.length]!; } export function downloadReportsCsv( rows: DailyReportSnapshot[], branchNames: Map, headers: { date: string; branch: string; totalRevenue: string; totalOrders: string; avgOrderValue: string; cashRevenue: string; cardRevenue: string; creditRevenue: string; netIncome: string; totalVoids: string; voidAmount: string; totalExpenses: string; }, filename: string ) { const cols = [ headers.date, headers.branch, headers.totalRevenue, headers.totalOrders, headers.avgOrderValue, headers.cashRevenue, headers.cardRevenue, headers.creditRevenue, headers.netIncome, headers.totalVoids, headers.voidAmount, headers.totalExpenses, ]; const lines = rows.map((r) => [ r.date, branchNames.get(r.branchId) ?? r.branchId, r.totalRevenue, r.totalOrders, r.avgOrderValue, r.cashRevenue, r.cardRevenue, r.creditRevenue, r.netIncome, r.totalVoids, r.voidAmount, r.totalExpenses, ].join(",") ); const bom = "\uFEFF"; const csv = bom + [cols.join(","), ...lines].join("\n"); const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }