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 { motion } from "framer-motion"; import ConfirmModal from "../components/ConfirmModal"; import FormModal from "../components/FormModal"; import FormField from "../components/FormField"; import CompanySettings from "./CompanySettings"; import { companySettingsOptions, systemInfoOptions, require2FAOptions, } from "../lib/queries/settings"; import { useApiMutation } from "../lib/queries/mutations"; import apiFetch from "../utils/api"; const API_BASE = "/api/admin"; 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 (e?: React.FormEvent) => { e?.preventDefault(); 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 renderRoleButtonContent = (): React.ReactNode => { const isPending = createRoleMutation.isPending || updateRoleMutation.isPending; if (isPending) { return ( <>
Ukládání... ); } return editingRole ? "Uložit změny" : "Vytvořit roli"; }; return (

Nastavení

{activeTab === "system" ? "Systémová nastavení" : activeTab === "firma" ? "Informace o firmě" : "Role"}

{availableTabs.length > 0 && (
{availableTabs.includes("roles") && ( )} {availableTabs.includes("system") && ( )} {availableTabs.includes("firma") && ( )}
)} {/* Security Settings */} {activeTab === "system" && canManageSystem && (

Zabezpečení

Povinné dvoufaktorové ověření (2FA)
{get2FADescription()}
{!require2FALoading && ( )}
)} {/* Login Security */} {activeTab === "system" && canManageSystem && (

Přihlašování

setSysForm((prev) => ({ ...prev, max_login_attempts: Number(e.target.value), })) } className="admin-form-input" min={1} /> setSysForm((prev) => ({ ...prev, lockout_minutes: Number(e.target.value), })) } className="admin-form-input" min={1} />
)} {/* Roles Table */} {activeTab === "roles" && (

Role

{roles.map((role: Role) => ( ))}
Název Popis Oprávnění Uživatelé Akce
{role.display_name}
{role.name}
{role.description || "—"} {isAdminRole(role) ? "Vše" : (role.permissions?.length ?? 0)} {role.user_count ?? 0} {!isAdminRole(role) && (
)}
)} {/* System Settings Tab */} {activeTab === "system" && ( <> {sysSettingsLoading && !sysFormInitialized ? (
) : ( <> {/* Section 1: Docházka */}

Docházka

setSysForm((prev) => ({ ...prev, break_threshold_hours: Number(e.target.value), })) } className="admin-form-input" min={0} step={0.5} /> setSysForm((prev) => ({ ...prev, break_duration_short: Number(e.target.value), })) } className="admin-form-input" min={0} />
setSysForm((prev) => ({ ...prev, break_duration_long: Number(e.target.value), })) } className="admin-form-input" min={0} />
{/* Section 2: Emailové notifikace */}

Emailové notifikace

setSysForm((prev) => ({ ...prev, smtp_from: e.target.value, })) } className="admin-form-input" placeholder="noreply@firma.cz" /> setSysForm((prev) => ({ ...prev, smtp_from_name: e.target.value, })) } className="admin-form-input" placeholder="" />
setSysForm((prev) => ({ ...prev, invoice_alert_email: e.target.value, })) } className="admin-form-input" placeholder="fakturace@firma.cz" /> setSysForm((prev) => ({ ...prev, leave_notify_email: e.target.value, })) } className="admin-form-input" placeholder="hr@firma.cz" />
{/* Section 4: Omezení požadavků */}

Omezení požadavků

setSysForm((prev) => ({ ...prev, max_requests_per_minute: Number(e.target.value), })) } className="admin-form-input" min={1} /> Změna se projeví po restartu serveru
{/* Section 5: Informace o aplikaci */}

Informace o aplikaci

{systemInfo ? ( {( [ [ "Verze", (systemInfo as Record) .app_version, ], [ "Node.js", (systemInfo as Record) .node_version, ], [ "Platforma", (systemInfo as Record).platform, ], [ "Uptime", (systemInfo as Record).uptime, ], [ "Prostředí", (systemInfo as Record) .environment, ], [ "Časová zóna", (systemInfo as Record).timezone, ], ] as [string, string][] ).map(([label, val]) => ( ))} {( [ [ "Proces (RSS)", ( systemInfo as Record< string, Record > ).memory?.rss as string, ], [ "Heap", `${(systemInfo as Record>).memory?.heap_used} / ${(systemInfo as Record>).memory?.heap_total}`, ], [ "Systém", `${(systemInfo as Record>).memory?.system_free} volné z ${(systemInfo as Record>).memory?.system_total}`, ], ] as [string, string][] ).map(([label, val]) => ( ))} {( [ [ "Projekty", ( systemInfo as Record< string, Record> > ).nas?.projects, ], [ "Finance", ( systemInfo as Record< string, Record> > ).nas?.financials, ], [ "Nabídky", ( systemInfo as Record< string, Record> > ).nas?.offers, ], ] as [string, Record][] ).map(([label, info]) => ( ))}
{label} {val}
Paměť
{label} {val}
Databáze
Stav >).database?.status === "ok" ? "admin-badge-success" : "admin-badge-danger"}`} > {( systemInfo as Record< string, Record > ).database?.status === "ok" ? "Připojeno" : "Chyba"}
Migrace { ( systemInfo as Record< string, Record > ).database?.migrations_applied as string }
NAS úložiště
{label} {info?.configured ? "Připojeno" : "Nenakonfigurováno"} {info?.configured && ( {info.path} )}
) : (
)}
{/* Save button */} )} )} {/* Firma tab */} {activeTab === "firma" && } {/* Create/Edit Modal */}
{editingRole && isAdminRole(editingRole) && (
Administrátor má vždy plný přístup ke všem funkcím
)} handleDisplayNameChange(e.target.value)} className="admin-form-input" placeholder="např. Manažer" disabled={!!(editingRole && isAdminRole(editingRole))} /> setForm((prev) => ({ ...prev, name: e.target.value })) } className="admin-form-input" placeholder="např. manager" disabled={!!editingRole} /> {!editingRole && ( Pouze malá písmena, čísla a pomlčky. Nelze později změnit. )}