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,9 +1,11 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import { companySettingsOptions } from "../lib/queries/settings";
|
||||
import { attendanceHistoryOptions } from "../lib/queries/attendance";
|
||||
import {
|
||||
formatDate,
|
||||
formatDatetime,
|
||||
@@ -16,10 +18,8 @@ import {
|
||||
formatTimeOrDatetimePrint,
|
||||
} from "../utils/attendanceHelpers";
|
||||
import FormField from "../components/FormField";
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
import { Skeleton } from "boneyard-js/react";
|
||||
import AttendanceHistoryFixture from "../fixtures/AttendanceHistoryFixture";
|
||||
interface ProjectLog {
|
||||
id?: number;
|
||||
project_id?: number;
|
||||
@@ -193,48 +193,21 @@ const renderProjectCell = (record: AttendanceRecord) => {
|
||||
};
|
||||
|
||||
export default function AttendanceHistory() {
|
||||
const alert = useAlert();
|
||||
const { user, hasPermission } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
const { data: companySettings } = useQuery(companySettingsOptions());
|
||||
const companyName =
|
||||
((companySettings as Record<string, unknown> | undefined)
|
||||
?.company_name as string) || "";
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
const [month, setMonth] = useState(() => {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
});
|
||||
const [records, setRecords] = useState<AttendanceRecord[]>([]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [yearStr, monthStr] = month.split("-");
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000&user_id=${user?.id || ""}`,
|
||||
);
|
||||
if (response.status === 401) return;
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setRecords(result.data);
|
||||
}
|
||||
} catch {
|
||||
alert.error("Nepodařilo se načíst data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [month, alert, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch(`${API_BASE}/company-settings`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (d.success) setCompanyName(d.data.company_name || "");
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const { data, isPending } = useQuery(
|
||||
attendanceHistoryOptions({ month, userId: user?.id }),
|
||||
);
|
||||
const records = (data as AttendanceRecord[] | undefined) ?? [];
|
||||
|
||||
const computed = useMemo(() => {
|
||||
const [yearStr, monthStr] = month.split("-");
|
||||
@@ -459,144 +432,123 @@ export default function AttendanceHistory() {
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
{loading && (
|
||||
<div className="admin-skeleton" style={{ gap: "0.5rem" }}>
|
||||
<div className="admin-skeleton-row" style={{ gap: "1rem" }}>
|
||||
<div
|
||||
className="admin-skeleton-line"
|
||||
style={{
|
||||
width: "48px",
|
||||
height: "48px",
|
||||
borderRadius: "12px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className="admin-skeleton-line w-1/2"
|
||||
style={{ marginBottom: "0.5rem" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line w-full"
|
||||
style={{ height: "6px", borderRadius: "3px" }}
|
||||
/>
|
||||
<div
|
||||
className="admin-skeleton-line w-1/3"
|
||||
style={{ height: "10px", marginTop: "0.5rem" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && computed.monthlyFund && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div className="admin-stat-icon info">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: "200px" }}>
|
||||
<Skeleton
|
||||
name="attendance-history-fund"
|
||||
loading={isPending}
|
||||
fixture={<AttendanceHistoryFixture />}
|
||||
>
|
||||
<>
|
||||
{computed.monthlyFund && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
marginBottom: "0.375rem",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "1rem",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Fond: {computed.monthlyFund.worked}h /{" "}
|
||||
{computed.monthlyFund.fund}h
|
||||
</span>
|
||||
<span
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.8125rem" }}
|
||||
>
|
||||
{computed.monthlyFund.business_days} prac. dnů
|
||||
</span>
|
||||
</div>
|
||||
<div className="attendance-balance-bar">
|
||||
<div
|
||||
className="attendance-balance-progress"
|
||||
style={{
|
||||
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.covered / computed.monthlyFund.fund) * 100 : 0)}%`,
|
||||
background:
|
||||
computed.monthlyFund.covered >=
|
||||
computed.monthlyFund.fund
|
||||
? "linear-gradient(135deg, var(--success), #059669)"
|
||||
: "var(--gradient)",
|
||||
}}
|
||||
/>
|
||||
<div className="admin-stat-icon info">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: "200px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
marginBottom: "0.375rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "1rem",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Fond: {computed.monthlyFund.worked}h /{" "}
|
||||
{computed.monthlyFund.fund}h
|
||||
</span>
|
||||
<span
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.8125rem" }}
|
||||
>
|
||||
{computed.monthlyFund.business_days} prac. dnů
|
||||
</span>
|
||||
</div>
|
||||
<div className="attendance-balance-bar">
|
||||
<div
|
||||
className="attendance-balance-progress"
|
||||
style={{
|
||||
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.covered / computed.monthlyFund.fund) * 100 : 0)}%`,
|
||||
background:
|
||||
computed.monthlyFund.covered >=
|
||||
computed.monthlyFund.fund
|
||||
? "linear-gradient(135deg, var(--success), #059669)"
|
||||
: "var(--gradient)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="text-muted"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.75rem",
|
||||
marginTop: "0.375rem",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{"Pokryto: "}
|
||||
{computed.monthlyFund.covered}h (práce{" "}
|
||||
{computed.monthlyFund.worked}h
|
||||
{computed.vacationHours > 0 &&
|
||||
` + dovolená ${computed.vacationHours}h`}
|
||||
{computed.sickHours > 0 &&
|
||||
` + nemoc ${computed.sickHours}h`}
|
||||
{computed.holidayHours > 0 &&
|
||||
` + svátek ${computed.holidayHours}h`}
|
||||
{computed.unpaidHours > 0 &&
|
||||
` + neplacené ${computed.unpaidHours}h`}
|
||||
)
|
||||
</span>
|
||||
{computed.monthlyFund.overtime > 0 ? (
|
||||
<span className="text-warning fw-600">
|
||||
Přesčas: +{computed.monthlyFund.overtime}h
|
||||
</span>
|
||||
) : (
|
||||
<span>Zbývá: {computed.monthlyFund.remaining}h</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!computed.monthlyFund && (
|
||||
<div
|
||||
className="text-muted"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.75rem",
|
||||
marginTop: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
textAlign: "center",
|
||||
padding: "0.5rem 0",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{"Pokryto: "}
|
||||
{computed.monthlyFund.covered}h (práce{" "}
|
||||
{computed.monthlyFund.worked}h
|
||||
{computed.vacationHours > 0 &&
|
||||
` + dovolená ${computed.vacationHours}h`}
|
||||
{computed.sickHours > 0 &&
|
||||
` + nemoc ${computed.sickHours}h`}
|
||||
{computed.holidayHours > 0 &&
|
||||
` + svátek ${computed.holidayHours}h`}
|
||||
{computed.unpaidHours > 0 &&
|
||||
` + neplacené ${computed.unpaidHours}h`}
|
||||
)
|
||||
</span>
|
||||
{computed.monthlyFund.overtime > 0 ? (
|
||||
<span className="text-warning fw-600">
|
||||
Přesčas: +{computed.monthlyFund.overtime}h
|
||||
</span>
|
||||
) : (
|
||||
<span>Zbývá: {computed.monthlyFund.remaining}h</span>
|
||||
)}
|
||||
Fond měsíce není k dispozici
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !computed.monthlyFund && (
|
||||
<div
|
||||
className="text-muted"
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
textAlign: "center",
|
||||
padding: "0.5rem 0",
|
||||
}}
|
||||
>
|
||||
Fond měsíce není k dispozici
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
</Skeleton>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -608,91 +560,90 @@ export default function AttendanceHistory() {
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
>
|
||||
<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" />
|
||||
<Skeleton
|
||||
name="attendance-history-table"
|
||||
loading={isPending}
|
||||
fixture={<AttendanceHistoryFixture />}
|
||||
>
|
||||
<>
|
||||
{records.length === 0 && (
|
||||
<div className="admin-empty-state">
|
||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!loading && records.length === 0 && (
|
||||
<div className="admin-empty-state">
|
||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && records.length > 0 && (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Typ</th>
|
||||
<th>Příchod</th>
|
||||
<th>Pauza</th>
|
||||
<th>Odchod</th>
|
||||
<th>Hodiny</th>
|
||||
<th>Projekty</th>
|
||||
<th>Poznámka</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (Number(record.leave_hours) || 8) * 60
|
||||
: calculateWorkMinutes(record);
|
||||
|
||||
return (
|
||||
<tr key={record.id}>
|
||||
<td className="admin-mono">
|
||||
{formatDate(record.shift_date)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
||||
>
|
||||
{getLeaveTypeName(leaveType)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatDatetime(record.arrival_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatBreakRange(record)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave
|
||||
? "—"
|
||||
: formatDatetime(record.departure_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{workMinutes > 0
|
||||
? formatMinutes(workMinutes, true)
|
||||
: "—"}
|
||||
</td>
|
||||
<td>{renderProjectCell(record)}</td>
|
||||
<td
|
||||
style={{
|
||||
maxWidth: "150px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{record.notes || ""}
|
||||
</td>
|
||||
)}
|
||||
{records.length > 0 && (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Typ</th>
|
||||
<th>Příchod</th>
|
||||
<th>Pauza</th>
|
||||
<th>Odchod</th>
|
||||
<th>Hodiny</th>
|
||||
<th>Projekty</th>
|
||||
<th>Poznámka</th>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (Number(record.leave_hours) || 8) * 60
|
||||
: calculateWorkMinutes(record);
|
||||
|
||||
return (
|
||||
<tr key={record.id}>
|
||||
<td className="admin-mono">
|
||||
{formatDate(record.shift_date)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
||||
>
|
||||
{getLeaveTypeName(leaveType)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave
|
||||
? "—"
|
||||
: formatDatetime(record.arrival_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatBreakRange(record)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave
|
||||
? "—"
|
||||
: formatDatetime(record.departure_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{workMinutes > 0
|
||||
? formatMinutes(workMinutes, true)
|
||||
: "—"}
|
||||
</td>
|
||||
<td>{renderProjectCell(record)}</td>
|
||||
<td
|
||||
style={{
|
||||
maxWidth: "150px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{record.notes || ""}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Skeleton>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user