Files
meezi/web/dashboard/src/lib/reports/analytics.ts
T

259 lines
7.4 KiB
TypeScript
Raw Normal View History

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<string, DailyReportSnapshot>();
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<string, TopProductSnapshot>();
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<string, string | number> = {
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<string, string>,
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);
}