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(() => { 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(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>(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, 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; 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 ; } return ( Plán prací {!canEdit && ( Režim náhledu — můžete pouze prohlížet plán. Úpravy jsou dostupné oprávněným uživatelům. )} {/* Nav buttons grouped so they stay on one row when the toolbar wraps on mobile. */} {formatRangeLabel(isoDate(range.from), isoDate(range.to), view)} {canEdit && ( )} {canEdit && ( )} {gridLoading && } {/* 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 ? ( ) : null} setCatModalOpen(false)} categories={categories} /> setBulkOpen(false)} form={bulkForm} setForm={setBulkForm} users={planUsers} projects={projects} categories={categories} onSubmit={submitBulk} submitting={bulkSubmitting} toggleUser={toggleBulkUser} toggleAllUsers={toggleAllBulkUsers} /> ); }