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,
|
Search, Plus, Minus, Trash2, Send, CreditCard, SplitSquareHorizontal,
|
||||||
X, WifiOff, ShoppingCart, Users, Coffee, ArrowRight, LayoutGrid, Armchair,
|
X, WifiOff, ShoppingCart, Users, Coffee, ArrowRight, LayoutGrid, Armchair,
|
||||||
Banknote, Check, Delete, ReceiptText, ShoppingBag, Loader2,
|
Banknote, Check, Delete, ReceiptText, ShoppingBag, Loader2,
|
||||||
BadgePercent, Sparkles, Home, StickyNote,
|
BadgePercent, Sparkles, Home, StickyNote, Lock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { notify } from "@/lib/notify";
|
import { notify } from "@/lib/notify";
|
||||||
@@ -27,6 +27,7 @@ import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
|
|||||||
import { printReceipt } from "@/lib/api/print";
|
import { printReceipt } from "@/lib/api/print";
|
||||||
import { PosCustomerPicker } from "@/components/pos2/pos-customer-picker";
|
import { PosCustomerPicker } from "@/components/pos2/pos-customer-picker";
|
||||||
import { Can } from "@/components/auth/can";
|
import { Can } from "@/components/auth/can";
|
||||||
|
import { useHasPermission } from "@/lib/permissions";
|
||||||
import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types";
|
import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types";
|
||||||
import { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2";
|
import { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2";
|
||||||
|
|
||||||
@@ -111,6 +112,11 @@ export function Pos2Screen() {
|
|||||||
const activeOrderId = useCartStore((s) => s.activeOrderId);
|
const activeOrderId = useCartStore((s) => s.activeOrderId);
|
||||||
const appliedCoupon = useCartStore((s) => s.appliedCoupon);
|
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
|
// local view state
|
||||||
const [view, setView] = useState<"board" | "order">("board");
|
const [view, setView] = useState<"board" | "order">("board");
|
||||||
const [activeTable, setActiveTable] = useState<TableBoardItem | null>(null);
|
const [activeTable, setActiveTable] = useState<TableBoardItem | null>(null);
|
||||||
@@ -361,12 +367,31 @@ export function Pos2Screen() {
|
|||||||
|
|
||||||
const ticketProps = {
|
const ticketProps = {
|
||||||
cafeId,
|
cafeId,
|
||||||
|
canVoid,
|
||||||
// mark fully-sent lines so their note becomes read-only (a note-only change on
|
// 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).
|
// 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,
|
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); },
|
onBump: (id: string, d: number) => {
|
||||||
onRemove: removeItem, onSend: send, onPay: openPay, onSplit: openPay,
|
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),
|
onNote: (id: string, notes: string) => setNotes(id, notes),
|
||||||
canPrint: !!activeOrderId && !isLocalOrder(activeOrderId),
|
canPrint: !!activeOrderId && !isLocalOrder(activeOrderId),
|
||||||
onPrintReceipt: printActiveReceipt,
|
onPrintReceipt: printActiveReceipt,
|
||||||
@@ -741,11 +766,11 @@ function Pos2Extras({ cafeId }: { cafeId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Order ticket ─────────────────────────────────────────────────────────────
|
// ── 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({
|
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;
|
count: number; pendingCount: number;
|
||||||
onBump: (id: string, d: number) => void; onRemove: (id: string) => void;
|
onBump: (id: string, d: number) => void; onRemove: (id: string) => void;
|
||||||
onNote: (id: string, notes: 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="line-clamp-1 font-medium">{l.menuItem.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{fmt(l.menuItem.price)} تومان</p>
|
<p className="text-xs text-muted-foreground">{fmt(l.menuItem.price)} تومان</p>
|
||||||
</div>
|
</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="کم">
|
const sent = l.sentQty ?? 0;
|
||||||
<Minus className="size-4" />
|
const minusLocked = !canVoid && l.quantity <= sent;
|
||||||
</button>
|
const removeLocked = !canVoid && sent > 0;
|
||||||
<span className="w-7 text-center font-bold">{fmt(l.quantity)}</span>
|
return (
|
||||||
<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" />
|
<div className="flex items-center gap-1">
|
||||||
</button>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<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="حذف">
|
onClick={() => onBump(l.menuItem.id, -1)}
|
||||||
<Trash2 className="size-4" />
|
disabled={minusLocked}
|
||||||
</button>
|
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>
|
</div>
|
||||||
{l.synced ? (
|
{l.synced ? (
|
||||||
// Already sent to the kitchen — note is read-only (can't be changed now).
|
// Already sent to the kitchen — note is read-only (can't be changed now).
|
||||||
|
|||||||
Reference in New Issue
Block a user