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

View File

@@ -1,9 +1,18 @@
import { useState } from "react"; import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import FormModal from "./FormModal"; import Box from "@mui/material/Box";
import FormField from "./FormField"; import Typography from "@mui/material/Typography";
import AdminDatePicker from "./AdminDatePicker"; import {
import ConfirmModal from "./ConfirmModal"; Modal,
ConfirmDialog,
Field,
TextField,
Select,
DateField,
CheckboxField,
Button,
Card,
} from "../ui";
import useReducedMotion from "../hooks/useReducedMotion"; import useReducedMotion from "../hooks/useReducedMotion";
import { import {
ResolvedCell, ResolvedCell,
@@ -77,6 +86,26 @@ interface Props {
onSwitchToEditRange: (entryId: number, userId: number, date: string) => 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) { export default function PlanCellModal(props: Props) {
const { open, mode, onClose } = props; const { open, mode, onClose } = props;
if (!open || mode.kind === "closed") return null; 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 ( return (
<> <>
<FormModal <Modal
isOpen isOpen
title={title} title={title}
onClose={onClose} onClose={onClose}
onSubmit={handleSubmit} onSubmit={handleSubmit}
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"} submitText={mode.kind === "create" ? "Vytvořit" : "Uložit"}
loading={submitting} loading={submitting}
footerLeft={ >
mode.kind !== "create" ? ( {mode.kind !== "create" && (
<button <Box sx={{ mb: 2 }}>
type="button" <Button
className="admin-btn admin-btn-secondary" variant="outlined"
color="error"
startIcon={TrashIcon}
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
disabled={submitting} 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 Smazat
</button> </Button>
) : null </Box>
} )}
> <Field label="Datum od">
<FormField label="Datum od"> <DateField
<AdminDatePicker
value={dateFrom} value={dateFrom}
onChange={setDateFrom} onChange={setDateFrom}
disabled={isOverride} disabled={isOverride}
/> />
</FormField> </Field>
{!isOverride && ( {!isOverride && (
<FormField label="Rozsah dnů"> <Field label="Rozsah dnů">
<label className="admin-form-checkbox"> <CheckboxField
<input label="Více dní"
type="checkbox" checked={isRange}
checked={isRange} onChange={setIsRange}
onChange={(e) => setIsRange(e.target.checked)} />
/> </Field>
<span>Více dní</span>
</label>
</FormField>
)} )}
{!isOverride && isRange && ( {!isOverride && isRange && (
<FormField label="Datum do"> <Field label="Datum do">
<AdminDatePicker value={dateTo} onChange={setDateTo} /> <DateField value={dateTo} onChange={setDateTo} />
</FormField> </Field>
)} )}
<FormField label="Projekt"> <Field label="Projekt">
<select <Select
className="admin-form-select" value={projectId === null ? "" : String(projectId)}
value={projectId ?? ""} onChange={(val) => setProjectId(val ? Number(val) : null)}
onChange={(e) => options={[
setProjectId(e.target.value ? Number(e.target.value) : null) { value: "", label: "— bez projektu —" },
} ...projects.map((p) => ({
> value: String(p.id),
<option value=""> bez projektu </option> label: p.name,
{projects.map((p) => ( })),
<option key={p.id} value={p.id}> ]}
{p.name} />
</option> </Field>
))} <Field label="Kategorie">
</select> <Select
</FormField>
<FormField label="Kategorie">
<select
className="admin-form-select"
value={category} value={category}
onChange={(e) => setCategory(e.target.value)} onChange={(val) => setCategory(val)}
> options={[
{/* If the row's current category was retired/hidden, still show it ...(showRetiredCategory
(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. */} value: category,
{category && !activeCategories.some((c) => c.key === category) && ( label: `${planCategoryLabel(
<option value={category}> category,
{planCategoryLabel( Object.fromEntries(
category, props.categories.map((x) => [x.key, x]),
Object.fromEntries(props.categories.map((x) => [x.key, x])), ),
)}{" "} )} (skrytá)`,
(skrytá) },
</option> ]
)} : []),
{activeCategories.map((c) => ( ...activeCategories.map((c) => ({
<option key={c.key} value={c.key}> value: c.key,
{c.label} label: c.label,
</option> })),
))} ]}
</select> />
</FormField> </Field>
<FormField label="Text poznámky (volitelný)"> <Field label="Text poznámky (volitelný)">
<textarea <TextField
className="admin-form-textarea" multiline
minRows={3}
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
maxLength={500} inputProps={{ maxLength: 500 }}
rows={3}
/> />
</FormField> </Field>
</FormModal> </Modal>
<ConfirmModal <ConfirmDialog
isOpen={confirmDelete} isOpen={confirmDelete}
title="Smazat záznam plánu?" title="Smazat záznam plánu?"
message="Tato akce je nevratná (záznam bude soft-delete)." message="Tato akce je nevratná (záznam bude soft-delete)."
confirmText="Smazat" confirmText="Smazat"
type="danger"
confirmVariant="danger" confirmVariant="danger"
loading={submitting} loading={submitting}
onClose={() => setConfirmDelete(false)} onClose={() => setConfirmDelete(false)}
@@ -376,26 +388,26 @@ function DayInRangeModal(
]; ];
return ( return (
<FormModal <Modal
isOpen isOpen
title="Den je součástí rozsahu" title="Den je součástí rozsahu"
onClose={onClose} onClose={onClose}
hideFooter onSubmit={onClose}
size="md" 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{" "} Den <strong>{mode.date}</strong> je součástí rozsahu{" "}
<strong> <strong>
{mode.range.date_from} {mode.range.date_to} {mode.range.date_from} {mode.range.date_to}
</strong> </strong>
. Vyberte, co chcete udělat: . Vyberte, co chcete udělat:
</p> </Typography>
<ul className="plan-choices"> <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
<AnimatePresence> <AnimatePresence>
{choices.map((c, i) => ( {choices.map((c, i) => (
<motion.li <motion.div
key={c.key} key={c.key}
className={`plan-choice plan-choice-${c.tone}`}
initial={reducedMotion ? false : { opacity: 0, y: 8 }} initial={reducedMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={reducedMotion ? undefined : { opacity: 0, y: -4 }} exit={reducedMotion ? undefined : { opacity: 0, y: -4 }}
@@ -405,35 +417,42 @@ function DayInRangeModal(
ease: [0.16, 1, 0.3, 1], ease: [0.16, 1, 0.3, 1],
}} }}
> >
<div className="plan-choice-body"> <Card
<div className="plan-choice-title">{c.title}</div> variant="outlined"
<div className="plan-choice-desc">{c.desc}</div> sx={{
</div> borderColor:
<button c.tone === "primary" ? "primary.main" : "divider",
type="button" }}
className={
c.tone === "primary"
? "admin-btn admin-btn-primary"
: "admin-btn admin-btn-secondary"
}
onClick={c.onClick}
> >
{c.cta} <Box
</button> sx={{
</motion.li> 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> </AnimatePresence>
</ul> </Box>
<div className="plan-modal-actions plan-modal-actions--solo"> </Modal>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={onClose}
>
Zavřít ponechat rozsah beze změny
</button>
</div>
</FormModal>
); );
} }
@@ -454,29 +473,35 @@ function ViewModal(props: Props) {
: fallback.name : fallback.name
: null); : null);
return ( return (
<FormModal isOpen title="Detail plánu" onClose={onClose} hideFooter> <Modal
<p> isOpen
title="Detail plánu"
onClose={onClose}
onSubmit={onClose}
submitText="Zavřít"
>
<Typography sx={{ mb: 1 }}>
<strong>Datum:</strong> {formatDate(c.shift_date)} <strong>Datum:</strong> {formatDate(c.shift_date)}
</p> </Typography>
<p> <Typography sx={{ mb: 1 }}>
<strong>Kategorie:</strong>{" "} <strong>Kategorie:</strong>{" "}
{planCategoryLabel( {planCategoryLabel(
c.category, c.category,
Object.fromEntries(categories.map((x) => [x.key, x])), Object.fromEntries(categories.map((x) => [x.key, x])),
)} )}
</p> </Typography>
<p> <Typography sx={{ mb: 1 }}>
<strong>Projekt:</strong> {projectLabel || "—"} <strong>Projekt:</strong> {projectLabel || "—"}
</p> </Typography>
<p> <Typography sx={{ mb: 1 }}>
<strong>Text:</strong> {c.note || "—"} <strong>Text:</strong> {c.note || "—"}
</p> </Typography>
{c.rangeFrom && c.rangeTo && ( {c.rangeFrom && c.rangeTo && (
<p> <Typography sx={{ mb: 1 }}>
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} {" "} <strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} {" "}
{formatDate(c.rangeTo)} {formatDate(c.rangeTo)}
</p> </Typography>
)} )}
</FormModal> </Modal>
); );
} }

View File

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