feat(pos): print customer receipt from the POS page
CI/CD / CI · API (dotnet build + test) (push) Successful in 44s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m12s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 48s
CI/CD / CI · Koja (tsc) (push) Successful in 51s
CI/CD / Deploy · all services (push) Successful in 3m0s

POS v2 auto-printed the receipt on full payment but had no manual button. Adds a
"چاپ فاکتور" (print receipt) action in the order panel that prints/reprints the
active saved order's customer receipt, plus a "print receipt" action on the
payment-success toast. Replaces the dead disabled "hold" placeholder button.
Backend print endpoint unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-21 14:18:20 +03:30
parent 0c2ded4070
commit 6184c83fa7
@@ -10,7 +10,7 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Search, Plus, Minus, Trash2, Send, CreditCard, Pause, SplitSquareHorizontal,
Search, Plus, Minus, Trash2, Send, CreditCard, SplitSquareHorizontal,
X, WifiOff, ShoppingCart, Users, Coffee, ArrowRight, LayoutGrid, Armchair,
Banknote, Check, Delete, ReceiptText, ShoppingBag, Loader2, RotateCcw,
BadgePercent, Sparkles, Home, StickyNote,
@@ -24,6 +24,7 @@ import { useCartStore } from "@/lib/stores/cart.store";
import { apiGet, apiPost, ApiClientError } from "@/lib/api/client";
import { submitOrderToApi, orderAmountDue, isLocalOrder } from "@/lib/pos/submit-order";
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
import { printReceipt } from "@/lib/api/print";
import { PosCustomerPicker } from "@/components/pos/pos-customer-picker";
import { Can } from "@/components/auth/can";
import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types";
@@ -107,6 +108,7 @@ export function Pos2Screen() {
const hydrateFromOrder = useCartStore((s) => s.hydrateFromOrder);
const clearSession = useCartStore((s) => s.clearSession);
const activeOrderNo = useCartStore((s) => s.activeOrderDisplayNumber);
const activeOrderId = useCartStore((s) => s.activeOrderId);
const appliedCoupon = useCartStore((s) => s.appliedCoupon);
// local view state
@@ -285,7 +287,10 @@ export function Pos2Screen() {
loyaltyPointsToRedeem: loyaltyRedeem > 0 ? loyaltyRedeem : undefined,
});
const paid = payments.reduce((s, p) => s + p.amount, 0);
notify.success(`پرداخت ${fmt(paid)} تومان ثبت شد`);
const paidOrderId = payTarget.id;
notify.success(`پرداخت ${fmt(paid)} تومان ثبت شد`, {
action: { label: "چاپ فاکتور", onClick: () => void printReceipt(cafeId as string, paidOrderId) },
});
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
backToBoard();
@@ -302,6 +307,27 @@ export function Pos2Screen() {
}
};
// Print (or reprint) the customer receipt for the active, already-saved order.
const printActiveReceipt = async () => {
if (!activeOrderId || isLocalOrder(activeOrderId)) {
notify.error("ابتدا سفارش را ثبت کنید");
return;
}
try {
await printReceipt(cafeId as string, activeOrderId);
notify.success("فاکتور برای چاپ ارسال شد");
} catch (e) {
const code = e instanceof ApiClientError ? e.code : "";
notify.error(
code === "PRINTER_NOT_CONFIGURED" || code === "KITCHEN_PRINTER_NOT_CONFIGURED"
? "پرینتر فاکتور تنظیم نشده است"
: code === "PRINTER_CONNECTION_FAILED"
? "اتصال به پرینتر برقرار نشد"
: "چاپ فاکتور ناموفق بود",
);
}
};
// ── guards ───────────────────────────────────────────────────────────────
if (!cafeId) {
return (
@@ -322,6 +348,8 @@ export function Pos2Screen() {
onBump: (id: string, d: number) => { const l = items.find((x) => x.menuItem.id === id); if (l) updateQty(id, l.quantity + d); },
onRemove: removeItem, onSend: send, onPay: openPay, onSplit: openPay,
onNote: (id: string, notes: string) => setNotes(id, notes),
canPrint: !!activeOrderId && !isLocalOrder(activeOrderId),
onPrintReceipt: printActiveReceipt,
};
// ── TABLE BOARD ────────────────────────────────────────────────────────────
@@ -673,13 +701,14 @@ function Pos2Extras({ cafeId }: { cafeId: string }) {
// ── Order ticket ─────────────────────────────────────────────────────────────
type TicketLine = { menuItem: MenuItem; quantity: number; notes?: string };
function Ticket({
cafeId, lines, subtotal, discount, tax, total, count, pendingCount, onBump, onRemove, onNote, onSend, onPay, onSplit,
cafeId, lines, subtotal, discount, tax, total, count, pendingCount, onBump, onRemove, onNote, onSend, onPay, onSplit, canPrint, onPrintReceipt,
}: {
cafeId: string; lines: TicketLine[]; subtotal: number; discount: number; tax: number; total: number;
count: number; pendingCount: number;
onBump: (id: string, d: number) => void; onRemove: (id: string) => void;
onNote: (id: string, notes: string) => void;
onSend: () => void; onPay: () => void; onSplit: () => void;
canPrint: boolean; onPrintReceipt: () => void;
}) {
const [noteFor, setNoteFor] = useState<string | null>(null);
return (
@@ -762,8 +791,10 @@ function Ticket({
<CreditCard className="size-5" /> پرداخت
</button>
</Can>
<button type="button" disabled className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground opacity-50">
<Pause className="size-4" /> نگهداشتن
<button type="button" disabled={!canPrint} onClick={onPrintReceipt}
className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-foreground hover:bg-accent disabled:opacity-40"
title="چاپ فاکتور مشتری">
<ReceiptText className="size-4" /> چاپ فاکتور
</button>
<Can permission="HandlePayments">
<button type="button" disabled={count === 0} onClick={onSplit}