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:
95
src/__tests__/planAuditDescription.test.ts
Normal file
95
src/__tests__/planAuditDescription.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
planCategoryLabel,
|
||||||
|
buildPlanAuditDescription,
|
||||||
|
} from "../utils/planAuditDescription";
|
||||||
|
|
||||||
|
describe("planCategoryLabel", () => {
|
||||||
|
it("maps known plan categories to Czech labels", () => {
|
||||||
|
expect(planCategoryLabel("work")).toBe("Práce");
|
||||||
|
expect(planCategoryLabel("preparation")).toBe("Příprava");
|
||||||
|
expect(planCategoryLabel("travel")).toBe("Cesta / Montáž");
|
||||||
|
expect(planCategoryLabel("leave")).toBe("Dovolená");
|
||||||
|
expect(planCategoryLabel("sick")).toBe("Nemoc");
|
||||||
|
expect(planCategoryLabel("training")).toBe("Školení");
|
||||||
|
expect(planCategoryLabel("other")).toBe("Jiné");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the raw value for an unknown category", () => {
|
||||||
|
expect(planCategoryLabel("unknown_xyz")).toBe("unknown_xyz");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildPlanAuditDescription", () => {
|
||||||
|
it("describes a multi-day entry with project", () => {
|
||||||
|
const desc = buildPlanAuditDescription({
|
||||||
|
userName: "Jan Novák",
|
||||||
|
category: "work",
|
||||||
|
projectName: "Rekonstrukce haly",
|
||||||
|
dateFrom: "2026-06-08",
|
||||||
|
dateTo: "2026-06-12",
|
||||||
|
});
|
||||||
|
expect(desc).toBe(
|
||||||
|
"Jan Novák · 08.06.2026 – 12.06.2026 · Práce · Rekonstrukce haly",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses a single-day range to one date", () => {
|
||||||
|
const desc = buildPlanAuditDescription({
|
||||||
|
userName: "Jan Novák",
|
||||||
|
category: "work",
|
||||||
|
projectName: "Rekonstrukce haly",
|
||||||
|
dateFrom: "2026-06-08",
|
||||||
|
dateTo: "2026-06-08",
|
||||||
|
});
|
||||||
|
expect(desc).toBe("Jan Novák · 08.06.2026 · Práce · Rekonstrukce haly");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the project segment when there is no project", () => {
|
||||||
|
const desc = buildPlanAuditDescription({
|
||||||
|
userName: "Petr Svoboda",
|
||||||
|
category: "leave",
|
||||||
|
projectName: null,
|
||||||
|
dateFrom: "2026-06-10",
|
||||||
|
dateTo: "2026-06-10",
|
||||||
|
});
|
||||||
|
expect(desc).toBe("Petr Svoboda · 10.06.2026 · Dovolená");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends the emergency-edit note when force is set", () => {
|
||||||
|
const desc = buildPlanAuditDescription({
|
||||||
|
userName: "Jan Novák",
|
||||||
|
category: "work",
|
||||||
|
projectName: null,
|
||||||
|
dateFrom: "2026-01-02",
|
||||||
|
dateTo: "2026-01-02",
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
expect(desc).toBe("Jan Novák · 02.01.2026 · Práce · nouzová úprava");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends a custom suffix (e.g. replaced override)", () => {
|
||||||
|
const desc = buildPlanAuditDescription({
|
||||||
|
userName: "Jan Novák",
|
||||||
|
category: "sick",
|
||||||
|
projectName: null,
|
||||||
|
dateFrom: "2026-06-10",
|
||||||
|
dateTo: "2026-06-10",
|
||||||
|
suffix: "nahrazeno novým záznamem",
|
||||||
|
});
|
||||||
|
expect(desc).toBe(
|
||||||
|
"Jan Novák · 10.06.2026 · Nemoc · nahrazeno novým záznamem",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a whitespace-only project name as no project", () => {
|
||||||
|
const desc = buildPlanAuditDescription({
|
||||||
|
userName: "Jan Novák",
|
||||||
|
category: "work",
|
||||||
|
projectName: " ",
|
||||||
|
dateFrom: "2026-06-08",
|
||||||
|
dateTo: "2026-06-08",
|
||||||
|
});
|
||||||
|
expect(desc).toBe("Jan Novák · 08.06.2026 · Práce");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import FormModal from "./FormModal";
|
import FormModal from "./FormModal";
|
||||||
import FormField from "./FormField";
|
import FormField from "./FormField";
|
||||||
import AdminDatePicker from "./AdminDatePicker";
|
import AdminDatePicker from "./AdminDatePicker";
|
||||||
import ConfirmModal from "./ConfirmModal";
|
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 {
|
interface Project {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -68,16 +76,6 @@ interface Props {
|
|||||||
onSwitchToEditRange: (entryId: number, userId: number, date: string) => void;
|
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) {
|
export default function PlanCellModal(props: Props) {
|
||||||
const { open, mode, onClose } = props;
|
const { open, mode, onClose } = props;
|
||||||
if (!open || mode.kind === "closed") return null;
|
if (!open || mode.kind === "closed") return null;
|
||||||
@@ -218,19 +216,36 @@ function EditForm(props: Props) {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
|
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
>
|
footerLeft={
|
||||||
{mode.kind !== "create" && (
|
mode.kind !== "create" ? (
|
||||||
<div className="plan-modal-actions">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="admin-btn admin-btn-danger"
|
className="admin-btn admin-btn-secondary"
|
||||||
onClick={() => setConfirmDelete(true)}
|
onClick={() => setConfirmDelete(true)}
|
||||||
disabled={submitting}
|
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
|
Smazat
|
||||||
</button>
|
</button>
|
||||||
</div>
|
) : null
|
||||||
)}
|
}
|
||||||
|
>
|
||||||
<FormField label="Datum od">
|
<FormField label="Datum od">
|
||||||
<AdminDatePicker
|
<AdminDatePicker
|
||||||
value={dateFrom}
|
value={dateFrom}
|
||||||
@@ -240,13 +255,13 @@ function EditForm(props: Props) {
|
|||||||
</FormField>
|
</FormField>
|
||||||
{!isOverride && (
|
{!isOverride && (
|
||||||
<FormField label="Rozsah dnů">
|
<FormField label="Rozsah dnů">
|
||||||
<label>
|
<label className="admin-form-checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isRange}
|
checked={isRange}
|
||||||
onChange={(e) => setIsRange(e.target.checked)}
|
onChange={(e) => setIsRange(e.target.checked)}
|
||||||
/>{" "}
|
/>
|
||||||
Více dní
|
<span>Více dní</span>
|
||||||
</label>
|
</label>
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
@@ -257,6 +272,7 @@ function EditForm(props: Props) {
|
|||||||
)}
|
)}
|
||||||
<FormField label="Projekt">
|
<FormField label="Projekt">
|
||||||
<select
|
<select
|
||||||
|
className="admin-form-select"
|
||||||
value={projectId ?? ""}
|
value={projectId ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setProjectId(e.target.value ? Number(e.target.value) : null)
|
setProjectId(e.target.value ? Number(e.target.value) : null)
|
||||||
@@ -272,10 +288,11 @@ function EditForm(props: Props) {
|
|||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Kategorie">
|
<FormField label="Kategorie">
|
||||||
<select
|
<select
|
||||||
|
className="admin-form-select"
|
||||||
value={category}
|
value={category}
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
>
|
>
|
||||||
{CATEGORIES.map((c) => (
|
{PLAN_CATEGORIES.map((c) => (
|
||||||
<option key={c.value} value={c.value}>
|
<option key={c.value} value={c.value}>
|
||||||
{c.label}
|
{c.label}
|
||||||
</option>
|
</option>
|
||||||
@@ -284,6 +301,7 @@ function EditForm(props: Props) {
|
|||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Text poznámky (volitelný)">
|
<FormField label="Text poznámky (volitelný)">
|
||||||
<textarea
|
<textarea
|
||||||
|
className="admin-form-textarea"
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
@@ -312,62 +330,88 @@ function DayInRangeModal(
|
|||||||
) {
|
) {
|
||||||
const { mode, onClose, onCreateOverrideFromRange, onSwitchToEditRange } =
|
const { mode, onClose, onCreateOverrideFromRange, onSwitchToEditRange } =
|
||||||
props;
|
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 (
|
return (
|
||||||
<FormModal
|
<FormModal
|
||||||
isOpen
|
isOpen
|
||||||
title={`Den ${mode.date} je součástí rozsahu`}
|
title="Den je součástí rozsahu"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
hideFooter
|
hideFooter
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
<p>
|
<p className="plan-modal-intro">
|
||||||
Tento den je součástí rozsahu:{" "}
|
Den <strong>{mode.date}</strong> je součástí rozsahu{" "}
|
||||||
<strong>
|
<strong>
|
||||||
{mode.range.date_from} – {mode.range.date_to}
|
{mode.range.date_from} – {mode.range.date_to}
|
||||||
</strong>
|
</strong>
|
||||||
|
. Vyberte, co chcete udělat:
|
||||||
</p>
|
</p>
|
||||||
<p>Co chcete udělat?</p>
|
<ul className="plan-choices">
|
||||||
<ul>
|
<AnimatePresence>
|
||||||
<li>
|
{choices.map((c, i) => (
|
||||||
<strong>Upravit celý rozsah</strong> — otevře editor celého rozsahu.
|
<motion.li
|
||||||
</li>
|
key={c.key}
|
||||||
<li>
|
className={`plan-choice plan-choice-${c.tone}`}
|
||||||
<strong>Upravit pouze tento den</strong> — vytvoří přepsání (override)
|
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
|
||||||
pro toto datum; zbytek rozsahu zůstane beze změny.
|
animate={{ opacity: 1, y: 0 }}
|
||||||
</li>
|
exit={reducedMotion ? undefined : { opacity: 0, y: -4 }}
|
||||||
<li>
|
transition={{
|
||||||
<strong>Zavřít</strong> — ponechá rozsah beze změny.
|
duration: reducedMotion ? 0 : 0.22,
|
||||||
</li>
|
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>
|
</ul>
|
||||||
<div className="plan-modal-actions">
|
<div className="plan-modal-actions plan-modal-actions--solo">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="admin-btn admin-btn-secondary"
|
className="admin-btn admin-btn-secondary"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
Zavřít
|
Zavřít — ponechat rozsah beze změny
|
||||||
</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
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
@@ -378,31 +422,36 @@ function ViewModal(props: Props) {
|
|||||||
const { mode, onClose, projects } = props;
|
const { mode, onClose, projects } = props;
|
||||||
if (mode.kind !== "view") return null;
|
if (mode.kind !== "view") return null;
|
||||||
const c = mode.cell;
|
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)
|
? (projects.find((p) => p.id === c.project_id) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
const projectLabel =
|
||||||
|
cellProjectLabel(c) ??
|
||||||
|
(fallback
|
||||||
|
? fallback.project_number
|
||||||
|
? `${fallback.project_number} — ${fallback.name}`
|
||||||
|
: fallback.name
|
||||||
|
: null);
|
||||||
return (
|
return (
|
||||||
<FormModal isOpen title="Detail plánu" onClose={onClose} hideFooter>
|
<FormModal isOpen title="Detail plánu" onClose={onClose} hideFooter>
|
||||||
<p>
|
<p>
|
||||||
<strong>Datum:</strong> {c.shift_date}
|
<strong>Datum:</strong> {formatDate(c.shift_date)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Kategorie:</strong> {c.category}
|
<strong>Kategorie:</strong> {planCategoryLabel(c.category)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Projekt:</strong>{" "}
|
<strong>Projekt:</strong> {projectLabel || "—"}
|
||||||
{project
|
|
||||||
? project.project_number
|
|
||||||
? `${project.project_number} — ${project.name}`
|
|
||||||
: project.name
|
|
||||||
: "—"}
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Text:</strong> {c.note || "—"}
|
<strong>Text:</strong> {c.note || "—"}
|
||||||
</p>
|
</p>
|
||||||
{c.rangeFrom && c.rangeTo && (
|
{c.rangeFrom && c.rangeTo && (
|
||||||
<p>
|
<p>
|
||||||
<strong>Patří do rozsahu:</strong> {c.rangeFrom} – {c.rangeTo}
|
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} –{" "}
|
||||||
|
{formatDate(c.rangeTo)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</FormModal>
|
</FormModal>
|
||||||
|
|||||||
@@ -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 type { Project } from "../lib/queries/projects";
|
||||||
import PlanRangeChips from "./PlanRangeChips";
|
import PlanRangeChips from "./PlanRangeChips";
|
||||||
|
|
||||||
@@ -6,6 +7,11 @@ interface Props {
|
|||||||
data: GridData | undefined;
|
data: GridData | undefined;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
projects: Project[];
|
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: (
|
onCellClick: (
|
||||||
userId: number,
|
userId: number,
|
||||||
date: string,
|
date: string,
|
||||||
@@ -13,7 +19,19 @@ interface Props {
|
|||||||
) => void;
|
) => 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[] {
|
function eachDay(from: string, to: string): string[] {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
@@ -34,12 +52,51 @@ function isWeekend(dateStr: string): boolean {
|
|||||||
return day === 0 || day === 6;
|
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({
|
export default function PlanGrid({
|
||||||
data,
|
data,
|
||||||
canEdit,
|
canEdit,
|
||||||
projects,
|
projects,
|
||||||
|
pulseKey,
|
||||||
onCellClick,
|
onCellClick,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const today = useMemo(() => todayIso(), []);
|
||||||
|
|
||||||
if (!data)
|
if (!data)
|
||||||
return (
|
return (
|
||||||
<div className="admin-loading">
|
<div className="admin-loading">
|
||||||
@@ -48,53 +105,134 @@ export default function PlanGrid({
|
|||||||
);
|
);
|
||||||
const days = eachDay(data.date_from, data.date_to);
|
const days = eachDay(data.date_from, data.date_to);
|
||||||
const users = data.users;
|
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 (
|
return (
|
||||||
<div className="plan-grid-wrap">
|
<div className="plan-grid-wrap" data-pulse={pulseAttr}>
|
||||||
<table className="plan-grid">
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="plan-grid-date-col">Datum</th>
|
<th className="plan-grid-date-col">Datum</th>
|
||||||
{users.map((u) => (
|
{users.map((u) => {
|
||||||
<th key={u.id}>{u.full_name}</th>
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{days.map((date) => (
|
{days.map((date) => {
|
||||||
<tr
|
const dow = czechWeekday(date);
|
||||||
key={date}
|
const dayNum = date.slice(8, 10);
|
||||||
className={isWeekend(date) ? "plan-grid-weekend" : ""}
|
const isToday = date === today;
|
||||||
>
|
const trCls = [
|
||||||
<td className="plan-grid-date-col">
|
isWeekend(date) ? "plan-grid-weekend" : "",
|
||||||
{czechWeekday(date)} {date.slice(8)}.{date.slice(5, 7)}.
|
isToday ? "is-today" : "",
|
||||||
</td>
|
]
|
||||||
{users.map((u) => {
|
.filter(Boolean)
|
||||||
const cell = data.cells[u.id]?.[date] ?? null;
|
.join(" ");
|
||||||
const cls = `plan-cell${canEdit ? "" : " plan-cell--readonly"}`;
|
return (
|
||||||
return (
|
<tr key={date} className={trCls}>
|
||||||
<td key={u.id}>
|
<td className="plan-grid-date-col">
|
||||||
<button
|
<span className="plan-date-stamp">
|
||||||
type="button"
|
<span className="plan-date-daynum">{dayNum}</span>
|
||||||
className={cls}
|
<span className="plan-date-dow">{dow}</span>
|
||||||
onClick={() => onCellClick(u.id, date, cell)}
|
</span>
|
||||||
>
|
</td>
|
||||||
<PlanRangeChips
|
{users.map((u) => {
|
||||||
cell={cell}
|
const cell = data.cells[u.id]?.[date] ?? null;
|
||||||
project={
|
const past = isPastDate(date, today);
|
||||||
cell?.project_id
|
// Past-day cells are read-only in the UI: an empty past
|
||||||
? (projects.find((p) => p.id === cell.project_id) ??
|
// cell is non-interactive (no create modal, no "+" hint),
|
||||||
null)
|
// a past cell with data opens in view mode only. The
|
||||||
: null
|
// 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}
|
>
|
||||||
/>
|
<PlanRangeChips
|
||||||
</button>
|
cell={cell}
|
||||||
</td>
|
project={
|
||||||
);
|
cell?.project_id
|
||||||
})}
|
? (projects.find(
|
||||||
</tr>
|
(p) => p.id === cell.project_id,
|
||||||
))}
|
) ?? null)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
readonly={!canEdit}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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";
|
import type { Project } from "../lib/queries/projects";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -7,34 +11,24 @@ interface Props {
|
|||||||
readonly?: boolean;
|
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) {
|
export default function PlanRangeChips({ cell, project, readonly }: Props) {
|
||||||
void readonly;
|
void readonly;
|
||||||
if (!cell) return null;
|
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.project_number} — ${project.name}`
|
? `${project.project_number} — ${project.name}`
|
||||||
: project.name
|
: project.name
|
||||||
: null;
|
: null;
|
||||||
const projectTitle = project
|
const projectLabel = cellProjectLabel(cell) ?? fallbackLabel;
|
||||||
? project.project_number
|
const projectTitle = projectLabel ?? undefined;
|
||||||
? `${project.project_number} — ${project.name}`
|
|
||||||
: project.name
|
|
||||||
: undefined;
|
|
||||||
return (
|
return (
|
||||||
<div className="plan-chip-block">
|
<div className="plan-chip-block">
|
||||||
<div className="plan-chip-line">
|
<div className="plan-chip-line">
|
||||||
<span className={`plan-chip plan-chip--${cell.category}`}>
|
<span className={`plan-chip plan-chip--${cell.category}`}>
|
||||||
{CATEGORY_LABELS[cell.category] ?? cell.category}
|
{planCategoryLabel(cell.category)}
|
||||||
</span>
|
</span>
|
||||||
{projectLabel && (
|
{projectLabel && (
|
||||||
<span className="plan-chip-project" title={projectTitle}>
|
<span className="plan-chip-project" title={projectTitle}>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useState, useCallback, useMemo } from "react";
|
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 apiFetch from "../utils/api";
|
||||||
import { planKeys, GridData, ResolvedCell } from "../lib/queries/plan";
|
import { planKeys, GridData, ResolvedCell } from "../lib/queries/plan";
|
||||||
|
|
||||||
@@ -85,13 +90,147 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
apiFetch(
|
apiFetch(
|
||||||
`/api/admin/plan/grid?date_from=${isoDate(range.from)}&date_to=${isoDate(range.to)}&view=${view}`,
|
`/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)),
|
).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(() => {
|
const invalidate = useCallback(() => {
|
||||||
qc.invalidateQueries({ queryKey: planKeys.all });
|
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]);
|
}, [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 ---
|
// --- Mutations ---
|
||||||
|
|
||||||
const createEntry = useMutation({
|
const createEntry = useMutation({
|
||||||
mutationFn: (body: any) =>
|
mutationFn: (body: any) =>
|
||||||
apiCall("/api/admin/plan/entries", {
|
apiCall("/api/admin/plan/entries", {
|
||||||
@@ -99,7 +238,29 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: { "Content-Type": "application/json" },
|
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({
|
const updateEntry = useMutation({
|
||||||
@@ -117,7 +278,52 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: { "Content-Type": "application/json" },
|
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({
|
const deleteEntry = useMutation({
|
||||||
@@ -125,7 +331,35 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
||||||
method: "DELETE",
|
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({
|
const createOverride = useMutation({
|
||||||
@@ -135,7 +369,25 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: { "Content-Type": "application/json" },
|
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({
|
const updateOverride = useMutation({
|
||||||
@@ -153,7 +405,36 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: { "Content-Type": "application/json" },
|
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({
|
const deleteOverride = useMutation({
|
||||||
@@ -161,9 +442,49 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
||||||
method: "DELETE",
|
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 {
|
return {
|
||||||
view,
|
view,
|
||||||
setView,
|
setView,
|
||||||
@@ -174,6 +495,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
range,
|
range,
|
||||||
grid: gridQuery.data,
|
grid: gridQuery.data,
|
||||||
gridLoading: gridQuery.isLoading,
|
gridLoading: gridQuery.isLoading,
|
||||||
|
gridFetching: gridQuery.isFetching,
|
||||||
|
gridIsPlaceholder: gridQuery.isPlaceholderData,
|
||||||
modal,
|
modal,
|
||||||
setModal,
|
setModal,
|
||||||
canEdit,
|
canEdit,
|
||||||
@@ -184,6 +507,10 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
updateOverride,
|
updateOverride,
|
||||||
deleteOverride,
|
deleteOverride,
|
||||||
invalidate,
|
invalidate,
|
||||||
|
// Exposed so PlanWork can roll back an optimistic patch when a
|
||||||
|
// mutation throws. Pass the failed mutation and the userId it
|
||||||
|
// targeted.
|
||||||
|
rollbackMutation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,52 @@ export interface ResolvedCell {
|
|||||||
user_id: number;
|
user_id: number;
|
||||||
shift_date: string;
|
shift_date: string;
|
||||||
project_id: number | null;
|
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;
|
category: string;
|
||||||
note: string;
|
note: string;
|
||||||
rangeFrom: string | null;
|
rangeFrom: string | null;
|
||||||
rangeTo: 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 {
|
export interface GridData {
|
||||||
view: "week" | "month";
|
view: "week" | "month";
|
||||||
date_from: string;
|
date_from: string;
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ const ENTITY_TYPE_LABELS: Record<string, string> = {
|
|||||||
trips: "Jízda",
|
trips: "Jízda",
|
||||||
vehicles: "Vozidlo",
|
vehicles: "Vozidlo",
|
||||||
bank_account: "Bankovní účet",
|
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]) => ({
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import {
|
import {
|
||||||
@@ -84,6 +85,19 @@ function formatRangeLabel(
|
|||||||
return `Týden ${week} (${fmt(from)} – ${fmt(to)} ${fromDate.getUTCFullYear()})`;
|
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() {
|
export default function PlanWork() {
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -104,12 +118,43 @@ export default function PlanWork() {
|
|||||||
createOverride,
|
createOverride,
|
||||||
updateOverride,
|
updateOverride,
|
||||||
deleteOverride,
|
deleteOverride,
|
||||||
|
rollbackMutation,
|
||||||
} = usePlanWork({ canEdit });
|
} = usePlanWork({ canEdit });
|
||||||
|
|
||||||
// Projects list — needed by the modal's project selector.
|
// Projects list — needed by the modal's project selector.
|
||||||
const { data: projectsData } = useQuery(projectListOptions({ perPage: 200 }));
|
const { data: projectsData } = useQuery(projectListOptions({ perPage: 200 }));
|
||||||
const projects: Project[] = (projectsData?.data as Project[]) ?? [];
|
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(() => {
|
const goPrev = useCallback(() => {
|
||||||
setAnchor((a) => shiftAnchor(a, view, -1));
|
setAnchor((a) => shiftAnchor(a, view, -1));
|
||||||
}, [setAnchor, view]);
|
}, [setAnchor, view]);
|
||||||
@@ -122,6 +167,13 @@ export default function PlanWork() {
|
|||||||
setAnchor(new Date());
|
setAnchor(new Date());
|
||||||
}, [setAnchor]);
|
}, [setAnchor]);
|
||||||
|
|
||||||
|
const setViewWithAnim = useCallback(
|
||||||
|
(next: "week" | "month") => {
|
||||||
|
setView(next);
|
||||||
|
},
|
||||||
|
[setView],
|
||||||
|
);
|
||||||
|
|
||||||
// --- Modal helpers ---------------------------------------------------------
|
// --- Modal helpers ---------------------------------------------------------
|
||||||
// The hook stores a slim ModalMode (no `cell` / `range` payload). The
|
// The hook stores a slim ModalMode (no `cell` / `range` payload). The
|
||||||
// PlanCellModal component expects a richer mode with those fields. We keep
|
// PlanCellModal component expects a richer mode with those fields. We keep
|
||||||
@@ -142,6 +194,19 @@ export default function PlanWork() {
|
|||||||
const openCell = useCallback(
|
const openCell = useCallback(
|
||||||
(userId: number, date: string) => {
|
(userId: number, date: string) => {
|
||||||
const cell = getCell(grid, userId, date);
|
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) {
|
if (!cell) {
|
||||||
// Empty cell — open the create modal if the user can edit, otherwise
|
// Empty cell — open the create modal if the user can edit, otherwise
|
||||||
// there's nothing to view.
|
// there's nothing to view.
|
||||||
@@ -222,84 +287,161 @@ export default function PlanWork() {
|
|||||||
|
|
||||||
// --- Mutation wrappers -----------------------------------------------------
|
// --- Mutation wrappers -----------------------------------------------------
|
||||||
// The modal calls these with plain objects / ids. We resolve them to the
|
// 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(
|
const saveEntry = useCallback(
|
||||||
async (body: any) => {
|
async (body: any) => {
|
||||||
try {
|
try {
|
||||||
await createEntry.mutateAsync(body);
|
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");
|
alert.success("Záznam vytvořen");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
rollbackMutation(createEntry, body.user_id);
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createEntry, alert],
|
[createEntry, alert, firePulse, rollbackMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateEntryFn = useCallback(
|
const updateEntryFn = useCallback(
|
||||||
async (id: number, body: any) => {
|
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 {
|
try {
|
||||||
await updateEntry.mutateAsync({ id, body });
|
await updateEntry.mutateAsync({ id, body });
|
||||||
|
if (userId !== null && firstDate !== null) firePulse(userId, firstDate);
|
||||||
alert.success("Záznam aktualizován");
|
alert.success("Záznam aktualizován");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (userId !== null) rollbackMutation(updateEntry, userId);
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateEntry, alert],
|
[updateEntry, grid, alert, firePulse, rollbackMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteEntryFn = useCallback(
|
const deleteEntryFn = useCallback(
|
||||||
async (id: number) => {
|
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 {
|
try {
|
||||||
await deleteEntry.mutateAsync({ id });
|
await deleteEntry.mutateAsync({ id });
|
||||||
|
if (userId !== null && firstDate !== null) firePulse(userId, firstDate);
|
||||||
alert.success("Záznam smazán");
|
alert.success("Záznam smazán");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (userId !== null) rollbackMutation(deleteEntry, userId);
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba při mazání");
|
alert.error(e instanceof Error ? e.message : "Chyba při mazání");
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[deleteEntry, alert],
|
[deleteEntry, grid, alert, firePulse, rollbackMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const saveOverride = useCallback(
|
const saveOverride = useCallback(
|
||||||
async (body: any) => {
|
async (body: any) => {
|
||||||
try {
|
try {
|
||||||
await createOverride.mutateAsync(body);
|
await createOverride.mutateAsync(body);
|
||||||
|
firePulse(body.user_id, body.shift_date);
|
||||||
alert.success("Přepsání vytvořeno");
|
alert.success("Přepsání vytvořeno");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
rollbackMutation(createOverride, body.user_id);
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createOverride, alert],
|
[createOverride, alert, firePulse, rollbackMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateOverrideFn = useCallback(
|
const updateOverrideFn = useCallback(
|
||||||
async (id: number, body: any) => {
|
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 {
|
try {
|
||||||
await updateOverride.mutateAsync({ id, body });
|
await updateOverride.mutateAsync({ id, body });
|
||||||
|
if (userId !== null && date !== null) firePulse(userId, date);
|
||||||
alert.success("Přepsání aktualizováno");
|
alert.success("Přepsání aktualizováno");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (userId !== null) rollbackMutation(updateOverride, userId);
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateOverride, alert],
|
[updateOverride, grid, alert, firePulse, rollbackMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteOverrideFn = useCallback(
|
const deleteOverrideFn = useCallback(
|
||||||
async (id: number) => {
|
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 {
|
try {
|
||||||
await deleteOverride.mutateAsync({ id });
|
await deleteOverride.mutateAsync({ id });
|
||||||
|
if (userId !== null && date !== null) firePulse(userId, date);
|
||||||
alert.success("Přepsání smazáno");
|
alert.success("Přepsání smazáno");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (userId !== null) rollbackMutation(deleteOverride, userId);
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba při mazání");
|
alert.error(e instanceof Error ? e.message : "Chyba při mazání");
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[deleteOverride, alert],
|
[deleteOverride, grid, alert, firePulse, rollbackMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
// "Create override from range" — turn a multi-day entry into a one-day
|
// "Create override from range" — turn a multi-day entry into a one-day
|
||||||
@@ -320,8 +462,10 @@ export default function PlanWork() {
|
|||||||
category: cell.category,
|
category: cell.category,
|
||||||
note: cell.note,
|
note: cell.note,
|
||||||
});
|
});
|
||||||
|
firePulse(userId, date);
|
||||||
alert.success("Přepsání dne vytvořeno");
|
alert.success("Přepsání dne vytvořeno");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
rollbackMutation(createOverride, userId);
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@@ -329,7 +473,7 @@ export default function PlanWork() {
|
|||||||
// symmetry with the modal contract.
|
// symmetry with the modal contract.
|
||||||
void entryId;
|
void entryId;
|
||||||
},
|
},
|
||||||
[grid, createOverride, alert],
|
[grid, createOverride, alert, firePulse, rollbackMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Modal key for remount-on-mode-change ----------------------------------
|
// --- Modal key for remount-on-mode-change ----------------------------------
|
||||||
@@ -392,10 +536,23 @@ export default function PlanWork() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="plan-work-page">
|
<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 && (
|
{!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>
|
<span className="plan-banner-icon" aria-hidden>
|
||||||
i
|
i
|
||||||
</span>
|
</span>
|
||||||
@@ -403,10 +560,15 @@ export default function PlanWork() {
|
|||||||
<strong>Režim náhledu</strong> — můžete pouze prohlížet plán. Úpravy
|
<strong>Režim náhledu</strong> — můžete pouze prohlížet plán. Úpravy
|
||||||
jsou dostupné oprávněným uživatelům.
|
jsou dostupné oprávněným uživatelům.
|
||||||
</span>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="admin-btn admin-btn-secondary"
|
className="admin-btn admin-btn-secondary"
|
||||||
@@ -430,8 +592,19 @@ export default function PlanWork() {
|
|||||||
>
|
>
|
||||||
→
|
→
|
||||||
</button>
|
</button>
|
||||||
<span className="plan-range-label">
|
<span className="plan-range-label-slot">
|
||||||
{formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
|
<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>
|
</span>
|
||||||
<div
|
<div
|
||||||
className="plan-view-toggle"
|
className="plan-view-toggle"
|
||||||
@@ -441,23 +614,27 @@ export default function PlanWork() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={
|
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
|
Týden
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={
|
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
|
Měsíc
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{gridLoading && (
|
{gridLoading && (
|
||||||
<div className="admin-loading">
|
<div className="admin-loading">
|
||||||
@@ -465,12 +642,37 @@ export default function PlanWork() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PlanGrid
|
{/*
|
||||||
data={grid}
|
Grid entrance: same pattern as the h1 / banner / toolbar above
|
||||||
projects={projects}
|
(opacity + 12px translateY, 250ms ease-out). The stagger is
|
||||||
canEdit={canEdit}
|
h1 (0s) → banner (0.06s) → toolbar (0.12s) → grid (0.18s), so
|
||||||
onCellClick={openCell}
|
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
|
<PlanCellModal
|
||||||
key={modalKey}
|
key={modalKey}
|
||||||
|
|||||||
1023
src/admin/plan.css
1023
src/admin/plan.css
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,8 @@ export const ENTITY_TYPE_LABELS: Record<string, string> = {
|
|||||||
trips: "Jízda",
|
trips: "Jízda",
|
||||||
vehicles: "Vozidlo",
|
vehicles: "Vozidlo",
|
||||||
bank_account: "Bankovní účet",
|
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> = {
|
export const ACTION_LABELS: Record<string, string> = {
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
|||||||
entityType: "work_plan_entry",
|
entityType: "work_plan_entry",
|
||||||
entityId: (result.data as any).id,
|
entityId: (result.data as any).id,
|
||||||
newValues: result.data,
|
newValues: result.data,
|
||||||
description: force ? "force-edit past date" : undefined,
|
description: result.description,
|
||||||
});
|
});
|
||||||
return success(reply, result.data, 201, "Plán vytvořen");
|
return success(reply, result.data, 201, "Plán vytvořen");
|
||||||
},
|
},
|
||||||
@@ -187,7 +187,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
oldValues: (result.oldData as any) ?? undefined,
|
oldValues: (result.oldData as any) ?? undefined,
|
||||||
newValues: result.data as Record<string, unknown>,
|
newValues: result.data as Record<string, unknown>,
|
||||||
description: force ? "force-edit past date" : undefined,
|
description: result.description,
|
||||||
});
|
});
|
||||||
return success(reply, result.data, 200, "Plán aktualizován");
|
return success(reply, result.data, 200, "Plán aktualizován");
|
||||||
},
|
},
|
||||||
@@ -210,7 +210,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
|||||||
entityType: "work_plan_entry",
|
entityType: "work_plan_entry",
|
||||||
entityId: id,
|
entityId: id,
|
||||||
oldValues: (result.oldData as any) ?? undefined,
|
oldValues: (result.oldData as any) ?? undefined,
|
||||||
description: force ? "force-edit past date" : undefined,
|
description: result.description,
|
||||||
});
|
});
|
||||||
return success(reply, { ok: true }, 200, "Plán smazán");
|
return success(reply, { ok: true }, 200, "Plán smazán");
|
||||||
},
|
},
|
||||||
@@ -240,7 +240,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
|||||||
entityType: "work_plan_override",
|
entityType: "work_plan_override",
|
||||||
entityId: (result.replacedData as any).id,
|
entityId: (result.replacedData as any).id,
|
||||||
oldValues: result.replacedData as Record<string, unknown>,
|
oldValues: result.replacedData as Record<string, unknown>,
|
||||||
description: "auto-soft-delete before replace",
|
description: result.replacedDescription,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await logAudit({
|
await logAudit({
|
||||||
@@ -250,7 +250,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
|||||||
entityType: "work_plan_override",
|
entityType: "work_plan_override",
|
||||||
entityId: (result.data as any).id,
|
entityId: (result.data as any).id,
|
||||||
newValues: result.data,
|
newValues: result.data,
|
||||||
description: force ? "force-edit past date" : undefined,
|
description: result.description,
|
||||||
});
|
});
|
||||||
return success(reply, result.data, 201, "Přepsání dne vytvořeno");
|
return success(reply, result.data, 201, "Přepsání dne vytvořeno");
|
||||||
},
|
},
|
||||||
@@ -281,7 +281,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
oldValues: (result.oldData as any) ?? undefined,
|
oldValues: (result.oldData as any) ?? undefined,
|
||||||
newValues: result.data,
|
newValues: result.data,
|
||||||
description: force ? "force-edit past date" : undefined,
|
description: result.description,
|
||||||
});
|
});
|
||||||
return success(reply, result.data, 200, "Přepsání aktualizováno");
|
return success(reply, result.data, 200, "Přepsání aktualizováno");
|
||||||
},
|
},
|
||||||
@@ -304,7 +304,7 @@ export default async function planRoutes(app: FastifyInstance) {
|
|||||||
entityType: "work_plan_override",
|
entityType: "work_plan_override",
|
||||||
entityId: id,
|
entityId: id,
|
||||||
oldValues: (result.oldData as any) ?? undefined,
|
oldValues: (result.oldData as any) ?? undefined,
|
||||||
description: force ? "force-edit past date" : undefined,
|
description: result.description,
|
||||||
});
|
});
|
||||||
return success(reply, { ok: true }, 200, "Přepsání smazáno");
|
return success(reply, { ok: true }, 200, "Přepsání smazáno");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,59 @@
|
|||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
|
import { buildPlanAuditDescription } from "../utils/planAuditDescription";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve display names for an audit description: the employee's full name
|
||||||
|
* and the project name (or null when there is no project). Used so audit-log
|
||||||
|
* rows show "Jan Novák · … · Projekt ABC" instead of bare numeric ids.
|
||||||
|
* Falls back to "#id" placeholders if a row was concurrently removed.
|
||||||
|
*/
|
||||||
|
async function resolvePlanLabels(
|
||||||
|
userId: number,
|
||||||
|
projectId: number | null,
|
||||||
|
): Promise<{ userName: string; projectName: string | null }> {
|
||||||
|
const [user, project] = await Promise.all([
|
||||||
|
prisma.users.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { first_name: true, last_name: true, username: true },
|
||||||
|
}),
|
||||||
|
projectId
|
||||||
|
? prisma.projects.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
select: { name: true, project_number: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userName = user
|
||||||
|
? `${user.first_name} ${user.last_name}`.trim() || user.username
|
||||||
|
: `uživatel #${userId}`;
|
||||||
|
|
||||||
|
const projectName = project
|
||||||
|
? project.name?.trim() || project.project_number || `projekt #${projectId}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { userName, projectName };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYY-MM-DD from a Prisma @db.Date value. Matches how the rest of this
|
||||||
|
* file reads date-only columns (UTC slice), so it is timezone-stable. */
|
||||||
|
function isoDay(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prisma `select` for the joined project, plus a mapper to the cell's
|
||||||
|
* project_number/project_name fields. Used so the grid carries the project
|
||||||
|
* label without a second client-side lookup. */
|
||||||
|
const projectSelect = { select: { project_number: true, name: true } } as const;
|
||||||
|
|
||||||
|
function projectFields(
|
||||||
|
p: { project_number: string | null; name: string | null } | null | undefined,
|
||||||
|
): { project_number: string | null; project_name: string | null } {
|
||||||
|
return {
|
||||||
|
project_number: p?.project_number ?? null,
|
||||||
|
project_name: p?.name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolved plan cell for a single (user, date). Either from a range entry
|
* Resolved plan cell for a single (user, date). Either from a range entry
|
||||||
@@ -15,6 +70,11 @@ export interface ResolvedCell {
|
|||||||
/** The date the cell resolves to (echoes the input, YYYY-MM-DD). */
|
/** The date the cell resolves to (echoes the input, YYYY-MM-DD). */
|
||||||
shift_date: string;
|
shift_date: string;
|
||||||
project_id: number | null;
|
project_id: number | null;
|
||||||
|
/** Project number/name resolved server-side from the projects relation,
|
||||||
|
* so the UI never depends on a separately-fetched (and capped/permission-
|
||||||
|
* gated) projects list to display the project. Null when no project. */
|
||||||
|
project_number: string | null;
|
||||||
|
project_name: string | null;
|
||||||
category: string;
|
category: string;
|
||||||
note: string;
|
note: string;
|
||||||
/** Original entry range, or null when source is "override". */
|
/** Original entry range, or null when source is "override". */
|
||||||
@@ -43,6 +103,7 @@ export async function resolveCell(
|
|||||||
// 1. Single-day override for this exact date
|
// 1. Single-day override for this exact date
|
||||||
const override = await prisma.work_plan_overrides.findFirst({
|
const override = await prisma.work_plan_overrides.findFirst({
|
||||||
where: { user_id: userId, shift_date: date, is_deleted: false },
|
where: { user_id: userId, shift_date: date, is_deleted: false },
|
||||||
|
include: { projects: projectSelect },
|
||||||
});
|
});
|
||||||
if (override) {
|
if (override) {
|
||||||
return {
|
return {
|
||||||
@@ -52,6 +113,7 @@ export async function resolveCell(
|
|||||||
user_id: override.user_id,
|
user_id: override.user_id,
|
||||||
shift_date: dateStr,
|
shift_date: dateStr,
|
||||||
project_id: override.project_id,
|
project_id: override.project_id,
|
||||||
|
...projectFields(override.projects),
|
||||||
category: override.category,
|
category: override.category,
|
||||||
note: override.note,
|
note: override.note,
|
||||||
rangeFrom: null,
|
rangeFrom: null,
|
||||||
@@ -68,6 +130,7 @@ export async function resolveCell(
|
|||||||
is_deleted: false,
|
is_deleted: false,
|
||||||
},
|
},
|
||||||
orderBy: { created_at: "desc" },
|
orderBy: { created_at: "desc" },
|
||||||
|
include: { projects: projectSelect },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (entries.length === 0) return null;
|
if (entries.length === 0) return null;
|
||||||
@@ -90,6 +153,7 @@ export async function resolveCell(
|
|||||||
user_id: entry.user_id,
|
user_id: entry.user_id,
|
||||||
shift_date: dateStr,
|
shift_date: dateStr,
|
||||||
project_id: entry.project_id,
|
project_id: entry.project_id,
|
||||||
|
...projectFields(entry.projects),
|
||||||
category: entry.category,
|
category: entry.category,
|
||||||
note: entry.note,
|
note: entry.note,
|
||||||
rangeFrom: entry.date_from.toISOString().slice(0, 10),
|
rangeFrom: entry.date_from.toISOString().slice(0, 10),
|
||||||
@@ -122,6 +186,7 @@ export async function resolveGrid(
|
|||||||
date_to: { gte: dateFrom },
|
date_to: { gte: dateFrom },
|
||||||
},
|
},
|
||||||
orderBy: { created_at: "desc" },
|
orderBy: { created_at: "desc" },
|
||||||
|
include: { projects: projectSelect },
|
||||||
}),
|
}),
|
||||||
prisma.work_plan_overrides.findMany({
|
prisma.work_plan_overrides.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -129,6 +194,7 @@ export async function resolveGrid(
|
|||||||
is_deleted: false,
|
is_deleted: false,
|
||||||
shift_date: { gte: dateFrom, lte: dateTo },
|
shift_date: { gte: dateFrom, lte: dateTo },
|
||||||
},
|
},
|
||||||
|
include: { projects: projectSelect },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -160,6 +226,7 @@ export async function resolveGrid(
|
|||||||
user_id: override.user_id,
|
user_id: override.user_id,
|
||||||
shift_date: dateStr,
|
shift_date: dateStr,
|
||||||
project_id: override.project_id,
|
project_id: override.project_id,
|
||||||
|
...projectFields(override.projects),
|
||||||
category: override.category,
|
category: override.category,
|
||||||
note: override.note,
|
note: override.note,
|
||||||
rangeFrom: null,
|
rangeFrom: null,
|
||||||
@@ -182,6 +249,7 @@ export async function resolveGrid(
|
|||||||
user_id: entry.user_id,
|
user_id: entry.user_id,
|
||||||
shift_date: dateStr,
|
shift_date: dateStr,
|
||||||
project_id: entry.project_id,
|
project_id: entry.project_id,
|
||||||
|
...projectFields(entry.projects),
|
||||||
category: entry.category,
|
category: entry.category,
|
||||||
note: entry.note,
|
note: entry.note,
|
||||||
rangeFrom: entry.date_from.toISOString().slice(0, 10),
|
rangeFrom: entry.date_from.toISOString().slice(0, 10),
|
||||||
@@ -292,7 +360,15 @@ export function assertNotPastDate(
|
|||||||
* On failure returns `{ error, status }`.
|
* On failure returns `{ error, status }`.
|
||||||
*/
|
*/
|
||||||
export type Result<T> =
|
export type Result<T> =
|
||||||
| { data: T; oldData: unknown | null; replacedData?: unknown | null }
|
| {
|
||||||
|
data: T;
|
||||||
|
oldData: unknown | null;
|
||||||
|
replacedData?: unknown | null;
|
||||||
|
/** Human-readable Czech subject for the audit-log row. */
|
||||||
|
description?: string;
|
||||||
|
/** Audit description for the `replacedData` soft-delete row, if any. */
|
||||||
|
replacedDescription?: string;
|
||||||
|
}
|
||||||
| { error: string; status: number };
|
| { error: string; status: number };
|
||||||
|
|
||||||
/** Convert a YYYY-MM-DD string to a Date at midnight UTC. */
|
/** Convert a YYYY-MM-DD string to a Date at midnight UTC. */
|
||||||
@@ -350,7 +426,20 @@ export async function createEntry(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data: created, oldData: null };
|
const { userName, projectName } = await resolvePlanLabels(
|
||||||
|
created.user_id,
|
||||||
|
created.project_id,
|
||||||
|
);
|
||||||
|
const description = buildPlanAuditDescription({
|
||||||
|
userName,
|
||||||
|
category: created.category,
|
||||||
|
projectName,
|
||||||
|
dateFrom: input.date_from,
|
||||||
|
dateTo: input.date_to,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data: created, oldData: null, description };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update a work_plan_entries row. Partial update. */
|
/** Update a work_plan_entries row. Partial update. */
|
||||||
@@ -396,7 +485,20 @@ export async function updateEntry(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data: updated, oldData: existing };
|
const { userName, projectName } = await resolvePlanLabels(
|
||||||
|
updated.user_id,
|
||||||
|
updated.project_id,
|
||||||
|
);
|
||||||
|
const description = buildPlanAuditDescription({
|
||||||
|
userName,
|
||||||
|
category: updated.category,
|
||||||
|
projectName,
|
||||||
|
dateFrom: isoDay(updated.date_from),
|
||||||
|
dateTo: isoDay(updated.date_to),
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data: updated, oldData: existing, description };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Soft-delete a work_plan_entries row. */
|
/** Soft-delete a work_plan_entries row. */
|
||||||
@@ -418,7 +520,20 @@ export async function deleteEntry(
|
|||||||
data: { is_deleted: true },
|
data: { is_deleted: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data: { ok: true }, oldData: existing };
|
const { userName, projectName } = await resolvePlanLabels(
|
||||||
|
existing.user_id,
|
||||||
|
existing.project_id,
|
||||||
|
);
|
||||||
|
const description = buildPlanAuditDescription({
|
||||||
|
userName,
|
||||||
|
category: existing.category,
|
||||||
|
projectName,
|
||||||
|
dateFrom: isoDay(existing.date_from),
|
||||||
|
dateTo: isoDay(existing.date_to),
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data: { ok: true }, oldData: existing, description };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -459,7 +574,7 @@ export async function createOverride(
|
|||||||
// can't both create active overrides for the same user-day.
|
// can't both create active overrides for the same user-day.
|
||||||
const date = toDateOnly(input.shift_date);
|
const date = toDateOnly(input.shift_date);
|
||||||
|
|
||||||
return prisma.$transaction(async (tx) => {
|
const { created, replaced } = await prisma.$transaction(async (tx) => {
|
||||||
const existing = await tx.work_plan_overrides.findFirst({
|
const existing = await tx.work_plan_overrides.findFirst({
|
||||||
where: {
|
where: {
|
||||||
user_id: input.user_id,
|
user_id: input.user_id,
|
||||||
@@ -468,7 +583,6 @@ export async function createOverride(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let replacedData: unknown = null;
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Lock the row before update so a concurrent createOverride for the
|
// Lock the row before update so a concurrent createOverride for the
|
||||||
// same (user, date) waits and sees the soft-deleted state.
|
// same (user, date) waits and sees the soft-deleted state.
|
||||||
@@ -477,10 +591,9 @@ export async function createOverride(
|
|||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: { is_deleted: true },
|
data: { is_deleted: true },
|
||||||
});
|
});
|
||||||
replacedData = existing;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await tx.work_plan_overrides.create({
|
const createdRow = await tx.work_plan_overrides.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: input.user_id,
|
user_id: input.user_id,
|
||||||
shift_date: date,
|
shift_date: date,
|
||||||
@@ -491,12 +604,42 @@ export async function createOverride(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { created: createdRow, replaced: existing };
|
||||||
data: created,
|
|
||||||
oldData: null,
|
|
||||||
replacedData,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { userName, projectName } = await resolvePlanLabels(
|
||||||
|
created.user_id,
|
||||||
|
created.project_id,
|
||||||
|
);
|
||||||
|
const description = buildPlanAuditDescription({
|
||||||
|
userName,
|
||||||
|
category: created.category,
|
||||||
|
projectName,
|
||||||
|
dateFrom: input.shift_date,
|
||||||
|
dateTo: input.shift_date,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
|
||||||
|
let replacedDescription: string | undefined;
|
||||||
|
if (replaced) {
|
||||||
|
const r = await resolvePlanLabels(replaced.user_id, replaced.project_id);
|
||||||
|
replacedDescription = buildPlanAuditDescription({
|
||||||
|
userName: r.userName,
|
||||||
|
category: replaced.category,
|
||||||
|
projectName: r.projectName,
|
||||||
|
dateFrom: isoDay(replaced.shift_date),
|
||||||
|
dateTo: isoDay(replaced.shift_date),
|
||||||
|
suffix: "nahrazeno novým záznamem",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: created,
|
||||||
|
oldData: null,
|
||||||
|
replacedData: replaced,
|
||||||
|
description,
|
||||||
|
replacedDescription,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update a work_plan_overrides row. */
|
/** Update a work_plan_overrides row. */
|
||||||
@@ -525,7 +668,20 @@ export async function updateOverride(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data: updated, oldData: existing };
|
const { userName, projectName } = await resolvePlanLabels(
|
||||||
|
updated.user_id,
|
||||||
|
updated.project_id,
|
||||||
|
);
|
||||||
|
const description = buildPlanAuditDescription({
|
||||||
|
userName,
|
||||||
|
category: updated.category,
|
||||||
|
projectName,
|
||||||
|
dateFrom: isoDay(updated.shift_date),
|
||||||
|
dateTo: isoDay(updated.shift_date),
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data: updated, oldData: existing, description };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Soft-delete a work_plan_overrides row. */
|
/** Soft-delete a work_plan_overrides row. */
|
||||||
@@ -549,7 +705,20 @@ export async function deleteOverride(
|
|||||||
data: { is_deleted: true },
|
data: { is_deleted: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data: { ok: true }, oldData: existing };
|
const { userName, projectName } = await resolvePlanLabels(
|
||||||
|
existing.user_id,
|
||||||
|
existing.project_id,
|
||||||
|
);
|
||||||
|
const description = buildPlanAuditDescription({
|
||||||
|
userName,
|
||||||
|
category: existing.category,
|
||||||
|
projectName,
|
||||||
|
dateFrom: isoDay(existing.shift_date),
|
||||||
|
dateTo: isoDay(existing.shift_date),
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data: { ok: true }, oldData: existing, description };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
94
src/utils/planAuditDescription.ts
Normal file
94
src/utils/planAuditDescription.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Human-readable Czech descriptions for work-plan audit-log rows.
|
||||||
|
*
|
||||||
|
* The audit log table ("Popis" column) shows only the `description` string,
|
||||||
|
* so it must be self-contained: who, when, what. Previously the plan routes
|
||||||
|
* left `description` undefined for normal edits, which rendered as "-" and
|
||||||
|
* made the audit rows useless. These helpers build a readable subject line
|
||||||
|
* such as: `Jan Novák · 08.06.2026 – 12.06.2026 · Práce · Projekt ABC`.
|
||||||
|
*
|
||||||
|
* Everything here is pure (no Prisma, no Date.now) so it is trivially unit
|
||||||
|
* testable. The service layer resolves user/project names and feeds plain
|
||||||
|
* strings in.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Czech labels for the `plan_category` enum. Mirrors the frontend
|
||||||
|
* PlanCellModal options so the audit log reads the same as the UI. */
|
||||||
|
export const PLAN_CATEGORY_LABELS_CS: Record<string, string> = {
|
||||||
|
work: "Práce",
|
||||||
|
preparation: "Příprava",
|
||||||
|
travel: "Cesta / Montáž",
|
||||||
|
leave: "Dovolená",
|
||||||
|
sick: "Nemoc",
|
||||||
|
training: "Školení",
|
||||||
|
other: "Jiné",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Translate a plan category to its Czech label, falling back to the raw
|
||||||
|
* value for unknown categories so nothing is ever silently dropped. */
|
||||||
|
export function planCategoryLabel(category: string): string {
|
||||||
|
return PLAN_CATEGORY_LABELS_CS[category] ?? category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a `YYYY-MM-DD` string as Czech `DD.MM.YYYY`.
|
||||||
|
*
|
||||||
|
* Takes a string (not a Date) on purpose: work-plan dates live in `@db.Date`
|
||||||
|
* columns and the service derives the ISO day via `toISOString().slice(0,10)`,
|
||||||
|
* so there is no timezone ambiguity to reintroduce here. Matches the
|
||||||
|
* `localDateCzStr` style in `src/utils/date.ts`.
|
||||||
|
*/
|
||||||
|
export function formatPlanDate(iso: string): string {
|
||||||
|
const [y, m, d] = iso.split("-");
|
||||||
|
if (!y || !m || !d) return iso;
|
||||||
|
return `${d}.${m}.${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanAuditDescriptionInput {
|
||||||
|
/** Employee the plan row belongs to (display name). */
|
||||||
|
userName: string;
|
||||||
|
/** `plan_category` enum value. */
|
||||||
|
category: string;
|
||||||
|
/** Resolved project name, or null when the row has no project. */
|
||||||
|
projectName?: string | null;
|
||||||
|
/** Range start (YYYY-MM-DD). For overrides, pass the shift date here. */
|
||||||
|
dateFrom: string;
|
||||||
|
/** Range end (YYYY-MM-DD). For overrides/single days, equals `dateFrom`. */
|
||||||
|
dateTo: string;
|
||||||
|
/** Append "nouzová úprava" when the change bypassed the past-date lock. */
|
||||||
|
force?: boolean;
|
||||||
|
/** Extra Czech note appended at the end (e.g. "nahrazeno novým záznamem"). */
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Czech audit description: a `·`-separated subject line. The action
|
||||||
|
* (create/update/delete) and entity type are shown in their own audit-table
|
||||||
|
* columns, so the description carries only the subject — never the verb.
|
||||||
|
*/
|
||||||
|
export function buildPlanAuditDescription(
|
||||||
|
opts: PlanAuditDescriptionInput,
|
||||||
|
): string {
|
||||||
|
const dateLabel =
|
||||||
|
opts.dateFrom === opts.dateTo
|
||||||
|
? formatPlanDate(opts.dateFrom)
|
||||||
|
: `${formatPlanDate(opts.dateFrom)} – ${formatPlanDate(opts.dateTo)}`;
|
||||||
|
|
||||||
|
const parts: string[] = [
|
||||||
|
opts.userName,
|
||||||
|
dateLabel,
|
||||||
|
planCategoryLabel(opts.category),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (opts.projectName && opts.projectName.trim()) {
|
||||||
|
parts.push(opts.projectName.trim());
|
||||||
|
}
|
||||||
|
if (opts.suffix && opts.suffix.trim()) {
|
||||||
|
parts.push(opts.suffix.trim());
|
||||||
|
}
|
||||||
|
if (opts.force) {
|
||||||
|
parts.push("nouzová úprava");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user