refactor: P3 dekompozice velkych komponent

Dashboard.jsx (1346 -> 378 LOC):
- DashKpiCards, DashQuickActions, DashActivityFeed, DashAttendanceToday, DashProfile, DashSessions
- dashboardHelpers.js (konstanty + helper funkce)

OfferDetail.jsx (1061 -> ~530 LOC):
- useOfferForm hook (form state, draft, items/sections, submit)
- OfferCustomerPicker (customer search/select dropdown)

AttendanceAdmin.jsx (1036 -> ~275 LOC):
- useAttendanceAdmin hook (data fetching, filters, CRUD, print)
- AttendanceShiftTable (shift records table)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 18:22:38 +01:00
parent 6863c7c557
commit df506dfea4
14 changed files with 2558 additions and 2297 deletions

View File

@@ -1,26 +1,15 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import DOMPurify from 'dompurify'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import AdminDatePicker from '../components/AdminDatePicker'
import BulkAttendanceModal from '../components/BulkAttendanceModal'
import ShiftFormModal from '../components/ShiftFormModal'
import AttendanceShiftTable from '../components/AttendanceShiftTable'
import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api'
import {
formatDate, formatDatetime, formatTime,
calculateWorkMinutes, formatMinutes,
getLeaveTypeName, getLeaveTypeBadgeClass,
getDatePart, getTimePart,
calcProjectMinutesTotal, calcFormWorkMinutes,
formatTimeOrDatetimePrint, calculateWorkMinutesPrint
} from '../utils/attendanceHelpers'
const API_BASE = '/api/admin'
import useAttendanceAdmin from '../hooks/useAttendanceAdmin'
import { formatMinutes } from '../utils/attendanceHelpers'
function getFundBarBackground(data) {
if (data.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)'
@@ -28,160 +17,29 @@ function getFundBarBackground(data) {
return 'var(--gradient)'
}
function formatBreak(record) {
if (record.break_start && record.break_end) {
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`
}
if (record.break_start) {
return `${formatTime(record.break_start)} - ?`
}
return '—'
}
function renderProjectCell(record) {
if (record.project_logs && record.project_logs.length > 0) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}>
{record.project_logs.map((log, i) => {
let h, m, isActive = false
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(log.hours) || 0
m = parseInt(log.minutes) || 0
} else {
isActive = !log.ended_at
const end = log.ended_at ? new Date(log.ended_at) : new Date()
const mins = Math.floor((end - new Date(log.started_at)) / 60000)
h = Math.floor(mins / 60)
m = mins % 60
}
return (
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}>
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' ▸' : ''})
</span>
)
})}
</div>
)
}
if (record.project_name) {
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
}
return '—'
}
function renderFundStatus(userData) {
if (userData.overtime > 0) {
return <span className="leave-badge badge-overtime">+{userData.overtime}h přesčas</span>
}
if (userData.missing > 0) {
return <span style={{ color: '#dc2626' }}>{userData.missing}h</span>
}
return <span style={{ color: '#16a34a' }}>splněno</span>
}
export default function AttendanceAdmin() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [month, setMonth] = useState(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
})
const [filterUserId, setFilterUserId] = useState('')
const [data, setData] = useState({
records: [],
users: [],
user_totals: {},
leave_balances: {}
})
const [printData, setPrintData] = useState(null)
const printRef = useRef(null)
const [showBulkModal, setShowBulkModal] = useState(false)
const [bulkSubmitting, setBulkSubmitting] = useState(false)
const [bulkForm, setBulkForm] = useState({
month: '',
user_ids: [],
arrival_time: '08:00',
departure_time: '16:30',
break_start_time: '12:00',
break_end_time: '12:30'
})
const [showCreateModal, setShowCreateModal] = useState(false)
const today = new Date().toISOString().split('T')[0]
const [createForm, setCreateForm] = useState({
user_id: '',
shift_date: today,
leave_type: 'work',
leave_hours: 8,
arrival_date: today,
arrival_time: '',
break_start_date: today,
break_start_time: '',
break_end_date: today,
break_end_time: '',
departure_date: today,
departure_time: '',
notes: ''
})
const [showEditModal, setShowEditModal] = useState(false)
const [editingRecord, setEditingRecord] = useState(null)
const [editForm, setEditForm] = useState({
shift_date: '',
leave_type: 'work',
leave_hours: 8,
arrival_date: '',
arrival_time: '',
break_start_date: '',
break_start_time: '',
break_end_date: '',
break_end_time: '',
departure_date: '',
departure_time: '',
notes: ''
})
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, record: null })
const [projectList, setProjectList] = useState([])
const [createProjectLogs, setCreateProjectLogs] = useState([])
const [editProjectLogs, setEditProjectLogs] = useState([])
useEffect(() => {
const loadProjects = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=projects`)
const result = await response.json()
if (result.success) setProjectList(result.data.projects || [])
} catch { /* silent */ }
}
loadProjects()
}, [])
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
let url = `${API_BASE}/attendance.php?action=admin&month=${month}`
if (filterUserId) {
url += `&user_id=${filterUserId}`
}
const response = await apiFetch(url)
if (response.status === 401) return
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)
}
}, [month, filterUserId, alert])
useEffect(() => {
fetchData()
}, [fetchData])
const {
loading, month, setMonth,
filterUserId, setFilterUserId,
data, hasData,
showBulkModal, setShowBulkModal,
bulkSubmitting, bulkForm, setBulkForm,
showCreateModal, setShowCreateModal,
createForm, setCreateForm,
showEditModal, setShowEditModal,
editingRecord, editForm, setEditForm,
deleteConfirm, setDeleteConfirm,
projectList,
createProjectLogs, setCreateProjectLogs,
editProjectLogs, setEditProjectLogs,
openCreateModal, handleCreateShiftDateChange, handleCreateSubmit,
openBulkModal, toggleBulkUser, toggleAllBulkUsers, handleBulkSubmit,
openEditModal, handleEditSubmit,
handleDelete, handlePrint
} = useAttendanceAdmin({ alert })
useModalLock(showBulkModal)
useModalLock(showEditModal)
@@ -189,388 +47,6 @@ export default function AttendanceAdmin() {
if (!hasPermission('attendance.admin')) return <Forbidden />
// --- Create modal ---
const openCreateModal = () => {
const todayDate = new Date().toISOString().split('T')[0]
setCreateForm({
user_id: '',
shift_date: todayDate,
leave_type: 'work',
leave_hours: 8,
arrival_date: todayDate,
arrival_time: '',
break_start_date: todayDate,
break_start_time: '',
break_end_date: todayDate,
break_end_time: '',
departure_date: todayDate,
departure_time: '',
notes: '',
project_id: ''
})
setCreateProjectLogs([])
setShowCreateModal(true)
}
const handleCreateShiftDateChange = (newDate) => {
setCreateForm({
...createForm,
shift_date: newDate,
arrival_date: newDate,
break_start_date: newDate,
break_end_date: newDate,
departure_date: newDate
})
}
const handleCreateSubmit = async () => {
if (!createForm.user_id || !createForm.shift_date) {
alert.error('Vyplňte zaměstnance a datum směny')
return
}
const filteredCreateLogs = createProjectLogs.filter(l => l.project_id)
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
const totalWork = calcFormWorkMinutes(createForm)
const totalProject = calcProjectMinutesTotal(filteredCreateLogs)
if (totalWork > 0 && totalProject !== totalWork) {
const wH = Math.floor(totalWork / 60)
const wM = totalWork % 60
const pH = Math.floor(totalProject / 60)
const pM = totalProject % 60
alert.error(`Součet hodin projektů (${pH}h ${pM}m) neodpovídá odpracovanému času (${wH}h ${wM}m)`)
return
}
}
try {
const payload = { ...createForm }
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
payload.project_logs = filteredCreateLogs
}
const response = await apiFetch(`${API_BASE}/attendance.php?action=create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const result = await response.json()
if (result.success) {
setShowCreateModal(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í')
}
}
// --- Bulk modal ---
const openBulkModal = () => {
setBulkForm({
month: month,
user_ids: data.users.map(u => String(u.id)),
arrival_time: '08:00',
departure_time: '16:30',
break_start_time: '12:00',
break_end_time: '12:30'
})
setShowBulkModal(true)
}
const toggleBulkUser = (userId) => {
const id = String(userId)
setBulkForm(prev => ({
...prev,
user_ids: prev.user_ids.includes(id)
? prev.user_ids.filter(u => u !== id)
: [...prev.user_ids, id]
}))
}
const toggleAllBulkUsers = () => {
const allIds = data.users.map(u => String(u.id))
setBulkForm(prev => ({
...prev,
user_ids: prev.user_ids.length === allIds.length ? [] : allIds
}))
}
const handleBulkSubmit = async () => {
if (!bulkForm.month) {
alert.error('Vyberte měsíc')
return
}
if (bulkForm.user_ids.length === 0) {
alert.error('Vyberte alespoň jednoho zaměstnance')
return
}
setBulkSubmitting(true)
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=bulk_attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bulkForm)
})
const result = await response.json()
if (result.success) {
setShowBulkModal(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í')
} finally {
setBulkSubmitting(false)
}
}
// --- Edit modal ---
const openEditModal = (record) => {
setEditingRecord(record)
setEditForm({
shift_date: record.shift_date,
leave_type: record.leave_type || 'work',
leave_hours: record.leave_hours || 8,
arrival_date: getDatePart(record.arrival_time) || record.shift_date,
arrival_time: getTimePart(record.arrival_time),
break_start_date: getDatePart(record.break_start) || record.shift_date,
break_start_time: getTimePart(record.break_start),
break_end_date: getDatePart(record.break_end) || record.shift_date,
break_end_time: getTimePart(record.break_end),
departure_date: getDatePart(record.departure_time) || record.shift_date,
departure_time: getTimePart(record.departure_time),
notes: record.notes || '',
project_id: record.project_id || ''
})
const logs = (record.project_logs || []).map(l => {
if (l.hours !== null && l.hours !== undefined) {
return {
project_id: String(l.project_id),
hours: String(l.hours),
minutes: String(l.minutes || 0)
}
}
if (l.started_at && l.ended_at) {
const mins = Math.max(0, Math.floor((new Date(l.ended_at) - new Date(l.started_at)) / 60000))
return {
project_id: String(l.project_id),
hours: String(Math.floor(mins / 60)),
minutes: String(mins % 60)
}
}
return { project_id: String(l.project_id), hours: '', minutes: '' }
})
setEditProjectLogs(logs)
setShowEditModal(true)
}
const handleEditSubmit = async () => {
const isWork = (editForm.leave_type || 'work') === 'work'
const filteredEditLogs = isWork ? editProjectLogs.filter(l => l.project_id) : []
if (filteredEditLogs.length > 0) {
const totalWork = calcFormWorkMinutes(editForm)
const totalProject = calcProjectMinutesTotal(filteredEditLogs)
if (totalWork > 0 && totalProject !== totalWork) {
const wH = Math.floor(totalWork / 60)
const wM = totalWork % 60
const pH = Math.floor(totalProject / 60)
const pM = totalProject % 60
alert.error(`Součet hodin projektů (${pH}h ${pM}m) neodpovídá odpracovanému času (${wH}h ${wM}m)`)
return
}
}
try {
const payload = { ...editForm }
payload.project_logs = filteredEditLogs
const response = await apiFetch(`${API_BASE}/attendance.php?id=${editingRecord.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
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í')
}
}
// --- Delete ---
const handleDelete = async () => {
if (!deleteConfirm.record) return
try {
const response = await apiFetch(`${API_BASE}/attendance.php?id=${deleteConfirm.record.id}`, {
method: 'DELETE',
credentials: 'include'
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, record: null })
await fetchData(false)
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
// --- Print ---
const handlePrint = async () => {
try {
let url = `${API_BASE}/attendance.php?action=print&month=${month}`
if (filterUserId) {
url += `&user_id=${filterUserId}`
}
const response = await apiFetch(url)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setPrintData(result.data)
setTimeout(() => {
if (printRef.current) {
const printWindow = window.open('', '_blank')
printWindow.document.write(`
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docházka - ${result.data.month_name}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 11px;
line-height: 1.4;
color: #000;
background: #fff;
padding: 15mm;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #333;
}
.print-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.print-logo {
height: 40px;
width: auto;
}
.print-header-text { text-align: left; }
.print-header-right { text-align: right; }
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
.print-header .company { font-size: 11px; color: #666; }
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.print-header .filters { font-size: 10px; color: #666; }
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
.user-section { margin-bottom: 25px; page-break-inside: avoid; }
.user-header {
background: #f5f5f5;
border: 1px solid #ddd;
padding: 10px 15px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.user-header h3 { font-size: 13px; font-weight: 600; }
.user-header .total { font-size: 12px; font-weight: 600; }
.user-section table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
.user-section th, .user-section td { border: 1px solid #333; padding: 6px 8px; text-align: left; }
.user-section th { background: #333; color: #fff; font-weight: 600; font-size: 10px; text-transform: uppercase; }
.user-section td { font-size: 10px; }
.user-section tr:nth-child(even) { background: #f9f9f9; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.user-section tfoot td { background: #eee; font-weight: 600; }
.leave-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 9px;
font-weight: 500;
}
.badge-vacation { background: #dbeafe; color: #1d4ed8; }
.badge-sick { background: #fee2e2; color: #dc2626; }
.badge-holiday { background: #dcfce7; color: #16a34a; }
.badge-unpaid { background: #f3f4f6; color: #6b7280; }
.badge-overtime { background: #fef3c7; color: #d97706; }
.leave-summary {
margin-top: 10px;
padding: 8px 15px;
background: #f9f9f9;
border: 1px solid #ddd;
font-size: 10px;
}
.print-wrapper-table {
width: 100%;
border-collapse: collapse;
border: none;
}
.print-wrapper-table > thead > tr > td,
.print-wrapper-table > tbody > tr > td {
padding: 0;
border: none;
background: none;
}
@media print {
body { padding: 0; margin: 0; }
@page { size: A4 portrait; margin: 10mm; }
.user-section { page-break-inside: avoid; }
}
</style>
</head>
<body>
${DOMPurify.sanitize(printRef.current.innerHTML)}
</body>
</html>
`)
printWindow.document.close()
printWindow.onload = () => {
printWindow.print()
}
}
}, 100)
}
} catch {
alert.error('Nepodařilo se připravit tisk')
}
}
const hasData = Object.keys(data.user_totals).length > 0
return (
<div>
<motion.div
@@ -761,98 +237,12 @@ export default function AttendanceAdmin() {
))}
</div>
)}
{!loading && data.records.length === 0 && (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
</div>
)}
{!loading && data.records.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Zaměstnanec</th>
<th>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekt</th>
<th>GPS</th>
<th>Poznámka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{data.records.map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const workMinutes = isLeave
? (record.leave_hours || 8) * 60
: calculateWorkMinutes(record)
const hasLocation = (record.arrival_lat && record.arrival_lng) || (record.departure_lat && record.departure_lng)
return (
<tr key={record.id}>
<td className="admin-mono">{formatDate(record.shift_date)}</td>
<td>{record.user_name}</td>
<td>
<span className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.arrival_time)}</td>
<td className="admin-mono">
{isLeave ? '—' : formatBreak(record)}
</td>
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.departure_time)}</td>
<td className="admin-mono">{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : '—'}</td>
<td>
{renderProjectCell(record)}
</td>
<td>
{hasLocation ? (
<Link to={`/attendance/location/${record.id}`} className="attendance-gps-link" title="Zobrazit polohu" aria-label="Zobrazit polohu">
📍
</Link>
) : '—'}
</td>
<td style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={record.notes || ''}>
{record.notes || ''}
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(record)}
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">
<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={() => setDeleteConfirm({ show: true, record })}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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>
{!loading && (
<AttendanceShiftTable
records={data.records}
onEdit={openEditModal}
onDelete={(record) => setDeleteConfirm({ show: true, record })}
/>
)}
</div>
</motion.div>
@@ -906,131 +296,6 @@ export default function AttendanceAdmin() {
confirmText="Smazat"
confirmVariant="danger"
/>
{/* Hidden Print Content */}
{printData && (
<div ref={printRef} style={{ display: 'none' }}>
<table className="print-wrapper-table">
<thead>
<tr><td>
<div className="print-header">
<div className="print-header-left">
<img src="/images/logo-light.png" alt="BOHA" className="print-logo" />
<div className="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1>
<div className="company">BOHA Automation s.r.o.</div>
</div>
</div>
<div className="print-header-right">
<div className="period">{printData.month_name}</div>
{printData.selected_user_name && <div className="filters">Zaměstnanec: {printData.selected_user_name}</div>}
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
</div>
</div>
</td></tr>
</thead>
<tbody>
<tr><td>
{Object.entries(printData.user_totals).map(([userId, userData]) => (
<div key={userId} className="user-section">
<div className="user-header">
<h3>{userData.name}</h3>
<span className="total">Odpracováno: {formatMinutes(userData.minutes)} h</span>
</div>
{printData.leave_balances[userId] && (
<div className="leave-summary">
<strong>Dovolená {printData.year}:</strong> Zbývá {printData.leave_balances[userId].vacation_remaining.toFixed(1)}h z {printData.leave_balances[userId].vacation_total}h
{userData.vacation_hours > 0 && <> | <span className="leave-badge badge-vacation">Tento měsíc: {userData.vacation_hours}h</span></>}
{userData.sick_hours > 0 && <> | <span className="leave-badge badge-sick">Nemoc: {userData.sick_hours}h</span></>}
{userData.holiday_hours > 0 && <> | <span className="leave-badge badge-holiday">Svátek: {userData.holiday_hours}h</span></>}
{userData.overtime > 0 && <> | <span className="leave-badge badge-overtime">Přesčas: +{userData.overtime}h</span></>}
</div>
)}
<table>
<thead>
<tr>
<th style={{ width: '70px' }}>Datum</th>
<th style={{ width: '70px' }}>Typ</th>
<th className="text-center" style={{ width: '70px' }}>Příchod</th>
<th className="text-center" style={{ width: '90px' }}>Pauza</th>
<th className="text-center" style={{ width: '70px' }}>Odchod</th>
<th className="text-center" style={{ width: '80px' }}>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{userData.records.map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const workMinutes = calculateWorkMinutesPrint(record)
const hours = Math.floor(workMinutes / 60)
const mins = workMinutes % 60
return (
<tr key={record.id}>
<td>{formatDate(record.shift_date)}</td>
<td><span className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>{getLeaveTypeName(leaveType)}</span></td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
<td className="text-center">
{isLeave || !record.break_start || !record.break_end
? '—'
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`
}
</td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td>
<td className="text-center">{workMinutes > 0 ? `${hours}:${String(mins).padStart(2, '0')}` : '—'}</td>
<td style={{ fontSize: '8px' }}>
{(record.project_logs && record.project_logs.length > 0)
? record.project_logs.map((log, i) => {
let h, m
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(log.hours) || 0; m = parseInt(log.minutes) || 0
} else if (log.started_at && log.ended_at) {
const mins2 = Math.max(0, Math.floor((new Date(log.ended_at) - new Date(log.started_at)) / 60000))
h = Math.floor(mins2 / 60); m = mins2 % 60
} else { h = 0; m = 0 }
return <div key={log.id || i}>{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h)</div>
})
: record.project_name || '—'}
</td>
<td>{record.notes || ''}</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr>
<td colSpan={6} className="text-right">Odpracováno:</td>
<td className="text-center">{formatMinutes(userData.minutes)} h</td>
<td colSpan={2}></td>
</tr>
{userData.fund !== null && (
<tr>
<td colSpan={6} className="text-right">Fond měsíce:</td>
<td className="text-center">{userData.covered}h / {userData.fund}h</td>
<td colSpan={2}>
{renderFundStatus(userData)}
</td>
</tr>
)}
</tfoot>
</table>
</div>
))}
{Object.keys(printData.user_totals).length === 0 && (
<p style={{ textAlign: 'center', padding: '20px' }}>Za vybrané období nejsou žádné záznamy.</p>
)}
</td></tr>
</tbody>
</table>
</div>
)}
</div>
)
}