fix(pos): cashier can't delete/reduce an item already sent to the kitchen
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 34s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m7s
CI/CD / CI · Admin Web (tsc) (push) Successful in 41s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m54s

On the POS, once a line is fired to the kitchen its sent quantity is the locked
portion: a user without the VoidOrder permission (the default cashier) can no
longer remove that line or decrease it below what was sent — otherwise they could
send food and then erase it from the order (charge less / pocket cash). The unsent
portion of a line stays freely editable, and adding more is always allowed. The
delete button is replaced by a lock icon on sent lines, and the minus button is
disabled at the sent floor. Gated by VoidOrder, so owners/managers with the
permission are unaffected. Mirrors the server-side order-cancel lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-25 11:40:13 +03:30
parent ae5c750d34
commit 67450393fc
@@ -13,7 +13,7 @@ import {
Search, Plus, Minus, Trash2, Send, CreditCard, SplitSquareHorizontal,
X, WifiOff, ShoppingCart, Users, Coffee, ArrowRight, LayoutGrid, Armchair,
Banknote, Check, Delete, ReceiptText, ShoppingBag, Loader2,
BadgePercent, Sparkles, Home, StickyNote,
BadgePercent, Sparkles, Home, StickyNote, Lock,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { notify } from "@/lib/notify";
@@ -27,6 +27,7 @@ import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
import { printReceipt } from "@/lib/api/print";
import { PosCustomerPicker } from "@/components/pos2/pos-customer-picker";
import { Can } from "@/components/auth/can";
import { useHasPermission } from "@/lib/permissions";
import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types";
import { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2";
@@ -111,6 +112,11 @@ export function Pos2Screen() {
const activeOrderId = useCartStore((s) => s.activeOrderId);
const appliedCoupon = useCartStore((s) => s.appliedCoupon);
// Removing/reducing an item that's already been fired to the kitchen is a void —
// a cashier must NOT be able to do it (send food, then erase it). Gated on the
// VoidOrder permission; the unsent portion of a line stays freely editable.
const canVoid = useHasPermission("VoidOrder");
// local view state
const [view, setView] = useState<"board" | "order">("board");
const [activeTable, setActiveTable] = useState<TableBoardItem | null>(null);
@@ -361,12 +367,31 @@ export function Pos2Screen() {
const ticketProps = {
cafeId,
canVoid,
// mark fully-sent lines so their note becomes read-only (a note-only change on
// an already-sent line would otherwise be silently dropped on the next send).
lines: live.map((l) => ({ ...l, synced: (syncedQty[l.menuItem.id] ?? 0) >= l.quantity })),
// sentQty = how much of this line is already fired to the kitchen (the locked
// portion a non-void user may neither remove nor reduce below).
lines: live.map((l) => ({
...l,
sentQty: syncedQty[l.menuItem.id] ?? 0,
synced: (syncedQty[l.menuItem.id] ?? 0) >= l.quantity,
})),
subtotal, discount, tax, total, count, pendingCount,
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,
onBump: (id: string, d: number) => {
const l = items.find((x) => x.menuItem.id === id);
if (!l) return;
const sent = syncedQty[id] ?? 0;
// Block reducing below what's already been sent unless the user can void.
if (!canVoid && l.quantity + d < sent) return;
updateQty(id, l.quantity + d);
},
onRemove: (id: string) => {
const sent = syncedQty[id] ?? 0;
if (!canVoid && sent > 0) return; // can't delete an item already sent to the kitchen
removeItem(id);
},
onSend: send, onPay: openPay, onSplit: openPay,
onNote: (id: string, notes: string) => setNotes(id, notes),
canPrint: !!activeOrderId && !isLocalOrder(activeOrderId),
onPrintReceipt: printActiveReceipt,
@@ -741,11 +766,11 @@ function Pos2Extras({ cafeId }: { cafeId: string }) {
}
// ── Order ticket ─────────────────────────────────────────────────────────────
type TicketLine = { menuItem: MenuItem; quantity: number; notes?: string; synced?: boolean };
type TicketLine = { menuItem: MenuItem; quantity: number; notes?: string; synced?: boolean; sentQty?: number };
function Ticket({
cafeId, lines, subtotal, discount, tax, total, count, pendingCount, onBump, onRemove, onNote, onSend, onPay, onSplit, canPrint, onPrintReceipt,
cafeId, canVoid, 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;
cafeId: string; canVoid: boolean; 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;
@@ -771,8 +796,21 @@ function Ticket({
<p className="line-clamp-1 font-medium">{l.menuItem.name}</p>
<p className="text-xs text-muted-foreground">{fmt(l.menuItem.price)} تومان</p>
</div>
{(() => {
const sent = l.sentQty ?? 0;
const minusLocked = !canVoid && l.quantity <= sent;
const removeLocked = !canVoid && sent > 0;
return (
<>
<div className="flex items-center gap-1">
<button type="button" onClick={() => onBump(l.menuItem.id, -1)} className="flex size-11 items-center justify-center rounded-lg bg-muted hover:bg-accent active:scale-95" aria-label="کم">
<button
type="button"
onClick={() => onBump(l.menuItem.id, -1)}
disabled={minusLocked}
title={minusLocked ? "این تعداد به آشپزخانه ارسال شده و قابل کاهش نیست" : undefined}
className="flex size-11 items-center justify-center rounded-lg bg-muted hover:bg-accent active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-muted"
aria-label="کم"
>
<Minus className="size-4" />
</button>
<span className="w-7 text-center font-bold">{fmt(l.quantity)}</span>
@@ -780,9 +818,22 @@ function Ticket({
<Plus className="size-4" />
</button>
</div>
{removeLocked ? (
<span
className="flex size-9 items-center justify-center rounded-lg text-muted-foreground/60"
title="به آشپزخانه ارسال شده — برای حذف نیاز به دسترسی ابطال است"
aria-label="ارسال‌شده؛ قابل حذف نیست"
>
<Lock className="size-4" />
</span>
) : (
<button type="button" onClick={() => onRemove(l.menuItem.id)} className="flex size-9 items-center justify-center rounded-lg text-red-500 hover:bg-red-50" aria-label="حذف">
<Trash2 className="size-4" />
</button>
)}
</>
);
})()}
</div>
{l.synced ? (
// Already sent to the kitchen — note is read-only (can't be changed now).