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"; 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 }} />