diff --git a/src/admin/components/dashboard/DashProfile.tsx b/src/admin/components/dashboard/DashProfile.tsx index edbee99..8d25e10 100644 --- a/src/admin/components/dashboard/DashProfile.tsx +++ b/src/admin/components/dashboard/DashProfile.tsx @@ -12,6 +12,7 @@ import DialogActions from "@mui/material/DialogActions"; import { useAuth } from "../../context/AuthContext"; import { useAlert } from "../../context/AlertContext"; import apiFetch from "../../utils/api"; +import { USER_INVALIDATE } from "../../lib/queries/users"; import { iconBadgeSx } from "../../theme"; import { Card, Button, Modal, Field, TextField } from "../../ui"; import useDialogScrollLock from "../../ui/useDialogScrollLock"; @@ -142,6 +143,9 @@ export default function DashProfile({ const { data: totpQrDataUrl, isError: totpQrFailed } = useQuery({ queryKey: ["totp", "qr", totpQrUri], enabled: !!totpQrUri, + // Purely local QR generation — the global "server data" error toast would + // misattribute a failure here; the totpQrFailed inline message covers it. + meta: { suppressGlobalErrorToast: true }, staleTime: Infinity, // The URI embeds the TOTP secret — drop it from the cache as soon as the // enrollment UI unmounts instead of keeping it for the default 5-min GC. @@ -221,9 +225,13 @@ export default function DashProfile({ fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(), }); // Refresh anything keyed on the current user's data so stale views - // (dashboard widgets, user lists) pick up the edited profile. + // pick up the edited profile — a self-rename must also refresh the + // domains embedding the display name (USER_INVALIDATE), not just the + // dashboard and user list. + for (const key of USER_INVALIDATE) { + queryClient.invalidateQueries({ queryKey: [key] }); + } queryClient.invalidateQueries({ queryKey: ["dashboard"] }); - queryClient.invalidateQueries({ queryKey: ["users"] }); setShowModal(false); // The 300ms wait is load-bearing: it lets the modal's close fade finish // before the success toast appears, so the toast doesn't flash over the diff --git a/src/admin/context/AlertContext.tsx b/src/admin/context/AlertContext.tsx index 326dfc2..ba4ef98 100644 --- a/src/admin/context/AlertContext.tsx +++ b/src/admin/context/AlertContext.tsx @@ -8,6 +8,7 @@ import { useEffect, type ReactNode, } from "react"; +import { registerQueryErrorNotifier } from "../lib/queryClient"; interface Alert { id: string; @@ -81,6 +82,13 @@ export function AlertProvider({ children }: { children: ReactNode }) { [addAlert, removeAlert], ); + // Global React Query error toasts (queryClient.ts). `addAlert` is a stable + // useCallback, so this registers once on mount; re-registering the same + // handler is idempotent either way. + useEffect(() => { + registerQueryErrorNotifier((msg) => addAlert(msg, "error")); + }, [addAlert]); + return ( diff --git a/src/admin/lib/queries/auditLog.ts b/src/admin/lib/queries/auditLog.ts deleted file mode 100644 index 0f1c83a..0000000 --- a/src/admin/lib/queries/auditLog.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { queryOptions } from "@tanstack/react-query"; -import { jsonQuery } from "../apiAdapter"; - -export const auditLogOptions = (filters: { - search?: string; - action?: string; - entityType?: string; - dateFrom?: string; - dateTo?: string; - page?: number; -}) => - queryOptions({ - queryKey: ["audit-log", filters], - queryFn: () => { - const params = new URLSearchParams(); - if (filters.search) params.set("search", filters.search); - if (filters.action) params.set("action", filters.action); - if (filters.entityType) params.set("entity_type", filters.entityType); - if (filters.dateFrom) params.set("date_from", filters.dateFrom); - if (filters.dateTo) params.set("date_to", filters.dateTo); - if (filters.page) params.set("page", String(filters.page)); - const qs = params.toString(); - return jsonQuery<{ - data: Record[]; - pagination: Record; - }>(`/api/admin/audit-log${qs ? `?${qs}` : ""}`); - }, - }); diff --git a/src/admin/lib/queries/issued-orders.ts b/src/admin/lib/queries/issued-orders.ts index 37f7e48..fb38354 100644 --- a/src/admin/lib/queries/issued-orders.ts +++ b/src/admin/lib/queries/issued-orders.ts @@ -144,6 +144,9 @@ export const issuedOrderDetailOptions = (id: string | undefined) => // 404s (deleted/missing orders) are not transient. Retrying just spams // GETs and keeps the detail page on an infinite spinner. retry: false, + // IssuedOrderDetail toasts its own Czech error + navigates away (and + // suppresses it during delete) — the global QueryCache toast would double up. + meta: { suppressGlobalErrorToast: true }, }); export const issuedOrderNextNumberOptions = () => diff --git a/src/admin/lib/queries/offers.ts b/src/admin/lib/queries/offers.ts index 11b15f0..d0e80d7 100644 --- a/src/admin/lib/queries/offers.ts +++ b/src/admin/lib/queries/offers.ts @@ -195,6 +195,9 @@ export const offerDetailOptions = (id: string | undefined) => // 404s (deleted/missing offers) are not transient. Retrying just spams // GETs and fires the detail-page redirect toast repeatedly. retry: false, + // OfferDetail toasts its own Czech error + navigates away (and suppresses + // it during delete) — the global QueryCache toast would double up. + meta: { suppressGlobalErrorToast: true }, }); export const offerNextNumberOptions = () => diff --git a/src/admin/lib/queries/users.ts b/src/admin/lib/queries/users.ts index 9e3ce42..a05e335 100644 --- a/src/admin/lib/queries/users.ts +++ b/src/admin/lib/queries/users.ts @@ -18,6 +18,20 @@ export interface Role { display_name: string; } +/** + * Query-key domains to invalidate on any user create/update/delete: a user's + * display name is embedded in these domains (CLAUDE.md: "user CRUD → + * trips+attendance"). + */ +export const USER_INVALIDATE: readonly string[] = [ + "users", + "trips", + "attendance", + "leave-requests", + "leave", + "projects", +]; + export const userListOptions = (permission?: string) => queryOptions({ queryKey: ["users", { permission }], diff --git a/src/admin/lib/queryClient.ts b/src/admin/lib/queryClient.ts index a038cbf..03ec01a 100644 --- a/src/admin/lib/queryClient.ts +++ b/src/admin/lib/queryClient.ts @@ -1,6 +1,39 @@ -import { QueryClient } from "@tanstack/react-query"; +import { QueryCache, QueryClient } from "@tanstack/react-query"; + +/** + * Global query-error notifier. AlertContext registers its `error()` toast + * here on mount; until then failures fall back to console.error. + */ +let queryErrorNotifier: ((msg: string) => void) | null = null; + +export function registerQueryErrorNotifier(fn: (msg: string) => void) { + queryErrorNotifier = fn; +} + +/** Rate limit: at most one query-error toast per window (a page can have many failing queries). */ +const ERROR_TOAST_WINDOW_MS = 4000; +let lastErrorToastAt = 0; export const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (error, query) => { + // Pages with bespoke error handling (detail pages that toast + navigate, + // purely-local queries like QR generation) opt out via query meta. + if (query.meta?.suppressGlobalErrorToast) return; + // Background refetch failure with stale data still on screen — stay silent. + if (query.state.data !== undefined) return; + // 401s are handled by apiFetch/AuthContext (token refresh / logout). + if (error instanceof Error && error.message === "Unauthorized") return; + const now = Date.now(); + if (now - lastErrorToastAt < ERROR_TOAST_WINDOW_MS) return; + lastErrorToastAt = now; + if (queryErrorNotifier) { + queryErrorNotifier("Nepodařilo se načíst data ze serveru"); + } else { + console.error("Query failed before alert notifier registered:", error); + } + }, + }), defaultOptions: { queries: { staleTime: 30_000, diff --git a/src/admin/pages/AuditLog.tsx b/src/admin/pages/AuditLog.tsx index ff3dc27..dc9f3e4 100644 --- a/src/admin/pages/AuditLog.tsx +++ b/src/admin/pages/AuditLog.tsx @@ -6,6 +6,7 @@ import { useAuth } from "../context/AuthContext"; import { useAlert } from "../context/AlertContext"; import Forbidden from "../components/Forbidden"; import { czechPlural } from "../utils/formatters"; +import useDebounce from "../hooks/useDebounce"; import apiFetch from "../utils/api"; import { useApiMutation } from "../lib/queries/mutations"; import { ENTITY_TYPE_LABELS } from "../lib/entityTypeLabels"; @@ -122,6 +123,9 @@ export default function AuditLog() { date_from: "", date_to: "", }); + // Raw value stays in the TextField; only the debounced value hits the + // queryKey/request, so typing doesn't refire the query on every keystroke. + const debouncedSearch = useDebounce(filters.search, 300); const [page, setPage] = useState(1); const [perPage] = useState(50); const [showCleanup, setShowCleanup] = useState(false); @@ -136,7 +140,7 @@ export default function AuditLog() { queryKey: [ "audit-log", { - search: filters.search, + search: debouncedSearch, action: filters.action, entityType: filters.entity_type, dateFrom: filters.date_from, @@ -150,7 +154,7 @@ export default function AuditLog() { page: String(page), per_page: String(perPage), }); - if (filters.search) params.set("search", filters.search); + if (debouncedSearch) params.set("search", debouncedSearch); if (filters.action) params.set("action", filters.action); if (filters.entity_type) params.set("entity_type", filters.entity_type); if (filters.date_from) params.set("date_from", filters.date_from); @@ -177,6 +181,12 @@ export default function AuditLog() { // doesn't drop to (which unmounts PageEnter and replays the // whole entrance animation on every filter change). placeholderData: keepPreviousData, + // logAudit() writes on every mutation app-wide and none of those + // mutations invalidate ["audit-log"], so cached data goes stale the + // moment anything happens elsewhere — always refetch on mount (same + // rationale as dashboardOptions). + staleTime: 0, + refetchOnMount: "always", }); const logs: AuditLogEntry[] = logsData?.data ?? []; diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index 9010252..95ddbae 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -1051,7 +1051,7 @@ export default function InvoiceDetail() { >({ url: () => (isEdit ? `${API_BASE}/invoices/${id}` : `${API_BASE}/invoices`), method: () => (isEdit ? "PUT" : "POST"), - invalidate: ["invoices", "orders"], + invalidate: ["invoices", "orders", "projects"], onSuccess: (data) => { const invoiceId = isEdit ? Number(id) : data.invoice_id; // PDF binary generation — KEEP as raw apiFetch @@ -1064,13 +1064,13 @@ export default function InvoiceDetail() { const statusMutation = useApiMutation<{ status: string }, unknown>({ url: () => `${API_BASE}/invoices/${id}`, method: () => "PUT", - invalidate: ["invoices", "orders"], + invalidate: ["invoices", "orders", "projects"], }); const invoiceDeleteMutation = useApiMutation({ url: () => `${API_BASE}/invoices/${id}`, method: () => "DELETE", - invalidate: ["invoices", "orders"], + invalidate: ["invoices", "orders", "projects"], }); const handleCreateSubmit = async (targetStatus?: string) => { diff --git a/src/admin/pages/LeaveRequests.tsx b/src/admin/pages/LeaveRequests.tsx index 987d252..98245da 100644 --- a/src/admin/pages/LeaveRequests.tsx +++ b/src/admin/pages/LeaveRequests.tsx @@ -87,7 +87,7 @@ export default function LeaveRequests() { >({ url: ({ id }) => `${API_BASE}/leave-requests/${id}`, method: () => "DELETE", - invalidate: ["leave-requests", "leave", "attendance"], + invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"], onSuccess: (data) => { setCancelModal({ open: false, id: null }); alert.success(data?.message || "Žádost byla zrušena"); diff --git a/src/admin/pages/OrderDetail.tsx b/src/admin/pages/OrderDetail.tsx index f433870..e162242 100644 --- a/src/admin/pages/OrderDetail.tsx +++ b/src/admin/pages/OrderDetail.tsx @@ -116,7 +116,7 @@ export default function OrderDetail() { useEffect(() => { if (orderQuery.error) { alert.error("Nepodařilo se načíst objednávku"); - navigate("/orders"); + navigate("/orders?tab=prijate"); } }, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps @@ -159,13 +159,13 @@ export default function OrderDetail() { const statusMutation = useApiMutation<{ status: string }, unknown>({ url: () => `${API_BASE}/orders/${id}`, method: () => "PUT", - invalidate: ["orders", "invoices"], + invalidate: ["orders", "offers", "projects", "invoices"], }); const notesMutation = useApiMutation<{ notes: string }, unknown>({ url: () => `${API_BASE}/orders/${id}`, method: () => "PUT", - invalidate: ["orders", "invoices"], + invalidate: ["orders", "offers", "projects", "invoices"], }); const orderDeleteMutation = useApiMutation< @@ -174,7 +174,7 @@ export default function OrderDetail() { >({ url: () => `${API_BASE}/orders/${id}`, method: () => "DELETE", - invalidate: ["orders", "invoices"], + invalidate: ["orders", "offers", "projects", "invoices"], }); if (!hasPermission("orders.view")) return ; @@ -277,7 +277,7 @@ export default function OrderDetail() { try { await orderDeleteMutation.mutateAsync({ delete_files: deleteFiles }); alert.success("Objednávka byla smazána"); - navigate("/orders"); + navigate("/orders?tab=prijate"); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } finally { @@ -396,7 +396,7 @@ export default function OrderDetail() { >