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.
|
// 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Search, Plus, Minus, Trash2, Send, CreditCard, SplitSquareHorizontal,
|
Search, Plus, Minus, Trash2, Send, CreditCard, SplitSquareHorizontal,
|
||||||
@@ -123,6 +123,7 @@ export function Pos2Screen() {
|
|||||||
const [payLoyalty, setPayLoyalty] = useState(0);
|
const [payLoyalty, setPayLoyalty] = useState(0);
|
||||||
// Order just paid — kept after the cart is cleared so the receipt stays printable.
|
// Order just paid — kept after the cart is cleared so the receipt stays printable.
|
||||||
const [paidOrderId, setPaidOrderId] = useState<string | null>(null);
|
const [paidOrderId, setPaidOrderId] = useState<string | null>(null);
|
||||||
|
const payingRef = useRef(false); // re-entry guard for the payment confirm
|
||||||
|
|
||||||
const [online, setOnline] = useState(true);
|
const [online, setOnline] = useState(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -275,7 +276,8 @@ export function Pos2Screen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmPay = async (payments: Payment[], loyaltyRedeem: number) => {
|
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);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const cardTotal = payments.filter((p) => p.method === "Card").reduce((s, p) => s + p.amount, 0);
|
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));
|
notify.error(posDeviceMsg(e));
|
||||||
} else if (e instanceof ApiClientError && e.code === "NO_OPEN_SHIFT") {
|
} else if (e instanceof ApiClientError && e.code === "NO_OPEN_SHIFT") {
|
||||||
notify.error("برای پرداخت باید شیفت باز باشد");
|
notify.error("برای پرداخت باید شیفت باز باشد");
|
||||||
|
} else if (e instanceof ApiClientError && e.code === "ORDER_ALREADY_CLOSED") {
|
||||||
|
notify.error("این سفارش قبلاً تسویه شده است");
|
||||||
} else {
|
} else {
|
||||||
notify.error(errMsg(e, "ثبت پرداخت ناموفق بود"));
|
notify.error(errMsg(e, "ثبت پرداخت ناموفق بود"));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
payingRef.current = false;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -355,7 +360,11 @@ export function Pos2Screen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ticketProps = {
|
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); },
|
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,
|
onRemove: removeItem, onSend: send, onPay: openPay, onSplit: openPay,
|
||||||
onNote: (id: string, notes: string) => setNotes(id, notes),
|
onNote: (id: string, notes: string) => setNotes(id, notes),
|
||||||
@@ -732,7 +741,7 @@ function Pos2Extras({ cafeId }: { cafeId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Order ticket ─────────────────────────────────────────────────────────────
|
// ── Order ticket ─────────────────────────────────────────────────────────────
|
||||||
type TicketLine = { menuItem: MenuItem; quantity: number; notes?: string };
|
type TicketLine = { menuItem: MenuItem; quantity: number; notes?: string; synced?: boolean };
|
||||||
function Ticket({
|
function Ticket({
|
||||||
cafeId, lines, subtotal, discount, tax, total, count, pendingCount, onBump, onRemove, onNote, onSend, onPay, onSplit, canPrint, onPrintReceipt,
|
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" />
|
<Trash2 className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<input
|
||||||
value={l.notes ?? ""}
|
value={l.notes ?? ""}
|
||||||
onChange={(e) => onNote(l.menuItem.id, e.target.value)}
|
onChange={(e) => onNote(l.menuItem.id, e.target.value)}
|
||||||
|
|||||||
@@ -1,16 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider, MutationCache } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ConfirmProvider } from "@/components/providers/confirm-provider";
|
import { ConfirmProvider } from "@/components/providers/confirm-provider";
|
||||||
import { MeeziToaster } from "@/components/ui/meezi-toaster";
|
import { MeeziToaster } from "@/components/ui/meezi-toaster";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { restoreQueryCache, startPersisting } from "@/lib/offline/query-persister";
|
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 }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
new QueryClient({
|
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: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export function ReservationsScreen() {
|
|||||||
<Can permission="EditReservation">
|
<Can permission="EditReservation">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={updateStatus.isPending}
|
||||||
onClick={() => updateStatus.mutate({ id: r.id, status: "Confirmed" })}
|
onClick={() => updateStatus.mutate({ id: r.id, status: "Confirmed" })}
|
||||||
>
|
>
|
||||||
{t("confirm")}
|
{t("confirm")}
|
||||||
@@ -243,6 +244,7 @@ export function ReservationsScreen() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
disabled={updateStatus.isPending}
|
||||||
onClick={() => updateStatus.mutate({ id: r.id, status: "Cancelled" })}
|
onClick={() => updateStatus.mutate({ id: r.id, status: "Cancelled" })}
|
||||||
>
|
>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
@@ -260,6 +262,7 @@ export function ReservationsScreen() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
disabled={updateStatus.isPending}
|
||||||
onClick={() => updateStatus.mutate({ id: r.id, status: "Completed" })}
|
onClick={() => updateStatus.mutate({ id: r.id, status: "Completed" })}
|
||||||
>
|
>
|
||||||
{t("markCompleted")}
|
{t("markCompleted")}
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export function SettingsNotificationsPanel() {
|
|||||||
disabled={!prefs.desktopEnabled}
|
disabled={!prefs.desktopEnabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (prefs.soundEnabled) playSound(prefs.soundId, prefs.volume);
|
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"));
|
notify.info(t("testToast"));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import * as signalR from "@microsoft/signalr";
|
|
||||||
import { useRouter } from "@/i18n/routing";
|
import { useRouter } from "@/i18n/routing";
|
||||||
import {
|
import {
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
@@ -10,23 +9,26 @@ import {
|
|||||||
type CafeNotification,
|
type CafeNotification,
|
||||||
} from "@/lib/api/notifications";
|
} from "@/lib/api/notifications";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { notify } from "@/lib/notify";
|
|
||||||
import { notificationDestinationFromStore } from "@/lib/notifications/notification-routes";
|
import { notificationDestinationFromStore } from "@/lib/notifications/notification-routes";
|
||||||
|
|
||||||
type UseNotificationsFeedOptions = {
|
type UseNotificationsFeedOptions = {
|
||||||
unreadOnly?: boolean;
|
unreadOnly?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
/** Show toast when a new guest order notification arrives (topbar). */
|
|
||||||
enableToasts?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type OpenNotificationOptions = {
|
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;
|
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 = {}) {
|
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 cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -45,37 +47,6 @@ export function useNotificationsFeed(options: UseNotificationsFeedOptions = {})
|
|||||||
void qc.invalidateQueries({ queryKey: ["notifications", cafeId] });
|
void qc.invalidateQueries({ queryKey: ["notifications", cafeId] });
|
||||||
}, [qc, 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(
|
const openNotification = useCallback(
|
||||||
async (n: CafeNotification, opts: OpenNotificationOptions = {}) => {
|
async (n: CafeNotification, opts: OpenNotificationOptions = {}) => {
|
||||||
if (!cafeId) return;
|
if (!cafeId) return;
|
||||||
|
|||||||
@@ -37,11 +37,13 @@ export function showDesktopNotification(opts: {
|
|||||||
tag?: string;
|
tag?: string;
|
||||||
/** In-app path to open when the popup is clicked. */
|
/** In-app path to open when the popup is clicked. */
|
||||||
path?: string | null;
|
path?: string | null;
|
||||||
|
/** Bypass the "only when tab hidden" rule — for the settings test button. */
|
||||||
|
force?: boolean;
|
||||||
}): void {
|
}): void {
|
||||||
if (!notificationsSupported()) return;
|
if (!notificationsSupported()) return;
|
||||||
if (!useNotifPrefs.getState().desktopEnabled) return;
|
if (!useNotifPrefs.getState().desktopEnabled) return;
|
||||||
if (Notification.permission !== "granted") 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 {
|
try {
|
||||||
const n = new Notification(opts.title, {
|
const n = new Notification(opts.title, {
|
||||||
body: opts.body,
|
body: opts.body,
|
||||||
|
|||||||
Reference in New Issue
Block a user