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,
|
calculateWorkMinutes,
|
||||||
getDatePart,
|
getDatePart,
|
||||||
getTimePart,
|
getTimePart,
|
||||||
|
formatDate,
|
||||||
|
formatMinutes,
|
||||||
|
getLeaveTypeName,
|
||||||
|
getLeaveTypeBadgeClass,
|
||||||
|
formatTimeOrDatetimePrint,
|
||||||
|
calculateWorkMinutesPrint,
|
||||||
} from '../utils/attendanceHelpers'
|
} from '../utils/attendanceHelpers'
|
||||||
import type { ShiftFormData, ProjectLog, Project, User } from '../components/ShiftFormModal'
|
import type { ShiftFormData, ProjectLog, Project, User } from '../components/ShiftFormModal'
|
||||||
|
|
||||||
@@ -200,6 +206,153 @@ function computeUserTotals(
|
|||||||
return totals
|
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
|
// Hook
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -706,11 +859,38 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Print (stub)
|
// Print
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
const handlePrint = async () => {
|
const handlePrint = async () => {
|
||||||
// TODO: implement print functionality
|
try {
|
||||||
alert.success('Funkce tisku bude brzy dostupná')
|
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