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 @@
+
+
+
+