import { useState, useEffect, useCallback } 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 apiFetch from "../utils/api"; 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 [loading, setLoading] = useState(true); const [year, setYear] = useState(new Date().getFullYear()); const [data, setData] = useState({ users: [], balances: {}, }); const [fundLoading, setFundLoading] = useState(true); const [fundData, setFundData] = useState({ months: {}, holidays: [], users: [], balances: {}, }); const [projectLoading, setProjectLoading] = useState(true); const [projectData, setProjectData] = useState({ months: {} }); 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: "" }); 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(() => { fetchData(); fetchFundData(); fetchProjectData(); }, [fetchData, fetchFundData, fetchProjectData]); 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 fetchData(false); await new Promise((resolve) => setTimeout(resolve, 300)); 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 fetchData(false); 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í

{loading && (
{[0, 1, 2, 3, 4].map((i) => (
))}
)} {!loading && Object.keys(data.balances).length === 0 && (

Žádní uživatelé k zobrazení.

)} {!loading && Object.keys(data.balances).length > 0 && (
{Object.entries(data.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 */} {!fundLoading && 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, )}
); })}
); })}
)} {fundLoading && (
{[0, 1, 2].map((i) => (
))}
)} {/* Monthly Project Overview */} {!projectLoading && 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
); })}
))}
); }, )}
)} {projectLoading && (
{[0, 1, 2].map((i) => (
))}
)} {/* 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" />
); }