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
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:
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user