feat(dashboard): Next.js 16 merchant panel with offline POS and PWA
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>
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user