"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(() => buildRangeFromPreset("7d")); const [branchId, setBranchId] = useState(null); const [planError, setPlanError] = useState(null); const canViewAllBranches = OWNER_ROLES.has(role ?? ""); const { data: branches = [] } = useQuery({ queryKey: ["branches", cafeId], queryFn: () => apiGet(`/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( `/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( `/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 (
{t("exportCsv")} } />
{(["7d", "30d", "90d"] as const).map((preset) => ( ))}
setRange((r) => ({ ...r, from: e.target.value, preset: "custom" })) } /> setRange((r) => ({ ...r, to: e.target.value, preset: "custom" })) } /> {branches.length > 0 ? ( ) : null}
{planError ? (

{planError}

) : null}
{t("topProductsTitle")} {topProducts.length === 0 ? ( ) : ( topProducts.map((p, idx) => ( )) )}
{t("colProduct")} {t("colQuantity")} {t("colRevenue")}
{t("noData")}
{formatNumber(idx + 1, numberLocale)}. {p.name} {formatNumber(p.quantity, numberLocale)} {formatCurrency(p.revenue, numberLocale)}
); } 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 ; } return ( }> ); } 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 (

{title}

{value}

{change !== null ? (

{positive ? ( ) : ( )} {formatNumber(Math.round(Math.abs(change) * 10) / 10, numberLocale)}% {vsLabel}

) : null}
); }