v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix
Highlights: - Warehouse module: receipts, issues, reservations, inventory, reports, dashboard, master data (categories, suppliers, locations), FIFO service, integration tests - Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek / So/Ne / Noc) with Czech weekday names and decimal hours - AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep - Remove leave_type=holiday entirely (auto-computed from Czech public holidays) - Allow multiple work shifts per day (overlap detection only) - Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads - Prisma: company_settings gets 6 nullable columns for warehouse numbering (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormModal from "../components/FormModal";
|
||||
import FormField from "../components/FormField";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import CompanySettings from "./CompanySettings";
|
||||
import {
|
||||
companySettingsOptions,
|
||||
@@ -14,9 +14,8 @@ import {
|
||||
require2FAOptions,
|
||||
} from "../lib/queries/settings";
|
||||
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import apiFetch from "../utils/api";
|
||||
import { Skeleton } from "boneyard-js/react";
|
||||
import SettingsFixture from "../fixtures/SettingsFixture";
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface SystemSettingsData {
|
||||
@@ -108,7 +107,6 @@ const DEFAULT_SYS_FORM: Omit<SystemSettingsData, "app_version"> = {
|
||||
export default function Settings() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const canManageRoles = hasPermission("settings.roles");
|
||||
const canManageCompany = hasPermission("settings.company");
|
||||
@@ -130,11 +128,11 @@ export default function Settings() {
|
||||
queryKey: ["settings", "roles"],
|
||||
queryFn: async () => {
|
||||
const [rolesRes, permsRes] = await Promise.all([
|
||||
apiFetch(`${API_BASE}/roles`),
|
||||
apiFetch(`${API_BASE}/roles/permissions`),
|
||||
apiFetch(`${API_BASE}/roles`).then((r) => r.json()),
|
||||
apiFetch(`${API_BASE}/roles/permissions`).then((r) => r.json()),
|
||||
]);
|
||||
const rolesResult = await rolesRes.json();
|
||||
const permsResult = await permsRes.json();
|
||||
const rolesResult = await rolesRes;
|
||||
const permsResult = await permsRes;
|
||||
if (!rolesResult.success)
|
||||
throw new Error(rolesResult.error || "Nepodařilo se načíst role");
|
||||
if (!permsResult.success)
|
||||
@@ -194,11 +192,8 @@ export default function Settings() {
|
||||
});
|
||||
|
||||
// ── Local state ──
|
||||
const [require2FASaving, setRequire2FASaving] = useState(false);
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<RoleForm>({
|
||||
name: "",
|
||||
display_name: "",
|
||||
@@ -210,9 +205,7 @@ export default function Settings() {
|
||||
show: boolean;
|
||||
role: Role | null;
|
||||
}>({ show: false, role: null });
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const [sysSettingsSaving, setSysSettingsSaving] = useState(false);
|
||||
const [sysForm, setSysForm] = useState(DEFAULT_SYS_FORM);
|
||||
const [sysFormInitialized, setSysFormInitialized] = useState(false);
|
||||
|
||||
@@ -256,61 +249,108 @@ export default function Settings() {
|
||||
setSysFormInitialized(true);
|
||||
}, [sysSettingsData, sysFormInitialized]);
|
||||
|
||||
useModalLock(showModal);
|
||||
|
||||
// ── Early return after all hooks ──
|
||||
if (!canAccessSettings) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const saveSystemSettingsMutation = useApiMutation<
|
||||
typeof sysForm,
|
||||
{ message?: string; error?: string }
|
||||
>({
|
||||
url: () => `${API_BASE}/company-settings`,
|
||||
method: () => "PUT",
|
||||
invalidate: [
|
||||
"settings",
|
||||
"company-settings",
|
||||
"attendance",
|
||||
"offers",
|
||||
"orders",
|
||||
"invoices",
|
||||
"leave",
|
||||
],
|
||||
onSuccess: (data) => {
|
||||
alert.success(data?.message || "Systémová nastavení byla uložena");
|
||||
},
|
||||
});
|
||||
|
||||
const toggle2FARoleMutation = useApiMutation<
|
||||
{ required: boolean },
|
||||
{ message?: string; error?: string }
|
||||
>({
|
||||
url: () => `${API_BASE}/totp/required`,
|
||||
method: () => "POST",
|
||||
invalidate: ["settings", "company-settings", "dashboard", "users"],
|
||||
onSuccess: (data) => {
|
||||
alert.success(data?.message || "2FA nastavení uloženo");
|
||||
},
|
||||
});
|
||||
|
||||
// useApiMutation JSON.stringifies the whole TIn as the request body, so
|
||||
// TIn must match the backend schema shape (CreateRoleSchema / UpdateRoleSchema)
|
||||
// directly — NOT a wrapper that nests the body. id is captured in the URL closure.
|
||||
const createRoleMutation = useApiMutation<
|
||||
{
|
||||
name: string;
|
||||
display_name: string;
|
||||
description?: string;
|
||||
permission_ids: number[];
|
||||
},
|
||||
{ message?: string; error?: string }
|
||||
>({
|
||||
url: () => `${API_BASE}/roles`,
|
||||
method: () => "POST",
|
||||
invalidate: ["settings", "users"],
|
||||
onSuccess: (data) => {
|
||||
closeModal();
|
||||
alert.success(data?.message || "Role byla vytvořena");
|
||||
},
|
||||
});
|
||||
|
||||
const updateRoleMutation = useApiMutation<
|
||||
{
|
||||
id: number;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
permission_ids: number[];
|
||||
},
|
||||
{ message?: string; error?: string }
|
||||
>({
|
||||
url: ({ id }) => `${API_BASE}/roles/${id}`,
|
||||
method: () => "PUT",
|
||||
invalidate: ["settings", "users"],
|
||||
onSuccess: (data) => {
|
||||
closeModal();
|
||||
alert.success(data?.message || "Role byla aktualizována");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteRoleMutation = useApiMutation<
|
||||
number,
|
||||
{ message?: string; error?: string }
|
||||
>({
|
||||
url: (id) => `${API_BASE}/roles/${id}`,
|
||||
method: () => "DELETE",
|
||||
invalidate: ["settings", "users"],
|
||||
onSuccess: (data) => {
|
||||
setDeleteConfirm({ show: false, role: null });
|
||||
alert.success(data?.message || "Role byla smazána");
|
||||
},
|
||||
});
|
||||
|
||||
const handleSaveSystemSettings = async () => {
|
||||
setSysSettingsSaving(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/company-settings`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(sysForm),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert.success(result.message || "Systémová nastavení byla uložena");
|
||||
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["leave"] });
|
||||
} else {
|
||||
alert.error(result.error || "Nepodařilo se uložit nastavení");
|
||||
}
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setSysSettingsSaving(false);
|
||||
await saveSystemSettingsMutation.mutateAsync(sysForm);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle2FARequired = async () => {
|
||||
setRequire2FASaving(true);
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/totp/required`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ required: !require2FA }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert.success(result.message || "2FA nastavení uloženo");
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
} else {
|
||||
alert.error(result.error || "Nepodařilo se uložit nastavení");
|
||||
}
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setRequire2FASaving(false);
|
||||
await toggle2FARoleMutation.mutateAsync({ required: !require2FA });
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -389,91 +429,53 @@ export default function Settings() {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
const permission_ids = form.permissions
|
||||
.map((name) => {
|
||||
// Find permission ID by name from groups
|
||||
for (const perms of Object.values(permissionGroups)) {
|
||||
const found = perms.find((p) => p.name === name);
|
||||
if (found) return found.id;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
try {
|
||||
const url = editingRole
|
||||
? `${API_BASE}/roles/${editingRole.id}`
|
||||
: `${API_BASE}/roles`;
|
||||
|
||||
const response = await apiFetch(url, {
|
||||
method: editingRole ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
permission_ids: form.permissions
|
||||
.map((name) => {
|
||||
// Find permission ID by name from groups
|
||||
for (const perms of Object.values(permissionGroups)) {
|
||||
const found = perms.find((p) => p.name === name);
|
||||
if (found) return found.id;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
closeModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success(
|
||||
result.message ||
|
||||
(editingRole ? "Role byla aktualizována" : "Role byla vytvořena"),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["settings", "roles"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["roles"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
if (editingRole) {
|
||||
await updateRoleMutation.mutateAsync({
|
||||
id: editingRole.id,
|
||||
display_name: form.display_name,
|
||||
description: form.description,
|
||||
permission_ids,
|
||||
});
|
||||
} else {
|
||||
alert.error(result.error || "Nepodařilo se uložit roli");
|
||||
await createRoleMutation.mutateAsync({
|
||||
name: form.name,
|
||||
display_name: form.display_name,
|
||||
description: form.description,
|
||||
permission_ids,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.role) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/roles/${deleteConfirm.role.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setDeleteConfirm({ show: false, role: null });
|
||||
alert.success(result.message || "Role byla smazána");
|
||||
queryClient.invalidateQueries({ queryKey: ["settings", "roles"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["roles"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
} else {
|
||||
alert.error(result.error || "Nepodařilo se smazat roli");
|
||||
}
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
await deleteRoleMutation.mutateAsync(deleteConfirm.role.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
};
|
||||
|
||||
if (canManageRoles && rolesLoading) {
|
||||
return (
|
||||
<Skeleton
|
||||
name="settings"
|
||||
loading={rolesLoading}
|
||||
fixture={<SettingsFixture />}
|
||||
>
|
||||
<div />
|
||||
</Skeleton>
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -482,13 +484,9 @@ export default function Settings() {
|
||||
const get2FADescription = (): React.ReactNode => {
|
||||
if (require2FALoading) {
|
||||
return (
|
||||
<Skeleton
|
||||
name="settings-2fa"
|
||||
loading={require2FALoading}
|
||||
fixture={<SettingsFixture />}
|
||||
>
|
||||
<span />
|
||||
</Skeleton>
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (require2FA)
|
||||
@@ -497,12 +495,14 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
const get2FAButtonLabel = (): string => {
|
||||
if (require2FASaving) return "Ukládání...";
|
||||
if (toggle2FARoleMutation.isPending) return "Ukládání...";
|
||||
return require2FA ? "Vypnout" : "Zapnout";
|
||||
};
|
||||
|
||||
const renderRoleButtonContent = (): React.ReactNode => {
|
||||
if (saving) {
|
||||
const isPending =
|
||||
createRoleMutation.isPending || updateRoleMutation.isPending;
|
||||
if (isPending) {
|
||||
return (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
@@ -634,7 +634,7 @@ export default function Settings() {
|
||||
{!require2FALoading && (
|
||||
<button
|
||||
onClick={handleToggle2FARequired}
|
||||
disabled={require2FASaving}
|
||||
disabled={toggle2FARoleMutation.isPending}
|
||||
className={`admin-btn admin-btn-sm ${require2FA ? "admin-btn-secondary" : "admin-btn-primary"}`}
|
||||
style={require2FA ? { color: "var(--danger)" } : {}}
|
||||
>
|
||||
@@ -693,9 +693,11 @@ export default function Settings() {
|
||||
<button
|
||||
onClick={handleSaveSystemSettings}
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
disabled={sysSettingsSaving}
|
||||
disabled={saveSystemSettingsMutation.isPending}
|
||||
>
|
||||
{sysSettingsSaving ? "Ukládání..." : "Uložit"}
|
||||
{saveSystemSettingsMutation.isPending
|
||||
? "Ukládání..."
|
||||
: "Uložit"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -852,13 +854,9 @@ export default function Settings() {
|
||||
{activeTab === "system" && (
|
||||
<>
|
||||
{sysSettingsLoading && !sysFormInitialized ? (
|
||||
<Skeleton
|
||||
name="settings-system"
|
||||
loading={sysSettingsLoading && !sysFormInitialized}
|
||||
fixture={<SettingsFixture />}
|
||||
>
|
||||
<div />
|
||||
</Skeleton>
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Section 1: Docházka */}
|
||||
@@ -1346,10 +1344,10 @@ export default function Settings() {
|
||||
>
|
||||
<button
|
||||
onClick={handleSaveSystemSettings}
|
||||
disabled={sysSettingsSaving}
|
||||
disabled={saveSystemSettingsMutation.isPending}
|
||||
className="admin-btn admin-btn-primary w-full"
|
||||
>
|
||||
{sysSettingsSaving ? (
|
||||
{saveSystemSettingsMutation.isPending ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Ukládání...
|
||||
@@ -1368,218 +1366,163 @@ export default function Settings() {
|
||||
{activeTab === "firma" && <CompanySettings embedded />}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<AnimatePresence>
|
||||
{showModal && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-backdrop" onClick={closeModal} />
|
||||
<motion.div
|
||||
className="admin-modal admin-modal-lg"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
<FormModal
|
||||
isOpen={showModal}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleSubmit}
|
||||
title={editingRole ? "Upravit roli" : "Nová role"}
|
||||
size="lg"
|
||||
loading={createRoleMutation.isPending || updateRoleMutation.isPending}
|
||||
>
|
||||
<div className="admin-form">
|
||||
{editingRole && isAdminRole(editingRole) && (
|
||||
<div className="admin-role-locked-notice">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
Administrátor má vždy plný přístup ke všem funkcím
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField label="Zobrazovaný název">
|
||||
<input
|
||||
type="text"
|
||||
value={form.display_name}
|
||||
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
||||
className="admin-form-input"
|
||||
placeholder="např. Manažer"
|
||||
disabled={!!(editingRole && isAdminRole(editingRole))}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Systémový název (slug)">
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="např. manager"
|
||||
disabled={!!editingRole}
|
||||
/>
|
||||
{!editingRole && (
|
||||
<small
|
||||
className="text-xs"
|
||||
style={{
|
||||
color: "var(--text-tertiary)",
|
||||
}}
|
||||
>
|
||||
Pouze malá písmena, čísla a pomlčky. Nelze později změnit.
|
||||
</small>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField label="Popis">
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
description: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="admin-form-input"
|
||||
rows={2}
|
||||
placeholder="Volitelný popis role"
|
||||
disabled={!!(editingRole && isAdminRole(editingRole))}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label
|
||||
className="admin-form-label"
|
||||
style={{ marginBottom: "0.75rem" }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">
|
||||
{editingRole ? "Upravit roli" : "Nová role"}
|
||||
</h2>
|
||||
</div>
|
||||
Oprávnění
|
||||
</label>
|
||||
|
||||
<div className="admin-modal-body">
|
||||
<div className="admin-form">
|
||||
{editingRole && isAdminRole(editingRole) && (
|
||||
<div className="admin-role-locked-notice">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
Administrátor má vždy plný přístup ke všem funkcím
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(permissionGroups)
|
||||
.sort(([a, aPerms], [b, bPerms]) => {
|
||||
if (a === "settings") return 1;
|
||||
if (b === "settings") return -1;
|
||||
const aMin = Math.min(...aPerms.map((p) => p.id));
|
||||
const bMin = Math.min(...bPerms.map((p) => p.id));
|
||||
return aMin - bMin;
|
||||
})
|
||||
.map(([module, perms], index) => {
|
||||
const modulePerms = perms.map((p) => p.name);
|
||||
const allChecked = modulePerms.every((p) =>
|
||||
form.permissions.includes(p),
|
||||
);
|
||||
const someChecked = modulePerms.some((p) =>
|
||||
form.permissions.includes(p),
|
||||
);
|
||||
const disabled = !!(editingRole && isAdminRole(editingRole));
|
||||
|
||||
<FormField label="Zobrazovaný název">
|
||||
<input
|
||||
type="text"
|
||||
value={form.display_name}
|
||||
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
||||
className="admin-form-input"
|
||||
placeholder="např. Manažer"
|
||||
disabled={!!(editingRole && isAdminRole(editingRole))}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Systémový název (slug)">
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
className="admin-form-input"
|
||||
placeholder="např. manager"
|
||||
disabled={!!editingRole}
|
||||
/>
|
||||
{!editingRole && (
|
||||
<small
|
||||
className="text-xs"
|
||||
return (
|
||||
<div key={module}>
|
||||
{index > 0 && (
|
||||
<hr
|
||||
style={{
|
||||
color: "var(--text-tertiary)",
|
||||
border: "none",
|
||||
borderTop: "1px solid var(--border-color, #e0e0e0)",
|
||||
margin: "0.75rem 0",
|
||||
}}
|
||||
>
|
||||
Pouze malá písmena, čísla a pomlčky. Nelze později
|
||||
změnit.
|
||||
</small>
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField label="Popis">
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
description: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="admin-form-input"
|
||||
rows={2}
|
||||
placeholder="Volitelný popis role"
|
||||
disabled={!!(editingRole && isAdminRole(editingRole))}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label
|
||||
className="admin-form-label"
|
||||
style={{ marginBottom: "0.75rem" }}
|
||||
>
|
||||
Oprávnění
|
||||
</label>
|
||||
|
||||
{Object.entries(permissionGroups)
|
||||
.sort(([a, aPerms], [b, bPerms]) => {
|
||||
if (a === "settings") return 1;
|
||||
if (b === "settings") return -1;
|
||||
const aMin = Math.min(...aPerms.map((p) => p.id));
|
||||
const bMin = Math.min(...bPerms.map((p) => p.id));
|
||||
return aMin - bMin;
|
||||
})
|
||||
.map(([module, perms], index) => {
|
||||
const modulePerms = perms.map((p) => p.name);
|
||||
const allChecked = modulePerms.every((p) =>
|
||||
form.permissions.includes(p),
|
||||
);
|
||||
const someChecked = modulePerms.some((p) =>
|
||||
form.permissions.includes(p),
|
||||
);
|
||||
const disabled = !!(
|
||||
editingRole && isAdminRole(editingRole)
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={module}>
|
||||
{index > 0 && (
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop:
|
||||
"1px solid var(--border-color, #e0e0e0)",
|
||||
margin: "0.75rem 0",
|
||||
}}
|
||||
<div className="admin-permission-group">
|
||||
<div className="admin-permission-group-title">
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={(el) => {
|
||||
if (el)
|
||||
el.indeterminate = someChecked && !allChecked;
|
||||
}}
|
||||
onChange={() => toggleModulePermissions(module)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span>{MODULE_LABELS[module] || module}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="admin-permission-list">
|
||||
{perms.map((perm) => (
|
||||
<div key={perm.id} className="admin-permission-item">
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.permissions.includes(perm.name)}
|
||||
onChange={() => togglePermission(perm.name)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span>{perm.display_name}</span>
|
||||
</label>
|
||||
{perm.description && (
|
||||
<div className="admin-permission-desc">
|
||||
{perm.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-permission-group">
|
||||
<div className="admin-permission-group-title">
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={(el) => {
|
||||
if (el)
|
||||
el.indeterminate =
|
||||
someChecked && !allChecked;
|
||||
}}
|
||||
onChange={() =>
|
||||
toggleModulePermissions(module)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span>{MODULE_LABELS[module] || module}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="admin-permission-list">
|
||||
{perms.map((perm) => (
|
||||
<div
|
||||
key={perm.id}
|
||||
className="admin-permission-item"
|
||||
>
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.permissions.includes(
|
||||
perm.name,
|
||||
)}
|
||||
onChange={() =>
|
||||
togglePermission(perm.name)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span>{perm.display_name}</span>
|
||||
</label>
|
||||
{perm.description && (
|
||||
<div className="admin-permission-desc">
|
||||
{perm.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={saving}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
{!(editingRole && isAdminRole(editingRole)) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={saving}
|
||||
>
|
||||
{renderRoleButtonContent()}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
{/* Delete Confirm Modal */}
|
||||
<ConfirmModal
|
||||
@@ -1591,7 +1534,7 @@ export default function Settings() {
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
loading={deleting}
|
||||
loading={deleteRoleMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user