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