Files
app/src/admin/pages/Settings.tsx
BOHA 1ee59b54bc chore(release): v2.4.35 — lint cleanup (68→0) + duplicate-Zavřít modal fix
UI fix:
- Close-only modals showing a redundant second close button now use
  hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
  buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.

Lint: cleared all 68 warnings → 0.
- preserve-caught-error: attach { cause } in ai.service / exchange-rates.
- no-require-imports: package.json version read via fs (APP_VERSION) instead
  of require(), avoiding a rootDir-expanding static JSON import.
- react-hooks/exhaustive-deps (11): ref-in-cleanup copies, derived-value
  useMemo wrapping, PlanGrid field extraction, stable nextKey useCallback,
  AuthContext documented cycle-break.
- no-explicit-any (53): precise route param/Prisma types, generic enrich*()
  preserving payload shape, minimal vite module type, frontend body/query-key
  types, SystemInfo for Settings.

Refactor (test enablement): shift-form types moved to dependency-free
shiftFormTypes.ts so the print-HTML builders are unit-testable without the
component graph; characterization test pins their output.

Gates: 649 tests pass, tsc -b clean, lint 0. Verified touched flows live
via Playwright (PlanWork CRUD + optimistic cache, warehouse form keys,
Settings system info, invoice detail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:35:31 +02:00

1417 lines
44 KiB
TypeScript

import { useState, useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Navigate, useSearchParams } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Checkbox from "@mui/material/Checkbox";
import CompanySettings from "./CompanySettings";
import {
companySettingsOptions,
systemInfoOptions,
require2FAOptions,
} from "../lib/queries/settings";
import {
Button,
Card,
Modal,
ConfirmDialog,
DataTable,
Field,
TextField,
Select,
StatusChip,
Tabs,
TabPanel,
LoadingState,
PageEnter,
type DataColumn,
type TabDef,
} from "../ui";
import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api";
import { iconBadgeSx } from "../theme";
const API_BASE = "/api/admin";
const PlusIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const EditIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const DeleteIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
interface SystemSettingsData {
break_threshold_hours: number;
break_duration_short: number;
break_duration_long: number;
clock_rounding_minutes: number;
invoice_alert_email: string;
leave_notify_email: string;
smtp_from: string;
smtp_from_name: string;
max_login_attempts: number;
lockout_minutes: number;
max_requests_per_minute: number;
default_currency: string;
default_vat_rate: number;
available_vat_rates: number[];
available_currencies: string[];
quotation_prefix: string;
order_type_code: string;
invoice_type_code: string;
offer_number_pattern: string;
order_number_pattern: string;
invoice_number_pattern: string;
app_version: string;
}
const MODULE_LABELS: Record<string, string> = {
attendance: "Docházka",
trips: "Kniha jízd",
vehicles: "Vozidla",
offers: "Nabídky",
orders: "Objednávky",
projects: "Projekty",
invoices: "Faktury",
warehouse: "Sklad",
customers: "Zákazníci",
users: "Uživatelé",
settings: "Nastavení",
ai: "AI asistent",
};
// Display order of the permission-module groups in the role modal. Before
// this list the order fell out of DB insertion ids, which differ between dev
// (reseeded) and prod (migration order). Reorder by rearranging this array;
// modules missing from it (e.g. added by a future migration before this list
// is updated) fall to the very end so they stay visible.
const MODULE_ORDER = [
"ai",
"attendance",
"trips",
"vehicles",
"offers",
"orders",
"projects",
"invoices",
"warehouse",
"customers",
"users",
"settings",
];
interface Permission {
id: number;
name: string;
display_name: string;
description?: string;
}
interface Role {
id: number;
name: string;
display_name: string;
description: string | null;
permissions: Permission[];
role_permissions?: unknown[];
user_count?: number;
}
interface RoleForm {
name: string;
display_name: string;
description: string;
permissions: string[];
}
const DEFAULT_SYS_FORM: Omit<SystemSettingsData, "app_version"> = {
break_threshold_hours: 6,
break_duration_short: 15,
break_duration_long: 30,
clock_rounding_minutes: 15,
invoice_alert_email: "",
leave_notify_email: "",
smtp_from: "",
smtp_from_name: "",
max_login_attempts: 5,
lockout_minutes: 15,
max_requests_per_minute: 300,
default_currency: "CZK",
default_vat_rate: 21,
available_vat_rates: [0, 10, 12, 15, 21],
available_currencies: ["CZK", "EUR", "USD", "GBP"],
quotation_prefix: "NA",
order_type_code: "71",
invoice_type_code: "81",
offer_number_pattern: "{YYYY}/{PREFIX}/{NNN}",
order_number_pattern: "{YY}{CODE}{NNNN}",
invoice_number_pattern: "{YY}{CODE}{NNNN}",
};
export default function Settings() {
const alert = useAlert();
const { hasPermission } = useAuth();
const canManageRoles = hasPermission("settings.roles");
const canManageCompany = hasPermission("settings.company");
const canManageSystem = hasPermission("settings.system");
const canManageBanking = hasPermission("settings.banking");
const canAccessSettings =
canManageRoles || canManageCompany || canManageSystem || canManageBanking;
const availableTabs = useMemo(() => {
const tabs: Array<"roles" | "system" | "firma"> = [];
if (canManageRoles) tabs.push("roles");
if (canManageSystem) tabs.push("system");
if (canManageCompany || canManageBanking) tabs.push("firma");
return tabs;
}, [canManageRoles, canManageCompany, canManageSystem, canManageBanking]);
// ── TanStack Query: roles, permissions ──
const { data: rolesData, isPending: rolesLoading } = useQuery({
queryKey: ["settings", "roles"],
queryFn: async () => {
const [rolesRes, permsRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`).then((r) => r.json()),
apiFetch(`${API_BASE}/roles/permissions`).then((r) => r.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)
throw new Error(permsResult.error || "Nepodařilo se načíst oprávnění");
return {
roles: Array.isArray(rolesResult.data) ? rolesResult.data : [],
permissions: Array.isArray(permsResult.data) ? permsResult.data : [],
};
},
enabled: canManageRoles,
});
const roles = rolesData?.roles ?? [];
// Group permissions by module
const permissionGroups = useMemo<Record<string, Permission[]>>(() => {
const perms: Permission[] = rolesData?.permissions ?? [];
const groups: Record<string, Permission[]> = {};
for (const p of perms) {
const mod = p.name.split(".")[0] || "other";
if (!groups[mod]) groups[mod] = [];
groups[mod].push(p);
}
return groups;
}, [rolesData?.permissions]);
// ── TanStack Query: 2FA required ──
const { data: totpData, isPending: require2FALoading } =
useQuery(require2FAOptions());
const require2FA = totpData?.require_2fa ?? false;
// ── TanStack Query: system settings (lazy, only on system/roles tab) ──
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get("tab");
const defaultTab = availableTabs[0] ?? "roles";
const activeTab = (
tabParam === "roles" && availableTabs.includes("roles")
? "roles"
: tabParam === "system" && availableTabs.includes("system")
? "system"
: tabParam === "firma" && availableTabs.includes("firma")
? "firma"
: defaultTab
) as "roles" | "system" | "firma";
const setActiveTab = (tab: "roles" | "system" | "firma") =>
setSearchParams({ tab }, { replace: true });
const { data: sysSettingsData, isPending: sysSettingsLoading } = useQuery({
...companySettingsOptions(),
enabled:
(canManageRoles || canManageSystem) &&
(activeTab === "system" || activeTab === "roles"),
});
const { data: systemInfo } = useQuery({
...systemInfoOptions(),
enabled: canManageSystem && activeTab === "system",
});
// ── Local state ──
const [showModal, setShowModal] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [form, setForm] = useState<RoleForm>({
name: "",
display_name: "",
description: "",
permissions: [],
});
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
role: Role | null;
}>({ show: false, role: null });
const [sysForm, setSysForm] = useState(DEFAULT_SYS_FORM);
const [sysFormInitialized, setSysFormInitialized] = useState(false);
// ── Populate sysForm from query data ──
useEffect(() => {
if (!sysSettingsData || sysFormInitialized) return;
setSysForm({
break_threshold_hours: sysSettingsData.break_threshold_hours ?? 6,
break_duration_short: sysSettingsData.break_duration_short ?? 15,
break_duration_long: sysSettingsData.break_duration_long ?? 30,
clock_rounding_minutes: sysSettingsData.clock_rounding_minutes ?? 15,
invoice_alert_email: sysSettingsData.invoice_alert_email || "",
leave_notify_email: sysSettingsData.leave_notify_email || "",
smtp_from: sysSettingsData.smtp_from || "",
smtp_from_name: sysSettingsData.smtp_from_name || "",
max_login_attempts: sysSettingsData.max_login_attempts ?? 5,
lockout_minutes: sysSettingsData.lockout_minutes ?? 15,
max_requests_per_minute: sysSettingsData.max_requests_per_minute ?? 300,
default_currency: sysSettingsData.default_currency || "CZK",
default_vat_rate: sysSettingsData.default_vat_rate ?? 21,
available_vat_rates:
Array.isArray(sysSettingsData.available_vat_rates) &&
sysSettingsData.available_vat_rates.length > 0
? sysSettingsData.available_vat_rates
: [0, 10, 12, 15, 21],
available_currencies:
Array.isArray(sysSettingsData.available_currencies) &&
sysSettingsData.available_currencies.length > 0
? sysSettingsData.available_currencies
: ["CZK", "EUR", "USD", "GBP"],
quotation_prefix: sysSettingsData.quotation_prefix || "NA",
order_type_code: sysSettingsData.order_type_code || "71",
invoice_type_code: sysSettingsData.invoice_type_code || "81",
offer_number_pattern:
sysSettingsData.offer_number_pattern || "{YYYY}/{PREFIX}/{NNN}",
order_number_pattern:
sysSettingsData.order_number_pattern || "{YY}{CODE}{NNNN}",
invoice_number_pattern:
sysSettingsData.invoice_number_pattern || "{YY}{CODE}{NNNN}",
});
setSysFormInitialized(true);
}, [sysSettingsData, sysFormInitialized]);
const saveSystemSettingsMutation = useApiMutation<
Partial<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");
},
});
// ── Early return after all hooks ──
if (!canAccessSettings) {
return <Navigate to="/" replace />;
}
const handleSaveSystemSettings = async () => {
try {
// Send ONLY the fields this tab edits. sysForm also carries
// numbering/currency fields captured once at init (sysFormInitialized
// never resets), and the backend applies every defined field — sending
// the whole form would silently revert a Firma-tab numbering edit made
// in the meantime with stale values.
await saveSystemSettingsMutation.mutateAsync({
break_threshold_hours: sysForm.break_threshold_hours,
break_duration_short: sysForm.break_duration_short,
break_duration_long: sysForm.break_duration_long,
clock_rounding_minutes: sysForm.clock_rounding_minutes,
invoice_alert_email: sysForm.invoice_alert_email,
leave_notify_email: sysForm.leave_notify_email,
smtp_from: sysForm.smtp_from,
smtp_from_name: sysForm.smtp_from_name,
max_login_attempts: sysForm.max_login_attempts,
lockout_minutes: sysForm.lockout_minutes,
max_requests_per_minute: sysForm.max_requests_per_minute,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleToggle2FARequired = async () => {
try {
await toggle2FARoleMutation.mutateAsync({ required: !require2FA });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const generateSlug = (text: string): string => {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
};
const openCreateModal = () => {
setEditingRole(null);
setForm({ name: "", display_name: "", description: "", permissions: [] });
setShowModal(true);
};
const openEditModal = (role: Role) => {
setEditingRole(role);
setForm({
name: role.name,
display_name: role.display_name,
description: role.description || "",
permissions: (role.permissions || []).map((p) =>
typeof p === "string" ? p : p.name,
),
});
setShowModal(true);
};
const closeModal = () => {
setShowModal(false);
setEditingRole(null);
};
const handleDisplayNameChange = (value: string) => {
const updates: Partial<RoleForm> = { display_name: value };
if (!editingRole) {
updates.name = generateSlug(value);
}
setForm((prev) => ({ ...prev, ...updates }));
};
const togglePermission = (permName: string) => {
setForm((prev) => ({
...prev,
permissions: prev.permissions.includes(permName)
? prev.permissions.filter((p) => p !== permName)
: [...prev.permissions, permName],
}));
};
const toggleModulePermissions = (moduleName: string) => {
const modulePerms = (permissionGroups[moduleName] || []).map((p) => p.name);
const allChecked = modulePerms.every((p) => form.permissions.includes(p));
setForm((prev) => ({
...prev,
permissions: allChecked
? prev.permissions.filter((p) => !modulePerms.includes(p))
: [...new Set([...prev.permissions, ...modulePerms])],
}));
};
const handleSubmit = async () => {
if (!form.display_name.trim()) {
alert.error("Zobrazovaný název je povinný");
return;
}
if (!editingRole && !form.name.trim()) {
alert.error("Název role je povinný");
return;
}
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 {
if (editingRole) {
await updateRoleMutation.mutateAsync({
id: editingRole.id,
display_name: form.display_name,
description: form.description,
permission_ids,
});
} else {
await createRoleMutation.mutateAsync({
name: form.name,
display_name: form.display_name,
description: form.description,
permission_ids,
});
}
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleDelete = async () => {
if (!deleteConfirm.role) return;
try {
await deleteRoleMutation.mutateAsync(deleteConfirm.role.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
if (canManageRoles && rolesLoading) {
return <LoadingState />;
}
const isAdminRole = (role: Role) => role.name === "admin";
const get2FADescription = (): React.ReactNode => {
if (require2FALoading) {
return <LoadingState />;
}
if (require2FA)
return "Všichni uživatelé musí mít aktivní 2FA pro přístup do systému";
return "2FA je volitelná - uživatelé si ji mohou aktivovat v profilu";
};
const get2FAButtonLabel = (): string => {
if (toggle2FARoleMutation.isPending) return "Ukládání...";
return require2FA ? "Vypnout" : "Zapnout";
};
const tabDefs: TabDef[] = [
...(availableTabs.includes("roles")
? [{ value: "roles", label: "Role" }]
: []),
...(availableTabs.includes("system")
? [{ value: "system", label: "Systémová nastavení" }]
: []),
...(availableTabs.includes("firma")
? [{ value: "firma", label: "Firma" }]
: []),
];
const roleColumns: DataColumn<Role>[] = [
{
key: "name",
header: "Název",
width: "26%",
render: (role) => (
<Box>
<Box sx={{ fontWeight: 500, color: "text.primary" }}>
{role.display_name}
</Box>
<Box sx={{ fontSize: "11px", color: "text.disabled" }}>
{role.name}
</Box>
</Box>
),
},
{
key: "description",
header: "Popis",
width: "30%",
render: (role) => (
<Box sx={{ color: "text.secondary" }}>{role.description || "—"}</Box>
),
},
{
key: "permissions",
header: "Oprávnění",
width: "14%",
render: (role) => (
<StatusChip
label={
isAdminRole(role) ? "Vše" : String(role.permissions?.length ?? 0)
}
color="info"
/>
),
},
{
key: "user_count",
header: "Uživatelé",
width: "14%",
render: (role) => (
<StatusChip label={String(role.user_count ?? 0)} color="default" />
),
},
{
key: "actions",
header: "Akce",
width: "16%",
align: "right",
render: (role) =>
isAdminRole(role) ? null : (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => openEditModal(role)}
title="Upravit"
aria-label="Upravit"
>
{EditIcon}
</IconButton>
{/* Disabled buttons don't fire title/tooltip, so wrap in a titled
span when delete is blocked (role still has users). */}
<Box
component="span"
title={
(role.user_count ?? 0) > 0
? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat"
}
>
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, role })}
aria-label={
(role.user_count ?? 0) > 0
? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat"
}
disabled={(role.user_count ?? 0) > 0}
>
{DeleteIcon}
</IconButton>
</Box>
</Box>
),
},
];
const renderSystemInfo = () => {
if (!systemInfo) {
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, py: 1 }}>
<Box
sx={{
height: 12,
bgcolor: "action.hover",
borderRadius: 1,
width: "60%",
}}
/>
<Box
sx={{
height: 12,
bgcolor: "action.hover",
borderRadius: 1,
width: "40%",
}}
/>
</Box>
);
}
type NasInfo = { configured?: boolean; path?: string | null };
type SystemInfo = {
app_version?: string;
node_version?: string;
platform?: string;
uptime?: string;
environment?: string;
timezone?: string;
memory?: {
rss?: string;
heap_used?: string;
heap_total?: string;
system_free?: string;
system_total?: string;
};
database?: { status?: string; migrations_applied?: number | string };
nas?: Record<string, NasInfo | undefined>;
};
const si = systemInfo as SystemInfo;
const Row = ({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) => (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
py: 0.5,
fontSize: "0.85rem",
}}
>
<Box sx={{ width: 160, flexShrink: 0, color: "text.secondary" }}>
{label}
</Box>
<Box>{children}</Box>
</Box>
);
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
<Typography
variant="caption"
sx={{
display: "block",
mt: 1.25,
mb: 0.5,
fontWeight: 600,
color: "text.disabled",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{children}
</Typography>
);
return (
<Box>
<Row label="Verze">
<Box sx={{ fontWeight: 500 }}>{si.app_version}</Box>
</Row>
<Row label="Node.js">
<Box sx={{ fontWeight: 500 }}>{si.node_version}</Box>
</Row>
<Row label="Platforma">
<Box sx={{ fontWeight: 500 }}>{si.platform}</Box>
</Row>
<Row label="Uptime">
<Box sx={{ fontWeight: 500 }}>{si.uptime}</Box>
</Row>
<Row label="Prostředí">
<Box sx={{ fontWeight: 500 }}>{si.environment}</Box>
</Row>
<Row label="Časová zóna">
<Box sx={{ fontWeight: 500 }}>{si.timezone}</Box>
</Row>
<SectionTitle>Paměť</SectionTitle>
<Row label="Proces (RSS)">{si.memory?.rss}</Row>
<Row label="Heap">
{`${si.memory?.heap_used} / ${si.memory?.heap_total}`}
</Row>
<Row label="Systém">
{`${si.memory?.system_free} volné z ${si.memory?.system_total}`}
</Row>
<SectionTitle>Databáze</SectionTitle>
<Row label="Stav">
<StatusChip
label={si.database?.status === "ok" ? "Připojeno" : "Chyba"}
color={si.database?.status === "ok" ? "success" : "error"}
/>
</Row>
<Row label="Migrace">{si.database?.migrations_applied}</Row>
<SectionTitle>NAS úložiště</SectionTitle>
{(
[
["Projekty", si.nas?.projects],
["Faktury", si.nas?.invoices],
["Objednávky", si.nas?.orders],
["Nabídky", si.nas?.offers],
] as [string, NasInfo | undefined][]
).map(([label, info]) => (
<Row key={label} label={label}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<StatusChip
label={info?.configured ? "Připojeno" : "Nenakonfigurováno"}
color={info?.configured ? "success" : "default"}
/>
{info?.configured && (
<Typography variant="caption" color="text.disabled">
{info.path}
</Typography>
)}
</Box>
</Row>
))}
</Box>
);
};
return (
<PageEnter>
<Box sx={{ mb: 3 }}>
<Typography variant="h4">Nastavení</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{activeTab === "system"
? "Systémová nastavení"
: activeTab === "firma"
? "Informace o firmě"
: "Role"}
</Typography>
</Box>
{tabDefs.length > 0 && (
<Tabs
value={activeTab}
onChange={(v) => setActiveTab(v as "roles" | "system" | "firma")}
tabs={tabDefs}
/>
)}
{/* Roles tab */}
<TabPanel value="roles" current={activeTab}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="h6">Role</Typography>
<Button startIcon={PlusIcon} size="small" onClick={openCreateModal}>
Přidat roli
</Button>
</Box>
<Card>
<DataTable<Role>
columns={roleColumns}
rows={roles}
rowKey={(role) => role.id}
/>
</Card>
</TabPanel>
{/* System settings tab */}
<TabPanel value="system" current={activeTab}>
{canManageSystem && (
<Box sx={{ mb: "1.5rem" }}>
<Card>
<Typography variant="h6" sx={{ mb: 2 }}>
Zabezpečení
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={[
{
width: 36,
height: 36,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
iconBadgeSx(require2FA ? "success" : "default"),
]}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</Box>
<Box>
<Typography
variant="body1"
sx={{ fontWeight: 500, color: "text.primary" }}
>
Povinné dvoufaktorové ověření (2FA)
</Typography>
<Typography variant="caption" color="text.secondary">
{get2FADescription()}
</Typography>
</Box>
</Box>
{!require2FALoading && (
<Button
size="small"
variant={require2FA ? "outlined" : "contained"}
color={require2FA ? "error" : "primary"}
onClick={handleToggle2FARequired}
disabled={toggle2FARoleMutation.isPending}
>
{get2FAButtonLabel()}
</Button>
)}
</Box>
</Card>
</Box>
)}
{canManageSystem && (
<Box sx={{ mb: "1.5rem" }}>
<Card>
<Typography variant="h6" sx={{ mb: 2 }}>
Přihlašování
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Max. pokusů o přihlášení">
<TextField
type="number"
value={sysForm.max_login_attempts}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
max_login_attempts: Number(e.target.value),
}))
}
inputProps={{ min: 1 }}
/>
</Field>
<Field label="Doba uzamčení účtu (minuty)">
<TextField
type="number"
value={sysForm.lockout_minutes}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
lockout_minutes: Number(e.target.value),
}))
}
inputProps={{ min: 1 }}
/>
</Field>
</Box>
<Box>
<Button
size="small"
onClick={handleSaveSystemSettings}
disabled={saveSystemSettingsMutation.isPending}
>
{saveSystemSettingsMutation.isPending
? "Ukládání..."
: "Uložit"}
</Button>
</Box>
</Card>
</Box>
)}
{sysSettingsLoading && !sysFormInitialized ? (
<LoadingState />
) : (
<>
{/* Section 1: Docházka */}
<Box sx={{ mb: "1.5rem" }}>
<Card>
<Typography variant="h6" sx={{ mb: 2 }}>
Docházka
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Limit pro automatickou přestávku (hodiny)">
<TextField
type="number"
value={sysForm.break_threshold_hours}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
break_threshold_hours: Number(e.target.value),
}))
}
inputProps={{ min: 0, step: 0.5 }}
/>
</Field>
<Field label="Krátká přestávka (minuty)">
<TextField
type="number"
value={sysForm.break_duration_short}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
break_duration_short: Number(e.target.value),
}))
}
inputProps={{ min: 0 }}
/>
</Field>
<Field label="Dlouhá přestávka (minuty)">
<TextField
type="number"
value={sysForm.break_duration_long}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
break_duration_long: Number(e.target.value),
}))
}
inputProps={{ min: 0 }}
/>
</Field>
<Field label="Zaokrouhlování času (minuty)">
<Select
value={String(sysForm.clock_rounding_minutes)}
onChange={(v) =>
setSysForm((prev) => ({
...prev,
clock_rounding_minutes: Number(v),
}))
}
options={[
{ value: "5", label: "5" },
{ value: "10", label: "10" },
{ value: "15", label: "15" },
{ value: "30", label: "30" },
]}
/>
</Field>
</Box>
</Card>
</Box>
{/* Section 2: Emailové notifikace */}
<Box sx={{ mb: "1.5rem" }}>
<Card>
<Typography variant="h6" sx={{ mb: 2 }}>
Emailové notifikace
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Odesílatel (email)">
<TextField
type="email"
value={sysForm.smtp_from}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
smtp_from: e.target.value,
}))
}
placeholder="noreply@firma.cz"
/>
</Field>
<Field label="Odesílatel (jméno)">
<TextField
value={sysForm.smtp_from_name}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
smtp_from_name: e.target.value,
}))
}
placeholder=""
/>
</Field>
<Field label="Email pro upozornění na faktury">
<TextField
type="email"
value={sysForm.invoice_alert_email}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
invoice_alert_email: e.target.value,
}))
}
placeholder="fakturace@firma.cz"
/>
</Field>
<Field label="Email pro notifikace dovolené">
<TextField
type="email"
value={sysForm.leave_notify_email}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
leave_notify_email: e.target.value,
}))
}
placeholder="hr@firma.cz"
/>
</Field>
</Box>
</Card>
</Box>
{/* Section 4: Omezení požadavků */}
<Box sx={{ mb: "1.5rem" }}>
<Card>
<Typography variant="h6" sx={{ mb: 2 }}>
Omezení požadavků
</Typography>
<Field
label="Max. požadavků za minutu"
hint="Změna se projeví po restartu serveru"
>
<TextField
type="number"
value={sysForm.max_requests_per_minute}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
max_requests_per_minute: Number(e.target.value),
}))
}
inputProps={{ min: 1 }}
/>
</Field>
</Card>
</Box>
{/* Section 5: Informace o aplikaci */}
<Box sx={{ mb: "1.5rem" }}>
<Card>
<Typography variant="h6" sx={{ mb: 2 }}>
Informace o aplikaci
</Typography>
{renderSystemInfo()}
</Card>
</Box>
{/* Save button */}
<Button
fullWidth
onClick={handleSaveSystemSettings}
disabled={saveSystemSettingsMutation.isPending}
>
{saveSystemSettingsMutation.isPending ? "Ukládání..." : "Uložit"}
</Button>
</>
)}
</TabPanel>
{/* Firma tab */}
<TabPanel value="firma" current={activeTab}>
<CompanySettings embedded />
</TabPanel>
{/* Create/Edit Modal */}
<Modal
isOpen={showModal}
onClose={closeModal}
onSubmit={handleSubmit}
title={editingRole ? "Upravit roli" : "Nová role"}
submitText={editingRole ? "Uložit změny" : "Vytvořit roli"}
maxWidth="lg"
loading={createRoleMutation.isPending || updateRoleMutation.isPending}
>
{editingRole && isAdminRole(editingRole) && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
mb: 2,
p: 1.5,
borderRadius: 1,
bgcolor: "info.light",
color: "info.dark",
fontSize: "0.85rem",
}}
>
<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 vždy plný přístup ke všem funkcím
</Box>
)}
<Field label="Zobrazovaný název">
<TextField
value={form.display_name}
onChange={(e) => handleDisplayNameChange(e.target.value)}
placeholder="např. Manažer"
disabled={!!(editingRole && isAdminRole(editingRole))}
/>
</Field>
<Field
label="Systémový název (slug)"
hint={
!editingRole
? "Pouze malá písmena, čísla a pomlčky. Nelze později změnit."
: undefined
}
>
<TextField
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="např. manager"
disabled={!!editingRole}
/>
</Field>
<Field label="Popis">
<TextField
multiline
minRows={2}
value={form.description}
onChange={(e) =>
setForm((prev) => ({
...prev,
description: e.target.value,
}))
}
placeholder="Volitelný popis role"
disabled={!!(editingRole && isAdminRole(editingRole))}
/>
</Field>
<Box>
<Typography
variant="body2"
sx={{ fontWeight: 600, color: "text.secondary", mb: 1.5 }}
>
Oprávnění
</Typography>
{Object.entries(permissionGroups)
.sort(([a, aPerms], [b, bPerms]) => {
const ai = MODULE_ORDER.indexOf(a);
const bi = MODULE_ORDER.indexOf(b);
const aKey = ai === -1 ? MODULE_ORDER.length : ai;
const bKey = bi === -1 ? MODULE_ORDER.length : bi;
if (aKey !== bKey) return aKey - bKey;
// Both unknown: stable fallback by DB insertion order.
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 (
<Box key={module}>
{index > 0 && (
<Box
sx={{
borderTop: 1,
borderColor: "divider",
my: 1.5,
}}
/>
)}
<Box>
<Box
component="label"
sx={{
display: "flex",
alignItems: "center",
cursor: disabled ? "default" : "pointer",
fontWeight: 600,
}}
>
<Checkbox
checked={allChecked}
indeterminate={someChecked && !allChecked}
onChange={() => toggleModulePermissions(module)}
disabled={disabled}
sx={{ p: 0.5, mr: 0.5 }}
/>
<span>{MODULE_LABELS[module] || module}</span>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "1fr 1fr",
},
gap: 0.5,
pl: 3,
}}
>
{perms.map((perm) => (
<Box key={perm.id}>
<Box
component="label"
sx={{
display: "flex",
alignItems: "center",
cursor: disabled ? "default" : "pointer",
}}
>
<Checkbox
checked={form.permissions.includes(perm.name)}
onChange={() => togglePermission(perm.name)}
disabled={disabled}
sx={{ p: 0.5, mr: 0.5 }}
/>
<Typography variant="body2">
{perm.display_name}
</Typography>
</Box>
{perm.description && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", pl: 4 }}
>
{perm.description}
</Typography>
)}
</Box>
))}
</Box>
</Box>
</Box>
);
})}
</Box>
</Modal>
{/* Delete Confirm Modal */}
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, role: null })}
onConfirm={handleDelete}
title="Smazat roli"
message={`Opravdu chcete smazat roli "${deleteConfirm.role?.display_name}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
confirmVariant="danger"
loading={deleteRoleMutation.isPending}
/>
</PageEnter>
);
}