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; } 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; } interface FundData { months: Record; holidays: unknown[]; users: UserShort[]; balances: Record; } interface ProjectData { months: Record; } 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: 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 ; 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 && (
{Object.entries(balancesData.balances).map( ([userId, balance]) => { const yf = getYearFundTotals(userId); return ( ); }, )}
Zaměstnanec Nárok (h) Čerpáno (h) Zbývá (h) Nemoc (h) Fond roku Pokryto +/− Akce
{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}

setEditForm({ ...editForm, vacation_total: parseFloat(e.target.value), }) } min="0" max="500" step="1" className="admin-form-input" /> setEditForm({ ...editForm, vacation_used: parseFloat(e.target.value), }) } min="0" max="500" step="0.5" className="admin-form-input" /> setEditForm({ ...editForm, sick_used: parseFloat(e.target.value), }) } min="0" max="500" step="0.5" className="admin-form-input" />
)} {/* 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" />
); }