fix(plan): readable audit descriptions, project labels, dashboard invalidation, view-only & sticky-column UI

Work-plan fixes from this session:
- Czech audit descriptions for plan entries/overrides (was empty '-')
- server-embedded project label on grid cells (was '-' past the 100-project cap)
- dashboard activity + audit-log cache invalidation on plan mutations
- read-only cells: no '+' on empty, keep hover on cells with a record
- view modal: Czech category + formatted dates
- sticky date column made opaque (no bleed-through on horizontal scroll)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-06 00:24:33 +02:00
parent 2f76fe0bc1
commit 4a9c283789
13 changed files with 2240 additions and 261 deletions

View File

@@ -1,5 +1,10 @@
import { useState, useCallback, useMemo } from "react";
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
import {
useQuery,
useQueryClient,
useMutation,
keepPreviousData,
} from "@tanstack/react-query";
import apiFetch from "../utils/api";
import { planKeys, GridData, ResolvedCell } from "../lib/queries/plan";
@@ -85,13 +90,147 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
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 | null) => ResolvedCell | null,
): Record<string, ResolvedCell | null> | null {
const prev = snapshotGrid(key);
if (!prev) return null;
const userPrev = prev.cells[userId] ?? {};
const rolled: Record<string, ResolvedCell | null> = {};
const userNext: Record<string, ResolvedCell | null> = { ...userPrev };
for (const date of dates) {
rolled[date] = userPrev[date] ?? null;
userNext[date] = mutator(rolled[date]);
}
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", {
@@ -99,7 +238,29 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
}),
onSuccess: invalidate,
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, () =>
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,
}),
);
// Stash the rollback on the mutation object so PlanWork can call
// it from onError.
(createEntry as any)._rolled = rolled;
invalidate();
},
});
const updateEntry = useMutation({
@@ -117,7 +278,52 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
}),
onSuccess: invalidate,
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 cell of Object.values(byDate)) {
if (cell && cell.entryId === id) {
ownerUserId = Number(uidStr);
break;
}
}
if (ownerUserId !== null) break;
}
}
if (ownerUserId !== null) {
const rolled = patchCells(currentGridKey, ownerUserId, days, (prev) =>
makeEntryCell({
userId: ownerUserId!,
date: days[0],
projectId:
body.project_id === undefined
? (prev?.project_id ?? null)
: body.project_id,
category: body.category ?? prev?.category ?? "work",
note: body.note ?? prev?.note ?? "",
rangeFrom: body.date_from,
rangeTo: body.date_to,
entryId: id,
}),
);
(updateEntry as any)._rolled = rolled;
}
}
invalidate();
},
});
const deleteEntry = useMutation({
@@ -125,7 +331,35 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
method: "DELETE",
}),
onSuccess: invalidate,
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)) {
for (const [, cell] of Object.entries(byDate)) {
if (
cell &&
cell.entryId === vars.id &&
cell.rangeFrom &&
cell.rangeTo
) {
const days = eachDay(cell.rangeFrom, cell.rangeTo);
const rolled = patchCells(
currentGridKey,
Number(uidStr),
days,
() => null,
);
(deleteEntry as any)._rolled = rolled;
break;
}
}
}
}
invalidate();
},
});
const createOverride = useMutation({
@@ -135,7 +369,25 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
}),
onSuccess: invalidate,
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],
() =>
makeOverrideCell({
userId: vars.user_id,
date: vars.shift_date,
projectId: vars.project_id ?? null,
category: vars.category,
note: vars.note,
overrideId: id,
}),
);
(createOverride as any)._rolled = rolled;
invalidate();
},
});
const updateOverride = useMutation({
@@ -153,7 +405,36 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
}),
onSuccess: invalidate,
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, cell] of Object.entries(byDate)) {
if (cell && cell.overrideId === vars.id) {
const rolled = patchCells(
currentGridKey,
Number(uidStr),
[date],
(prev) =>
makeOverrideCell({
userId: Number(uidStr),
date,
projectId: vars.body.project_id ?? prev?.project_id ?? null,
category: vars.body.category ?? prev?.category ?? "work",
note: vars.body.note ?? prev?.note ?? "",
overrideId: vars.id,
}),
);
(updateOverride as any)._rolled = rolled;
break;
}
}
}
}
invalidate();
},
});
const deleteOverride = useMutation({
@@ -161,9 +442,49 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
method: "DELETE",
}),
onSuccess: invalidate,
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, cell] of Object.entries(byDate)) {
if (cell && cell.overrideId === vars.id) {
const rolled = patchCells(
currentGridKey,
Number(uidStr),
[date],
() => null,
);
(deleteOverride as any)._rolled = rolled;
break;
}
}
}
}
invalidate();
},
});
// Roll back an optimistic patch on mutation error. Called from
// PlanWork's mutation wrappers via `rollbackMutation(mutation, key)`.
function rollbackMutation(
mutation: { _rolled?: Record<string, ResolvedCell | null> | null },
userId: number,
) {
if (!mutation._rolled) return;
const prev = qc.getQueryData<GridData>(currentGridKey as any);
if (!prev) return;
const userPrev = prev.cells[userId] ?? {};
const userNext = { ...userPrev };
for (const [date, cell] of Object.entries(mutation._rolled)) {
userNext[date] = cell;
}
qc.setQueryData(currentGridKey, {
...prev,
cells: { ...prev.cells, [userId]: userNext },
});
mutation._rolled = null;
}
return {
view,
setView,
@@ -174,6 +495,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
range,
grid: gridQuery.data,
gridLoading: gridQuery.isLoading,
gridFetching: gridQuery.isFetching,
gridIsPlaceholder: gridQuery.isPlaceholderData,
modal,
setModal,
canEdit,
@@ -184,6 +507,10 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
updateOverride,
deleteOverride,
invalidate,
// Exposed so PlanWork can roll back an optimistic patch when a
// mutation throws. Pass the failed mutation and the userId it
// targeted.
rollbackMutation,
};
}