initial commit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 08:46:51 +01:00
commit 4608494a3f
130 changed files with 40361 additions and 0 deletions

View File

@@ -0,0 +1,728 @@
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)) {
totalFund += 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>
)
}