From c360fbb06894cacb72df9361df8b4b287b7b7ff5 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sun, 21 Jun 2026 23:15:34 +0330 Subject: [PATCH] feat(orders): recent orders view with receipt / kitchen / bar reprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "سفارش‌ها" (Orders) nav page listing closed orders by day (date + branch filter, paged), each with reprint actions: - چاپ فاکتور → customer receipt - فیش آشپزخانه → kitchen ticket (all stations) - one button per print station (e.g. Bar) → reprints only that station's items Backend: the kitchen print endpoint gains an optional ?stationId= to reprint a single station; PrintKitchenTicketAsync filters its station groups accordingly (NO_STATION_ITEMS when that station has nothing on the order). Nav gated by ViewOrders (visible to branch staff too). fa/en/ar strings added. Note: local backend build couldn't run (NU1301 — NuGet restore network timeout); dashboard typecheck is clean and the C# changes are minimal — CI builds via the Nexus mirror. Co-Authored-By: Claude Opus 4.8 --- src/Meezi.API/Controllers/PrintController.cs | 11 +- .../Printing/NetworkPrinterService.cs | 12 +- web/dashboard/messages/ar.json | 21 ++ web/dashboard/messages/en.json | 21 ++ web/dashboard/messages/fa.json | 21 ++ .../app/[locale]/(dashboard)/orders/page.tsx | 7 + .../orders/recent-orders-screen.tsx | 220 ++++++++++++++++++ web/dashboard/src/lib/api/print.ts | 9 +- web/dashboard/src/lib/permissions.ts | 1 + web/dashboard/src/lib/sidebar-nav.ts | 4 + 10 files changed, 321 insertions(+), 6 deletions(-) create mode 100644 web/dashboard/src/app/[locale]/(dashboard)/orders/page.tsx create mode 100644 web/dashboard/src/components/orders/recent-orders-screen.tsx diff --git a/src/Meezi.API/Controllers/PrintController.cs b/src/Meezi.API/Controllers/PrintController.cs index 2f51977..991745b 100644 --- a/src/Meezi.API/Controllers/PrintController.cs +++ b/src/Meezi.API/Controllers/PrintController.cs @@ -32,11 +32,14 @@ public class PrintController : CafeApiControllerBase string cafeId, string orderId, ITenantContext tenant, - CancellationToken ct) + CancellationToken ct, + [FromQuery] string? stationId) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; - var result = await _printer.PrintKitchenTicketAsync(cafeId, orderId, ct); + // stationId omitted → print every station (kitchen + bar …); provided → + // reprint only that one station's items. + var result = await _printer.PrintKitchenTicketAsync(cafeId, orderId, stationId, ct); return ToActionResult(result); } @@ -62,7 +65,8 @@ public class PrintController : CafeApiControllerBase var status = result.ErrorCode switch { - "PRINTER_NOT_CONFIGURED" or "KITCHEN_PRINTER_NOT_CONFIGURED" => StatusCodes.Status400BadRequest, + "PRINTER_NOT_CONFIGURED" or "KITCHEN_PRINTER_NOT_CONFIGURED" or "NO_STATION_ITEMS" + => StatusCodes.Status400BadRequest, "ORDER_NOT_FOUND" => StatusCodes.Status404NotFound, _ => StatusCodes.Status502BadGateway }; @@ -75,6 +79,7 @@ public class PrintController : CafeApiControllerBase { "PRINTER_NOT_CONFIGURED" => "Receipt printer IP is not configured for this branch.", "KITCHEN_PRINTER_NOT_CONFIGURED" => "Kitchen printer IP is not configured for this branch.", + "NO_STATION_ITEMS" => "This order has no items for the selected station.", "PRINTER_CONNECTION_FAILED" => "Could not connect to the printer.", "ORDER_NOT_FOUND" => "Order not found.", _ => "Print failed." diff --git a/src/Meezi.API/Services/Printing/NetworkPrinterService.cs b/src/Meezi.API/Services/Printing/NetworkPrinterService.cs index a708327..6c19803 100644 --- a/src/Meezi.API/Services/Printing/NetworkPrinterService.cs +++ b/src/Meezi.API/Services/Printing/NetworkPrinterService.cs @@ -19,6 +19,7 @@ public interface IPrinterService Task PrintKitchenTicketAsync( string cafeId, string orderId, + string? stationId = null, CancellationToken ct = default); Task TestPrintAsync(string printerIp, int port, CancellationToken ct = default); } @@ -62,6 +63,7 @@ public class NetworkPrinterService : IPrinterService public async Task PrintKitchenTicketAsync( string cafeId, string orderId, + string? stationId = null, CancellationToken ct = default) { var ctx = await BuildContextAsync(cafeId, orderId, ct); @@ -102,6 +104,14 @@ public class NetworkPrinterService : IPrinterService }) .ToList(); + // Optionally reprint a single station only (e.g. just the bar ticket). + if (!string.IsNullOrEmpty(stationId)) + { + groups = groups.Where(g => g.Key == stationId).ToList(); + if (groups.Count == 0) + return PrintResult.Fail("NO_STATION_ITEMS"); + } + PrintResult? lastFail = null; var anyPrinted = false; @@ -243,7 +253,7 @@ public static class PrinterBackgroundJobs try { var printer = scope.ServiceProvider.GetRequiredService(); - var result = await printer.PrintKitchenTicketAsync(cafeId, orderId, CancellationToken.None); + var result = await printer.PrintKitchenTicketAsync(cafeId, orderId, null, CancellationToken.None); if (!result.Success) logger.LogWarning("Kitchen print failed for {OrderId}: {Code}", orderId, result.ErrorCode); } diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index 00463a1..30e6afa 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -118,6 +118,7 @@ "menu": "القائمة", "crm": "العملاء", "coupons": "القسائم", + "orders": "الطلبات", "inventory": "المخزون", "hr": "الموارد البشرية", "reports": "التقارير", @@ -333,6 +334,9 @@ "posDeviceHint": "عند الدفع بالبطاقة، يُرسل المبلغ عبر HTTP (POST /pay) إلى الجهاز على الشبكة المحلية.", "posDeviceIp": "عنوان IP لجهاز نقطة البيع", "testSent": "تم إرسال الاختبار إلى الطابعة.", + "sent": "تم الإرسال إلى الطابعة.", + "noStationItems": "لا توجد أصناف لهذه المحطة في هذا الطلب.", + "printFailed": "فشلت الطباعة.", "stations": { "title": "محطات طباعة المطبخ والبار", "subtitle": "امنح كل قسم تحضير طابعته الخاصة ووجّه فئات القائمة إليها.", @@ -1086,6 +1090,23 @@ } } }, + "recentOrders": { + "title": "الطلبات الأخيرة", + "subtitle": "تصفّح الطلبات المغلقة وأعد طباعة فاتورة العميل وتذاكر المطبخ/البار.", + "date": "التاريخ", + "branch": "الفرع", + "allBranches": "كل الفروع", + "empty": "لا توجد طلبات لهذا اليوم.", + "loadFailed": "تعذّر تحميل الطلبات.", + "retry": "إعادة المحاولة", + "prevPage": "السابق", + "nextPage": "التالي", + "table": "الطاولة", + "statusPaid": "مدفوع", + "statusCancelled": "ملغى", + "receipt": "الفاتورة", + "kitchen": "تذكرة المطبخ" + }, "notifications": { "title": "الإشعارات", "pageTitle": "الإشعارات", diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index cf5bb9c..1013f11 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -129,6 +129,7 @@ "crm": "CRM", "coupons": "Coupons", "menu": "Menu", + "orders": "Orders", "inventory": "Inventory", "hr": "HR", "reports": "Reports", @@ -352,6 +353,9 @@ "posDeviceHint": "On card payment, the amount is sent via HTTP (POST /pay) to the device on your LAN.", "posDeviceIp": "POS device IP address", "testSent": "Test sent to the printer.", + "sent": "Sent to the printer.", + "noStationItems": "This order has no items for that station.", + "printFailed": "Print failed.", "stations": { "title": "Kitchen & bar print stations", "subtitle": "Give each prep area its own printer and route menu categories to it.", @@ -1146,6 +1150,23 @@ } } }, + "recentOrders": { + "title": "Recent orders", + "subtitle": "Browse closed orders and reprint the customer receipt and the kitchen / bar tickets.", + "date": "Date", + "branch": "Branch", + "allBranches": "All branches", + "empty": "No orders for this day.", + "loadFailed": "Could not load orders.", + "retry": "Retry", + "prevPage": "Previous", + "nextPage": "Next", + "table": "Table", + "statusPaid": "Paid", + "statusCancelled": "Cancelled", + "receipt": "Receipt", + "kitchen": "Kitchen ticket" + }, "notifications": { "title": "Notifications", "pageTitle": "Notifications", diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index d103f22..05691b1 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -129,6 +129,7 @@ "crm": "مشتریان", "coupons": "کوپن‌ها", "menu": "منو", + "orders": "سفارش‌ها", "inventory": "انبار", "hr": "منابع انسانی", "reports": "گزارش‌ها", @@ -352,6 +353,9 @@ "posDeviceHint": "هنگام پرداخت کارتی، مبلغ به آدرس HTTP دستگاه ارسال می‌شود (POST /pay).", "posDeviceIp": "آدرس IP دستگاه پوز", "testSent": "تست به پرینتر ارسال شد.", + "sent": "به پرینتر ارسال شد.", + "noStationItems": "این سفارش آیتمی برای این ایستگاه ندارد.", + "printFailed": "چاپ ناموفق بود.", "stations": { "title": "ایستگاه‌های چاپ آشپزخانه و بار", "subtitle": "برای هر بخش آماده‌سازی یک پرینتر جدا بگذارید و دسته‌های منو را به آن وصل کنید.", @@ -1147,6 +1151,23 @@ } } }, + "recentOrders": { + "title": "سفارش‌های اخیر", + "subtitle": "سفارش‌های بسته‌شده را ببینید و فاکتور مشتری و فیش آشپزخانه/بار را دوباره چاپ کنید.", + "date": "تاریخ", + "branch": "شعبه", + "allBranches": "همه شعب", + "empty": "سفارشی برای این روز نیست.", + "loadFailed": "بارگذاری سفارش‌ها ناموفق بود.", + "retry": "تلاش مجدد", + "prevPage": "قبلی", + "nextPage": "بعدی", + "table": "میز", + "statusPaid": "پرداخت‌شده", + "statusCancelled": "لغوشده", + "receipt": "فاکتور", + "kitchen": "فیش آشپزخانه" + }, "notifications": { "title": "اعلان‌ها", "pageTitle": "اعلان‌ها", diff --git a/web/dashboard/src/app/[locale]/(dashboard)/orders/page.tsx b/web/dashboard/src/app/[locale]/(dashboard)/orders/page.tsx new file mode 100644 index 0000000..34e1540 --- /dev/null +++ b/web/dashboard/src/app/[locale]/(dashboard)/orders/page.tsx @@ -0,0 +1,7 @@ +import { RecentOrdersScreen } from "@/components/orders/recent-orders-screen"; + +/** Recent orders — browse closed orders and reprint the customer receipt and the + * kitchen / bar tickets per order. */ +export default function OrdersPage() { + return ; +} diff --git a/web/dashboard/src/components/orders/recent-orders-screen.tsx b/web/dashboard/src/components/orders/recent-orders-screen.tsx new file mode 100644 index 0000000..5b91b80 --- /dev/null +++ b/web/dashboard/src/components/orders/recent-orders-screen.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useLocale, useTranslations } from "next-intl"; +import { Loader2, ReceiptText, ChefHat, Printer } from "lucide-react"; +import { apiGet, apiGetPaged, ApiClientError } from "@/lib/api/client"; +import { useAuthStore } from "@/lib/stores/auth.store"; +import { formatCurrency, formatNumber } from "@/lib/format"; +import { isoTodayTehran } from "@/lib/reports/analytics"; +import { printReceipt, printKitchen } from "@/lib/api/print"; +import { fetchKitchenStations } from "@/lib/api/kitchen-stations"; +import type { Order } from "@/lib/api/types"; +import { notify } from "@/lib/notify"; +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 { PageHeader } from "@/components/layout/page-header"; +import { cn } from "@/lib/utils"; + +type Branch = { id: string; name: string }; + +const PAGE_SIZE = 30; + +export function RecentOrdersScreen() { + const t = useTranslations("recentOrders"); + const tPrint = useTranslations("print"); + const locale = useLocale(); + const numberLocale = locale === "en" ? "en-US" : "fa-IR"; + const cafeId = useAuthStore((s) => s.user?.cafeId); + + const [date, setDate] = useState(() => isoTodayTehran()); + const [branchId, setBranchId] = useState(""); + const [page, setPage] = useState(1); + const [printing, setPrinting] = useState(null); + + const { data: branches = [] } = useQuery({ + queryKey: ["branches", cafeId], + queryFn: () => apiGet(`/api/cafes/${cafeId}/branches`), + enabled: !!cafeId, + }); + + const { data: stations = [] } = useQuery({ + queryKey: ["kitchen-stations", cafeId], + queryFn: () => fetchKitchenStations(cafeId!), + enabled: !!cafeId, + }); + + const queryString = useMemo(() => { + const p = new URLSearchParams({ date, page: String(page), pageSize: String(PAGE_SIZE) }); + if (branchId) p.set("branchId", branchId); + return p.toString(); + }, [date, branchId, page]); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ["recent-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 / PAGE_SIZE)); + + const timeFmt = useMemo( + () => new Intl.DateTimeFormat(numberLocale, { hour: "2-digit", minute: "2-digit", hour12: false }), + [numberLocale] + ); + + const printErr = (e: unknown): string => { + const code = e instanceof ApiClientError ? e.code : ""; + if (code === "PRINTER_NOT_CONFIGURED" || code === "KITCHEN_PRINTER_NOT_CONFIGURED") + return tPrint("notConfigured"); + if (code === "NO_STATION_ITEMS") return tPrint("noStationItems"); + if (code === "PRINTER_CONNECTION_FAILED") return tPrint("connectionFailed"); + return tPrint("printFailed"); + }; + + const run = async (key: string, fn: () => Promise) => { + setPrinting(key); + try { + await fn(); + notify.success(tPrint("sent")); + } catch (e) { + notify.error(printErr(e)); + } finally { + setPrinting(null); + } + }; + + if (!cafeId) return null; + + return ( +
+ + + + + + { setDate(iso); setPage(1); }} + /> + + {branches.length > 1 ? ( + + + + ) : null} + + + + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

{t("loadFailed")}

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

{t("empty")}

+ ) : ( +
+ {orders.map((o) => { + const cancelled = o.status === "Cancelled"; + const items = o.items.filter((i) => !i.isVoided); + return ( + + +
+ #{formatNumber(o.displayNumber, numberLocale)} + {o.tableNumber ? ( + {t("table")} {o.tableNumber} + ) : null} + + {timeFmt.format(new Date(o.createdAt))} + + + {cancelled ? t("statusCancelled") : t("statusPaid")} + + + {formatCurrency(o.total, numberLocale)} + +
+ + {items.length > 0 ? ( +

+ {items.map((i) => + `${formatNumber(i.quantity, numberLocale)}× ${i.menuItemName}${i.notes ? ` (${i.notes})` : ""}` + ).join(" · ")} +

+ ) : null} + +
+ + + {stations.map((s) => ( + + ))} +
+
+
+ ); + })} +
+ )} + + {totalPages > 1 ? ( +
+ + + {formatNumber(page, numberLocale)} / {formatNumber(totalPages, numberLocale)} + + +
+ ) : null} +
+ ); +} diff --git a/web/dashboard/src/lib/api/print.ts b/web/dashboard/src/lib/api/print.ts index 93ac17f..31397ac 100644 --- a/web/dashboard/src/lib/api/print.ts +++ b/web/dashboard/src/lib/api/print.ts @@ -4,8 +4,13 @@ export async function printReceipt(cafeId: string, orderId: string): Promise { - await apiPost(`/api/cafes/${cafeId}/print/kitchen/${orderId}`, {}); +export async function printKitchen( + cafeId: string, + orderId: string, + stationId?: string +): Promise { + const qs = stationId ? `?stationId=${encodeURIComponent(stationId)}` : ""; + await apiPost(`/api/cafes/${cafeId}/print/kitchen/${orderId}${qs}`, {}); } export async function testPrinter( diff --git a/web/dashboard/src/lib/permissions.ts b/web/dashboard/src/lib/permissions.ts index b477ef4..7c2d49a 100644 --- a/web/dashboard/src/lib/permissions.ts +++ b/web/dashboard/src/lib/permissions.ts @@ -123,6 +123,7 @@ export const NAV_REQUIRED_PERMISSION: Partial> = kds: "ViewKitchen", reservations: "ViewReservations", menu: "ViewMenu", + orders: "ViewOrders", reports: "ViewReports", crm: "ViewCustomers", coupons: "ViewCoupons", diff --git a/web/dashboard/src/lib/sidebar-nav.ts b/web/dashboard/src/lib/sidebar-nav.ts index d32b650..015e94e 100644 --- a/web/dashboard/src/lib/sidebar-nav.ts +++ b/web/dashboard/src/lib/sidebar-nav.ts @@ -16,6 +16,7 @@ import { Settings, ChefHat, ListOrdered, + ReceiptText, Building2, CreditCard, Wallet, @@ -33,6 +34,7 @@ export type NavItemKey = | "queue" | "reservations" | "menu" + | "orders" | "reports" | "crm" | "coupons" @@ -80,6 +82,7 @@ export const NAV_GROUPS: NavGroupDef[] = [ { key: "queue", href: "/queue", icon: ListOrdered }, { key: "reservations", href: "/reservations", icon: Calendar }, { key: "menu", href: "/menu", icon: BookOpen }, + { key: "orders", href: "/orders", icon: ReceiptText }, { key: "reports", href: "/reports", icon: BarChart3 }, ], }, @@ -132,6 +135,7 @@ export const BRANCH_ALLOWED_NAV_KEYS: ReadonlySet = new Set