Files
app/src/admin/components/PlanCellModal.tsx
BOHA b00d18d0b3 fix(plan): drop duplicate 'Zrušit' in the view-mode cell modal
The read-only plan-cell detail modal (view mode) already has a "Zavřít"
submit button, but the kit Modal also rendered its built-in "Zrušit"
cancel — two buttons that do the same thing. Pass hideCancel so only
"Zavřít" remains (same fix as the category-management modal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:54:42 +02:00

509 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import {
Modal,
ConfirmDialog,
Field,
TextField,
Select,
DateField,
CheckboxField,
Button,
Card,
} from "../ui";
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;
}
const TrashIcon = (
<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>
);
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);
}
};
// 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.
const showRetiredCategory =
category && !activeCategories.some((c) => c.key === category);
return (
<>
<Modal
isOpen
title={title}
onClose={onClose}
onSubmit={handleSubmit}
submitText={mode.kind === "create" ? "Vytvořit" : "Uložit"}
loading={submitting}
>
{mode.kind !== "create" && (
<Box sx={{ mb: 2 }}>
<Button
variant="outlined"
color="error"
startIcon={TrashIcon}
onClick={() => setConfirmDelete(true)}
disabled={submitting}
>
Smazat
</Button>
</Box>
)}
<Field label="Datum od">
<DateField
value={dateFrom}
onChange={setDateFrom}
disabled={isOverride}
/>
</Field>
{!isOverride && (
<Field label="Rozsah dnů">
<CheckboxField
label="Více dní"
checked={isRange}
onChange={setIsRange}
/>
</Field>
)}
{!isOverride && isRange && (
<Field label="Datum do">
<DateField value={dateTo} onChange={setDateTo} />
</Field>
)}
<Field label="Projekt">
<Select
value={projectId === null ? "" : String(projectId)}
onChange={(val) => setProjectId(val ? Number(val) : null)}
options={[
{ value: "", label: "— bez projektu —" },
...projects.map((p) => ({
value: String(p.id),
label: p.name,
})),
]}
/>
</Field>
<Field label="Kategorie">
<Select
value={category}
onChange={(val) => setCategory(val)}
options={[
...(showRetiredCategory
? [
{
value: category,
label: `${planCategoryLabel(
category,
Object.fromEntries(
props.categories.map((x) => [x.key, x]),
),
)} (skrytá)`,
},
]
: []),
...activeCategories.map((c) => ({
value: c.key,
label: c.label,
})),
]}
/>
</Field>
<Field label="Text poznámky (volitelný)">
<TextField
multiline
minRows={3}
value={note}
onChange={(e) => setNote(e.target.value)}
inputProps={{ maxLength: 500 }}
/>
</Field>
</Modal>
<ConfirmDialog
isOpen={confirmDelete}
title="Smazat záznam plánu?"
message="Tato akce je nevratná (záznam bude soft-delete)."
confirmText="Smazat"
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 (
<Modal
isOpen
title="Den je součástí rozsahu"
onClose={onClose}
onSubmit={onClose}
submitText="Zavřít — ponechat rozsah beze změny"
maxWidth="md"
>
<Typography variant="body2" sx={{ mb: 2 }}>
Den <strong>{mode.date}</strong> je součástí rozsahu{" "}
<strong>
{mode.range.date_from} {mode.range.date_to}
</strong>
. Vyberte, co chcete udělat:
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
<AnimatePresence>
{choices.map((c, i) => (
<motion.div
key={c.key}
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],
}}
>
<Card
variant="outlined"
sx={{
borderColor:
c.tone === "primary" ? "primary.main" : "divider",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
flexWrap: "wrap",
}}
>
<Box sx={{ flex: 1, minWidth: 200 }}>
<Typography sx={{ fontWeight: 600 }}>{c.title}</Typography>
<Typography variant="body2" color="text.secondary">
{c.desc}
</Typography>
</Box>
<Button
variant={c.tone === "primary" ? "contained" : "outlined"}
color={c.tone === "primary" ? "primary" : "inherit"}
onClick={c.onClick}
>
{c.cta}
</Button>
</Box>
</Card>
</motion.div>
))}
</AnimatePresence>
</Box>
</Modal>
);
}
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 (
<Modal
isOpen
title="Detail plánu"
onClose={onClose}
onSubmit={onClose}
submitText="Zavřít"
hideCancel
>
<Typography sx={{ mb: 1 }}>
<strong>Datum:</strong> {formatDate(c.shift_date)}
</Typography>
<Typography sx={{ mb: 1 }}>
<strong>Kategorie:</strong>{" "}
{planCategoryLabel(
c.category,
Object.fromEntries(categories.map((x) => [x.key, x])),
)}
</Typography>
<Typography sx={{ mb: 1 }}>
<strong>Projekt:</strong> {projectLabel || "—"}
</Typography>
<Typography sx={{ mb: 1 }}>
<strong>Text:</strong> {c.note || "—"}
</Typography>
{c.rangeFrom && c.rangeTo && (
<Typography sx={{ mb: 1 }}>
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} {" "}
{formatDate(c.rangeTo)}
</Typography>
)}
</Modal>
);
}