fix(dashboard): stale punch button/KPIs after attendance changes on other pages

Verified flow: clock in on dashboard -> delete the record in /attendance/admin
-> navigate back: 'Zaznamenat odchod', the Pritomni KPI and the presence card
still showed the pre-delete state until F5. Root cause: every attendance
mutation outside the dashboard invalidated only ["attendance"], never
["dashboard"], so within the dashboard query's 60s staleTime the remount was
served from cache.

- useAttendanceAdmin create/bulk/edit/delete, /attendance punch +
  leave-request, AttendanceCreate, LeaveApproval approve/reject now also
  invalidate ["dashboard"] (CLAUDE.md rule: mutations invalidate every domain
  that embeds their data)
- systemic backstop: dashboardOptions uses refetchOnMount "always" - the
  dashboard aggregates attendance/offers/invoices/orders/projects/leave, and
  domain pages can't all be expected to invalidate it; returning to the
  dashboard must never show pre-mutation data

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 11:40:06 +02:00
parent 4e534471d9
commit c2746d78c9
5 changed files with 28 additions and 5 deletions

View File

@@ -1048,6 +1048,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) { if (result.success) {
setShowCreateModal(false); setShowCreateModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] }); queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false); await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
alert.success( alert.success(
@@ -1120,6 +1124,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) { if (result.success) {
setShowBulkModal(false); setShowBulkModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] }); queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false); await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
alert.success( alert.success(
@@ -1255,6 +1263,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) { if (result.success) {
setShowEditModal(false); setShowEditModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] }); queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false); await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
alert.success( alert.success(
@@ -1285,6 +1297,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) { if (result.success) {
setDeleteConfirm({ show: false, record: null }); setDeleteConfirm({ show: false, record: null });
queryClient.invalidateQueries({ queryKey: ["attendance"] }); queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false); await fetchData(false);
alert.success( alert.success(
result.message || result.data?.message || "Záznam smazán", result.message || result.data?.message || "Záznam smazán",

View File

@@ -6,6 +6,11 @@ export const dashboardOptions = () =>
queryKey: ["dashboard"], queryKey: ["dashboard"],
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"), queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"),
staleTime: 60_000, staleTime: 60_000,
// The dashboard aggregates MANY domains (attendance, offers, invoices,
// orders, projects, leave). Mutations in those domains can't all be
// expected to invalidate ["dashboard"], so always refetch on mount —
// navigating back to the dashboard must never show pre-mutation data.
refetchOnMount: "always",
}); });
// require2FAOptions lives in ./settings.ts (the single definition consumers // require2FAOptions lives in ./settings.ts (the single definition consumers

View File

@@ -274,7 +274,8 @@ export default function Attendance() {
>({ >({
url: () => `${API_BASE}/attendance`, url: () => `${API_BASE}/attendance`,
method: () => "POST", method: () => "POST",
invalidate: ["attendance"], // dashboard included: the punch-button state + presence cards live there.
invalidate: ["attendance", "dashboard"],
}); });
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>( const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
@@ -300,7 +301,8 @@ export default function Attendance() {
>({ >({
url: () => `${API_BASE}/leave-requests`, url: () => `${API_BASE}/leave-requests`,
method: () => "POST", method: () => "POST",
invalidate: ["attendance", "leave-requests", "leave", "users"], // dashboard included: approvers see the pending-requests KPI there.
invalidate: ["attendance", "leave-requests", "leave", "users", "dashboard"],
}); });
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);

View File

@@ -74,7 +74,7 @@ export default function AttendanceCreate() {
const createMutation = useApiMutation<CreatePayload, { message?: string }>({ const createMutation = useApiMutation<CreatePayload, { message?: string }>({
url: () => `${API_BASE}/attendance`, url: () => `${API_BASE}/attendance`,
method: () => "POST", method: () => "POST",
invalidate: ["attendance", "users"], invalidate: ["attendance", "users", "dashboard"],
}); });
const [form, setForm] = useState<CreateForm>(() => { const [form, setForm] = useState<CreateForm>(() => {

View File

@@ -161,7 +161,7 @@ export default function LeaveApproval() {
>({ >({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`, url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "PUT", method: () => "PUT",
invalidate: ["leave-requests", "leave", "attendance", "users"], invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
onSuccess: () => { onSuccess: () => {
setApproveModal({ open: false, request: null }); setApproveModal({ open: false, request: null });
alert.success("Žádost byla schválena"); alert.success("Žádost byla schválena");
@@ -174,7 +174,7 @@ export default function LeaveApproval() {
>({ >({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`, url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "PUT", method: () => "PUT",
invalidate: ["leave-requests", "leave", "attendance", "users"], invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
onSuccess: () => { onSuccess: () => {
setRejectModal({ open: false, request: null }); setRejectModal({ open: false, request: null });
setRejectNote(""); setRejectNote("");