From 13d1fc2be322c54ddd5a562e40b655bfe7b98ff2 Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 8 Jun 2026 12:06:19 +0200 Subject: [PATCH] feat(plan): bulk assignment modal on /plan-work Co-Authored-By: Claude Sonnet 4.6 --- src/admin/components/BulkPlanModal.tsx | 191 +++++++++++++++++++++++++ src/admin/hooks/usePlanWork.ts | 15 ++ src/admin/pages/PlanWork.tsx | 102 +++++++++++++ 3 files changed, 308 insertions(+) create mode 100644 src/admin/components/BulkPlanModal.tsx diff --git a/src/admin/components/BulkPlanModal.tsx b/src/admin/components/BulkPlanModal.tsx new file mode 100644 index 0000000..44507e8 --- /dev/null +++ b/src/admin/components/BulkPlanModal.tsx @@ -0,0 +1,191 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { + Modal, + Field, + DateField, + Select, + TextField, + CheckboxField, +} from "../ui"; +import type { PlanUser, PlanCategory } from "../lib/queries/plan"; +import type { Project } from "../lib/queries/projects"; + +export interface BulkPlanForm { + date_from: string; + date_to: string; + is_range: boolean; + user_ids: string[]; + include_weekends: boolean; + project_id: string; + category: string; + note: string; +} + +interface Props { + isOpen: boolean; + onClose: () => void; + form: BulkPlanForm; + setForm: (form: BulkPlanForm) => void; + users: PlanUser[]; + projects: Project[]; + categories: PlanCategory[]; + onSubmit: () => void; + submitting: boolean; + toggleUser: (userId: number) => void; + toggleAllUsers: () => void; +} + +export default function BulkPlanModal({ + isOpen, + onClose, + form, + setForm, + users, + projects, + categories, + onSubmit, + submitting, + toggleUser, + toggleAllUsers, +}: Props) { + const activeCategories = categories.filter((c) => c.is_active); + return ( + + + Vytvoří záznamy pro vybrané dny u zvolených zaměstnanců. Dny, které už + mají 3 záznamy, se přeskočí. + + + + setForm({ ...form, date_from: val })} + /> + + + setForm({ ...form, is_range: checked })} + /> + + {form.is_range && ( + + setForm({ ...form, date_to: val })} + /> + + )} + + + setForm({ ...form, include_weekends: checked }) + } + /> + + + + + + Zaměstnanci + + + {users.length > 0 && form.user_ids.length === users.length + ? "Odznačit vše" + : "Vybrat vše"} + + + + {users.map((u) => ( + toggleUser(u.id)} + /> + ))} + + + Vybráno: {form.user_ids.length} z {users.length} + + + + + setForm({ ...form, category: val })} + options={activeCategories.map((c) => ({ + value: c.key, + label: c.label, + }))} + /> + + + setForm({ ...form, note: e.target.value })} + inputProps={{ maxLength: 500 }} + /> + + + ); +} diff --git a/src/admin/hooks/usePlanWork.ts b/src/admin/hooks/usePlanWork.ts index 5bb2a35..e1731dd 100644 --- a/src/admin/hooks/usePlanWork.ts +++ b/src/admin/hooks/usePlanWork.ts @@ -488,6 +488,20 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { }, }); + const bulkCreate = useMutation({ + mutationFn: (body: any) => + apiCall("/api/admin/plan/entries/bulk", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }), + onSuccess: () => { + // No optimistic patch — bulk can touch many cells; just invalidate and + // let the grid refetch the authoritative state. + invalidate(); + }, + }); + // Roll back an optimistic patch on mutation error. Called from // PlanWork's mutation wrappers via `rollbackMutation(mutation, key)`. // `mutation` is a TanStack mutation result with a `_rolled` snapshot stashed @@ -534,6 +548,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { createOverride, updateOverride, deleteOverride, + bulkCreate, invalidate, // Exposed so PlanWork can roll back an optimistic patch when a // mutation throws. Pass the failed mutation and the userId it diff --git a/src/admin/pages/PlanWork.tsx b/src/admin/pages/PlanWork.tsx index 590de21..29f33ad 100644 --- a/src/admin/pages/PlanWork.tsx +++ b/src/admin/pages/PlanWork.tsx @@ -15,6 +15,7 @@ import type { PlanCellModalMode } from "../components/PlanCellModal"; import PlanGrid from "../components/PlanGrid"; import PlanCellModal from "../components/PlanCellModal"; import PlanCategoriesModal from "../components/PlanCategoriesModal"; +import BulkPlanModal, { type BulkPlanForm } from "../components/BulkPlanModal"; import Forbidden from "../components/Forbidden"; import { Button, Alert, LoadingState, PageEnter } from "../ui"; import { projectListOptions, type Project } from "../lib/queries/projects"; @@ -128,6 +129,7 @@ export default function PlanWork() { createOverride, updateOverride, deleteOverride, + bulkCreate, rollbackMutation, } = usePlanWork({ canEdit }); @@ -140,6 +142,84 @@ export default function PlanWork() { const [catModalOpen, setCatModalOpen] = useState(false); + const [bulkOpen, setBulkOpen] = useState(false); + const [bulkSubmitting, setBulkSubmitting] = useState(false); + const [bulkForm, setBulkForm] = useState(() => { + const today = todayIsoLocal(); + return { + date_from: today, + date_to: today, + is_range: false, + user_ids: [], + include_weekends: false, + project_id: "", + category: "work", + note: "", + }; + }); + + const planUsers = useMemo(() => grid?.users ?? [], [grid]); + + const toggleBulkUser = useCallback((userId: number) => { + setBulkForm((prev) => ({ + ...prev, + user_ids: prev.user_ids.includes(String(userId)) + ? prev.user_ids.filter((u) => u !== String(userId)) + : [...prev.user_ids, String(userId)], + })); + }, []); + + const toggleAllBulkUsers = useCallback(() => { + const allIds = (grid?.users ?? []).map((u) => String(u.id)); + if (allIds.length === 0) return; // grid not yet loaded — no-op + setBulkForm((prev) => ({ + ...prev, + user_ids: prev.user_ids.length === allIds.length ? [] : allIds, + })); + }, [grid]); + + const submitBulk = useCallback(async () => { + if (bulkForm.user_ids.length === 0) return; + setBulkSubmitting(true); + try { + const data: any = await bulkCreate.mutateAsync({ + user_ids: bulkForm.user_ids.map(Number), + date_from: bulkForm.date_from, + date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from, + include_weekends: bulkForm.include_weekends, + project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null, + category: bulkForm.category, + note: bulkForm.note, + }); + // apiCall returns the full envelope { success, data, message }; + // the summary counts are under .data + const summary = data?.data ?? data; + const skipped = + summary?.skipped_days > 0 + ? ` ${summary.skipped_days} dní přeskočeno (limit 3 na den).` + : ""; + alert.success( + `Vytvořeno ${summary?.created_days ?? 0} záznamů pro ${summary?.users ?? 0} zaměstnanců.${skipped}`, + ); + setBulkOpen(false); + // Reset the volatile parts so reopening starts clean (project / + // category / weekends stay sticky — users often repeat those). + const today = todayIsoLocal(); + setBulkForm((prev) => ({ + ...prev, + date_from: today, + date_to: today, + is_range: false, + user_ids: [], + note: "", + })); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba při ukládání"); + } finally { + setBulkSubmitting(false); + } + }, [bulkForm, bulkCreate, alert]); + // `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 @@ -673,6 +753,15 @@ export default function PlanWork() { Správa kategorií )} + {canEdit && ( + + )} {gridLoading && } @@ -727,6 +816,19 @@ export default function PlanWork() { onClose={() => setCatModalOpen(false)} categories={categories} /> + setBulkOpen(false)} + form={bulkForm} + setForm={setBulkForm} + users={planUsers} + projects={projects} + categories={categories} + onSubmit={submitBulk} + submitting={bulkSubmitting} + toggleUser={toggleBulkUser} + toggleAllUsers={toggleAllBulkUsers} + /> ); }