feat(mui): migrate PlanWork modals + toolbar onto MUI kit (planner grid kept bespoke)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-07 01:29:48 +02:00
parent cc4b14f862
commit ebda3ecf23
3 changed files with 324 additions and 277 deletions

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import FormModal from "./FormModal";
import Box from "@mui/material/Box";
import { Modal, TextField, Button } from "../ui";
import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext";
import { PlanCategory } from "../lib/queries/plan";
@@ -91,20 +92,26 @@ export default function PlanCategoriesModal({
const busy =
createCat.isPending || updateCat.isPending || deleteCat.isPending;
// Shared layout for a category row: color swatch | text input | actions.
const rowSx = {
display: "flex",
alignItems: "center",
gap: 1,
py: 1,
} as const;
return (
<FormModal
<Modal
isOpen={isOpen}
onClose={onClose}
onSubmit={onClose}
title="Správa kategorií"
hideFooter
size="md"
submitText="Zavřít"
maxWidth="md"
>
<div className="plan-cat-manager">
<Box>
{categories.map((c) => (
<div
key={c.id}
className={`plan-cat-row${c.is_active ? "" : " plan-cat-row--inactive"}`}
>
<Box key={c.id} sx={{ ...rowSx, opacity: c.is_active ? 1 : 0.55 }}>
<input
type="color"
className="plan-cat-color"
@@ -122,62 +129,66 @@ export default function PlanCategoriesModal({
}}
aria-label={`Barva ${c.label}`}
/>
<input
type="text"
className="admin-form-input"
<TextField
defaultValue={c.label}
disabled={busy}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== c.label) patch(c.id, { label: v });
}}
aria-label={`Název ${c.label}`}
inputProps={{ "aria-label": `Název ${c.label}` }}
/>
{confirmDeleteId === c.id ? (
<>
<button
type="button"
className="admin-btn admin-btn-danger admin-btn-sm"
<Button
variant="contained"
color="error"
size="small"
disabled={busy}
onClick={() => handleDelete(c.id)}
>
Opravdu smazat
</button>
<button
type="button"
className="admin-btn admin-btn-secondary admin-btn-sm"
</Button>
<Button
variant="outlined"
color="inherit"
size="small"
disabled={busy}
onClick={() => setConfirmDeleteId(null)}
>
Zrušit
</button>
</Button>
</>
) : (
<>
<button
type="button"
className="admin-btn admin-btn-secondary admin-btn-sm"
<Button
variant="outlined"
color="inherit"
size="small"
disabled={busy}
onClick={() => patch(c.id, { is_active: !c.is_active })}
>
{c.is_active ? "Skrýt" : "Obnovit"}
</button>
<button
type="button"
className="admin-btn admin-btn-danger admin-btn-sm plan-cat-delete"
</Button>
<Button
variant="contained"
color="error"
size="small"
disabled={busy}
onClick={() => setConfirmDeleteId(c.id)}
aria-label={`Smazat ${c.label}`}
title="Smazat kategorii"
>
Smazat
</button>
</Button>
</>
)}
</div>
</Box>
))}
<div className="plan-cat-row plan-cat-row--new">
<Box
sx={{ ...rowSx, mt: 1, pt: 2, borderTop: 1, borderColor: "divider" }}
>
<input
type="color"
className="plan-cat-color"
@@ -186,25 +197,24 @@ export default function PlanCategoriesModal({
onChange={(e) => setNewColor(e.target.value)}
aria-label="Barva nové kategorie"
/>
<input
type="text"
className="admin-form-input"
<TextField
placeholder="Nová kategorie…"
value={newLabel}
disabled={busy}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<button
type="button"
className="admin-btn admin-btn-primary admin-btn-sm"
<Button
variant="contained"
color="primary"
size="small"
disabled={busy}
onClick={handleAdd}
>
Přidat
</button>
</div>
</div>
</FormModal>
</Button>
</Box>
</Box>
</Modal>
);
}

View File

@@ -1,9 +1,18 @@
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 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,
@@ -77,6 +86,26 @@ interface Props {
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;
@@ -213,128 +242,111 @@ function EditForm(props: Props) {
}
};
// 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 (
<>
<FormModal
<Modal
isOpen
title={title}
onClose={onClose}
onSubmit={handleSubmit}
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
submitText={mode.kind === "create" ? "Vytvořit" : "Uložit"}
loading={submitting}
footerLeft={
mode.kind !== "create" ? (
<button
type="button"
className="admin-btn admin-btn-secondary"
>
{mode.kind !== "create" && (
<Box sx={{ mb: 2 }}>
<Button
variant="outlined"
color="error"
startIcon={TrashIcon}
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
</Button>
</Box>
)}
<Field label="Datum od">
<DateField
value={dateFrom}
onChange={setDateFrom}
disabled={isOverride}
/>
</FormField>
</Field>
{!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>
<Field label="Rozsah dnů">
<CheckboxField
label="Více dní"
checked={isRange}
onChange={setIsRange}
/>
</Field>
)}
{!isOverride && isRange && (
<FormField label="Datum do">
<AdminDatePicker value={dateTo} onChange={setDateTo} />
</FormField>
<Field label="Datum do">
<DateField value={dateTo} onChange={setDateTo} />
</Field>
)}
<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"
<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={(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"
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)}
maxLength={500}
rows={3}
inputProps={{ maxLength: 500 }}
/>
</FormField>
</FormModal>
</Field>
</Modal>
<ConfirmModal
<ConfirmDialog
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)}
@@ -376,26 +388,26 @@ function DayInRangeModal(
];
return (
<FormModal
<Modal
isOpen
title="Den je součástí rozsahu"
onClose={onClose}
hideFooter
size="md"
onSubmit={onClose}
submitText="Zavřít — ponechat rozsah beze změny"
maxWidth="md"
>
<p className="plan-modal-intro">
<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:
</p>
<ul className="plan-choices">
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
<AnimatePresence>
{choices.map((c, i) => (
<motion.li
<motion.div
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 }}
@@ -405,35 +417,42 @@ function DayInRangeModal(
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}
<Card
variant="outlined"
sx={{
borderColor:
c.tone === "primary" ? "primary.main" : "divider",
}}
>
{c.cta}
</button>
</motion.li>
<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>
</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>
</Box>
</Modal>
);
}
@@ -454,29 +473,35 @@ function ViewModal(props: Props) {
: fallback.name
: null);
return (
<FormModal isOpen title="Detail plánu" onClose={onClose} hideFooter>
<p>
<Modal
isOpen
title="Detail plánu"
onClose={onClose}
onSubmit={onClose}
submitText="Zavřít"
>
<Typography sx={{ mb: 1 }}>
<strong>Datum:</strong> {formatDate(c.shift_date)}
</p>
<p>
</Typography>
<Typography sx={{ mb: 1 }}>
<strong>Kategorie:</strong>{" "}
{planCategoryLabel(
c.category,
Object.fromEntries(categories.map((x) => [x.key, x])),
)}
</p>
<p>
</Typography>
<Typography sx={{ mb: 1 }}>
<strong>Projekt:</strong> {projectLabel || "—"}
</p>
<p>
</Typography>
<Typography sx={{ mb: 1 }}>
<strong>Text:</strong> {c.note || "—"}
</p>
</Typography>
{c.rangeFrom && c.rangeTo && (
<p>
<Typography sx={{ mb: 1 }}>
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} {" "}
{formatDate(c.rangeTo)}
</p>
</Typography>
)}
</FormModal>
</Modal>
);
}

View File

@@ -1,6 +1,8 @@
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { motion, AnimatePresence } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import {
@@ -13,6 +15,7 @@ import PlanGrid from "../components/PlanGrid";
import PlanCellModal from "../components/PlanCellModal";
import PlanCategoriesModal from "../components/PlanCategoriesModal";
import Forbidden from "../components/Forbidden";
import { Button, Alert } from "../ui";
import { projectListOptions, type Project } from "../lib/queries/projects";
import { planCategoriesOptions, type PlanCategory } from "../lib/queries/plan";
// plan.css is imported once globally in AdminApp.tsx — no per-page re-import.
@@ -542,120 +545,129 @@ export default function PlanWork() {
}
return (
<div className="plan-work-page">
<motion.h1
className="admin-page-title"
<Box className="plan-work-page">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
Plán prací
</motion.h1>
<Typography variant="h4" sx={{ fontWeight: 700, mb: 2 }}>
Plán prací
</Typography>
</motion.div>
{!canEdit && (
<motion.div
className="plan-banner"
role="status"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<span className="plan-banner-icon" aria-hidden>
i
</span>
<span>
<strong>Režim náhledu</strong> můžete pouze prohlížet plán. Úpravy
jsou dostupné oprávněným uživatelům.
</span>
<Box sx={{ mb: 2 }}>
<Alert severity="info">
<strong>Režim náhledu</strong> můžete pouze prohlížet plán.
Úpravy jsou dostupné oprávněným uživatelům.
</Alert>
</Box>
</motion.div>
)}
<motion.div
className="plan-toolbar"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
{/* Nav buttons grouped so they stay on one row when the toolbar
stacks on mobile (see .plan-toolbar-nav in plan.css). On desktop
the wrapper is an inline-flex with the same 12px gap, so the
layout is visually identical. */}
<div className="plan-toolbar-nav">
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={goToToday}
>
Dnes
</button>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={goPrev}
aria-label="Předchozí"
>
</button>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={goNext}
aria-label="Další"
>
</button>
</div>
<span className="plan-range-label-slot">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
className="plan-range-label"
key={`${isoDate(range.from)}-${view}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
</motion.span>
</AnimatePresence>
</span>
<div
className="plan-view-toggle"
role="group"
aria-label="Měřítko zobrazení"
<Box
sx={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: 1.5,
mb: 2,
}}
>
<button
type="button"
className={
view === "week"
? "admin-btn admin-btn-primary"
: "admin-btn admin-btn-secondary"
}
onClick={() => setViewWithAnim("week")}
{/* Nav buttons grouped so they stay on one row when the toolbar
wraps on mobile. */}
<Box sx={{ display: "inline-flex", gap: 1 }}>
<Button variant="outlined" color="inherit" onClick={goToToday}>
Dnes
</Button>
<Button
variant="outlined"
color="inherit"
onClick={goPrev}
aria-label="Předchozí"
>
</Button>
<Button
variant="outlined"
color="inherit"
onClick={goNext}
aria-label="Další"
>
</Button>
</Box>
<Box
sx={{
flex: 1,
minWidth: 220,
display: "flex",
justifyContent: "center",
}}
>
Týden
</button>
<button
type="button"
className={
view === "month"
? "admin-btn admin-btn-primary"
: "admin-btn admin-btn-secondary"
}
onClick={() => setViewWithAnim("month")}
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={`${isoDate(range.from)}-${view}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<Typography
variant="h6"
sx={{ fontWeight: 600, whiteSpace: "nowrap" }}
>
{formatRangeLabel(
isoDate(range.from),
isoDate(range.to),
view,
)}
</Typography>
</motion.div>
</AnimatePresence>
</Box>
<Box
role="group"
aria-label="Měřítko zobrazení"
sx={{ display: "inline-flex", gap: 1 }}
>
Měsíc
</button>
</div>
{canEdit && (
<button
type="button"
className="admin-btn admin-btn-secondary plan-toolbar-cat"
onClick={() => setCatModalOpen(true)}
>
Správa kategorií
</button>
)}
<Button
variant={view === "week" ? "contained" : "outlined"}
color={view === "week" ? "primary" : "inherit"}
onClick={() => setViewWithAnim("week")}
>
Týden
</Button>
<Button
variant={view === "month" ? "contained" : "outlined"}
color={view === "month" ? "primary" : "inherit"}
onClick={() => setViewWithAnim("month")}
>
Měsíc
</Button>
</Box>
{canEdit && (
<Button
variant="outlined"
color="inherit"
onClick={() => setCatModalOpen(true)}
>
Správa kategorií
</Button>
)}
</Box>
</motion.div>
{gridLoading && (
@@ -718,6 +730,6 @@ export default function PlanWork() {
onClose={() => setCatModalOpen(false)}
categories={categories}
/>
</div>
</Box>
);
}