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,11 +1,5 @@
import {
useState,
useEffect,
useCallback,
useRef,
lazy,
Suspense,
} from "react";
import { useState, useEffect, useRef, lazy, Suspense } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link, useSearchParams } from "react-router-dom";
@@ -17,8 +11,18 @@ import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useListData from "../hooks/useListData";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
invoiceListOptions,
invoiceStatsOptions,
type Invoice,
type InvoiceStats,
type CurrencyAmount,
} from "../lib/queries/invoices";
import Pagination from "../components/Pagination";
import { Skeleton } from "boneyard-js/react";
import InvoicesFixture from "../fixtures/InvoicesFixture";
import ReceivedInvoicesFixture from "../fixtures/ReceivedInvoicesFixture";
const ReceivedInvoices = lazy(() => import("./ReceivedInvoices"));
const API_BASE = "/api/admin";
@@ -39,11 +43,6 @@ const MONTH_NAMES = [
"prosinec",
];
interface CurrencyAmount {
amount: number;
currency: string;
}
function formatMultiCurrency(amounts: CurrencyAmount[]): string {
if (!Array.isArray(amounts) || amounts.length === 0) return "0 Kč";
return amounts.map((a) => formatCurrency(a.amount, a.currency)).join(" · ");
@@ -84,31 +83,6 @@ const STATUS_FILTERS = [
{ value: "overdue", label: "Po splatnosti" },
];
interface Invoice {
id: number;
invoice_number: string;
customer_name: string | null;
status: string;
issue_date: string;
due_date: string;
total: number;
currency: string;
}
interface InvoiceStats {
paid_month: CurrencyAmount[];
paid_month_czk: number;
paid_month_count: number;
awaiting: CurrencyAmount[];
awaiting_czk: number;
awaiting_count: number;
overdue: CurrencyAmount[];
overdue_czk: number;
overdue_count: number;
vat_month: CurrencyAmount[];
vat_month_czk: number;
}
interface DraftData {
form: Record<string, unknown>;
items: Record<string, unknown>[];
@@ -134,8 +108,6 @@ export default function Invoices() {
const now = new Date();
const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1);
const [statsYear, setStatsYear] = useState(now.getFullYear());
const [stats, setStats] = useState<InvoiceStats | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0);
const blobUrlRef = useRef<string | null>(null);
@@ -154,28 +126,15 @@ export default function Invoices() {
statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear();
const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`;
const fetchStats = useCallback(async () => {
setStatsLoading(true);
try {
const res = await apiFetch(
`${API_BASE}/invoices/stats?month=${statsMonth}&year=${statsYear}`,
);
const data = await res.json();
if (data.success) {
setStats(data.data);
hasLoadedOnce.current = true;
setSlideKey((k) => k + 1);
}
} catch {
/* ignore */
} finally {
setStatsLoading(false);
}
}, [statsMonth, statsYear]);
const statsQuery = useQuery(invoiceStatsOptions(statsMonth, statsYear));
const stats = statsQuery.data ?? null;
useEffect(() => {
fetchStats();
}, [fetchStats]);
if (statsQuery.data) {
hasLoadedOnce.current = true;
setSlideKey((k) => k + 1);
}
}, [statsQuery.data]);
const prevMonth = () => {
slideDirection.current = -1;
@@ -225,24 +184,23 @@ export default function Invoices() {
setDraft(null);
};
const queryClient = useQueryClient();
const {
items: invoices,
loading,
initialLoad,
pagination,
refetch: fetchData,
} = useListData<Invoice>("invoices", {
search,
sort,
order,
page,
extraParams: {
month: String(statsMonth),
year: String(statsYear),
...(statusFilter ? { status: statusFilter } : {}),
},
errorMsg: "Nepodařilo se načíst faktury",
});
isPending: initialLoad,
isFetching: loading,
} = usePaginatedQuery<Invoice>(
invoiceListOptions({
search,
sort,
order,
page,
month: statsMonth,
year: statsYear,
status: statusFilter || undefined,
}),
);
if (!hasPermission("invoices.view")) return <Forbidden />;
@@ -260,8 +218,8 @@ export default function Invoices() {
if (result.success) {
setDeleteConfirm({ show: false, invoice: null });
alert.success(result.message || "Faktura byla smazána");
fetchData();
fetchStats();
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
} else {
alert.error(result.error || "Nepodařilo se smazat fakturu");
}
@@ -283,8 +241,8 @@ export default function Invoices() {
const data = await res.json();
if (data.success) {
alert.success("Faktura označena jako zaplacená");
fetchData();
fetchStats();
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
} else {
alert.error(data.error || "Nepodařilo se změnit stav");
}
@@ -323,81 +281,13 @@ export default function Invoices() {
if (initialLoad) {
return (
<div>
<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: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-kpi-grid admin-kpi-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div
className="admin-skeleton-line"
style={{ width: "80px" }}
/>
<div className="admin-skeleton-line w-1/4" />
<div
className="admin-skeleton-line"
style={{ width: "70px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "90px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "90px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "100px" }}
/>
</div>
))}
</div>
</div>
</div>
</div>
<Skeleton
name="invoices"
loading={initialLoad}
fixture={<InvoicesFixture />}
>
<div />
</Skeleton>
);
}
@@ -528,35 +418,13 @@ export default function Invoices() {
>
<Suspense
fallback={
<div
className="admin-kpi-grid admin-kpi-4"
style={{ marginBottom: "1.5rem" }}
<Skeleton
name="invoices-received-kpi"
loading={true}
fixture={<ReceivedInvoicesFixture />}
>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
<div />
</Skeleton>
}
>
<ReceivedInvoices
@@ -574,36 +442,14 @@ export default function Invoices() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }}
>
{!hasLoadedOnce.current && statsLoading ? (
<div
className="admin-kpi-grid admin-kpi-4"
style={{ marginBottom: "1.5rem" }}
{statsQuery.isPending && !hasLoadedOnce.current ? (
<Skeleton
name="invoices-kpi"
loading={statsQuery.isPending && !hasLoadedOnce.current}
fixture={<InvoicesFixture />}
>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
<div />
</Skeleton>
) : (
stats && (
<div style={{ overflow: "hidden", marginBottom: "1.5rem" }}>