Files
app/src/admin/pages/AttendanceBalances.tsx
BOHA 9724a7b2e9 fix: separate full month fund from prorated fund
Monthly cards show full month fund (e.g., 168h for 21 days).
Yearly summary table uses fund_to_date (prorated to today for
current month) so the +/- column is accurate mid-month.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:12:13 +01:00

730 lines
27 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, 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<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 [loading, setLoading] = useState(true)
const [year, setYear] = useState(new Date().getFullYear())
const [data, setData] = useState<BalancesData>({
users: [],
balances: {}
})
const [fundLoading, setFundLoading] = useState(true)
const [fundData, setFundData] = useState<FundData>({
months: {},
holidays: [],
users: [],
balances: {}
})
const [projectLoading, setProjectLoading] = useState(true)
const [projectData, setProjectData] = useState<ProjectData>({ 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 <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 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 (
<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">
{loading && (
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
)}
{!loading && Object.keys(data.balances).length === 0 && (
<div className="admin-empty-state">
<p>Žádní uživatelé k zobrazení.</p>
</div>
)}
{!loading && Object.keys(data.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>Odpracováno</th>
<th>+/</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{Object.entries(data.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.worked}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>
)}
</div>
</motion.div>
{/* Monthly Fund Overview */}
{!fundLoading && 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}h ({monthData.business_days} 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 pct = monthData.fund > 0 ? Math.min(100, (us.covered / monthData.fund) * 100) : 0
const isFulfilled = us.covered >= monthData.fund
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>
)}
{fundLoading && (
<div className="mt-6">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
)}
{/* Monthly Project Overview */}
{!projectLoading && 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>
)}
{projectLoading && (
<div className="mt-6">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
)}
{/* 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>
)
}