feat: اصلاح سند payment corrections + audit-log & daily P&L views
CI/CD / CI · API (dotnet build + test) (push) Successful in 51s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Failing after 1m12s
CI/CD / CI · Admin Web (tsc) (push) Successful in 40s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 51s
CI/CD / Deploy · all services (push) Has been skipped
CI/CD / CI · API (dotnet build + test) (push) Successful in 51s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Failing after 1m12s
CI/CD / CI · Admin Web (tsc) (push) Successful in 40s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 51s
CI/CD / Deploy · all services (push) Has been skipped
Backend:
- POST /orders/{id}/payments/corrections (Manager/Owner): void wrong
payments (marked Refunded, never deleted) and/or record replacements
atomically; mandatory reason; requires an open register shift; full
before/after written to the immutable audit trail.
- GET /orders/closed?date= — closed orders of one Iran-calendar day,
paged, the browsing surface for corrections.
- CalculateExpectedCash now subtracts cash refunds so corrections keep
the drawer expectation honest.
Dashboard (reports screen now has three tabs):
- عملکرد و سود: existing KPIs/charts + new day-by-day breakdown table
(orders, revenue, expenses, net profit per Jalali day).
- اصلاح سند: closed-orders browser with payment chips + correction
dialog (void checkboxes, replacement rows, live balance, reason).
- گزارش عملیات: filterable audit-log viewer (category, Jalali range,
branch) with expandable structured details.
fa/en/ar translations included. 86 backend tests pass; dashboard tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -529,7 +529,80 @@
|
||||
"csvNetIncome": "صافي الدخل",
|
||||
"csvVoids": "الإلغاءات",
|
||||
"csvVoidAmount": "مبلغ الإلغاء",
|
||||
"csvExpenses": "المصروفات"
|
||||
"csvExpenses": "المصروفات",
|
||||
"tabs": {
|
||||
"performance": "الأداء والأرباح",
|
||||
"corrections": "تصحيح المستندات",
|
||||
"auditLog": "سجل العمليات"
|
||||
},
|
||||
"dailyBreakdownTitle": "التفصيل اليومي — المبيعات والمصروفات والأرباح",
|
||||
"colDate": "التاريخ",
|
||||
"colOrders": "الطلبات",
|
||||
"colExpenses": "المصروفات",
|
||||
"colNet": "صافي الربح",
|
||||
"corrections": {
|
||||
"date": "التاريخ",
|
||||
"branch": "الفرع",
|
||||
"allBranches": "كل الفروع",
|
||||
"hint": "ابحث عن الطلب ذي الدفعة الخاطئة واضغط «تصحيح».",
|
||||
"loadFailed": "فشل تحميل الطلبات.",
|
||||
"retry": "إعادة المحاولة",
|
||||
"empty": "لا توجد طلبات مغلقة في هذا اليوم.",
|
||||
"colOrder": "الطلب",
|
||||
"colTime": "الوقت",
|
||||
"colStatus": "الحالة",
|
||||
"colTotal": "المبلغ",
|
||||
"colPayments": "الدفعات",
|
||||
"table": "طاولة",
|
||||
"statusPaid": "مسدّد",
|
||||
"statusCancelled": "ملغى",
|
||||
"correctAction": "تصحيح",
|
||||
"prevPage": "السابق",
|
||||
"nextPage": "التالي",
|
||||
"dialogTitle": "تصحيح مستند الدفع",
|
||||
"orderTotal": "مبلغ الطلب",
|
||||
"voidSection": "الدفعات الخاطئة (اختر للإلغاء)",
|
||||
"replacementSection": "الدفعات البديلة",
|
||||
"addReplacement": "إضافة",
|
||||
"noReplacements": "اتركه فارغاً إذا كنت تُلغي فقط.",
|
||||
"method": "طريقة الدفع",
|
||||
"amount": "المبلغ",
|
||||
"removeReplacement": "حذف الصف",
|
||||
"reason": "سبب التصحيح (إلزامي)",
|
||||
"reasonPlaceholder": "مثلاً: سُجّلت نقداً بالخطأ وكان الدفع بالبطاقة",
|
||||
"paidAfter": "إجمالي المدفوع بعد التصحيح",
|
||||
"shortBy": "أقل من مبلغ الطلب بـ",
|
||||
"overBy": "أكثر من مبلغ الطلب بـ",
|
||||
"cancel": "إلغاء",
|
||||
"submit": "تسجيل التصحيح",
|
||||
"saved": "تم تسجيل التصحيح.",
|
||||
"saveFailed": "فشل تسجيل التصحيح."
|
||||
},
|
||||
"auditLog": {
|
||||
"category": "الفئة",
|
||||
"allCategories": "الكل",
|
||||
"categories": {
|
||||
"Payment": "الدفع",
|
||||
"Order": "الطلب",
|
||||
"Register": "الصندوق",
|
||||
"Staff": "الموظفون"
|
||||
},
|
||||
"fromDate": "من",
|
||||
"toDate": "إلى",
|
||||
"branch": "الفرع",
|
||||
"allBranches": "كل الفروع",
|
||||
"loadFailed": "فشل تحميل سجل العمليات.",
|
||||
"retry": "إعادة المحاولة",
|
||||
"empty": "لا يوجد شيء مسجّل.",
|
||||
"colTime": "الوقت",
|
||||
"colCategory": "الفئة",
|
||||
"colActor": "المستخدم",
|
||||
"colSummary": "الوصف",
|
||||
"details": "التفاصيل",
|
||||
"systemActor": "النظام",
|
||||
"prevPage": "السابق",
|
||||
"nextPage": "التالي"
|
||||
}
|
||||
},
|
||||
"expenses": {
|
||||
"title": "المصروفات",
|
||||
|
||||
@@ -557,7 +557,80 @@
|
||||
"csvNetIncome": "Net income",
|
||||
"csvVoids": "Voids",
|
||||
"csvVoidAmount": "Void amount",
|
||||
"csvExpenses": "Expenses"
|
||||
"csvExpenses": "Expenses",
|
||||
"tabs": {
|
||||
"performance": "Performance & profit",
|
||||
"corrections": "Payment corrections",
|
||||
"auditLog": "Activity log"
|
||||
},
|
||||
"dailyBreakdownTitle": "Daily breakdown — sales, expenses & profit",
|
||||
"colDate": "Date",
|
||||
"colOrders": "Orders",
|
||||
"colExpenses": "Expenses",
|
||||
"colNet": "Net profit",
|
||||
"corrections": {
|
||||
"date": "Date",
|
||||
"branch": "Branch",
|
||||
"allBranches": "All branches",
|
||||
"hint": "Find the order with the wrongly-recorded payment and hit “Correct”.",
|
||||
"loadFailed": "Failed to load orders.",
|
||||
"retry": "Retry",
|
||||
"empty": "No closed orders on this day.",
|
||||
"colOrder": "Order",
|
||||
"colTime": "Time",
|
||||
"colStatus": "Status",
|
||||
"colTotal": "Total",
|
||||
"colPayments": "Payments",
|
||||
"table": "Table",
|
||||
"statusPaid": "Settled",
|
||||
"statusCancelled": "Cancelled",
|
||||
"correctAction": "Correct",
|
||||
"prevPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"dialogTitle": "Payment correction",
|
||||
"orderTotal": "Order total",
|
||||
"voidSection": "Wrong payments (select to void)",
|
||||
"replacementSection": "Replacement payments",
|
||||
"addReplacement": "Add",
|
||||
"noReplacements": "Leave empty if you are only voiding.",
|
||||
"method": "Method",
|
||||
"amount": "Amount",
|
||||
"removeReplacement": "Remove row",
|
||||
"reason": "Reason (required)",
|
||||
"reasonPlaceholder": "e.g. recorded as cash by mistake, was paid by card",
|
||||
"paidAfter": "Paid total after correction",
|
||||
"shortBy": "Short of order total by",
|
||||
"overBy": "Over order total by",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Submit correction",
|
||||
"saved": "Correction recorded.",
|
||||
"saveFailed": "Failed to record correction."
|
||||
},
|
||||
"auditLog": {
|
||||
"category": "Category",
|
||||
"allCategories": "All",
|
||||
"categories": {
|
||||
"Payment": "Payment",
|
||||
"Order": "Order",
|
||||
"Register": "Register",
|
||||
"Staff": "Staff"
|
||||
},
|
||||
"fromDate": "From",
|
||||
"toDate": "To",
|
||||
"branch": "Branch",
|
||||
"allBranches": "All branches",
|
||||
"loadFailed": "Failed to load the activity log.",
|
||||
"retry": "Retry",
|
||||
"empty": "Nothing recorded.",
|
||||
"colTime": "Time",
|
||||
"colCategory": "Category",
|
||||
"colActor": "User",
|
||||
"colSummary": "Summary",
|
||||
"details": "Details",
|
||||
"systemActor": "System",
|
||||
"prevPage": "Previous",
|
||||
"nextPage": "Next"
|
||||
}
|
||||
},
|
||||
"shifts": {
|
||||
"title": "Cash shift",
|
||||
|
||||
@@ -557,7 +557,80 @@
|
||||
"csvNetIncome": "درآمد خالص",
|
||||
"csvVoids": "ابطالها",
|
||||
"csvVoidAmount": "مبلغ ابطال",
|
||||
"csvExpenses": "هزینهها"
|
||||
"csvExpenses": "هزینهها",
|
||||
"tabs": {
|
||||
"performance": "عملکرد و سود",
|
||||
"corrections": "اصلاح سند",
|
||||
"auditLog": "گزارش عملیات"
|
||||
},
|
||||
"dailyBreakdownTitle": "ریز روزانه — فروش، هزینه و سود",
|
||||
"colDate": "تاریخ",
|
||||
"colOrders": "سفارشها",
|
||||
"colExpenses": "هزینهها",
|
||||
"colNet": "سود خالص",
|
||||
"corrections": {
|
||||
"date": "تاریخ",
|
||||
"branch": "شعبه",
|
||||
"allBranches": "همه شعبهها",
|
||||
"hint": "برای اصلاح پرداختِ ثبتشده اشتباه، سفارش را پیدا کنید و «اصلاح سند» را بزنید.",
|
||||
"loadFailed": "بارگذاری سفارشها ناموفق بود.",
|
||||
"retry": "تلاش دوباره",
|
||||
"empty": "در این روز سفارش بستهشدهای نیست.",
|
||||
"colOrder": "سفارش",
|
||||
"colTime": "ساعت",
|
||||
"colStatus": "وضعیت",
|
||||
"colTotal": "مبلغ",
|
||||
"colPayments": "پرداختها",
|
||||
"table": "میز",
|
||||
"statusPaid": "تسویهشده",
|
||||
"statusCancelled": "لغوشده",
|
||||
"correctAction": "اصلاح سند",
|
||||
"prevPage": "قبلی",
|
||||
"nextPage": "بعدی",
|
||||
"dialogTitle": "اصلاح سند پرداخت",
|
||||
"orderTotal": "مبلغ سفارش",
|
||||
"voidSection": "پرداختهای اشتباه (برای ابطال انتخاب کنید)",
|
||||
"replacementSection": "پرداختهای جایگزین",
|
||||
"addReplacement": "افزودن",
|
||||
"noReplacements": "اگر فقط ابطال میکنید، چیزی اضافه نکنید.",
|
||||
"method": "روش پرداخت",
|
||||
"amount": "مبلغ (تومان)",
|
||||
"removeReplacement": "حذف ردیف",
|
||||
"reason": "دلیل اصلاح (الزامی)",
|
||||
"reasonPlaceholder": "مثلاً: بهاشتباه نقد ثبت شده بود، پرداخت با کارت بود",
|
||||
"paidAfter": "جمع پرداختی پس از اصلاح",
|
||||
"shortBy": "کسری نسبت به مبلغ سفارش",
|
||||
"overBy": "مازاد نسبت به مبلغ سفارش",
|
||||
"cancel": "انصراف",
|
||||
"submit": "ثبت اصلاح",
|
||||
"saved": "اصلاح سند ثبت شد.",
|
||||
"saveFailed": "ثبت اصلاح ناموفق بود."
|
||||
},
|
||||
"auditLog": {
|
||||
"category": "دسته",
|
||||
"allCategories": "همه",
|
||||
"categories": {
|
||||
"Payment": "پرداخت",
|
||||
"Order": "سفارش",
|
||||
"Register": "صندوق",
|
||||
"Staff": "کارکنان"
|
||||
},
|
||||
"fromDate": "از تاریخ",
|
||||
"toDate": "تا تاریخ",
|
||||
"branch": "شعبه",
|
||||
"allBranches": "همه شعبهها",
|
||||
"loadFailed": "بارگذاری گزارش عملیات ناموفق بود.",
|
||||
"retry": "تلاش دوباره",
|
||||
"empty": "موردی ثبت نشده است.",
|
||||
"colTime": "زمان",
|
||||
"colCategory": "دسته",
|
||||
"colActor": "کاربر",
|
||||
"colSummary": "شرح",
|
||||
"details": "جزئیات",
|
||||
"systemActor": "سیستم",
|
||||
"prevPage": "قبلی",
|
||||
"nextPage": "بعدی"
|
||||
}
|
||||
},
|
||||
"shifts": {
|
||||
"title": "شیفت صندوق",
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { ChevronDown, Loader2 } from "lucide-react";
|
||||
import { apiGet, apiGetPaged } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { isoTodayTehran } from "@/lib/reports/analytics";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { JalaliDateField } from "@/components/ui/jalali-date-field";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Branch = { id: string; name: string };
|
||||
|
||||
type AuditLogRow = {
|
||||
id: string;
|
||||
category: string;
|
||||
action: string;
|
||||
entityType?: string | null;
|
||||
entityId?: string | null;
|
||||
branchId?: string | null;
|
||||
actorId?: string | null;
|
||||
actorName?: string | null;
|
||||
actorRole?: string | null;
|
||||
summary: string;
|
||||
detailsJson?: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const CATEGORIES = ["", "Payment", "Order", "Register", "Staff"] as const;
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
function isoDaysAgoTehran(days: number): string {
|
||||
const today = isoTodayTehran();
|
||||
const d = new Date(`${today}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() - days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function AuditLogsTab() {
|
||||
const t = useTranslations("reports.auditLog");
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
|
||||
const [category, setCategory] = useState<string>("");
|
||||
const [from, setFrom] = useState<string>(() => isoDaysAgoTehran(7));
|
||||
const [to, setTo] = useState<string>(() => isoTodayTehran());
|
||||
const [branchId, setBranchId] = useState<string>("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const { data: branches = [] } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const queryString = useMemo(() => {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(PAGE_SIZE),
|
||||
// The API filters on UTC timestamps; pass the Iran-day boundaries.
|
||||
from: `${from}T00:00:00+03:30`,
|
||||
to: `${to}T23:59:59+03:30`,
|
||||
});
|
||||
if (category) params.set("category", category);
|
||||
if (branchId) params.set("branchId", branchId);
|
||||
return params.toString();
|
||||
}, [page, from, to, category, branchId]);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["audit-logs", cafeId, queryString],
|
||||
queryFn: () => apiGetPaged<AuditLogRow>(`/api/cafes/${cafeId}/audit-logs?${queryString}`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.meta.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const dateTimeFormatter = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(locale === "en" ? "en-US" : "fa-IR-u-ca-persian", {
|
||||
year: "2-digit",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}),
|
||||
[locale]
|
||||
);
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="rounded-xl border border-border/80 bg-card">
|
||||
<CardContent className="flex flex-wrap items-end gap-4 pt-6">
|
||||
<LabeledField label={t("category")} htmlFor="audit-cat">
|
||||
<select
|
||||
id="audit-cat"
|
||||
className="h-9 min-w-[9rem] rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={category}
|
||||
onChange={(e) => {
|
||||
setCategory(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c === "" ? t("allCategories") : t(`categories.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("fromDate")} htmlFor="audit-from">
|
||||
<JalaliDateField
|
||||
id="audit-from"
|
||||
className="w-40"
|
||||
value={from}
|
||||
onChange={(iso) => {
|
||||
setFrom(iso);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("toDate")} htmlFor="audit-to">
|
||||
<JalaliDateField
|
||||
id="audit-to"
|
||||
className="w-40"
|
||||
value={to}
|
||||
onChange={(iso) => {
|
||||
setTo(iso);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</LabeledField>
|
||||
{branches.length > 1 ? (
|
||||
<LabeledField label={t("branch")} htmlFor="audit-branch">
|
||||
<select
|
||||
id="audit-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);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("allBranches")}</option>
|
||||
{branches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-xl border border-border/80 bg-card">
|
||||
<CardContent className="overflow-x-auto pt-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center text-sm text-muted-foreground">
|
||||
<p>{t("loadFailed")}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<table className="w-full min-w-[42rem] 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("colTime")}</th>
|
||||
<th className="py-2 text-start">{t("colCategory")}</th>
|
||||
<th className="py-2 text-start">{t("colActor")}</th>
|
||||
<th className="py-2 text-start">{t("colSummary")}</th>
|
||||
<th className="py-2 text-end" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const isOpen = expanded === row.id;
|
||||
let details: Record<string, unknown> | null = null;
|
||||
if (isOpen && row.detailsJson) {
|
||||
try {
|
||||
details = JSON.parse(row.detailsJson) as Record<string, unknown>;
|
||||
} catch {
|
||||
details = null;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="whitespace-nowrap py-2.5 tabular-nums text-muted-foreground">
|
||||
{dateTimeFormatter.format(new Date(row.createdAt))}
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium">
|
||||
{(CATEGORIES as readonly string[]).includes(row.category)
|
||||
? t(`categories.${row.category}`)
|
||||
: row.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
{row.actorName ?? row.actorId ?? t("systemActor")}
|
||||
{row.actorRole ? (
|
||||
<span className="ms-1.5 text-xs text-muted-foreground">
|
||||
({row.actorRole})
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="max-w-[22rem] py-2.5">
|
||||
<span className="line-clamp-2">{row.summary}</span>
|
||||
</td>
|
||||
<td className="py-2.5 text-end">
|
||||
{row.detailsJson ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(isOpen ? null : row.id)}
|
||||
className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{t("details")}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 transition-transform",
|
||||
isOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen && details ? (
|
||||
<tr className="border-b border-border/50 bg-muted/40">
|
||||
<td colSpan={5} className="px-3 py-3">
|
||||
<dl className="grid gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
|
||||
{Object.entries(details).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2">
|
||||
<dt className="shrink-0 font-medium text-muted-foreground">
|
||||
{key}:
|
||||
</dt>
|
||||
<dd
|
||||
className="min-w-0 break-all tabular-nums"
|
||||
dir="ltr"
|
||||
>
|
||||
{typeof value === "object"
|
||||
? JSON.stringify(value)
|
||||
: String(value)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<div className="flex items-center justify-center gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
{t("prevPage")}
|
||||
</Button>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{formatNumber(page, numberLocale)} / {formatNumber(totalPages, numberLocale)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{t("nextPage")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { FilePen, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { apiGet, apiGetPaged, apiPost } from "@/lib/api/client";
|
||||
import { notify, notifyError } from "@/lib/notify";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { isoTodayTehran } from "@/lib/reports/analytics";
|
||||
import type { Order, PaymentLine } from "@/lib/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { JalaliDateField } from "@/components/ui/jalali-date-field";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Branch = { id: string; name: string };
|
||||
type Method = "Cash" | "Card" | "Credit";
|
||||
type ReplacementRow = { method: Method; amount: string };
|
||||
|
||||
const METHODS: Method[] = ["Cash", "Card", "Credit"];
|
||||
|
||||
function methodKey(method: string): string {
|
||||
return method.charAt(0).toLowerCase() + method.slice(1);
|
||||
}
|
||||
|
||||
export function PaymentCorrectionsTab() {
|
||||
const t = useTranslations("reports.corrections");
|
||||
const tMethods = useTranslations("reports");
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [date, setDate] = useState<string>(() => isoTodayTehran());
|
||||
const [branchId, setBranchId] = useState<string>("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [target, setTarget] = useState<Order | null>(null);
|
||||
|
||||
const { data: branches = [] } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const queryString = useMemo(() => {
|
||||
const params = new URLSearchParams({ date, page: String(page), pageSize: "30" });
|
||||
if (branchId) params.set("branchId", branchId);
|
||||
return params.toString();
|
||||
}, [date, branchId, page]);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["closed-orders", cafeId, queryString],
|
||||
queryFn: () => apiGetPaged<Order>(`/api/cafes/${cafeId}/orders/closed?${queryString}`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const orders = data?.items ?? [];
|
||||
const total = data?.meta.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / 30));
|
||||
|
||||
const timeFormatter = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(numberLocale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}),
|
||||
[numberLocale]
|
||||
);
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="rounded-xl border border-border/80 bg-card">
|
||||
<CardContent className="flex flex-wrap items-end gap-4 pt-6">
|
||||
<LabeledField label={t("date")} htmlFor="corr-date">
|
||||
<JalaliDateField
|
||||
id="corr-date"
|
||||
className="w-40"
|
||||
value={date}
|
||||
onChange={(iso) => {
|
||||
setDate(iso);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</LabeledField>
|
||||
{branches.length > 1 ? (
|
||||
<LabeledField label={t("branch")} htmlFor="corr-branch">
|
||||
<select
|
||||
id="corr-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);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("allBranches")}</option>
|
||||
{branches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabeledField>
|
||||
) : null}
|
||||
<p className="ms-auto text-xs text-muted-foreground">{t("hint")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-xl border border-border/80 bg-card">
|
||||
<CardContent className="overflow-x-auto pt-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center text-sm text-muted-foreground">
|
||||
<p>{t("loadFailed")}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<table className="w-full min-w-[40rem] 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("colOrder")}</th>
|
||||
<th className="py-2 text-start">{t("colTime")}</th>
|
||||
<th className="py-2 text-start">{t("colStatus")}</th>
|
||||
<th className="py-2 text-end">{t("colTotal")}</th>
|
||||
<th className="py-2 text-start ps-6">{t("colPayments")}</th>
|
||||
<th className="py-2 text-end" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((o) => {
|
||||
const cancelled = o.status === "Cancelled";
|
||||
return (
|
||||
<tr key={o.id} className="border-b border-border/50">
|
||||
<td className="py-2.5 font-medium">
|
||||
#{formatNumber(o.displayNumber, numberLocale)}
|
||||
{o.tableNumber ? (
|
||||
<span className="ms-2 text-xs text-muted-foreground">
|
||||
{t("table")} {o.tableNumber}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="py-2.5 tabular-nums text-muted-foreground">
|
||||
{timeFormatter.format(new Date(o.createdAt))}
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
cancelled
|
||||
? "bg-red-50 text-red-700"
|
||||
: "bg-emerald-50 text-emerald-700"
|
||||
)}
|
||||
>
|
||||
{cancelled ? t("statusCancelled") : t("statusPaid")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 text-end font-medium tabular-nums">
|
||||
{formatCurrency(o.total, numberLocale)}
|
||||
</td>
|
||||
<td className="py-2.5 ps-6">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{o.payments.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
) : (
|
||||
o.payments.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className={cn(
|
||||
"rounded-md border px-1.5 py-0.5 text-xs tabular-nums",
|
||||
p.status === "Refunded"
|
||||
? "border-border/60 text-muted-foreground line-through"
|
||||
: "border-border"
|
||||
)}
|
||||
>
|
||||
{tMethods(methodKey(p.method))} ·{" "}
|
||||
{formatCurrency(p.amount, numberLocale)}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 text-end">
|
||||
{o.payments.some((p) => p.status === "Completed") ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => setTarget(o)}
|
||||
>
|
||||
<FilePen className="size-3.5" />
|
||||
{t("correctAction")}
|
||||
</Button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<div className="flex items-center justify-center gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
{t("prevPage")}
|
||||
</Button>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{formatNumber(page, numberLocale)} / {formatNumber(totalPages, numberLocale)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{t("nextPage")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{target ? (
|
||||
<CorrectionDialog
|
||||
cafeId={cafeId}
|
||||
order={target}
|
||||
numberLocale={numberLocale}
|
||||
onClose={() => setTarget(null)}
|
||||
onDone={() => {
|
||||
setTarget(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["closed-orders", cafeId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["audit-logs", cafeId] });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CorrectionDialog({
|
||||
cafeId,
|
||||
order,
|
||||
numberLocale,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
cafeId: string;
|
||||
order: Order;
|
||||
numberLocale: string;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const t = useTranslations("reports.corrections");
|
||||
const tMethods = useTranslations("reports");
|
||||
|
||||
const [voidIds, setVoidIds] = useState<Set<string>>(new Set());
|
||||
const [replacements, setReplacements] = useState<ReplacementRow[]>([]);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const livePayments = order.payments.filter((p) => p.status === "Completed");
|
||||
|
||||
const toggleVoid = (p: PaymentLine) => {
|
||||
setVoidIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(p.id)) next.delete(p.id);
|
||||
else next.add(p.id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addReplacement = () =>
|
||||
setReplacements((rows) => [...rows, { method: "Cash", amount: "" }]);
|
||||
const removeReplacement = (i: number) =>
|
||||
setReplacements((rows) => rows.filter((_, idx) => idx !== i));
|
||||
const patchReplacement = (i: number, patch: Partial<ReplacementRow>) =>
|
||||
setReplacements((rows) => rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||
|
||||
const keptAmount = livePayments
|
||||
.filter((p) => !voidIds.has(p.id))
|
||||
.reduce((s, p) => s + p.amount, 0);
|
||||
const replacementAmount = replacements.reduce(
|
||||
(s, r) => s + (Number(r.amount) > 0 ? Number(r.amount) : 0),
|
||||
0
|
||||
);
|
||||
const paidAfter = keptAmount + replacementAmount;
|
||||
const delta = paidAfter - order.total;
|
||||
|
||||
const valid =
|
||||
reason.trim().length >= 3 &&
|
||||
(voidIds.size > 0 || replacements.length > 0) &&
|
||||
replacements.every((r) => Number(r.amount) > 0);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost(`/api/cafes/${cafeId}/orders/${order.id}/payments/corrections`, {
|
||||
voidPaymentIds: [...voidIds],
|
||||
replacements: replacements.map((r) => ({
|
||||
method: r.method,
|
||||
amount: Number(r.amount),
|
||||
reference: null,
|
||||
})),
|
||||
reason: reason.trim(),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
notify.success(t("saved"));
|
||||
onDone();
|
||||
},
|
||||
onError: (err) => notifyError(err, t("saveFailed")),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="correction-modal-title"
|
||||
>
|
||||
<Card className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-border/80 bg-card shadow-lg">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<h2 id="correction-modal-title" className="text-base font-semibold">
|
||||
{t("dialogTitle")} — #{formatNumber(order.displayNumber, numberLocale)}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("orderTotal")}: {formatCurrency(order.total, numberLocale)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">
|
||||
{/* Payments to void */}
|
||||
<section>
|
||||
<h3 className="mb-2 text-sm font-medium">{t("voidSection")}</h3>
|
||||
<div className="space-y-1.5">
|
||||
{livePayments.map((p) => (
|
||||
<label
|
||||
key={p.id}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg border border-border px-3 py-2.5 text-sm transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 accent-primary"
|
||||
checked={voidIds.has(p.id)}
|
||||
onChange={() => toggleVoid(p)}
|
||||
/>
|
||||
<span className={cn(voidIds.has(p.id) && "line-through opacity-60")}>
|
||||
{tMethods(methodKey(p.method))} — {formatCurrency(p.amount, numberLocale)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Replacements */}
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">{t("replacementSection")}</h3>
|
||||
<Button variant="outline" size="sm" className="gap-1" onClick={addReplacement}>
|
||||
<Plus className="size-3.5" />
|
||||
{t("addReplacement")}
|
||||
</Button>
|
||||
</div>
|
||||
{replacements.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("noReplacements")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{replacements.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<select
|
||||
aria-label={t("method")}
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={r.method}
|
||||
onChange={(e) => patchReplacement(i, { method: e.target.value as Method })}
|
||||
>
|
||||
{METHODS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{tMethods(methodKey(m))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
placeholder={t("amount")}
|
||||
className="h-9 flex-1 tabular-nums"
|
||||
value={r.amount}
|
||||
onChange={(e) => patchReplacement(i, { amount: e.target.value })}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeReplacement(i)}
|
||||
aria-label={t("removeReplacement")}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Reason */}
|
||||
<section>
|
||||
<LabeledField label={t("reason")} htmlFor="corr-reason">
|
||||
<textarea
|
||||
id="corr-reason"
|
||||
rows={2}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/40"
|
||||
placeholder={t("reasonPlaceholder")}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</LabeledField>
|
||||
</section>
|
||||
|
||||
{/* Balance summary */}
|
||||
<div className="rounded-lg bg-muted/60 px-3 py-2.5 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t("paidAfter")}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatCurrency(paidAfter, numberLocale)}
|
||||
</span>
|
||||
</div>
|
||||
{delta !== 0 ? (
|
||||
<div className="mt-1 flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{delta < 0 ? t("shortBy") : t("overBy")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums font-medium",
|
||||
delta < 0 ? "text-amber-600" : "text-sky-600"
|
||||
)}
|
||||
>
|
||||
{formatCurrency(Math.abs(delta), numberLocale)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<Button variant="ghost" onClick={onClose} disabled={mutation.isPending}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={!valid || mutation.isPending}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{mutation.isPending ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{t("submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,8 @@ 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";
|
||||
import { PaymentCorrectionsTab } from "@/components/reports/payment-corrections-tab";
|
||||
import { AuditLogsTab } from "@/components/reports/audit-logs-tab";
|
||||
|
||||
const LazyReportsCharts = lazy(() =>
|
||||
import("@/components/reports/reports-charts").then((m) => ({
|
||||
@@ -39,6 +41,7 @@ const LazyReportsCharts = lazy(() =>
|
||||
);
|
||||
|
||||
type Branch = { id: string; name: string };
|
||||
type ReportsTab = "performance" | "corrections" | "auditLog";
|
||||
|
||||
const OWNER_ROLES = new Set(["Owner", "Manager"]);
|
||||
const MULTI_BRANCH_PLANS = new Set(["Pro", "Business", "Enterprise"]);
|
||||
@@ -55,8 +58,10 @@ export function ReportsScreen() {
|
||||
const [range, setRange] = useState<ReportRange>(() => buildRangeFromPreset("7d"));
|
||||
const [branchId, setBranchId] = useState<string | null>(null);
|
||||
const [planError, setPlanError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<ReportsTab>("performance");
|
||||
|
||||
const canViewAllBranches = OWNER_ROLES.has(role ?? "");
|
||||
const canManage = OWNER_ROLES.has(role ?? "");
|
||||
|
||||
const { data: branches = [] } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
@@ -155,6 +160,16 @@ export function ReportsScreen() {
|
||||
[branches]
|
||||
);
|
||||
|
||||
const jalaliDayFormatter = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(locale === "en" ? "en-US" : "fa-IR-u-ca-persian", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
}),
|
||||
[locale]
|
||||
);
|
||||
|
||||
const setPreset = (preset: DateRangePreset) => {
|
||||
setRange(buildRangeFromPreset(preset));
|
||||
};
|
||||
@@ -186,24 +201,61 @@ export function ReportsScreen() {
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
const tabs: { id: ReportsTab; label: string }[] = [
|
||||
{ id: "performance", label: t("tabs.performance") },
|
||||
...(canManage
|
||||
? ([
|
||||
{ id: "corrections" as const, label: t("tabs.corrections") },
|
||||
{ id: "auditLog" as const, label: t("tabs.auditLog") },
|
||||
] satisfies { id: ReportsTab; label: string }[])
|
||||
: []),
|
||||
];
|
||||
|
||||
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>
|
||||
tab === "performance" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-[#0F6E56]/40"
|
||||
onClick={handleExportCsv}
|
||||
disabled={snapshots.length === 0}
|
||||
>
|
||||
<Download className="ms-2 h-4 w-4" />
|
||||
{t("exportCsv")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{tabs.length > 1 ? (
|
||||
<div className="flex gap-1 overflow-x-auto rounded-xl border border-border/80 bg-card p-1">
|
||||
{tabs.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTab(id)}
|
||||
className={cn(
|
||||
"min-h-[38px] shrink-0 cursor-pointer rounded-lg px-4 text-sm font-medium transition-colors",
|
||||
tab === id
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "corrections" ? <PaymentCorrectionsTab /> : null}
|
||||
{tab === "auditLog" ? <AuditLogsTab /> : null}
|
||||
|
||||
{tab !== "performance" ? null : (
|
||||
<>
|
||||
<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">
|
||||
@@ -358,6 +410,64 @@ export function ReportsScreen() {
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Day-by-day breakdown — orders, sales, expenses, net profit per day */}
|
||||
<Card className="rounded-xl border border-border/80 bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("dailyBreakdownTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full min-w-[34rem] 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("colDate")}</th>
|
||||
<th className="py-2 text-end">{t("colOrders")}</th>
|
||||
<th className="py-2 text-end">{t("colRevenue")}</th>
|
||||
<th className="py-2 text-end">{t("colExpenses")}</th>
|
||||
<th className="py-2 text-end">{t("colNet")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-4 text-muted-foreground">
|
||||
{t("noData")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
[...displayRows]
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
.map((row) => (
|
||||
<tr key={row.date} className="border-b border-border/50">
|
||||
<td className="whitespace-nowrap py-2.5">
|
||||
{jalaliDayFormatter.format(new Date(`${row.date}T12:00:00Z`))}
|
||||
</td>
|
||||
<td className="py-2.5 text-end tabular-nums">
|
||||
{formatNumber(row.totalOrders, numberLocale)}
|
||||
</td>
|
||||
<td className="py-2.5 text-end tabular-nums">
|
||||
{formatCurrency(row.totalRevenue, numberLocale)}
|
||||
</td>
|
||||
<td className="py-2.5 text-end tabular-nums text-[#BA7517]">
|
||||
{formatCurrency(row.totalExpenses, numberLocale)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"py-2.5 text-end font-medium tabular-nums",
|
||||
row.netIncome >= 0 ? "text-[#0F6E56]" : "text-red-600"
|
||||
)}
|
||||
>
|
||||
{formatCurrency(row.netIncome, numberLocale)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user