|
|
|
|
@@ -59,6 +59,49 @@ async function apiCall(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Request-body shapes for the plan mutations. The server validates the full
|
|
|
|
|
// schema; these capture the fields the optimistic patches read.
|
|
|
|
|
export interface PlanEntryBody {
|
|
|
|
|
user_id: number;
|
|
|
|
|
date_from: string;
|
|
|
|
|
date_to: string;
|
|
|
|
|
project_id?: number | null;
|
|
|
|
|
category: string;
|
|
|
|
|
note: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface PlanEntryPatchBody {
|
|
|
|
|
date_from?: string;
|
|
|
|
|
date_to?: string;
|
|
|
|
|
project_id?: number | null;
|
|
|
|
|
category?: string;
|
|
|
|
|
note?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface PlanOverrideBody {
|
|
|
|
|
user_id: number;
|
|
|
|
|
shift_date: string;
|
|
|
|
|
project_id?: number | null;
|
|
|
|
|
category: string;
|
|
|
|
|
note: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface PlanOverridePatchBody {
|
|
|
|
|
project_id?: number | null;
|
|
|
|
|
category?: string;
|
|
|
|
|
note?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface BulkCreateBody {
|
|
|
|
|
user_ids: number[];
|
|
|
|
|
date_from: string;
|
|
|
|
|
date_to: string;
|
|
|
|
|
include_weekends: boolean;
|
|
|
|
|
project_id: number | null;
|
|
|
|
|
category: string;
|
|
|
|
|
note: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface UsePlanWorkArgs {
|
|
|
|
|
initialDate?: Date;
|
|
|
|
|
canEdit: boolean;
|
|
|
|
|
@@ -88,10 +131,24 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|
|
|
|
|
|
|
|
|
const gridQuery = useQuery({
|
|
|
|
|
queryKey: planKeys.grid(isoDate(range.from), isoDate(range.to), view),
|
|
|
|
|
queryFn: () =>
|
|
|
|
|
apiFetch(
|
|
|
|
|
queryFn: async (): Promise<GridData> => {
|
|
|
|
|
const res = await 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)),
|
|
|
|
|
);
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
let message = "Chyba serveru";
|
|
|
|
|
try {
|
|
|
|
|
const errBody = await res.json();
|
|
|
|
|
if (errBody && typeof errBody.error === "string")
|
|
|
|
|
message = errBody.error;
|
|
|
|
|
} catch {
|
|
|
|
|
// body wasn't JSON; use default
|
|
|
|
|
}
|
|
|
|
|
throw new Error(message);
|
|
|
|
|
}
|
|
|
|
|
const body = (await res.json()) as { data: GridData };
|
|
|
|
|
return body.data;
|
|
|
|
|
},
|
|
|
|
|
// 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.
|
|
|
|
|
@@ -232,20 +289,54 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Mutations ---
|
|
|
|
|
//
|
|
|
|
|
// Optimistic-update pattern (idiomatic TanStack Query):
|
|
|
|
|
// onMutate → apply the optimistic grid patch and RETURN the rollback
|
|
|
|
|
// snapshot as this invocation's context (concurrency-safe:
|
|
|
|
|
// each in-flight mutation owns its own context object, so two
|
|
|
|
|
// concurrent mutations can't clobber each other's snapshot —
|
|
|
|
|
// the previous `(mutation as any)._rolled = …` stash could).
|
|
|
|
|
// onError → restore the cells captured in onMutate's context.
|
|
|
|
|
// onSettled → invalidate the domain so the server (source of truth)
|
|
|
|
|
// reconciles the patch on both success AND error.
|
|
|
|
|
// The visible UX is unchanged: the new/updated cell still appears
|
|
|
|
|
// immediately and the broad invalidate still refetches the grid.
|
|
|
|
|
|
|
|
|
|
const createEntry = useMutation({
|
|
|
|
|
mutationFn: (body: any) =>
|
|
|
|
|
type CellRollback = {
|
|
|
|
|
key: readonly unknown[];
|
|
|
|
|
userId: number;
|
|
|
|
|
rolled: Record<string, ResolvedCell[]> | null;
|
|
|
|
|
} | null;
|
|
|
|
|
|
|
|
|
|
// Restore the cells a patch overwrote, from an onMutate-returned context.
|
|
|
|
|
// `ctx` may be `undefined` (e.g. if onMutate itself threw) — guarded below.
|
|
|
|
|
function rollbackCells(ctx: CellRollback | undefined) {
|
|
|
|
|
if (!ctx || !ctx.rolled) return;
|
|
|
|
|
const prev = qc.getQueryData<GridData>(ctx.key as any);
|
|
|
|
|
if (!prev) return;
|
|
|
|
|
const userPrev = prev.cells[ctx.userId] ?? {};
|
|
|
|
|
const userNext = { ...userPrev };
|
|
|
|
|
for (const [date, cells] of Object.entries(ctx.rolled)) {
|
|
|
|
|
userNext[date] = cells;
|
|
|
|
|
}
|
|
|
|
|
qc.setQueryData(ctx.key, {
|
|
|
|
|
...prev,
|
|
|
|
|
cells: { ...prev.cells, [ctx.userId]: userNext },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const createEntry = useMutation<unknown, Error, PlanEntryBody, CellRollback>({
|
|
|
|
|
mutationFn: (body: PlanEntryBody) =>
|
|
|
|
|
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.
|
|
|
|
|
onMutate: (body): CellRollback => {
|
|
|
|
|
// Patch the visible grid with the new entry. We don't have the
|
|
|
|
|
// server-assigned id yet (entryId stays null) — the refetch fills it
|
|
|
|
|
// within ~200ms; PlanGrid only uses entryId for the edit flow.
|
|
|
|
|
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,
|
|
|
|
|
@@ -255,135 +346,134 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|
|
|
|
note: body.note,
|
|
|
|
|
rangeFrom: body.date_from,
|
|
|
|
|
rangeTo: body.date_to,
|
|
|
|
|
entryId: id,
|
|
|
|
|
entryId: null,
|
|
|
|
|
}),
|
|
|
|
|
...prev,
|
|
|
|
|
]);
|
|
|
|
|
// Stash the rollback on the mutation object so PlanWork can call
|
|
|
|
|
// it from onError.
|
|
|
|
|
(createEntry as any)._rolled = rolled;
|
|
|
|
|
invalidate();
|
|
|
|
|
return { key: currentGridKey, userId: body.user_id, rolled };
|
|
|
|
|
},
|
|
|
|
|
onError: (_err, _body, ctx) => rollbackCells(ctx),
|
|
|
|
|
onSettled: () => invalidate(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const updateEntry = useMutation({
|
|
|
|
|
mutationFn: ({
|
|
|
|
|
id,
|
|
|
|
|
body,
|
|
|
|
|
force,
|
|
|
|
|
}: {
|
|
|
|
|
id: number;
|
|
|
|
|
body: any;
|
|
|
|
|
force?: boolean;
|
|
|
|
|
}) =>
|
|
|
|
|
const updateEntry = useMutation<
|
|
|
|
|
unknown,
|
|
|
|
|
Error,
|
|
|
|
|
{ id: number; body: PlanEntryPatchBody; force?: boolean },
|
|
|
|
|
CellRollback
|
|
|
|
|
>({
|
|
|
|
|
mutationFn: ({ id, body, force }) =>
|
|
|
|
|
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
}),
|
|
|
|
|
onSuccess: (_data, vars) => {
|
|
|
|
|
onMutate: ({ id, body }): CellRollback => {
|
|
|
|
|
// 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 (!body.date_from || !body.date_to) return null;
|
|
|
|
|
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;
|
|
|
|
|
if (ownerUserId !== null) break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
invalidate();
|
|
|
|
|
if (ownerUserId === null) return 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)];
|
|
|
|
|
});
|
|
|
|
|
return { key: currentGridKey, userId: ownerUserId, rolled };
|
|
|
|
|
},
|
|
|
|
|
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
|
|
|
|
onSettled: () => invalidate(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const deleteEntry = useMutation({
|
|
|
|
|
mutationFn: ({ id, force }: { id: number; force?: boolean }) =>
|
|
|
|
|
const deleteEntry = useMutation<
|
|
|
|
|
unknown,
|
|
|
|
|
Error,
|
|
|
|
|
{ id: number; force?: boolean },
|
|
|
|
|
CellRollback
|
|
|
|
|
>({
|
|
|
|
|
mutationFn: ({ id, force }) =>
|
|
|
|
|
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
}),
|
|
|
|
|
onSuccess: (_data, vars) => {
|
|
|
|
|
onMutate: (vars): CellRollback => {
|
|
|
|
|
// 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;
|
|
|
|
|
if (!grid) return null;
|
|
|
|
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
|
|
|
|
let entryRange: { 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) {
|
|
|
|
|
entryRange = { from: hit.rangeFrom!, to: hit.rangeTo! };
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (entryRange) {
|
|
|
|
|
const days = eachDay(entryRange.from, entryRange.to);
|
|
|
|
|
const rolled = patchCells(
|
|
|
|
|
currentGridKey,
|
|
|
|
|
Number(uidStr),
|
|
|
|
|
days,
|
|
|
|
|
(prev) => prev.filter((c) => c.entryId !== vars.id),
|
|
|
|
|
);
|
|
|
|
|
return { key: currentGridKey, userId: Number(uidStr), rolled };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
invalidate();
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
|
|
|
|
onSettled: () => invalidate(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const createOverride = useMutation({
|
|
|
|
|
mutationFn: (body: any) =>
|
|
|
|
|
const createOverride = useMutation<
|
|
|
|
|
unknown,
|
|
|
|
|
Error,
|
|
|
|
|
PlanOverrideBody,
|
|
|
|
|
CellRollback
|
|
|
|
|
>({
|
|
|
|
|
mutationFn: (body: PlanOverrideBody) =>
|
|
|
|
|
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;
|
|
|
|
|
onMutate: (vars): CellRollback => {
|
|
|
|
|
// overrideId stays null optimistically; the refetch fills the real id.
|
|
|
|
|
const rolled = patchCells(
|
|
|
|
|
currentGridKey,
|
|
|
|
|
vars.user_id,
|
|
|
|
|
@@ -395,101 +485,103 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|
|
|
|
projectId: vars.project_id ?? null,
|
|
|
|
|
category: vars.category,
|
|
|
|
|
note: vars.note,
|
|
|
|
|
overrideId: id,
|
|
|
|
|
overrideId: null,
|
|
|
|
|
}),
|
|
|
|
|
...prev,
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
(createOverride as any)._rolled = rolled;
|
|
|
|
|
invalidate();
|
|
|
|
|
return { key: currentGridKey, userId: vars.user_id, rolled };
|
|
|
|
|
},
|
|
|
|
|
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
|
|
|
|
onSettled: () => invalidate(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const updateOverride = useMutation({
|
|
|
|
|
mutationFn: ({
|
|
|
|
|
id,
|
|
|
|
|
body,
|
|
|
|
|
force,
|
|
|
|
|
}: {
|
|
|
|
|
id: number;
|
|
|
|
|
body: any;
|
|
|
|
|
force?: boolean;
|
|
|
|
|
}) =>
|
|
|
|
|
const updateOverride = useMutation<
|
|
|
|
|
unknown,
|
|
|
|
|
Error,
|
|
|
|
|
{ id: number; body: PlanOverridePatchBody; force?: boolean },
|
|
|
|
|
CellRollback
|
|
|
|
|
>({
|
|
|
|
|
mutationFn: ({ id, body, force }) =>
|
|
|
|
|
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
}),
|
|
|
|
|
onSuccess: (_data, vars) => {
|
|
|
|
|
onMutate: (vars): CellRollback => {
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
if (!grid) return null;
|
|
|
|
|
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),
|
|
|
|
|
];
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
return { key: currentGridKey, userId: Number(uidStr), rolled };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
invalidate();
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
|
|
|
|
onSettled: () => invalidate(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const deleteOverride = useMutation({
|
|
|
|
|
mutationFn: ({ id, force }: { id: number; force?: boolean }) =>
|
|
|
|
|
const deleteOverride = useMutation<
|
|
|
|
|
unknown,
|
|
|
|
|
Error,
|
|
|
|
|
{ id: number; force?: boolean },
|
|
|
|
|
CellRollback
|
|
|
|
|
>({
|
|
|
|
|
mutationFn: ({ id, force }) =>
|
|
|
|
|
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
}),
|
|
|
|
|
onSuccess: (_data, vars) => {
|
|
|
|
|
onMutate: (vars): CellRollback => {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
if (!grid) return null;
|
|
|
|
|
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),
|
|
|
|
|
);
|
|
|
|
|
return { key: currentGridKey, userId: Number(uidStr), rolled };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
invalidate();
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
|
|
|
|
onSettled: () => invalidate(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const bulkCreate = useMutation({
|
|
|
|
|
mutationFn: (body: any) =>
|
|
|
|
|
const bulkCreate = useMutation<unknown, Error, BulkCreateBody>({
|
|
|
|
|
mutationFn: (body: BulkCreateBody) =>
|
|
|
|
|
apiCall("/api/admin/plan/entries/bulk", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
@@ -502,29 +594,15 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
// Rollback is now handled idiomatically inside each mutation's `onError`
|
|
|
|
|
// from its `onMutate`-returned context (concurrency-safe per-invocation
|
|
|
|
|
// snapshot). This is kept as a no-op for the existing call sites in
|
|
|
|
|
// PlanWork.tsx so they continue to compile unchanged; by the time a
|
|
|
|
|
// `mutateAsync` promise rejects, the mutation's own `onError` has already
|
|
|
|
|
// restored the patched cells, so there is nothing left to do here.
|
|
|
|
|
|
|
|
|
|
function rollbackMutation(_mutation: unknown, _userId: number) {
|
|
|
|
|
/* no-op — see onMutate/onError above */
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|