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,4 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
@@ -6,8 +6,16 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import FormField from "../components/FormField";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
attendanceBalancesOptions,
|
||||
attendanceWorkFundOptions,
|
||||
attendanceProjectReportOptions,
|
||||
} from "../lib/queries/attendance";
|
||||
|
||||
import apiFetch from "../utils/api";
|
||||
import { Skeleton } from "boneyard-js/react";
|
||||
import AttendanceBalancesFixture from "../fixtures/AttendanceBalancesFixture";
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface BalanceEntry {
|
||||
@@ -134,23 +142,20 @@ const getProgressBackground = (
|
||||
export default function AttendanceBalances() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const queryClient = useQueryClient();
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [data, setData] = useState<BalancesData>({
|
||||
users: [],
|
||||
balances: {},
|
||||
});
|
||||
|
||||
const [fundLoading, setFundLoading] = useState(true);
|
||||
const [fundData, setFundData] = useState<FundData>({
|
||||
months: {},
|
||||
holidays: [],
|
||||
users: [],
|
||||
balances: {},
|
||||
});
|
||||
|
||||
const [projectLoading, setProjectLoading] = useState(true);
|
||||
const [projectData, setProjectData] = useState<ProjectData>({ months: {} });
|
||||
const { data: balancesRaw, isPending: balancesPending } = useQuery(
|
||||
attendanceBalancesOptions(year),
|
||||
);
|
||||
const { data: fundRaw, isPending: fundPending } = useQuery(
|
||||
attendanceWorkFundOptions(year),
|
||||
);
|
||||
const { data: projectRaw, isPending: projectPending } = useQuery(
|
||||
attendanceProjectReportOptions(year),
|
||||
);
|
||||
const balancesData = balancesRaw as BalancesData | undefined;
|
||||
const fundData = fundRaw as FundData | undefined;
|
||||
const projectData = projectRaw as ProjectData | undefined;
|
||||
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<{
|
||||
@@ -169,67 +174,6 @@ export default function AttendanceBalances() {
|
||||
userName: string;
|
||||
}>({ show: false, userId: null, userName: "" });
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/attendance?action=balances&year=${year}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
}
|
||||
} catch {
|
||||
alert.error("Nepodařilo se načíst data");
|
||||
} finally {
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
},
|
||||
[year, alert],
|
||||
);
|
||||
|
||||
const fetchFundData = useCallback(async () => {
|
||||
setFundLoading(true);
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/attendance?action=workfund&year=${year}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setFundData(result.data);
|
||||
}
|
||||
} catch {
|
||||
// silent - fund data is supplementary
|
||||
} finally {
|
||||
setFundLoading(false);
|
||||
}
|
||||
}, [year]);
|
||||
|
||||
const fetchProjectData = useCallback(async () => {
|
||||
setProjectLoading(true);
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/attendance?action=project_report&year=${year}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setProjectData(result.data);
|
||||
}
|
||||
} catch {
|
||||
// silent - project data is supplementary
|
||||
} finally {
|
||||
setProjectLoading(false);
|
||||
}
|
||||
}, [year]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAll = async () => {
|
||||
await Promise.all([fetchData(), fetchFundData(), fetchProjectData()]);
|
||||
};
|
||||
loadAll();
|
||||
}, [fetchData, fetchFundData, fetchProjectData]);
|
||||
|
||||
useModalLock(showEditModal);
|
||||
|
||||
if (!hasPermission("attendance.balances")) return <Forbidden />;
|
||||
@@ -265,8 +209,7 @@ export default function AttendanceBalances() {
|
||||
|
||||
if (result.success) {
|
||||
setShowEditModal(false);
|
||||
await fetchData(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||
alert.success(result.message);
|
||||
} else {
|
||||
alert.error(result.error);
|
||||
@@ -297,7 +240,7 @@ export default function AttendanceBalances() {
|
||||
|
||||
if (result.success) {
|
||||
setResetConfirm({ show: false, userId: null, userName: "" });
|
||||
await fetchData(false);
|
||||
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||
alert.success(result.message);
|
||||
} else {
|
||||
alert.error(result.error);
|
||||
@@ -315,7 +258,7 @@ export default function AttendanceBalances() {
|
||||
}
|
||||
|
||||
const getYearFundTotals = (userId: string) => {
|
||||
if (!fundData.months || Object.keys(fundData.months).length === 0)
|
||||
if (!fundData?.months || Object.keys(fundData.months).length === 0)
|
||||
return null;
|
||||
let totalFund = 0;
|
||||
let totalWorked = 0;
|
||||
@@ -380,132 +323,137 @@ export default function AttendanceBalances() {
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
{loading && (
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[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" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!loading && Object.keys(data.balances).length === 0 && (
|
||||
<div className="admin-empty-state">
|
||||
<p>Žádní uživatelé k zobrazení.</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && Object.keys(data.balances).length > 0 && (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zaměstnanec</th>
|
||||
<th>Nárok (h)</th>
|
||||
<th>Čerpáno (h)</th>
|
||||
<th>Zbývá (h)</th>
|
||||
<th>Nemoc (h)</th>
|
||||
<th>Fond roku</th>
|
||||
<th>Pokryto</th>
|
||||
<th>+/−</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(data.balances).map(([userId, balance]) => {
|
||||
const yf = getYearFundTotals(userId);
|
||||
return (
|
||||
<tr key={userId}>
|
||||
<td className="fw-500">{balance.name}</td>
|
||||
<td className="admin-mono">{balance.vacation_total}</td>
|
||||
<td className="admin-mono">
|
||||
{balance.vacation_used.toFixed(1)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
<span
|
||||
className={getVacationClass(
|
||||
balance.vacation_remaining,
|
||||
)}
|
||||
>
|
||||
{balance.vacation_remaining.toFixed(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{balance.sick_used.toFixed(1)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{yf ? `${yf.fund}h` : "—"}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{yf ? `${yf.covered}h` : "—"}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{yf ? renderFundDiff(yf) : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<button
|
||||
onClick={() => openEditModal(userId, balance)}
|
||||
className="admin-btn-icon"
|
||||
title="Upravit"
|
||||
aria-label="Upravit"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setResetConfirm({
|
||||
show: true,
|
||||
userId,
|
||||
userName: balance.name,
|
||||
})
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Resetovat"
|
||||
aria-label="Resetovat"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Skeleton
|
||||
name="attendance-balances"
|
||||
loading={balancesPending}
|
||||
fixture={<AttendanceBalancesFixture />}
|
||||
>
|
||||
<>
|
||||
{balancesData &&
|
||||
Object.keys(balancesData.balances).length === 0 && (
|
||||
<div className="admin-empty-state">
|
||||
<p>Žádní uživatelé k zobrazení.</p>
|
||||
</div>
|
||||
)}
|
||||
{balancesData &&
|
||||
Object.keys(balancesData.balances).length > 0 && (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zaměstnanec</th>
|
||||
<th>Nárok (h)</th>
|
||||
<th>Čerpáno (h)</th>
|
||||
<th>Zbývá (h)</th>
|
||||
<th>Nemoc (h)</th>
|
||||
<th>Fond roku</th>
|
||||
<th>Pokryto</th>
|
||||
<th>+/−</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(balancesData.balances).map(
|
||||
([userId, balance]) => {
|
||||
const yf = getYearFundTotals(userId);
|
||||
return (
|
||||
<tr key={userId}>
|
||||
<td className="fw-500">{balance.name}</td>
|
||||
<td className="admin-mono">
|
||||
{balance.vacation_total}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{balance.vacation_used.toFixed(1)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
<span
|
||||
className={getVacationClass(
|
||||
balance.vacation_remaining,
|
||||
)}
|
||||
>
|
||||
{balance.vacation_remaining.toFixed(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{balance.sick_used.toFixed(1)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{yf ? `${yf.fund}h` : "—"}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{yf ? `${yf.covered}h` : "—"}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{yf ? renderFundDiff(yf) : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<button
|
||||
onClick={() =>
|
||||
openEditModal(userId, balance)
|
||||
}
|
||||
className="admin-btn-icon"
|
||||
title="Upravit"
|
||||
aria-label="Upravit"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setResetConfirm({
|
||||
show: true,
|
||||
userId,
|
||||
userName: balance.name,
|
||||
})
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Resetovat"
|
||||
aria-label="Resetovat"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Skeleton>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Monthly Fund Overview */}
|
||||
{!fundLoading &&
|
||||
fundData.months &&
|
||||
{!fundPending &&
|
||||
fundData?.months &&
|
||||
Object.keys(fundData.months).length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
@@ -587,7 +535,7 @@ export default function AttendanceBalances() {
|
||||
gap: "0.375rem",
|
||||
}}
|
||||
>
|
||||
{fundData.users &&
|
||||
{fundData?.users &&
|
||||
fundData.users.map((user) => {
|
||||
const us = monthData.users?.[String(user.id)];
|
||||
if (!us) return null;
|
||||
@@ -668,23 +616,19 @@ export default function AttendanceBalances() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{fundLoading && (
|
||||
<div className="mt-6">
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2].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" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{fundPending && (
|
||||
<Skeleton
|
||||
name="attendance-balances-fund"
|
||||
loading={fundPending}
|
||||
fixture={<AttendanceBalancesFixture />}
|
||||
>
|
||||
<div className="mt-6" />
|
||||
</Skeleton>
|
||||
)}
|
||||
|
||||
{/* Monthly Project Overview */}
|
||||
{!projectLoading &&
|
||||
projectData.months &&
|
||||
{!projectPending &&
|
||||
projectData?.months &&
|
||||
Object.keys(projectData.months).length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
@@ -876,18 +820,14 @@ export default function AttendanceBalances() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{projectLoading && (
|
||||
<div className="mt-6">
|
||||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||||
{[0, 1, 2].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" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{projectPending && (
|
||||
<Skeleton
|
||||
name="attendance-balances-projects"
|
||||
loading={projectPending}
|
||||
fixture={<AttendanceBalancesFixture />}
|
||||
>
|
||||
<div className="mt-6" />
|
||||
</Skeleton>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
|
||||
Reference in New Issue
Block a user