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
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:
@@ -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,18 +796,44 @@ 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>
|
||||
<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="کم">
|
||||
<Minus className="size-4" />
|
||||
</button>
|
||||
<span className="w-7 text-center font-bold">{fmt(l.quantity)}</span>
|
||||
<button type="button" onClick={() => onBump(l.menuItem.id, 1)} className="flex size-11 items-center justify-center rounded-lg bg-primary/10 text-primary hover:bg-primary/20 active:scale-95" aria-label="زیاد">
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
{(() => {
|
||||
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)}
|
||||
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>
|
||||
<button type="button" onClick={() => onBump(l.menuItem.id, 1)} className="flex size-11 items-center justify-center rounded-lg bg-primary/10 text-primary hover:bg-primary/20 active:scale-95" aria-label="زیاد">
|
||||
<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).
|
||||
|
||||
Reference in New Issue
Block a user