import { useState, useEffect, useCallback } from "react"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import { useNavigate, Navigate, useSearchParams } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; import ConfirmModal from "../components/ConfirmModal"; import FormField from "../components/FormField"; import useModalLock from "../hooks/useModalLock"; import CompanySettings from "./CompanySettings"; 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", offers: "Nabídky", orders: "Objednávky", projects: "Projekty", invoices: "Faktury", 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[]; } interface RoleForm { name: string; display_name: string; description: string; permissions: string[]; } export default function Settings() { const alert = useAlert(); const { hasPermission } = useAuth(); const navigate = useNavigate(); const [loading, setLoading] = useState(true); const [roles, setRoles] = useState([]); const [, setAllPermissions] = useState([]); const [permissionGroups, setPermissionGroups] = useState< Record >({}); const [require2FA, setRequire2FA] = useState(false); const [require2FALoading, setRequire2FALoading] = useState(true); const [require2FASaving, setRequire2FASaving] = useState(false); const [showModal, setShowModal] = useState(false); const [editingRole, setEditingRole] = useState(null); const [saving, setSaving] = useState(false); const [form, setForm] = useState({ name: "", display_name: "", description: "", permissions: [], }); const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; role: Role | null; }>({ show: false, role: null }); const [deleting, setDeleting] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); const tabParam = searchParams.get("tab"); const activeTab = ( tabParam === "system" ? "system" : tabParam === "firma" ? "firma" : "security" ) as "security" | "system" | "firma"; const setActiveTab = (tab: "security" | "system" | "firma") => setSearchParams({ tab }, { replace: true }); const [sysSettings, setSysSettings] = useState( null, ); const [sysSettingsLoading, setSysSettingsLoading] = useState(false); const [sysSettingsSaving, setSysSettingsSaving] = useState(false); const [systemInfo, setSystemInfo] = useState | null>( null, ); const [sysForm, setSysForm] = useState< 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}", }); const canManage = hasPermission("settings.manage"); if (!canManage) { return ; } useModalLock(showModal); const fetchData = useCallback(async () => { if (!canManage) { setLoading(false); return; } try { const [rolesRes, permsRes] = await Promise.all([ apiFetch(`${API_BASE}/roles`), apiFetch(`${API_BASE}/roles/permissions`), ]); const rolesResult = await rolesRes.json(); const permsResult = await permsRes.json(); if (rolesResult.success) { setRoles(Array.isArray(rolesResult.data) ? rolesResult.data : []); } else { alert.error(rolesResult.error || "Nepodařilo se načíst role"); } if (permsResult.success) { const perms: Permission[] = Array.isArray(permsResult.data) ? permsResult.data : []; setAllPermissions(perms); // Group by module (part before '.') const groups: Record = {}; for (const p of perms) { const mod = p.name.split(".")[0] || "other"; if (!groups[mod]) groups[mod] = []; groups[mod].push(p); } setPermissionGroups(groups); } } catch { alert.error("Chyba připojení"); } finally { setLoading(false); } }, [alert, canManage]); useEffect(() => { fetchData(); }, [fetchData]); const fetch2FARequired = useCallback(async () => { if (!canManage) { setRequire2FALoading(false); return; } try { const response = await apiFetch(`${API_BASE}/totp/required`); const result = await response.json(); if (result.success) { setRequire2FA(result.data.require_2fa); } } catch { /* ignore */ } finally { setRequire2FALoading(false); } }, [canManage]); useEffect(() => { fetch2FARequired(); }, [fetch2FARequired]); const fetchSystemSettings = useCallback(async () => { if (!canManage) return; setSysSettingsLoading(true); try { const response = await apiFetch(`${API_BASE}/company-settings`); const result = await response.json(); if (result.success) { const d: SystemSettingsData = result.data; setSysSettings(d); setSysForm({ break_threshold_hours: d.break_threshold_hours ?? 6, break_duration_short: d.break_duration_short ?? 15, break_duration_long: d.break_duration_long ?? 30, clock_rounding_minutes: d.clock_rounding_minutes ?? 15, invoice_alert_email: d.invoice_alert_email || "", leave_notify_email: d.leave_notify_email || "", smtp_from: d.smtp_from || "", smtp_from_name: d.smtp_from_name || "", max_login_attempts: d.max_login_attempts ?? 5, lockout_minutes: d.lockout_minutes ?? 15, max_requests_per_minute: d.max_requests_per_minute ?? 300, default_currency: d.default_currency || "CZK", default_vat_rate: d.default_vat_rate ?? 21, available_vat_rates: Array.isArray(d.available_vat_rates) && d.available_vat_rates.length > 0 ? d.available_vat_rates : [0, 10, 12, 15, 21], available_currencies: Array.isArray(d.available_currencies) && d.available_currencies.length > 0 ? d.available_currencies : ["CZK", "EUR", "USD", "GBP"], quotation_prefix: d.quotation_prefix || "NA", order_type_code: d.order_type_code || "71", invoice_type_code: d.invoice_type_code || "81", offer_number_pattern: d.offer_number_pattern || "{YYYY}/{PREFIX}/{NNN}", order_number_pattern: d.order_number_pattern || "{YY}{CODE}{NNNN}", invoice_number_pattern: d.invoice_number_pattern || "{YY}{CODE}{NNNN}", }); } else { alert.error(result.error || "Nepodařilo se načíst systémová nastavení"); } } catch { alert.error("Chyba připojení"); } finally { setSysSettingsLoading(false); } // Fetch system info apiFetch(`${API_BASE}/company-settings/system-info`) .then((r) => r.json()) .then((d) => { if (d.success) setSystemInfo(d.data); }) .catch(() => {}); }, [alert, canManage]); useEffect(() => { fetchSystemSettings(); }, [fetchSystemSettings]); 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"); fetchSystemSettings(); } else { alert.error(result.error || "Nepodařilo se uložit nastavení"); } } catch { alert.error("Chyba připojení"); } finally { setSysSettingsSaving(false); } }; 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) { setRequire2FA(!require2FA); alert.success(result.message || "2FA nastavení uloženo"); } else { alert.error(result.error || "Nepodařilo se uložit nastavení"); } } catch { alert.error("Chyba připojení"); } finally { setRequire2FASaving(false); } }; const generateSlug = (text: string): string => { return text .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/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; } setSaving(true); 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"), ); fetchData(); } else { alert.error(result.error || "Nepodařilo se uložit roli"); } } catch { alert.error("Chyba připojení"); } finally { setSaving(false); } }; 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"); fetchData(); } else { alert.error(result.error || "Nepodařilo se smazat roli"); } } catch { alert.error("Chyba připojení"); } finally { setDeleting(false); } }; if (loading) { return (
{[0, 1, 2, 3, 4].map((i) => (
))}
); } 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 (require2FASaving) return "Ukládání..."; return require2FA ? "Vypnout" : "Zapnout"; }; const renderRoleButtonContent = (): React.ReactNode => { if (saving) { 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ě" : "Zabezpečení a správa rolí"}

{canManage && (
)} {/* Security Settings */} {activeTab === "security" && canManage && (

Zabezpečení

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

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 === "security" && canManage && (

Role

{roles.map((role) => ( ))}
Název Popis Oprávnění Uživatelé Akce
{role.display_name}
{role.name}
{role.description || "\u2014"} {isAdminRole(role) ? "Vše" : (role.permissions?.length ?? 0)} {0} {!isAdminRole(role) && (
)}
)} {/* System Settings Tab */} {activeTab === "system" && canManage && ( <> {sysSettingsLoading ? (
{[0, 1, 2].map((i) => (
))}
) : ( <> {/* 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: Číslování dokladů */}

Číslování dokladů

Dostupné zástupné znaky:{" "} {"{YYYY}"} rok, {"{YY}"} rok (2 číslice), {"{PREFIX}"} prefix nabídky,{" "} {"{CODE}"} typový kód, {"{NNN}"}{" "} pořadí (3 číslice), {"{NNNN}"} pořadí (4 číslice), {"{NNNNN}"} pořadí (5 číslic)
{[ { label: "Nabídky", patternKey: "offer_number_pattern" as const, defaultPattern: "{YYYY}/{PREFIX}/{NNN}", prefixKey: "quotation_prefix" as const, prefixLabel: "Prefix", codeKey: null, }, { label: "Objednávky a projekty", patternKey: "order_number_pattern" as const, defaultPattern: "{YY}{CODE}{NNNN}", prefixKey: null, codeKey: "order_type_code" as const, codeLabel: "Typový kód", }, { label: "Faktury", patternKey: "invoice_number_pattern" as const, defaultPattern: "{YY}{CODE}{NNNN}", prefixKey: null, codeKey: "invoice_type_code" as const, codeLabel: "Typový kód", }, ].map((cfg, idx) => { const pattern = sysForm[cfg.patternKey] || cfg.defaultPattern; const yyyy = String(new Date().getFullYear()); const yy = yyyy.slice(-2); const prefix = cfg.prefixKey ? sysForm[cfg.prefixKey] || "NA" : ""; const code = cfg.codeKey ? sysForm[cfg.codeKey] || "" : ""; const preview = pattern.replace( /\{(\w+)\}/g, (m: string, k: string) => { if (k === "YYYY") return yyyy; if (k === "YY") return yy; if (k === "PREFIX") return prefix; if (k === "CODE") return code; if (/^N+$/.test(k)) return "1".padStart(k.length, "0"); return m; }, ); return (
{idx > 0 && (
)}
{cfg.label}
setSysForm((p) => ({ ...p, [cfg.patternKey]: e.target.value, })) } className="admin-form-input" placeholder={cfg.defaultPattern} /> {cfg.prefixKey && ( setSysForm((p) => ({ ...p, [cfg.prefixKey!]: e.target.value, })) } className="admin-form-input" style={{ maxWidth: 100 }} /> )} {cfg.codeKey && ( setSysForm((p) => ({ ...p, [cfg.codeKey!]: e.target.value, })) } className="admin-form-input" style={{ maxWidth: 100 }} /> )}
Ukázka:{" "} {preview}
); })}
{/* Section 6: Měna a DPH */}

Měna a DPH

setSysForm((prev) => ({ ...prev, default_vat_rate: Number(e.target.value), })) } className="admin-form-input" min={0} step={1} />
setSysForm((prev) => ({ ...prev, available_currencies: e.target.value .split(",") .map((s) => s.trim()) .filter(Boolean), })) } className="admin-form-input" placeholder="CZK, EUR, USD" /> setSysForm((prev) => ({ ...prev, available_vat_rates: e.target.value .split(",") .map((s) => s.trim()) .filter(Boolean) .map(Number) .filter((n) => !isNaN(n)), })) } className="admin-form-input" placeholder="0, 12, 21" />
{/* Section 6: Informace o aplikaci */}

Informace o aplikaci

{systemInfo ? ( {( [ ["Verze", systemInfo.app_version], ["Node.js", systemInfo.node_version], ["Platforma", systemInfo.platform], ["Uptime", systemInfo.uptime], ["Prostředí", systemInfo.environment], ["Časová zóna", systemInfo.timezone], ] as [string, string][] ).map(([label, val]) => ( ))} {( [ ["Proces (RSS)", systemInfo.memory?.rss], [ "Heap", `${systemInfo.memory?.heap_used} / ${systemInfo.memory?.heap_total}`, ], [ "Systém", `${systemInfo.memory?.system_free} volné z ${systemInfo.memory?.system_total}`, ], ] as [string, string][] ).map(([label, val]) => ( ))} {( [ ["Projekty", systemInfo.nas?.projects], ["Finance", systemInfo.nas?.financials], ["Nabídky", systemInfo.nas?.offers], ] as [string, Record][] ).map(([label, info]) => ( ))}
{label} {val}
Paměť
{label} {val}
Databáze
Stav {systemInfo.database?.status === "ok" ? "Připojeno" : "Chyba"}
Migrace {systemInfo.database?.migrations_applied}
NAS úložiště
{label} {info?.configured ? "Připojeno" : "Nenakonfigurováno"} {info?.configured && ( {info.path} )}
) : (
)}
{/* Save button */} )} )} {/* Firma tab */} {activeTab === "firma" && canManage && } {/* Create/Edit Modal */} {showModal && (

{editingRole ? "Upravit roli" : "Nová role"}

{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. )}