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
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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "الإشعارات",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user