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; onUpdateEntry: (id: number, body: any) => Promise; onDeleteEntry: (id: number) => Promise; onSaveOverride: (body: any) => Promise; onUpdateOverride: (id: number, body: any) => Promise; onDeleteOverride: (id: number) => Promise; onCreateOverrideFromRange: ( entryId: number, userId: number, date: string, ) => Promise; 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 = ( ); export default function PlanCellModal(props: Props) { const { open, mode, onClose } = props; if (!open || mode.kind === "closed") return null; if (mode.kind === "view") return ; if (mode.kind === "day-in-range") return ; if (mode.kind === "day") return ; return ; } 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(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 ( <> {!isCreate && ( )} {!isOverride && ( )} {!isOverride && isRange && ( )} 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, })), ]} /> setNote(e.target.value)} inputProps={{ maxLength: 500 }} /> setConfirmDelete(false)} onConfirm={handleDelete} /> ); } function DayInRangeModal( props: Props & { mode: Extract }, ) { 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 ( Den {mode.date} je součástí rozsahu{" "} {mode.range.date_from} – {mode.range.date_to} . Vyberte, co chcete udělat: {choices.map((c, i) => ( {c.title} {c.desc} ))} ); } 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 ( Datum: {formatDate(c.shift_date)} Kategorie:{" "} {planCategoryLabel( c.category, Object.fromEntries(categories.map((x) => [x.key, x])), )} Projekt: {projectLabel || "—"} Text: {c.note || "—"} {c.rangeFrom && c.rangeTo && ( Patří do rozsahu: {formatDate(c.rangeFrom)} –{" "} {formatDate(c.rangeTo)} )} ); } const EditIcon = ( ); function DayPanel( props: Props & { mode: Extract }, ) { 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 ( {mode.cells.map((c, i) => { const color = catMap[c.category]?.color || "var(--mui-palette-divider)"; const projectLabel = cellProjectLabel(c); return ( {planCategoryLabel(c.category, catMap)} {projectLabel ? ` · ${projectLabel}` : ""} {c.note && ( {c.note} )} ); })} {atCap ? "Maximum 3 záznamy na den" : `${free} ${free === 1 ? "volné místo" : "volná místa"}`} ); }