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 { Link } from "react-router-dom";
|
||||
@@ -14,6 +15,9 @@ import {
|
||||
import FormField from "../components/FormField";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import apiFetch from "../utils/api";
|
||||
import { jsonQuery } from "../lib/apiAdapter";
|
||||
import { Skeleton } from "boneyard-js/react";
|
||||
import AttendanceFixture from "../fixtures/AttendanceFixture";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -92,22 +96,20 @@ function getFundBarBackground(fund: MonthlyFund) {
|
||||
export default function Attendance() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [data, setData] = useState<AttendanceData>({
|
||||
ongoing_shift: null,
|
||||
today_shifts: [],
|
||||
date: "",
|
||||
leave_balance: {
|
||||
vacation_total: 160,
|
||||
vacation_used: 0,
|
||||
vacation_remaining: 160,
|
||||
sick_used: 0,
|
||||
},
|
||||
monthly_fund: null,
|
||||
project_logs: [],
|
||||
active_project_id: null,
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["attendance", "status"],
|
||||
queryFn: () => jsonQuery<AttendanceData>("/api/admin/attendance/status"),
|
||||
});
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: ["attendance", "projects"],
|
||||
queryFn: () =>
|
||||
jsonQuery<Project[]>("/api/admin/attendance?action=projects"),
|
||||
});
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
|
||||
const [leaveForm, setLeaveForm] = useState({
|
||||
leave_type: "vacation",
|
||||
@@ -117,10 +119,7 @@ export default function Attendance() {
|
||||
});
|
||||
const [requestSubmitting, setRequestSubmitting] = useState(false);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [switchingProject, setSwitchingProject] = useState(false);
|
||||
const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([]);
|
||||
const [activeProjectId, setActiveProjectId] = useState<number | null>(null);
|
||||
const [gpsConfirm, setGpsConfirm] = useState<{
|
||||
isOpen: boolean;
|
||||
action: string | null;
|
||||
@@ -139,45 +138,12 @@ export default function Attendance() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/attendance/status`);
|
||||
if (response.status === 401) return;
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
setNotes(result.data.ongoing_shift?.notes || "");
|
||||
setProjectLogs(result.data.project_logs || []);
|
||||
setActiveProjectId(result.data.active_project_id || null);
|
||||
}
|
||||
} catch {
|
||||
alert.error("Nepodařilo se načíst data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// Sync notes from query data when the shift changes
|
||||
useEffect(() => {
|
||||
if (statusQuery.data) {
|
||||
setNotes(statusQuery.data.ongoing_shift?.notes || "");
|
||||
}
|
||||
}, [alert]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/attendance?action=projects`,
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const items = Array.isArray(result.data) ? result.data : [];
|
||||
setProjects(items);
|
||||
}
|
||||
} catch {
|
||||
// silent - projects are supplementary
|
||||
}
|
||||
};
|
||||
loadProjects();
|
||||
}, []);
|
||||
}, [statusQuery.data]);
|
||||
|
||||
useModalLock(isLeaveModalOpen);
|
||||
|
||||
@@ -277,7 +243,9 @@ export default function Attendance() {
|
||||
setSubmitting(false);
|
||||
|
||||
if (result.success) {
|
||||
await fetchData();
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["attendance", "status"],
|
||||
});
|
||||
punchTimeoutRef.current = setTimeout(() => {
|
||||
alert.success(result.data?.message || result.message || "Uloženo");
|
||||
}, 300);
|
||||
@@ -302,7 +270,9 @@ export default function Attendance() {
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
await fetchData();
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["attendance", "status"],
|
||||
});
|
||||
alert.success(
|
||||
result.data?.message || result.message || "Přestávka zaznamenána",
|
||||
);
|
||||
@@ -348,7 +318,9 @@ export default function Attendance() {
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
await fetchData();
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["attendance", "status"],
|
||||
});
|
||||
alert.success(
|
||||
result.data?.message || result.message || "Projekt přepnut",
|
||||
);
|
||||
@@ -390,7 +362,9 @@ export default function Attendance() {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setIsLeaveModalOpen(false);
|
||||
await fetchData();
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["attendance", "status"],
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success(
|
||||
result.data?.message || result.message || "Žádost odeslána",
|
||||
@@ -411,103 +385,15 @@ export default function Attendance() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (!statusQuery.data) {
|
||||
return (
|
||||
<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>
|
||||
<div style={{ display: "flex", gap: "1.5rem" }}>
|
||||
<div className="admin-card" style={{ flex: 2 }}>
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "120px", marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ width: "180px" }}
|
||||
/>
|
||||
<div className="admin-skeleton-row">
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
className="admin-skeleton-line w-1/3"
|
||||
style={{ marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line w-1/4"
|
||||
style={{ height: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
className="admin-skeleton-line w-1/3"
|
||||
style={{ marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line w-1/4"
|
||||
style={{ height: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="admin-skeleton-line h-10"
|
||||
style={{ width: "100%", borderRadius: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: "1rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-line w-1/3"
|
||||
style={{ marginBottom: "0.25rem" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "80px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "100%", height: "6px", borderRadius: "3px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: "1rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-line w-1/3"
|
||||
style={{ marginBottom: "0.25rem" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line h-8"
|
||||
style={{ width: "80px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{ width: "100%", height: "6px", borderRadius: "3px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton
|
||||
name="attendance"
|
||||
loading={statusQuery.isPending}
|
||||
fixture={<AttendanceFixture />}
|
||||
>
|
||||
<div />
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -515,7 +401,11 @@ export default function Attendance() {
|
||||
ongoing_shift: ongoingShift,
|
||||
today_shifts: todayShifts,
|
||||
leave_balance: leaveBalance,
|
||||
} = data;
|
||||
} = statusQuery.data;
|
||||
const data = statusQuery.data;
|
||||
const projects = projectsQuery.data ?? [];
|
||||
const projectLogs = data.project_logs ?? [];
|
||||
const activeProjectId = data.active_project_id ?? null;
|
||||
const isOngoingShift = ongoingShift && !ongoingShift.departure_time;
|
||||
const completedToday = todayShifts.filter((s) => s.departure_time);
|
||||
const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8);
|
||||
|
||||
Reference in New Issue
Block a user