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:
@@ -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]) => ({
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user