Files
app/src/admin/components/PlanCellModal.tsx
2026-06-08 11:29:52 +02:00

658 lines
20 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: "create-override"; 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 }
| { kind: "day"; userId: number; date: string; cells: 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;
/** Open the create form for a brand-new record on (userId, date). The
* `source` picks the layer the new record lands in: "entry" in normal mode,
* "override" in exception mode (a day whose shown records are overrides). */
onAddRecord: (
userId: number,
date: string,
source: "entry" | "override",
) => void;
/** Open the right editor for one of the day's existing records. */
onEditRecord: (cell: ResolvedCell, 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} />;
if (mode.kind === "day") return <DayPanel {...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" | "create-override" | "edit-range" |
// "edit-override" but the parameter type is the full union, so we narrow
// explicitly here.
if (
mode.kind !== "create" &&
mode.kind !== "create-override" &&
mode.kind !== "edit-range" &&
mode.kind !== "edit-override"
) {
return null;
}
// An override is a single-day record — both editing one ("edit-override")
// and creating one ("create-override") lock the date and hide the range UI.
const isOverride =
mode.kind === "edit-override" || mode.kind === "create-override";
const activeCategories = props.categories.filter((c) => c.is_active);
const initial = (() => {
if (mode.kind === "create" || mode.kind === "create-override") {
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 === "create-override"
? "Nové přepsání dne"
: 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 === "create-override") {
// An override is a single day — lock it to mode.date regardless of
// any local dateFrom/isRange state (the date field is disabled for
// overrides, but be explicit).
await onSaveOverride({
user_id: mode.userId,
shift_date: mode.date,
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);
// Both create flows ("create" entry, "create-override") show "Vytvořit" and
// have no Smazat button (there is no existing row to delete yet).
const isCreate = mode.kind === "create" || mode.kind === "create-override";
return (
<>
<Modal
isOpen
title={title}
onClose={onClose}
onSubmit={handleSubmit}
submitText={isCreate ? "Vytvořit" : "Uložit"}
loading={submitting}
>
{!isCreate && (
<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>
);
}
const EditIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
function DayPanel(
props: Props & { mode: Extract<PlanCellModalMode, { kind: "day" }> },
) {
const { mode, onClose, onAddRecord, onEditRecord, categories } = props;
const catMap = Object.fromEntries(categories.map((x) => [x.key, x]));
const atCap = mode.cells.length >= 3;
const free = 3 - mode.cells.length;
// A day is in "exception mode" when its shown records are overrides. Cells
// are single-layer (all overrides OR all entries), so cells[0] is enough.
// "+ Přidat záznam" must add to the layer that's showing — an override here,
// otherwise resolveCell would hide a freshly-created entry behind the
// existing override after refetch.
const isOverrideMode = mode.cells[0]?.source === "override";
return (
<Modal
isOpen
title={`Plán — ${formatDate(mode.date)}`}
onClose={onClose}
onSubmit={onClose}
submitText="Zavřít"
hideCancel
>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25, mb: 2 }}>
{mode.cells.map((c, i) => {
const color =
catMap[c.category]?.color || "var(--mui-palette-divider)";
const projectLabel = cellProjectLabel(c);
return (
<Card
key={
c.entryId != null
? c.entryId
: c.overrideId != null
? `o${c.overrideId}`
: i
}
variant="outlined"
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
width: 4,
alignSelf: "stretch",
minHeight: 28,
borderRadius: 2,
bgcolor: color,
}}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontWeight: 600, fontSize: "0.85rem" }}>
{planCategoryLabel(c.category, catMap)}
{projectLabel ? ` · ${projectLabel}` : ""}
</Typography>
{c.note && (
<Typography variant="body2" color="text.secondary" noWrap>
{c.note}
</Typography>
)}
</Box>
<Button
variant="outlined"
color="inherit"
startIcon={EditIcon}
onClick={() => onEditRecord(c, mode.userId, mode.date)}
>
Upravit
</Button>
</Box>
</Card>
);
})}
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Button
variant="contained"
color="primary"
disabled={atCap}
onClick={() =>
onAddRecord(
mode.userId,
mode.date,
isOverrideMode ? "override" : "entry",
)
}
>
+ Přidat záznam
</Button>
<Typography variant="caption" color="text.secondary">
{atCap
? "Maximum 3 záznamy na den"
: `${free} ${free === 1 ? "volné místo" : "volná místa"}`}
</Typography>
</Box>
</Modal>
);
}