feat: implement attendance admin print functionality
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user