diff --git a/src/Meezi.API/Controllers/OrdersController.cs b/src/Meezi.API/Controllers/OrdersController.cs index a8e8ef2..d1994ce 100644 --- a/src/Meezi.API/Controllers/OrdersController.cs +++ b/src/Meezi.API/Controllers/OrdersController.cs @@ -20,6 +20,7 @@ public class OrdersController : CafeApiControllerBase private readonly IValidator _paymentsValidator; private readonly IValidator _appendValidator; private readonly IValidator _sessionValidator; + private readonly IValidator _correctionValidator; public OrdersController( IOrderService orderService, @@ -28,7 +29,8 @@ public class OrdersController : CafeApiControllerBase IValidator statusValidator, IValidator paymentsValidator, IValidator appendValidator, - IValidator sessionValidator) + IValidator sessionValidator, + IValidator correctionValidator) { _orderService = orderService; _audit = audit; @@ -37,6 +39,7 @@ public class OrdersController : CafeApiControllerBase _paymentsValidator = paymentsValidator; _appendValidator = appendValidator; _sessionValidator = sessionValidator; + _correctionValidator = correctionValidator; } [HttpGet] @@ -63,6 +66,35 @@ public class OrdersController : CafeApiControllerBase return Ok(new ApiResponse>(true, data)); } + /// Closed orders (delivered/cancelled) of one Iran-calendar day — the + /// browsing surface for اصلاح سند payment corrections. + [HttpGet("closed")] + public async Task GetClosedOrders( + string cafeId, + ITenantContext tenant, + CancellationToken cancellationToken, + [FromQuery] string? date = null, + [FromQuery] string? branchId = null, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 30) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsureBranchAccess(branchId, tenant) is { } branchDenied) return branchDenied; + + DateOnly day; + if (string.IsNullOrWhiteSpace(date)) day = IranCalendar.TodayInIran; + else if (!DateOnly.TryParse(date, out day)) + return BadRequest(new ApiResponse( + false, null, new ApiError("VALIDATION_ERROR", "Invalid date (expected YYYY-MM-DD).", "date"))); + + if (page < 1) page = 1; + if (pageSize is < 1 or > 100) pageSize = 30; + + var (items, total) = await _orderService.GetClosedOrdersAsync( + cafeId, day, branchId, page, pageSize, cancellationToken); + return Ok(new PagedApiResponse(true, items, new PagedMeta(total, page, pageSize))); + } + [HttpGet("live")] public async Task GetLiveOrders(string cafeId, ITenantContext tenant, CancellationToken cancellationToken) { @@ -273,6 +305,56 @@ public class OrdersController : CafeApiControllerBase return Ok(new ApiResponse>(true, result.Data)); } + /// + /// اصلاح سند — void wrongly-recorded payments and/or record replacements on a + /// closed order, atomically, with a mandatory reason. Manager/Owner only; + /// the full before/after is written to the immutable audit trail. + /// + [HttpPost("{id}/payments/corrections")] + public async Task CorrectPayments( + string cafeId, + string id, + [FromBody] CorrectPaymentsRequest request, + ITenantContext tenant, + CancellationToken cancellationToken) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsureManager(tenant) is { } forbidden) return forbidden; + var validation = await _correctionValidator.ValidateAsync(request, cancellationToken); + if (!validation.IsValid) return BadRequest(ValidationError(validation)); + + // Snapshot the payments before the change so the audit row carries a + // complete before/after picture even after later corrections. + var before = await _orderService.GetOrderAsync(cafeId, id, cancellationToken); + + var result = await _orderService.CorrectPaymentsAsync( + cafeId, id, request, tenant.UserId, cancellationToken); + if (!result.Success) return OrderError(result.ErrorCode!, result.Field); + + await _audit.LogAsync(new AuditEntry + { + Category = "Payment", + Action = "PaymentCorrected", + EntityType = "Order", + EntityId = id, + Summary = $"اصلاح سند: voided {request.VoidPaymentIds.Count} payment(s), " + + $"recorded {request.Replacements.Count} replacement(s) — {request.Reason}", + Details = new + { + orderId = id, + displayNumber = result.Data!.DisplayNumber, + reason = request.Reason, + voidedPaymentIds = request.VoidPaymentIds, + paymentsBefore = before?.Payments.Select(p => new { p.Id, p.Method, p.Amount, p.Status }), + paymentsAfter = result.Data.Payments.Select(p => new { p.Id, p.Method, p.Amount, p.Status }), + paidAmountAfter = result.Data.PaidAmount, + orderTotal = result.Data.Total + } + }, cancellationToken); + + return Ok(new ApiResponse(true, result.Data)); + } + private IActionResult OrderError(string code, string? field = null) => code switch { @@ -300,6 +382,10 @@ public class OrdersController : CafeApiControllerBase false, null, new ApiError(code, "Table is being cleaned.", field))), "NO_OPEN_SHIFT" => BadRequest(new ApiResponse( false, null, new ApiError(code, "Open the cash register shift before taking payment.", field))), + "PAYMENT_NOT_FOUND" => NotFound(new ApiResponse( + false, null, new ApiError(code, "Payment not found on this order.", field))), + "PAYMENT_ALREADY_REFUNDED" => BadRequest(new ApiResponse( + false, null, new ApiError(code, "Payment is already refunded.", field))), _ => BadRequest(new ApiResponse( false, null, new ApiError(code, "Invalid order request.", field))) }; diff --git a/src/Meezi.API/Models/Orders/OrderDtos.cs b/src/Meezi.API/Models/Orders/OrderDtos.cs index 5459b97..2422f5f 100644 --- a/src/Meezi.API/Models/Orders/OrderDtos.cs +++ b/src/Meezi.API/Models/Orders/OrderDtos.cs @@ -70,6 +70,17 @@ public record RecordPaymentsRequest( IReadOnlyList Payments, int? LoyaltyPointsToRedeem = null); +/// +/// اصلاح سند — amend the payments of an order after the fact (wrong method, +/// wrong amount, or payment recorded on the wrong order). Voids the listed +/// payments (marked Refunded, never deleted) and records the replacements in +/// one atomic operation. A reason is mandatory; the whole change is audit-logged. +/// +public record CorrectPaymentsRequest( + IReadOnlyList VoidPaymentIds, + IReadOnlyList Replacements, + string Reason); + public record PaymentDto(string Id, PaymentMethod Method, decimal Amount, PaymentStatus Status, string? Reference); public record LiveOrderDto( diff --git a/src/Meezi.API/Services/OrderService.cs b/src/Meezi.API/Services/OrderService.cs index 4f2c71c..601ab44 100644 --- a/src/Meezi.API/Services/OrderService.cs +++ b/src/Meezi.API/Services/OrderService.cs @@ -67,6 +67,19 @@ public interface IOrderService RecordPaymentsRequest request, string? userId, CancellationToken cancellationToken = default); + Task<(IReadOnlyList Items, int Total)> GetClosedOrdersAsync( + string cafeId, + DateOnly date, + string? branchId, + int page, + int pageSize, + CancellationToken cancellationToken = default); + Task> CorrectPaymentsAsync( + string cafeId, + string orderId, + CorrectPaymentsRequest request, + string? userId, + CancellationToken cancellationToken = default); } public class OrderService : IOrderService @@ -1119,6 +1132,117 @@ public class OrderService : IOrderService return new OrderServiceResult>(true, dtos); } + public async Task<(IReadOnlyList Items, int Total)> GetClosedOrdersAsync( + string cafeId, + DateOnly date, + string? branchId, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + var (utcStart, utcEnd) = IranCalendar.GetUtcRangeForIranDay(date); + + var query = _db.Orders + .Where(o => o.CafeId == cafeId + && (o.Status == OrderStatus.Delivered || o.Status == OrderStatus.Cancelled) + && o.CreatedAt >= utcStart + && o.CreatedAt < utcEnd); + + if (!string.IsNullOrEmpty(branchId)) + query = query.Where(o => o.BranchId == branchId); + + var total = await query.CountAsync(cancellationToken); + + var orders = await ApplyOrderIncludes(query) + .OrderByDescending(o => o.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .AsNoTracking() + .ToListAsync(cancellationToken); + + return (orders.Select(MapOrder).ToList(), total); + } + + public async Task> CorrectPaymentsAsync( + string cafeId, + string orderId, + CorrectPaymentsRequest request, + string? userId, + CancellationToken cancellationToken = default) + { + var order = await LoadOrderAsync(cafeId, orderId, cancellationToken); + if (order is null) + return new OrderServiceResult(false, null, "ORDER_NOT_FOUND"); + + // Resolve the payments being voided — they must belong to this order and + // still be live. Payments are never deleted; voiding marks them Refunded + // so the original سند stays visible in history and audit. + var toVoid = new List(); + foreach (var paymentId in request.VoidPaymentIds.Distinct()) + { + var payment = order.Payments.FirstOrDefault(p => p.Id == paymentId); + if (payment is null) + return new OrderServiceResult(false, null, "PAYMENT_NOT_FOUND", "voidPaymentIds"); + if (payment.Status != PaymentStatus.Completed) + return new OrderServiceResult(false, null, "PAYMENT_ALREADY_REFUNDED", "voidPaymentIds"); + toVoid.Add(payment); + } + + var branchId = await ResolveOrderBranchIdAsync(order, cafeId, cancellationToken); + if (string.IsNullOrEmpty(branchId)) + return new OrderServiceResult(false, null, "NO_OPEN_SHIFT", "branchId"); + + // Corrections move money through the drawer, so they need an open shift + // exactly like recording a payment does. + var shiftCheck = await _shiftService.RequireOpenShiftForBranchAsync(cafeId, branchId, cancellationToken); + if (!shiftCheck.Success) + return new OrderServiceResult(false, null, shiftCheck.ErrorCode, shiftCheck.Field); + var openShift = shiftCheck.Data!; + + foreach (var payment in toVoid) + payment.Status = PaymentStatus.Refunded; + + var replacements = request.Replacements.Select(p => new Payment + { + OrderId = orderId, + Method = p.Method, + Amount = p.Amount, + Reference = p.Reference, + Status = PaymentStatus.Completed + }).ToList(); + _db.Payments.AddRange(replacements); + + // Fully paid again after the correction → ensure the order is closed; + // underpaid → leave the status alone (the remainder can be collected + // through the normal payment flow later). EF navigation fixup may have + // already appended the replacements to order.Payments, so exclude them + // by reference to avoid double-counting. + var paidTotal = order.Payments + .Where(p => p.Status == PaymentStatus.Completed && !replacements.Contains(p)) + .Sum(p => p.Amount) + + replacements.Sum(p => p.Amount); + if (paidTotal >= order.Total && OpenForPaymentStatuses.Contains(order.Status)) + order.Status = OrderStatus.Delivered; + + await _db.SaveChangesAsync(cancellationToken); + + var createdBy = userId ?? openShift.OpenedByUserId; + foreach (var payment in toVoid) + { + await _shiftService.RecordTransactionAsync( + cafeId, openShift.Id, CashTransactionType.Refund, payment.Method, + payment.Amount, createdBy, orderId, request.Reason, cancellationToken); + } + foreach (var payment in replacements) + { + await _shiftService.RecordTransactionAsync( + cafeId, openShift.Id, CashTransactionType.OrderPayment, payment.Method, + payment.Amount, createdBy, orderId, request.Reason, cancellationToken); + } + + return new OrderServiceResult(true, MapOrder(order)); + } + private static IQueryable ApplyOrderIncludes(IQueryable query) => query .Include(o => o.Items) diff --git a/src/Meezi.API/Services/ShiftService.cs b/src/Meezi.API/Services/ShiftService.cs index 0e14c3c..f0e36d6 100644 --- a/src/Meezi.API/Services/ShiftService.cs +++ b/src/Meezi.API/Services/ShiftService.cs @@ -228,11 +228,16 @@ public class ShiftService : IShiftService .Where(t => t.Type == CashTransactionType.OrderPayment && t.Method == PaymentMethod.Cash) .Sum(t => t.Amount); + // Payment corrections (اصلاح سند) refund cash back out of the drawer. + var cashRefunds = transactions + .Where(t => t.Type == CashTransactionType.Refund && t.Method == PaymentMethod.Cash) + .Sum(t => t.Amount); + var withdrawals = transactions .Where(t => t.Type == CashTransactionType.Withdrawal) .Sum(t => t.Amount); - return openingCash + cashPayments - withdrawals; + return openingCash + cashPayments - cashRefunds - withdrawals; } private static ShiftDto ToDto(Shift s) => new( diff --git a/src/Meezi.API/Validators/PosValidators.cs b/src/Meezi.API/Validators/PosValidators.cs index 6f33853..c969d47 100644 --- a/src/Meezi.API/Validators/PosValidators.cs +++ b/src/Meezi.API/Validators/PosValidators.cs @@ -139,6 +139,22 @@ public class RecordPaymentsRequestValidator : AbstractValidator +{ + public CorrectPaymentsRequestValidator() + { + RuleFor(x => x.Reason).NotEmpty().MinimumLength(3).MaximumLength(500); + RuleFor(x => x) + .Must(x => (x.VoidPaymentIds?.Count ?? 0) > 0 || (x.Replacements?.Count ?? 0) > 0) + .WithMessage("At least one payment to void or one replacement is required."); + RuleForEach(x => x.Replacements).ChildRules(p => + { + p.RuleFor(x => x.Method).IsInEnum(); + p.RuleFor(x => x.Amount).GreaterThan(0); + }); + } +} + public class AppendOrderItemsRequestValidator : AbstractValidator { public AppendOrderItemsRequestValidator() diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index 38a2874..624b634 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -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": "المصروفات", diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index 6f91f93..40acff8 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -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", diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index 62801ee..effae62 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -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": "شیفت صندوق", diff --git a/web/dashboard/src/components/reports/audit-logs-tab.tsx b/web/dashboard/src/components/reports/audit-logs-tab.tsx new file mode 100644 index 0000000..717439a --- /dev/null +++ b/web/dashboard/src/components/reports/audit-logs-tab.tsx @@ -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(""); + const [from, setFrom] = useState(() => isoDaysAgoTehran(7)); + const [to, setTo] = useState(() => isoTodayTehran()); + const [branchId, setBranchId] = useState(""); + const [page, setPage] = useState(1); + const [expanded, setExpanded] = useState(null); + + const { data: branches = [] } = useQuery({ + queryKey: ["branches", cafeId], + queryFn: () => apiGet(`/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(`/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 ( +
+ + + + + + + { + setFrom(iso); + setPage(1); + }} + /> + + + { + setTo(iso); + setPage(1); + }} + /> + + {branches.length > 1 ? ( + + + + ) : null} + + + + + + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

{t("loadFailed")}

+ +
+ ) : rows.length === 0 ? ( +

{t("empty")}

+ ) : ( + + + + + + + + + + + {rows.map((row) => { + const isOpen = expanded === row.id; + let details: Record | null = null; + if (isOpen && row.detailsJson) { + try { + details = JSON.parse(row.detailsJson) as Record; + } catch { + details = null; + } + } + return ( + + + + + + + + + {isOpen && details ? ( + + + + ) : null} + + ); + })} + +
{t("colTime")}{t("colCategory")}{t("colActor")}{t("colSummary")} +
+ {dateTimeFormatter.format(new Date(row.createdAt))} + + + {(CATEGORIES as readonly string[]).includes(row.category) + ? t(`categories.${row.category}`) + : row.category} + + + {row.actorName ?? row.actorId ?? t("systemActor")} + {row.actorRole ? ( + + ({row.actorRole}) + + ) : null} + + {row.summary} + + {row.detailsJson ? ( + + ) : null} +
+
+ {Object.entries(details).map(([key, value]) => ( +
+
+ {key}: +
+
+ {typeof value === "object" + ? JSON.stringify(value) + : String(value)} +
+
+ ))} +
+
+ )} + + {totalPages > 1 ? ( +
+ + + {formatNumber(page, numberLocale)} / {formatNumber(totalPages, numberLocale)} + + +
+ ) : null} +
+
+
+ ); +} diff --git a/web/dashboard/src/components/reports/payment-corrections-tab.tsx b/web/dashboard/src/components/reports/payment-corrections-tab.tsx new file mode 100644 index 0000000..f75a78f --- /dev/null +++ b/web/dashboard/src/components/reports/payment-corrections-tab.tsx @@ -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(() => isoTodayTehran()); + const [branchId, setBranchId] = useState(""); + const [page, setPage] = useState(1); + const [target, setTarget] = useState(null); + + const { data: branches = [] } = useQuery({ + queryKey: ["branches", cafeId], + queryFn: () => apiGet(`/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(`/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 ( +
+ + + + { + setDate(iso); + setPage(1); + }} + /> + + {branches.length > 1 ? ( + + + + ) : null} +

{t("hint")}

+
+
+ + + + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

{t("loadFailed")}

+ +
+ ) : orders.length === 0 ? ( +

{t("empty")}

+ ) : ( + + + + + + + + + + + + {orders.map((o) => { + const cancelled = o.status === "Cancelled"; + return ( + + + + + + + + + ); + })} + +
{t("colOrder")}{t("colTime")}{t("colStatus")}{t("colTotal")}{t("colPayments")} +
+ #{formatNumber(o.displayNumber, numberLocale)} + {o.tableNumber ? ( + + {t("table")} {o.tableNumber} + + ) : null} + + {timeFormatter.format(new Date(o.createdAt))} + + + {cancelled ? t("statusCancelled") : t("statusPaid")} + + + {formatCurrency(o.total, numberLocale)} + +
+ {o.payments.length === 0 ? ( + + ) : ( + o.payments.map((p) => ( + + {tMethods(methodKey(p.method))} ·{" "} + {formatCurrency(p.amount, numberLocale)} + + )) + )} +
+
+ {o.payments.some((p) => p.status === "Completed") ? ( + + ) : null} +
+ )} + + {totalPages > 1 ? ( +
+ + + {formatNumber(page, numberLocale)} / {formatNumber(totalPages, numberLocale)} + + +
+ ) : null} +
+
+ + {target ? ( + setTarget(null)} + onDone={() => { + setTarget(null); + void queryClient.invalidateQueries({ queryKey: ["closed-orders", cafeId] }); + void queryClient.invalidateQueries({ queryKey: ["audit-logs", cafeId] }); + }} + /> + ) : null} +
+ ); +} + +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>(new Set()); + const [replacements, setReplacements] = useState([]); + 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) => + 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 ( +
+ +
+

+ {t("dialogTitle")} — #{formatNumber(order.displayNumber, numberLocale)} +

+

+ {t("orderTotal")}: {formatCurrency(order.total, numberLocale)} +

+
+ +
+ {/* Payments to void */} +
+

{t("voidSection")}

+
+ {livePayments.map((p) => ( + + ))} +
+
+ + {/* Replacements */} +
+
+

{t("replacementSection")}

+ +
+ {replacements.length === 0 ? ( +

{t("noReplacements")}

+ ) : ( +
+ {replacements.map((r, i) => ( +
+ + patchReplacement(i, { amount: e.target.value })} + /> + +
+ ))} +
+ )} +
+ + {/* Reason */} +
+ +