Files
meezi/web/dashboard/src/components/reports/reports-screen.tsx
T
soroush.asadi 131ecdbbe6 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>
2026-05-27 21:34:12 +03:30

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>
);
}