Files
app/src/admin/pages/AttendanceBalances.tsx
BOHA ba95723b61 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>
2026-04-28 22:35:43 +02:00

952 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
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 {
name: string;
vacation_total: number;
vacation_used: number;
vacation_remaining: number;
sick_used: number;
}
interface UserShort {
id: number | string;
name: string;
}
interface FundUserData {
name: string;
worked: number;
covered: number;
overtime: number;
missing: number;
}
interface MonthFundData {
month_name: string;
fund: number;
fund_to_date: number;
business_days: number;
users?: Record<string, FundUserData>;
}
interface ProjectUser {
user_id: number;
user_name: string;
hours: number;
}
interface ProjectEntry {
project_id: number | null;
project_number?: string;
project_name?: string;
hours: number;
users: ProjectUser[];
}
interface MonthProjectData {
month_name: string;
projects: ProjectEntry[];
}
interface BalancesData {
users: UserShort[];
balances: Record<string, BalanceEntry>;
}
interface FundData {
months: Record<string, MonthFundData>;
holidays: unknown[];
users: UserShort[];
balances: Record<string, unknown>;
}
interface ProjectData {
months: Record<string, MonthProjectData>;
}
const getVacationClass = (remaining: number): string => {
if (remaining <= 0) return "text-danger";
if (remaining < 20) return "text-warning";
return "";
};
const renderFundDiff = (data: { overtime: number; missing: number }) => {
if (data.overtime > 0) {
return <span className="text-warning fw-600">+{data.overtime}h</span>;
}
if (data.missing > 0) {
return <span className="text-danger">-{data.missing}h</span>;
}
return <span className="text-success">0h</span>;
};
const renderMonthlyStatus = (
us: FundUserData,
isFulfilled: boolean,
isCurrentMonth: boolean,
) => {
if (us.overtime > 0) {
return (
<span className="text-warning fw-600" style={{ fontSize: "11px" }}>
+{us.overtime}h
</span>
);
}
if (us.missing > 0) {
return (
<span className="text-danger fw-600" style={{ fontSize: "11px" }}>
-{us.missing}h
</span>
);
}
if (isFulfilled && !isCurrentMonth) {
return (
<span className="text-success" style={{ fontSize: "11px" }}>
OK
</span>
);
}
return null;
};
const getProgressBackground = (
us: FundUserData,
isFulfilled: boolean,
isCurrentMonth: boolean,
): string => {
if (us.overtime > 0)
return "linear-gradient(135deg, var(--warning), #d97706)";
if (isFulfilled) return "linear-gradient(135deg, var(--success), #059669)";
if (isCurrentMonth) return "var(--gradient)";
return "var(--danger)";
};
export default function AttendanceBalances() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const [year, setYear] = useState(new Date().getFullYear());
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<{
id: string;
name: string;
} | null>(null);
const [editForm, setEditForm] = useState({
vacation_total: 160,
vacation_used: 0,
sick_used: 0,
});
const [resetConfirm, setResetConfirm] = useState<{
show: boolean;
userId: string | null;
userName: string;
}>({ show: false, userId: null, userName: "" });
useModalLock(showEditModal);
if (!hasPermission("attendance.balances")) return <Forbidden />;
const openEditModal = (userId: string, balance: BalanceEntry) => {
setEditingUser({ id: userId, name: balance.name });
setEditForm({
vacation_total: balance.vacation_total,
vacation_used: balance.vacation_used,
sick_used: balance.sick_used,
});
setShowEditModal(true);
};
const handleEditSubmit = async () => {
if (!editingUser) return;
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=balances`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: editingUser.id,
year,
action_type: "edit",
...editForm,
}),
},
);
const result = await response.json();
if (result.success) {
setShowEditModal(false);
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
}
};
const handleReset = async () => {
if (!resetConfirm.userId) return;
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=balances`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: resetConfirm.userId,
year,
action_type: "reset",
}),
},
);
const result = await response.json();
if (result.success) {
setResetConfirm({ show: false, userId: null, userName: "" });
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
}
};
const years: number[] = [];
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
for (let y = currentYear - 5; y <= currentYear + 5; y++) {
years.push(y);
}
const getYearFundTotals = (userId: string) => {
if (!fundData?.months || Object.keys(fundData.months).length === 0)
return null;
let totalFund = 0;
let totalWorked = 0;
let totalCovered = 0;
for (const monthData of Object.values(fundData.months)) {
// Use prorated fund (fund_to_date) for current month, full fund for past
totalFund += monthData.fund_to_date ?? monthData.fund;
const us = monthData.users?.[userId];
if (us) {
totalWorked += us.worked;
totalCovered += us.covered;
}
}
const missing = Math.max(
0,
Math.round((totalFund - totalCovered) * 10) / 10,
);
const overtime = Math.max(
0,
Math.round((totalCovered - totalFund) * 10) / 10,
);
return {
fund: totalFund,
worked: Math.round(totalWorked * 10) / 10,
covered: Math.round(totalCovered * 10) / 10,
missing,
overtime,
};
};
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">Správa bilancí</h1>
</div>
<div className="admin-page-actions">
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="admin-form-select"
style={{ minWidth: "100px" }}
>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</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-body">
<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 */}
{!fundPending &&
fundData?.months &&
Object.keys(fundData.months).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
className="mt-6"
>
<h2
className="admin-page-title mb-4"
style={{ fontSize: "1.25rem" }}
>
Měsíční přehled fondu {year}
</h2>
<div className="admin-grid admin-grid-3">
{Object.entries(fundData.months).map(([monthKey, monthData]) => {
const isCurrentMonth =
year === currentYear && parseInt(monthKey) === currentMonth;
return (
<div
key={monthKey}
className="admin-card"
style={
isCurrentMonth
? {
borderColor: "var(--accent-color)",
boxShadow: "0 0 0 1px var(--accent-color)",
}
: {}
}
>
<div className="admin-card-body">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.75rem",
}}
>
<h3
style={{
fontWeight: 600,
fontSize: "1rem",
margin: 0,
}}
>
{monthData.month_name}
{isCurrentMonth && (
<span
style={{
marginLeft: "0.5rem",
fontSize: "0.7rem",
padding: "0.125rem 0.375rem",
background: "var(--accent-light)",
color: "var(--accent-color)",
borderRadius: "var(--border-radius-sm)",
fontWeight: 500,
}}
>
aktuální
</span>
)}
</h3>
<span
className="text-secondary"
style={{ fontSize: "12px" }}
>
{monthData.fund_to_date ?? monthData.fund}h (
{Math.round(
(monthData.fund_to_date ?? monthData.fund) / 8,
)}{" "}
dnů)
</span>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.375rem",
}}
>
{fundData?.users &&
fundData.users.map((user) => {
const us = monthData.users?.[String(user.id)];
if (!us) return null;
const effectiveFund =
monthData.fund_to_date ?? monthData.fund;
const pct =
effectiveFund > 0
? Math.min(
100,
(us.covered / effectiveFund) * 100,
)
: 0;
const isFulfilled = us.covered >= effectiveFund;
return (
<div key={user.id}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "12px",
}}
>
<span
style={{ color: "var(--text-primary)" }}
>
{us.name}
</span>
<span
style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
}}
>
<span className="text-secondary">
{us.worked}h
</span>
{renderMonthlyStatus(
us,
isFulfilled,
isCurrentMonth,
)}
</span>
</div>
<div
style={{
marginTop: "0.125rem",
height: "3px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${pct}%`,
background: getProgressBackground(
us,
isFulfilled,
isCurrentMonth,
),
borderRadius: "2px",
transition: "width 0.3s ease",
}}
/>
</div>
</div>
);
})}
</div>
</div>
</div>
);
})}
</div>
</motion.div>
)}
{fundPending && (
<Skeleton
name="attendance-balances-fund"
loading={fundPending}
fixture={<AttendanceBalancesFixture />}
>
<div className="mt-6" />
</Skeleton>
)}
{/* Monthly Project Overview */}
{!projectPending &&
projectData?.months &&
Object.keys(projectData.months).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
className="mt-6"
>
<h2
className="admin-page-title mb-4"
style={{ fontSize: "1.25rem" }}
>
Měsíční přehled projektů {year}
</h2>
<div className="admin-grid admin-grid-3">
{Object.entries(projectData.months).map(
([monthKey, monthInfo]) => {
const isCurrentMonth =
year === currentYear && parseInt(monthKey) === currentMonth;
const totalHours = monthInfo.projects.reduce(
(sum, p) => sum + p.hours,
0,
);
if (monthInfo.projects.length === 0) return null;
return (
<div
key={monthKey}
className="admin-card"
style={
isCurrentMonth
? {
borderColor: "var(--accent-color)",
boxShadow: "0 0 0 1px var(--accent-color)",
}
: {}
}
>
<div className="admin-card-body">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.75rem",
}}
>
<h3
style={{
fontWeight: 600,
fontSize: "1rem",
margin: 0,
}}
>
{monthInfo.month_name}
{isCurrentMonth && (
<span
style={{
marginLeft: "0.5rem",
fontSize: "0.7rem",
padding: "0.125rem 0.375rem",
background: "var(--accent-light)",
color: "var(--accent-color)",
borderRadius: "var(--border-radius-sm)",
fontWeight: 500,
}}
>
aktuální
</span>
)}
</h3>
<span
className="text-secondary fw-600"
style={{ fontSize: "12px" }}
>
{totalHours.toFixed(1)}h
</span>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.75rem",
}}
>
{monthInfo.projects.map((proj) => (
<div key={proj.project_id || "no-project"}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.25rem",
}}
>
<span
style={{
fontSize: "12px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{proj.project_id
? proj.project_number
: "Bez projektu"}
</span>
<span
className="text-secondary fw-600"
style={{ fontSize: "12px" }}
>
{proj.hours.toFixed(1)}h
</span>
</div>
{proj.project_id && proj.project_name && (
<div
className="text-muted"
style={{
fontSize: "0.7rem",
marginBottom: "0.25rem",
}}
>
{proj.project_name}
</div>
)}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.125rem",
}}
>
{proj.users.map((u) => {
const pct =
proj.hours > 0
? Math.min(
100,
(u.hours / proj.hours) * 100,
)
: 0;
return (
<div key={u.user_id}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "11px",
}}
>
<span className="text-secondary">
{u.user_name}
</span>
<span className="text-secondary">
{u.hours.toFixed(1)}h
</span>
</div>
<div
style={{
marginTop: "1px",
height: "3px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${pct}%`,
background: proj.project_id
? "var(--gradient)"
: "#94a3b8",
borderRadius: "2px",
transition: "width 0.3s ease",
}}
/>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
</div>
);
},
)}
</div>
</motion.div>
)}
{projectPending && (
<Skeleton
name="attendance-balances-projects"
loading={projectPending}
fixture={<AttendanceBalancesFixture />}
>
<div className="mt-6" />
</Skeleton>
)}
{/* Edit Modal */}
<AnimatePresence>
{showEditModal && editingUser && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => setShowEditModal(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit dovolenou</h2>
<p className="text-secondary" style={{ marginTop: "0.25rem" }}>
{editingUser.name}
</p>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Nárok na dovolenou (hodiny)">
<input
type="number"
value={editForm.vacation_total}
onChange={(e) =>
setEditForm({
...editForm,
vacation_total: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="1"
className="admin-form-input"
/>
</FormField>
<FormField label="Čerpáno dovolené (hodiny)">
<input
type="number"
value={editForm.vacation_used}
onChange={(e) =>
setEditForm({
...editForm,
vacation_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
<FormField label="Čerpáno nemocenské (hodiny)">
<input
type="number"
value={editForm.sick_used}
onChange={(e) =>
setEditForm({
...editForm,
sick_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleEditSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Reset Confirmation */}
<ConfirmModal
isOpen={resetConfirm.show}
onClose={() =>
setResetConfirm({ show: false, userId: null, userName: "" })
}
onConfirm={handleReset}
title="Resetovat bilanci"
message={`Opravdu chcete vynulovat čerpání dovolené a nemocenské pro ${resetConfirm.userName} za rok ${year}?`}
confirmText="Resetovat"
confirmVariant="danger"
/>
</div>
);
}