feat: implement attendance admin print functionality

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 09:33:43 +01:00
parent ab71de78ce
commit 8c1fd07293

View File

@@ -6,6 +6,12 @@ import {
calculateWorkMinutes,
getDatePart,
getTimePart,
formatDate,
formatMinutes,
getLeaveTypeName,
getLeaveTypeBadgeClass,
formatTimeOrDatetimePrint,
calculateWorkMinutesPrint,
} from '../utils/attendanceHelpers'
import type { ShiftFormData, ProjectLog, Project, User } from '../components/ShiftFormModal'
@@ -200,6 +206,153 @@ function computeUserTotals(
return totals
}
// ---------------------------------------------------------------------------
// Print helpers
// ---------------------------------------------------------------------------
function renderFundStatus(userData: Record<string, any>): string {
if (userData.overtime > 0) return `<span class="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>'
}
function buildProjectLogsHtml(record: Record<string, any>): string {
if (record.project_logs && record.project_logs.length > 0) {
return record.project_logs.map((log: any) => {
let h: number, m: number
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 mins = Math.max(0, Math.floor((new Date(log.ended_at).getTime() - new Date(log.started_at).getTime()) / 60000))
h = Math.floor(mins / 60)
m = mins % 60
} else { h = 0; m = 0 }
return `<div>${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, '0')}h)</div>`
}).join('')
}
return record.project_name || '—'
}
function buildLeaveSummaryHtml(userId: string, userData: Record<string, any>, printData: Record<string, any>): string {
const bal = printData.leave_balances[userId]
let parts = `<strong>Dovolená ${printData.year}:</strong> Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`
if (userData.vacation_hours > 0) parts += ` | <span class="leave-badge badge-vacation">Tento měsíc: ${userData.vacation_hours}h</span>`
if (userData.sick_hours > 0) parts += ` | <span class="leave-badge badge-sick">Nemoc: ${userData.sick_hours}h</span>`
if (userData.holiday_hours > 0) parts += ` | <span class="leave-badge badge-holiday">Svátek: ${userData.holiday_hours}h</span>`
if (userData.overtime > 0) parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${userData.overtime}h</span>`
return `<div class="leave-summary">${parts}</div>`
}
function buildUserSectionHtml(userId: string, userData: Record<string, any>, printData: Record<string, any>): string {
const records = (printData.records || []).filter((r: any) => String(r.user_id) === userId)
let rows = ''
for (const record of records) {
const leaveType = record.leave_type || 'work'
const typeBadge = leaveType === 'work'
? 'Práce'
: `<span class="leave-badge ${getLeaveTypeBadgeClass(leaveType)}">${getLeaveTypeName(leaveType)}</span>`
const workMins = calculateWorkMinutesPrint(record)
const hoursStr = workMins > 0 ? `${Math.floor(workMins / 60)}:${String(workMins % 60).padStart(2, '0')}` : (leaveType !== 'work' ? `${record.leave_hours || 8}h` : '—')
rows += `<tr>
<td>${formatDate(record.shift_date)}</td>
<td>${typeBadge}</td>
<td>${formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
<td>${record.break_start ? formatTimeOrDatetimePrint(record.break_start, record.shift_date) + ' ' + formatTimeOrDatetimePrint(record.break_end, record.shift_date) : '—'}</td>
<td>${formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td>
<td>${hoursStr}</td>
<td>${buildProjectLogsHtml(record)}</td>
<td>${record.notes || ''}</td>
</tr>`
}
const leaveSummary = printData.leave_balances[userId] ? buildLeaveSummaryHtml(userId, userData, printData) : ''
return `<div class="user-section">
<h2>${userData.name}</h2>
${leaveSummary}
<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>${rows}</tbody>
<tfoot>
<tr class="total-row">
<td colspan="5"><strong>Celkem odpracováno</strong></td>
<td><strong>${formatMinutes(userData.minutes)}</strong></td>
<td colspan="2"></td>
</tr>
<tr>
<td colspan="5"><strong>Fond: ${userData.fund ?? '—'}h (${userData.business_days ?? '—'} prac. dnů)</strong></td>
<td colspan="3">${renderFundStatus(userData)}</td>
</tr>
</tfoot>
</table>
</div>`
}
function buildPrintHtml(pData: Record<string, any>, userSections: string, emptyMsg: string, filterNote: string): string {
const now = new Date()
const generated = now.toLocaleDateString('cs-CZ') + ' ' + now.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
return `<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Docházka - ${pData.month_name}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; font-size: 11px; color: #1a1a1a; padding: 10mm; }
.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #1e40af; padding-bottom: 10px; margin-bottom: 15px; }
.header-left { display: flex; align-items: center; gap: 15px; }
.header-left img { height: 40px; }
.header-title { font-size: 16px; font-weight: bold; color: #1e40af; }
.header-company { font-size: 11px; color: #666; }
.header-right { text-align: right; font-size: 10px; color: #666; }
.filters { background: #f0f4ff; padding: 5px 10px; border-radius: 4px; margin-bottom: 10px; font-size: 10px; }
.user-section { margin-bottom: 20px; page-break-inside: avoid; }
.user-section h2 { font-size: 13px; color: #1e40af; border-bottom: 1px solid #ddd; padding-bottom: 3px; margin-bottom: 5px; }
table { width: 100%; border-collapse: collapse; font-size: 10px; margin-bottom: 5px; }
th { background: #f1f5f9; text-align: left; padding: 4px 6px; border: 1px solid #ddd; font-weight: 600; }
td { padding: 3px 6px; border: 1px solid #eee; }
tbody tr:nth-child(even) { background: #fafafa; }
.total-row td { background: #f1f5f9; font-weight: bold; border-top: 2px solid #1e40af; }
.leave-summary { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 4px; padding: 5px 8px; margin-bottom: 5px; font-size: 10px; }
.leave-badge { padding: 1px 6px; border-radius: 3px; font-size: 9px; font-weight: 600; }
.badge-vacation { background: #dbeafe; color: #1e40af; }
.badge-sick { background: #fee2e2; color: #dc2626; }
.badge-holiday { background: #fef3c7; color: #92400e; }
.badge-overtime { background: #d1fae5; color: #065f46; }
.badge-unpaid { background: #f3f4f6; color: #374151; }
@media print {
body { padding: 0; }
.user-section { page-break-inside: avoid; }
}
@page { size: A4 portrait; margin: 10mm; }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<img src="/images/logo-light.png" alt="Logo">
<div>
<div class="header-title">EVIDENCE DOCHÁZKY</div>
<div class="header-company">BOHA Automation s.r.o.</div>
</div>
</div>
<div class="header-right">
<div><strong>Období:</strong> ${pData.month_name}</div>
<div>Vygenerováno: ${generated}</div>
</div>
</div>
${filterNote}
${emptyMsg}
${userSections}
</body>
</html>`
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
@@ -706,11 +859,38 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
}
// =========================================================================
// Print (stub)
// Print
// =========================================================================
const handlePrint = async () => {
// TODO: implement print functionality
alert.success('Funkce tisku bude brzy dostupná')
try {
let url = `${API_BASE}/attendance?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) {
const pData = result.data
const userSections = Object.entries(pData.user_totals)
.map(([uid, uData]) => buildUserSectionHtml(uid, uData as Record<string, any>, pData))
.join('')
const emptyMsg = Object.keys(pData.user_totals).length === 0
? '<p style="text-align:center;padding:20px">Za vybrané období nejsou žádné záznamy.</p>'
: ''
const filterNote = pData.selected_user_name
? `<div class="filters">Zaměstnanec: ${pData.selected_user_name}</div>`
: ''
const bodyContent = buildPrintHtml(pData, userSections, emptyMsg, filterNote)
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.open()
printWindow.document.write(bodyContent)
printWindow.document.close()
printWindow.onload = () => printWindow.print()
}
}
} catch {
alert.error('Nepodařilo se připravit tisk')
}
}
// =========================================================================