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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user