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 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 as any).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 Odpracováno +/− 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 as any).fund_to_date ?? monthData.fund}h ({Math.round(((monthData as any).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 as any).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" />
) }