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 = (
);
const EditIcon = (
);
const DeleteIcon = (
);
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 = {
attendance: "Docházka",
trips: "Kniha jízd",
vehicles: "Vozidla",
offers: "Nabídky",
orders: "Objednávky",
projects: "Projekty",
invoices: "Faktury",
customers: "Zákazníci",
users: "Uživatelé",
settings: "Nastavení",
};
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 = {
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>(() => {
const perms: Permission[] = rolesData?.permissions ?? [];
const groups: Record = {};
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(null);
const [form, setForm] = useState({
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]);
// ── Early return after all hooks ──
if (!canAccessSettings) {
return ;
}
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 () => {
try {
await saveSystemSettingsMutation.mutateAsync(sysForm);
} 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 = { 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 ;
}
const isAdminRole = (role: Role) => role.name === "admin";
const get2FADescription = (): React.ReactNode => {
if (require2FALoading) {
return ;
}
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[] = [
{
key: "name",
header: "Název",
width: "26%",
render: (role) => (
{role.display_name}
{role.name}
),
},
{
key: "description",
header: "Popis",
width: "30%",
render: (role) => (
{role.description || "—"}
),
},
{
key: "permissions",
header: "Oprávnění",
width: "14%",
render: (role) => (
),
},
{
key: "user_count",
header: "Uživatelé",
width: "14%",
render: (role) => (
),
},
{
key: "actions",
header: "Akce",
width: "16%",
align: "right",
render: (role) =>
isAdminRole(role) ? null : (
openEditModal(role)}
title="Upravit"
aria-label="Upravit"
>
{EditIcon}
{/* Disabled buttons don't fire title/tooltip, so wrap in a titled
span when delete is blocked (role still has users). */}
0
? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat"
}
>
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}
),
},
];
const renderSystemInfo = () => {
if (!systemInfo) {
return (
);
}
const si = systemInfo as Record;
const Row = ({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) => (
{label}
{children}
);
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
{children}
);
return (
{si.app_version}
{si.node_version}
{si.platform}
{si.uptime}
{si.environment}
{si.timezone}
Paměť
{si.memory?.rss}
{`${si.memory?.heap_used} / ${si.memory?.heap_total}`}
{`${si.memory?.system_free} volné z ${si.memory?.system_total}`}
Databáze
{si.database?.migrations_applied}
NAS úložiště
{(
[
["Projekty", si.nas?.projects],
["Finance", si.nas?.financials],
["Nabídky", si.nas?.offers],
] as [string, Record][]
).map(([label, info]) => (
{info?.configured && (
{info.path}
)}
))}
);
};
return (
Nastavení
{activeTab === "system"
? "Systémová nastavení"
: activeTab === "firma"
? "Informace o firmě"
: "Role"}
{tabDefs.length > 0 && (
setActiveTab(v as "roles" | "system" | "firma")}
tabs={tabDefs}
/>
)}
{/* Roles tab */}
Role
columns={roleColumns}
rows={roles}
rowKey={(role) => role.id}
/>
{/* System settings tab */}
{canManageSystem && (
Zabezpečení
Povinné dvoufaktorové ověření (2FA)
{get2FADescription()}
{!require2FALoading && (
)}
)}
{canManageSystem && (
Přihlašování
setSysForm((prev) => ({
...prev,
max_login_attempts: Number(e.target.value),
}))
}
inputProps={{ min: 1 }}
/>
setSysForm((prev) => ({
...prev,
lockout_minutes: Number(e.target.value),
}))
}
inputProps={{ min: 1 }}
/>
)}
{sysSettingsLoading && !sysFormInitialized ? (
) : (
<>
{/* Section 1: Docházka */}
Docházka
setSysForm((prev) => ({
...prev,
break_threshold_hours: Number(e.target.value),
}))
}
inputProps={{ min: 0, step: 0.5 }}
/>
setSysForm((prev) => ({
...prev,
break_duration_short: Number(e.target.value),
}))
}
inputProps={{ min: 0 }}
/>
setSysForm((prev) => ({
...prev,
break_duration_long: Number(e.target.value),
}))
}
inputProps={{ min: 0 }}
/>
{/* Section 2: Emailové notifikace */}
Emailové notifikace
setSysForm((prev) => ({
...prev,
smtp_from: e.target.value,
}))
}
placeholder="noreply@firma.cz"
/>
setSysForm((prev) => ({
...prev,
smtp_from_name: e.target.value,
}))
}
placeholder=""
/>
setSysForm((prev) => ({
...prev,
invoice_alert_email: e.target.value,
}))
}
placeholder="fakturace@firma.cz"
/>
setSysForm((prev) => ({
...prev,
leave_notify_email: e.target.value,
}))
}
placeholder="hr@firma.cz"
/>
{/* Section 4: Omezení požadavků */}
Omezení požadavků
setSysForm((prev) => ({
...prev,
max_requests_per_minute: Number(e.target.value),
}))
}
inputProps={{ min: 1 }}
/>
{/* Section 5: Informace o aplikaci */}
Informace o aplikaci
{renderSystemInfo()}
{/* Save button */}
>
)}
{/* Firma tab */}
{/* Create/Edit Modal */}
{editingRole && isAdminRole(editingRole) && (
Administrátor má vždy plný přístup ke všem funkcím
)}
handleDisplayNameChange(e.target.value)}
placeholder="např. Manažer"
disabled={!!(editingRole && isAdminRole(editingRole))}
/>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="např. manager"
disabled={!!editingRole}
/>
setForm((prev) => ({
...prev,
description: e.target.value,
}))
}
placeholder="Volitelný popis role"
disabled={!!(editingRole && isAdminRole(editingRole))}
/>
Oprávnění
{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 (
{index > 0 && (
)}
toggleModulePermissions(module)}
disabled={disabled}
sx={{ p: 0.5, mr: 0.5 }}
/>
{MODULE_LABELS[module] || module}
{perms.map((perm) => (
togglePermission(perm.name)}
disabled={disabled}
sx={{ p: 0.5, mr: 0.5 }}
/>
{perm.display_name}
{perm.description && (
{perm.description}
)}
))}
);
})}
{/* Delete Confirm Modal */}
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}
/>
);
}