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

@@ -60,6 +60,8 @@ const ENTITY_TYPE_LABELS: Record<string, string> = {
trips: "Jízda",
vehicles: "Vozidlo",
bank_account: "Bankovní účet",
work_plan_entry: "Plán prací záznam",
work_plan_override: "Plán prací přepsání dne",
};
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({

View File

@@ -1,5 +1,6 @@
import { useState, useMemo, useCallback } from "react";
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { motion, AnimatePresence } from "framer-motion";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import {
@@ -84,6 +85,19 @@ function formatRangeLabel(
return `Týden ${week} (${fmt(from)} ${fmt(to)} ${fromDate.getUTCFullYear()})`;
}
// Local-time YYYY-MM-DD for "now". Matches the server's
// `assertNotPastDate` guard in src/services/plan.service.ts and the
// `todayIso()` helper in PlanGrid — all three use the same local-time
// getters so the past-date gate stays consistent across layers.
function todayIsoLocal(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function isPastDate(dateStr: string, today: string): boolean {
return dateStr < today;
}
export default function PlanWork() {
const { hasPermission } = useAuth();
const alert = useAlert();
@@ -104,12 +118,43 @@ export default function PlanWork() {
createOverride,
updateOverride,
deleteOverride,
rollbackMutation,
} = usePlanWork({ canEdit });
// Projects list — needed by the modal's project selector.
const { data: projectsData } = useQuery(projectListOptions({ perPage: 200 }));
const projects: Project[] = (projectsData?.data as Project[]) ?? [];
// `lastMutated` is the (userId, date) of the most recent successful
// mutation. We pass it to PlanGrid as `pulseKey`; the matching cell
// gets a one-shot CSS pulse animation. We clear it after 700ms (the
// animation duration + a small buffer) so subsequent mutations on
// the same cell can re-trigger it.
const [lastMutated, setLastMutated] = useState<{
userId: number;
date: string;
nonce: number;
} | null>(null);
const pulseTimer = useRef<number | null>(null);
const firePulse = useCallback((userId: number, date: string) => {
if (pulseTimer.current !== null) {
window.clearTimeout(pulseTimer.current);
}
setLastMutated({ userId, date, nonce: Date.now() });
pulseTimer.current = window.setTimeout(() => {
setLastMutated(null);
pulseTimer.current = null;
}, 700);
}, []);
useEffect(() => {
return () => {
if (pulseTimer.current !== null) {
window.clearTimeout(pulseTimer.current);
}
};
}, []);
const goPrev = useCallback(() => {
setAnchor((a) => shiftAnchor(a, view, -1));
}, [setAnchor, view]);
@@ -122,6 +167,13 @@ export default function PlanWork() {
setAnchor(new Date());
}, [setAnchor]);
const setViewWithAnim = useCallback(
(next: "week" | "month") => {
setView(next);
},
[setView],
);
// --- Modal helpers ---------------------------------------------------------
// The hook stores a slim ModalMode (no `cell` / `range` payload). The
// PlanCellModal component expects a richer mode with those fields. We keep
@@ -142,6 +194,19 @@ export default function PlanWork() {
const openCell = useCallback(
(userId: number, date: string) => {
const cell = getCell(grid, userId, date);
// Past-date guard: the server rejects create/edit on past dates with
// a 403 (see assertNotPastDate in plan.service.ts). To keep the user
// out of the "click → modal opens → error toast" state, route any
// past-day click to view mode (or to no-op if there's no record).
if (isPastDate(date, todayIsoLocal())) {
if (!cell) {
// Empty past cell — nothing to show or edit.
return;
}
setModalCell(cell);
setHookModal({ kind: "view", userId, date });
return;
}
if (!cell) {
// Empty cell — open the create modal if the user can edit, otherwise
// there's nothing to view.
@@ -222,84 +287,161 @@ export default function PlanWork() {
// --- Mutation wrappers -----------------------------------------------------
// The modal calls these with plain objects / ids. We resolve them to the
// hook's mutateAsync calls and surface server errors as alerts.
// hook's mutateAsync calls, surface server errors as alerts, and (on
// success) trigger the per-cell pulse. On failure we also roll back the
// optimistic cache patch the hook already applied.
const saveEntry = useCallback(
async (body: any) => {
try {
await createEntry.mutateAsync(body);
// Pulse the first day of the new entry — for multi-day entries
// the first day is what the modal was opened on (body.date_from).
firePulse(body.user_id, body.date_from);
alert.success("Záznam vytvořen");
} catch (e) {
rollbackMutation(createEntry, body.user_id);
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
throw e;
}
},
[createEntry, alert],
[createEntry, alert, firePulse, rollbackMutation],
);
const updateEntryFn = useCallback(
async (id: number, body: any) => {
// Find the user for this entry so we know which cell to pulse /
// which cache to roll back. The hook's optimistic patch already
// discovered this; we read it from the current grid.
let userId: number | null = null;
let firstDate: string | null = null;
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cell] of Object.entries(byDate)) {
if (cell && cell.entryId === id) {
userId = Number(uidStr);
firstDate = body.date_from ?? date;
break;
}
}
if (userId !== null) break;
}
}
try {
await updateEntry.mutateAsync({ id, body });
if (userId !== null && firstDate !== null) firePulse(userId, firstDate);
alert.success("Záznam aktualizován");
} catch (e) {
if (userId !== null) rollbackMutation(updateEntry, userId);
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
throw e;
}
},
[updateEntry, alert],
[updateEntry, grid, alert, firePulse, rollbackMutation],
);
const deleteEntryFn = useCallback(
async (id: number) => {
// Find the user/date for the pulse + rollback.
let userId: number | null = null;
let firstDate: string | null = null;
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cell] of Object.entries(byDate)) {
if (cell && cell.entryId === id) {
userId = Number(uidStr);
firstDate = cell.rangeFrom ?? date;
break;
}
}
if (userId !== null) break;
}
}
try {
await deleteEntry.mutateAsync({ id });
if (userId !== null && firstDate !== null) firePulse(userId, firstDate);
alert.success("Záznam smazán");
} catch (e) {
if (userId !== null) rollbackMutation(deleteEntry, userId);
alert.error(e instanceof Error ? e.message : "Chyba při mazání");
throw e;
}
},
[deleteEntry, alert],
[deleteEntry, grid, alert, firePulse, rollbackMutation],
);
const saveOverride = useCallback(
async (body: any) => {
try {
await createOverride.mutateAsync(body);
firePulse(body.user_id, body.shift_date);
alert.success("Přepsání vytvořeno");
} catch (e) {
rollbackMutation(createOverride, body.user_id);
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
throw e;
}
},
[createOverride, alert],
[createOverride, alert, firePulse, rollbackMutation],
);
const updateOverrideFn = useCallback(
async (id: number, body: any) => {
// Find the user/date for the pulse + rollback.
let userId: number | null = null;
let date: string | null = null;
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [d, cell] of Object.entries(byDate)) {
if (cell && cell.overrideId === id) {
userId = Number(uidStr);
date = d;
break;
}
}
if (userId !== null) break;
}
}
try {
await updateOverride.mutateAsync({ id, body });
if (userId !== null && date !== null) firePulse(userId, date);
alert.success("Přepsání aktualizováno");
} catch (e) {
if (userId !== null) rollbackMutation(updateOverride, userId);
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
throw e;
}
},
[updateOverride, alert],
[updateOverride, grid, alert, firePulse, rollbackMutation],
);
const deleteOverrideFn = useCallback(
async (id: number) => {
let userId: number | null = null;
let date: string | null = null;
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [d, cell] of Object.entries(byDate)) {
if (cell && cell.overrideId === id) {
userId = Number(uidStr);
date = d;
break;
}
}
if (userId !== null) break;
}
}
try {
await deleteOverride.mutateAsync({ id });
if (userId !== null && date !== null) firePulse(userId, date);
alert.success("Přepsání smazáno");
} catch (e) {
if (userId !== null) rollbackMutation(deleteOverride, userId);
alert.error(e instanceof Error ? e.message : "Chyba při mazání");
throw e;
}
},
[deleteOverride, alert],
[deleteOverride, grid, alert, firePulse, rollbackMutation],
);
// "Create override from range" — turn a multi-day entry into a one-day
@@ -320,8 +462,10 @@ export default function PlanWork() {
category: cell.category,
note: cell.note,
});
firePulse(userId, date);
alert.success("Přepsání dne vytvořeno");
} catch (e) {
rollbackMutation(createOverride, userId);
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
throw e;
}
@@ -329,7 +473,7 @@ export default function PlanWork() {
// symmetry with the modal contract.
void entryId;
},
[grid, createOverride, alert],
[grid, createOverride, alert, firePulse, rollbackMutation],
);
// --- Modal key for remount-on-mode-change ----------------------------------
@@ -392,10 +536,23 @@ export default function PlanWork() {
return (
<div className="plan-work-page">
<h1 className="admin-page-title">Plán prací</h1>
<motion.h1
className="admin-page-title"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
Plán prací
</motion.h1>
{!canEdit && (
<div className="plan-banner" role="status">
<motion.div
className="plan-banner"
role="status"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<span className="plan-banner-icon" aria-hidden>
i
</span>
@@ -403,10 +560,15 @@ export default function PlanWork() {
<strong>Režim náhledu</strong> můžete pouze prohlížet plán. Úpravy
jsou dostupné oprávněným uživatelům.
</span>
</div>
</motion.div>
)}
<div className="plan-toolbar">
<motion.div
className="plan-toolbar"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<button
type="button"
className="admin-btn admin-btn-secondary"
@@ -430,8 +592,19 @@ export default function PlanWork() {
>
</button>
<span className="plan-range-label">
{formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
<span className="plan-range-label-slot">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
className="plan-range-label"
key={`${isoDate(range.from)}-${view}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
</motion.span>
</AnimatePresence>
</span>
<div
className="plan-view-toggle"
@@ -441,23 +614,27 @@ export default function PlanWork() {
<button
type="button"
className={
view === "week" ? "admin-btn admin-btn-primary" : "admin-btn"
view === "week"
? "admin-btn admin-btn-primary"
: "admin-btn admin-btn-secondary"
}
onClick={() => setView("week")}
onClick={() => setViewWithAnim("week")}
>
Týden
</button>
<button
type="button"
className={
view === "month" ? "admin-btn admin-btn-primary" : "admin-btn"
view === "month"
? "admin-btn admin-btn-primary"
: "admin-btn admin-btn-secondary"
}
onClick={() => setView("month")}
onClick={() => setViewWithAnim("month")}
>
Měsíc
</button>
</div>
</div>
</motion.div>
{gridLoading && (
<div className="admin-loading">
@@ -465,12 +642,37 @@ export default function PlanWork() {
</div>
)}
<PlanGrid
data={grid}
projects={projects}
canEdit={canEdit}
onCellClick={openCell}
/>
{/*
Grid entrance: same pattern as the h1 / banner / toolbar above
(opacity + 12px translateY, 250ms ease-out). The stagger is
h1 (0s) → banner (0.06s) → toolbar (0.12s) → grid (0.18s), so
the page assembles top-to-bottom in a single cascade.
The motion.div mounts ONCE when `grid` first becomes defined
and stays mounted across all range changes (keepPreviousData
keeps `grid` defined even while a new fetch is in flight, so
the condition never drops to null). This prevents the entrance
animation from re-firing on every week/month switch.
Range changes are instant — keepPreviousData holds the old data
visible until the new data arrives, so there's no flash. Mutations
patch the cache in place and pulse a single cell via pulseKey.
*/}
{grid ? (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut", delay: 0.18 }}
>
<PlanGrid
data={grid}
projects={projects}
canEdit={canEdit}
pulseKey={lastMutated}
onCellClick={openCell}
/>
</motion.div>
) : null}
<PlanCellModal
key={modalKey}