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, useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
@@ -9,65 +10,35 @@ import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
import { formatDate, formatTime } from "../utils/attendanceHelpers";
|
||||
import apiFetch from "../utils/api";
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface LocationRecord {
|
||||
user_name: string;
|
||||
shift_date: string;
|
||||
arrival_time?: string | null;
|
||||
departure_time?: string | null;
|
||||
arrival_lat?: string | number | null;
|
||||
arrival_lng?: string | number | null;
|
||||
arrival_accuracy?: number | null;
|
||||
arrival_address?: string | null;
|
||||
departure_lat?: string | number | null;
|
||||
departure_lng?: string | number | null;
|
||||
departure_accuracy?: number | null;
|
||||
departure_address?: string | null;
|
||||
}
|
||||
import {
|
||||
attendanceLocationOptions,
|
||||
type LocationRecord,
|
||||
} from "../lib/queries/attendance";
|
||||
import { Skeleton } from "boneyard-js/react";
|
||||
import AttendanceLocationFixture from "../fixtures/AttendanceLocationFixture";
|
||||
|
||||
export default function AttendanceLocation() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [record, setRecord] = useState<LocationRecord | null>(null);
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<unknown>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/attendance?action=location&id=${id}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const raw = result.data.record || result.data;
|
||||
// Enrich with user_name from nested users relation
|
||||
const userName = raw.users
|
||||
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
|
||||
: raw.user_name || "";
|
||||
setRecord({ ...raw, user_name: userName });
|
||||
} else {
|
||||
alert.error("Záznam nebyl nalezen");
|
||||
navigate("/attendance/admin");
|
||||
}
|
||||
} catch {
|
||||
alert.error("Nepodařilo se načíst data");
|
||||
navigate("/attendance/admin");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const locationQuery = useQuery(attendanceLocationOptions(id));
|
||||
const record = locationQuery.data ?? null;
|
||||
const isPending = locationQuery.isPending;
|
||||
|
||||
fetchData();
|
||||
}, [id, alert, navigate]);
|
||||
// Navigate away on fetch error
|
||||
useEffect(() => {
|
||||
if (locationQuery.error) {
|
||||
alert.error("Nepodařilo se načíst data");
|
||||
navigate("/attendance/admin");
|
||||
}
|
||||
}, [locationQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!record || loading) return;
|
||||
if (!record || isPending) return;
|
||||
|
||||
const hasArrivalLocation = record.arrival_lat && record.arrival_lng;
|
||||
const hasDepartureLocation = record.departure_lat && record.departure_lng;
|
||||
@@ -175,7 +146,7 @@ export default function AttendanceLocation() {
|
||||
mapInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [record, loading]);
|
||||
}, [record, isPending]);
|
||||
|
||||
const formatDatetimeLocal = (datetime: string | null | undefined): string => {
|
||||
if (!datetime) return "—";
|
||||
@@ -185,56 +156,6 @@ export default function AttendanceLocation() {
|
||||
|
||||
if (!hasPermission("attendance.admin")) return <Forbidden />;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-row"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}
|
||||
>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "200px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "100%", height: "300px", borderRadius: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "1.25rem",
|
||||
}}
|
||||
>
|
||||
{[0, 1].map((i) => (
|
||||
<div key={i} className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: "1rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "50%" }}
|
||||
/>
|
||||
<div className="admin-skeleton-line w-full" />
|
||||
<div className="admin-skeleton-line w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
@@ -248,102 +169,70 @@ export default function AttendanceLocation() {
|
||||
const month = shiftDateStr.substring(0, 7);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Poloha záznamu</h1>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<Link
|
||||
to={`/attendance/admin?month=${month}`}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
← Zpět na správu
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-header">
|
||||
<h2 className="admin-card-title">
|
||||
{record.user_name} — {formatDate(record.shift_date)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
{hasAnyLocation && (
|
||||
<div ref={mapRef} className="attendance-location-map" />
|
||||
)}
|
||||
|
||||
<div className="attendance-location-grid">
|
||||
{/* Arrival */}
|
||||
<div
|
||||
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
|
||||
<Skeleton
|
||||
name="attendance-location"
|
||||
loading={isPending}
|
||||
fixture={<AttendanceLocationFixture />}
|
||||
>
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Poloha záznamu</h1>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<Link
|
||||
to={`/attendance/admin?month=${month}`}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
<h3 className="attendance-location-title">Příchod</h3>
|
||||
<div className="attendance-location-time">
|
||||
{record.arrival_time
|
||||
? formatDatetimeLocal(record.arrival_time)
|
||||
: "—"}
|
||||
</div>
|
||||
{hasArrivalLocation ? (
|
||||
<>
|
||||
<div className="attendance-location-address">
|
||||
{record.arrival_address || <em>Adresa nezjištěna</em>}
|
||||
</div>
|
||||
<div className="attendance-location-coords">
|
||||
GPS: {record.arrival_lat}, {record.arrival_lng}
|
||||
{record.arrival_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
|
||||
</div>
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
|
||||
>
|
||||
Otevřít v Google Maps
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<div className="attendance-location-address">
|
||||
<em>Poloha nebyla zaznamenána</em>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
← Zpět na správu
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Departure */}
|
||||
{(hasDepartureLocation || record.departure_time) && (
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-header">
|
||||
<h2 className="admin-card-title">
|
||||
{record.user_name} — {formatDate(record.shift_date)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
{hasAnyLocation && (
|
||||
<div ref={mapRef} className="attendance-location-map" />
|
||||
)}
|
||||
|
||||
<div className="attendance-location-grid">
|
||||
{/* Arrival */}
|
||||
<div
|
||||
className={`attendance-location-card ${!hasDepartureLocation ? "empty" : ""}`}
|
||||
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
|
||||
>
|
||||
<h3 className="attendance-location-title">Odchod</h3>
|
||||
<h3 className="attendance-location-title">Příchod</h3>
|
||||
<div className="attendance-location-time">
|
||||
{record.departure_time
|
||||
? formatDatetimeLocal(record.departure_time)
|
||||
{record.arrival_time
|
||||
? formatDatetimeLocal(record.arrival_time)
|
||||
: "—"}
|
||||
</div>
|
||||
{hasDepartureLocation ? (
|
||||
{hasArrivalLocation ? (
|
||||
<>
|
||||
<div className="attendance-location-address">
|
||||
{record.departure_address || <em>Adresa nezjištěna</em>}
|
||||
{record.arrival_address || <em>Adresa nezjištěna</em>}
|
||||
</div>
|
||||
<div className="attendance-location-coords">
|
||||
GPS: {record.departure_lat}, {record.departure_lng}
|
||||
{record.departure_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
|
||||
GPS: {record.arrival_lat}, {record.arrival_lng}
|
||||
{record.arrival_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
|
||||
</div>
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
|
||||
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
|
||||
@@ -357,10 +246,48 @@ export default function AttendanceLocation() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Departure */}
|
||||
{(hasDepartureLocation || record.departure_time) && (
|
||||
<div
|
||||
className={`attendance-location-card ${!hasDepartureLocation ? "empty" : ""}`}
|
||||
>
|
||||
<h3 className="attendance-location-title">Odchod</h3>
|
||||
<div className="attendance-location-time">
|
||||
{record.departure_time
|
||||
? formatDatetimeLocal(record.departure_time)
|
||||
: "—"}
|
||||
</div>
|
||||
{hasDepartureLocation ? (
|
||||
<>
|
||||
<div className="attendance-location-address">
|
||||
{record.departure_address || <em>Adresa nezjištěna</em>}
|
||||
</div>
|
||||
<div className="attendance-location-coords">
|
||||
GPS: {record.departure_lat}, {record.departure_lng}
|
||||
{record.departure_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
|
||||
</div>
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
|
||||
>
|
||||
Otevřít v Google Maps
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<div className="attendance-location-address">
|
||||
<em>Poloha nebyla zaznamenána</em>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user