Files
app/src/admin/pages/PlanWork.tsx
2026-06-08 12:11:20 +02:00

835 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
usePlanWork,
getCell,
getCells,
type ModalMode as HookModalMode,
} from "../hooks/usePlanWork";
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";
import {
planCategoriesOptions,
type PlanCategory,
type ResolvedCell,
} from "../lib/queries/plan";
// plan.css is imported once globally in AdminApp.tsx — no per-page re-import.
const MONTH_NAMES = [
"Leden",
"Únor",
"Březen",
"Duben",
"Květen",
"Červen",
"Červenec",
"Srpen",
"Září",
"Říjen",
"Listopad",
"Prosinec",
];
const DAY_MS = 86400000;
function isoDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
function addDays(d: Date, n: number): Date {
const r = new Date(d);
r.setUTCDate(d.getUTCDate() + n);
return r;
}
function shiftAnchor(
anchor: Date,
view: "week" | "month",
direction: 1 | -1,
): Date {
if (view === "week") {
return addDays(anchor, direction * 7);
}
// Month view: jump a calendar month
const d = new Date(anchor);
d.setUTCMonth(d.getUTCMonth() + direction);
return d;
}
function isoWeekNumber(d: Date): number {
// ISO-8601 week number
const target = new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
);
const dayNr = (target.getUTCDay() + 6) % 7;
target.setUTCDate(target.getUTCDate() - dayNr + 3);
const jan1 = new Date(Date.UTC(target.getUTCFullYear(), 0, 1));
return Math.ceil(((target.getTime() - jan1.getTime()) / DAY_MS + 1) / 7);
}
function formatRangeLabel(
from: string,
to: string,
view: "week" | "month",
): string {
const fmt = (s: string) => {
const [, mm, dd] = s.split("-");
return `${Number(dd)}.${Number(mm)}.`;
};
if (view === "month") {
const [y, m] = from.split("-");
return `${MONTH_NAMES[Number(m) - 1]} ${y}`;
}
const fromDate = new Date(from + "T00:00:00.000Z");
const week = isoWeekNumber(fromDate);
return `Týden ${week} (${fmt(from)} ${fmt(to)} ${fromDate.getUTCFullYear()})`;
}
// Local-time YYYY-MM-DD for "now". Matches the server's
// `assertNotPastDate` guard in src/services/plan.service.ts and the
// `todayIso()` helper in PlanGrid — all three use the same local-time
// getters so the past-date gate stays consistent across layers.
function todayIsoLocal(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function isPastDate(dateStr: string, today: string): boolean {
return dateStr < today;
}
export default function PlanWork() {
const { hasPermission } = useAuth();
const alert = useAlert();
const canEdit = hasPermission("attendance.manage");
const {
view,
setView,
setAnchor,
range,
grid,
gridLoading,
modal: hookModal,
setModal: setHookModal,
createEntry,
updateEntry,
deleteEntry,
createOverride,
updateOverride,
deleteOverride,
bulkCreate,
rollbackMutation,
} = usePlanWork({ canEdit });
// Projects list — needed by the modal's project selector.
const { data: projectsData } = useQuery(projectListOptions({ perPage: 200 }));
const projects: Project[] = (projectsData?.data as Project[]) ?? [];
const { data: categoriesData } = useQuery(planCategoriesOptions());
const categories: PlanCategory[] = categoriesData ?? [];
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
// animation duration + a small buffer) so subsequent mutations on
// the same cell can re-trigger it.
const [lastMutated, setLastMutated] = useState<{
userId: number;
date: string;
nonce: number;
} | null>(null);
const pulseTimer = useRef<number | null>(null);
const firePulse = useCallback((userId: number, date: string) => {
if (pulseTimer.current !== null) {
window.clearTimeout(pulseTimer.current);
}
setLastMutated({ userId, date, nonce: Date.now() });
pulseTimer.current = window.setTimeout(() => {
setLastMutated(null);
pulseTimer.current = null;
}, 700);
}, []);
useEffect(() => {
return () => {
if (pulseTimer.current !== null) {
window.clearTimeout(pulseTimer.current);
}
};
}, []);
const goPrev = useCallback(() => {
setAnchor((a) => shiftAnchor(a, view, -1));
}, [setAnchor, view]);
const goNext = useCallback(() => {
setAnchor((a) => shiftAnchor(a, view, 1));
}, [setAnchor, view]);
const goToToday = useCallback(() => {
setAnchor(new Date());
}, [setAnchor]);
const setViewWithAnim = useCallback(
(next: "week" | "month") => {
setView(next);
},
[setView],
);
// --- Modal helpers ---------------------------------------------------------
// The hook stores a slim ModalMode (no `cell` / `range` payload). The
// PlanCellModal component expects a richer mode with those fields. We keep
// a separate `modalCell` state and merge it in at render time.
// The ResolvedCell currently being edited / viewed (filled on openCell).
// Null when the modal is closed or in a "create" state with no source cell.
const [modalCell, setModalCell] = useState<ReturnType<typeof getCell>>(null);
// Open the create form for a brand-new record. `source` picks the layer:
// "entry" (default) opens the normal create form; "override" opens the
// single-day create-override form. The day panel passes "override" when the
// day is in exception mode so the new record lands in the same layer that's
// showing (otherwise resolveCell would hide a new entry behind the override).
const openCreate = useCallback(
(userId: number, date: string, source: "entry" | "override" = "entry") => {
setModalCell(null);
if (source === "override") {
setHookModal({ kind: "create-override", userId, date });
} else {
setHookModal({ kind: "create", userId, date });
}
},
[setHookModal],
);
const openCell = useCallback(
(userId: number, date: string, cells: ResolvedCell[]) => {
const primary = cells[0] ?? null;
// Past-date guard: the server rejects create/edit on past dates with
// a 403 (see assertNotPastDate in plan.service.ts). To keep the user
// out of the "click → modal opens → error toast" state, route any
// past-day click to view mode (or to no-op if there's no record).
if (isPastDate(date, todayIsoLocal())) {
if (!primary) return;
setModalCell(primary);
setHookModal({ kind: "view", userId, date });
return;
}
if (cells.length === 0) {
if (canEdit) openCreate(userId, date);
return;
}
if (!canEdit) {
setModalCell(primary);
setHookModal({ kind: "view", userId, date });
return;
}
setHookModal({ kind: "day", userId, date });
},
[canEdit, openCreate, setHookModal],
);
// Open the correct editor for one record from the day panel. This is the
// per-record version of the old openCell branch logic: a multi-day entry
// routes through the day-in-range chooser; a single-day entry → edit-range;
// an override → edit-override.
const editRecord = useCallback(
(cell: ReturnType<typeof getCell>, userId: number, date: string) => {
if (!cell) return;
setModalCell(cell);
if (cell.source === "entry" && cell.entryId !== null) {
if (cell.rangeFrom && cell.rangeTo && cell.rangeFrom !== cell.rangeTo) {
setHookModal({
kind: "day-in-range",
entryId: cell.entryId,
userId,
date,
});
} else {
setHookModal({
kind: "edit-range",
entryId: cell.entryId,
userId,
date,
});
}
return;
}
if (cell.source === "override" && cell.overrideId !== null) {
setHookModal({
kind: "edit-override",
overrideId: cell.overrideId,
userId,
date,
});
}
},
[setHookModal],
);
const closeModal = useCallback(() => {
setHookModal({ kind: "closed" });
setModalCell(null);
}, [setHookModal]);
// Switch the modal from "day-in-range" to "edit-range" mode, keeping the
// same cell context so the EditForm opens with the range's values.
// Uses `modalCell` — the record the user actually clicked in the day panel
// (captured by editRecord) — NOT getCell (which returns the PRIMARY record).
// On a day with 2+ records these differ; using the primary would edit the
// wrong range. modalCell is already set to the clicked record at this point
// (editRecord → day-in-range → here).
const switchToEditRange = useCallback(
(entryId: number, userId: number, date: string) => {
if (!modalCell) return;
setHookModal({
kind: "edit-range",
entryId,
userId,
date,
});
},
[modalCell, setHookModal],
);
// --- Mutation wrappers -----------------------------------------------------
// The modal calls these with plain objects / ids. We resolve them to the
// hook's mutateAsync calls, surface server errors as alerts, and (on
// success) trigger the per-cell pulse. On failure we also roll back the
// optimistic cache patch the hook already applied.
const saveEntry = useCallback(
async (body: any) => {
try {
await createEntry.mutateAsync(body);
// Pulse the first day of the new entry — for multi-day entries
// the first day is what the modal was opened on (body.date_from).
firePulse(body.user_id, body.date_from);
alert.success("Záznam vytvořen");
} catch (e) {
rollbackMutation(createEntry, body.user_id);
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
throw e;
}
},
[createEntry, alert, firePulse, rollbackMutation],
);
const updateEntryFn = useCallback(
async (id: number, body: any) => {
// Find the user for this entry so we know which cell to pulse /
// which cache to roll back. The hook's optimistic patch already
// discovered this; we read it from the current grid.
let userId: number | null = null;
let firstDate: string | null = null;
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cells] of Object.entries(byDate)) {
const hit = cells.find((c) => c.entryId === id);
if (hit) {
userId = Number(uidStr);
firstDate = body.date_from ?? date;
break;
}
}
if (userId !== null) break;
}
}
try {
await updateEntry.mutateAsync({ id, body });
if (userId !== null && firstDate !== null) firePulse(userId, firstDate);
alert.success("Záznam aktualizován");
} catch (e) {
if (userId !== null) rollbackMutation(updateEntry, userId);
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
throw e;
}
},
[updateEntry, grid, alert, firePulse, rollbackMutation],
);
const deleteEntryFn = useCallback(
async (id: number) => {
// Find the user/date for the pulse + rollback.
let userId: number | null = null;
let firstDate: string | null = null;
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cells] of Object.entries(byDate)) {
const hit = cells.find((c) => c.entryId === id);
if (hit) {
userId = Number(uidStr);
firstDate = hit.rangeFrom ?? date;
break;
}
}
if (userId !== null) break;
}
}
try {
await deleteEntry.mutateAsync({ id });
if (userId !== null && firstDate !== null) firePulse(userId, firstDate);
alert.success("Záznam smazán");
} catch (e) {
if (userId !== null) rollbackMutation(deleteEntry, userId);
alert.error(e instanceof Error ? e.message : "Chyba při mazání");
throw e;
}
},
[deleteEntry, grid, alert, firePulse, rollbackMutation],
);
const saveOverride = useCallback(
async (body: any) => {
try {
await createOverride.mutateAsync(body);
firePulse(body.user_id, body.shift_date);
alert.success("Přepsání vytvořeno");
} catch (e) {
rollbackMutation(createOverride, body.user_id);
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
throw e;
}
},
[createOverride, alert, firePulse, rollbackMutation],
);
const updateOverrideFn = useCallback(
async (id: number, body: any) => {
// Find the user/date for the pulse + rollback.
let userId: number | null = null;
let date: string | null = null;
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [d, cells] of Object.entries(byDate)) {
const hit = cells.find((c) => c.overrideId === id);
if (hit) {
userId = Number(uidStr);
date = d;
break;
}
}
if (userId !== null) break;
}
}
try {
await updateOverride.mutateAsync({ id, body });
if (userId !== null && date !== null) firePulse(userId, date);
alert.success("Přepsání aktualizováno");
} catch (e) {
if (userId !== null) rollbackMutation(updateOverride, userId);
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
throw e;
}
},
[updateOverride, grid, alert, firePulse, rollbackMutation],
);
const deleteOverrideFn = useCallback(
async (id: number) => {
let userId: number | null = null;
let date: string | null = null;
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [d, cells] of Object.entries(byDate)) {
const hit = cells.find((c) => c.overrideId === id);
if (hit) {
userId = Number(uidStr);
date = d;
break;
}
}
if (userId !== null) break;
}
}
try {
await deleteOverride.mutateAsync({ id });
if (userId !== null && date !== null) firePulse(userId, date);
alert.success("Přepsání smazáno");
} catch (e) {
if (userId !== null) rollbackMutation(deleteOverride, userId);
alert.error(e instanceof Error ? e.message : "Chyba při mazání");
throw e;
}
},
[deleteOverride, grid, alert, firePulse, rollbackMutation],
);
// "Create override from range" — turn a multi-day entry into a one-day
// override for the clicked date. We mirror the CLICKED record's fields
// (modalCell, captured by editRecord) — NOT getCell, which returns the
// PRIMARY record. On a day with 2+ records the clicked entry can differ
// from the primary; carving from the primary would copy the wrong
// project/category/note into the override.
const createOverrideFromRange = useCallback(
async (entryId: number, userId: number, date: string) => {
const cell = modalCell;
if (!cell) {
alert.error("Nepodařilo se najít zdrojový záznam");
return;
}
try {
await createOverride.mutateAsync({
user_id: userId,
shift_date: date,
project_id: cell.project_id,
category: cell.category,
note: cell.note,
});
firePulse(userId, date);
alert.success("Přepsání dne vytvořeno");
} catch (e) {
rollbackMutation(createOverride, userId);
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
throw e;
}
// Suppress unused-var warning for entryId — kept in the signature for
// symmetry with the modal contract.
void entryId;
},
[modalCell, createOverride, alert, firePulse, rollbackMutation],
);
// --- Modal key for remount-on-mode-change ----------------------------------
// PlanCellModal mounts internal state from `mode` once. Re-keying the
// component whenever the mode changes (or the modal closes) forces a clean
// remount so the new mode's initial state is computed freshly.
const modalKey = useMemo(() => {
if (hookModal.kind === "closed") return "closed";
const m = hookModal as Exclude<HookModalMode, { kind: "closed" }>;
return `${m.kind}-${m.userId}-${m.date}`;
}, [hookModal]);
// Convert hook's ModalMode to PlanCellModalMode. The hook stores slim
// variants (no `cell` / `range` payload). The modal component expects rich
// variants with those fields; we fill them from `modalCell` (captured at
// open-time) so the form keeps the right initial values even if the grid
// is refetched while the modal is open.
const modalMode: PlanCellModalMode = useMemo(() => {
if (hookModal.kind === "closed") return { kind: "closed" };
if (hookModal.kind === "create") return hookModal;
// create-override carries the same userId/date payload as create and needs
// no merged cell/range — pass it straight through (EditForm handles it).
if (hookModal.kind === "create-override") return hookModal;
if (hookModal.kind === "view") {
if (!modalCell) return { kind: "closed" };
return {
kind: "view",
userId: hookModal.userId,
date: hookModal.date,
cell: modalCell,
};
}
if (hookModal.kind === "edit-override") {
if (!modalCell) return { kind: "closed" };
return {
kind: "edit-override",
overrideId: hookModal.overrideId,
userId: hookModal.userId,
date: hookModal.date,
cell: modalCell,
};
}
if (hookModal.kind === "edit-range" || hookModal.kind === "day-in-range") {
if (!modalCell) return { kind: "closed" };
return {
...hookModal,
range: {
date_from: modalCell.rangeFrom ?? hookModal.date,
date_to: modalCell.rangeTo ?? hookModal.date,
project_id: modalCell.project_id,
category: modalCell.category,
note: modalCell.note,
},
} as PlanCellModalMode;
}
if (hookModal.kind === "day") {
return {
kind: "day",
userId: hookModal.userId,
date: hookModal.date,
cells: getCells(grid, hookModal.userId, hookModal.date),
};
}
return { kind: "closed" };
}, [hookModal, modalCell, grid]);
if (!canEdit && !hasPermission("attendance.record")) {
// Hard block for users who have neither manage nor record permission.
return <Forbidden />;
}
return (
<PageEnter sx={{ position: "relative" }}>
<Typography variant="h4" sx={{ fontWeight: 700, mb: 2 }}>
Plán prací
</Typography>
{!canEdit && (
<Box role="status" 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>
)}
<Box
sx={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: 1.5,
mb: 2,
}}
>
{/* 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",
}}
>
<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 }}
>
<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>
)}
{canEdit && (
<Button
variant="outlined"
color="inherit"
onClick={() => setBulkOpen(true)}
>
Hromadné přiřazení
</Button>
)}
</Box>
{gridLoading && <LoadingState />}
{/*
Grid entrance: same pattern as the h1 / banner / toolbar above
(opacity + 12px translateY, 250ms ease-out). The stagger is
h1 (0s) → banner (0.06s) → toolbar (0.12s) → grid (0.18s), so
the page assembles top-to-bottom in a single cascade.
The motion.div mounts ONCE when `grid` first becomes defined
and stays mounted across all range changes (keepPreviousData
keeps `grid` defined even while a new fetch is in flight, so
the condition never drops to null). This prevents the entrance
animation from re-firing on every week/month switch.
Range changes are instant — keepPreviousData holds the old data
visible until the new data arrives, so there's no flash. Mutations
patch the cache in place and pulse a single cell via pulseKey.
*/}
{grid ? (
<PlanGrid
data={grid}
projects={projects}
categories={categories}
canEdit={canEdit}
pulseKey={lastMutated}
onCellClick={openCell}
/>
) : null}
<PlanCellModal
key={modalKey}
open={hookModal.kind !== "closed"}
mode={modalMode}
projects={projects}
categories={categories}
onClose={closeModal}
onSaveEntry={saveEntry}
onUpdateEntry={updateEntryFn}
onDeleteEntry={deleteEntryFn}
onSaveOverride={saveOverride}
onUpdateOverride={updateOverrideFn}
onDeleteOverride={deleteOverrideFn}
onCreateOverrideFromRange={createOverrideFromRange}
onSwitchToEditRange={switchToEditRange}
onAddRecord={openCreate}
onEditRecord={editRecord}
/>
<PlanCategoriesModal
isOpen={catModalOpen}
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>
);
}