658 lines
20 KiB
TypeScript
658 lines
20 KiB
TypeScript
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>
|
||
);
|
||
}
|