diff --git a/src/admin/AdminApp.tsx b/src/admin/AdminApp.tsx index 2413d12..8208a84 100644 --- a/src/admin/AdminApp.tsx +++ b/src/admin/AdminApp.tsx @@ -23,6 +23,7 @@ import "./responsive.css"; import "./login.css"; import "./dashboard.css"; import "./attendance.css"; +import "./plan.css"; import "./settings.css"; import "./offers.css"; import "./invoices.css"; @@ -37,6 +38,7 @@ const AttendanceCreate = lazy(() => import("./pages/AttendanceCreate")); const LeaveRequests = lazy(() => import("./pages/LeaveRequests")); const LeaveApproval = lazy(() => import("./pages/LeaveApproval")); const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation")); +const PlanWork = lazy(() => import("./pages/PlanWork")); const Trips = lazy(() => import("./pages/Trips")); const TripsHistory = lazy(() => import("./pages/TripsHistory")); const TripsAdmin = lazy(() => import("./pages/TripsAdmin")); @@ -128,6 +130,7 @@ export default function AdminApp() { path="attendance/location/:id" element={} /> + } /> } /> } /> } /> diff --git a/src/admin/components/Sidebar.tsx b/src/admin/components/Sidebar.tsx index 285e4f2..23217fa 100644 --- a/src/admin/components/Sidebar.tsx +++ b/src/admin/components/Sidebar.tsx @@ -155,6 +155,26 @@ const menuSections: MenuSection[] = [ ), }, + { + path: "/plan-work", + label: "Plán prací", + permission: ["attendance.manage", "attendance.record"], + icon: ( + + + + + + + ), + }, ], }, { diff --git a/src/admin/pages/PlanWork.tsx b/src/admin/pages/PlanWork.tsx new file mode 100644 index 0000000..d7c4415 --- /dev/null +++ b/src/admin/pages/PlanWork.tsx @@ -0,0 +1,469 @@ +import { useState, useMemo, useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useAuth } from "../context/AuthContext"; +import { useAlert } from "../context/AlertContext"; +import { + usePlanWork, + getCell, + type ModalMode as HookModalMode, +} from "../hooks/usePlanWork"; +import type { PlanCellModalMode } from "../components/PlanCellModal"; +import PlanGrid from "../components/PlanGrid"; +import PlanCellModal from "../components/PlanCellModal"; +import Forbidden from "../components/Forbidden"; +import { projectListOptions, type Project } from "../lib/queries/projects"; +import "../plan.css"; + +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()})`; +} + +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, + } = 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 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]); + + // --- 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); + + const openCreate = useCallback( + (userId: number, date: string) => { + setModalCell(null); + setHookModal({ kind: "create", userId, date }); + }, + [setHookModal], + ); + + const openCell = useCallback( + (userId: number, date: string) => { + const cell = getCell(grid, userId, date); + if (!cell) { + // Empty cell — open the create modal if the user can edit, otherwise + // there's nothing to view. + if (canEdit) openCreate(userId, date); + return; + } + setModalCell(cell); + if (cell.source === "entry" && cell.entryId !== null) { + if (canEdit) { + // A multi-day entry clicked on a single day → "day-in-range" so the + // user can decide between editing the range or creating a one-day + // override. + if ( + cell.rangeFrom && + cell.rangeTo && + cell.rangeFrom !== cell.rangeTo + ) { + setHookModal({ + kind: "day-in-range", + entryId: cell.entryId, + userId, + date, + }); + return; + } + setHookModal({ + kind: "edit-range", + entryId: cell.entryId, + userId, + date, + }); + return; + } + // Read-only view + setHookModal({ kind: "view", userId, date }); + return; + } + if (cell.source === "override" && cell.overrideId !== null) { + if (canEdit) { + setHookModal({ + kind: "edit-override", + overrideId: cell.overrideId, + userId, + date, + }); + } else { + setHookModal({ kind: "view", userId, date }); + } + return; + } + // No source — treat as empty + if (canEdit) openCreate(userId, date); + }, + [grid, canEdit, openCreate, setHookModal], + ); + + const closeModal = useCallback(() => { + setHookModal({ kind: "closed" }); + setModalCell(null); + }, [setHookModal]); + + // --- Mutation wrappers ----------------------------------------------------- + // The modal calls these with plain objects / ids. We resolve them to the + // hook's mutateAsync calls and surface server errors as alerts. + + const saveEntry = useCallback( + async (body: any) => { + try { + await createEntry.mutateAsync(body); + alert.success("Záznam vytvořen"); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba při ukládání"); + throw e; + } + }, + [createEntry, alert], + ); + + const updateEntryFn = useCallback( + async (id: number, body: any) => { + try { + await updateEntry.mutateAsync({ id, body }); + alert.success("Záznam aktualizován"); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba při ukládání"); + throw e; + } + }, + [updateEntry, alert], + ); + + const deleteEntryFn = useCallback( + async (id: number) => { + try { + await deleteEntry.mutateAsync({ id }); + alert.success("Záznam smazán"); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba při mazání"); + throw e; + } + }, + [deleteEntry, alert], + ); + + const saveOverride = useCallback( + async (body: any) => { + try { + await createOverride.mutateAsync(body); + alert.success("Přepsání vytvořeno"); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba při ukládání"); + throw e; + } + }, + [createOverride, alert], + ); + + const updateOverrideFn = useCallback( + async (id: number, body: any) => { + try { + await updateOverride.mutateAsync({ id, body }); + alert.success("Přepsání aktualizováno"); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba při ukládání"); + throw e; + } + }, + [updateOverride, alert], + ); + + const deleteOverrideFn = useCallback( + async (id: number) => { + try { + await deleteOverride.mutateAsync({ id }); + alert.success("Přepsání smazáno"); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba při mazání"); + throw e; + } + }, + [deleteOverride, alert], + ); + + // "Create override from range" — turn a multi-day entry into a one-day + // override for the clicked date. We look up the cell's source entry and + // create a new override mirroring its fields, locked to a single date. + const createOverrideFromRange = useCallback( + async (entryId: number, userId: number, date: string) => { + const cell = getCell(grid, userId, date); + 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, + }); + alert.success("Přepsání dne vytvořeno"); + } catch (e) { + 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; + }, + [grid, createOverride, alert], + ); + + // --- 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; + 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; + } + return { kind: "closed" }; + }, [hookModal, modalCell]); + + if (!canEdit && !hasPermission("attendance.record")) { + // Hard block for users who have neither manage nor record permission. + return ; + } + + return ( +
+

Plán prací

+ + {!canEdit && ( +
+ + i + + + Režim náhledu — můžete pouze prohlížet plán. Úpravy + jsou dostupné oprávněným uživatelům. + +
+ )} + +
+ + + + + {formatRangeLabel(isoDate(range.from), isoDate(range.to), view)} + +
+ + +
+
+ + {gridLoading && ( +
+
+
+ )} + + + + +
+ ); +}