From 67450393fc0ae0d4a95768defe3bda96b605f4a9 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 25 Jun 2026 11:40:13 +0330 Subject: [PATCH] fix(pos): cashier can't delete/reduce an item already sent to the kitchen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/pos2/pos2-screen.tsx | 89 +++++++++++++++---- 1 file changed, 70 insertions(+), 19 deletions(-) diff --git a/web/dashboard/src/components/pos2/pos2-screen.tsx b/web/dashboard/src/components/pos2/pos2-screen.tsx index c11d27d..75aca90 100644 --- a/web/dashboard/src/components/pos2/pos2-screen.tsx +++ b/web/dashboard/src/components/pos2/pos2-screen.tsx @@ -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(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,18 +796,44 @@ function Ticket({

{l.menuItem.name}

{fmt(l.menuItem.price)} تومان

-
- - {fmt(l.quantity)} - -
- + {(() => { + const sent = l.sentQty ?? 0; + const minusLocked = !canVoid && l.quantity <= sent; + const removeLocked = !canVoid && sent > 0; + return ( + <> +
+ + {fmt(l.quantity)} + +
+ {removeLocked ? ( + + + + ) : ( + + )} + + ); + })()} {l.synced ? ( // Already sent to the kitchen — note is read-only (can't be changed now).