Files
app/src/admin/pages/AttendanceHistory.jsx
Simon 10fbb9ebc7 refactor: CSS utility tridy + slouceni badge souboru
- pridano 20 utility trid (flex-1, mb-2, text-right, fw-500, admin-spinner-sm, atd.)
- nahrazeno ~100 opakovanych inline stylu ve 39 JSX souborech
- slouceno leave.css, orders.css, projects.css do admin.css (status badges)
- bundle size: 228.91 -> 228.43 kB (-0.48 kB)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:27:15 +01:00

528 lines
23 KiB
JavaScript
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, useRef } from 'react'
import DOMPurify from 'dompurify'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import { formatDate, formatDatetime, formatTime, calculateWorkMinutes, formatMinutes, getLeaveTypeName, getLeaveTypeBadgeClass, calculateWorkMinutesPrint, formatTimeOrDatetimePrint } from '../utils/attendanceHelpers'
import FormField from '../components/FormField'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const formatBreakRange = (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 '—'
}
const 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 '—'
}
const renderPrintFundStatus = (fund) => {
if (fund.overtime > 0) {
return <span className="leave-badge badge-overtime">+{fund.overtime}h přesčas</span>
}
if (fund.remaining > 0) {
return <span style={{ color: '#dc2626' }}>{fund.remaining}h</span>
}
return <span style={{ color: '#16a34a' }}>splněno</span>
}
export default function AttendanceHistory() {
const alert = useAlert()
const { user, hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const printRef = useRef(null)
const [month, setMonth] = useState(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
})
const [data, setData] = useState({
records: [],
month_name: '',
year: new Date().getFullYear(),
total_minutes: 0,
vacation_hours: 0,
sick_hours: 0,
holiday_hours: 0,
unpaid_hours: 0,
leave_balance: null,
monthly_fund: null
})
const fetchData = useCallback(async () => {
setLoading(true)
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=history&month=${month}`)
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 {
setLoading(false)
}
}, [month, alert])
useEffect(() => {
fetchData()
}, [fetchData])
if (!hasPermission('attendance.history')) return <Forbidden />
const handlePrint = () => {
if (!printRef.current) return
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 - ${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-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; }
.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; }
.leave-summary {
margin-top: 10px;
padding: 8px 15px;
background: #f9f9f9;
border: 1px solid #ddd;
font-size: 10px;
}
.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; }
@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()
}
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Historie docházky</h1>
<p className="admin-page-subtitle">{data.month_name}</p>
</div>
<div className="admin-page-actions">
{data.records.length > 0 && (
<button
onClick={handlePrint}
className="admin-btn admin-btn-secondary"
title="Tisk docházky"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
Tisk
</button>
)}
</div>
</motion.div>
{/* Filters */}
<motion.div
className="admin-card mb-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-body">
<div className="admin-form-row">
<FormField label="Měsíc">
<AdminDatePicker
mode="month"
value={month}
onChange={(val) => setMonth(val)}
/>
</FormField>
</div>
</div>
</motion.div>
{/* Monthly Fund Card */}
<motion.div
className="admin-card mb-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.15 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: '0.5rem' }}>
<div className="admin-skeleton-row" style={{ gap: '1rem' }}>
<div className="admin-skeleton-line" style={{ width: '48px', height: '48px', borderRadius: '12px', flexShrink: 0 }} />
<div className="flex-1">
<div className="admin-skeleton-line w-1/2" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-full" style={{ height: '6px', borderRadius: '3px' }} />
<div className="admin-skeleton-line w-1/3" style={{ height: '10px', marginTop: '0.5rem' }} />
</div>
</div>
</div>
)}
{!loading && data.monthly_fund && (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<div className="admin-stat-icon info">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div style={{ flex: 1, minWidth: '200px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.375rem' }}>
<span style={{ fontWeight: 600, fontSize: '1rem', color: 'var(--text-primary)' }}>
Fond: {data.monthly_fund.worked}h / {data.monthly_fund.fund}h
</span>
<span className="text-secondary" style={{ fontSize: '0.8125rem' }}>
{data.monthly_fund.business_days} prac. dnů
</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, data.monthly_fund.fund > 0 ? (data.monthly_fund.covered / data.monthly_fund.fund) * 100 : 0)}%`,
background: data.monthly_fund.covered >= data.monthly_fund.fund
? 'linear-gradient(135deg, var(--success), #059669)'
: 'var(--gradient)'
}}
/>
</div>
<div className="text-muted" style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', marginTop: '0.375rem' }}>
<span>
{'Pokryto: '}{data.monthly_fund.covered}h (práce {data.monthly_fund.worked}h
{data.vacation_hours > 0 && ` + dovolená ${data.vacation_hours}h`}
{data.sick_hours > 0 && ` + nemoc ${data.sick_hours}h`}
{data.holiday_hours > 0 && ` + svátek ${data.holiday_hours}h`}
{data.unpaid_hours > 0 && ` + neplacené ${data.unpaid_hours}h`}
)
</span>
{data.monthly_fund.overtime > 0 ? (
<span className="text-warning fw-600">Přesčas: +{data.monthly_fund.overtime}h</span>
) : (
<span>Zbývá: {data.monthly_fund.remaining}h</span>
)}
</div>
</div>
</div>
)}
{!loading && !data.monthly_fund && (
<div className="text-muted" style={{ fontSize: '0.875rem', textAlign: 'center', padding: '0.5rem 0' }}>
Fond měsíce není k dispozici
</div>
)}
</div>
</motion.div>
{/* Records Table */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<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 && 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>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</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)
return (
<tr key={record.id}>
<td className="admin-mono">{formatDate(record.shift_date)}</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 ? '—' : formatBreakRange(record)}
</td>
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.departure_time)}</td>
<td className="admin-mono">{workMinutes > 0 ? formatMinutes(workMinutes, true) : '—'}</td>
<td>
{renderProjectCell(record)}
</td>
<td style={{ maxWidth: '150px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{record.notes || ''}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{/* Hidden Print Content */}
{data.records.length > 0 && (
<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">{data.month_name}</div>
<div className="filters">Zaměstnanec: {user?.fullName || ''}</div>
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
</div>
</div>
</td></tr>
</thead>
<tbody>
<tr><td>
<div className="user-section">
<div className="user-header">
<h3>{user?.fullName || ''}</h3>
<span className="total">Odpracováno: {formatMinutes(data.total_minutes, true)}</span>
</div>
{data.leave_balance && (
<div className="leave-summary">
<strong>Dovolená {data.year}:</strong> Zbývá {data.leave_balance.vacation_remaining.toFixed(1)}h z {data.leave_balance.vacation_total}h
{data.vacation_hours > 0 && <> | <span className="leave-badge badge-vacation">Tento měsíc: {data.vacation_hours}h</span></>}
{data.sick_hours > 0 && <> | <span className="leave-badge badge-sick">Nemoc: {data.sick_hours}h</span></>}
{data.holiday_hours > 0 && <> | <span className="leave-badge badge-holiday">Svátek: {data.holiday_hours}h</span></>}
{data.monthly_fund?.overtime > 0 && <> | <span className="leave-badge badge-overtime">Přesčas: +{data.monthly_fund.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>
{[...data.records].sort((a, b) => a.shift_date.localeCompare(b.shift_date)).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(data.total_minutes, true)}</td>
<td colSpan={2}></td>
</tr>
{data.monthly_fund && (
<tr>
<td colSpan={6} className="text-right">Fond měsíce:</td>
<td className="text-center">{data.monthly_fund.covered}h / {data.monthly_fund.fund}h</td>
<td colSpan={2}>
{renderPrintFundStatus(data.monthly_fund)}
</td>
</tr>
)}
</tfoot>
</table>
</div>
</td></tr>
</tbody>
</table>
</div>
)}
</div>
)
}