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 } from "react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
@@ -7,9 +8,9 @@ import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
import { tripHistoryOptions, tripVehiclesOptions } from "../lib/queries/trips";
import { Skeleton } from "boneyard-js/react";
import TripsHistoryFixture from "../fixtures/TripsHistoryFixture";
interface Vehicle {
id: number | string;
@@ -34,14 +35,30 @@ interface Trip {
export default function TripsHistory() {
const alert = useAlert();
const { user, hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [month, setMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [vehicleId, setVehicleId] = useState("");
const [trips, setTrips] = useState<Trip[]>([]);
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
const vehicles = vehiclesData as Vehicle[];
const { data: tripsData, isPending } = useQuery(
tripHistoryOptions({
month,
vehicleId: vehicleId ? Number(vehicleId) : undefined,
userId: user?.id,
}),
);
const trips = ((tripsData ?? []) as Record<string, unknown>[]).map((t) => ({
...t,
spz: (t.vehicles as Record<string, string>)?.spz || "",
driver_name: t.users
? `${(t.users as Record<string, string>).first_name || ""} ${(t.users as Record<string, string>).last_name || ""}`.trim()
: "",
distance: ((t.end_km as number) || 0) - ((t.start_km as number) || 0),
})) as Trip[];
const totals = trips.reduce(
(acc, t) => ({
@@ -52,52 +69,6 @@ export default function TripsHistory() {
{ total: 0, business: 0, count: 0 },
);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ month });
if (user?.id) params.set("user_id", String(user.id));
if (vehicleId) params.set("vehicle_id", vehicleId);
const [tripsRes, vehiclesRes] = await Promise.all([
apiFetch(`${API_BASE}/trips?${params}`),
apiFetch(`${API_BASE}/vehicles`),
]);
if (tripsRes.status === 401) return;
const tripsResult = await tripsRes.json();
const vehiclesResult = await vehiclesRes.json();
if (tripsResult.success) {
const raw = Array.isArray(tripsResult.data)
? tripsResult.data
: tripsResult.data?.items || [];
setTrips(
raw.map((t: Record<string, unknown>) => ({
...t,
spz: (t.vehicles as Record<string, string>)?.spz || "",
driver_name: t.users
? `${(t.users as Record<string, string>).first_name || ""} ${(t.users as Record<string, string>).last_name || ""}`.trim()
: "",
distance:
((t.end_km as number) || 0) - ((t.start_km as number) || 0),
})),
);
}
if (vehiclesResult.success) {
setVehicles(
Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [],
);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false);
}
}, [month, vehicleId, alert, user?.id]);
useEffect(() => {
fetchData();
}, [fetchData]);
if (!hasPermission("trips.history")) return <Forbidden />;
const getMonthName = (monthStr: string): string => {
@@ -240,88 +211,86 @@ export default function TripsHistory() {
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton gap-5">
{[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/3" />
<div className="admin-skeleton-line w-1/4" />
<Skeleton
name="trips-history"
loading={isPending}
fixture={<TripsHistoryFixture />}
>
<>
{trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
))}
</div>
)}
{!loading && trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
)}
{!loading && trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Vozidlo</th>
<th>Řidič</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td style={{ color: "var(--text-secondary)" }}>
{trip.driver_name}
</td>
<td>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span
style={{
whiteSpace: "nowrap",
color: "var(--text-secondary)",
}}
>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td>
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td
style={{
color: "var(--text-secondary)",
maxWidth: "200px",
}}
>
{trip.notes || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
)}
{trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Vozidlo</th>
<th>Řidič</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td style={{ color: "var(--text-secondary)" }}>
{trip.driver_name}
</td>
<td>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span
style={{
whiteSpace: "nowrap",
color: "var(--text-secondary)",
}}
>
{formatKm(trip.start_km)} -{" "}
{formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td>
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td
style={{
color: "var(--text-secondary)",
maxWidth: "200px",
}}
>
{trip.notes || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
</Skeleton>
</div>
</motion.div>
</div>