Update validates the category only when it changes, so a note-only edit on an entry whose category was later hidden/deleted no longer 400s. The edit select also shows the current (retired) category marked '(skryta)' instead of silently snapping to the first active option; create defaults to an active category. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
483 lines
14 KiB
TypeScript
483 lines
14 KiB
TypeScript
import { useState } from "react";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import FormModal from "./FormModal";
|
||
import FormField from "./FormField";
|
||
import AdminDatePicker from "./AdminDatePicker";
|
||
import ConfirmModal from "./ConfirmModal";
|
||
import useReducedMotion from "../hooks/useReducedMotion";
|
||
import {
|
||
ResolvedCell,
|
||
PlanCategory,
|
||
planCategoryLabel,
|
||
cellProjectLabel,
|
||
} from "../lib/queries/plan";
|
||
import { formatDate } from "../utils/formatters";
|
||
|
||
interface Project {
|
||
id: number;
|
||
name: string;
|
||
project_number?: string;
|
||
}
|
||
|
||
export type PlanCellModalMode =
|
||
| { kind: "closed" }
|
||
| { kind: "create"; userId: number; date: string }
|
||
| {
|
||
kind: "edit-range";
|
||
entryId: number;
|
||
userId: number;
|
||
date: string;
|
||
range: {
|
||
date_from: string;
|
||
date_to: string;
|
||
project_id: number | null;
|
||
category: string;
|
||
note: string;
|
||
};
|
||
}
|
||
| {
|
||
kind: "edit-override";
|
||
overrideId: number;
|
||
userId: number;
|
||
date: string;
|
||
cell: ResolvedCell;
|
||
}
|
||
| {
|
||
kind: "day-in-range";
|
||
entryId: number;
|
||
userId: number;
|
||
date: string;
|
||
range: {
|
||
date_from: string;
|
||
date_to: string;
|
||
project_id: number | null;
|
||
category: string;
|
||
note: string;
|
||
};
|
||
}
|
||
| { kind: "view"; userId: number; date: string; cell: ResolvedCell };
|
||
|
||
interface Props {
|
||
open: boolean;
|
||
mode: PlanCellModalMode;
|
||
projects: Project[];
|
||
categories: PlanCategory[];
|
||
onClose: () => void;
|
||
onSaveEntry: (body: any) => Promise<void>;
|
||
onUpdateEntry: (id: number, body: any) => Promise<void>;
|
||
onDeleteEntry: (id: number) => Promise<void>;
|
||
onSaveOverride: (body: any) => Promise<void>;
|
||
onUpdateOverride: (id: number, body: any) => Promise<void>;
|
||
onDeleteOverride: (id: number) => Promise<void>;
|
||
onCreateOverrideFromRange: (
|
||
entryId: number,
|
||
userId: number,
|
||
date: string,
|
||
) => Promise<void>;
|
||
onSwitchToEditRange: (entryId: number, userId: number, date: string) => void;
|
||
}
|
||
|
||
export default function PlanCellModal(props: Props) {
|
||
const { open, mode, onClose } = props;
|
||
if (!open || mode.kind === "closed") return null;
|
||
if (mode.kind === "view") return <ViewModal {...props} />;
|
||
if (mode.kind === "day-in-range")
|
||
return <DayInRangeModal {...props} mode={mode} />;
|
||
return <EditForm {...props} />;
|
||
}
|
||
|
||
function EditForm(props: Props) {
|
||
const {
|
||
mode,
|
||
onClose,
|
||
onSaveEntry,
|
||
onUpdateEntry,
|
||
onDeleteEntry,
|
||
onSaveOverride,
|
||
onUpdateOverride,
|
||
onDeleteOverride,
|
||
projects,
|
||
} = props;
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||
|
||
// Narrow mode to the kinds EditForm actually handles. The call site in
|
||
// PlanCellModal only passes "create" | "edit-range" | "edit-override" but
|
||
// the parameter type is the full union, so we narrow explicitly here.
|
||
if (
|
||
mode.kind !== "create" &&
|
||
mode.kind !== "edit-range" &&
|
||
mode.kind !== "edit-override"
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
const isOverride = mode.kind === "edit-override";
|
||
const activeCategories = props.categories.filter((c) => c.is_active);
|
||
|
||
const initial = (() => {
|
||
if (mode.kind === "create") {
|
||
return {
|
||
date_from: mode.date,
|
||
date_to: mode.date,
|
||
is_range: false,
|
||
project_id: null as number | null,
|
||
// Default to "work" when it's active, otherwise the first active
|
||
// category, so a new entry never starts on a retired key.
|
||
category: activeCategories.some((c) => c.key === "work")
|
||
? "work"
|
||
: (activeCategories[0]?.key ?? "work"),
|
||
note: "",
|
||
};
|
||
}
|
||
if (mode.kind === "edit-range") {
|
||
return {
|
||
date_from: mode.range.date_from,
|
||
date_to: mode.range.date_to,
|
||
is_range: mode.range.date_from !== mode.range.date_to,
|
||
project_id: mode.range.project_id,
|
||
category: mode.range.category,
|
||
note: mode.range.note,
|
||
};
|
||
}
|
||
// mode.kind === "edit-override" (narrowed above)
|
||
return {
|
||
date_from: mode.date,
|
||
date_to: mode.date,
|
||
is_range: false,
|
||
project_id: mode.cell.project_id,
|
||
category: mode.cell.category,
|
||
note: mode.cell.note,
|
||
};
|
||
})();
|
||
|
||
const [dateFrom, setDateFrom] = useState(initial.date_from);
|
||
const [dateTo, setDateTo] = useState(initial.date_to);
|
||
const [isRange, setIsRange] = useState(initial.is_range);
|
||
const [projectId, setProjectId] = useState<number | null>(initial.project_id);
|
||
const [category, setCategory] = useState(initial.category);
|
||
const [note, setNote] = useState(initial.note);
|
||
|
||
const title =
|
||
mode.kind === "create"
|
||
? "Nový záznam plánu"
|
||
: mode.kind === "edit-range"
|
||
? "Upravit rozsah"
|
||
: "Upravit přepsání dne";
|
||
|
||
const handleSubmit = async () => {
|
||
setSubmitting(true);
|
||
try {
|
||
if (mode.kind === "create") {
|
||
const userId = mode.userId;
|
||
await onSaveEntry({
|
||
user_id: userId,
|
||
date_from: dateFrom,
|
||
date_to: isRange ? dateTo : dateFrom,
|
||
project_id: projectId,
|
||
category,
|
||
note,
|
||
});
|
||
} else if (mode.kind === "edit-range") {
|
||
await onUpdateEntry(mode.entryId, {
|
||
date_from: dateFrom,
|
||
date_to: isRange ? dateTo : dateFrom,
|
||
project_id: projectId,
|
||
category,
|
||
note,
|
||
});
|
||
} else {
|
||
await onUpdateOverride(mode.overrideId, {
|
||
project_id: projectId,
|
||
category,
|
||
note,
|
||
});
|
||
}
|
||
onClose();
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
setSubmitting(true);
|
||
try {
|
||
if (mode.kind === "edit-range") {
|
||
await onDeleteEntry(mode.entryId);
|
||
} else if (mode.kind === "edit-override") {
|
||
await onDeleteOverride(mode.overrideId);
|
||
}
|
||
onClose();
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<FormModal
|
||
isOpen
|
||
title={title}
|
||
onClose={onClose}
|
||
onSubmit={handleSubmit}
|
||
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
|
||
loading={submitting}
|
||
footerLeft={
|
||
mode.kind !== "create" ? (
|
||
<button
|
||
type="button"
|
||
className="admin-btn admin-btn-secondary"
|
||
onClick={() => setConfirmDelete(true)}
|
||
disabled={submitting}
|
||
>
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden
|
||
>
|
||
<polyline points="3 6 5 6 21 6" />
|
||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||
<path d="M10 11v6" />
|
||
<path d="M14 11v6" />
|
||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||
</svg>
|
||
Smazat
|
||
</button>
|
||
) : null
|
||
}
|
||
>
|
||
<FormField label="Datum od">
|
||
<AdminDatePicker
|
||
value={dateFrom}
|
||
onChange={setDateFrom}
|
||
disabled={isOverride}
|
||
/>
|
||
</FormField>
|
||
{!isOverride && (
|
||
<FormField label="Rozsah dnů">
|
||
<label className="admin-form-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={isRange}
|
||
onChange={(e) => setIsRange(e.target.checked)}
|
||
/>
|
||
<span>Více dní</span>
|
||
</label>
|
||
</FormField>
|
||
)}
|
||
{!isOverride && isRange && (
|
||
<FormField label="Datum do">
|
||
<AdminDatePicker value={dateTo} onChange={setDateTo} />
|
||
</FormField>
|
||
)}
|
||
<FormField label="Projekt">
|
||
<select
|
||
className="admin-form-select"
|
||
value={projectId ?? ""}
|
||
onChange={(e) =>
|
||
setProjectId(e.target.value ? Number(e.target.value) : null)
|
||
}
|
||
>
|
||
<option value="">— bez projektu —</option>
|
||
{projects.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</FormField>
|
||
<FormField label="Kategorie">
|
||
<select
|
||
className="admin-form-select"
|
||
value={category}
|
||
onChange={(e) => setCategory(e.target.value)}
|
||
>
|
||
{/* If the row's current category was retired/hidden, still show it
|
||
(marked) so the select reflects reality instead of silently
|
||
snapping to the first active option. The server only re-checks
|
||
the category if it changes, so keeping it saves fine. */}
|
||
{category && !activeCategories.some((c) => c.key === category) && (
|
||
<option value={category}>
|
||
{planCategoryLabel(
|
||
category,
|
||
Object.fromEntries(props.categories.map((x) => [x.key, x])),
|
||
)}{" "}
|
||
(skrytá)
|
||
</option>
|
||
)}
|
||
{activeCategories.map((c) => (
|
||
<option key={c.key} value={c.key}>
|
||
{c.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</FormField>
|
||
<FormField label="Text poznámky (volitelný)">
|
||
<textarea
|
||
className="admin-form-textarea"
|
||
value={note}
|
||
onChange={(e) => setNote(e.target.value)}
|
||
maxLength={500}
|
||
rows={3}
|
||
/>
|
||
</FormField>
|
||
</FormModal>
|
||
|
||
<ConfirmModal
|
||
isOpen={confirmDelete}
|
||
title="Smazat záznam plánu?"
|
||
message="Tato akce je nevratná (záznam bude soft-delete)."
|
||
confirmText="Smazat"
|
||
type="danger"
|
||
confirmVariant="danger"
|
||
loading={submitting}
|
||
onClose={() => setConfirmDelete(false)}
|
||
onConfirm={handleDelete}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function DayInRangeModal(
|
||
props: Props & { mode: Extract<PlanCellModalMode, { kind: "day-in-range" }> },
|
||
) {
|
||
const { mode, onClose, onCreateOverrideFromRange, onSwitchToEditRange } =
|
||
props;
|
||
const reducedMotion = useReducedMotion();
|
||
|
||
// Two clear paths: edit the whole range (default, safe) or carve out
|
||
// a one-day override (deviates from the plan). Close = no action.
|
||
const choices = [
|
||
{
|
||
key: "edit-range",
|
||
title: "Upravit celý rozsah",
|
||
desc: `Změní projekt, kategorii nebo poznámku pro všechny dny v rozsahu ${mode.range.date_from} – ${mode.range.date_to}.`,
|
||
tone: "primary" as const,
|
||
onClick: () => onSwitchToEditRange(mode.entryId, mode.userId, mode.date),
|
||
cta: "Upravit rozsah",
|
||
},
|
||
{
|
||
key: "override-day",
|
||
title: "Upravit pouze tento den",
|
||
desc: `Vytvoří přepsání pro ${mode.range.date_from === mode.date ? "tento" : mode.date}; zbytek rozsahu zůstane beze změny.`,
|
||
tone: "secondary" as const,
|
||
onClick: async () => {
|
||
await onCreateOverrideFromRange(mode.entryId, mode.userId, mode.date);
|
||
onClose();
|
||
},
|
||
cta: "Vytvořit přepsání",
|
||
},
|
||
];
|
||
|
||
return (
|
||
<FormModal
|
||
isOpen
|
||
title="Den je součástí rozsahu"
|
||
onClose={onClose}
|
||
hideFooter
|
||
size="md"
|
||
>
|
||
<p className="plan-modal-intro">
|
||
Den <strong>{mode.date}</strong> je součástí rozsahu{" "}
|
||
<strong>
|
||
{mode.range.date_from} – {mode.range.date_to}
|
||
</strong>
|
||
. Vyberte, co chcete udělat:
|
||
</p>
|
||
<ul className="plan-choices">
|
||
<AnimatePresence>
|
||
{choices.map((c, i) => (
|
||
<motion.li
|
||
key={c.key}
|
||
className={`plan-choice plan-choice-${c.tone}`}
|
||
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={reducedMotion ? undefined : { opacity: 0, y: -4 }}
|
||
transition={{
|
||
duration: reducedMotion ? 0 : 0.22,
|
||
delay: reducedMotion ? 0 : 0.04 + i * 0.05,
|
||
ease: [0.16, 1, 0.3, 1],
|
||
}}
|
||
>
|
||
<div className="plan-choice-body">
|
||
<div className="plan-choice-title">{c.title}</div>
|
||
<div className="plan-choice-desc">{c.desc}</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={
|
||
c.tone === "primary"
|
||
? "admin-btn admin-btn-primary"
|
||
: "admin-btn admin-btn-secondary"
|
||
}
|
||
onClick={c.onClick}
|
||
>
|
||
{c.cta}
|
||
</button>
|
||
</motion.li>
|
||
))}
|
||
</AnimatePresence>
|
||
</ul>
|
||
<div className="plan-modal-actions plan-modal-actions--solo">
|
||
<button
|
||
type="button"
|
||
className="admin-btn admin-btn-secondary"
|
||
onClick={onClose}
|
||
>
|
||
Zavřít — ponechat rozsah beze změny
|
||
</button>
|
||
</div>
|
||
</FormModal>
|
||
);
|
||
}
|
||
|
||
function ViewModal(props: Props) {
|
||
const { mode, onClose, projects, categories } = props;
|
||
if (mode.kind !== "view") return null;
|
||
const c = mode.cell;
|
||
// Prefer the server-embedded project label; fall back to the projects-list
|
||
// lookup only for cells that predate the embed (stale cache).
|
||
const fallback = c.project_id
|
||
? (projects.find((p) => p.id === c.project_id) ?? null)
|
||
: null;
|
||
const projectLabel =
|
||
cellProjectLabel(c) ??
|
||
(fallback
|
||
? fallback.project_number
|
||
? `${fallback.project_number} — ${fallback.name}`
|
||
: fallback.name
|
||
: null);
|
||
return (
|
||
<FormModal isOpen title="Detail plánu" onClose={onClose} hideFooter>
|
||
<p>
|
||
<strong>Datum:</strong> {formatDate(c.shift_date)}
|
||
</p>
|
||
<p>
|
||
<strong>Kategorie:</strong>{" "}
|
||
{planCategoryLabel(
|
||
c.category,
|
||
Object.fromEntries(categories.map((x) => [x.key, x])),
|
||
)}
|
||
</p>
|
||
<p>
|
||
<strong>Projekt:</strong> {projectLabel || "—"}
|
||
</p>
|
||
<p>
|
||
<strong>Text:</strong> {c.note || "—"}
|
||
</p>
|
||
{c.rangeFrom && c.rangeTo && (
|
||
<p>
|
||
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} –{" "}
|
||
{formatDate(c.rangeTo)}
|
||
</p>
|
||
)}
|
||
</FormModal>
|
||
);
|
||
}
|