fix(dashboard): review fixes — error toasts, dedupe socket, POS guards
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m11s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 47s
CI/CD / CI · Koja (tsc) (push) Successful in 52s
CI/CD / Deploy · all services (push) Successful in 3m20s

- Global MutationCache.onError safety net so mutations without their own onError
  no longer fail silently (skips ones that handle errors → no double toast).
- Notifications feed no longer opens its own SignalR connection; it reuses the
  one in useOrderAlerts (was double sockets + double cache churn per session).
- "Send test notification" now works on the settings page (force flag bypasses
  the tab-visible guard) instead of silently doing nothing.
- POS: re-entry guard on payment confirm (no duplicate payment on double-tap);
  notes on already-sent lines are read-only (a note-only edit was silently lost);
  ORDER_ALREADY_CLOSED surfaced with a clear Persian message.
- Reservation Confirm/Cancel/Complete buttons disabled while pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-22 15:54:02 +03:30
parent 63e3cb6962
commit 72abf05a5f
6 changed files with 53 additions and 46 deletions
@@ -7,7 +7,7 @@
// Mounted at /[locale]/pos (and /pos2). Design mirrors pos2-prototype.tsx.
// ─────────────────────────────────────────────────────────────────────────────
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Search, Plus, Minus, Trash2, Send, CreditCard, SplitSquareHorizontal,
@@ -123,6 +123,7 @@ export function Pos2Screen() {
const [payLoyalty, setPayLoyalty] = useState(0);
// Order just paid — kept after the cart is cleared so the receipt stays printable.
const [paidOrderId, setPaidOrderId] = useState<string | null>(null);
const payingRef = useRef(false); // re-entry guard for the payment confirm
const [online, setOnline] = useState(true);
useEffect(() => {
@@ -275,7 +276,8 @@ export function Pos2Screen() {
};
const confirmPay = async (payments: Payment[], loyaltyRedeem: number) => {
if (!payTarget) return;
if (!payTarget || payingRef.current) return; // guard against a double-tap
payingRef.current = true;
setBusy(true);
try {
const cardTotal = payments.filter((p) => p.method === "Card").reduce((s, p) => s + p.amount, 0);
@@ -306,10 +308,13 @@ export function Pos2Screen() {
notify.error(posDeviceMsg(e));
} else if (e instanceof ApiClientError && e.code === "NO_OPEN_SHIFT") {
notify.error("برای پرداخت باید شیفت باز باشد");
} else if (e instanceof ApiClientError && e.code === "ORDER_ALREADY_CLOSED") {
notify.error("این سفارش قبلاً تسویه شده است");
} else {
notify.error(errMsg(e, "ثبت پرداخت ناموفق بود"));
}
} finally {
payingRef.current = false;
setBusy(false);
}
};
@@ -355,7 +360,11 @@ export function Pos2Screen() {
);
const ticketProps = {
cafeId, lines: live, subtotal, discount, tax, total, count, pendingCount,
cafeId,
// 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 })),
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,
onNote: (id: string, notes: string) => setNotes(id, notes),
@@ -732,7 +741,7 @@ function Pos2Extras({ cafeId }: { cafeId: string }) {
}
// ── Order ticket ─────────────────────────────────────────────────────────────
type TicketLine = { menuItem: MenuItem; quantity: number; notes?: string };
type TicketLine = { menuItem: MenuItem; quantity: number; notes?: string; synced?: boolean };
function Ticket({
cafeId, lines, subtotal, discount, tax, total, count, pendingCount, onBump, onRemove, onNote, onSend, onPay, onSplit, canPrint, onPrintReceipt,
}: {
@@ -775,7 +784,14 @@ function Ticket({
<Trash2 className="size-4" />
</button>
</div>
{noteFor === l.menuItem.id || l.notes ? (
{l.synced ? (
// Already sent to the kitchen — note is read-only (can't be changed now).
l.notes ? (
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<StickyNote className="size-3.5 shrink-0" /> {l.notes}
</p>
) : null
) : noteFor === l.menuItem.id || l.notes ? (
<input
value={l.notes ?? ""}
onChange={(e) => onNote(l.menuItem.id, e.target.value)}