feat(plan): bulk assignment modal on /plan-work
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
191
src/admin/components/BulkPlanModal.tsx
Normal file
191
src/admin/components/BulkPlanModal.tsx
Normal file
@@ -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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Hromadné přiřazení"
|
||||
maxWidth="lg"
|
||||
loading={submitting}
|
||||
onSubmit={onSubmit}
|
||||
submitText="Vytvořit"
|
||||
submitDisabled={form.user_ids.length === 0}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.25, mb: 2 }}
|
||||
>
|
||||
Vytvoří záznamy pro vybrané dny u zvolených zaměstnanců. Dny, které už
|
||||
mají 3 záznamy, se přeskočí.
|
||||
</Typography>
|
||||
|
||||
<Field label="Datum od">
|
||||
<DateField
|
||||
value={form.date_from}
|
||||
onChange={(val) => setForm({ ...form, date_from: val })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Rozsah dnů">
|
||||
<CheckboxField
|
||||
label="Více dní"
|
||||
checked={form.is_range}
|
||||
onChange={(checked) => setForm({ ...form, is_range: checked })}
|
||||
/>
|
||||
</Field>
|
||||
{form.is_range && (
|
||||
<Field label="Datum do">
|
||||
<DateField
|
||||
value={form.date_to}
|
||||
onChange={(val) => setForm({ ...form, date_to: val })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Víkendy">
|
||||
<CheckboxField
|
||||
label="Zahrnout víkendy"
|
||||
checked={form.include_weekends}
|
||||
onChange={(checked) =>
|
||||
setForm({ ...form, include_weekends: checked })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 0.5 }}>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||
>
|
||||
Zaměstnanci
|
||||
</Typography>
|
||||
<Typography
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={toggleAllUsers}
|
||||
variant="caption"
|
||||
sx={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
p: 0,
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
color: "primary.main",
|
||||
}}
|
||||
>
|
||||
{users.length > 0 && form.user_ids.length === users.length
|
||||
? "Odznačit vše"
|
||||
: "Vybrat vše"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 0.25,
|
||||
maxHeight: 200,
|
||||
overflowY: "auto",
|
||||
p: 1.5,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{users.map((u) => (
|
||||
<CheckboxField
|
||||
key={u.id}
|
||||
label={u.full_name}
|
||||
checked={form.user_ids.includes(String(u.id))}
|
||||
onChange={() => toggleUser(u.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Vybráno: {form.user_ids.length} z {users.length}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Field label="Projekt">
|
||||
<Select
|
||||
value={form.project_id}
|
||||
onChange={(val) => setForm({ ...form, project_id: val })}
|
||||
options={[
|
||||
{ value: "", label: "— bez projektu —" },
|
||||
...projects.map((p) => ({ value: String(p.id), label: p.name })),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kategorie">
|
||||
<Select
|
||||
value={form.category}
|
||||
onChange={(val) => setForm({ ...form, category: val })}
|
||||
options={activeCategories.map((c) => ({
|
||||
value: c.key,
|
||||
label: c.label,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Text poznámky (volitelný)">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
value={form.note}
|
||||
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
||||
inputProps={{ maxLength: 500 }}
|
||||
/>
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<BulkPlanForm>(() => {
|
||||
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í
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => setBulkOpen(true)}
|
||||
>
|
||||
Hromadné přiřazení
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{gridLoading && <LoadingState />}
|
||||
@@ -727,6 +816,19 @@ export default function PlanWork() {
|
||||
onClose={() => setCatModalOpen(false)}
|
||||
categories={categories}
|
||||
/>
|
||||
<BulkPlanModal
|
||||
isOpen={bulkOpen}
|
||||
onClose={() => setBulkOpen(false)}
|
||||
form={bulkForm}
|
||||
setForm={setBulkForm}
|
||||
users={planUsers}
|
||||
projects={projects}
|
||||
categories={categories}
|
||||
onSubmit={submitBulk}
|
||||
submitting={bulkSubmitting}
|
||||
toggleUser={toggleBulkUser}
|
||||
toggleAllUsers={toggleAllBulkUsers}
|
||||
/>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user