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:
BOHA
2026-04-28 22:35:43 +02:00
parent 12289bdce3
commit ba95723b61
109 changed files with 26410 additions and 10159 deletions

View File

@@ -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>