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,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={
|
||||
|
||||
Reference in New Issue
Block a user