835 lines
27 KiB
TypeScript
835 lines
27 KiB
TypeScript
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>
|
||
);
|
||
}
|