feat(plan): add Sprava kategorii management modal and button

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-06 00:55:20 +02:00
parent dab5ad8aa5
commit b94f4879e3
3 changed files with 196 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
import { useState } from "react";
import FormModal from "./FormModal";
import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext";
import { PlanCategory } from "../lib/queries/plan";
interface Props {
isOpen: boolean;
onClose: () => void;
categories: PlanCategory[];
}
const DEFAULT_NEW_COLOR = "#2563eb";
export default function PlanCategoriesModal({
isOpen,
onClose,
categories,
}: Props) {
const alert = useAlert();
const [newLabel, setNewLabel] = useState("");
const [newColor, setNewColor] = useState(DEFAULT_NEW_COLOR);
const createCat = useApiMutation<
{ label: string; color: string },
PlanCategory
>({
url: "/api/admin/plan/categories",
method: "POST",
invalidate: ["plan", "dashboard", "audit-log"],
});
const updateCat = useApiMutation<
{ id: number; label?: string; color?: string; is_active?: boolean },
PlanCategory
>({
url: (v) => `/api/admin/plan/categories/${v.id}`,
method: "PATCH",
invalidate: ["plan", "dashboard", "audit-log"],
});
const handleAdd = () => {
const label = newLabel.trim();
if (!label) {
alert.error("Zadejte název kategorie");
return;
}
createCat.mutate(
{ label, color: newColor },
{
onSuccess: () => {
setNewLabel("");
setNewColor(DEFAULT_NEW_COLOR);
},
onError: (e) => alert.error(e.message),
},
);
};
const patch = (
id: number,
body: { label?: string; color?: string; is_active?: boolean },
) => {
updateCat.mutate(
{ id, ...body },
{ onError: (e) => alert.error(e.message) },
);
};
const busy = createCat.isPending || updateCat.isPending;
return (
<FormModal
isOpen={isOpen}
onClose={onClose}
title="Správa kategorií"
hideFooter
size="md"
>
<div className="plan-cat-manager">
{categories.map((c) => (
<div
key={c.id}
className={`plan-cat-row${c.is_active ? "" : " plan-cat-row--inactive"}`}
>
<input
type="color"
className="plan-cat-color"
value={c.color}
disabled={busy}
onChange={(e) => patch(c.id, { color: e.target.value })}
aria-label={`Barva ${c.label}`}
/>
<input
type="text"
className="admin-form-input"
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}`}
/>
<button
type="button"
className="admin-btn admin-btn-secondary admin-btn-sm"
disabled={busy}
onClick={() => patch(c.id, { is_active: !c.is_active })}
>
{c.is_active ? "Skrýt" : "Obnovit"}
</button>
</div>
))}
<div className="plan-cat-row plan-cat-row--new">
<input
type="color"
className="plan-cat-color"
value={newColor}
disabled={busy}
onChange={(e) => setNewColor(e.target.value)}
aria-label="Barva nové kategorie"
/>
<input
type="text"
className="admin-form-input"
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"
disabled={busy}
onClick={handleAdd}
>
Přidat
</button>
</div>
</div>
</FormModal>
);
}

View File

@@ -11,6 +11,7 @@ import {
import type { PlanCellModalMode } from "../components/PlanCellModal";
import PlanGrid from "../components/PlanGrid";
import PlanCellModal from "../components/PlanCellModal";
import PlanCategoriesModal from "../components/PlanCategoriesModal";
import Forbidden from "../components/Forbidden";
import { projectListOptions, type Project } from "../lib/queries/projects";
import { planCategoriesOptions, type PlanCategory } from "../lib/queries/plan";
@@ -129,6 +130,8 @@ export default function PlanWork() {
const { data: categoriesData } = useQuery(planCategoriesOptions());
const categories: PlanCategory[] = categoriesData ?? [];
const [catModalOpen, setCatModalOpen] = useState(false);
// `lastMutated` is the (userId, date) of the most recent successful
// mutation. We pass it to PlanGrid as `pulseKey`; the matching cell
// gets a one-shot CSS pulse animation. We clear it after 700ms (the
@@ -638,6 +641,15 @@ export default function PlanWork() {
Měsíc
</button>
</div>
{canEdit && (
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={() => setCatModalOpen(true)}
>
Správa kategorií
</button>
)}
</motion.div>
{gridLoading && (
@@ -695,6 +707,11 @@ export default function PlanWork() {
onCreateOverrideFromRange={createOverrideFromRange}
onSwitchToEditRange={switchToEditRange}
/>
<PlanCategoriesModal
isOpen={catModalOpen}
onClose={() => setCatModalOpen(false)}
categories={categories}
/>
</div>
);
}

View File

@@ -977,3 +977,37 @@
animation: none !important;
}
}
/* Category manager modal rows */
.plan-cat-manager {
display: flex;
flex-direction: column;
gap: 8px;
}
.plan-cat-row {
display: flex;
align-items: center;
gap: 8px;
}
.plan-cat-row--inactive {
opacity: 0.55;
}
.plan-cat-row .admin-form-input {
flex: 1;
min-width: 0;
}
.plan-cat-color {
width: 34px;
height: 34px;
padding: 0;
border: 1px solid var(--plan-paper-rule);
border-radius: 6px;
background: none;
cursor: pointer;
flex: 0 0 auto;
}
.plan-cat-row--new {
margin-top: 6px;
padding-top: 10px;
border-top: 1px dashed var(--plan-paper-rule);
}