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

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:
soroush.asadi
2026-06-12 01:24:19 +03:30
parent 2a4cf1d20b
commit c47922414a
11 changed files with 1367 additions and 14 deletions
+74 -1
View File
@@ -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": "المصروفات",
+74 -1
View File
@@ -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",
+74 -1
View File
@@ -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>
);
}