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:
soroush.asadi
2026-05-27 21:34:12 +03:30
parent ef15fd6247
commit 131ecdbbe6
208 changed files with 37123 additions and 0 deletions
@@ -0,0 +1,52 @@
"use client";
import { useTranslations } from "next-intl";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
function ChartAreaSkeleton() {
return (
<div className="flex h-full flex-col justify-end gap-2 pt-4">
<div className="flex h-full items-end gap-1">
{[40, 65, 50, 80, 55, 70, 45].map((h, i) => (
<Skeleton key={i} className="flex-1 rounded-t-md" style={{ height: `${h}%` }} />
))}
</div>
<Skeleton className="h-3 w-full" />
</div>
);
}
function ChartPieSkeleton() {
return (
<div className="flex h-full items-center justify-center">
<Skeleton className="h-40 w-40 rounded-full" />
</div>
);
}
export function ReportsChartsFallback() {
const t = useTranslations("reports");
return (
<>
<div className="grid gap-4 lg:grid-cols-3">
<Card className="rounded-xl border border-border/80 bg-card lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">{t("revenueChartTitle")}</CardTitle>
</CardHeader>
<CardContent className="h-72">
<ChartAreaSkeleton />
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 bg-card">
<CardHeader>
<CardTitle className="text-base">{t("paymentMixTitle")}</CardTitle>
</CardHeader>
<CardContent className="h-72">
<ChartPieSkeleton />
</CardContent>
</Card>
</div>
</>
);
}
@@ -0,0 +1,212 @@
"use client";
import { useTranslations } from "next-intl";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
type LegendProps,
} from "recharts";
import { chartColor } from "@/lib/reports/analytics";
import { formatCurrency, formatNumber } from "@/lib/format";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { ReportsChartsProps } from "@/components/reports/reports-charts.types";
export type { ReportsChartPoint, ReportsPieSlice, ReportsChartsProps } from "@/components/reports/reports-charts.types";
export function ReportsCharts({
isLoading,
numberLocale,
chartData,
pieData,
branchCompareData,
showBranchCompare,
branches,
}: ReportsChartsProps) {
const t = useTranslations("reports");
return (
<>
<div className="grid gap-4 lg:grid-cols-3">
<Card className="rounded-xl border border-border/80 bg-card lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">{t("revenueChartTitle")}</CardTitle>
</CardHeader>
<CardContent className="h-72">
{isLoading ? (
<ChartAreaSkeleton />
) : chartData.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("noData")}</p>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="revFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#0F6E56" stopOpacity={0.35} />
<stop offset="95%" stopColor="#0F6E56" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} interval="preserveStartEnd" />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => formatNumber(Number(v), numberLocale)}
width={56}
/>
<Tooltip
formatter={(value: number) => formatCurrency(value, numberLocale)}
labelFormatter={(_, payload) =>
payload?.[0]?.payload?.date
? String(payload[0].payload.date)
: ""
}
/>
<Area
type="monotone"
dataKey="revenue"
name={t("revenue")}
stroke="#0F6E56"
fill="url(#revFill)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 bg-card">
<CardHeader>
<CardTitle className="text-base">{t("paymentMixTitle")}</CardTitle>
</CardHeader>
<CardContent className="h-72">
{isLoading ? (
<ChartPieSkeleton />
) : pieData.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("noData")}</p>
) : (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={48}
outerRadius={80}
paddingAngle={2}
>
{pieData.map((entry) => (
<Cell key={entry.key} fill={entry.fill} />
))}
</Pie>
<Tooltip formatter={(value: number) => formatCurrency(value, numberLocale)} />
<Legend content={<ChartLegend />} />
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
{showBranchCompare ? (
<Card className="rounded-xl border border-border/80 bg-card">
<CardHeader>
<CardTitle className="text-base">{t("branchCompareTitle")}</CardTitle>
</CardHeader>
<CardContent className="h-80">
{isLoading ? (
<ChartBarSkeleton />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={branchCompareData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
<YAxis
tickFormatter={(v) => formatNumber(Number(v), numberLocale)}
width={56}
/>
<Tooltip formatter={(value: number) => formatCurrency(value, numberLocale)} />
<Legend content={<ChartLegend />} />
{branches.map((b, i) => (
<Bar
key={b.id}
dataKey={b.id}
name={b.name}
fill={chartColor(i)}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
) : null}
</>
);
}
function ChartLegend({ payload }: LegendProps) {
if (!payload?.length) return null;
return (
<ul className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 pt-3">
{payload.map((entry, index) => (
<li
key={`legend-${String(entry.value)}-${index}`}
className="flex items-center gap-2 text-xs text-foreground"
>
<span
className="inline-block size-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: entry.color }}
aria-hidden
/>
<span className="leading-none">{entry.value}</span>
</li>
))}
</ul>
);
}
function ChartAreaSkeleton() {
return (
<div className="flex h-full flex-col justify-end gap-2 pt-4">
<div className="flex h-full items-end gap-1">
{[40, 65, 50, 80, 55, 70, 45].map((h, i) => (
<Skeleton key={i} className="flex-1 rounded-t-md" style={{ height: `${h}%` }} />
))}
</div>
<Skeleton className="h-3 w-full" />
</div>
);
}
function ChartPieSkeleton() {
return (
<div className="flex h-full items-center justify-center">
<Skeleton className="h-40 w-40 rounded-full" />
</div>
);
}
function ChartBarSkeleton() {
return (
<div className="flex h-full items-end gap-2 pt-4">
{[55, 70, 45, 80, 60].map((h, i) => (
<Skeleton key={i} className="flex-1 rounded-t-md" style={{ height: `${h}%` }} />
))}
</div>
);
}
@@ -0,0 +1,22 @@
export type ReportsChartPoint = {
date: string;
label: string;
revenue: number;
};
export type ReportsPieSlice = {
key: string;
name: string;
value: number;
fill: string;
};
export type ReportsChartsProps = {
isLoading: boolean;
numberLocale: string;
chartData: ReportsChartPoint[];
pieData: ReportsPieSlice[];
branchCompareData: Array<Record<string, string | number>>;
showBranchCompare: boolean;
branches: { id: string; name: string }[];
};
@@ -0,0 +1,444 @@
"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>
);
}