feat(plan): page, sidebar entry, and route registration

This commit is contained in:
BOHA
2026-06-05 13:17:16 +02:00
parent 916cf34efd
commit a0f351b0aa
3 changed files with 492 additions and 0 deletions

View File

@@ -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={<AttendanceLocation />}
/>
<Route path="plan-work" element={<PlanWork />} />
<Route path="trips" element={<Trips />} />
<Route path="trips/history" element={<TripsHistory />} />
<Route path="trips/admin" element={<TripsAdmin />} />

View File

@@ -155,6 +155,26 @@ const menuSections: MenuSection[] = [
</svg>
),
},
{
path: "/plan-work",
label: "Plán prací",
permission: ["attendance.manage", "attendance.record"],
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
),
},
],
},
{

View File

@@ -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<ReturnType<typeof getCell>>(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<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;
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 <Forbidden />;
}
return (
<div className="plan-work-page">
<h1 className="admin-page-title">Plán prací</h1>
{!canEdit && (
<div className="plan-banner" role="status">
<span className="plan-banner-icon" aria-hidden>
i
</span>
<span>
<strong>Režim náhledu</strong> můžete pouze prohlížet plán. Úpravy
jsou dostupné oprávněným uživatelům.
</span>
</div>
)}
<div className="plan-toolbar">
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={goToToday}
>
Dnes
</button>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={goPrev}
aria-label="Předchozí"
>
</button>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={goNext}
aria-label="Další"
>
</button>
<span className="plan-range-label">
{formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
</span>
<div
className="plan-view-toggle"
role="group"
aria-label="Měřítko zobrazení"
>
<button
type="button"
className={
view === "week" ? "admin-btn admin-btn-primary" : "admin-btn"
}
onClick={() => setView("week")}
>
Týden
</button>
<button
type="button"
className={
view === "month" ? "admin-btn admin-btn-primary" : "admin-btn"
}
onClick={() => setView("month")}
>
Měsíc
</button>
</div>
</div>
{gridLoading && (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
)}
<PlanGrid data={grid} canEdit={canEdit} onCellClick={openCell} />
<PlanCellModal
key={modalKey}
open={hookModal.kind !== "closed"}
mode={modalMode}
projects={projects}
onClose={closeModal}
onSaveEntry={saveEntry}
onUpdateEntry={updateEntryFn}
onDeleteEntry={deleteEntryFn}
onSaveOverride={saveOverride}
onUpdateOverride={updateOverrideFn}
onDeleteOverride={deleteOverrideFn}
onCreateOverrideFromRange={createOverrideFromRange}
/>
</div>
);
}