From c20f6d9dffbdffe42260e23d4b277c9775a2063f Mon Sep 17 00:00:00 2001 From: Felix Schlusche Date: Thu, 23 Oct 2025 17:04:55 +0200 Subject: [PATCH] feat: add employee input fields and PDF export buttons - Added input fields for employee name and ID in settings. - Introduced a button for exporting the current month as a PDF. - Added a button for bulk exporting selected entries as a PDF. - Included jsPDF and jsPDF-AutoTable libraries for PDF generation. --- public/app.js | 936 +++++++++++++++++++++++++++++++++++++++++++++- public/index.html | 26 ++ 2 files changed, 946 insertions(+), 16 deletions(-) diff --git a/public/app.js b/public/app.js index f5bfd36..aeb7db2 100644 --- a/public/app.js +++ b/public/app.js @@ -15,6 +15,9 @@ let timerStartTime = null; let timerPausedDuration = 0; // Total paused time in seconds let isPaused = false; let pauseTimeout = null; +let pauseStartElapsed = 0; // Elapsed time when pause started (to freeze display) +let pauseEndTime = 0; // Timestamp when pause will end (for countdown) +let timerStartTimeString = ''; // Start time as string (HH:MM) for display let currentEntryId = null; // ID of today's entry being timed // Current month display state @@ -373,6 +376,7 @@ async function checkRunningTimer() { const startDate = new Date(); startDate.setHours(hours, minutes, 0, 0); timerStartTime = startDate.getTime(); + timerStartTimeString = todayEntry.startTime; // Update UI const startBtn = document.getElementById('btnStartWork'); @@ -387,12 +391,66 @@ async function checkRunningTimer() { document.getElementById('timerStatus').classList.remove('cursor-pointer', 'underline', 'hover:text-blue-300'); document.getElementById('timerStatus').classList.add('cursor-default'); + // Calculate elapsed time and check for active pauses + const elapsed = Date.now() - timerStartTime; + const elapsedSeconds = Math.floor(elapsed / 1000); + + // Check if we're in a pause + const sixHoursSeconds = 6 * 60 * 60; + const nineHoursSeconds = 9 * 60 * 60; + const thirtyMinutes = 30 * 60; + const fifteenMinutes = 15 * 60; + + // Check if in 6-hour pause (6h to 6h30m real time) + if (elapsedSeconds >= sixHoursSeconds && elapsedSeconds < sixHoursSeconds + thirtyMinutes) { + isPaused = true; + pauseStartElapsed = sixHoursSeconds; + timerPausedDuration = 0; + const remainingPause = (sixHoursSeconds + thirtyMinutes) - elapsedSeconds; + pauseEndTime = Date.now() + (remainingPause * 1000); + document.getElementById('timerStatus').textContent = `Läuft seit ${todayEntry.startTime} - Pause (${Math.ceil(remainingPause / 60)} Min)`; + + // Schedule end of pause + pauseTimeout = setTimeout(() => { + timerPausedDuration = thirtyMinutes; + isPaused = false; + pauseStartElapsed = 0; + pauseEndTime = 0; + document.getElementById('timerStatus').textContent = 'Läuft seit ' + todayEntry.startTime; + }, remainingPause * 1000); + } + // Check if in 9-hour pause (9h30m to 9h45m real time = 9h to 9h work time) + else if (elapsedSeconds >= nineHoursSeconds + thirtyMinutes && elapsedSeconds < nineHoursSeconds + thirtyMinutes + fifteenMinutes) { + isPaused = true; + pauseStartElapsed = nineHoursSeconds; // Work time when pause starts + timerPausedDuration = thirtyMinutes; + const remainingPause = (nineHoursSeconds + thirtyMinutes + fifteenMinutes) - elapsedSeconds; + pauseEndTime = Date.now() + (remainingPause * 1000); + document.getElementById('timerStatus').textContent = `Läuft seit ${todayEntry.startTime} - Pause (${Math.ceil(remainingPause / 60)} Min)`; + + // Schedule end of pause + pauseTimeout = setTimeout(() => { + timerPausedDuration = thirtyMinutes + fifteenMinutes; + isPaused = false; + pauseStartElapsed = 0; + pauseEndTime = 0; + document.getElementById('timerStatus').textContent = 'Läuft seit ' + todayEntry.startTime; + }, remainingPause * 1000); + } + // Not in pause, but may have completed pauses + else if (elapsedSeconds >= sixHoursSeconds + thirtyMinutes && elapsedSeconds < nineHoursSeconds + thirtyMinutes) { + // After first pause, before second pause + timerPausedDuration = thirtyMinutes; + } else if (elapsedSeconds >= nineHoursSeconds + thirtyMinutes + fifteenMinutes) { + // After both pauses + timerPausedDuration = thirtyMinutes + fifteenMinutes; + } + // Start timer interval timerInterval = setInterval(updateTimer, 1000); updateTimer(); // Immediate update // Schedule automatic pauses - const elapsed = Date.now() - timerStartTime; schedulePausesWithOffset(elapsed); } } @@ -416,6 +474,7 @@ async function startWork() { currentEntryId = entry.id; timerStartTime = roundedStart.getTime(); + timerStartTimeString = startTime; timerPausedDuration = 0; isPaused = false; @@ -497,8 +556,55 @@ async function stopWork() { function updateTimer() { if (!timerStartTime) return; - const elapsed = Math.floor((Date.now() - timerStartTime) / 1000) - timerPausedDuration; + const now = Date.now(); + let elapsed; + + // If currently paused, show the frozen time (time when pause started) + if (isPaused) { + elapsed = pauseStartElapsed; + + // Update pause countdown live + const remainingSeconds = Math.max(0, Math.ceil((pauseEndTime - now) / 1000)); + const remainingMinutes = Math.ceil(remainingSeconds / 60); + document.getElementById('timerStatus').textContent = `Läuft seit ${timerStartTimeString} - Pause (${remainingMinutes} Min)`; + } else { + elapsed = Math.floor((now - timerStartTime) / 1000) - timerPausedDuration; + } + document.getElementById('timerDisplay').textContent = formatDuration(elapsed); + + // Update live net hours in the table for current day + const netHoursCell = document.getElementById('current-day-net-hours'); + if (netHoursCell) { + const netHours = elapsed / 3600; // Convert seconds to hours + netHoursCell.textContent = netHours.toFixed(2); + } + + // Update live pause minutes in the table for current day + const pauseCell = document.getElementById('current-day-pause'); + if (pauseCell) { + // Calculate total pause time in minutes + let totalPauseMinutes = Math.floor(timerPausedDuration / 60); + + // If currently in a pause, add the elapsed pause time + if (isPaused) { + const pauseDuration = Math.floor((now - (pauseEndTime - (isPaused ? (pauseEndTime - now) : 0))) / 1000); + // Determine which pause we're in and add its duration to the display + const sixHoursSeconds = 6 * 60 * 60; + const nineHoursSeconds = 9 * 60 * 60; + const elapsedTotal = Math.floor((now - timerStartTime) / 1000); + + if (elapsedTotal >= sixHoursSeconds && elapsedTotal < sixHoursSeconds + 30 * 60) { + // In 6-hour pause (30 minutes) + totalPauseMinutes = 30; + } else if (elapsedTotal >= nineHoursSeconds + 30 * 60 && elapsedTotal < nineHoursSeconds + 30 * 60 + 15 * 60) { + // In 9-hour pause (30 + 15 minutes) + totalPauseMinutes = 30 + 15; + } + } + + pauseCell.textContent = totalPauseMinutes; + } } /** @@ -534,12 +640,16 @@ function schedulePausesWithOffset(elapsedMs) { */ function pauseTimer(durationSeconds) { isPaused = true; - document.getElementById('timerStatus').textContent = `Pause (${Math.floor(durationSeconds / 60)} Min)...`; + pauseEndTime = Date.now() + (durationSeconds * 1000); + + // Store the elapsed time when pause starts (this is what we want to freeze at) + pauseStartElapsed = Math.floor((Date.now() - timerStartTime) / 1000) - timerPausedDuration; pauseTimeout = setTimeout(() => { timerPausedDuration += durationSeconds; isPaused = false; - document.getElementById('timerStatus').textContent = 'Läuft...'; + pauseEndTime = 0; + document.getElementById('timerStatus').textContent = 'Läuft seit ' + timerStartTimeString; }, durationSeconds * 1000); } @@ -935,6 +1045,7 @@ function renderMonthlyView(entries) { // Add inline border style for today if (isToday) { row.style.borderLeft = '4px solid #3b82f6'; // blue-500 + row.id = 'current-day-row'; // Add ID for live updates } // Icon and text based on entry type @@ -972,8 +1083,8 @@ function renderMonthlyView(entries) { ${isTimerRunning ? '' : `data-field="endTime" data-id="${entry.id}" data-value="${entry.endTime}"`}> ${endTimeDisplay} - + ${entry.pauseMinutes} `; } @@ -990,7 +1101,7 @@ function renderMonthlyView(entries) { ${dayOfWeek} ${formatDateDisplay(entry.date)} ${displayTimes} - ${entry.netHours.toFixed(2)} + ${entry.netHours.toFixed(2)} ${displayIcon} ${displayText} @@ -1180,6 +1291,18 @@ async function loadMonthlyView() { renderMonthlyView(entries); updateMonthDisplay(); updateStatistics(entries); + + // Show/hide PDF export button based on whether month is complete + const today = new Date(); + const isCurrentOrFutureMonth = displayYear > today.getFullYear() || + (displayYear === today.getFullYear() && displayMonth >= today.getMonth()); + + const pdfButton = document.getElementById('btnExportPDF'); + if (isCurrentOrFutureMonth) { + pdfButton.style.display = 'none'; + } else { + pdfButton.style.display = ''; + } } /** @@ -1274,11 +1397,20 @@ async function updateStatistics(entries) { .filter(e => new Date(e.date) <= today) .reduce((sum, entry) => sum + entry.netHours, 0); - // Add currently running timer hours to actual hours - if (timerStartTime) { - const now = new Date(); - const elapsedMs = now.getTime() - timerStartTime; - const elapsedHours = elapsedMs / (1000 * 60 * 60); + // Add currently running timer hours to actual hours (only for current month) + const isCurrentMonth = currentYear === today.getFullYear() && currentMonth === today.getMonth(); + if (timerStartTime && isCurrentMonth) { + const now = Date.now(); + let elapsedSeconds; + + // Use same logic as timer display - respect pauses + if (isPaused) { + elapsedSeconds = pauseStartElapsed; + } else { + elapsedSeconds = Math.floor((now - timerStartTime) / 1000) - timerPausedDuration; + } + + const elapsedHours = elapsedSeconds / 3600; actualHours += elapsedHours; } @@ -1291,10 +1423,16 @@ async function updateStatistics(entries) { // Total balance = previous balance + current month balance const totalBalance = previousBalance + balance; + // Count actual work entries (excluding vacation/flextime, only up to today) + const workEntriesCount = entries.filter(e => { + const entryDate = new Date(e.date); + return entryDate <= today && (!e.entryType || e.entryType === 'work'); + }).length; + // Update UI document.getElementById('statTargetHours').textContent = targetHours.toFixed(1) + 'h'; document.getElementById('statActualHours').textContent = actualHours.toFixed(1) + 'h'; - document.getElementById('statWorkdays').textContent = `${entries.length}/${totalWorkdaysInMonth}`; + document.getElementById('statWorkdays').textContent = `${workEntriesCount}/${totalWorkdaysInMonth}`; // Current month balance const balanceElement = document.getElementById('statBalance'); @@ -1911,6 +2049,343 @@ async function bulkDeleteEntries() { toggleBulkEditMode(); // Close bulk edit mode } +/** + * Bulk export selected entries as PDF + */ +async function bulkExportPDF() { + if (selectedEntries.size === 0) { + showNotification('Keine Einträge ausgewählt', 'error'); + return; + } + + try { + const { jsPDF } = window.jspdf; + + // Get all entries and filter by selected IDs + const allEntries = await fetchEntries(); + const selectedEntriesData = allEntries.filter(e => selectedEntries.has(e.id)); + + if (selectedEntriesData.length === 0) { + showNotification('Keine Einträge zum Exportieren gefunden', 'error'); + return; + } + + // Sort by date + selectedEntriesData.sort((a, b) => new Date(a.date) - new Date(b.date)); + + // Get date range (parse dates correctly to avoid timezone issues) + const firstDateParts = selectedEntriesData[0].date.split('-'); + const firstDate = new Date(parseInt(firstDateParts[0]), parseInt(firstDateParts[1]) - 1, parseInt(firstDateParts[2])); + const lastDateParts = selectedEntriesData[selectedEntriesData.length - 1].date.split('-'); + const lastDate = new Date(parseInt(lastDateParts[0]), parseInt(lastDateParts[1]) - 1, parseInt(lastDateParts[2])); + const dateRange = selectedEntriesData[0].date === selectedEntriesData[selectedEntriesData.length - 1].date ? + formatDateDisplay(selectedEntriesData[0].date) : + `${formatDateDisplay(selectedEntriesData[0].date)} - ${formatDateDisplay(selectedEntriesData[selectedEntriesData.length - 1].date)}`; + + // Get employee data from settings + const employeeName = await getSetting('employeeName') || ''; + const employeeId = await getSetting('employeeId') || ''; + + // Calculate statistics based only on selected entries + const today = new Date(); + let workdaysPassed = 0; + + // Get unique dates from selected entries + const selectedDates = new Set(selectedEntriesData.map(e => e.date)); + + // Count vacation days from selected entries + const vacationDaysSet = new Set( + selectedEntriesData + .filter(e => e.entryType === 'vacation') + .map(e => e.date) + ); + + // Count flextime days from selected entries + const flextimeDaysSet = new Set( + selectedEntriesData + .filter(e => e.entryType === 'flextime') + .map(e => e.date) + ); + + // Count workdays based on selected entries only + selectedDates.forEach(dateISO => { + const dateObj = new Date(dateISO); + const isVacation = vacationDaysSet.has(dateISO); + const isFlextime = flextimeDaysSet.has(dateISO); + const isWeekendHoliday = isWeekendOrHoliday(dateObj); + + if (!isWeekendHoliday && !isVacation) { + // Normal workday + if (dateObj <= today) { + workdaysPassed++; + } + } else if (isFlextime && isWeekendHoliday) { + // Flextime on weekend/holiday counts as workday + if (dateObj <= today) { + workdaysPassed++; + } + } + // Vacation days are excluded from workday count + }); + + // Calculate total hours and days + let totalNetHours = 0; + let vacationDays = 0; + let flextimeDays = 0; + let workEntriesCount = 0; + + selectedEntriesData.forEach(entry => { + const entryDate = new Date(entry.date); + if (entryDate <= today) { + if (!entry.entryType || entry.entryType === 'work') { + totalNetHours += entry.netHours; + workEntriesCount++; + } else if (entry.entryType === 'vacation') { + vacationDays++; + totalNetHours += entry.netHours; + } else if (entry.entryType === 'flextime') { + flextimeDays++; + totalNetHours += entry.netHours; + } + } + }); + + const targetHours = workdaysPassed * 8; + const balance = totalNetHours - targetHours; + + // Create PDF + const doc = new jsPDF('p', 'mm', 'a4'); + + // Header with gradient effect + doc.setFillColor(15, 23, 42); + doc.rect(0, 0, 210, 35, 'F'); + + doc.setTextColor(255, 255, 255); + doc.setFontSize(22); + doc.setFont(undefined, 'bold'); + doc.text('Zeiterfassung', 105, 18, { align: 'center' }); + + doc.setFontSize(13); + doc.setFont(undefined, 'normal'); + doc.text(dateRange, 105, 27, { align: 'center' }); + + // Statistics box - centered and styled + let yPos = 43; + doc.setFillColor(30, 41, 59); + doc.roundedRect(20, yPos, 170, 38, 3, 3, 'F'); + + doc.setTextColor(156, 163, 175); + doc.setFontSize(9); + + // Employee info (if available) + if (employeeName || employeeId) { + let employeeInfo = ''; + if (employeeName) employeeInfo += `Mitarbeiter: ${employeeName}`; + if (employeeId) { + if (employeeInfo) employeeInfo += ' | '; + employeeInfo += `Personal-Nr.: ${employeeId}`; + } + doc.text(employeeInfo, 105, yPos + 8, { align: 'center' }); + yPos += 5; + } + + // Statistics - centered layout with three columns + const col1X = 45; + const col2X = 105; + const col3X = 165; + + doc.text('Soll-Stunden', col1X, yPos + 12, { align: 'center' }); + doc.text('Ist-Stunden', col2X, yPos + 12, { align: 'center' }); + doc.text('Saldo', col3X, yPos + 12, { align: 'center' }); + + doc.setTextColor(255, 255, 255); + doc.setFontSize(16); + doc.setFont(undefined, 'bold'); + doc.text(`${targetHours.toFixed(1)}h`, col1X, yPos + 22, { align: 'center' }); + doc.text(`${totalNetHours.toFixed(1)}h`, col2X, yPos + 22, { align: 'center' }); + + if (balance >= 0) { + doc.setTextColor(34, 197, 94); + } else { + doc.setTextColor(239, 68, 68); + } + doc.text(`${balance >= 0 ? '+' : ''}${balance.toFixed(1)}h`, col3X, yPos + 22, { align: 'center' }); + + // Additional info + if (vacationDays > 0 || flextimeDays > 0) { + yPos += 30; + doc.setTextColor(156, 163, 175); + doc.setFontSize(8); + let infoText = ''; + if (vacationDays > 0) infoText += `Urlaubstage: ${vacationDays}`; + if (flextimeDays > 0) { + if (infoText) infoText += ' | '; + infoText += `Gleittage: ${flextimeDays}`; + } + doc.text(infoText, 105, yPos + 8, { align: 'center' }); + yPos += 13; + } else { + yPos += 43; + } + + // Table data - include all days in range (including weekends/holidays) + const allDaysData = []; + const entriesMap = new Map(selectedEntriesData.map(e => [e.date, e])); + + let currentDate = new Date(firstDate); + while (currentDate <= lastDate) { + const yearStr = currentDate.getFullYear(); + const monthStr = String(currentDate.getMonth() + 1).padStart(2, '0'); + const dayStr = String(currentDate.getDate()).padStart(2, '0'); + const dateISO = `${yearStr}-${monthStr}-${dayStr}`; + const formattedDate = formatDateDisplay(dateISO); + const weekday = currentDate.toLocaleDateString('de-DE', { weekday: 'short' }); + + const isWeekendHoliday = isWeekendOrHoliday(currentDate); + const entry = entriesMap.get(dateISO); + + if (entry) { + // Entry exists - use actual data + const deviation = entry.netHours - 8.0; + let deviationStr = Math.abs(deviation) < 0.01 ? '-' : (deviation >= 0 ? '+' : '') + deviation.toFixed(2) + 'h'; + + let locationText = ''; + let startTime = entry.startTime; + let endTime = entry.endTime; + let pauseText = entry.pauseMinutes + ' min'; + let netHoursText = entry.netHours.toFixed(2) + 'h'; + + if (entry.entryType === 'vacation') { + locationText = 'Urlaub'; + startTime = '-'; + endTime = '-'; + pauseText = '-'; + netHoursText = '-'; + deviationStr = '-'; + } else if (entry.entryType === 'flextime') { + locationText = 'Gleittag'; + startTime = '-'; + endTime = '-'; + pauseText = '-'; + } else { + locationText = entry.location === 'home' ? 'Home' : 'Büro'; + } + + allDaysData.push([ + formattedDate, + weekday, + startTime, + endTime, + pauseText, + locationText, + netHoursText, + deviationStr + ]); + } else if (isWeekendHoliday) { + // Weekend or holiday without entry + const holidayName = getHolidayName(currentDate); + const dayOfWeek = currentDate.getDay(); + const isWeekendDay = dayOfWeek === 0 || dayOfWeek === 6; + + let dayType = ''; + if (holidayName) { + dayType = 'Feiertag'; + } else if (isWeekendDay) { + dayType = 'Wochenende'; + } + + allDaysData.push([ + formattedDate, + weekday, + '-', + '-', + '-', + dayType, + '-', + '-' + ]); + } + // Skip regular workdays without entries + + currentDate.setDate(currentDate.getDate() + 1); + } + + const tableData = allDaysData; + + doc.autoTable({ + startY: yPos, + head: [['Datum', 'Tag', 'Beginn', 'Ende', 'Pause', 'Ort', 'Netto', 'Abw.']], + body: tableData, + theme: 'grid', + headStyles: { + fillColor: [30, 41, 59], + textColor: [255, 255, 255], + fontSize: 9, + fontStyle: 'bold', + halign: 'center', + cellPadding: 3 + }, + bodyStyles: { + fillColor: [248, 250, 252], + textColor: [15, 23, 42], + fontSize: 8, + cellPadding: 2.5 + }, + alternateRowStyles: { + fillColor: [241, 245, 249] + }, + columnStyles: { + 0: { halign: 'center', cellWidth: 24 }, // Datum + 1: { halign: 'center', cellWidth: 14 }, // Wochentag + 2: { halign: 'center', cellWidth: 18 }, // Beginn + 3: { halign: 'center', cellWidth: 18 }, // Ende + 4: { halign: 'center', cellWidth: 18 }, // Pause + 5: { halign: 'center', cellWidth: 28 }, // Ort + 6: { halign: 'center', cellWidth: 18 }, // Netto + 7: { halign: 'center', cellWidth: 18 } // Abweichung + }, + didParseCell: function(data) { + if (data.column.index === 7 && data.section === 'body') { + const value = data.cell.raw; + if (value.startsWith('+')) { + data.cell.styles.textColor = [34, 197, 94]; + data.cell.styles.fontStyle = 'bold'; + } else if (value.startsWith('-') && value !== '-') { + data.cell.styles.textColor = [239, 68, 68]; + data.cell.styles.fontStyle = 'bold'; + } + } + }, + // Calculate margins to center the table + // Total column width: 156mm, page width: 210mm, so we need (210-156)/2 = 27mm margins + margin: { left: 27, right: 27 } + }); + + // Footer + const finalY = doc.lastAutoTable.finalY || yPos + 50; + if (finalY < 270) { + doc.setTextColor(156, 163, 175); + doc.setFontSize(8); + doc.text(`Erstellt am: ${new Date().toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + })}`, 105, 285, { align: 'center' }); + } + + // Save PDF + const fileName = `Zeiterfassung_${selectedEntriesData[0].date}_${selectedEntriesData[selectedEntriesData.length - 1].date}.pdf`; + doc.save(fileName); + + showNotification(`PDF mit ${allDaysData.length} Tag(en) erstellt`, 'success'); + } catch (error) { + console.error('Error exporting PDF:', error); + showNotification('Fehler beim PDF-Export', 'error'); + } +} + /** * Bulk set vacation entries */ @@ -2190,6 +2665,16 @@ async function loadSettings() { totalVacationDays = parseInt(savedVacationDays); document.getElementById('vacationDaysInput').value = totalVacationDays; } + + const savedEmployeeName = await getSetting('employeeName'); + if (savedEmployeeName) { + document.getElementById('employeeName').value = savedEmployeeName; + } + + const savedEmployeeId = await getSetting('employeeId'); + if (savedEmployeeId) { + document.getElementById('employeeId').value = savedEmployeeId; + } } /** @@ -2454,6 +2939,355 @@ async function handleExport(onlyDeviations = false) { } } +/** + * Export current month as PDF + */ +async function handleExportPDF() { + try { + const { jsPDF } = window.jspdf; + + // Get current month data + const year = displayYear; + const month = displayMonth + 1; // displayMonth is 0-indexed, we need 1-12 + const monthName = new Date(year, displayMonth).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); + + // Get first and last day of month in YYYY-MM-DD format + const currentYear = year; + const currentMonth = displayMonth; // Keep 0-indexed for Date constructor + const lastDay = new Date(currentYear, currentMonth + 1, 0).getDate(); + const fromDate = `${year}-${String(month).padStart(2, '0')}-01`; + const toDate = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + + // Fetch entries + const entries = await fetchEntries(fromDate, toDate); + + if (entries.length === 0) { + showNotification('Keine Einträge im Monat vorhanden', 'error'); + return; + } + + // Calculate statistics + const today = new Date(); + + // Count workdays (excluding weekends and holidays, only up to today) + let workdaysPassed = 0; + let totalWorkdaysInMonth = 0; + + // Count vacation days to exclude from workdays + const vacationDaysSet = new Set( + entries + .filter(e => e.entryType === 'vacation') + .map(e => e.date) + ); + + // Count flextime days (they are workdays with 0 hours worked) + const flextimeDaysSet = new Set( + entries + .filter(e => e.entryType === 'flextime') + .map(e => e.date) + ); + + for (let day = 1; day <= lastDay; day++) { + const dateObj = new Date(currentYear, currentMonth, day); + const yearStr = dateObj.getFullYear(); + const monthStr = String(dateObj.getMonth() + 1).padStart(2, '0'); + const dayStr = String(dateObj.getDate()).padStart(2, '0'); + const dateISO = `${yearStr}-${monthStr}-${dayStr}`; + + const isVacation = vacationDaysSet.has(dateISO); + const isFlextime = flextimeDaysSet.has(dateISO); + const isWeekendHoliday = isWeekendOrHoliday(dateObj); + + if (!isWeekendHoliday && !isVacation) { + // Normal workday (excluding vacation days) + totalWorkdaysInMonth++; + if (dateObj <= today) { + workdaysPassed++; + } + } else if (isFlextime && isWeekendHoliday) { + // Flextime on weekend/holiday counts as additional workday + totalWorkdaysInMonth++; + if (new Date(dateISO) <= today) { + workdaysPassed++; + } + } + // Vacation days are excluded from all counts + } + + let totalNetHours = 0; + let vacationDays = 0; + let flextimeDays = 0; + let workEntriesCount = 0; + + entries.forEach(entry => { + const entryDate = new Date(entry.date); + if (entryDate <= today) { + if (!entry.entryType || entry.entryType === 'work') { + totalNetHours += entry.netHours; + workEntriesCount++; + } else if (entry.entryType === 'vacation') { + vacationDays++; + totalNetHours += entry.netHours; + } else if (entry.entryType === 'flextime') { + flextimeDays++; + totalNetHours += entry.netHours; + } + } + }); + + // Add running timer if active (only for current month) + const isCurrentMonth = currentYear === today.getFullYear() && currentMonth === today.getMonth(); + let runningTimerHours = 0; + if (timerStartTime && isCurrentMonth) { + const now = Date.now(); + const elapsed = now - timerStartTime; + const hours = elapsed / (1000 * 60 * 60); + const netHours = Math.max(0, hours - 0.5); // Subtract 30 min pause + runningTimerHours = netHours; + totalNetHours += netHours; + } + + const targetHours = workdaysPassed * 8; + const monthBalance = totalNetHours - targetHours; + + // Get previous balance + const previousBalance = await calculatePreviousBalance(year, month); + const totalBalance = monthBalance + previousBalance; + + // Create PDF + const doc = new jsPDF('p', 'mm', 'a4'); + + // Get employee data from settings + const employeeName = await getSetting('employeeName') || ''; + const employeeId = await getSetting('employeeId') || ''; + + // Header with gradient effect + doc.setFillColor(15, 23, 42); + doc.rect(0, 0, 210, 35, 'F'); + + doc.setTextColor(255, 255, 255); + doc.setFontSize(22); + doc.setFont(undefined, 'bold'); + doc.text('Zeiterfassung', 105, 18, { align: 'center' }); + + doc.setFontSize(13); + doc.setFont(undefined, 'normal'); + doc.text(monthName, 105, 27, { align: 'center' }); + + // Statistics box - centered and styled + let yPos = 43; + doc.setFillColor(30, 41, 59); + doc.roundedRect(20, yPos, 170, 38, 3, 3, 'F'); + + doc.setTextColor(156, 163, 175); + doc.setFontSize(9); + + // Employee info (if available) + if (employeeName || employeeId) { + let employeeInfo = ''; + if (employeeName) employeeInfo += `Mitarbeiter: ${employeeName}`; + if (employeeId) { + if (employeeInfo) employeeInfo += ' | '; + employeeInfo += `Personal-Nr.: ${employeeId}`; + } + doc.text(employeeInfo, 105, yPos + 8, { align: 'center' }); + yPos += 5; + } + + // Statistics - centered layout with three columns + const col1X = 45; + const col2X = 105; + const col3X = 165; + + doc.text('Soll-Stunden', col1X, yPos + 12, { align: 'center' }); + doc.text('Ist-Stunden', col2X, yPos + 12, { align: 'center' }); + doc.text('Saldo', col3X, yPos + 12, { align: 'center' }); + + doc.setTextColor(255, 255, 255); + doc.setFontSize(16); + doc.setFont(undefined, 'bold'); + doc.text(`${targetHours.toFixed(1)}h`, col1X, yPos + 22, { align: 'center' }); + doc.text(`${totalNetHours.toFixed(1)}h`, col2X, yPos + 22, { align: 'center' }); + + if (monthBalance >= 0) { + doc.setTextColor(34, 197, 94); + } else { + doc.setTextColor(239, 68, 68); + } + doc.text(`${monthBalance >= 0 ? '+' : ''}${monthBalance.toFixed(1)}h`, col3X, yPos + 22, { align: 'center' }); + + // Additional info if vacation or flextime days exist + if (vacationDays > 0 || flextimeDays > 0) { + yPos += 30; + doc.setTextColor(156, 163, 175); + doc.setFontSize(8); + let infoText = ''; + if (vacationDays > 0) infoText += `Urlaubstage: ${vacationDays}`; + if (flextimeDays > 0) { + if (infoText) infoText += ' | '; + infoText += `Gleittage: ${flextimeDays}`; + } + doc.text(infoText, 105, yPos + 8, { align: 'center' }); + yPos += 13; + } else { + yPos += 43; + } + + // Table with entries + // Create a complete list of all days in the month including weekends/holidays + const allDaysData = []; + const entriesMap = new Map(entries.map(e => [e.date, e])); + + for (let day = 1; day <= lastDay; day++) { + const dateObj = new Date(currentYear, currentMonth, day); + const yearStr = dateObj.getFullYear(); + const monthStr = String(dateObj.getMonth() + 1).padStart(2, '0'); + const dayStr = String(dateObj.getDate()).padStart(2, '0'); + const dateISO = `${yearStr}-${monthStr}-${dayStr}`; + const formattedDate = formatDateDisplay(dateISO); + const weekday = dateObj.toLocaleDateString('de-DE', { weekday: 'short' }); + + const isWeekendHoliday = isWeekendOrHoliday(dateObj); + const entry = entriesMap.get(dateISO); + + if (entry) { + // Entry exists - use actual data + const deviation = entry.netHours - 8.0; + let deviationStr = Math.abs(deviation) < 0.01 ? '-' : (deviation >= 0 ? '+' : '') + deviation.toFixed(2) + 'h'; + + let locationText = ''; + let startTime = entry.startTime; + let endTime = entry.endTime; + let pauseText = entry.pauseMinutes + ' min'; + let netHoursText = entry.netHours.toFixed(2) + 'h'; + + if (entry.entryType === 'vacation') { + locationText = 'Urlaub'; + startTime = '-'; + endTime = '-'; + pauseText = '-'; + netHoursText = '-'; + deviationStr = '-'; + } else if (entry.entryType === 'flextime') { + locationText = 'Gleittag'; + startTime = '-'; + endTime = '-'; + pauseText = '-'; + } else { + locationText = entry.location === 'home' ? 'Home' : 'Büro'; + } + + allDaysData.push([ + formattedDate, + weekday, + startTime, + endTime, + pauseText, + locationText, + netHoursText, + deviationStr + ]); + } else if (isWeekendHoliday) { + // Weekend or holiday without entry + const isWeekendDay = isWeekend(dateObj); + const isHoliday = isPublicHoliday(dateObj); + + let dayType = ''; + if (isWeekendDay) { + dayType = 'Wochenende'; + } else if (isHoliday) { + dayType = 'Feiertag'; + } + + allDaysData.push([ + formattedDate, + weekday, + '-', + '-', + '-', + dayType, + '-', + '-' + ]); + } + // Skip regular workdays without entries (not shown in PDF) + } + + const tableData = allDaysData; + + doc.autoTable({ + startY: yPos, + head: [['Datum', 'Tag', 'Beginn', 'Ende', 'Pause', 'Ort', 'Netto', 'Abw.']], + body: tableData, + theme: 'grid', + headStyles: { + fillColor: [30, 41, 59], + textColor: [255, 255, 255], + fontSize: 9, + fontStyle: 'bold', + halign: 'center', + cellPadding: 3 + }, + bodyStyles: { + fillColor: [248, 250, 252], + textColor: [15, 23, 42], + fontSize: 8, + cellPadding: 2.5 + }, + alternateRowStyles: { + fillColor: [241, 245, 249] + }, + columnStyles: { + 0: { halign: 'center', cellWidth: 24 }, // Datum + 1: { halign: 'center', cellWidth: 14 }, // Wochentag + 2: { halign: 'center', cellWidth: 18 }, // Beginn + 3: { halign: 'center', cellWidth: 18 }, // Ende + 4: { halign: 'center', cellWidth: 18 }, // Pause + 5: { halign: 'center', cellWidth: 28 }, // Ort + 6: { halign: 'center', cellWidth: 18 }, // Netto + 7: { halign: 'center', cellWidth: 18 } // Abweichung + }, + didParseCell: function(data) { + // Color code deviations in the last column + if (data.column.index === 7 && data.section === 'body') { + const value = data.cell.raw; + if (value.startsWith('+')) { + data.cell.styles.textColor = [34, 197, 94]; // green + data.cell.styles.fontStyle = 'bold'; + } else if (value.startsWith('-') && value !== '-') { + data.cell.styles.textColor = [239, 68, 68]; // red + data.cell.styles.fontStyle = 'bold'; + } + } + }, + margin: { left: 15, right: 15 } + }); + + // Footer with generation date + const finalY = doc.lastAutoTable.finalY || yPos + 50; + if (finalY < 270) { + doc.setTextColor(156, 163, 175); + doc.setFontSize(8); + doc.text(`Erstellt am: ${new Date().toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + })}`, 105, 285, { align: 'center' }); + } + + // Save PDF + const fileName = `Zeiterfassung_${monthName.replace(' ', '_')}.pdf`; + doc.save(fileName); + + showNotification('PDF erfolgreich erstellt', 'success'); + } catch (error) { + console.error('Error exporting PDF:', error); + showNotification('Fehler beim PDF-Export', 'error'); + } +} + /** * Show a temporary notification */ @@ -2576,6 +3410,7 @@ async function handleManualStartTime(timeStr) { currentEntryId = entry.id; timerStartTime = startDate.getTime(); + timerStartTimeString = timeStr; timerPausedDuration = 0; isPaused = false; @@ -2592,14 +3427,67 @@ async function handleManualStartTime(timeStr) { document.getElementById('timerStatus').classList.remove('cursor-pointer', 'underline', 'hover:text-blue-300'); document.getElementById('timerStatus').classList.add('cursor-default'); + // Calculate elapsed time and check for active pauses + const elapsed = now.getTime() - startDate.getTime(); + const elapsedSeconds = Math.floor(elapsed / 1000); + + // Check if we're in a pause + const sixHoursSeconds = 6 * 60 * 60; + const nineHoursSeconds = 9 * 60 * 60; + const thirtyMinutes = 30 * 60; + const fifteenMinutes = 15 * 60; + + // Check if in 6-hour pause (6h to 6h30m real time) + if (elapsedSeconds >= sixHoursSeconds && elapsedSeconds < sixHoursSeconds + thirtyMinutes) { + isPaused = true; + pauseStartElapsed = sixHoursSeconds; + timerPausedDuration = 0; + const remainingPause = (sixHoursSeconds + thirtyMinutes) - elapsedSeconds; + pauseEndTime = Date.now() + (remainingPause * 1000); + document.getElementById('timerStatus').textContent = `Läuft seit ${timeStr} - Pause (${Math.ceil(remainingPause / 60)} Min)`; + + // Schedule end of pause + pauseTimeout = setTimeout(() => { + timerPausedDuration = thirtyMinutes; + isPaused = false; + pauseStartElapsed = 0; + pauseEndTime = 0; + document.getElementById('timerStatus').textContent = 'Läuft seit ' + timeStr; + }, remainingPause * 1000); + } + // Check if in 9-hour pause (9h30m to 9h45m real time = 9h to 9h work time) + else if (elapsedSeconds >= nineHoursSeconds + thirtyMinutes && elapsedSeconds < nineHoursSeconds + thirtyMinutes + fifteenMinutes) { + isPaused = true; + pauseStartElapsed = nineHoursSeconds; // Work time when pause starts + timerPausedDuration = thirtyMinutes; + const remainingPause = (nineHoursSeconds + thirtyMinutes + fifteenMinutes) - elapsedSeconds; + pauseEndTime = Date.now() + (remainingPause * 1000); + document.getElementById('timerStatus').textContent = `Läuft seit ${timeStr} - Pause (${Math.ceil(remainingPause / 60)} Min)`; + + // Schedule end of pause + pauseTimeout = setTimeout(() => { + timerPausedDuration = thirtyMinutes + fifteenMinutes; + isPaused = false; + pauseStartElapsed = 0; + pauseEndTime = 0; + document.getElementById('timerStatus').textContent = 'Läuft seit ' + timeStr; + }, remainingPause * 1000); + } + // Not in pause, but may have completed pauses + else if (elapsedSeconds >= sixHoursSeconds + thirtyMinutes && elapsedSeconds < nineHoursSeconds + thirtyMinutes) { + // After first pause, before second pause + timerPausedDuration = thirtyMinutes; + } else if (elapsedSeconds >= nineHoursSeconds + thirtyMinutes + fifteenMinutes) { + // After both pauses + timerPausedDuration = thirtyMinutes + fifteenMinutes; + } + // Start timer interval timerInterval = setInterval(updateTimer, 1000); updateTimer(); // Immediate update // Calculate offset for pause scheduling - const timeSinceStart = now.getTime() - startDate.getTime(); - const offsetMinutes = Math.floor(timeSinceStart / 60000); - schedulePausesWithOffset(offsetMinutes); + schedulePausesWithOffset(elapsed); // Reload view to show entry await loadMonthlyView(); @@ -2615,6 +3503,9 @@ function initializeEventListeners() { // Auto-fill month button document.getElementById('btnAutoFill').addEventListener('click', handleAutoFillMonth); + // PDF Export button + document.getElementById('btnExportPDF').addEventListener('click', handleExportPDF); + // Bulk edit toggle document.getElementById('btnToggleBulkEdit').addEventListener('click', toggleBulkEditMode); @@ -2629,6 +3520,7 @@ function initializeEventListeners() { document.getElementById('btnBulkSetVacation').addEventListener('click', bulkSetVacation); document.getElementById('btnBulkSetFlextime').addEventListener('click', bulkSetFlextime); document.getElementById('btnBulkDelete').addEventListener('click', bulkDeleteEntries); + document.getElementById('btnBulkExportPDF').addEventListener('click', bulkExportPDF); // Cancel modal button document.getElementById('btnCancelModal').addEventListener('click', closeModal); @@ -2704,6 +3596,18 @@ function initializeEventListeners() { // Vacation days input document.getElementById('vacationDaysInput').addEventListener('change', handleVacationDaysChange); + // Employee name input + document.getElementById('employeeName').addEventListener('change', async (e) => { + await setSetting('employeeName', e.target.value); + showNotification('Mitarbeitername gespeichert', 'success'); + }); + + // Employee ID input + document.getElementById('employeeId').addEventListener('change', async (e) => { + await setSetting('employeeId', e.target.value); + showNotification('Personalnummer gespeichert', 'success'); + }); + // Close modal when clicking outside document.getElementById('entryModal').addEventListener('click', (e) => { if (e.target.id === 'entryModal') { diff --git a/public/index.html b/public/index.html index 1ef03d1..280c5ba 100644 --- a/public/index.html +++ b/public/index.html @@ -447,6 +447,18 @@ ⚙️ Einstellungen +
+
+ + +
+
+ + +
+
@@ -560,6 +572,11 @@ Ausfüllen +
@@ -623,6 +640,11 @@ Löschen +
@@ -763,6 +785,10 @@ + + + +