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,12 +1,17 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import FormField from "../components/FormField";
import ConfirmModal from "../components/ConfirmModal";
import { companySettingsOptions } from "../lib/queries/settings";
import { bankAccountsOptions } from "../lib/queries/common";
import { motion } from "framer-motion";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import CompanySettingsFixture from "../fixtures/CompanySettingsFixture";
const API_BASE = "/api/admin";
const DEFAULT_FIELD_ORDER = [
@@ -68,7 +73,7 @@ export default function CompanySettings({
}: { embedded?: boolean } = {}) {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const queryClient = useQueryClient();
const [saving, setSaving] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const [uploadingLogoDark, setUploadingLogoDark] = useState(false);
@@ -89,14 +94,12 @@ export default function CompanySettings({
const [fieldOrder, setFieldOrder] = useState<string[]>([
...DEFAULT_FIELD_ORDER,
]);
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([
"CZK",
"EUR",
"USD",
"GBP",
]);
const [bankLoading, setBankLoading] = useState(true);
const [bankSaving, setBankSaving] = useState(false);
const [editingBank, setEditingBank] = useState<number | null>(null);
const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{
@@ -182,84 +185,63 @@ export default function CompanySettings({
}
}, []);
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/company-settings`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const d = result.data;
setForm({
company_name: d.company_name || "",
street: d.street || "",
city: d.city || "",
postal_code: d.postal_code || "",
country: d.country || "",
company_id: d.company_id || "",
vat_id: d.vat_id || "",
});
const cf =
Array.isArray(d.custom_fields) && d.custom_fields.length > 0
? d.custom_fields.map(
(
f: {
name: string;
value: string;
showLabel?: boolean;
_key?: string;
},
i: number,
) => ({
...f,
_key: f._key || `cf-${Date.now()}-${i}`,
}),
)
: [];
setCustomFields(cf);
if (
Array.isArray(d.supplier_field_order) &&
d.supplier_field_order.length > 0
) {
setFieldOrder(d.supplier_field_order);
} else {
setFieldOrder([...DEFAULT_FIELD_ORDER]);
}
if (
Array.isArray(d.available_currencies) &&
d.available_currencies.length > 0
) {
setAvailableCurrencies(d.available_currencies);
}
if (d.has_logo) {
fetchLogo("light");
}
if (d.has_logo_dark) {
fetchLogo("dark");
}
} else {
alert.error(result.error || "Nepodařilo se načíst nastavení");
}
} catch {
alert.error("Chyba připojení");
} finally {
setLoading(false);
}
}, [alert, fetchLogo]);
// ── TanStack Query: company settings ──
const { data: settingsData, isPending: settingsLoading } = useQuery(
companySettingsOptions(),
);
const fetchBankAccounts = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/bank-accounts`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setBankAccounts(result.data);
}
} catch {
// ignore
} finally {
setBankLoading(false);
// ── TanStack Query: bank accounts ──
const { data: bankAccountsData, isPending: bankLoading } = useQuery(
bankAccountsOptions(),
);
const bankAccountsList: BankAccount[] = Array.isArray(bankAccountsData)
? (bankAccountsData as unknown as BankAccount[])
: [];
// Populate form state when settings data arrives
useEffect(() => {
if (!settingsData) return;
const d = settingsData as Record<string, unknown>;
setForm({
company_name: (d.company_name as string) || "",
street: (d.street as string) || "",
city: (d.city as string) || "",
postal_code: (d.postal_code as string) || "",
country: (d.country as string) || "",
company_id: (d.company_id as string) || "",
vat_id: (d.vat_id as string) || "",
});
const cf: CustomField[] =
Array.isArray(d.custom_fields) && d.custom_fields.length > 0
? (d.custom_fields as CustomField[]).map((f, i) => ({
...f,
showLabel: f.showLabel !== false,
_key: f._key || `cf-${Date.now()}-${i}`,
}))
: [];
setCustomFields(cf);
if (
Array.isArray(d.supplier_field_order) &&
d.supplier_field_order.length > 0
) {
setFieldOrder(d.supplier_field_order as string[]);
} else {
setFieldOrder([...DEFAULT_FIELD_ORDER]);
}
}, []);
if (
Array.isArray(d.available_currencies) &&
d.available_currencies.length > 0
) {
setAvailableCurrencies(d.available_currencies as string[]);
}
if (d.has_logo) {
fetchLogo("light");
}
if (d.has_logo_dark) {
fetchLogo("dark");
}
}, [settingsData, fetchLogo]);
const resetBankForm = () => {
setEditingBank(null);
@@ -294,7 +276,7 @@ export default function CompanySettings({
if (result.success) {
alert.success(result.message);
resetBankForm();
fetchBankAccounts();
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
} else {
alert.error(result.error || "Chyba při ukládání");
}
@@ -322,7 +304,7 @@ export default function CompanySettings({
if (result.success) {
alert.success(result.message);
if (editingBank === bankDeleteConfirm.id) resetBankForm();
fetchBankAccounts();
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
} else {
alert.error(result.error || "Chyba při mazání");
}
@@ -346,11 +328,6 @@ export default function CompanySettings({
});
};
useEffect(() => {
fetchData();
fetchBankAccounts();
}, [fetchData, fetchBankAccounts]);
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
@@ -377,6 +354,7 @@ export default function CompanySettings({
const result = await response.json();
if (result.success) {
alert.success(result.message || "Nastavení bylo uloženo");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
@@ -411,6 +389,7 @@ export default function CompanySettings({
const result = await response.json();
if (result.success) {
alert.success(result.message || "Logo bylo nahráno");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
fetchLogo(variant);
} else {
alert.error(result.error || "Nepodařilo se nahrát logo");
@@ -429,50 +408,15 @@ export default function CompanySettings({
if (!embedded && !hasPermission("settings.manage")) return <Forbidden />;
if (loading) {
if (settingsLoading) {
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
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1.25rem",
}}
>
{[0, 1, 2, 3, 4, 5].map((i) => (
<div key={i} className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
<div
className="admin-skeleton-line h-8"
style={{ width: "60%" }}
/>
{[0, 1, 2].map((j) => (
<div key={j} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
))}
</div>
</div>
<Skeleton
name="company-settings"
loading={settingsLoading}
fixture={<CompanySettingsFixture />}
>
<div />
</Skeleton>
);
}
@@ -774,18 +718,16 @@ export default function CompanySettings({
</div>
<div className="admin-card-body">
{bankLoading ? (
<div className="admin-skeleton" style={{ gap: "1rem" }}>
{[0, 1, 2].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
<Skeleton
name="company-settings-bank"
loading={bankLoading}
fixture={<CompanySettingsFixture />}
>
<div />
</Skeleton>
) : (
<>
{bankAccounts.length > 0 && (
{bankAccountsList.length > 0 && (
<div className="admin-table-responsive mb-4">
<table className="admin-table">
<thead>
@@ -801,7 +743,7 @@ export default function CompanySettings({
</tr>
</thead>
<tbody>
{bankAccounts.map((acc) => (
{bankAccountsList.map((acc) => (
<tr
key={acc.id}
style={