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,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion, AnimatePresence } from "framer-motion";
@@ -10,6 +11,14 @@ import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useModalLock from "../hooks/useModalLock";
import AdminDatePicker from "../components/AdminDatePicker";
import { companySettingsOptions } from "../lib/queries/settings";
import { supplierListOptions } from "../lib/queries/common";
import {
receivedInvoiceListOptions,
receivedInvoiceStatsOptions,
} from "../lib/queries/invoices";
import { Skeleton } from "boneyard-js/react";
import ReceivedInvoicesFixture from "../fixtures/ReceivedInvoicesFixture";
const API_BASE = "/api/admin";
@@ -131,7 +140,7 @@ interface CompanySettings {
available_vat_rates: number[];
}
function emptyMeta(settings: CompanySettings | null): UploadMeta {
function emptyMeta(settings: CompanySettings | null | undefined): UploadMeta {
return {
supplier_name: "",
invoice_number: "",
@@ -155,10 +164,16 @@ export default function ReceivedInvoices({
const { sort, order, handleSort, activeSort } = useTableSort("created_at");
const [search, setSearch] = useState("");
const [invoices, setInvoices] = useState<ReceivedInvoice[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<ReceivedStats | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [editInvoice, setEditInvoice] = useState<EditInvoice | null>(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
invoice: ReceivedInvoice | null;
}>({ show: false, invoice: null });
const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
@@ -166,18 +181,42 @@ export default function ReceivedInvoices({
const prevMonth = useRef(statsMonth);
const prevYear = useRef(statsYear);
const [editOpen, setEditOpen] = useState(false);
const [editInvoice, setEditInvoice] = useState<EditInvoice | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
invoice: ReceivedInvoice | null;
}>({ show: false, invoice: null });
const [deleting, setDeleting] = useState(false);
const [saving, setSaving] = useState(false);
const { data: supplierNames = [] } = useQuery(supplierListOptions());
const companySettings = useQuery(companySettingsOptions()).data as unknown as
| CompanySettings
| undefined;
const [supplierNames, setSupplierNames] = useState<string[]>([]);
const [companySettings, setCompanySettings] =
useState<CompanySettings | null>(null);
// List query — auto-refetches when filters change
const listQuery = useQuery(
receivedInvoiceListOptions({
month: statsMonth,
year: statsYear,
search,
sort,
order,
}),
);
// Stats query — auto-refetches when month/year change
const statsQuery = useQuery(
receivedInvoiceStatsOptions(statsMonth, statsYear),
);
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
const invoices = (listQuery.data?.data ?? []) as ReceivedInvoice[];
if (listQuery.data || statsQuery.data) hasLoadedOnce.current = true;
// Derive stats from query
const stats = (statsQuery.data as unknown as ReceivedStats) ?? null;
// Trigger slide animation when stats data changes
useEffect(() => {
if (statsQuery.data) {
setSlideKey((k) => k + 1);
}
}, [statsQuery.data]);
const showListSkeleton = listQuery.isPending && !hasLoadedOnce.current;
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
const [uploadMeta, setUploadMeta] = useState<UploadMeta[]>([]);
@@ -201,57 +240,6 @@ export default function ReceivedInvoices({
prevMonth.current = statsMonth;
prevYear.current = statsYear;
const fetchList = useCallback(async () => {
if (!hasLoadedOnce.current) setLoading(true);
try {
const params = new URLSearchParams({
month: String(statsMonth),
year: String(statsYear),
});
if (search) {
params.set("search", search);
}
if (sort) {
params.set("sort", sort);
}
if (order) {
params.set("order", order);
}
const res = await apiFetch(`${API_BASE}/received-invoices?${params}`);
const data = await res.json();
if (data.success) {
setInvoices(Array.isArray(data.data) ? data.data : []);
}
} catch {
/* ignore */
} finally {
setLoading(false);
hasLoadedOnce.current = true;
}
}, [statsMonth, statsYear, search, sort, order]);
useEffect(() => {
fetchList();
}, [fetchList]);
useEffect(() => {
apiFetch(`${API_BASE}/received-invoices/suppliers`)
.then((r) => r.json())
.then((d) => {
if (d.success) setSupplierNames(d.data || []);
})
.catch(() => {});
}, []);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanySettings(d.data);
})
.catch(() => {});
}, []);
const currencyOptions =
companySettings?.available_currencies || DEFAULT_CURRENCIES;
const vatRateOptions =
@@ -259,45 +247,6 @@ export default function ReceivedInvoices({
const defaultCurrency = companySettings?.default_currency || "CZK";
const defaultVatRate = String(companySettings?.default_vat_rate ?? 21);
// Fetch stats (silent refresh without animation)
const refreshStats = useCallback(async () => {
try {
const res = await apiFetch(
`${API_BASE}/received-invoices/stats?month=${statsMonth}&year=${statsYear}`,
);
const data = await res.json();
if (data.success) {
setStats(data.data);
hasLoadedOnce.current = true;
}
} catch {
/* ignore */
}
}, [statsMonth, statsYear]);
// Fetch stats on month change (with slide animation)
useEffect(() => {
setStatsLoading(true);
const load = async () => {
try {
const res = await apiFetch(
`${API_BASE}/received-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);
}
};
load();
}, [statsMonth, statsYear]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []);
if (selected.length === 0) {
@@ -395,8 +344,9 @@ export default function ReceivedInvoices({
setUploadFiles([]);
setUploadMeta([]);
setUploadErrors({});
fetchList();
refreshStats();
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
});
} else {
alert.error(data.error || "Chyba při nahrávání");
}
@@ -465,8 +415,9 @@ export default function ReceivedInvoices({
alert.success(data.message || "Faktura byla aktualizována");
setEditOpen(false);
setEditInvoice(null);
fetchList();
refreshStats();
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
});
} else {
alert.error(data.error || "Chyba při ukládání");
}
@@ -493,8 +444,9 @@ export default function ReceivedInvoices({
if (data.success) {
alert.success(data.message || "Faktura byla smazána");
setDeleteConfirm({ show: false, invoice: null });
fetchList();
refreshStats();
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
});
} else {
alert.error(data.error || "Chyba při mazání");
}
@@ -538,8 +490,9 @@ export default function ReceivedInvoices({
const data = await res.json();
if (data.success) {
alert.success("Faktura označena jako uhrazená");
fetchList();
refreshStats();
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
});
} else {
alert.error(data.error || "Nepodařilo se změnit stav");
}
@@ -551,26 +504,15 @@ export default function ReceivedInvoices({
const monthLabel = `${MONTH_NAMES[statsMonth - 1]}`;
const renderKpi = () => {
if (!hasLoadedOnce.current && statsLoading) {
if (statsQuery.isPending && !hasLoadedOnce.current) {
return (
<div className="admin-kpi-grid admin-kpi-4 mb-6">
{[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>
<Skeleton
name="received-invoices-kpi"
loading={statsQuery.isPending && !hasLoadedOnce.current}
fixture={<ReceivedInvoicesFixture />}
>
<div />
</Skeleton>
);
}
if (!stats) {
@@ -680,18 +622,16 @@ export default function ReceivedInvoices({
/>
</div>
{loading && (
<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 w-1/4" />
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
{showListSkeleton && (
<Skeleton
name="received-invoices-list"
loading={showListSkeleton}
fixture={<ReceivedInvoicesFixture />}
>
<div />
</Skeleton>
)}
{!loading && invoices.length === 0 && (
{!showListSkeleton && invoices.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
@@ -723,7 +663,7 @@ export default function ReceivedInvoices({
)}
</div>
)}
{!loading && invoices.length > 0 && (
{!showListSkeleton && invoices.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>