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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user