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,9 +1,17 @@
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import FormModal from "./FormModal";
import FormField from "./FormField";
import AdminDatePicker from "./AdminDatePicker";
import ConfirmModal from "./ConfirmModal";
import { ResolvedCell } from "../lib/queries/plan";
import useReducedMotion from "../hooks/useReducedMotion";
import {
ResolvedCell,
PLAN_CATEGORIES,
planCategoryLabel,
cellProjectLabel,
} from "../lib/queries/plan";
import { formatDate } from "../utils/formatters";
interface Project {
id: number;
@@ -68,16 +76,6 @@ interface Props {
onSwitchToEditRange: (entryId: number, userId: number, date: string) => void;
}
const CATEGORIES = [
{ value: "work", label: "Práce" },
{ value: "preparation", label: "Příprava" },
{ value: "travel", label: "Cesta / Montáž" },
{ value: "leave", label: "Dovolená" },
{ value: "sick", label: "Nemoc" },
{ value: "training", label: "Školení" },
{ value: "other", label: "Jiné" },
];
export default function PlanCellModal(props: Props) {
const { open, mode, onClose } = props;
if (!open || mode.kind === "closed") return null;
@@ -218,19 +216,36 @@ function EditForm(props: Props) {
onSubmit={handleSubmit}
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
loading={submitting}
>
{mode.kind !== "create" && (
<div className="plan-modal-actions">
footerLeft={
mode.kind !== "create" ? (
<button
type="button"
className="admin-btn admin-btn-danger"
className="admin-btn admin-btn-secondary"
onClick={() => setConfirmDelete(true)}
disabled={submitting}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
Smazat
</button>
</div>
)}
) : null
}
>
<FormField label="Datum od">
<AdminDatePicker
value={dateFrom}
@@ -240,13 +255,13 @@ function EditForm(props: Props) {
</FormField>
{!isOverride && (
<FormField label="Rozsah dnů">
<label>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={isRange}
onChange={(e) => setIsRange(e.target.checked)}
/>{" "}
Více dní
/>
<span>Více dní</span>
</label>
</FormField>
)}
@@ -257,6 +272,7 @@ function EditForm(props: Props) {
)}
<FormField label="Projekt">
<select
className="admin-form-select"
value={projectId ?? ""}
onChange={(e) =>
setProjectId(e.target.value ? Number(e.target.value) : null)
@@ -272,10 +288,11 @@ function EditForm(props: Props) {
</FormField>
<FormField label="Kategorie">
<select
className="admin-form-select"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
{CATEGORIES.map((c) => (
{PLAN_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
@@ -284,6 +301,7 @@ function EditForm(props: Props) {
</FormField>
<FormField label="Text poznámky (volitelný)">
<textarea
className="admin-form-textarea"
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={500}
@@ -312,62 +330,88 @@ function DayInRangeModal(
) {
const { mode, onClose, onCreateOverrideFromRange, onSwitchToEditRange } =
props;
const reducedMotion = useReducedMotion();
// Two clear paths: edit the whole range (default, safe) or carve out
// a one-day override (deviates from the plan). Close = no action.
const choices = [
{
key: "edit-range",
title: "Upravit celý rozsah",
desc: `Změní projekt, kategorii nebo poznámku pro všechny dny v rozsahu ${mode.range.date_from} ${mode.range.date_to}.`,
tone: "primary" as const,
onClick: () => onSwitchToEditRange(mode.entryId, mode.userId, mode.date),
cta: "Upravit rozsah",
},
{
key: "override-day",
title: "Upravit pouze tento den",
desc: `Vytvoří přepsání pro ${mode.range.date_from === mode.date ? "tento" : mode.date}; zbytek rozsahu zůstane beze změny.`,
tone: "secondary" as const,
onClick: async () => {
await onCreateOverrideFromRange(mode.entryId, mode.userId, mode.date);
onClose();
},
cta: "Vytvořit přepsání",
},
];
return (
<FormModal
isOpen
title={`Den ${mode.date} je součástí rozsahu`}
title="Den je součástí rozsahu"
onClose={onClose}
hideFooter
size="md"
>
<p>
Tento den je součástí rozsahu:{" "}
<p className="plan-modal-intro">
Den <strong>{mode.date}</strong> je součástí rozsahu{" "}
<strong>
{mode.range.date_from} {mode.range.date_to}
</strong>
. Vyberte, co chcete udělat:
</p>
<p>Co chcete udělat?</p>
<ul>
<li>
<strong>Upravit celý rozsah</strong> otevře editor celého rozsahu.
</li>
<li>
<strong>Upravit pouze tento den</strong> vytvoří přepsání (override)
pro toto datum; zbytek rozsahu zůstane beze změny.
</li>
<li>
<strong>Zavřít</strong> ponechá rozsah beze změny.
</li>
<ul className="plan-choices">
<AnimatePresence>
{choices.map((c, i) => (
<motion.li
key={c.key}
className={`plan-choice plan-choice-${c.tone}`}
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reducedMotion ? undefined : { opacity: 0, y: -4 }}
transition={{
duration: reducedMotion ? 0 : 0.22,
delay: reducedMotion ? 0 : 0.04 + i * 0.05,
ease: [0.16, 1, 0.3, 1],
}}
>
<div className="plan-choice-body">
<div className="plan-choice-title">{c.title}</div>
<div className="plan-choice-desc">{c.desc}</div>
</div>
<button
type="button"
className={
c.tone === "primary"
? "admin-btn admin-btn-primary"
: "admin-btn admin-btn-secondary"
}
onClick={c.onClick}
>
{c.cta}
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
<div className="plan-modal-actions">
<div className="plan-modal-actions plan-modal-actions--solo">
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={onClose}
>
Zavřít
</button>
<button
type="button"
className="admin-btn admin-btn-primary"
onClick={async () => {
await onCreateOverrideFromRange(
mode.entryId,
mode.userId,
mode.date,
);
onClose();
}}
>
Upravit pouze tento den
</button>
<button
type="button"
className="admin-btn admin-btn-primary"
onClick={() =>
onSwitchToEditRange(mode.entryId, mode.userId, mode.date)
}
>
Upravit celý rozsah
Zavřít ponechat rozsah beze změny
</button>
</div>
</FormModal>
@@ -378,31 +422,36 @@ function ViewModal(props: Props) {
const { mode, onClose, projects } = props;
if (mode.kind !== "view") return null;
const c = mode.cell;
const project = c.project_id
// Prefer the server-embedded project label; fall back to the projects-list
// lookup only for cells that predate the embed (stale cache).
const fallback = c.project_id
? (projects.find((p) => p.id === c.project_id) ?? null)
: null;
const projectLabel =
cellProjectLabel(c) ??
(fallback
? fallback.project_number
? `${fallback.project_number}${fallback.name}`
: fallback.name
: null);
return (
<FormModal isOpen title="Detail plánu" onClose={onClose} hideFooter>
<p>
<strong>Datum:</strong> {c.shift_date}
<strong>Datum:</strong> {formatDate(c.shift_date)}
</p>
<p>
<strong>Kategorie:</strong> {c.category}
<strong>Kategorie:</strong> {planCategoryLabel(c.category)}
</p>
<p>
<strong>Projekt:</strong>{" "}
{project
? project.project_number
? `${project.project_number}${project.name}`
: project.name
: "—"}
<strong>Projekt:</strong> {projectLabel || ""}
</p>
<p>
<strong>Text:</strong> {c.note || "—"}
</p>
{c.rangeFrom && c.rangeTo && (
<p>
<strong>Patří do rozsahu:</strong> {c.rangeFrom} {c.rangeTo}
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} {" "}
{formatDate(c.rangeTo)}
</p>
)}
</FormModal>

View File

@@ -1,4 +1,5 @@
import { GridData, ResolvedCell } from "../lib/queries/plan";
import { useMemo } from "react";
import { GridData, ResolvedCell, planCategoryLabel } from "../lib/queries/plan";
import type { Project } from "../lib/queries/projects";
import PlanRangeChips from "./PlanRangeChips";
@@ -6,6 +7,11 @@ interface Props {
data: GridData | undefined;
canEdit: boolean;
projects: Project[];
// The (userId, date) of the most recent successful mutation. The
// matching cell gets a one-shot highlight pulse. `nonce` is part of
// the value so back-to-back mutations on the same cell re-trigger
// the animation (React needs a new key to re-mount the class).
pulseKey?: { userId: number; date: string; nonce: number } | null;
onCellClick: (
userId: number,
date: string,
@@ -13,7 +19,19 @@ interface Props {
) => void;
}
const CZECH_WEEKDAYS = ["Ne", "Po", "Út", "St", "Čt", "Pá", "So"];
// Full Czech weekday names, indexed by Date.getUTCDay() (0 = Sunday).
// Used in the date column "stamp" so the day name reads in full rather
// than as a 2-letter abbreviation — easier to scan when the planner
// spans several weeks.
const CZECH_WEEKDAYS = [
"Neděle",
"Pondělí",
"Úterý",
"Středa",
"Čtvrtek",
"Pátek",
"Sobota",
];
function eachDay(from: string, to: string): string[] {
const out: string[] = [];
@@ -34,12 +52,51 @@ function isWeekend(dateStr: string): boolean {
return day === 0 || day === 6;
}
// Today's local date as YYYY-MM-DD. Used to mark the current row and to
// gate past-day editing. Uses local time (matches the project's date
// conventions in CLAUDE.md and the server's `assertNotPastDate` guard in
// src/services/plan.service.ts).
function todayIso(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
// True when the given YYYY-MM-DD cell date is strictly before today (local).
// Used to gate the create / edit UI on past days — the server still enforces
// the rule with a 403, but the client should never put the user in the
// "I clicked, modal opened, error toast" state in the first place.
function isPastDate(dateStr: string, today: string): boolean {
return dateStr < today;
}
// Split a full name into first / last for the column header.
function splitName(full: string): { first: string; last: string } {
const parts = full.trim().split(/\s+/);
if (parts.length === 1) return { first: parts[0], last: "" };
return { first: parts[0], last: parts.slice(1).join(" ") };
}
// Short role label for the column header sub-line.
function shortRole(role: string | null): string {
if (!role) return "";
// Keep it brief — column header is tight
if (/^admin$/i.test(role)) return "Admin";
if (/^viewer$/i.test(role)) return "Viewer";
return role.length > 10 ? role.slice(0, 9) + "…" : role;
}
export default function PlanGrid({
data,
canEdit,
projects,
pulseKey,
onCellClick,
}: Props) {
const today = useMemo(() => todayIso(), []);
if (!data)
return (
<div className="admin-loading">
@@ -48,53 +105,134 @@ export default function PlanGrid({
);
const days = eachDay(data.date_from, data.date_to);
const users = data.users;
// pulseKey?.nonce is included in the data-pulse attribute so a second
// mutation on the same cell re-triggers the animation (CSS animations
// don't restart unless the keyframe applies to a fresh element/class
// binding).
const pulseAttr = pulseKey
? `${pulseKey.userId}|${pulseKey.date}|${pulseKey.nonce}`
: undefined;
return (
<div className="plan-grid-wrap">
<div className="plan-grid-wrap" data-pulse={pulseAttr}>
<table className="plan-grid">
{/* The colgroup is what actually controls column width in a
table — `min-width` on `<th>`/`<td>` is just a floor that
`table-layout: auto` happily blows past. The first column
gets a fixed width via the col element so the date stamp
doesn't get stretched to share space with person columns. */}
<colgroup>
<col className="plan-grid-date-col" />
{users.map((u) => (
<col key={u.id} />
))}
</colgroup>
<thead>
<tr>
<th className="plan-grid-date-col">Datum</th>
{users.map((u) => (
<th key={u.id}>{u.full_name}</th>
))}
{users.map((u) => {
const { first, last } = splitName(u.full_name);
return (
<th key={u.id}>
<span className="plan-person-head">
<span className="plan-person-dot" aria-hidden />
<span className="plan-person-name">
<strong title={u.full_name}>
{first}
{last ? ` ${last}` : ""}
</strong>
{shortRole(u.role_name) && (
<small>{shortRole(u.role_name)}</small>
)}
</span>
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{days.map((date) => (
<tr
key={date}
className={isWeekend(date) ? "plan-grid-weekend" : ""}
>
<td className="plan-grid-date-col">
{czechWeekday(date)} {date.slice(8)}.{date.slice(5, 7)}.
</td>
{users.map((u) => {
const cell = data.cells[u.id]?.[date] ?? null;
const cls = `plan-cell${canEdit ? "" : " plan-cell--readonly"}`;
return (
<td key={u.id}>
<button
type="button"
className={cls}
onClick={() => onCellClick(u.id, date, cell)}
>
<PlanRangeChips
cell={cell}
project={
cell?.project_id
? (projects.find((p) => p.id === cell.project_id) ??
null)
: null
{days.map((date) => {
const dow = czechWeekday(date);
const dayNum = date.slice(8, 10);
const isToday = date === today;
const trCls = [
isWeekend(date) ? "plan-grid-weekend" : "",
isToday ? "is-today" : "",
]
.filter(Boolean)
.join(" ");
return (
<tr key={date} className={trCls}>
<td className="plan-grid-date-col">
<span className="plan-date-stamp">
<span className="plan-date-daynum">{dayNum}</span>
<span className="plan-date-dow">{dow}</span>
</span>
</td>
{users.map((u) => {
const cell = data.cells[u.id]?.[date] ?? null;
const past = isPastDate(date, today);
// Past-day cells are read-only in the UI: an empty past
// cell is non-interactive (no create modal, no "+" hint),
// a past cell with data opens in view mode only. The
// server still enforces the past-date rule with a 403
// (defense in depth), but the click path here never
// reaches a create/edit submission for a past date.
const isLocked = !canEdit || past;
// `isPulsing` is true for the single (user, date) cell
// that the most recent successful mutation touched.
// CSS restarts the keyframe animation whenever the
// `nonce` changes (we embed it in data-pulse on the
// wrapper, see above), so back-to-back mutations on the
// same cell re-trigger the pulse.
const isPulsing =
!!pulseKey &&
pulseKey.userId === u.id &&
pulseKey.date === date;
let cls: string;
if (cell) {
cls = isLocked
? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}`
: "plan-cell";
} else {
cls = isLocked
? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}`
: "plan-cell plan-cell--empty";
}
if (isPulsing) cls += " plan-cell--pulse";
return (
<td key={u.id}>
<button
type="button"
className={cls}
onClick={() => onCellClick(u.id, date, cell)}
aria-label={
cell
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category)}`
: isLocked
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
: `${u.full_name}, ${date}, prázdné — přidat záznam`
}
readonly={!canEdit}
/>
</button>
</td>
);
})}
</tr>
))}
>
<PlanRangeChips
cell={cell}
project={
cell?.project_id
? (projects.find(
(p) => p.id === cell.project_id,
) ?? null)
: null
}
readonly={!canEdit}
/>
</button>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>

View File

@@ -1,4 +1,8 @@
import { ResolvedCell } from "../lib/queries/plan";
import {
ResolvedCell,
planCategoryLabel,
cellProjectLabel,
} from "../lib/queries/plan";
import type { Project } from "../lib/queries/projects";
interface Props {
@@ -7,34 +11,24 @@ interface Props {
readonly?: boolean;
}
const CATEGORY_LABELS: Record<string, string> = {
work: "Práce",
preparation: "Příprava",
travel: "Cesta / Montáž",
leave: "Dovolená",
sick: "Nemoc",
training: "Školení",
other: "Jiné",
};
export default function PlanRangeChips({ cell, project, readonly }: Props) {
void readonly;
if (!cell) return null;
const projectLabel = project
// Prefer the server-embedded project label (always present). Fall back to
// the looked-up `project` prop only if the cell predates the embed (stale
// cache).
const fallbackLabel = project
? project.project_number
? `${project.project_number}${project.name}`
: project.name
: null;
const projectTitle = project
? project.project_number
? `${project.project_number}${project.name}`
: project.name
: undefined;
const projectLabel = cellProjectLabel(cell) ?? fallbackLabel;
const projectTitle = projectLabel ?? undefined;
return (
<div className="plan-chip-block">
<div className="plan-chip-line">
<span className={`plan-chip plan-chip--${cell.category}`}>
{CATEGORY_LABELS[cell.category] ?? cell.category}
{planCategoryLabel(cell.category)}
</span>
{projectLabel && (
<span className="plan-chip-project" title={projectTitle}>

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,
};
}

View File

@@ -18,12 +18,52 @@ export interface ResolvedCell {
user_id: number;
shift_date: string;
project_id: number | null;
/** Project label resolved server-side (see resolveGrid). Lets the UI show
* the project without depending on the capped/permission-gated projects
* list. Null when the cell has no project. */
project_number: string | null;
project_name: string | null;
category: string;
note: string;
rangeFrom: string | null;
rangeTo: string | null;
}
/** Czech labels for the plan_category enum — single source of truth shared
* by the grid chips, the detail view, and aria-labels. */
export const PLAN_CATEGORIES: ReadonlyArray<{ value: string; label: string }> =
[
{ value: "work", label: "Práce" },
{ value: "preparation", label: "Příprava" },
{ value: "travel", label: "Cesta / Montáž" },
{ value: "leave", label: "Dovolená" },
{ value: "sick", label: "Nemoc" },
{ value: "training", label: "Školení" },
{ value: "other", label: "Jiné" },
];
const PLAN_CATEGORY_LABELS: Record<string, string> = Object.fromEntries(
PLAN_CATEGORIES.map((c) => [c.value, c.label]),
);
/** Czech label for a plan category, falling back to the raw value. */
export function planCategoryLabel(value: string): string {
return PLAN_CATEGORY_LABELS[value] ?? value;
}
/** Build the display label for a cell's project, preferring the server-
* embedded fields. Returns null when the cell has no project. */
export function cellProjectLabel(cell: {
project_number: string | null;
project_name: string | null;
}): string | null {
if (!cell.project_number && !cell.project_name) return null;
if (cell.project_number && cell.project_name) {
return `${cell.project_number}${cell.project_name}`;
}
return cell.project_number ?? cell.project_name;
}
export interface GridData {
view: "week" | "month";
date_from: string;

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}

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,8 @@ export 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",
};
export const ACTION_LABELS: Record<string, string> = {