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,
type BalanceEntry,
type BalancesData,
type FundData,
type FundUserData,
type MonthFundData,
type ProjectData,
type UserShort,
} 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";
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 +{data.overtime}h;
}
if (data.missing > 0) {
return -{data.missing}h;
}
return 0h;
};
const renderMonthlyStatus = (
us: FundUserData,
isFulfilled: boolean,
isCurrentMonth: boolean,
) => {
if (us.overtime > 0) {
return (
+{us.overtime}h
);
}
if (us.missing > 0) {
return (
-{us.missing}h
);
}
if (isFulfilled && !isCurrentMonth) {
return (
OK
);
}
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: balancesData, isPending: balancesPending } = useQuery(
attendanceBalancesOptions(year),
);
const { data: fundData, isPending: fundPending } = useQuery(
attendanceWorkFundOptions(year),
);
const { data: projectData, isPending: projectPending } = useQuery(
attendanceProjectReportOptions(year),
);
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 ;
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 (
Správa bilancí
}
>
<>
{balancesData &&
Object.keys(balancesData.balances).length === 0 && (
Žádní uživatelé k zobrazení.
)}
{balancesData &&
Object.keys(balancesData.balances).length > 0 && (
| Zaměstnanec |
Nárok (h) |
Čerpáno (h) |
Zbývá (h) |
Nemoc (h) |
Fond roku |
Pokryto |
+/− |
Akce |
{Object.entries(balancesData.balances).map(
([userId, balance]) => {
const yf = getYearFundTotals(userId);
return (
| {balance.name} |
{balance.vacation_total}
|
{balance.vacation_used.toFixed(1)}
|
{balance.vacation_remaining.toFixed(1)}
|
{balance.sick_used.toFixed(1)}
|
{yf ? `${yf.fund}h` : "—"}
|
{yf ? `${yf.covered}h` : "—"}
|
{yf ? renderFundDiff(yf) : "—"}
|
|
);
},
)}
)}
>
{/* Monthly Fund Overview */}
{!fundPending &&
fundData?.months &&
Object.keys(fundData.months).length > 0 && (
Měsíční přehled fondu {year}
{Object.entries(fundData.months).map(([monthKey, monthData]) => {
const isCurrentMonth =
year === currentYear && parseInt(monthKey) === currentMonth;
return (
{monthData.month_name}
{isCurrentMonth && (
aktuální
)}
{monthData.fund_to_date ?? monthData.fund}h (
{Math.round(
(monthData.fund_to_date ?? monthData.fund) / 8,
)}{" "}
dnů)
{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 (
{us.name}
{us.worked}h
{renderMonthlyStatus(
us,
isFulfilled,
isCurrentMonth,
)}
);
})}
);
})}
)}
{fundPending && (
}
>
)}
{/* Monthly Project Overview */}
{!projectPending &&
projectData?.months &&
Object.keys(projectData.months).length > 0 && (
Měsíční přehled projektů {year}
{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 (
{monthInfo.month_name}
{isCurrentMonth && (
aktuální
)}
{totalHours.toFixed(1)}h
{monthInfo.projects.map((proj) => (
{proj.project_id
? proj.project_number
: "Bez projektu"}
{proj.hours.toFixed(1)}h
{proj.project_id && proj.project_name && (
{proj.project_name}
)}
{proj.users.map((u) => {
const pct =
proj.hours > 0
? Math.min(
100,
(u.hours / proj.hours) * 100,
)
: 0;
return (
{u.user_name}
{u.hours.toFixed(1)}h
);
})}
))}
);
},
)}
)}
{projectPending && (
}
>
)}
{/* Edit Modal */}
{showEditModal && editingUser && (
setShowEditModal(false)}
/>
Upravit dovolenou
{editingUser.name}
)}
{/* Reset Confirmation */}
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"
/>
);
}