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>
445 lines
15 KiB
TypeScript
445 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useLocale, useTranslations } from "next-intl";
|
|
import { Download, TrendingDown, TrendingUp } from "lucide-react";
|
|
import { apiGet, ApiClientError } from "@/lib/api/client";
|
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
|
import { formatCurrency, formatNumber } from "@/lib/format";
|
|
import {
|
|
aggregateByDate,
|
|
branchComparisonPoints,
|
|
buildRangeFromPreset,
|
|
downloadReportsCsv,
|
|
isoTodayTehran,
|
|
percentChange,
|
|
previousPeriod,
|
|
revenueChartPoints,
|
|
sumSnapshots,
|
|
topProductsFromRange,
|
|
type DailyReportSnapshot,
|
|
type DateRangePreset,
|
|
type ReportRange,
|
|
} from "@/lib/reports/analytics";
|
|
import { PageHeader } from "@/components/layout/page-header";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { LabeledField } from "@/components/ui/labeled-field";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { cn } from "@/lib/utils";
|
|
import { ReportsChartsFallback } from "@/components/reports/reports-charts-fallback";
|
|
import type { ReportsChartsProps } from "@/components/reports/reports-charts.types";
|
|
|
|
const LazyReportsCharts = lazy(() =>
|
|
import("@/components/reports/reports-charts").then((m) => ({
|
|
default: m.ReportsCharts,
|
|
}))
|
|
);
|
|
|
|
type Branch = { id: string; name: string };
|
|
|
|
const OWNER_ROLES = new Set(["Owner", "Manager"]);
|
|
const MULTI_BRANCH_PLANS = new Set(["Pro", "Business", "Enterprise"]);
|
|
|
|
export function ReportsScreen() {
|
|
const t = useTranslations("reports");
|
|
const locale = useLocale();
|
|
const rtl = locale === "fa" || locale === "ar";
|
|
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
|
const role = useAuthStore((s) => s.user?.role);
|
|
const planTier = useAuthStore((s) => s.user?.planTier ?? "Free");
|
|
|
|
const [range, setRange] = useState<ReportRange>(() => buildRangeFromPreset("7d"));
|
|
const [branchId, setBranchId] = useState<string | null>(null);
|
|
const [planError, setPlanError] = useState<string | null>(null);
|
|
|
|
const canViewAllBranches = OWNER_ROLES.has(role ?? "");
|
|
|
|
const { data: branches = [] } = useQuery({
|
|
queryKey: ["branches", cafeId],
|
|
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
|
|
enabled: !!cafeId,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!canViewAllBranches && branchId === null && branches.length > 0) {
|
|
setBranchId(branches[0]!.id);
|
|
}
|
|
}, [canViewAllBranches, branchId, branches]);
|
|
|
|
const branchQuery = branchId ? `&branchId=${encodeURIComponent(branchId)}` : "";
|
|
const rangeKey = `${range.from}_${range.to}_${branchId ?? "all"}`;
|
|
|
|
const { data: snapshots = [], isLoading, isError, error } = useQuery({
|
|
queryKey: ["reports-daily-range", cafeId, rangeKey],
|
|
queryFn: async () => {
|
|
setPlanError(null);
|
|
return apiGet<DailyReportSnapshot[]>(
|
|
`/api/cafes/${cafeId}/reports/daily/range?from=${range.from}&to=${range.to}${branchQuery}`
|
|
);
|
|
},
|
|
enabled: !!cafeId && !!range.from && !!range.to,
|
|
retry: false,
|
|
});
|
|
|
|
const prev = useMemo(
|
|
() => previousPeriod(range.from, range.to),
|
|
[range.from, range.to]
|
|
);
|
|
|
|
const { data: prevSnapshots = [] } = useQuery({
|
|
queryKey: ["reports-daily-range-prev", cafeId, prev.from, prev.to, branchId],
|
|
queryFn: () =>
|
|
apiGet<DailyReportSnapshot[]>(
|
|
`/api/cafes/${cafeId}/reports/daily/range?from=${prev.from}&to=${prev.to}${branchQuery}`
|
|
),
|
|
enabled: !!cafeId,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (isError && error instanceof ApiClientError && error.code === "PLAN_LIMIT_REACHED") {
|
|
setPlanError(error.message);
|
|
} else if (!isError) {
|
|
setPlanError(null);
|
|
}
|
|
}, [isError, error]);
|
|
|
|
const displayRows = useMemo(() => {
|
|
if (branchId) return [...snapshots].sort((a, b) => a.date.localeCompare(b.date));
|
|
return aggregateByDate(snapshots);
|
|
}, [snapshots, branchId]);
|
|
|
|
const prevRows = useMemo(() => {
|
|
if (branchId) return prevSnapshots;
|
|
return aggregateByDate(prevSnapshots);
|
|
}, [prevSnapshots, branchId]);
|
|
|
|
const totals = useMemo(() => sumSnapshots(displayRows), [displayRows]);
|
|
const prevTotals = useMemo(() => sumSnapshots(prevRows), [prevRows]);
|
|
|
|
const chartData = useMemo(
|
|
() => revenueChartPoints(displayRows, locale, rtl),
|
|
[displayRows, locale, rtl]
|
|
);
|
|
|
|
const pieData = useMemo(
|
|
() => [
|
|
{ key: "cash", name: t("cash"), value: totals.cashRevenue, fill: "#0F6E56" },
|
|
{ key: "card", name: t("card"), value: totals.cardRevenue, fill: "#0C447C" },
|
|
{ key: "credit", name: t("credit"), value: totals.creditRevenue, fill: "#BA7517" },
|
|
].filter((d) => d.value > 0),
|
|
[totals, t]
|
|
);
|
|
|
|
const topProducts = useMemo(
|
|
() => topProductsFromRange(displayRows, 10),
|
|
[displayRows]
|
|
);
|
|
|
|
const showBranchCompare =
|
|
!branchId &&
|
|
branches.length > 1 &&
|
|
OWNER_ROLES.has(role ?? "") &&
|
|
MULTI_BRANCH_PLANS.has(planTier);
|
|
|
|
const branchCompareData = useMemo(() => {
|
|
if (!showBranchCompare) return [];
|
|
return branchComparisonPoints(snapshots, branches, locale, rtl);
|
|
}, [showBranchCompare, snapshots, branches, locale, rtl]);
|
|
|
|
const branchNameMap = useMemo(
|
|
() => new Map(branches.map((b) => [b.id, b.name])),
|
|
[branches]
|
|
);
|
|
|
|
const setPreset = (preset: DateRangePreset) => {
|
|
setRange(buildRangeFromPreset(preset));
|
|
};
|
|
|
|
const handleExportCsv = () => {
|
|
const sorted = [...snapshots].sort(
|
|
(a, b) => a.date.localeCompare(b.date) || a.branchId.localeCompare(b.branchId)
|
|
);
|
|
downloadReportsCsv(
|
|
sorted,
|
|
branchNameMap,
|
|
{
|
|
date: t("csvDate"),
|
|
branch: t("csvBranch"),
|
|
totalRevenue: t("csvTotalRevenue"),
|
|
totalOrders: t("csvTotalOrders"),
|
|
avgOrderValue: t("csvAvgOrder"),
|
|
cashRevenue: t("csvCash"),
|
|
cardRevenue: t("csvCard"),
|
|
creditRevenue: t("csvCredit"),
|
|
netIncome: t("csvNetIncome"),
|
|
totalVoids: t("csvVoids"),
|
|
voidAmount: t("csvVoidAmount"),
|
|
totalExpenses: t("csvExpenses"),
|
|
},
|
|
`meezi-reports-${range.from}_${range.to}.csv`
|
|
);
|
|
};
|
|
|
|
if (!cafeId) return null;
|
|
|
|
return (
|
|
<div className="space-y-6 bg-[#f5f5f4] min-h-full -m-4 p-4 md:-m-6 md:p-6">
|
|
<PageHeader
|
|
title={t("title")}
|
|
subtitle={t("subtitle")}
|
|
action={
|
|
<Button
|
|
variant="outline"
|
|
className="border-[#0F6E56]/40"
|
|
onClick={handleExportCsv}
|
|
disabled={snapshots.length === 0}
|
|
>
|
|
<Download className="ms-2 h-4 w-4" />
|
|
{t("exportCsv")}
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<Card className="rounded-xl border border-border/80 bg-card">
|
|
<CardContent className="flex flex-wrap items-end gap-4 pt-6">
|
|
<div className="flex flex-wrap gap-2">
|
|
{(["7d", "30d", "90d"] as const).map((preset) => (
|
|
<Button
|
|
key={preset}
|
|
size="sm"
|
|
variant={range.preset === preset ? "default" : "outline"}
|
|
className={cn(
|
|
range.preset === preset && "bg-[#0F6E56] hover:bg-[#0d5e49]"
|
|
)}
|
|
onClick={() => setPreset(preset)}
|
|
>
|
|
{t(`preset.${preset}`)}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
<LabeledField label={t("fromDate")} htmlFor="report-from">
|
|
<Input
|
|
id="report-from"
|
|
type="date"
|
|
dir="ltr"
|
|
className="w-40 text-end"
|
|
value={range.from}
|
|
max={range.to}
|
|
onChange={(e) =>
|
|
setRange((r) => ({ ...r, from: e.target.value, preset: "custom" }))
|
|
}
|
|
/>
|
|
</LabeledField>
|
|
<LabeledField label={t("toDate")} htmlFor="report-to">
|
|
<Input
|
|
id="report-to"
|
|
type="date"
|
|
dir="ltr"
|
|
className="w-40 text-end"
|
|
value={range.to}
|
|
min={range.from}
|
|
max={isoTodayTehran()}
|
|
onChange={(e) =>
|
|
setRange((r) => ({ ...r, to: e.target.value, preset: "custom" }))
|
|
}
|
|
/>
|
|
</LabeledField>
|
|
|
|
{branches.length > 0 ? (
|
|
<LabeledField label={t("branch")} htmlFor="report-branch">
|
|
<select
|
|
id="report-branch"
|
|
className="h-9 min-w-[10rem] rounded-md border border-input bg-background px-3 text-sm"
|
|
value={branchId ?? ""}
|
|
onChange={(e) => setBranchId(e.target.value || null)}
|
|
>
|
|
{canViewAllBranches ? (
|
|
<option value="">{t("allBranches")}</option>
|
|
) : null}
|
|
{branches.map((b) => (
|
|
<option key={b.id} value={b.id}>
|
|
{b.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</LabeledField>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{planError ? (
|
|
<p className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-[#BA7517]">
|
|
{planError}
|
|
</p>
|
|
) : null}
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
|
<KpiCard
|
|
title={t("kpiTotalRevenue")}
|
|
value={isLoading ? "…" : formatCurrency(totals.totalRevenue, numberLocale)}
|
|
change={percentChange(totals.totalRevenue, prevTotals.totalRevenue)}
|
|
vsLabel={t("vsPrevious")}
|
|
numberLocale={numberLocale}
|
|
/>
|
|
<KpiCard
|
|
title={t("kpiTotalOrders")}
|
|
value={isLoading ? "…" : formatNumber(totals.totalOrders, numberLocale)}
|
|
change={percentChange(totals.totalOrders, prevTotals.totalOrders)}
|
|
vsLabel={t("vsPrevious")}
|
|
numberLocale={numberLocale}
|
|
/>
|
|
<KpiCard
|
|
title={t("kpiAvgOrder")}
|
|
value={isLoading ? "…" : formatCurrency(totals.avgOrderValue, numberLocale)}
|
|
change={percentChange(totals.avgOrderValue, prevTotals.avgOrderValue)}
|
|
vsLabel={t("vsPrevious")}
|
|
numberLocale={numberLocale}
|
|
/>
|
|
<KpiCard
|
|
title={t("kpiNetIncome")}
|
|
value={isLoading ? "…" : formatCurrency(totals.netIncome, numberLocale)}
|
|
change={percentChange(totals.netIncome, prevTotals.netIncome)}
|
|
vsLabel={t("vsPrevious")}
|
|
numberLocale={numberLocale}
|
|
/>
|
|
<KpiCard
|
|
title={t("kpiTotalExpenses")}
|
|
value={isLoading ? "…" : formatCurrency(totals.totalExpenses, numberLocale)}
|
|
change={percentChange(totals.totalExpenses, prevTotals.totalExpenses)}
|
|
vsLabel={t("vsPrevious")}
|
|
numberLocale={numberLocale}
|
|
valueClassName="text-[#BA7517]"
|
|
/>
|
|
</div>
|
|
|
|
<DeferredReportsCharts
|
|
isLoading={isLoading}
|
|
numberLocale={numberLocale}
|
|
chartData={chartData}
|
|
pieData={pieData}
|
|
branchCompareData={branchCompareData}
|
|
showBranchCompare={showBranchCompare}
|
|
branches={branches}
|
|
/>
|
|
|
|
<Card className="rounded-xl border border-border/80 bg-card">
|
|
<CardHeader>
|
|
<CardTitle className="text-base">{t("topProductsTitle")}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="overflow-x-auto">
|
|
<table className="w-full min-w-[24rem] text-sm">
|
|
<thead>
|
|
<tr className="border-b text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
|
<th className="py-2 text-start">{t("colProduct")}</th>
|
|
<th className="py-2 text-end">{t("colQuantity")}</th>
|
|
<th className="py-2 text-end">{t("colRevenue")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{topProducts.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={3} className="py-4 text-muted-foreground">
|
|
{t("noData")}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
topProducts.map((p, idx) => (
|
|
<tr key={p.productId} className="border-b border-border/50">
|
|
<td className="py-2.5">
|
|
<span className="me-2 text-muted-foreground">
|
|
{formatNumber(idx + 1, numberLocale)}.
|
|
</span>
|
|
{p.name}
|
|
</td>
|
|
<td className="py-2.5 text-end tabular-nums">
|
|
{formatNumber(p.quantity, numberLocale)}
|
|
</td>
|
|
<td className="py-2.5 text-end font-medium text-[#0F6E56] tabular-nums">
|
|
{formatCurrency(p.revenue, numberLocale)}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DeferredReportsCharts(props: ReportsChartsProps) {
|
|
const [ready, setReady] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const id =
|
|
typeof requestIdleCallback !== "undefined"
|
|
? requestIdleCallback(() => setReady(true))
|
|
: window.setTimeout(() => setReady(true), 0);
|
|
return () => {
|
|
if (typeof requestIdleCallback !== "undefined" && typeof id === "number") {
|
|
cancelIdleCallback(id);
|
|
} else {
|
|
clearTimeout(id as number);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
if (!ready) {
|
|
return <ReportsChartsFallback />;
|
|
}
|
|
|
|
return (
|
|
<Suspense fallback={<ReportsChartsFallback />}>
|
|
<LazyReportsCharts {...props} />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function KpiCard({
|
|
title,
|
|
value,
|
|
change,
|
|
vsLabel,
|
|
numberLocale,
|
|
valueClassName,
|
|
}: {
|
|
title: string;
|
|
value: string;
|
|
change: number | null;
|
|
vsLabel: string;
|
|
numberLocale: string;
|
|
valueClassName?: string;
|
|
}) {
|
|
const positive = change !== null && change >= 0;
|
|
return (
|
|
<Card className="rounded-xl border border-border/80 bg-card">
|
|
<CardContent className="space-y-1 pt-5">
|
|
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
|
{title}
|
|
</p>
|
|
<p className={cn("text-xl font-semibold text-foreground", valueClassName)}>{value}</p>
|
|
{change !== null ? (
|
|
<p
|
|
className={cn(
|
|
"flex items-center gap-1 text-xs",
|
|
positive ? "text-[#0F6E56]" : "text-[#A32D2D]"
|
|
)}
|
|
>
|
|
{positive ? (
|
|
<TrendingUp className="h-3.5 w-3.5" />
|
|
) : (
|
|
<TrendingDown className="h-3.5 w-3.5" />
|
|
)}
|
|
{formatNumber(Math.round(Math.abs(change) * 10) / 10, numberLocale)}% {vsLabel}
|
|
</p>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|