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>
730 lines
27 KiB
TypeScript
730 lines
27 KiB
TypeScript
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>
|
||
)
|
||
}
|