feat(plan): page, sidebar entry, and route registration
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
469
src/admin/pages/PlanWork.tsx
Normal file
469
src/admin/pages/PlanWork.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user