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