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