575 lines
19 KiB
TypeScript
575 lines
19 KiB
TypeScript
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<unknown> {
|
|
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<ViewMode>("week");
|
|
const [anchor, setAnchor] = useState<Date>(initialDate ?? new Date());
|
|
const [filterActive, setFilterActive] = useState(true);
|
|
const [modal, setModal] = useState<ModalMode>({ 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<GridData>(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<string, ResolvedCell[]> | null {
|
|
const prev = snapshotGrid(key);
|
|
if (!prev) return null;
|
|
const userPrev = prev.cells[userId] ?? {};
|
|
const rolled: Record<string, ResolvedCell[]> = {};
|
|
const userNext: Record<string, ResolvedCell[]> = { ...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<GridData>(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<GridData>(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<GridData>(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<GridData>(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<string, ResolvedCell[]> | null;
|
|
};
|
|
if (!stash._rolled) return;
|
|
const prev = qc.getQueryData<GridData>(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;
|
|
}
|