131ecdbbe6
Complete merchant dashboard upgrade:
Next.js 16 compatibility:
- Fix params/searchParams typed as Promise<{}> throughout App Router
- Replace middleware.ts with proxy.ts (Next.js 16 convention)
- Remove unused @ts-expect-error directives caught by stricter TS
- Cast dynamic next-intl t() keys to fix TranslateArgs type errors
Offline POS:
- IndexedDB queue (meezi_pos_offline) for orders created while offline
- Zustand sync store tracking queueCount, isSyncing, isOnline
- useOfflineSync hook: auto-syncs on reconnect/visibility-change
- SyncStatusIndicator chip in topbar (amber=offline, blue=syncing)
- submitOrderToApi falls back to local order on network failure
- Local orders skip payment flow; sync on reconnect
PWA (installable):
- @ducanh2912/next-pwa with Workbox runtime caching rules
- Web App Manifest (manifest.ts) — RTL/Farsi, theme #0F6E56
- PWA icons: 192px, 512px, maskable 512px
- next.config.ts replaces next.config.mjs
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
259 lines
7.4 KiB
TypeScript
259 lines
7.4 KiB
TypeScript
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);
|
|
}
|