v1.6.7: add settings.system permission, move numbering/VAT to company tab, rename security tab to roles

- New settings.system permission with migration (idempotent INSERT)
- requireAnyPermission helper for route guards accepting multiple perms
- Move document numbering + currency/VAT cards from system tab to CompanySettings
- Rename security tab to roles, add canManageSystem alongside canManageCompany
- TOTP required endpoint and system-info now use settings.system
- Roles list now includes user_count
- Sidebar Settings link includes settings.system

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-05-18 14:03:31 +02:00
parent 55648c9a30
commit 66b98e2765
10 changed files with 1041 additions and 962 deletions

View File

@@ -71,6 +71,7 @@ interface Role {
description: string | null;
permissions: Permission[];
role_permissions?: unknown[];
user_count?: number;
}
interface RoleForm {
@@ -109,38 +110,44 @@ export default function Settings() {
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const canManage = hasPermission("settings.roles");
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;
// ── TanStack Query: roles, permissions, users ──
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, usersRes] = await Promise.all([
const [rolesRes, permsRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`),
apiFetch(`${API_BASE}/roles/permissions`),
apiFetch(`${API_BASE}/users`),
]);
const rolesResult = await rolesRes.json();
const permsResult = await permsRes.json();
const usersResult = await usersRes.json();
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í");
if (!usersResult.success)
throw new Error(usersResult.error || "Nepodařilo se načíst uživatele");
return {
roles: Array.isArray(rolesResult.data) ? rolesResult.data : [],
permissions: Array.isArray(permsResult.data) ? permsResult.data : [],
users: Array.isArray(usersResult.data) ? usersResult.data : [],
};
},
enabled: canManage,
enabled: canManageRoles,
});
const roles = rolesData?.roles ?? [];
const users = rolesData?.users ?? [];
// Group permissions by module
const permissionGroups = useMemo<Record<string, Permission[]>>(() => {
const perms: Permission[] = rolesData?.permissions ?? [];
@@ -158,27 +165,32 @@ export default function Settings() {
useQuery(require2FAOptions());
const require2FA = totpData?.require_2fa ?? false;
// ── TanStack Query: system settings (lazy, only on system/security tab) ──
// ── 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 === "system"
? "system"
: tabParam === "firma"
? "firma"
: "security"
) as "security" | "system" | "firma";
const setActiveTab = (tab: "security" | "system" | "firma") =>
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: canManage && (activeTab === "system" || activeTab === "security"),
enabled:
(canManageRoles || canManageSystem) &&
(activeTab === "system" || activeTab === "roles"),
});
const { data: systemInfo } = useQuery({
...systemInfoOptions(),
enabled: canManage && activeTab === "system",
enabled: canManageSystem && activeTab === "system",
});
// ── Local state ──
@@ -247,7 +259,7 @@ export default function Settings() {
useModalLock(showModal);
// ── Early return after all hooks ──
if (!canManage) {
if (!canAccessSettings) {
return <Navigate to="/" replace />;
}
@@ -453,7 +465,7 @@ export default function Settings() {
}
};
if (rolesLoading) {
if (canManageRoles && rolesLoading) {
return (
<Skeleton
name="settings"
@@ -516,36 +528,42 @@ export default function Settings() {
? "Systémová nastavení"
: activeTab === "firma"
? "Informace o firmě"
: "Zabezpečení a správa rolí"}
: "Role"}
</p>
</div>
</motion.div>
{canManage && (
{availableTabs.length > 0 && (
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem" }}>
<button
className={`admin-btn admin-btn-sm ${activeTab === "security" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("security")}
>
Zabezpečení a role
</button>
<button
className={`admin-btn admin-btn-sm ${activeTab === "system" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("system")}
>
Systémová nastavení
</button>
<button
className={`admin-btn admin-btn-sm ${activeTab === "firma" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("firma")}
>
Firma
</button>
{availableTabs.includes("roles") && (
<button
className={`admin-btn admin-btn-sm ${activeTab === "roles" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("roles")}
>
Role
</button>
)}
{availableTabs.includes("system") && (
<button
className={`admin-btn admin-btn-sm ${activeTab === "system" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("system")}
>
Systémová nastavení
</button>
)}
{availableTabs.includes("firma") && (
<button
className={`admin-btn admin-btn-sm ${activeTab === "firma" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("firma")}
>
Firma
</button>
)}
</div>
)}
{/* Security Settings */}
{activeTab === "security" && canManage && (
{activeTab === "system" && canManageSystem && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
@@ -629,7 +647,7 @@ export default function Settings() {
)}
{/* Login Security */}
{activeTab === "security" && canManage && (
{activeTab === "system" && canManageSystem && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
@@ -686,7 +704,7 @@ export default function Settings() {
)}
{/* Roles Table */}
{activeTab === "security" && canManage && (
{activeTab === "roles" && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
@@ -765,11 +783,7 @@ export default function Settings() {
</td>
<td>
<span className="admin-badge admin-badge-secondary">
{
users.filter(
(u: { role_id: number }) => u.role_id === role.id,
).length
}
{role.user_count ?? 0}
</span>
</td>
<td>
@@ -799,27 +813,16 @@ export default function Settings() {
}
className="admin-btn-icon danger"
title={
users.filter(
(u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
(role.user_count ?? 0) > 0
? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat"
}
aria-label={
users.filter(
(u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
(role.user_count ?? 0) > 0
? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat"
}
disabled={
users.filter(
(u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
}
disabled={(role.user_count ?? 0) > 0}
>
<svg
width="16"
@@ -846,7 +849,7 @@ export default function Settings() {
)}
{/* System Settings Tab */}
{activeTab === "system" && canManage && (
{activeTab === "system" && (
<>
{sysSettingsLoading && !sysFormInitialized ? (
<Skeleton
@@ -1052,260 +1055,7 @@ export default function Settings() {
</div>
</motion.div>
{/* Section 5: Číslování dokladů */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.28 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">Číslování dokladů</h2>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div
style={{
padding: "0.75rem",
background: "var(--bg-tertiary)",
borderRadius: 8,
fontSize: "0.8rem",
color: "var(--text-secondary)",
marginBottom: "1rem",
}}
>
<strong>Dostupné zástupné znaky:</strong>{" "}
<code>{"{YYYY}"}</code> rok, <code>{"{YY}"}</code> rok (2
číslice), <code>{"{PREFIX}"}</code> prefix nabídky,{" "}
<code>{"{CODE}"}</code> typový kód, <code>{"{NNN}"}</code>{" "}
pořadí (3 číslice), <code>{"{NNNN}"}</code> pořadí (4
číslice), <code>{"{NNNNN}"}</code> pořadí (5 číslic)
</div>
{[
{
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 (
<div key={cfg.patternKey}>
{idx > 0 && (
<hr
style={{
border: "none",
borderTop: "1px solid var(--border-color)",
margin: "1rem 0",
}}
/>
)}
<div
className="fw-600 text-md"
style={{
marginBottom: "0.5rem",
}}
>
{cfg.label}
</div>
<div className="admin-form-row">
<FormField label="Formát">
<input
type="text"
value={sysForm[cfg.patternKey]}
onChange={(e) =>
setSysForm((p) => ({
...p,
[cfg.patternKey]: e.target.value,
}))
}
className="admin-form-input"
placeholder={cfg.defaultPattern}
/>
</FormField>
{cfg.prefixKey && (
<FormField label="Prefix">
<input
type="text"
value={sysForm[cfg.prefixKey]}
onChange={(e) =>
setSysForm((p) => ({
...p,
[cfg.prefixKey!]: e.target.value,
}))
}
className="admin-form-input"
style={{ maxWidth: 100 }}
/>
</FormField>
)}
{cfg.codeKey && (
<FormField label={cfg.codeLabel || "Kód"}>
<input
type="text"
value={sysForm[cfg.codeKey]}
onChange={(e) =>
setSysForm((p) => ({
...p,
[cfg.codeKey!]: e.target.value,
}))
}
className="admin-form-input"
style={{ maxWidth: 100 }}
/>
</FormField>
)}
</div>
<small
style={{
color: "var(--text-tertiary)",
fontSize: "0.8rem",
}}
>
Ukázka:{" "}
<strong style={{ color: "var(--text-primary)" }}>
{preview}
</strong>
</small>
</div>
);
})}
</div>
</div>
</motion.div>
{/* Section 6: Měna a DPH */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.3 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">Měna a DPH</h2>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Výchozí měna">
<select
value={sysForm.default_currency}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
default_currency: e.target.value,
}))
}
className="admin-form-input"
>
{sysForm.available_currencies.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</FormField>
<FormField label="Výchozí sazba DPH (%)">
<input
type="number"
value={sysForm.default_vat_rate}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
default_vat_rate: Number(e.target.value),
}))
}
className="admin-form-input"
min={0}
step={1}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Dostupné měny">
<input
type="text"
value={sysForm.available_currencies.join(", ")}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
available_currencies: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
}))
}
className="admin-form-input"
placeholder="CZK, EUR, USD"
/>
</FormField>
<FormField label="Dostupné sazby DPH (%)">
<input
type="text"
value={sysForm.available_vat_rates.join(", ")}
onChange={(e) =>
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"
/>
</FormField>
</div>
</div>
</div>
</motion.div>
{/* Section 6: Informace o aplikaci */}
{/* Section 5: Informace o aplikaci */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
@@ -1615,7 +1365,7 @@ export default function Settings() {
)}
{/* Firma tab */}
{activeTab === "firma" && canManage && <CompanySettings embedded />}
{activeTab === "firma" && <CompanySettings embedded />}
{/* Create/Edit Modal */}
<AnimatePresence>