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
+21
View File
@@ -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": "الإشعارات",
+21
View File
@@ -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",
+21
View File
@@ -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": "اعلان‌ها",
@@ -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}`, {});
}
export async function printKitchen(cafeId: string, orderId: string): Promise<void> {
await apiPost(`/api/cafes/${cafeId}/print/kitchen/${orderId}`, {});
export async function printKitchen(
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(
+1
View File
@@ -123,6 +123,7 @@ export const NAV_REQUIRED_PERMISSION: Partial<Record<NavItemKey, Permission>> =
kds: "ViewKitchen",
reservations: "ViewReservations",
menu: "ViewMenu",
orders: "ViewOrders",
reports: "ViewReports",
crm: "ViewCustomers",
coupons: "ViewCoupons",
+4
View File
@@ -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<NavItemKey> = new Set<NavItemK
"kds",
"queue",
"reservations",
"orders",
]);
export function findNavGroupForPath(pathname: string): NavGroupId | null {