feat(orders): recent orders view with receipt / kitchen / bar reprint
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m34s

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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-21 23:15:34 +03:30
parent 1264606410
commit c360fbb068
10 changed files with 321 additions and 6 deletions
+8 -3
View File
@@ -32,11 +32,14 @@ public class PrintController : CafeApiControllerBase
string cafeId, string cafeId,
string orderId, string orderId,
ITenantContext tenant, ITenantContext tenant,
CancellationToken ct) CancellationToken ct,
[FromQuery] string? stationId)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; 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); return ToActionResult(result);
} }
@@ -62,7 +65,8 @@ public class PrintController : CafeApiControllerBase
var status = result.ErrorCode switch 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, "ORDER_NOT_FOUND" => StatusCodes.Status404NotFound,
_ => StatusCodes.Status502BadGateway _ => StatusCodes.Status502BadGateway
}; };
@@ -75,6 +79,7 @@ public class PrintController : CafeApiControllerBase
{ {
"PRINTER_NOT_CONFIGURED" => "Receipt printer IP is not configured for this branch.", "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.", "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.", "PRINTER_CONNECTION_FAILED" => "Could not connect to the printer.",
"ORDER_NOT_FOUND" => "Order not found.", "ORDER_NOT_FOUND" => "Order not found.",
_ => "Print failed." _ => "Print failed."
@@ -19,6 +19,7 @@ public interface IPrinterService
Task<PrintResult> PrintKitchenTicketAsync( Task<PrintResult> PrintKitchenTicketAsync(
string cafeId, string cafeId,
string orderId, string orderId,
string? stationId = null,
CancellationToken ct = default); CancellationToken ct = default);
Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct = default); Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct = default);
} }
@@ -62,6 +63,7 @@ public class NetworkPrinterService : IPrinterService
public async Task<PrintResult> PrintKitchenTicketAsync( public async Task<PrintResult> PrintKitchenTicketAsync(
string cafeId, string cafeId,
string orderId, string orderId,
string? stationId = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var ctx = await BuildContextAsync(cafeId, orderId, ct); var ctx = await BuildContextAsync(cafeId, orderId, ct);
@@ -102,6 +104,14 @@ public class NetworkPrinterService : IPrinterService
}) })
.ToList(); .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; PrintResult? lastFail = null;
var anyPrinted = false; var anyPrinted = false;
@@ -243,7 +253,7 @@ public static class PrinterBackgroundJobs
try try
{ {
var printer = scope.ServiceProvider.GetRequiredService<IPrinterService>(); var printer = scope.ServiceProvider.GetRequiredService<IPrinterService>();
var result = await printer.PrintKitchenTicketAsync(cafeId, orderId, CancellationToken.None); var result = await printer.PrintKitchenTicketAsync(cafeId, orderId, null, CancellationToken.None);
if (!result.Success) if (!result.Success)
logger.LogWarning("Kitchen print failed for {OrderId}: {Code}", orderId, result.ErrorCode); logger.LogWarning("Kitchen print failed for {OrderId}: {Code}", orderId, result.ErrorCode);
} }
+21
View File
@@ -118,6 +118,7 @@
"menu": "القائمة", "menu": "القائمة",
"crm": "العملاء", "crm": "العملاء",
"coupons": "القسائم", "coupons": "القسائم",
"orders": "الطلبات",
"inventory": "المخزون", "inventory": "المخزون",
"hr": "الموارد البشرية", "hr": "الموارد البشرية",
"reports": "التقارير", "reports": "التقارير",
@@ -333,6 +334,9 @@
"posDeviceHint": "عند الدفع بالبطاقة، يُرسل المبلغ عبر HTTP (POST /pay) إلى الجهاز على الشبكة المحلية.", "posDeviceHint": "عند الدفع بالبطاقة، يُرسل المبلغ عبر HTTP (POST /pay) إلى الجهاز على الشبكة المحلية.",
"posDeviceIp": "عنوان IP لجهاز نقطة البيع", "posDeviceIp": "عنوان IP لجهاز نقطة البيع",
"testSent": "تم إرسال الاختبار إلى الطابعة.", "testSent": "تم إرسال الاختبار إلى الطابعة.",
"sent": "تم الإرسال إلى الطابعة.",
"noStationItems": "لا توجد أصناف لهذه المحطة في هذا الطلب.",
"printFailed": "فشلت الطباعة.",
"stations": { "stations": {
"title": "محطات طباعة المطبخ والبار", "title": "محطات طباعة المطبخ والبار",
"subtitle": "امنح كل قسم تحضير طابعته الخاصة ووجّه فئات القائمة إليها.", "subtitle": "امنح كل قسم تحضير طابعته الخاصة ووجّه فئات القائمة إليها.",
@@ -1086,6 +1090,23 @@
} }
} }
}, },
"recentOrders": {
"title": "الطلبات الأخيرة",
"subtitle": "تصفّح الطلبات المغلقة وأعد طباعة فاتورة العميل وتذاكر المطبخ/البار.",
"date": "التاريخ",
"branch": "الفرع",
"allBranches": "كل الفروع",
"empty": "لا توجد طلبات لهذا اليوم.",
"loadFailed": "تعذّر تحميل الطلبات.",
"retry": "إعادة المحاولة",
"prevPage": "السابق",
"nextPage": "التالي",
"table": "الطاولة",
"statusPaid": "مدفوع",
"statusCancelled": "ملغى",
"receipt": "الفاتورة",
"kitchen": "تذكرة المطبخ"
},
"notifications": { "notifications": {
"title": "الإشعارات", "title": "الإشعارات",
"pageTitle": "الإشعارات", "pageTitle": "الإشعارات",
+21
View File
@@ -129,6 +129,7 @@
"crm": "CRM", "crm": "CRM",
"coupons": "Coupons", "coupons": "Coupons",
"menu": "Menu", "menu": "Menu",
"orders": "Orders",
"inventory": "Inventory", "inventory": "Inventory",
"hr": "HR", "hr": "HR",
"reports": "Reports", "reports": "Reports",
@@ -352,6 +353,9 @@
"posDeviceHint": "On card payment, the amount is sent via HTTP (POST /pay) to the device on your LAN.", "posDeviceHint": "On card payment, the amount is sent via HTTP (POST /pay) to the device on your LAN.",
"posDeviceIp": "POS device IP address", "posDeviceIp": "POS device IP address",
"testSent": "Test sent to the printer.", "testSent": "Test sent to the printer.",
"sent": "Sent to the printer.",
"noStationItems": "This order has no items for that station.",
"printFailed": "Print failed.",
"stations": { "stations": {
"title": "Kitchen & bar print stations", "title": "Kitchen & bar print stations",
"subtitle": "Give each prep area its own printer and route menu categories to it.", "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": { "notifications": {
"title": "Notifications", "title": "Notifications",
"pageTitle": "Notifications", "pageTitle": "Notifications",
+21
View File
@@ -129,6 +129,7 @@
"crm": "مشتریان", "crm": "مشتریان",
"coupons": "کوپن‌ها", "coupons": "کوپن‌ها",
"menu": "منو", "menu": "منو",
"orders": "سفارش‌ها",
"inventory": "انبار", "inventory": "انبار",
"hr": "منابع انسانی", "hr": "منابع انسانی",
"reports": "گزارش‌ها", "reports": "گزارش‌ها",
@@ -352,6 +353,9 @@
"posDeviceHint": "هنگام پرداخت کارتی، مبلغ به آدرس HTTP دستگاه ارسال می‌شود (POST /pay).", "posDeviceHint": "هنگام پرداخت کارتی، مبلغ به آدرس HTTP دستگاه ارسال می‌شود (POST /pay).",
"posDeviceIp": "آدرس IP دستگاه پوز", "posDeviceIp": "آدرس IP دستگاه پوز",
"testSent": "تست به پرینتر ارسال شد.", "testSent": "تست به پرینتر ارسال شد.",
"sent": "به پرینتر ارسال شد.",
"noStationItems": "این سفارش آیتمی برای این ایستگاه ندارد.",
"printFailed": "چاپ ناموفق بود.",
"stations": { "stations": {
"title": "ایستگاه‌های چاپ آشپزخانه و بار", "title": "ایستگاه‌های چاپ آشپزخانه و بار",
"subtitle": "برای هر بخش آماده‌سازی یک پرینتر جدا بگذارید و دسته‌های منو را به آن وصل کنید.", "subtitle": "برای هر بخش آماده‌سازی یک پرینتر جدا بگذارید و دسته‌های منو را به آن وصل کنید.",
@@ -1147,6 +1151,23 @@
} }
} }
}, },
"recentOrders": {
"title": "سفارش‌های اخیر",
"subtitle": "سفارش‌های بسته‌شده را ببینید و فاکتور مشتری و فیش آشپزخانه/بار را دوباره چاپ کنید.",
"date": "تاریخ",
"branch": "شعبه",
"allBranches": "همه شعب",
"empty": "سفارشی برای این روز نیست.",
"loadFailed": "بارگذاری سفارش‌ها ناموفق بود.",
"retry": "تلاش مجدد",
"prevPage": "قبلی",
"nextPage": "بعدی",
"table": "میز",
"statusPaid": "پرداخت‌شده",
"statusCancelled": "لغوشده",
"receipt": "فاکتور",
"kitchen": "فیش آشپزخانه"
},
"notifications": { "notifications": {
"title": "اعلان‌ها", "title": "اعلان‌ها",
"pageTitle": "اعلان‌ها", "pageTitle": "اعلان‌ها",
@@ -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 <RecentOrdersScreen />;
}
@@ -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<string>(() => isoTodayTehran());
const [branchId, setBranchId] = useState<string>("");
const [page, setPage] = useState(1);
const [printing, setPrinting] = useState<string | null>(null);
const { data: branches = [] } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () => apiGet<Branch[]>(`/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<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 / 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<void>) => {
setPrinting(key);
try {
await fn();
notify.success(tPrint("sent"));
} catch (e) {
notify.error(printErr(e));
} finally {
setPrinting(null);
}
};
if (!cafeId) return null;
return (
<div className="space-y-6">
<PageHeader title={t("title")} subtitle={t("subtitle")} />
<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="ro-date">
<JalaliDateField
id="ro-date"
className="w-40"
value={date}
onChange={(iso) => { setDate(iso); setPage(1); }}
/>
</LabeledField>
{branches.length > 1 ? (
<LabeledField label={t("branch")} htmlFor="ro-branch">
<select
id="ro-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>
{isLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : isError ? (
<div className="flex flex-col items-center gap-3 py-10 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-12 text-center text-sm text-muted-foreground">{t("empty")}</p>
) : (
<div className="space-y-3">
{orders.map((o) => {
const cancelled = o.status === "Cancelled";
const items = o.items.filter((i) => !i.isVoided);
return (
<Card key={o.id} className="rounded-xl border border-border/80">
<CardContent className="space-y-3 p-4">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-semibold">#{formatNumber(o.displayNumber, numberLocale)}</span>
{o.tableNumber ? (
<span className="text-sm text-muted-foreground">{t("table")} {o.tableNumber}</span>
) : null}
<span className="text-sm tabular-nums text-muted-foreground">
{timeFmt.format(new Date(o.createdAt))}
</span>
<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>
<span className="ms-auto font-bold tabular-nums text-primary">
{formatCurrency(o.total, numberLocale)}
</span>
</div>
{items.length > 0 ? (
<p className="text-xs leading-relaxed text-muted-foreground">
{items.map((i) =>
`${formatNumber(i.quantity, numberLocale)}× ${i.menuItemName}${i.notes ? ` (${i.notes})` : ""}`
).join(" · ")}
</p>
) : null}
<div className="flex flex-wrap gap-2 border-t border-border/60 pt-3">
<Button
variant="outline" size="sm" className="gap-1.5"
disabled={printing === `${o.id}:receipt`}
onClick={() => run(`${o.id}:receipt`, () => printReceipt(cafeId, o.id))}
>
<ReceiptText className="size-3.5" /> {t("receipt")}
</Button>
<Button
variant="outline" size="sm" className="gap-1.5"
disabled={printing === `${o.id}:kitchen`}
onClick={() => run(`${o.id}:kitchen`, () => printKitchen(cafeId, o.id))}
>
<ChefHat className="size-3.5" /> {t("kitchen")}
</Button>
{stations.map((s) => (
<Button
key={s.id}
variant="ghost" size="sm" className="gap-1.5"
disabled={printing === `${o.id}:${s.id}`}
onClick={() => run(`${o.id}:${s.id}`, () => printKitchen(cafeId, o.id, s.id))}
>
<Printer className="size-3.5" /> {s.name}
</Button>
))}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{totalPages > 1 ? (
<div className="flex items-center justify-center gap-3 pt-1">
<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}
</div>
);
}
+7 -2
View File
@@ -4,8 +4,13 @@ export async function printReceipt(cafeId: string, orderId: string): Promise<voi
await apiPost(`/api/cafes/${cafeId}/print/receipt/${orderId}`, {}); await apiPost(`/api/cafes/${cafeId}/print/receipt/${orderId}`, {});
} }
export async function printKitchen(cafeId: string, orderId: string): Promise<void> { export async function printKitchen(
await apiPost(`/api/cafes/${cafeId}/print/kitchen/${orderId}`, {}); cafeId: string,
orderId: string,
stationId?: string
): Promise<void> {
const qs = stationId ? `?stationId=${encodeURIComponent(stationId)}` : "";
await apiPost(`/api/cafes/${cafeId}/print/kitchen/${orderId}${qs}`, {});
} }
export async function testPrinter( export async function testPrinter(
+1
View File
@@ -123,6 +123,7 @@ export const NAV_REQUIRED_PERMISSION: Partial<Record<NavItemKey, Permission>> =
kds: "ViewKitchen", kds: "ViewKitchen",
reservations: "ViewReservations", reservations: "ViewReservations",
menu: "ViewMenu", menu: "ViewMenu",
orders: "ViewOrders",
reports: "ViewReports", reports: "ViewReports",
crm: "ViewCustomers", crm: "ViewCustomers",
coupons: "ViewCoupons", coupons: "ViewCoupons",
+4
View File
@@ -16,6 +16,7 @@ import {
Settings, Settings,
ChefHat, ChefHat,
ListOrdered, ListOrdered,
ReceiptText,
Building2, Building2,
CreditCard, CreditCard,
Wallet, Wallet,
@@ -33,6 +34,7 @@ export type NavItemKey =
| "queue" | "queue"
| "reservations" | "reservations"
| "menu" | "menu"
| "orders"
| "reports" | "reports"
| "crm" | "crm"
| "coupons" | "coupons"
@@ -80,6 +82,7 @@ export const NAV_GROUPS: NavGroupDef[] = [
{ key: "queue", href: "/queue", icon: ListOrdered }, { key: "queue", href: "/queue", icon: ListOrdered },
{ key: "reservations", href: "/reservations", icon: Calendar }, { key: "reservations", href: "/reservations", icon: Calendar },
{ key: "menu", href: "/menu", icon: BookOpen }, { key: "menu", href: "/menu", icon: BookOpen },
{ key: "orders", href: "/orders", icon: ReceiptText },
{ key: "reports", href: "/reports", icon: BarChart3 }, { key: "reports", href: "/reports", icon: BarChart3 },
], ],
}, },
@@ -132,6 +135,7 @@ export const BRANCH_ALLOWED_NAV_KEYS: ReadonlySet<NavItemKey> = new Set<NavItemK
"kds", "kds",
"queue", "queue",
"reservations", "reservations",
"orders",
]); ]);
export function findNavGroupForPath(pathname: string): NavGroupId | null { export function findNavGroupForPath(pathname: string): NavGroupId | null {