import { useState, useCallback, useMemo } from "react"; import { useQuery, useQueryClient, useMutation, keepPreviousData, } from "@tanstack/react-query"; import apiFetch from "../utils/api"; import { planKeys, GridData, ResolvedCell } from "../lib/queries/plan"; export type ViewMode = "week" | "month"; export type ModalMode = | { kind: "closed" } | { kind: "create"; userId: number; date: string } | { kind: "create-override"; userId: number; date: string } | { kind: "edit-range"; entryId: number; userId: number; date: string } | { kind: "edit-override"; overrideId: number; userId: number; date: string } | { kind: "day-in-range"; userId: number; date: string; entryId: number } | { kind: "day"; userId: number; date: string } | { kind: "view"; userId: number; date: string }; function isoDate(d: Date): string { return d.toISOString().slice(0, 10); } function startOfWeek(d: Date): Date { const day = d.getUTCDay(); // 0 = Sun const diff = day === 0 ? -6 : 1 - day; // Monday-start const r = new Date(d); r.setUTCDate(d.getUTCDate() + diff); return r; } function addDays(d: Date, n: number): Date { const r = new Date(d); r.setUTCDate(d.getUTCDate() + n); return r; } async function apiCall( url: string, options: RequestInit = {}, ): Promise { const res = await apiFetch(url, options); if (!res.ok) { let message = "Chyba serveru"; try { const body = await res.json(); if (body && typeof body.error === "string") message = body.error; } catch { // body wasn't JSON; use default } throw new Error(message); } try { return await res.json(); } catch { return null; } } export interface UsePlanWorkArgs { initialDate?: Date; canEdit: boolean; } export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { const qc = useQueryClient(); const [view, setView] = useState("week"); const [anchor, setAnchor] = useState(initialDate ?? new Date()); const [filterActive, setFilterActive] = useState(true); const [modal, setModal] = useState({ kind: "closed" }); const range = useMemo(() => { if (view === "week") { const from = startOfWeek(anchor); const to = addDays(from, 6); return { from, to }; } const from = new Date( Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth(), 1), ); const to = new Date( Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth() + 1, 0), ); return { from, to }; }, [view, anchor]); const gridQuery = useQuery({ queryKey: planKeys.grid(isoDate(range.from), isoDate(range.to), view), queryFn: () => apiFetch( `/api/admin/plan/grid?date_from=${isoDate(range.from)}&date_to=${isoDate(range.to)}&view=${view}`, ).then((r) => r.json().then((b: any) => b.data as GridData)), // keepPreviousData holds the old range's data visible while the new // range's fetch is in flight. Without this, `gridQuery.data` is // immediately undefined on key change and the grid flashes empty. // `isPlaceholderData` is true while the displayed data is stale. placeholderData: keepPreviousData, }); const invalidate = useCallback(() => { qc.invalidateQueries({ queryKey: planKeys.all }); // Plan mutations write audit-log rows, so they touch the audit domain // too. Invalidate the dashboard activity feed and the audit-log page so // they reflect the change without a manual F5 (prefix-matches all // ["audit-log", {...}] filter variants). The dashboard query is active // when visible and refetches immediately despite its staleTime. qc.invalidateQueries({ queryKey: ["dashboard"] }); qc.invalidateQueries({ queryKey: ["audit-log"] }); }, [qc]); // --- Optimistic cache patches --------------------------------------------- // After a mutation succeeds, we patch the visible grid query's data // *before* the refetch resolves. This is what stops the full-grid // re-animation: the visible data updates in place (and the new cell // gets a one-shot pulse via PlanWork's lastMutated state), so the grid // never unmounts. The subsequent invalidate+refetch confirms the patch // from the server (which is the source of truth) and is a no-op if the // server's data matches our patch. // // If the mutation fails, the mutation's onError handler in PlanWork // rolls the cache back to the snapshot we took before the patch. const currentGridKey = planKeys.grid( isoDate(range.from), isoDate(range.to), view, ); // Walk the days in [from, to] inclusive (YYYY-MM-DD strings). Used to // figure out which cell keys to patch for a range entry. function eachDay(from: string, to: string): string[] { const out: string[] = []; const a = new Date(from + "T00:00:00.000Z"); const b = new Date(to + "T00:00:00.000Z"); for (let d = new Date(a); d <= b; d.setUTCDate(d.getUTCDate() + 1)) { out.push(d.toISOString().slice(0, 10)); } return out; } // Snapshot the currently-cached grid for a given key. Returns // `undefined` if there's nothing cached (we don't patch empty data). function snapshotGrid(key: readonly unknown[]): GridData | undefined { return qc.getQueryData(key as any); } // Apply a per-date cell patch to the cached grid at `key`. Returns the // previous cells[userId] map so the caller can roll back on error. function patchCells( key: readonly unknown[], userId: number, dates: string[], mutator: (prev: ResolvedCell[]) => ResolvedCell[], ): Record | null { const prev = snapshotGrid(key); if (!prev) return null; const userPrev = prev.cells[userId] ?? {}; const rolled: Record = {}; const userNext: Record = { ...userPrev }; for (const date of dates) { rolled[date] = userPrev[date] ?? []; userNext[date] = mutator(rolled[date]).slice(0, 3); } qc.setQueryData(key, { ...prev, cells: { ...prev.cells, [userId]: userNext }, }); return rolled; } // Build a ResolvedCell for a freshly-created entry. The server returns // a row with the same fields, so we mirror that shape. We don't have // entryId/overrideId from the request body — but the createEntry // response *does* include id; we could plumb it through. For now we // leave entryId as a placeholder that the refetch will replace. // (PlanGrid only uses source/entryId/overrideId to decide edit flow; // a fresh cell from an entry is "entry" source — the refetch fills // the actual id within ~200ms.) function makeEntryCell(args: { userId: number; date: string; projectId: number | null; category: string; note: string; rangeFrom: string; rangeTo: string; entryId: number | null; }): ResolvedCell { return { source: "entry", entryId: args.entryId, overrideId: null, user_id: args.userId, shift_date: args.date, project_id: args.projectId, // Optimistic placeholder — the invalidate+refetch fills the real // project label (resolveGrid embeds it) within ~200ms. project_number: null, project_name: null, category: args.category, note: args.note, rangeFrom: args.rangeFrom, rangeTo: args.rangeTo, }; } function makeOverrideCell(args: { userId: number; date: string; projectId: number | null; category: string; note: string; overrideId: number | null; }): ResolvedCell { return { source: "override", entryId: null, overrideId: args.overrideId, user_id: args.userId, shift_date: args.date, project_id: args.projectId, // Optimistic placeholder — refetch fills the real project label. project_number: null, project_name: null, category: args.category, note: args.note, rangeFrom: null, rangeTo: null, }; } // --- Mutations --- const createEntry = useMutation({ mutationFn: (body: any) => apiCall("/api/admin/plan/entries", { method: "POST", body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, }), onSuccess: (data: any, body: any) => { // Patch the visible grid with the new entry. We use the response // id (server-assigned) for the new entry's entryId; the rest of // the ResolvedCell mirrors the request body. const days = eachDay(body.date_from, body.date_to); const id = data && typeof data.id === "number" ? data.id : null; const rolled = patchCells(currentGridKey, body.user_id, days, (prev) => [ makeEntryCell({ userId: body.user_id, date: days[0], projectId: body.project_id ?? null, category: body.category, note: body.note, rangeFrom: body.date_from, rangeTo: body.date_to, entryId: id, }), ...prev, ]); // Stash the rollback on the mutation object so PlanWork can call // it from onError. (createEntry as any)._rolled = rolled; invalidate(); }, }); const updateEntry = useMutation({ mutationFn: ({ id, body, force, }: { id: number; body: any; force?: boolean; }) => apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, { method: "PATCH", body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, }), onSuccess: (_data, vars) => { // We don't know the new full range without the response, but we // do have the body's date_from/date_to. If those are present, // patch the new range. Old cells outside the new range are NOT // cleared here — they'll either still be valid (date_from/to // were partial updates) or the refetch will fix them. const { id, body } = vars; if (body.date_from && body.date_to) { const days = eachDay(body.date_from, body.date_to); // Find the user that owns this entry in the current grid by // looking for any cell with entryId === id (we already know // the id from vars; it doesn't change across the update). const grid = qc.getQueryData(currentGridKey as any); let ownerUserId: number | null = null; if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const cells of Object.values(byDate)) { if (cells.some((c) => c.entryId === id)) { ownerUserId = Number(uidStr); break; } } if (ownerUserId !== null) break; } } if (ownerUserId !== null) { const rolled = patchCells( currentGridKey, ownerUserId, days, (prev) => { const existing = prev.find((c) => c.entryId === id) ?? null; const updated = makeEntryCell({ userId: ownerUserId!, date: days[0], projectId: body.project_id === undefined ? (existing?.project_id ?? null) : body.project_id, category: body.category ?? existing?.category ?? "work", note: body.note ?? existing?.note ?? "", rangeFrom: body.date_from, rangeTo: body.date_to, entryId: id, }); return [updated, ...prev.filter((c) => c.entryId !== id)]; }, ); (updateEntry as any)._rolled = rolled; } } invalidate(); }, }); const deleteEntry = useMutation({ mutationFn: ({ id, force }: { id: number; force?: boolean }) => apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, { method: "DELETE", }), onSuccess: (_data, vars) => { // For delete we need to know the entry's user_id and full range. // Look it up from the current grid: find the user that has a cell // with entryId === id, and read rangeFrom/rangeTo from that cell. const grid = qc.getQueryData(currentGridKey as any); if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { let range: { from: string; to: string } | null = null; for (const cells of Object.values(byDate)) { const hit = cells.find( (c) => c.entryId === vars.id && c.rangeFrom && c.rangeTo, ); if (hit) { range = { from: hit.rangeFrom!, to: hit.rangeTo! }; break; } } if (range) { const days = eachDay(range.from, range.to); const rolled = patchCells( currentGridKey, Number(uidStr), days, (prev) => prev.filter((c) => c.entryId !== vars.id), ); (deleteEntry as any)._rolled = rolled; break; } } } invalidate(); }, }); const createOverride = useMutation({ mutationFn: (body: any) => apiCall("/api/admin/plan/overrides", { method: "POST", body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, }), onSuccess: (data: any, vars: any) => { const id = data && typeof data.id === "number" ? data.id : null; const rolled = patchCells( currentGridKey, vars.user_id, [vars.shift_date], (prev) => [ makeOverrideCell({ userId: vars.user_id, date: vars.shift_date, projectId: vars.project_id ?? null, category: vars.category, note: vars.note, overrideId: id, }), ...prev, ], ); (createOverride as any)._rolled = rolled; invalidate(); }, }); const updateOverride = useMutation({ mutationFn: ({ id, body, force, }: { id: number; body: any; force?: boolean; }) => apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, { method: "PATCH", body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, }), onSuccess: (_data, vars) => { // Find the user/date for this overrideId in the current grid, then // patch that single cell with the new values. const grid = qc.getQueryData(currentGridKey as any); if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const [date, cells] of Object.entries(byDate)) { if (cells.some((c) => c.overrideId === vars.id)) { const rolled = patchCells( currentGridKey, Number(uidStr), [date], (prev) => { const existing = prev.find((c) => c.overrideId === vars.id) ?? null; const updated = makeOverrideCell({ userId: Number(uidStr), date, projectId: vars.body.project_id ?? existing?.project_id ?? null, category: vars.body.category ?? existing?.category ?? "work", note: vars.body.note ?? existing?.note ?? "", overrideId: vars.id, }); return [ updated, ...prev.filter((c) => c.overrideId !== vars.id), ]; }, ); (updateOverride as any)._rolled = rolled; break; } } } } invalidate(); }, }); const deleteOverride = useMutation({ mutationFn: ({ id, force }: { id: number; force?: boolean }) => apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, { method: "DELETE", }), onSuccess: (_data, vars) => { const grid = qc.getQueryData(currentGridKey as any); if (grid) { for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const [date, cells] of Object.entries(byDate)) { if (cells.some((c) => c.overrideId === vars.id)) { const rolled = patchCells( currentGridKey, Number(uidStr), [date], (prev) => prev.filter((c) => c.overrideId !== vars.id), ); (deleteOverride as any)._rolled = rolled; break; } } } } invalidate(); }, }); const bulkCreate = useMutation({ mutationFn: (body: any) => apiCall("/api/admin/plan/entries/bulk", { method: "POST", body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, }), onSuccess: () => { // No optimistic patch — bulk can touch many cells; just invalidate and // let the grid refetch the authoritative state. invalidate(); }, }); // Roll back an optimistic patch on mutation error. Called from // PlanWork's mutation wrappers via `rollbackMutation(mutation, key)`. // `mutation` is a TanStack mutation result with a `_rolled` snapshot stashed // on it (see the `(createEntry as any)._rolled = …` writes above). Typed as // `unknown` + an explicit cast so callers can pass the mutation object // directly without the "weak type" mismatch a `{ _rolled? }` param causes. function rollbackMutation(mutation: unknown, userId: number) { const stash = mutation as { _rolled?: Record | null; }; if (!stash._rolled) return; const prev = qc.getQueryData(currentGridKey as any); if (!prev) return; const userPrev = prev.cells[userId] ?? {}; const userNext = { ...userPrev }; for (const [date, cells] of Object.entries(stash._rolled)) { userNext[date] = cells; } qc.setQueryData(currentGridKey, { ...prev, cells: { ...prev.cells, [userId]: userNext }, }); stash._rolled = null; } return { view, setView, anchor, setAnchor, filterActive, setFilterActive, range, grid: gridQuery.data, gridLoading: gridQuery.isLoading, gridFetching: gridQuery.isFetching, gridIsPlaceholder: gridQuery.isPlaceholderData, modal, setModal, canEdit, createEntry, updateEntry, deleteEntry, createOverride, updateOverride, deleteOverride, bulkCreate, invalidate, // Exposed so PlanWork can roll back an optimistic patch when a // mutation throws. Pass the failed mutation and the userId it // targeted. rollbackMutation, }; } export function getCells( grid: GridData | undefined, userId: number, date: string, ): ResolvedCell[] { return grid?.cells?.[userId]?.[date] ?? []; } export function getCell( grid: GridData | undefined, userId: number, date: string, ): ResolvedCell | null { return getCells(grid, userId, date)[0] ?? null; }