feat(plan): page, sidebar entry, and route registration
This commit is contained in:
@@ -23,6 +23,7 @@ import "./responsive.css";
|
|||||||
import "./login.css";
|
import "./login.css";
|
||||||
import "./dashboard.css";
|
import "./dashboard.css";
|
||||||
import "./attendance.css";
|
import "./attendance.css";
|
||||||
|
import "./plan.css";
|
||||||
import "./settings.css";
|
import "./settings.css";
|
||||||
import "./offers.css";
|
import "./offers.css";
|
||||||
import "./invoices.css";
|
import "./invoices.css";
|
||||||
@@ -37,6 +38,7 @@ const AttendanceCreate = lazy(() => import("./pages/AttendanceCreate"));
|
|||||||
const LeaveRequests = lazy(() => import("./pages/LeaveRequests"));
|
const LeaveRequests = lazy(() => import("./pages/LeaveRequests"));
|
||||||
const LeaveApproval = lazy(() => import("./pages/LeaveApproval"));
|
const LeaveApproval = lazy(() => import("./pages/LeaveApproval"));
|
||||||
const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation"));
|
const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation"));
|
||||||
|
const PlanWork = lazy(() => import("./pages/PlanWork"));
|
||||||
const Trips = lazy(() => import("./pages/Trips"));
|
const Trips = lazy(() => import("./pages/Trips"));
|
||||||
const TripsHistory = lazy(() => import("./pages/TripsHistory"));
|
const TripsHistory = lazy(() => import("./pages/TripsHistory"));
|
||||||
const TripsAdmin = lazy(() => import("./pages/TripsAdmin"));
|
const TripsAdmin = lazy(() => import("./pages/TripsAdmin"));
|
||||||
@@ -128,6 +130,7 @@ export default function AdminApp() {
|
|||||||
path="attendance/location/:id"
|
path="attendance/location/:id"
|
||||||
element={<AttendanceLocation />}
|
element={<AttendanceLocation />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="plan-work" element={<PlanWork />} />
|
||||||
<Route path="trips" element={<Trips />} />
|
<Route path="trips" element={<Trips />} />
|
||||||
<Route path="trips/history" element={<TripsHistory />} />
|
<Route path="trips/history" element={<TripsHistory />} />
|
||||||
<Route path="trips/admin" element={<TripsAdmin />} />
|
<Route path="trips/admin" element={<TripsAdmin />} />
|
||||||
|
|||||||
@@ -155,6 +155,26 @@ const menuSections: MenuSection[] = [
|
|||||||
</svg>
|
</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