v1.5.6: boneyard-js skeleton migration, TanStack Query refactor, rate-limit config
- Replace hand-coded skeleton CSS/JSX with boneyard-js auto-generated bones - Remove skeleton.css and @keyframes shimmer from base.css - Add <Skeleton> wrappers with fixtures to all 25+ page components - Generate 20 bone captures via boneyard CLI (CDP auth-gated capture) - Refactor data fetching from useEffect+useState to TanStack Query - Extract query hooks into src/admin/lib/queries/ and apiAdapter - Add usePaginatedQuery hook replacing useApiCall/useListData - Fix parseFloat || 0 anti-pattern in OfferDetail and OffersTemplates inputs - Fix customer_id mandatory validation on offer creation - Fix leave-requests comma-separated status filter (Prisma enum in: []) - Add cross-entity cache invalidation for orders/offers/invoices/projects - Make rate limits configurable via env vars (RATE_LIMIT_MAX, RATE_LIMIT_REFRESH, etc.) - Add boneyard.config.json with routes and breakpoints Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,22 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useNavigate, Navigate, useSearchParams } from "react-router-dom";
|
||||
import { 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 {
|
||||
companySettingsOptions,
|
||||
systemInfoOptions,
|
||||
require2FAOptions,
|
||||
} from "../lib/queries/settings";
|
||||
|
||||
import apiFetch from "../utils/api";
|
||||
import { Skeleton } from "boneyard-js/react";
|
||||
import SettingsFixture from "../fixtures/SettingsFixture";
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface SystemSettingsData {
|
||||
@@ -70,20 +78,108 @@ interface RoleForm {
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_SYS_FORM: Omit<SystemSettingsData, "app_version"> = {
|
||||
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 navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [users, setUsers] = useState<{ role_id: number }[]>([]);
|
||||
const [, setAllPermissions] = useState<Permission[]>([]);
|
||||
const [permissionGroups, setPermissionGroups] = useState<
|
||||
Record<string, Permission[]>
|
||||
>({});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [require2FA, setRequire2FA] = useState(false);
|
||||
const [require2FALoading, setRequire2FALoading] = useState(true);
|
||||
const canManage = hasPermission("settings.manage");
|
||||
|
||||
// ── TanStack Query: roles, permissions, users ──
|
||||
const { data: rolesData, isPending: rolesLoading } = useQuery({
|
||||
queryKey: ["roles"],
|
||||
queryFn: async () => {
|
||||
const [rolesRes, permsRes, usersRes] = 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,
|
||||
});
|
||||
|
||||
const roles = rolesData?.roles ?? [];
|
||||
const users = rolesData?.users ?? [];
|
||||
|
||||
// Group permissions by module
|
||||
const permissionGroups = useMemo<Record<string, Permission[]>>(() => {
|
||||
const perms: Permission[] = rolesData?.permissions ?? [];
|
||||
const groups: Record<string, Permission[]> = {};
|
||||
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/security tab) ──
|
||||
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 { data: sysSettingsData, isPending: sysSettingsLoading } = useQuery({
|
||||
...companySettingsOptions(),
|
||||
enabled: canManage && (activeTab === "system" || activeTab === "security"),
|
||||
});
|
||||
|
||||
const { data: systemInfo } = useQuery({
|
||||
...systemInfoOptions(),
|
||||
enabled: canManage && activeTab === "system",
|
||||
});
|
||||
|
||||
// ── Local state ──
|
||||
const [require2FASaving, setRequire2FASaving] = useState(false);
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -102,194 +198,56 @@ export default function Settings() {
|
||||
}>({ 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<SystemSettingsData | null>(
|
||||
null,
|
||||
);
|
||||
const [sysSettingsLoading, setSysSettingsLoading] = useState(false);
|
||||
const [sysSettingsSaving, setSysSettingsSaving] = useState(false);
|
||||
const [systemInfo, setSystemInfo] = useState<Record<string, any> | null>(
|
||||
null,
|
||||
);
|
||||
const [sysForm, setSysForm] = useState<
|
||||
Omit<SystemSettingsData, "app_version">
|
||||
>({
|
||||
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 [sysForm, setSysForm] = useState(DEFAULT_SYS_FORM);
|
||||
const [sysFormInitialized, setSysFormInitialized] = useState(false);
|
||||
|
||||
const canManage = hasPermission("settings.manage");
|
||||
|
||||
if (!canManage) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
// ── Populate sysForm from query data ──
|
||||
useEffect(() => {
|
||||
if (!sysSettingsData || sysFormInitialized) return;
|
||||
const d = sysSettingsData as Record<string, unknown>;
|
||||
setSysForm({
|
||||
break_threshold_hours: (d.break_threshold_hours as number) ?? 6,
|
||||
break_duration_short: (d.break_duration_short as number) ?? 15,
|
||||
break_duration_long: (d.break_duration_long as number) ?? 30,
|
||||
clock_rounding_minutes: (d.clock_rounding_minutes as number) ?? 15,
|
||||
invoice_alert_email: (d.invoice_alert_email as string) || "",
|
||||
leave_notify_email: (d.leave_notify_email as string) || "",
|
||||
smtp_from: (d.smtp_from as string) || "",
|
||||
smtp_from_name: (d.smtp_from_name as string) || "",
|
||||
max_login_attempts: (d.max_login_attempts as number) ?? 5,
|
||||
lockout_minutes: (d.lockout_minutes as number) ?? 15,
|
||||
max_requests_per_minute: (d.max_requests_per_minute as number) ?? 300,
|
||||
default_currency: (d.default_currency as string) || "CZK",
|
||||
default_vat_rate: (d.default_vat_rate as number) ?? 21,
|
||||
available_vat_rates:
|
||||
Array.isArray(d.available_vat_rates) && d.available_vat_rates.length > 0
|
||||
? (d.available_vat_rates as number[])
|
||||
: [0, 10, 12, 15, 21],
|
||||
available_currencies:
|
||||
Array.isArray(d.available_currencies) &&
|
||||
d.available_currencies.length > 0
|
||||
? (d.available_currencies as string[])
|
||||
: ["CZK", "EUR", "USD", "GBP"],
|
||||
quotation_prefix: (d.quotation_prefix as string) || "NA",
|
||||
order_type_code: (d.order_type_code as string) || "71",
|
||||
invoice_type_code: (d.invoice_type_code as string) || "81",
|
||||
offer_number_pattern:
|
||||
(d.offer_number_pattern as string) || "{YYYY}/{PREFIX}/{NNN}",
|
||||
order_number_pattern:
|
||||
(d.order_number_pattern as string) || "{YY}{CODE}{NNNN}",
|
||||
invoice_number_pattern:
|
||||
(d.invoice_number_pattern as string) || "{YY}{CODE}{NNNN}",
|
||||
});
|
||||
setSysFormInitialized(true);
|
||||
}, [sysSettingsData, sysFormInitialized]);
|
||||
|
||||
useModalLock(showModal);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!canManage) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [rolesRes, permsRes, usersRes] = 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) {
|
||||
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<string, Permission[]> = {};
|
||||
for (const p of perms) {
|
||||
const mod = p.name.split(".")[0] || "other";
|
||||
if (!groups[mod]) groups[mod] = [];
|
||||
groups[mod].push(p);
|
||||
}
|
||||
setPermissionGroups(groups);
|
||||
}
|
||||
|
||||
if (usersResult.success) {
|
||||
setUsers(Array.isArray(usersResult.data) ? usersResult.data : []);
|
||||
}
|
||||
} 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]);
|
||||
// ── Early return after all hooks ──
|
||||
if (!canManage) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const handleSaveSystemSettings = async () => {
|
||||
setSysSettingsSaving(true);
|
||||
@@ -302,7 +260,7 @@ export default function Settings() {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert.success(result.message || "Systémová nastavení byla uložena");
|
||||
fetchSystemSettings();
|
||||
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
|
||||
} else {
|
||||
alert.error(result.error || "Nepodařilo se uložit nastavení");
|
||||
}
|
||||
@@ -323,8 +281,8 @@ export default function Settings() {
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setRequire2FA(!require2FA);
|
||||
alert.success(result.message || "2FA nastavení uloženo");
|
||||
queryClient.invalidateQueries({ queryKey: ["settings", "2fa"] });
|
||||
} else {
|
||||
alert.error(result.error || "Nepodařilo se uložit nastavení");
|
||||
}
|
||||
@@ -339,7 +297,7 @@ export default function Settings() {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
};
|
||||
@@ -443,7 +401,7 @@ export default function Settings() {
|
||||
result.message ||
|
||||
(editingRole ? "Role byla aktualizována" : "Role byla vytvořena"),
|
||||
);
|
||||
fetchData();
|
||||
queryClient.invalidateQueries({ queryKey: ["roles"] });
|
||||
} else {
|
||||
alert.error(result.error || "Nepodařilo se uložit roli");
|
||||
}
|
||||
@@ -471,7 +429,7 @@ export default function Settings() {
|
||||
if (result.success) {
|
||||
setDeleteConfirm({ show: false, role: null });
|
||||
alert.success(result.message || "Role byla smazána");
|
||||
fetchData();
|
||||
queryClient.invalidateQueries({ queryKey: ["roles"] });
|
||||
} else {
|
||||
alert.error(result.error || "Nepodařilo se smazat roli");
|
||||
}
|
||||
@@ -482,39 +440,15 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (rolesLoading) {
|
||||
return (
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-row"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "200px", marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div className="admin-skeleton-line" style={{ width: "140px" }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="admin-skeleton-row">
|
||||
<div className="admin-skeleton-line circle" />
|
||||
<div className="flex-1">
|
||||
<div className="admin-skeleton-line w-1/3 mb-2" />
|
||||
<div
|
||||
className="admin-skeleton-line w-1/4"
|
||||
style={{ height: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-skeleton-line w-1/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton
|
||||
name="settings"
|
||||
loading={rolesLoading}
|
||||
fixture={<SettingsFixture />}
|
||||
>
|
||||
<div />
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -523,10 +457,13 @@ export default function Settings() {
|
||||
const get2FADescription = (): React.ReactNode => {
|
||||
if (require2FALoading) {
|
||||
return (
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "200px", height: "12px" }}
|
||||
/>
|
||||
<Skeleton
|
||||
name="settings-2fa"
|
||||
loading={require2FALoading}
|
||||
fixture={<SettingsFixture />}
|
||||
>
|
||||
<span />
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
if (require2FA)
|
||||
@@ -783,7 +720,7 @@ export default function Settings() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{roles.map((role) => (
|
||||
{roles.map((role: Role) => (
|
||||
<tr key={role.id}>
|
||||
<td>
|
||||
<div
|
||||
@@ -804,7 +741,7 @@ export default function Settings() {
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ color: "var(--text-secondary)" }}>
|
||||
{role.description || "\u2014"}
|
||||
{role.description || "—"}
|
||||
</td>
|
||||
<td>
|
||||
<span className="admin-badge admin-badge-info">
|
||||
@@ -815,7 +752,11 @@ export default function Settings() {
|
||||
</td>
|
||||
<td>
|
||||
<span className="admin-badge admin-badge-secondary">
|
||||
{users.filter((u) => u.role_id === role.id).length}
|
||||
{
|
||||
users.filter(
|
||||
(u: { role_id: number }) => u.role_id === role.id,
|
||||
).length
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@@ -845,20 +786,26 @@ export default function Settings() {
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title={
|
||||
users.filter((u) => u.role_id === role.id)
|
||||
.length > 0
|
||||
users.filter(
|
||||
(u: { role_id: number }) =>
|
||||
u.role_id === role.id,
|
||||
).length > 0
|
||||
? "Nelze smazat roli s přiřazenými uživateli"
|
||||
: "Smazat"
|
||||
}
|
||||
aria-label={
|
||||
users.filter((u) => u.role_id === role.id)
|
||||
.length > 0
|
||||
users.filter(
|
||||
(u: { role_id: number }) =>
|
||||
u.role_id === role.id,
|
||||
).length > 0
|
||||
? "Nelze smazat roli s přiřazenými uživateli"
|
||||
: "Smazat"
|
||||
}
|
||||
disabled={
|
||||
users.filter((u) => u.role_id === role.id)
|
||||
.length > 0
|
||||
users.filter(
|
||||
(u: { role_id: number }) =>
|
||||
u.role_id === role.id,
|
||||
).length > 0
|
||||
}
|
||||
>
|
||||
<svg
|
||||
@@ -888,21 +835,14 @@ export default function Settings() {
|
||||
{/* System Settings Tab */}
|
||||
{activeTab === "system" && canManage && (
|
||||
<>
|
||||
{sysSettingsLoading ? (
|
||||
<div
|
||||
className="admin-skeleton"
|
||||
style={{ padding: 0, gap: "1.5rem" }}
|
||||
{sysSettingsLoading && !sysFormInitialized ? (
|
||||
<Skeleton
|
||||
name="settings-system"
|
||||
loading={sysSettingsLoading && !sysFormInitialized}
|
||||
fixture={<SettingsFixture />}
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: "1rem" }}>
|
||||
<div className="admin-skeleton-line w-1/3 mb-2" />
|
||||
<div className="admin-skeleton-line w-full" />
|
||||
<div className="admin-skeleton-line w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div />
|
||||
</Skeleton>
|
||||
) : (
|
||||
<>
|
||||
{/* Section 1: Docházka */}
|
||||
@@ -1374,12 +1314,33 @@ export default function Settings() {
|
||||
<tbody>
|
||||
{(
|
||||
[
|
||||
["Verze", systemInfo.app_version],
|
||||
["Node.js", systemInfo.node_version],
|
||||
["Platforma", systemInfo.platform],
|
||||
["Uptime", systemInfo.uptime],
|
||||
["Prostředí", systemInfo.environment],
|
||||
["Časová zóna", systemInfo.timezone],
|
||||
[
|
||||
"Verze",
|
||||
(systemInfo as Record<string, unknown>)
|
||||
.app_version,
|
||||
],
|
||||
[
|
||||
"Node.js",
|
||||
(systemInfo as Record<string, unknown>)
|
||||
.node_version,
|
||||
],
|
||||
[
|
||||
"Platforma",
|
||||
(systemInfo as Record<string, unknown>).platform,
|
||||
],
|
||||
[
|
||||
"Uptime",
|
||||
(systemInfo as Record<string, unknown>).uptime,
|
||||
],
|
||||
[
|
||||
"Prostředí",
|
||||
(systemInfo as Record<string, unknown>)
|
||||
.environment,
|
||||
],
|
||||
[
|
||||
"Časová zóna",
|
||||
(systemInfo as Record<string, unknown>).timezone,
|
||||
],
|
||||
] as [string, string][]
|
||||
).map(([label, val]) => (
|
||||
<tr key={label}>
|
||||
@@ -1415,14 +1376,22 @@ export default function Settings() {
|
||||
</tr>
|
||||
{(
|
||||
[
|
||||
["Proces (RSS)", systemInfo.memory?.rss],
|
||||
[
|
||||
"Proces (RSS)",
|
||||
(
|
||||
systemInfo as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>
|
||||
).memory?.rss as string,
|
||||
],
|
||||
[
|
||||
"Heap",
|
||||
`${systemInfo.memory?.heap_used} / ${systemInfo.memory?.heap_total}`,
|
||||
`${(systemInfo as Record<string, Record<string, unknown>>).memory?.heap_used} / ${(systemInfo as Record<string, Record<string, unknown>>).memory?.heap_total}`,
|
||||
],
|
||||
[
|
||||
"Systém",
|
||||
`${systemInfo.memory?.system_free} volné z ${systemInfo.memory?.system_total}`,
|
||||
`${(systemInfo as Record<string, Record<string, unknown>>).memory?.system_free} volné z ${(systemInfo as Record<string, Record<string, unknown>>).memory?.system_total}`,
|
||||
],
|
||||
] as [string, string][]
|
||||
).map(([label, val]) => (
|
||||
@@ -1464,9 +1433,14 @@ export default function Settings() {
|
||||
</td>
|
||||
<td style={{ padding: "4px 0" }}>
|
||||
<span
|
||||
className={`admin-badge ${systemInfo.database?.status === "ok" ? "admin-badge-success" : "admin-badge-danger"}`}
|
||||
className={`admin-badge ${(systemInfo as Record<string, Record<string, unknown>>).database?.status === "ok" ? "admin-badge-success" : "admin-badge-danger"}`}
|
||||
>
|
||||
{systemInfo.database?.status === "ok"
|
||||
{(
|
||||
systemInfo as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>
|
||||
).database?.status === "ok"
|
||||
? "Připojeno"
|
||||
: "Chyba"}
|
||||
</span>
|
||||
@@ -1482,7 +1456,14 @@ export default function Settings() {
|
||||
Migrace
|
||||
</td>
|
||||
<td style={{ padding: "4px 0" }}>
|
||||
{systemInfo.database?.migrations_applied}
|
||||
{
|
||||
(
|
||||
systemInfo as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>
|
||||
).database?.migrations_applied as string
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -1502,9 +1483,33 @@ export default function Settings() {
|
||||
</tr>
|
||||
{(
|
||||
[
|
||||
["Projekty", systemInfo.nas?.projects],
|
||||
["Finance", systemInfo.nas?.financials],
|
||||
["Nabídky", systemInfo.nas?.offers],
|
||||
[
|
||||
"Projekty",
|
||||
(
|
||||
systemInfo as Record<
|
||||
string,
|
||||
Record<string, Record<string, unknown>>
|
||||
>
|
||||
).nas?.projects,
|
||||
],
|
||||
[
|
||||
"Finance",
|
||||
(
|
||||
systemInfo as Record<
|
||||
string,
|
||||
Record<string, Record<string, unknown>>
|
||||
>
|
||||
).nas?.financials,
|
||||
],
|
||||
[
|
||||
"Nabídky",
|
||||
(
|
||||
systemInfo as Record<
|
||||
string,
|
||||
Record<string, Record<string, unknown>>
|
||||
>
|
||||
).nas?.offers,
|
||||
],
|
||||
] as [string, Record<string, any>][]
|
||||
).map(([label, info]) => (
|
||||
<tr key={label}>
|
||||
@@ -1541,10 +1546,15 @@ export default function Settings() {
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "60%", height: 14 }}
|
||||
/>
|
||||
<Skeleton
|
||||
name="settings-permissions"
|
||||
loading={
|
||||
!role.permissions || role.permissions.length === 0
|
||||
}
|
||||
fixture={<span>...</span>}
|
||||
>
|
||||
<span>{role.permissions?.length || 0} oprávnění</span>
|
||||
</Skeleton>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
Reference in New Issue
Block a user