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)}
+16 -1
View File
@@ -1,16 +1,31 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider, MutationCache } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { ConfirmProvider } from "@/components/providers/confirm-provider";
import { MeeziToaster } from "@/components/ui/meezi-toaster";
import { useAuthStore } from "@/lib/stores/auth.store";
import { restoreQueryCache, startPersisting } from "@/lib/offline/query-persister";
import { notify, getErrorMessage } from "@/lib/notify";
/** Generic, locale-aware fallback for mutations that don't handle their own error. */
function globalMutationErrorFallback(): string {
const lang = typeof document !== "undefined" ? document.documentElement.lang : "fa";
return lang === "en" ? "Something went wrong" : lang === "ar" ? "حدث خطأ ما" : "خطایی رخ داد";
}
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
// Safety net: surface a toast for any mutation that doesn't define its own
// onError (skips ones that already handle it, so no double toasts).
mutationCache: new MutationCache({
onError: (error, _vars, _ctx, mutation) => {
if (mutation.options.onError) return;
notify.error(getErrorMessage(error, globalMutationErrorFallback()));
},
}),
defaultOptions: {
queries: {
staleTime: 30_000,
@@ -234,6 +234,7 @@ export function ReservationsScreen() {
<Can permission="EditReservation">
<Button
size="sm"
disabled={updateStatus.isPending}
onClick={() => updateStatus.mutate({ id: r.id, status: "Confirmed" })}
>
{t("confirm")}
@@ -243,6 +244,7 @@ export function ReservationsScreen() {
<Button
size="sm"
variant="outline"
disabled={updateStatus.isPending}
onClick={() => updateStatus.mutate({ id: r.id, status: "Cancelled" })}
>
{t("cancel")}
@@ -260,6 +262,7 @@ export function ReservationsScreen() {
<Button
size="sm"
variant="outline"
disabled={updateStatus.isPending}
onClick={() => updateStatus.mutate({ id: r.id, status: "Completed" })}
>
{t("markCompleted")}
@@ -186,7 +186,7 @@ export function SettingsNotificationsPanel() {
disabled={!prefs.desktopEnabled}
onClick={() => {
if (prefs.soundEnabled) playSound(prefs.soundId, prefs.volume);
showDesktopNotification({ title: t("testTitle"), body: t("testBody") });
showDesktopNotification({ title: t("testTitle"), body: t("testBody"), force: true });
notify.info(t("testToast"));
}}
>
@@ -1,8 +1,7 @@
"use client";
import { useCallback, useEffect } from "react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import * as signalR from "@microsoft/signalr";
import { useRouter } from "@/i18n/routing";
import {
fetchNotifications,
@@ -10,23 +9,26 @@ import {
type CafeNotification,
} from "@/lib/api/notifications";
import { useAuthStore } from "@/lib/stores/auth.store";
import { notify } from "@/lib/notify";
import { notificationDestinationFromStore } from "@/lib/notifications/notification-routes";
type UseNotificationsFeedOptions = {
unreadOnly?: boolean;
limit?: number;
/** Show toast when a new guest order notification arrives (topbar). */
enableToasts?: boolean;
};
type OpenNotificationOptions = {
/** Navigate to KDS/tables after marking read (notifications page only). */
/** Navigate to the related page after marking read (notifications page only). */
navigate?: boolean;
};
/**
* Notification list + read actions. The live SignalR connection is owned by
* useOrderAlerts (mounted once in the dashboard shell), which invalidates the
* ["notifications", cafeId] query on each incoming event — so this hook just
* reads the shared query (no second hub connection) and polls as a backstop.
*/
export function useNotificationsFeed(options: UseNotificationsFeedOptions = {}) {
const { unreadOnly = false, limit = 50, enableToasts = false } = options;
const { unreadOnly = false, limit = 50 } = options;
const cafeId = useAuthStore((s) => s.user?.cafeId);
const router = useRouter();
const qc = useQueryClient();
@@ -45,37 +47,6 @@ export function useNotificationsFeed(options: UseNotificationsFeedOptions = {})
void qc.invalidateQueries({ queryKey: ["notifications", cafeId] });
}, [qc, cafeId]);
useEffect(() => {
if (!cafeId) return;
const token = localStorage.getItem("meezi_access_token");
const baseUrl = process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
const connection = new signalR.HubConnectionBuilder()
.withUrl(`${baseUrl}/hubs/kds`, { accessTokenFactory: () => token ?? "" })
.withAutomaticReconnect()
.build();
connection
.start()
.then(() => connection.invoke("JoinCafe", cafeId))
.catch(() => undefined);
connection.on("NotificationReceived", (n: CafeNotification) => {
refresh();
if (enableToasts) {
if (n.type === "table_call_waiter") {
notify.warning(n.title, { description: n.body ?? undefined });
} else if (n.type === "guest_order_new") {
notify.info(n.title, { description: n.body ?? undefined });
}
}
});
connection.on("OrderCreated", refresh);
return () => {
void connection.stop();
};
}, [cafeId, refresh, enableToasts]);
const openNotification = useCallback(
async (n: CafeNotification, opts: OpenNotificationOptions = {}) => {
if (!cafeId) return;
@@ -37,11 +37,13 @@ export function showDesktopNotification(opts: {
tag?: string;
/** In-app path to open when the popup is clicked. */
path?: string | null;
/** Bypass the "only when tab hidden" rule — for the settings test button. */
force?: boolean;
}): void {
if (!notificationsSupported()) return;
if (!useNotifPrefs.getState().desktopEnabled) return;
if (Notification.permission !== "granted") return;
if (typeof document !== "undefined" && document.visibilityState === "visible") return;
if (!opts.force && typeof document !== "undefined" && document.visibilityState === "visible") return;
try {
const n = new Notification(opts.title, {
body: opts.body,